whisper-money/resources/js/components/charts/multi-level-donut.tsx

445 lines
14 KiB
TypeScript

import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { usePrivacyMode } from '@/contexts/privacy-mode-context';
import { SankeyData } from '@/hooks/use-cashflow-data';
import { useLocale } from '@/hooks/use-locale';
import { buildDonutRings, DonutRing, DonutSegment } from '@/lib/donut-utils';
import { cn } from '@/lib/utils';
import { getCategoryChartColor } from '@/types/category';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { format } from 'date-fns';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
interface MultiLevelDonutProps {
data: SankeyData;
showIncome: boolean;
height?: number;
className?: string;
currency?: string;
period?: { from: Date; to: Date };
}
const RAD = Math.PI / 180;
// Radii as a fraction of the chart radius (half the smaller dimension).
const HOLE_FRACTION = 0.3;
const RIM_FRACTION = 0.6;
const RING_GAP = 2;
// Minimum arc (degrees) a slice must span to earn a label.
const MIN_INSIDE_DEG = 18;
const MIN_OUTER_DEG = 4;
// Outer leader-line label layout.
const LEADER_ELBOW = 12;
const LEADER_RUN = 12;
const LABEL_GAP = 14;
const MAX_LABEL_CHARS = 18;
// Below this width the leader labels don't fit; the outer ring is read on tap.
const LEADER_MIN_WIDTH = 480;
function segmentColor(segment: DonutSegment): string {
return getCategoryChartColor(segment.color ?? 'gray');
}
function truncate(name: string): string {
return name.length > MAX_LABEL_CHARS
? `${name.slice(0, MAX_LABEL_CHARS - 1)}`
: name;
}
interface OuterLabel {
key: string;
side: 1 | -1;
edgeX: number;
edgeY: number;
y: number;
name: string;
percentage: number;
}
/**
* Position the outermost ring's labels outside the donut with a leader line,
* pushing them apart vertically per side so thin slices stay readable without
* overlapping (Highcharts pie-donut style).
*/
function layoutOuterLabels(
ring: DonutRing,
cx: number,
cy: number,
radius: number,
height: number,
): OuterLabel[] {
if (ring.total <= 0) {
return [];
}
let cursor = 0;
const labels: OuterLabel[] = [];
for (const segment of ring.segments) {
const midFraction = (cursor + segment.value / 2) / ring.total;
cursor += segment.value;
if ((segment.value / ring.total) * 360 < MIN_OUTER_DEG) {
continue;
}
const angle = -(90 - midFraction * 360) * RAD;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
labels.push({
key: segment.key,
side: cos >= 0 ? 1 : -1,
edgeX: cx + radius * cos,
edgeY: cy + radius * sin,
y: cy + (radius + LEADER_ELBOW) * sin,
name: truncate(segment.name),
percentage: (segment.value / ring.total) * 100,
});
}
for (const side of [1, -1] as const) {
const column = labels
.filter((label) => label.side === side)
.sort((a, b) => a.y - b.y);
let previous = -Infinity;
for (const label of column) {
label.y = Math.max(label.y, previous + LABEL_GAP);
previous = label.y;
}
const bottom = height - 6;
if (column.length > 0 && column[column.length - 1].y > bottom) {
let next = bottom;
for (let i = column.length - 1; i >= 0; i--) {
column[i].y = Math.min(column[i].y, next);
next = column[i].y - LABEL_GAP;
}
}
}
return labels;
}
interface PieLabelProps {
cx?: number;
cy?: number;
midAngle?: number;
innerRadius?: number;
outerRadius?: number;
percent?: number;
name?: string | number;
}
/** Inside label for the innermost ring's wide-enough slices. */
function InsideLabel({
cx = 0,
cy = 0,
midAngle = 0,
innerRadius = 0,
outerRadius = 0,
percent = 0,
name = '',
}: PieLabelProps) {
if (percent * 360 < MIN_INSIDE_DEG) {
return null;
}
const r = (innerRadius + outerRadius) / 2;
const angle = -midAngle * RAD;
return (
<text
x={cx + r * Math.cos(angle)}
y={cy + r * Math.sin(angle)}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={600}
fill="#fff"
style={{
paintOrder: 'stroke',
stroke: 'rgba(0,0,0,0.45)',
strokeWidth: 2.5,
}}
>
{truncate(String(name))}
</text>
);
}
interface DonutTooltipProps {
active?: boolean;
payload?: { payload?: DonutSegment }[];
totalIncome: number;
totalExpense: number;
mask: (value: number) => string;
}
function DonutTooltip({
active,
payload,
totalIncome,
totalExpense,
mask,
}: DonutTooltipProps) {
const segment = active ? payload?.[0]?.payload : undefined;
if (!segment) {
return null;
}
const directionTotal =
segment.direction === 'income' ? totalIncome : totalExpense;
const percentage =
directionTotal > 0 ? (segment.value / directionTotal) * 100 : 0;
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="flex items-center gap-2 font-medium">
<span
className="size-2 rounded-full"
style={{ background: segmentColor(segment) }}
/>
{segment.name}
</div>
<div className="mt-1 flex items-center justify-between gap-4 tabular-nums">
<span className="font-mono font-medium">
{mask(segment.value)}
</span>
<span className="text-muted-foreground">
{percentage.toFixed(1)}%
</span>
</div>
</div>
);
}
export function MultiLevelDonut({
data,
showIncome,
height = 400,
className,
currency = 'USD',
period,
}: MultiLevelDonutProps) {
const locale = useLocale();
const { isPrivacyModeEnabled } = usePrivacyMode();
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container || typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(() =>
setWidth(container.clientWidth),
);
observer.observe(container);
return () => observer.disconnect();
}, []);
const rings = useMemo<DonutRing[]>(
() =>
buildDonutRings(data.income_categories, data.expense_categories, {
showIncome,
}),
[data.income_categories, data.expense_categories, showIncome],
);
const geometry = useMemo(() => {
const base = Math.min(height, width || height);
const chartRadius = base / 2;
const holeRadius = chartRadius * HOLE_FRACTION;
const rimRadius = chartRadius * RIM_FRACTION;
const band =
rings.length > 0 ? (rimRadius - holeRadius) / rings.length : 0;
return {
cx: (width || height) / 2,
cy: height / 2,
holeRadius,
band,
rimRadius,
};
}, [width, height, rings.length]);
const outerLabels = useMemo(() => {
if (rings.length === 0 || width < LEADER_MIN_WIDTH) {
return [];
}
return layoutOuterLabels(
rings[rings.length - 1],
geometry.cx,
geometry.cy,
geometry.rimRadius - RING_GAP,
height,
);
}, [rings, geometry, width, height]);
const mask = (value: number) => {
const formatted = formatCurrency(value, currency, locale, 0, 0);
return isPrivacyModeEnabled ? formatted.replace(/\d/g, '*') : formatted;
};
if (rings.length === 0) {
return (
<div
className={cn(
'flex items-center justify-center text-muted-foreground',
className,
)}
style={{ height }}
>
{__('No cashflow data for this period')}
</div>
);
}
const handleClick = (segment: DonutSegment) => {
if (!segment.categoryId || !period) {
return;
}
router.visit(
transactionsIndex({
query: {
category_ids: segment.categoryId,
date_from: format(period.from, 'yyyy-MM-dd'),
date_to: format(period.to, 'yyyy-MM-dd'),
},
}).url,
);
};
const centerLabel = showIncome ? __('Net') : __('Expenses');
const centerValue = showIncome
? data.total_income - data.total_expense
: data.total_expense;
return (
<div
ref={containerRef}
className={cn('relative w-full', className)}
style={{ height }}
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip
wrapperStyle={{ zIndex: 40 }}
content={
<DonutTooltip
totalIncome={data.total_income}
totalExpense={data.total_expense}
mask={mask}
/>
}
/>
{rings.map((ring, index) => (
<Pie
key={`${ring.direction}-${ring.level}`}
data={ring.segments}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={
geometry.holeRadius + index * geometry.band
}
outerRadius={
geometry.holeRadius +
(index + 1) * geometry.band -
RING_GAP
}
startAngle={90}
endAngle={-270}
paddingAngle={0}
stroke="var(--color-background)"
strokeWidth={1}
isAnimationActive={false}
labelLine={false}
label={index === 0 ? InsideLabel : false}
onClick={(entry) => {
const withPayload = entry as {
payload?: DonutSegment;
};
handleClick(
withPayload.payload ??
(entry as unknown as DonutSegment),
);
}}
>
{ring.segments.map((segment) => (
<Cell
key={segment.key}
fill={segmentColor(segment)}
className={cn(
segment.categoryId && 'cursor-pointer',
)}
/>
))}
</Pie>
))}
</PieChart>
</ResponsiveContainer>
{outerLabels.length > 0 && (
<svg
className="pointer-events-none absolute inset-0"
width={width}
height={height}
>
{outerLabels.map((label) => {
const elbowX =
geometry.cx + label.side * (geometry.rimRadius + 2);
const textX = elbowX + label.side * LEADER_RUN;
return (
<g key={label.key}>
<polyline
points={`${label.edgeX},${label.edgeY} ${elbowX},${label.y} ${textX},${label.y}`}
fill="none"
stroke="var(--color-border)"
strokeWidth={1}
/>
<text
x={textX + label.side * 3}
y={label.y}
textAnchor={
label.side > 0 ? 'start' : 'end'
}
dominantBaseline="central"
fontSize={10}
fill="var(--color-foreground)"
>
{label.name}
<tspan fill="var(--color-muted-foreground)">
{' '}
{label.percentage.toFixed(1)}%
</tspan>
</text>
</g>
);
})}
</svg>
)}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
<span className="text-xs text-muted-foreground">
{centerLabel}
</span>
<span className="font-mono text-sm font-semibold tabular-nums">
{mask(centerValue)}
</span>
</div>
</div>
);
}