SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
19.1 KB · 401 lines tsx
Raw Blame History
1'use client';23import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';4import { compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale';56/**7 * Price-history chart for asset pages: verified sales (dots), daily RareIndex Valuation (line) with its8 * low–high band, lowest ask and guide observations (dashed), a current-RIV reference line, period9 * control (URL hash), log/linear toggle and a touch-first crosshair. Follows the dataviz spec:10 * 2px lines, ≥8px markers with a surface ring, hairline solid grid, ≤5 axis ticks, text in text tokens,11 * legend for ≥2 series, hidden data table for assistive tech.12 */1314export interface SalePoint {15  date: string;16  usd: number;17  label?: string;18  variantId?: string | null;19}20export interface DailyPoint {21  x: string;22  y: number;23  low?: number | null;24  high?: number | null;25}26export type Period = '1M' | '3M' | '6M' | '1Y' | '3Y' | '5Y' | 'ALL';27const PERIODS: Array<{ id: Period; days: number | null }> = [28  { id: '1M', days: 31 },29  { id: '3M', days: 92 },30  { id: '6M', days: 183 },31  { id: '1Y', days: 366 },32  { id: '3Y', days: 1096 },33  { id: '5Y', days: 1827 },34  { id: 'ALL', days: null },35];3637export interface PriceChartProps {38  sales: SalePoint[];39  riv?: DailyPoint[];40  ask?: DailyPoint[];41  guide?: DailyPoint[];42  reference?: { value: number; label: string } | null;43  height?: number;44  ariaLabel: string;45  emptyLabel?: string;46  /** URL hash key used to persist the period (default "p") */47  hashKey?: string;48  defaultPeriod?: Period;49  className?: string;50}5152const C = {53  riv: 'var(--ri-series-1, #2f5bd6)',54  sales: 'var(--ri-series-2, #c2410c)',55  ask: 'var(--ri-series-3, #0e9384)',56  guide: 'var(--ri-series-4, #7c3aed)',57};5859function writeHash(key: string, value: string) {60  if (typeof window === 'undefined') return;61  const parts = window.location.hash.replace(/^#/, '').split('&').filter((kv) => kv && !kv.startsWith(`${key}=`));62  parts.push(`${key}=${encodeURIComponent(value)}`);63  window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${parts.join('&')}`);64  for (const l of hashListeners) l();65}66const hashListeners = new Set<() => void>();67function subscribeHash(cb: () => void) {68  hashListeners.add(cb);69  window.addEventListener('hashchange', cb);70  return () => {71    hashListeners.delete(cb);72    window.removeEventListener('hashchange', cb);73  };74}7576export function PriceChart({ sales, riv = [], ask = [], guide = [], reference = null, height = 300, ariaLabel, emptyLabel = 'No priced observations yet for this asset', hashKey = 'p', defaultPeriod = 'ALL', className }: PriceChartProps) {77  const wrap = useRef<HTMLDivElement>(null);78  const [width, setWidth] = useState(720);79  // Period/scale live in the URL hash (shareable, survives reloads). Read through an external store so80  // the server render (no hash) and the client stay consistent without effects.81  const hash = useSyncExternalStore(subscribeHash, () => (typeof window === 'undefined' ? '' : window.location.hash), () => '');82  const hashPeriod = (hash.replace(/^#/, '').split('&').find((kv) => kv.startsWith(`${hashKey}=`))?.slice(hashKey.length + 1) ?? null) as Period | null;83  const period: Period = hashPeriod && PERIODS.some((x) => x.id === hashPeriod) ? hashPeriod : defaultPeriod;84  const scaleChoice = hash.includes('scale=log') ? 'log' : hash.includes('scale=linear') ? 'linear' : null;85  const [hover, setHover] = useState<{ px: number; sticky: boolean } | null>(null);86  const uid = useId();8788  useEffect(() => {89    const el = wrap.current;90    if (!el) return;91    const ro = new ResizeObserver((entries) => {92      for (const e of entries) setWidth(Math.max(280, Math.floor(e.contentRect.width)));93    });94    ro.observe(el);95    return () => ro.disconnect();96  }, []);9798  const data = useMemo(() => {99    const s = sales.map((p) => ({ x: parseDay(p.date), y: p.usd, label: p.label ?? null })).filter((p) => Number.isFinite(p.x) && p.y > 0).sort((a, b) => a.x - b.x);100    const line = (pts: DailyPoint[]) => pts.map((p) => ({ x: parseDay(p.x), y: p.y, low: p.low ?? null, high: p.high ?? null })).filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && p.y > 0).sort((a, b) => a.x - b.x);101    return { sales: s, riv: line(riv), ask: line(ask), guide: line(guide) };102  }, [sales, riv, ask, guide]);103104  const all = [...data.sales, ...data.riv, ...data.ask, ...data.guide];105  const lastX = all.length ? Math.max(...all.map((p) => p.x)) : 0;106  const days = PERIODS.find((p) => p.id === period)?.days ?? null;107  const cutoff = days ? lastX - days * 86_400_000 : -Infinity;108  const vis = {109    sales: data.sales.filter((p) => p.x >= cutoff),110    riv: data.riv.filter((p) => p.x >= cutoff),111    ask: data.ask.filter((p) => p.x >= cutoff),112    guide: data.guide.filter((p) => p.x >= cutoff),113  };114  const visAll = [...vis.sales, ...vis.riv, ...vis.ask, ...vis.guide];115  const available = PERIODS.filter((p) => p.days === null || all.some((pt) => pt.x < lastX - p.days! * 86_400_000) || p.id === period);116117  const setPeriodPersist = useCallback((p: Period) => writeHash(hashKey, p), [hashKey]);118  const setLog = (v: boolean) => writeHash('scale', v ? 'log' : 'linear');119120  if (all.length === 0) {121    return (122      <div ref={wrap} className={className}>123        <div className="flex items-center justify-center rounded-md border border-dashed border-border text-xs text-subtle" style={{ height: Math.min(height, 180) }}>124          {emptyLabel}125        </div>126      </div>127    );128  }129130  // ---- scales131  const pad = { top: 14, right: 58, bottom: 26, left: 6 };132  const W = width;133  const H = height;134  const iw = W - pad.left - pad.right;135  const ih = H - pad.top - pad.bottom;136  const xsAll = visAll.length ? visAll.map((p) => p.x) : [lastX];137  let xMin = Math.min(...xsAll);138  let xMax = Math.max(...xsAll);139  if (days) xMin = Math.min(xMin, cutoff);140  if (xMax - xMin < 7 * 86_400_000) {141    xMin -= 15 * 86_400_000;142    xMax += 15 * 86_400_000;143  }144  const ysRaw = visAll.flatMap((p) => ('low' in p && p.low != null && p.high != null ? [p.y, p.low, p.high] : [p.y])).concat(reference ? [reference.value] : []);145  const positive = ysRaw.filter((y) => y > 0);146  const spread = positive.length ? Math.max(...positive) / Math.min(...positive) : 1;147  const log = scaleChoice ? scaleChoice === 'log' : spread > 40;148  const canLog = log && ysRaw.every((y) => y > 0);149  const ty = (y: number) => (canLog ? Math.log10(y) : y);150  const fy = (v: number) => (canLog ? 10 ** v : v);151  let yMin = Math.min(...ysRaw.map(ty));152  let yMax = Math.max(...ysRaw.map(ty));153  if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) {154    yMin = 0;155    yMax = 1;156  }157  if (yMin === yMax) {158    yMin -= Math.abs(yMin) * 0.05 || 1;159    yMax += Math.abs(yMax) * 0.05 || 1;160  }161  const yPad = (yMax - yMin) * 0.08;162  yMin -= yPad;163  yMax += yPad;164  const ticks = canLog ? logTicks(fy(yMin), fy(yMax)) : niceTicks(yMin, yMax, W < 480 ? 3 : 4);165  const sx = (x: number) => Math.round((pad.left + ((x - xMin) / (xMax - xMin)) * iw) * 100) / 100;166  const sy = (y: number) => Math.round((pad.top + ih - ((ty(y) - yMin) / (yMax - yMin)) * ih) * 100) / 100;167  const span = xMax - xMin;168  const xTicks = timeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 96))));169  const linePath = (pts: Array<{ x: number; y: number }>) => pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' ');170  const band = vis.riv.filter((p) => p.low != null && p.high != null);171  const bandPath = band.length >= 2 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.high!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.low!).toFixed(1)}`).join(' ')} Z` : null;172173  // ---- hover: nearest date across series174  const hoverX = hover ? xMin + ((hover.px - pad.left) / iw) * (xMax - xMin) : null;175  const nearest = <T extends { x: number }>(pts: T[]): T | null => {176    if (hoverX === null || !pts.length) return null;177    let best = pts[0]!;178    let bd = Infinity;179    for (const p of pts) {180      const d = Math.abs(p.x - hoverX);181      if (d < bd) {182        bd = d;183        best = p;184      }185    }186    return best;187  };188  const hRiv = nearest(vis.riv);189  const hAsk = nearest(vis.ask);190  const hGuide = nearest(vis.guide);191  // sales: nearest within 3% of the span so a finger lands on a dot, not a random one far away192  const hSaleCand = nearest(vis.sales);193  const hSale = hSaleCand && hoverX !== null && Math.abs(hSaleCand.x - hoverX) <= Math.max(span * 0.03, 86_400_000) ? hSaleCand : null;194  const hDate = hover ? [hSale?.x, hRiv?.x, hAsk?.x, hGuide?.x].filter((x): x is number => x !== undefined).sort((a, b) => Math.abs(a - hoverX!) - Math.abs(b - hoverX!))[0] ?? null : null;195196  const onPointer = (e: React.PointerEvent<SVGSVGElement>, sticky: boolean) => {197    const rect = e.currentTarget.getBoundingClientRect();198    const px = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;199    if (px >= pad.left - 8 && px <= pad.left + iw + 8) setHover({ px: Math.min(pad.left + iw, Math.max(pad.left, px)), sticky });200  };201202  const series = [203    vis.sales.length ? { id: 'sales', label: `Sales (${vis.sales.length})`, color: C.sales, kind: 'dots' as const } : null,204    vis.riv.length ? { id: 'riv', label: 'RIV (daily)', color: C.riv, kind: 'line' as const } : null,205    bandPath ? { id: 'band', label: 'RIV low–high', color: C.riv, kind: 'band' as const } : null,206    vis.ask.length ? { id: 'ask', label: 'Lowest ask', color: C.ask, kind: 'dashed' as const } : null,207    vis.guide.length ? { id: 'guide', label: 'Guide price', color: C.guide, kind: 'dashed' as const } : null,208  ].filter((s): s is NonNullable<typeof s> => Boolean(s));209210  const readout = hDate !== null;211  const fmtFull = (v: number) => fullNumber(v, { currency: true });212213  return (214    <div ref={wrap} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}>215      {/* controls */}216      <div className="mb-2 flex items-center justify-between gap-2">217        <div className="scrollbar-none -mx-1 flex min-w-0 flex-1 gap-0.5 overflow-x-auto px-1" role="group" aria-label="Period">218          {available.map((p) => (219            <button key={p.id} type="button" aria-pressed={period === p.id} onClick={() => setPeriodPersist(p.id)} className={`num h-8 min-w-[40px] shrink-0 rounded-md px-2 text-[12px] font-medium ${period === p.id ? 'bg-accent text-accent-fg' : 'text-muted hover:bg-inset hover:text-fg'}`}>220              {p.id}221            </button>222          ))}223        </div>224        <div className="flex shrink-0 items-center gap-1 text-[11px]">225          <button type="button" aria-pressed={canLog} onClick={() => setLog(!log)} disabled={!ysRaw.every((y) => y > 0)} className={`h-8 rounded-md border px-2 font-medium ${canLog ? 'border-fg bg-accent text-accent-fg' : 'border-border text-muted hover:text-fg'} disabled:opacity-40`} title="Logarithmic scale">226            Log227          </button>228        </div>229      </div>230231      {/* readout (touch-friendly: never under the finger) */}232      <div className="mb-1 flex min-h-[34px] flex-wrap items-center gap-x-3 gap-y-0.5 rounded-md bg-sunken px-2.5 py-1.5 text-[11px]" aria-live="polite">233        {readout ? (234          <>235            <span className="font-medium text-fg">{fmtDayFull(hDate!)}</span>236            {hSale ? (237              <span className="inline-flex items-center gap-1 text-muted">238                <i className="inline-block h-2 w-2 rounded-full" style={{ background: C.sales }} />239                Sale <b className="num font-semibold text-fg">{fmtFull(hSale.y)}</b>240                {hSale.label ? <span className="text-subtle">· {hSale.label}</span> : null}241              </span>242            ) : null}243            {hRiv ? (244              <span className="inline-flex items-center gap-1 text-muted">245                <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.riv }} />246                RIV <b className="num font-semibold text-fg">{fmtFull(hRiv.y)}</b>247                {hRiv.low != null && hRiv.high != null ? <span className="num text-subtle">({compactNumber(hRiv.low, { currency: true })}–{compactNumber(hRiv.high, { currency: true })})</span> : null}248              </span>249            ) : null}250            {hAsk ? (251              <span className="inline-flex items-center gap-1 text-muted">252                <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.ask }} />253                Ask <b className="num font-semibold text-fg">{fmtFull(hAsk.y)}</b>254              </span>255            ) : null}256            {hGuide ? (257              <span className="inline-flex items-center gap-1 text-muted">258                <i className="inline-block h-0.5 w-3 rounded-full" style={{ background: C.guide }} />259                Guide <b className="num font-semibold text-fg">{fmtFull(hGuide.y)}</b>260              </span>261            ) : null}262          </>263        ) : (264          <span className="text-subtle">265            {visAll.length ? <>Touch or hover the chart · {vis.sales.length.toLocaleString('en-US')} sales · {vis.riv.length.toLocaleString('en-US')} daily valuations{canLog ? ' · log scale' : ''}</> : 'No observations in this period'}266          </span>267        )}268      </div>269270      <svg271        width="100%"272        height={H}273        viewBox={`0 0 ${W} ${H}`}274        preserveAspectRatio="none"275        role="img"276        aria-label={ariaLabel}277        className="block max-w-full select-none"278        style={{ touchAction: 'pan-y' }}279        onPointerDown={(e) => onPointer(e, e.pointerType !== 'mouse')}280        onPointerMove={(e) => {281          if (e.pointerType === 'mouse' || e.buttons > 0) onPointer(e, e.pointerType !== 'mouse');282        }}283        onPointerLeave={(e) => {284          if (e.pointerType === 'mouse') setHover(null);285        }}286      >287        <defs>288          <clipPath id={`${uid}-clip`}>289            <rect x={pad.left - 6} y={0} width={iw + 12} height={H} />290          </clipPath>291        </defs>292        {ticks.map((t) => (293          <g key={t}>294            <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />295            <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">296              {compactNumber(t, { currency: true })}297            </text>298          </g>299        ))}300        {xTicks.map((t) => (301          <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">302            {fmtDay(t, span)}303          </text>304        ))}305        <g clipPath={`url(#${uid}-clip)`}>306          {bandPath ? <path d={bandPath} fill={C.riv} opacity={0.1} /> : null}307          {vis.guide.length >= 2 ? <path d={linePath(vis.guide)} fill="none" stroke={C.guide} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null}308          {vis.ask.length >= 2 ? <path d={linePath(vis.ask)} fill="none" stroke={C.ask} strokeWidth={2} strokeDasharray="4 4" strokeLinejoin="round" strokeLinecap="round" /> : null}309          {vis.riv.length >= 2 ? <path d={linePath(vis.riv)} fill="none" stroke={C.riv} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}310          {vis.riv.length === 1 ? <circle cx={sx(vis.riv[0]!.x)} cy={sy(vis.riv[0]!.y)} r={4} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}311          {reference ? (312            <g>313              <line x1={pad.left} x2={pad.left + iw} y1={sy(reference.value)} y2={sy(reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="2 3" />314              <text x={pad.left + 4} y={sy(reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">315                {reference.label}316              </text>317            </g>318          ) : null}319          {vis.sales.map((p, i) => (320            <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={vis.sales.length > 400 ? 3 : 4} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={vis.sales.length > 400 ? 1 : 1.5} opacity={0.9} />321          ))}322          {hDate !== null ? <line x1={sx(hDate)} x2={sx(hDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} /> : null}323          {hSale ? <circle cx={sx(hSale.x)} cy={sy(hSale.y)} r={6} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}324          {hRiv && hDate !== null ? <circle cx={sx(hRiv.x)} cy={sy(hRiv.y)} r={5} fill={C.riv} stroke="var(--ri-bg-elevated)" strokeWidth={2} /> : null}325        </g>326      </svg>327328      {series.length > 1 ? (329        <ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">330          {series.map((s) => (331            <li key={s.id} className="flex items-center gap-1.5">332              {s.kind === 'dots' ? <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} /> : s.kind === 'band' ? <span className="inline-block h-2.5 w-4 rounded-sm" style={{ background: s.color, opacity: 0.2 }} /> : <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.kind === 'dashed' ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />}333              {s.label}334            </li>335          ))}336        </ul>337      ) : null}338339      {/* accessible data table */}340      <table className="sr-only">341        <caption>{ariaLabel}</caption>342        <thead>343          <tr>344            <th>Date</th>345            <th>Series</th>346            <th>USD</th>347          </tr>348        </thead>349        <tbody>350          {vis.sales.slice(-200).map((p, i) => (351            <tr key={`s${i}`}>352              <td>{fmtDayFull(p.x)}</td>353              <td>Sale{p.label ? ` (${p.label})` : ''}</td>354              <td>{fmtFull(p.y)}</td>355            </tr>356          ))}357          {vis.riv.slice(-200).map((p, i) => (358            <tr key={`r${i}`}>359              <td>{fmtDayFull(p.x)}</td>360              <td>RIV</td>361              <td>{fmtFull(p.y)}</td>362            </tr>363          ))}364        </tbody>365      </table>366    </div>367  );368}369370function timeTicks(min: number, max: number, count: number): number[] {371  if (max <= min) return [min];372  const span = max - min;373  const day = 86_400_000;374  const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day, 3652 * day];375  const want = Math.max(3, count);376  // largest step that still yields at least `want` ticks; otherwise the smallest step377  let step = candidates[0]!;378  for (const c of candidates) if (span / c >= want - 1) step = c;379  const out: number[] = [];380  const start = Math.ceil(min / step) * step;381  for (let t = start; t <= max; t += step) out.push(t);382  while (out.length > count + 1) out.splice(1, 1); // thin if the step produced too many383  if (out.length < 2) return [min + span * 0.15, min + span * 0.5, min + span * 0.85];384  return out;385}386387function logTicks(min: number, max: number): number[] {388  const out: number[] = [];389  const lo = Math.floor(Math.log10(Math.max(min, 1e-9)));390  const hi = Math.ceil(Math.log10(Math.max(max, 1e-9)));391  for (let e = lo; e <= hi; e++) {392    for (const m of [1, 2, 5]) {393      const v = m * 10 ** e;394      if (v >= min && v <= max) out.push(v);395    }396  }397  if (out.length <= 5) return out;398  const n = out.length;399  return [0, 1, 2, 3, 4].map((i) => out[Math.round((i * (n - 1)) / 4)]!);400}401