TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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