SPB Git

Repository Public

chart.tsx 4.9 KB 66 lines tsx

1import { useEffect, useMemo, useRef, useState } from 'react';23export interface Pt { t: number; p: number }4/** Tiny trend line for list rows. Decorative: the adjacent text carries the value. */5export function Spark({ data, w = 64, h = 26 }: { data: number[]; w?: number; h?: number }) {6  if (data.length < 2) return <span style={{ width: w, height: h }} className="inline-block" />;7  const min = Math.min(...data), max = Math.max(...data), span = max - min || 1;8  const d = data.map((v, k) => `${k ? 'L' : 'M'}${((k / (data.length - 1)) * (w - 2) + 1).toFixed(1)} ${(h - 2 - ((v - min) / span) * (h - 4)).toFixed(1)}`).join('');9  return <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden="true" className="shrink-0 text-mute"><path d={d} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>;10}1112/**13 * Single-series price line: 2px line, ~10% area wash, hairline reference at the period's opening price,14 * crosshair that snaps to the nearest point (mouse or touch), value read-out in ink colour above the plot.15 */16export function PriceChart({ points, format, formatTime, onHover }: { points: Pt[]; format: (v: number) => string; formatTime: (t: number) => string; onHover?: (p: Pt | null) => void }) {17  const box = useRef<HTMLDivElement>(null);18  const [w, setW] = useState(340), [at, setAt] = useState<number | null>(null);19  const H = 210, padT = 14, padB = 22;20  useEffect(() => {21    const el = box.current; if (!el) return;22    const ro = new ResizeObserver(() => setW(el.clientWidth)); ro.observe(el); setW(el.clientWidth);23    return () => ro.disconnect();24  }, []);25  const g = useMemo(() => {26    if (points.length < 2) return null;27    const ps = points.map((p) => p.p), min = Math.min(...ps), max = Math.max(...ps), span = max - min || max || 1;28    const x = (k: number) => (k / (points.length - 1)) * w, y = (v: number) => padT + (1 - (v - min) / span) * (H - padT - padB);29    const line = points.map((p, k) => `${k ? 'L' : 'M'}${x(k).toFixed(1)} ${y(p.p).toFixed(1)}`).join('');30    return { min, max, x, y, line, area: `${line}L${w} ${H - padB}L0 ${H - padB}Z`, open: y(points[0]!.p) };31  }, [points, w]);32  const move = (clientX: number) => {33    const el = box.current; if (!el || !g) return;34    const r = el.getBoundingClientRect();35    const k = Math.max(0, Math.min(points.length - 1, Math.round(((clientX - r.left) / r.width) * (points.length - 1))));36    setAt(k); onHover?.(points[k]!);37  };38  const leave = () => { setAt(null); onHover?.(null); };39  if (!g) return <div ref={box} className="grid h-[210px] place-items-center text-[13px] text-mute">—</div>;40  const first = points[0]!, last = points[points.length - 1]!;41  const cur = at === null ? null : points[at]!;42  return (43    <div ref={box} className="relative touch-pan-y select-none" role="img"44      aria-label={`${formatTime(first.t)} → ${formatTime(last.t)} : ${format(first.p)} → ${format(last.p)} (min ${format(g.min)}, max ${format(g.max)})`}45      onPointerDown={(e) => move(e.clientX)} onPointerMove={(e) => { if (e.pointerType === 'mouse' || e.buttons || e.pressure > 0) move(e.clientX); }}46      onPointerUp={(e) => { if (e.pointerType !== 'mouse') leave(); }} onPointerLeave={leave} onPointerCancel={leave}>47      <svg width={w} height={H} viewBox={`0 0 ${w} ${H}`} className="block overflow-visible">48        <defs><linearGradient id="spw-area" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="var(--blue)" stopOpacity="0.16" /><stop offset="1" stopColor="var(--blue)" stopOpacity="0" /></linearGradient></defs>49        <line x1="0" x2={w} y1={g.open} y2={g.open} stroke="var(--line)" strokeWidth="1" />50        <path d={g.area} fill="url(#spw-area)" />51        <path d={g.line} fill="none" stroke="var(--blue)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />52        {cur && at !== null && <>53          <line x1={g.x(at)} x2={g.x(at)} y1={padT - 6} y2={H - padB} stroke="var(--mute)" strokeWidth="1" />54          <circle cx={g.x(at)} cy={g.y(cur.p)} r="5" fill="var(--blue)" stroke="var(--surface)" strokeWidth="2" />55        </>}56        {at === null && <circle cx={w - 1} cy={g.y(last.p)} r="4" fill="var(--blue)" stroke="var(--surface)" strokeWidth="2" />}57      </svg>58      <div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-between text-[11px] text-mute"><span>{formatTime(first.t)}</span><span>{formatTime(last.t)}</span></div>59      <div className="pointer-events-none absolute right-0 top-0 text-right text-[11px] leading-tight text-mute"><div>max {format(g.max)}</div></div>60      <div className="pointer-events-none absolute bottom-6 right-0 text-right text-[11px] leading-tight text-mute"><div>min {format(g.min)}</div></div>61      {cur && at !== null && <div className="pointer-events-none absolute top-0 -translate-x-1/2 whitespace-nowrap rounded-lg bg-primary px-2 py-1 text-[11.5px] font-medium text-onprimary"62        style={{ left: Math.max(46, Math.min(w - 46, g.x(at))) }}>{formatTime(cur.t)}</div>}63    </div>64  );65}66
Files main
Menu

Theme