spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { AreaSeries, ColorType, createChart, HistogramSeries, LineSeries, type IChartApi, type ISeriesApi, type Time, type UTCTimestamp } from "lightweight-charts";4import { useEffect, useMemo, useRef, useState } from "react";5import { clientApi } from "@/lib/client-api";6import { cx, priceDecimals } from "@/lib/format";7import { useLiveQuote } from "@/lib/stream";8import type { Bar, Envelope } from "@/lib/types";910export type Range = "1D" | "5D" | "1M" | "3M" | "6M" | "YTD" | "1Y" | "MAX";11const RANGES: Range[] = ["1D", "5D", "1M", "3M", "6M", "YTD", "1Y", "MAX"];1213function rangeQuery(r: Range): { resolution: "1m" | "1h" | "1d"; from: Date | null; limit: number } {14 const now = new Date();15 const d = (n: number) => new Date(now.getTime() - n * 86_400_000);16 switch (r) {17 case "1D":18 return { resolution: "1m", from: d(1), limit: 1500 };19 case "5D":20 return { resolution: "1h", from: d(5), limit: 500 };21 case "1M":22 return { resolution: "1d", from: d(31), limit: 400 };23 case "3M":24 return { resolution: "1d", from: d(93), limit: 400 };25 case "6M":26 return { resolution: "1d", from: d(186), limit: 400 };27 case "YTD":28 return { resolution: "1d", from: new Date(Date.UTC(now.getUTCFullYear(), 0, 1)), limit: 400 };29 case "1Y":30 return { resolution: "1d", from: d(366), limit: 400 };31 default:32 return { resolution: "1d", from: null, limit: 5000 };33 }34}3536function cssVar(name: string): string {37 if (typeof window === "undefined") return "#000";38 return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || "#000";39}4041/**42 * Price chart backed by GET /v1/history/:id. Intraday (1D) is fed live from the stream;43 * daily history comes from end-of-day sources and is labelled as such. Never interpolates gaps.44 */45export function PriceChart({ instrumentId, assetClass, className, defaultRange = "1D", height = 340 }: { instrumentId: string; assetClass?: string | null; className?: string; defaultRange?: Range; height?: number }) {46 const [range, setRange] = useState<Range>(defaultRange);47 const [bars, setBars] = useState<Bar[] | null>(null);48 const [meta, setMeta] = useState<{ producers?: string[]; data_status?: string }>({});49 const [error, setError] = useState<string | null>(null);50 const [mode, setMode] = useState<"area" | "line">("area");51 const wrap = useRef<HTMLDivElement>(null);52 const chart = useRef<IChartApi | null>(null);53 const series = useRef<ISeriesApi<"Area" | "Line"> | null>(null);54 const volume = useRef<ISeriesApi<"Histogram"> | null>(null);55 const live = useLiveQuote(instrumentId);56 const q = useMemo(() => rangeQuery(range), [range]);5758 useEffect(() => {59 let alive = true;60 setBars(null);61 setError(null);62 const params = new URLSearchParams({ resolution: q.resolution, limit: String(q.limit) });63 if (q.from) params.set("from", q.from.toISOString());64 fetch(`/v1/history/${encodeURIComponent(instrumentId)}?${params}`)65 .then(async (r) => {66 const body = (await r.json()) as Envelope<Bar[]>;67 if (!r.ok) throw new Error("history unavailable");68 if (!alive) return;69 setBars(body.data);70 setMeta(body.meta as { producers?: string[]; data_status?: string });71 })72 .catch((e) => alive && setError(e instanceof Error ? e.message : "error"));73 return () => {74 alive = false;75 };76 }, [instrumentId, q]);7778 useEffect(() => {79 if (!wrap.current) return;80 const el = wrap.current;81 const c = createChart(el, {82 autoSize: true,83 layout: { background: { type: ColorType.Solid, color: "transparent" }, textColor: cssVar("--ink-3"), fontFamily: "var(--font-geist-mono), ui-monospace, monospace", fontSize: 11, attributionLogo: false },84 grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } },85 rightPriceScale: { borderColor: cssVar("--rule") },86 timeScale: { borderColor: cssVar("--rule"), timeVisible: true, secondsVisible: false },87 crosshair: { vertLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") }, horzLine: { color: cssVar("--rule-strong"), labelBackgroundColor: cssVar("--ink") } },88 handleScroll: true,89 handleScale: true,90 });91 chart.current = c;92 volume.current = c.addSeries(HistogramSeries, { priceScaleId: "vol", color: cssVar("--rule-strong"), priceFormat: { type: "volume" }, lastValueVisible: false, priceLineVisible: false });93 c.priceScale("vol").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });94 const obs = new MutationObserver(() => {95 c.applyOptions({ layout: { textColor: cssVar("--ink-3") }, grid: { vertLines: { color: cssVar("--rule") }, horzLines: { color: cssVar("--rule") } } });96 series.current?.applyOptions(seriesColors(mode));97 });98 obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });99 return () => {100 obs.disconnect();101 c.remove();102 chart.current = null;103 series.current = null;104 volume.current = null;105 };106 // eslint-disable-next-line react-hooks/exhaustive-deps107 }, []);108109 const seriesColors = (m: "area" | "line") => {110 const accent = cssVar("--accent");111 return m === "area" ? { lineColor: accent, topColor: accent + "55", bottomColor: accent + "05", lineWidth: 2 as const } : { color: accent, lineWidth: 2 as const };112 };113114 useEffect(() => {115 const c = chart.current;116 if (!c || !bars) return;117 if (series.current) c.removeSeries(series.current);118 const up = bars.length > 1 && bars[bars.length - 1]!.c >= bars[0]!.c;119 const color = cssVar(up ? "--positive" : "--negative");120 const s = mode === "area" ? c.addSeries(AreaSeries, { lineColor: color, topColor: color + "55", bottomColor: color + "05", lineWidth: 2, priceLineVisible: true, lastValueVisible: true }) : c.addSeries(LineSeries, { color, lineWidth: 2 });121 const decimals = bars.length ? priceDecimals(bars[bars.length - 1]!.c, assetClass) : 2;122 s.applyOptions({ priceFormat: { type: "price", precision: decimals, minMove: 10 ** -decimals } });123 s.setData(bars.map((b) => ({ time: (b.t / 1000) as UTCTimestamp, value: b.c })));124 volume.current?.setData(bars.filter((b) => b.v != null).map((b, i, arr) => ({ time: (b.t / 1000) as UTCTimestamp, value: b.v!, color: (i > 0 && b.c < arr[i - 1]!.c ? cssVar("--negative") : cssVar("--positive")) + "66" })));125 series.current = s;126 c.timeScale().fitContent();127 }, [bars, mode, assetClass]);128129 // Live intraday updates: extend/refresh the last 1-minute point from the stream (1D only).130 useEffect(() => {131 if (range !== "1D" || !live || live.price == null || !series.current || !bars) return;132 const minute = Math.floor(live.timestamp / 60_000) * 60;133 const lastBarSec = bars.length ? bars[bars.length - 1]!.t / 1000 : 0;134 if (minute < lastBarSec) return;135 try {136 series.current.update({ time: minute as Time as UTCTimestamp, value: live.price });137 } catch {138 /* out-of-order update ignored */139 }140 }, [live, range, bars]);141142 const producers = meta.producers ?? [];143 const label = producers.length === 0 ? null : producers.includes("consensus") && producers.length === 1 ? "Derived from Market Atlas consensus (1-minute bars)" : producers.includes("consensus") ? "Consensus intraday + end-of-day history" : "End-of-day history · licensed daily bars";144 return (145 <div className={cx("rounded-md border border-rule bg-surface", className)}>146 <div className="flex flex-wrap items-center justify-between gap-2 border-b border-rule px-2 py-1.5">147 <div className="flex gap-0.5">148 {RANGES.map((r) => (149 <button key={r} type="button" onClick={() => setRange(r)} className={cx("mono h-8 min-w-[40px] rounded px-2 text-xs", r === range ? "bg-ink text-canvas" : "text-ink-2 hover:bg-surface-2")}>150 {r}151 </button>152 ))}153 </div>154 <div className="flex items-center gap-2 text-[11px] text-ink-3">155 {label && <span className="hidden sm:inline">{label}</span>}156 <button type="button" onClick={() => setMode((m) => (m === "area" ? "line" : "area"))} className="h-8 rounded border border-rule px-2 hover:text-ink">157 {mode === "area" ? "line" : "area"}158 </button>159 </div>160 </div>161 <div className="relative" style={{ height }}>162 <div ref={wrap} className="absolute inset-0" />163 {bars && bars.length === 0 && (164 <div className="absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-ink-3">165 {range === "1D" || range === "5D" ? "No intraday history yet — Market Atlas records 1-minute bars from the moment an instrument is observed live." : "No daily history is available for this range."}166 </div>167 )}168 {!bars && !error && <div className="absolute inset-0 flex items-center justify-center text-sm text-ink-3">Loading history…</div>}169 {error && <div className="absolute inset-0 flex items-center justify-center text-sm text-negative">History unavailable.</div>}170 </div>171 </div>172 );173}174