This commit includes the temporary Next.js build cache files, public brand logos, and project configuration added in this change.
202 lines
5.5 KiB
TypeScript
202 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useId } from "react";
|
|
import { withAlpha } from "./palette";
|
|
|
|
export type DonutBucket = {
|
|
label: string;
|
|
value: number;
|
|
color: string;
|
|
};
|
|
|
|
export function DonutChart({
|
|
buckets,
|
|
total,
|
|
centerLabel,
|
|
centerSubLabel,
|
|
size = 180,
|
|
compactLegend = false,
|
|
}: {
|
|
buckets: DonutBucket[];
|
|
total: number;
|
|
centerLabel: string;
|
|
centerSubLabel?: string;
|
|
size?: number;
|
|
/**
|
|
* Stack the legend below the donut in a 2-col grid. Use on narrow
|
|
* cards (e.g. dashboard 3-col grid) where the side-by-side layout
|
|
* clips long labels like "Approval Pending".
|
|
*/
|
|
compactLegend?: boolean;
|
|
}) {
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const r = size * 0.36;
|
|
const stroke = size * 0.13;
|
|
const circumference = 2 * Math.PI * r;
|
|
const gradId = useId();
|
|
|
|
if (total === 0 || buckets.length === 0) {
|
|
return (
|
|
<div
|
|
className="flex items-center justify-center text-sm text-muted-foreground"
|
|
style={{ height: size }}
|
|
>
|
|
No data yet.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Build segments with running cumulative offset via reduce (lint-friendly)
|
|
const segments = buckets.reduce<
|
|
Array<DonutBucket & { dash: number; offset: number; fraction: number }>
|
|
>((acc, b) => {
|
|
const priorTotal = acc.reduce((s, x) => s + x.fraction, 0);
|
|
const fraction = b.value / total;
|
|
acc.push({
|
|
...b,
|
|
dash: fraction * circumference,
|
|
offset: -priorTotal * circumference,
|
|
fraction,
|
|
});
|
|
return acc;
|
|
}, []);
|
|
|
|
const chartBlock = (
|
|
<div className="relative shrink-0">
|
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
<defs>
|
|
{segments.map((s, i) => (
|
|
<filter
|
|
key={i}
|
|
id={`glow-${gradId}-${i}`}
|
|
x="-20%"
|
|
y="-20%"
|
|
width="140%"
|
|
height="140%"
|
|
>
|
|
<feGaussianBlur stdDeviation="2.5" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
))}
|
|
</defs>
|
|
{/* Background ring */}
|
|
<circle
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={withAlpha("#94A3B8", 14)}
|
|
strokeWidth={stroke}
|
|
/>
|
|
{segments.map((s, i) => (
|
|
<circle
|
|
key={i}
|
|
cx={cx}
|
|
cy={cy}
|
|
r={r}
|
|
fill="none"
|
|
stroke={s.color}
|
|
strokeWidth={stroke}
|
|
strokeDasharray={`${s.dash} ${circumference}`}
|
|
strokeDashoffset={s.offset}
|
|
transform={`rotate(-90 ${cx} ${cy})`}
|
|
strokeLinecap="butt"
|
|
filter={`url(#glow-${gradId}-${i})`}
|
|
/>
|
|
))}
|
|
{/* Center label */}
|
|
<text
|
|
x={cx}
|
|
y={centerSubLabel ? cy - 6 : cy + 6}
|
|
textAnchor="middle"
|
|
style={{
|
|
fontSize: size * 0.16,
|
|
fontFamily: "var(--font-heading)",
|
|
fontWeight: 700,
|
|
fill: "var(--foreground)",
|
|
}}
|
|
>
|
|
{centerLabel}
|
|
</text>
|
|
{centerSubLabel && (
|
|
<text
|
|
x={cx}
|
|
y={cy + 14}
|
|
textAnchor="middle"
|
|
style={{
|
|
fontSize: size * 0.07,
|
|
letterSpacing: 1.4,
|
|
textTransform: "uppercase",
|
|
fill: "var(--muted-foreground)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{centerSubLabel}
|
|
</text>
|
|
)}
|
|
</svg>
|
|
</div>
|
|
);
|
|
|
|
if (compactLegend) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-3">
|
|
{chartBlock}
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 w-full text-[11px]">
|
|
{segments.map((s, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-1.5 min-w-0"
|
|
title={`${s.label}: ${s.value} (${Math.round(s.fraction * 100)}%)`}
|
|
>
|
|
<span
|
|
className="h-2 w-2 rounded-full shrink-0"
|
|
style={{
|
|
background: s.color,
|
|
boxShadow: `0 0 6px ${withAlpha(s.color, 50)}`,
|
|
}}
|
|
/>
|
|
<span className="truncate">{s.label}</span>
|
|
<span className="ml-auto font-semibold tabular-nums shrink-0">
|
|
{s.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col sm:flex-row items-center gap-4">
|
|
{chartBlock}
|
|
{/* Legend */}
|
|
<div className="flex-1 w-full space-y-1.5 text-xs">
|
|
{segments.map((s, i) => (
|
|
<div key={i} className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span
|
|
className="h-2.5 w-2.5 rounded-full shrink-0"
|
|
style={{
|
|
background: s.color,
|
|
boxShadow: `0 0 8px ${withAlpha(s.color, 50)}`,
|
|
}}
|
|
/>
|
|
<span className="truncate" title={s.label}>{s.label}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="font-semibold tabular-nums">{s.value}</span>
|
|
<span className="text-muted-foreground tabular-nums w-9 text-right">
|
|
{Math.round(s.fraction * 100)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|