import { useCallback, useMemo } from 'react'; import { PieChart, Pie, Cell, Curve, Tooltip, Sector, PieProps, PieLabelRenderProps, ResponsiveContainer, } from 'recharts'; import type CSS from 'csstype'; import { TOOLTIP_STYLE, TOOLTIP_OTHER_PROPS, LABEL_STYLE, COUNT_STYLE, CHART_MISSING_FILL, RADIAN, LABEL_THRESHOLD, COUNT_TEXT_STYLE, TEXT_STYLE, OTHER_KEY, } from '../../constants/chartConstants'; import type { PieChartProps, TooltipPayload } from '../../types/chartTypes'; import { useChartTheme, useChartTranslation, useChartThreshold, useChartMaxLabelChars, } from '../../ChartConfigProvider'; import { polarToCartesian, useTransformedChartData } from '../../util/chartUtils'; import NoData from '../NoData'; import ChartWrapper from './ChartWrapper'; const labelShortName = (name: string, maxChars: number) => { if (name.length <= maxChars) { return name; } // removing 3 character cause ... s add three characters return `${name.substring(0, maxChars - 3)}\u2026`; }; const _entryFill = (entry: { name: string }, index: number, theme: string[]) => entry.name.toLowerCase() === 'missing' ? CHART_MISSING_FILL : theme[index % theme.length]; // Prevents the last segment from having the same fill as the first segment (unless "missing") to ensure visual distinction. const getPieSegmentFill = (entry: { name: string }, index: number, data: Array<{ name: string }>, theme: string[]) => { let fill = _entryFill(entry, index, theme); if (index === data.length - 1 && entry.name.toLowerCase() !== 'missing') { const firstEntry = data[0]; const firstFill = _entryFill(firstEntry, 0, theme); if (fill === firstFill) { fill = theme[(index + 1) % theme.length]; } } return fill; }; const BentoPie = ({ height, width, onClick, sort = true, colorTheme = 'default', chartThreshold, maxLabelChars, ...params }: PieChartProps) => { const t = useChartTranslation(); const { fill: theme } = useChartTheme().pie[colorTheme]; const defaultChartThreshold = useChartThreshold(); const defaultMaxLabelChars = useChartMaxLabelChars(); const resolvedChartThreshold = chartThreshold ?? defaultChartThreshold; const resolvedMaxLabelChars = maxLabelChars ?? defaultMaxLabelChars; // ##################### Data processing ##################### const transformedData = useTransformedChartData(params, true, sort); const { data, sum } = useMemo(() => { let data = [...transformedData]; // combining sections with less than chartThreshold const sum = data.reduce((acc, e) => acc + e.y, 0); const length = data.length; const threshold = resolvedChartThreshold * sum; const dataAboveThreshold = data.filter((e) => e.y > threshold); // length - 1 intentional: if there is just one category below threshold, the "Other" category is not necessary. data = dataAboveThreshold.length === length - 1 ? data : dataAboveThreshold; if (data.length !== length) { data.push({ x: t[OTHER_KEY], y: sum - data.reduce((acc, e) => acc + e.y, 0), id: OTHER_KEY, }); } return { data: data.map((e) => ({ name: e.x, value: e.y, ...e })), sum, }; }, [t, transformedData, resolvedChartThreshold]); // ##################### Rendering ##################### const onHover: PieProps['onMouseOver'] = useCallback( (data, _index, e) => { const { target } = e; if (onClick && target && data.name !== t[OTHER_KEY]) (target as SVGElement).style.cursor = 'pointer'; }, [t, onClick] ); if (data.length === 0) { return ; } return ( {data.map((entry, index) => ( ))} } isAnimationActive={false} /> ); }; const toNumber = (val: number | string | undefined, defaultValue?: number): number => { if (val && typeof val === 'string') { return Number(val); } else if (val && typeof val === 'number') { return val; } return defaultValue || 0; }; const renderLabel = (resolvedMaxLabelChars: number): PieProps['label'] => { const BentoPieLabel = (params: PieLabelRenderProps) => { const { fill, payload } = params; const percent: number = params.percent || 0; const midAngle: number = params.midAngle || 0; // skip rendering if segment is too small a percentage (avoids label clutter) if (percent < LABEL_THRESHOLD) { return; } const outerRadius = toNumber(params.outerRadius); const cx = toNumber(params.cx); const cy = toNumber(params.cy); const name = payload.name === 'null' ? '(Empty)' : payload.name; const sin = Math.sin(-RADIAN * midAngle); const cos = Math.cos(-RADIAN * midAngle); const sx = cx + (outerRadius + 10) * cos; const sy = cy + (outerRadius + 10) * sin; const mx = cx + (outerRadius + 20) * cos; const my = cy + (outerRadius + 20) * sin; const ex = mx + (cos >= 0 ? 1 : -1) * 22; const ey = my; const textAnchor = cos >= 0 ? 'start' : 'end'; const currentTextStyle: CSS.Properties = { ...TEXT_STYLE, fontWeight: payload.selected ? 'bold' : 'normal', fontStyle: payload.name === 'null' ? 'italic' : 'normal', }; const offsetRadius = 20; const startPoint = polarToCartesian(cx, cy, outerRadius, midAngle); const endPoint = polarToCartesian(cx, cy, outerRadius + offsetRadius, midAngle); const lineProps = { ...params, fill: 'none', stroke: fill, points: [startPoint, endPoint], }; return ( = 0 ? 1 : -1) * 12} y={ey + 3} textAnchor={textAnchor} style={currentTextStyle}> {labelShortName(name, resolvedMaxLabelChars)} = 0 ? 1 : -1) * 12} y={ey} dy={14} textAnchor={textAnchor} style={COUNT_TEXT_STYLE}> {`(${payload.value})`} ); }; BentoPieLabel.displayName = BentoPieLabel; return BentoPieLabel; }; const PieChartShape: PieProps['shape'] = (params) => { const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, isActive } = params; return ( {isActive ? ( // render arc around active segment ) : null} ); }; const CustomTooltip = ({ active, payload, totalCount, }: { active?: boolean; payload?: TooltipPayload; totalCount: number; }) => { if (!active) { return null; } const name = payload ? payload[0].name : ''; const value = payload ? payload[0].value : 0; const percentage = totalCount ? Math.round((value / totalCount) * 100) : 0; return name !== 'other' ? (

{name}

{' '} {value} ({percentage} %)

) : (
No data
); }; export default BentoPie;