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>
This commit is contained in:
121
handover/component-library/components/PlateChart.tsx
Normal file
121
handover/component-library/components/PlateChart.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user