SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
6.9 KB · 157 lines tsx
Raw Blame History
1'use client';2import { bisector, extent } from 'd3-array';3import { scaleLinear, scaleTime } from 'd3-scale';4import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';5import { useId, useMemo, useRef, useState } from 'react';6import { cn } from '@/lib/cn';7import { fmt1, fmtDate, fmtDateShort } from '@/lib/format';89export type LineSeries = { id: string; label: string; points: { day: string; value: number | null }[]; color?: string };1011/**12 * Responsive multi-series time chart (SVG). Hover/touch crosshair with a value readout; y from data (optionally 0-based);13 * `baseline` dashed reference (index 100). Colours cycle through `--series-n`. Width follows the container (viewBox).14 */15export function LineChart({ series, height = 180, baseline, yZero = false, className, unit = '', showLegend = true, yFormat = fmt1, ariaLabel }: { series: LineSeries[]; height?: number; baseline?: number; yZero?: boolean; className?: string; unit?: string; showLegend?: boolean; yFormat?: (v: number) => string; ariaLabel?: string }) {16  const W = 800;17  const H = height;18  const m = { t: 10, r: 12, b: 22, l: 40 };19  const uid = useId();20  const ref = useRef<SVGSVGElement>(null);21  const [hover, setHover] = useState<number | null>(null);2223  const { x, y, dates, parsed } = useMemo(() => {24    const parsed = series.map((s) => ({ ...s, pts: s.points.map((p) => ({ t: new Date(p.day.length === 10 ? `${p.day}T00:00:00Z` : p.day).getTime(), v: p.value })).filter((p) => Number.isFinite(p.t)) }));25    const allT = parsed.flatMap((s) => s.pts.map((p) => p.t));26    const allV = parsed.flatMap((s) => s.pts.map((p) => p.v)).filter((v): v is number => v !== null && Number.isFinite(v));27    const [t0, t1] = allT.length ? (extent(allT) as [number, number]) : [Date.now() - 86400000, Date.now()];28    let [v0, v1] = allV.length ? (extent([...allV, ...(baseline !== undefined ? [baseline] : [])]) as [number, number]) : [0, 1];29    if (yZero) v0 = Math.min(0, v0);30    if (v0 === v1) {31      v0 -= 1;32      v1 += 1;33    }34    const pad = (v1 - v0) * 0.08;35    const x = scaleTime()36      .domain([new Date(t0), new Date(t1)])37      .range([m.l, W - m.r]);38    const y = scaleLinear()39      .domain([v0 - (yZero && v0 === 0 ? 0 : pad), v1 + pad])40      .range([H - m.b, m.t])41      .nice(4);42    const dates = [...new Set(allT)].sort((a, b) => a - b);43    return { x, y, dates, parsed };44  }, [series, baseline, yZero, H, m.b, m.l, m.r, m.t]);4546  if (!parsed.some((s) => s.pts.some((p) => p.v !== null))) {47    return (48      <div className={cn('flex items-center justify-center border border-dashed border-rule-strong text-xs text-ink-3', className)} style={{ height }} role="status">49        No series available yet.50      </div>51    );52  }5354  const ticksY = y.ticks(4);55  const ticksX = x.ticks(Math.min(6, Math.max(2, dates.length)));56  const bis = bisector<number, number>((d) => d).center;57  const onMove = (clientX: number) => {58    const svg = ref.current;59    if (!svg) return;60    const r = svg.getBoundingClientRect();61    const px = ((clientX - r.left) / r.width) * W;62    const t = x.invert(px).getTime();63    setHover(dates[bis(dates, t)] ?? null);64  };65  const line = d3line<{ t: number; v: number | null }>()66    .defined((d) => d.v !== null)67    .x((d) => x(d.t))68    .y((d) => y(d.v as number))69    .curve(curveMonotoneX);70  const area = d3area<{ t: number; v: number | null }>()71    .defined((d) => d.v !== null)72    .x((d) => x(d.t))73    .y0(y.range()[0] as number)74    .y1((d) => y(d.v as number))75    .curve(curveMonotoneX);76  const colorOf = (i: number, c?: string) => c ?? `var(--series-${(i % 6) + 1})`;7778  return (79    <div className={cn('w-full', className)}>80      <svg81        ref={ref}82        viewBox={`0 0 ${W} ${H}`}83        className="block h-auto w-full touch-pan-y select-none"84        role="img"85        aria-label={ariaLabel ?? `${series.map((s) => s.label).join(', ')} over time`}86        onMouseMove={(e) => onMove(e.clientX)}87        onMouseLeave={() => setHover(null)}88        onTouchStart={(e) => e.touches[0] && onMove(e.touches[0].clientX)}89        onTouchMove={(e) => e.touches[0] && onMove(e.touches[0].clientX)}90        onTouchEnd={() => setHover(null)}91      >92        <defs>93          <clipPath id={`${uid}-clip`}>94            <rect x={m.l} y={0} width={W - m.l - m.r} height={H} />95          </clipPath>96        </defs>97        {ticksY.map((t) => (98          <g key={t}>99            <line x1={m.l} x2={W - m.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />100            <text x={m.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">101              {yFormat(t)}102            </text>103          </g>104        ))}105        {ticksX.map((t) => (106          <text key={+t} x={x(t)} y={H - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">107            {fmtDateShort(t.toISOString())}108          </text>109        ))}110        {baseline !== undefined && <line x1={m.l} x2={W - m.r} y1={y(baseline)} y2={y(baseline)} stroke="var(--rule-strong)" strokeDasharray="3 4" />}111        <g clipPath={`url(#${uid}-clip)`}>112          {parsed.map((s, i) => (113            <g key={s.id}>114              {parsed.length === 1 && <path d={area(s.pts) ?? ''} fill={colorOf(i, s.color)} opacity={0.08} />}115              <path d={line(s.pts) ?? ''} fill="none" stroke={colorOf(i, s.color)} strokeWidth={1.6} strokeLinejoin="round" strokeLinecap="round" />116            </g>117          ))}118        </g>119        {hover !== null && (120          <g>121            <line x1={x(hover)} x2={x(hover)} y1={m.t} y2={H - m.b} stroke="var(--ink-3)" strokeDasharray="2 3" />122            {parsed.map((s, i) => {123              const p = s.pts.find((q) => q.t === hover);124              if (!p || p.v === null) return null;125              return <circle key={s.id} cx={x(p.t)} cy={y(p.v)} r={3} fill={colorOf(i, s.color)} stroke="var(--canvas)" strokeWidth={1.5} />;126            })}127          </g>128        )}129      </svg>130      <div className="mt-1 flex min-h-5 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">131        {hover !== null ? (132          <>133            <span className="tnum text-ink-2">{fmtDate(new Date(hover).toISOString())}</span>134            {parsed.map((s, i) => {135              const p = s.pts.find((q) => q.t === hover);136              return (137                <span key={s.id} className="inline-flex items-center gap-1.5">138                  <span className="inline-block size-2 rounded-full" style={{ background: colorOf(i, s.color) }} />139                  {s.label} <span className="tnum font-medium text-ink">{p && p.v !== null ? `${yFormat(p.v)}${unit}` : '—'}</span>140                </span>141              );142            })}143          </>144        ) : (145          showLegend &&146          parsed.map((s, i) => (147            <span key={s.id} className="inline-flex items-center gap-1.5">148              <span className="inline-block size-2 rounded-full" style={{ background: colorOf(i, s.color) }} />149              {s.label}150            </span>151          ))152        )}153      </div>154    </div>155  );156}157