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>
122 lines
4.6 KiB
TypeScript
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>
|
|
);
|
|
}
|