169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
'use client';
|
|
|
|
import type { CSSProperties } from 'react';
|
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
|
import type { TodayChartBucket } from '@/types/today';
|
|
import { TodayChartFrame } from '@/components/ui/today/TodayChartFrame';
|
|
import {
|
|
chartRankColor,
|
|
TODAY_CHART_TOOLTIP_STYLE,
|
|
} from '@/components/today/chart-theme';
|
|
|
|
interface TodayDonutChartBaseProps {
|
|
data: TodayChartBucket[];
|
|
labelForCode: (code: string) => string;
|
|
colorForCode?: (code: string, index: number) => string;
|
|
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
|
}
|
|
|
|
interface TodayDonutChartProps extends TodayDonutChartBaseProps {
|
|
variant?: 'donut' | 'pie';
|
|
/** Inline legend + chart row (legacy). Prefer TodayDonutChartLegend + sidePanelLayout. */
|
|
sideLegend?: boolean;
|
|
}
|
|
|
|
function useDonutChartModel({
|
|
data,
|
|
labelForCode,
|
|
colorForCode,
|
|
swatchStyleForCode,
|
|
}: TodayDonutChartBaseProps) {
|
|
const chartData = data.map((item) => ({
|
|
...item,
|
|
displayLabel: labelForCode(item.code),
|
|
}));
|
|
|
|
const resolveColor = (code: string, index: number) =>
|
|
colorForCode?.(code, index) ?? chartRankColor(index);
|
|
|
|
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
|
swatchStyleForCode?.(code, index) ?? {
|
|
backgroundColor: resolveColor(code, index),
|
|
borderColor: 'rgba(0, 0, 0, 0.18)',
|
|
};
|
|
|
|
return { chartData, resolveColor, resolveSwatchStyle };
|
|
}
|
|
|
|
export function TodayDonutChartLegend({
|
|
data,
|
|
labelForCode,
|
|
colorForCode,
|
|
swatchStyleForCode,
|
|
}: TodayDonutChartBaseProps) {
|
|
const { chartData, resolveSwatchStyle } = useDonutChartModel({
|
|
data,
|
|
labelForCode,
|
|
colorForCode,
|
|
swatchStyleForCode,
|
|
});
|
|
|
|
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
|
|
|
return (
|
|
<div className="flex min-w-0 items-start overflow-hidden">
|
|
<div className="flex max-h-full min-w-0 flex-col items-start gap-1.5 overflow-y-auto">
|
|
{chartData.map((entry, index) => (
|
|
<span key={entry.code} className={rowClass}>
|
|
<span
|
|
className="inline-block h-3 w-3 rounded-sm border"
|
|
style={resolveSwatchStyle(entry.code, index)}
|
|
aria-hidden
|
|
/>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="ml-2 flex min-w-0 flex-col items-start gap-1.5 overflow-hidden">
|
|
{chartData.map((entry) => (
|
|
<span
|
|
key={entry.code}
|
|
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
|
>
|
|
{entry.displayLabel}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
|
{chartData.map((entry) => (
|
|
<span
|
|
key={entry.code}
|
|
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
|
>
|
|
{entry.count}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TodayDonutChart({
|
|
data,
|
|
labelForCode,
|
|
colorForCode,
|
|
swatchStyleForCode,
|
|
variant = 'donut',
|
|
sideLegend = false,
|
|
}: TodayDonutChartProps) {
|
|
const { chartData, resolveColor } = useDonutChartModel({
|
|
data,
|
|
labelForCode,
|
|
colorForCode,
|
|
swatchStyleForCode,
|
|
});
|
|
|
|
const innerRadius = variant === 'pie' ? 0 : '62%';
|
|
const outerRadius = variant === 'pie' ? '88%' : 92;
|
|
|
|
const pieChart = (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
|
<Pie
|
|
data={chartData}
|
|
dataKey="count"
|
|
nameKey="displayLabel"
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={innerRadius}
|
|
outerRadius={outerRadius}
|
|
paddingAngle={variant === 'pie' ? 1 : 2}
|
|
stroke="transparent"
|
|
>
|
|
{chartData.map((entry, index) => (
|
|
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
|
formatter={(value, _name, item) => {
|
|
const row = item?.payload as TodayChartBucket | undefined;
|
|
return [value, row ? labelForCode(row.code) : ''];
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
|
|
if (sideLegend) {
|
|
return (
|
|
<div className="flex h-full min-h-0 w-full items-center gap-3 overflow-hidden sm:gap-4">
|
|
<div className="flex min-h-0 min-w-0 flex-1 items-center overflow-hidden">
|
|
<TodayDonutChartLegend
|
|
data={data}
|
|
labelForCode={labelForCode}
|
|
colorForCode={colorForCode}
|
|
swatchStyleForCode={swatchStyleForCode}
|
|
/>
|
|
</div>
|
|
<div className="aspect-square h-[min(100%,9.5rem)] w-[min(100%,9.5rem)] shrink-0">
|
|
{pieChart}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return <TodayChartFrame>{pieChart}</TodayChartFrame>;
|
|
}
|