Files
pocket-pascal/handover/component-library/components/PlateChart.tsx
Steffi Müller a482bf5b09 Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.
Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 12:22:32 +02:00

122 lines
4.6 KiB
TypeScript

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 (
<div className="relative select-none" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Plate breakdown">
<defs>
<radialGradient id={`${uid}-well`} cx="50%" cy="50%" r="65%">
<stop offset="0%" stopColor="var(--plate-well-center)" />
<stop offset="100%" stopColor="var(--plate-well-edge)" />
</radialGradient>
<linearGradient id={`${uid}-rim`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--plate-rim-light)" />
<stop offset="100%" stopColor="var(--plate-rim-shadow)" />
</linearGradient>
{segments.map((s) => (
<linearGradient key={s.id} id={`${uid}-slice-${s.id}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={s.colors[0]} />
<stop offset="100%" stopColor={s.colors[1]} />
</linearGradient>
))}
</defs>
{/* ceramic rim */}
<circle cx={cx} cy={cy} r={rimR} fill="none" stroke={`url(#${uid}-rim)`} strokeWidth={6} />
{/* recessed well */}
<circle cx={cx} cy={cy} r={rimR - 8} fill={`url(#${uid}-well)`} />
{segments.map(
(s) => s.d && <path key={s.id} d={s.d} fill={`url(#${uid}-slice-${s.id})`} />
)}
{/* center hole, matches well so the label sits on clean ground */}
<circle cx={cx} cy={cy} r={innerR - 6} fill={`url(#${uid}-well)`} />
</svg>
{(centerLabel || centerValue) && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
{centerValue && (
<span className="font-display text-2xl font-black leading-none text-[var(--ink)]">
{centerValue}
</span>
)}
{centerLabel && (
<span className="font-body mt-1 text-[11px] font-bold uppercase tracking-wide text-[var(--ink-soft)]">
{centerLabel}
</span>
)}
</div>
)}
</div>
);
}