spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
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