import { useId } from "react"; export interface PlateSlice { id: string; label: string; value: number; /** 2-stop gradient for this slice, e.g. ["#D9F227", "#1FD9C4"]. */ colors: [string, string]; } export interface PlateChartProps { slices: PlateSlice[]; size?: number; centerLabel?: string; centerValue?: string; /** Physical gap between slices in px, converted to an angular gap at render time. */ gapPx?: number; } function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; } /** Donut-segment path between two radii, from startAngle to endAngle (degrees, 0 = top). */ function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number) { const startOuter = polarToCartesian(cx, cy, rOuter, endAngle); const endOuter = polarToCartesian(cx, cy, rOuter, startAngle); const startInner = polarToCartesian(cx, cy, rInner, endAngle); const endInner = polarToCartesian(cx, cy, rInner, startAngle); const largeArc = endAngle - startAngle <= 180 ? 0 : 1; return [ "M", startOuter.x, startOuter.y, "A", rOuter, rOuter, 0, largeArc, 0, endOuter.x, endOuter.y, "L", endInner.x, endInner.y, "A", rInner, rInner, 0, largeArc, 1, startInner.x, startInner.y, "Z", ].join(" "); } /** * "Dining Plate" chart — a donut styled to read as a ceramic plate: outer * rim (gradient stroke), recessed well (radial gradient), slices as * gradient-filled donut segments with a small gap between each, and a * center overlay for the headline metric. * * Meant to live inside a `GlassCard`. All visuals are gradients — no images. */ export function PlateChart({ slices, size = 240, centerLabel, centerValue, gapPx = 2 }: PlateChartProps) { const uid = useId().replace(/:/g, ""); const cx = size / 2; const cy = size / 2; const rimR = size / 2 - 4; const outerR = rimR - 14; const innerR = size * 0.32; const total = slices.reduce((sum, s) => sum + s.value, 0) || 1; const avgR = (outerR + innerR) / 2; const gapDeg = avgR > 0 ? (gapPx / (avgR * 2 * Math.PI)) * 360 : 0; let angle = 0; const segments = slices .filter((s) => s.value > 0) .map((slice) => { const sweep = (slice.value / total) * 360; const start = angle + gapDeg / 2; const end = angle + sweep - gapDeg / 2; angle += sweep; return { ...slice, start, end, d: end > start ? arcPath(cx, cy, outerR, innerR, start, end) : null }; }); return (
{segments.map((s) => ( ))} {/* ceramic rim */} {/* recessed well */} {segments.map( (s) => s.d && )} {/* center hole, matches well so the label sits on clean ground */} {(centerLabel || centerValue) && (
{centerValue && ( {centerValue} )} {centerLabel && ( {centerLabel} )}
)}
); }