Climate

Solar Production

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.

Install

npx shadcn@latest add @paragon/solar-production

solar-production.tsx

"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>
  );
}