'use client'; import { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale'; /** * Price-history chart for asset pages: verified sales (dots), daily RareIndex Valuation (line) with its * low–high band, lowest ask and guide observations (dashed), a current-RIV reference line, period * control (URL hash), log/linear toggle and a touch-first crosshair. Follows the dataviz spec: * 2px lines, ≥8px markers with a surface ring, hairline solid grid, ≤5 axis ticks, text in text tokens, * legend for ≥2 series, hidden data table for assistive tech. */ export interface SalePoint { date: string; usd: number; label?: string; variantId?: string | null; } export interface DailyPoint { x: string; y: number; low?: number | null; high?: number | null; } export type Period = '1M' | '3M' | '6M' | '1Y' | '3Y' | '5Y' | 'ALL'; const PERIODS: Array<{ id: Period; days: number | null }> = [ { id: '1M', days: 31 }, { id: '3M', days: 92 }, { id: '6M', days: 183 }, { id: '1Y', days: 366 }, { id: '3Y', days: 1096 }, { id: '5Y', days: 1827 }, { id: 'ALL', days: null }, ]; export interface PriceChartProps { sales: SalePoint[]; riv?: DailyPoint[]; ask?: DailyPoint[]; guide?: DailyPoint[]; reference?: { value: number; label: string } | null; height?: number; ariaLabel: string; emptyLabel?: string; /** URL hash key used to persist the period (default "p") */ hashKey?: string; defaultPeriod?: Period; className?: string; } const C = { riv: 'var(--ri-series-1, #2f5bd6)', sales: 'var(--ri-series-2, #c2410c)', ask: 'var(--ri-series-3, #0e9384)', guide: 'var(--ri-series-4, #7c3aed)', }; function writeHash(key: string, value: string) { if (typeof window === 'undefined') return; const parts = window.location.hash.replace(/^#/, '').split('&').filter((kv) => kv && !kv.startsWith(`${key}=`)); parts.push(`${key}=${encodeURIComponent(value)}`); window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${parts.join('&')}`); for (const l of hashListeners) l(); } const hashListeners = new Set<() => void>(); function subscribeHash(cb: () => void) { hashListeners.add(cb); window.addEventListener('hashchange', cb); return () => { hashListeners.delete(cb); window.removeEventListener('hashchange', cb); }; } export 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) { const wrap = useRef(null); const [width, setWidth] = useState(720); // Period/scale live in the URL hash (shareable, survives reloads). Read through an external store so // the server render (no hash) and the client stay consistent without effects. const hash = useSyncExternalStore(subscribeHash, () => (typeof window === 'undefined' ? '' : window.location.hash), () => ''); const hashPeriod = (hash.replace(/^#/, '').split('&').find((kv) => kv.startsWith(`${hashKey}=`))?.slice(hashKey.length + 1) ?? null) as Period | null; const period: Period = hashPeriod && PERIODS.some((x) => x.id === hashPeriod) ? hashPeriod : defaultPeriod; const scaleChoice = hash.includes('scale=log') ? 'log' : hash.includes('scale=linear') ? 'linear' : null; const [hover, setHover] = useState<{ px: number; sticky: boolean } | null>(null); const uid = useId(); useEffect(() => { const el = wrap.current; if (!el) return; const ro = new ResizeObserver((entries) => { for (const e of entries) setWidth(Math.max(280, Math.floor(e.contentRect.width))); }); ro.observe(el); return () => ro.disconnect(); }, []); const data = useMemo(() => { 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); 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); return { sales: s, riv: line(riv), ask: line(ask), guide: line(guide) }; }, [sales, riv, ask, guide]); const all = [...data.sales, ...data.riv, ...data.ask, ...data.guide]; const lastX = all.length ? Math.max(...all.map((p) => p.x)) : 0; const days = PERIODS.find((p) => p.id === period)?.days ?? null; const cutoff = days ? lastX - days * 86_400_000 : -Infinity; const vis = { sales: data.sales.filter((p) => p.x >= cutoff), riv: data.riv.filter((p) => p.x >= cutoff), ask: data.ask.filter((p) => p.x >= cutoff), guide: data.guide.filter((p) => p.x >= cutoff), }; const visAll = [...vis.sales, ...vis.riv, ...vis.ask, ...vis.guide]; const available = PERIODS.filter((p) => p.days === null || all.some((pt) => pt.x < lastX - p.days! * 86_400_000) || p.id === period); const setPeriodPersist = useCallback((p: Period) => writeHash(hashKey, p), [hashKey]); const setLog = (v: boolean) => writeHash('scale', v ? 'log' : 'linear'); if (all.length === 0) { return (
{emptyLabel}
); } // ---- scales const pad = { top: 14, right: 58, bottom: 26, left: 6 }; const W = width; const H = height; const iw = W - pad.left - pad.right; const ih = H - pad.top - pad.bottom; const xsAll = visAll.length ? visAll.map((p) => p.x) : [lastX]; let xMin = Math.min(...xsAll); let xMax = Math.max(...xsAll); if (days) xMin = Math.min(xMin, cutoff); if (xMax - xMin < 7 * 86_400_000) { xMin -= 15 * 86_400_000; xMax += 15 * 86_400_000; } 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] : []); const positive = ysRaw.filter((y) => y > 0); const spread = positive.length ? Math.max(...positive) / Math.min(...positive) : 1; const log = scaleChoice ? scaleChoice === 'log' : spread > 40; const canLog = log && ysRaw.every((y) => y > 0); const ty = (y: number) => (canLog ? Math.log10(y) : y); const fy = (v: number) => (canLog ? 10 ** v : v); let yMin = Math.min(...ysRaw.map(ty)); let yMax = Math.max(...ysRaw.map(ty)); if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { yMin = 0; yMax = 1; } if (yMin === yMax) { yMin -= Math.abs(yMin) * 0.05 || 1; yMax += Math.abs(yMax) * 0.05 || 1; } const yPad = (yMax - yMin) * 0.08; yMin -= yPad; yMax += yPad; const ticks = canLog ? logTicks(fy(yMin), fy(yMax)) : niceTicks(yMin, yMax, W < 480 ? 3 : 4); const sx = (x: number) => Math.round((pad.left + ((x - xMin) / (xMax - xMin)) * iw) * 100) / 100; const sy = (y: number) => Math.round((pad.top + ih - ((ty(y) - yMin) / (yMax - yMin)) * ih) * 100) / 100; const span = xMax - xMin; const xTicks = timeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 96)))); 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(' '); const band = vis.riv.filter((p) => p.low != null && p.high != null); 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; // ---- hover: nearest date across series const hoverX = hover ? xMin + ((hover.px - pad.left) / iw) * (xMax - xMin) : null; const nearest = (pts: T[]): T | null => { if (hoverX === null || !pts.length) return null; let best = pts[0]!; let bd = Infinity; for (const p of pts) { const d = Math.abs(p.x - hoverX); if (d < bd) { bd = d; best = p; } } return best; }; const hRiv = nearest(vis.riv); const hAsk = nearest(vis.ask); const hGuide = nearest(vis.guide); // sales: nearest within 3% of the span so a finger lands on a dot, not a random one far away const hSaleCand = nearest(vis.sales); const hSale = hSaleCand && hoverX !== null && Math.abs(hSaleCand.x - hoverX) <= Math.max(span * 0.03, 86_400_000) ? hSaleCand : null; 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; const onPointer = (e: React.PointerEvent, sticky: boolean) => { const rect = e.currentTarget.getBoundingClientRect(); const px = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; if (px >= pad.left - 8 && px <= pad.left + iw + 8) setHover({ px: Math.min(pad.left + iw, Math.max(pad.left, px)), sticky }); }; const series = [ vis.sales.length ? { id: 'sales', label: `Sales (${vis.sales.length})`, color: C.sales, kind: 'dots' as const } : null, vis.riv.length ? { id: 'riv', label: 'RIV (daily)', color: C.riv, kind: 'line' as const } : null, bandPath ? { id: 'band', label: 'RIV low–high', color: C.riv, kind: 'band' as const } : null, vis.ask.length ? { id: 'ask', label: 'Lowest ask', color: C.ask, kind: 'dashed' as const } : null, vis.guide.length ? { id: 'guide', label: 'Guide price', color: C.guide, kind: 'dashed' as const } : null, ].filter((s): s is NonNullable => Boolean(s)); const readout = hDate !== null; const fmtFull = (v: number) => fullNumber(v, { currency: true }); return (
{/* controls */}
{available.map((p) => ( ))}
{/* readout (touch-friendly: never under the finger) */}
{readout ? ( <> {fmtDayFull(hDate!)} {hSale ? ( Sale {fmtFull(hSale.y)} {hSale.label ? · {hSale.label} : null} ) : null} {hRiv ? ( RIV {fmtFull(hRiv.y)} {hRiv.low != null && hRiv.high != null ? ({compactNumber(hRiv.low, { currency: true })}–{compactNumber(hRiv.high, { currency: true })}) : null} ) : null} {hAsk ? ( Ask {fmtFull(hAsk.y)} ) : null} {hGuide ? ( Guide {fmtFull(hGuide.y)} ) : null} ) : ( {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'} )}
onPointer(e, e.pointerType !== 'mouse')} onPointerMove={(e) => { if (e.pointerType === 'mouse' || e.buttons > 0) onPointer(e, e.pointerType !== 'mouse'); }} onPointerLeave={(e) => { if (e.pointerType === 'mouse') setHover(null); }} > {ticks.map((t) => ( {compactNumber(t, { currency: true })} ))} {xTicks.map((t) => ( {fmtDay(t, span)} ))} {bandPath ? : null} {vis.guide.length >= 2 ? : null} {vis.ask.length >= 2 ? : null} {vis.riv.length >= 2 ? : null} {vis.riv.length === 1 ? : null} {reference ? ( {reference.label} ) : null} {vis.sales.map((p, i) => ( 400 ? 3 : 4} fill={C.sales} stroke="var(--ri-bg-elevated)" strokeWidth={vis.sales.length > 400 ? 1 : 1.5} opacity={0.9} /> ))} {hDate !== null ? : null} {hSale ? : null} {hRiv && hDate !== null ? : null} {series.length > 1 ? (
    {series.map((s) => (
  • {s.kind === 'dots' ? : s.kind === 'band' ? : } {s.label}
  • ))}
) : null} {/* accessible data table */} {vis.sales.slice(-200).map((p, i) => ( ))} {vis.riv.slice(-200).map((p, i) => ( ))}
{ariaLabel}
Date Series USD
{fmtDayFull(p.x)} Sale{p.label ? ` (${p.label})` : ''} {fmtFull(p.y)}
{fmtDayFull(p.x)} RIV {fmtFull(p.y)}
); } function timeTicks(min: number, max: number, count: number): number[] { if (max <= min) return [min]; const span = max - min; const day = 86_400_000; 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]; const want = Math.max(3, count); // largest step that still yields at least `want` ticks; otherwise the smallest step let step = candidates[0]!; for (const c of candidates) if (span / c >= want - 1) step = c; const out: number[] = []; const start = Math.ceil(min / step) * step; for (let t = start; t <= max; t += step) out.push(t); while (out.length > count + 1) out.splice(1, 1); // thin if the step produced too many if (out.length < 2) return [min + span * 0.15, min + span * 0.5, min + span * 0.85]; return out; } function logTicks(min: number, max: number): number[] { const out: number[] = []; const lo = Math.floor(Math.log10(Math.max(min, 1e-9))); const hi = Math.ceil(Math.log10(Math.max(max, 1e-9))); for (let e = lo; e <= hi; e++) { for (const m of [1, 2, 5]) { const v = m * 10 ** e; if (v >= min && v <= max) out.push(v); } } if (out.length <= 5) return out; const n = out.length; return [0, 1, 2, 3, 4].map((i) => out[Math.round((i * (n - 1)) / 4)]!); }