SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
3.9 KB · 98 lines tsx
Raw Blame History
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