HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
1'use client';2import { useMemo, useRef, useState } from 'react';3import { cn } from '@/lib/cn';4import { fmtCompact } from '@/lib/format';5import { LineChart, type LineChartProps, lineLayout } from './charts';67/**8 * Progressive enhancement of `LineChart`: the same SSR SVG, plus a crosshair and a nearest-point tooltip on hover / touch.9 * Pointer position is mapped from CSS pixels to viewBox units, so it works at any rendered width.10 */11export function InteractiveLineChart({ xFormat, ...props }: LineChartProps & { xFormat?: (x: number) => string }) {12 const L = useMemo(() => lineLayout(props), [props]);13 const wrap = useRef<HTMLDivElement>(null);14 const [hover, setHover] = useState<{ x: number; idx: number[] } | null>(null);15 const yFormat = props.yFormat ?? fmtCompact;1617 if (!L) return <LineChart {...props} />;18 const fmtXFull = xFormat ?? ((v: number) => (L.xTime ? new Date(v).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : String(v)));1920 const onMove = (clientX: number) => {21 const el = wrap.current;22 if (!el) return;23 const rect = el.getBoundingClientRect();24 const vx = ((clientX - rect.left) / rect.width) * L.w;25 if (vx < L.pad.l || vx > L.w - L.pad.r) {26 setHover(null);27 return;28 }29 const tx = L.xInvert(vx);30 // nearest point index per series31 const idx = L.series.map((s) => {32 let best = -1;33 let bd = Infinity;34 s.pts.forEach((p, i) => {35 const d = Math.abs(p.x - tx);36 if (d < bd) {37 bd = d;38 best = i;39 }40 });41 return best;42 });43 const anchor = L.series.map((s, i) => (idx[i] as number) >= 0 ? (s.pts[idx[i] as number] as { x: number }).x : NaN).filter(Number.isFinite);44 if (!anchor.length) return;45 // snap the crosshair to the point nearest the pointer among all series46 const snap = anchor.reduce((a, b) => (Math.abs(b - tx) < Math.abs(a - tx) ? b : a));47 setHover({ x: snap, idx });48 };4950 const rows = hover51 ? L.series52 .map((s, i) => {53 const p = s.pts[hover.idx[i] as number];54 return p ? { name: s.name, color: s.color, p } : null;55 })56 .filter((r): r is { name: string; color: string; p: { x: number; y: number } } => !!r)57 : [];58 const hx = hover ? L.x(hover.x) : 0;59 const leftPct = (hx / L.w) * 100;60 const flip = leftPct > 60;6162 return (63 <div64 ref={wrap}65 className={cn('relative', props.className)}66 onPointerMove={(e) => onMove(e.clientX)}67 onPointerDown={(e) => onMove(e.clientX)}68 onPointerLeave={() => setHover(null)}69 onTouchMove={(e) => e.touches[0] && onMove(e.touches[0].clientX)}70 onTouchEnd={() => setTimeout(() => setHover(null), 1500)}71 data-interactive-chart72 >73 <LineChart {...props} className={undefined}>74 {hover && (75 <g pointerEvents="none">76 <line x1={hx} x2={hx} y1={L.pad.t} y2={L.height - L.pad.b} stroke="var(--rule-strong)" strokeDasharray="3 3" />77 {rows.map((r) => (78 <circle key={r.name} cx={L.x(r.p.x)} cy={L.y(r.p.y)} r={4} fill={r.color} stroke="var(--canvas)" strokeWidth={1.5} />79 ))}80 </g>81 )}82 </LineChart>83 {hover && rows.length > 0 && (84 <div role="status" className="panel pointer-events-none absolute top-2 z-10 min-w-[9rem] px-2.5 py-1.5 text-xs shadow-lg" style={flip ? { right: `${100 - leftPct + 1}%` } : { left: `${leftPct + 1}%` }}>85 <p className="tnum text-ink-3">{fmtXFull(hover.x)}</p>86 {rows.map((r) => (87 <p key={r.name} className="mt-0.5 flex items-center gap-1.5">88 <span className="inline-block h-[3px] w-3 rounded-sm" style={{ background: r.color }} />89 <span className="truncate text-ink-2">{r.name}</span>90 <span className="tnum ml-auto pl-2 font-medium text-ink">{yFormat(r.p.y)}</span>91 </p>92 ))}93 </div>94 )}95 </div>96 );97}98