A day-long solar generation curve as a hand-built SVG area with usage overlaid, a self-drawing production stroke, and a live now marker.
npx shadcn@latest add @paragon/solar-production"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SolarProductionProps extends React.ComponentProps<"div"> {
/** 24 hourly production values in kW (index 0 = midnight). */
production?: number[];
/** 24 hourly household usage values in kW. */
usage?: number[];
/** Peak of the y-axis in kW; defaults to a headroom above the data max. */
peak?: number;
/** Current hour marker, 0–23 (fractional allowed). Null hides it. */
now?: number | null;
}
const DEFAULT_PRODUCTION = [
0, 0, 0, 0, 0, 0.2, 0.9, 2.1, 3.4, 4.6, 5.5, 6.1, 6.4, 6.2, 5.6, 4.7, 3.5,
2.2, 1.1, 0.3, 0, 0, 0, 0,
];
const DEFAULT_USAGE = [
1.1, 0.9, 0.8, 0.8, 0.9, 1.2, 2.1, 2.8, 2.2, 1.9, 1.7, 1.8, 2.0, 1.9, 1.8,
1.9, 2.4, 3.6, 4.1, 3.8, 3.1, 2.4, 1.7, 1.3,
];
const W = 320;
const H = 150;
const PAD_L = 4;
const PAD_R = 4;
const PAD_T = 10;
const PAD_B = 18;
function buildPath(values: number[], peak: number, area: boolean) {
const n = values.length;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
const x = (i: number) => PAD_L + (i / (n - 1)) * innerW;
const y = (v: number) => PAD_T + innerH - (v / peak) * innerH;
let d = "";
values.forEach((v, i) => {
d += `${i === 0 ? "M" : "L"} ${x(i).toFixed(2)} ${y(v).toFixed(2)} `;
});
if (area) {
d += `L ${x(n - 1).toFixed(2)} ${(H - PAD_B).toFixed(2)} L ${x(0).toFixed(2)} ${(H - PAD_B).toFixed(2)} Z`;
}
return d.trim();
}
/**
* A day-long solar generation curve drawn as a hand-built SVG area, with the
* household usage line overlaid so the self-consumption gap reads at a glance.
* The production stroke draws itself in on mount (a one-shot dash reveal that
* respects reduced motion), and a "now" marker pins the current hour. All values
* are deterministic and figures use tabular numerals.
*/
export function SolarProduction({
production = DEFAULT_PRODUCTION,
usage = DEFAULT_USAGE,
peak,
now = 12.5,
className,
...props
}: SolarProductionProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const dataMax = Math.max(...production, ...usage);
const top = peak ?? Math.ceil((dataMax * 1.15) / 2) * 2;
const prodLine = buildPath(production, top, false);
const prodArea = buildPath(production, top, true);
const usageLine = buildPath(usage, top, false);
const totalGen = production.reduce((a, b) => a + b, 0);
const totalUse = usage.reduce((a, b) => a + b, 0);
const selfUse = production.reduce((a, p, i) => a + Math.min(p, usage[i]), 0);
const selfPct = totalUse > 0 ? Math.round((selfUse / totalUse) * 100) : 0;
const innerW = W - PAD_L - PAD_R;
const nowX =
now == null ? null : PAD_L + (now / (production.length - 1)) * innerW;
return (
<div
data-slot="solar-production"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<style href="paragon-solar-production" precedence="paragon">{`
@keyframes paragon-solar-draw { to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) {
[data-solar-draw] { animation: none !important; stroke-dashoffset: 0 !important; }
}
`}</style>
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-medium">Solar production</h3>
<p className="text-xs text-muted-foreground">Today, per hour</p>
</div>
<div className="text-right">
<div className="text-lg font-semibold tabular-nums">
{totalGen.toFixed(1)} kWh
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{selfPct}% self-powered
</div>
</div>
</div>
<svg
role="img"
aria-label={`Solar production curve peaking at ${dataMax.toFixed(1)} kilowatts against household usage`}
viewBox={`0 0 ${W} ${H}`}
className="mt-3 w-full"
>
<defs>
<linearGradient id={`solar-fill-${uid}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="var(--color-warning)" stopOpacity="0.35" />
<stop offset="100%" stopColor="var(--color-warning)" stopOpacity="0.02" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1={PAD_L}
x2={W - PAD_R}
y1={PAD_T + (H - PAD_T - PAD_B) * f}
y2={PAD_T + (H - PAD_T - PAD_B) * f}
className="stroke-border"
strokeWidth={1}
strokeDasharray="2 3"
/>
))}
<path d={prodArea} fill={`url(#solar-fill-${uid})`} />
<path
data-solar-draw
d={prodLine}
fill="none"
className="stroke-warning"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
strokeDashoffset={1}
style={{ animation: `paragon-solar-draw 1.1s var(--ease-out) forwards` }}
/>
<path
d={usageLine}
fill="none"
className="stroke-foreground/45"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="3 3"
/>
{nowX != null && (
<g>
<line
x1={nowX}
x2={nowX}
y1={PAD_T}
y2={H - PAD_B}
className="stroke-foreground/30"
strokeWidth={1}
/>
<circle cx={nowX} cy={PAD_T} r={2.5} className="fill-warning" />
</g>
)}
{["00", "06", "12", "18", "24"].map((lbl, i) => (
<text
key={lbl}
x={PAD_L + (i / 4) * innerW}
y={H - 5}
textAnchor={i === 0 ? "start" : i === 4 ? "end" : "middle"}
className="fill-muted-foreground"
style={{ fontSize: 9 }}
>
{lbl}
</text>
))}
</svg>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-0.5 w-3 rounded-full bg-warning" /> Production
</span>
<span className="flex items-center gap-1.5">
<span className="h-0.5 w-3 rounded-full bg-foreground/45" /> Usage
</span>
<span className="ml-auto tabular-nums">
{totalUse.toFixed(1)} kWh used
</span>
</div>
</div>
);
}