SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
9.0 KB · 174 lines tsx
Raw Blame History
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