PriceChart
An intraday chart of one instrument: a line or candles over a store of bars, a crosshair moved by the pointer or arrow keys, the time axis in the venue's zone, and the last price with its signed change.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { barId, type Bar } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart } from "@/components/ui/price-chart"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const start = Date.parse("2026-09-22T14:00:00Z")
const minute = 60_000
const bars: Bar[] = [
{ time: start, open: 110.5, high: 110.53125, low: 110.484375, close: 110.515625, volume: 200 },
{ time: start + minute, open: 110.515625, high: 110.53125, low: 110.46875, close: 110.484375, volume: 100 },
{ time: start + 2 * minute, open: 110.484375, high: 110.546875, low: 110.484375, close: 110.53125, volume: 300 },
{ time: start + 3 * minute, open: 110.53125, high: 110.5625, low: 110.515625, close: 110.546875, volume: 200 },
{ time: start + 4 * minute, open: 110.546875, high: 110.546875, low: 110.5, close: 110.515625, volume: 400 },
{ time: start + 5 * minute, open: 110.515625, high: 110.578125, low: 110.515625, close: 110.5625, volume: 100 },
]
export default function PriceChartDemo() {
const [store] = useState(() => {
const store = createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" })
store.applyDeltas({ upsert: bars })
return store
})
return <div className="w-xl max-w-full"><PriceChart store={store} convention={ZN} label="ZN sample, one-minute bars" zone="America/Chicago" baseline={110.5} className="h-72" /></div>
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/price-chart#v1.4.13
npx shadcn@latest add tradecn/ui/price-chart#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/price-chart#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/price-chart#v1.4.13
-
Install the dependencies:
pnpm add cn uplotnpm install cn uplotyarn add cn uplotbun add cn uplot -
Copy the files into your project:
components/ui/ price-chart.tsx import "uplot/dist/uPlot.min.css" import { cn } from "cn" import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type KeyboardEvent } from "react" import uPlot from "uplot" import { directionClass, type Direction } from "@/hooks/use-flash" import { useStoreMeta } from "@/hooks/use-row-store" import { formatPercent, formatPrice, formatQuantity, NUMERIC_CLASS, numericFontClass, type InstrumentConvention, type PriceConvention } from "@/lib/format" import { columnsOf, dayFormatter, EMPTY_COLUMNS, formatChange, priceIncrements, priceOf, summarize, timeFormatter, type Bar, type BarColumns, type SeriesSummary } from "@/lib/price-series" import type { RowStore } from "@/lib/row-store" // An intraday price chart on uPlot: a line or candles over a store of bars, a crosshair the pointer and the // arrow keys both move, the time axis in the venue's zone, the last price printed with its sign and change, // and every color from the page's own tokens. // // The canvas cannot read a Tailwind class, so the tokens are read off the page when the chart mounts and // again when the class on <html> changes (a mode or a theme), and every stroke is a function uPlot asks at // draw time. The chart itself is an owned object: made in an effect once its box has a size and the store // has a bar, fed by setData once per applied batch, destroyed on the way out, never remade per update. // The store is read the way the grid's footer reads it, through useStoreMeta and a memo keyed on the batch // version, so the header commits in the same microtask flush as every other item on the screen; a plain // state set from the subscription would land a scheduler task later, which the bench caught. // Direction never rides on hue alone (contract rule 15): the last price prints its change with a sign, the // root carries data-direction, and the accessible name says the direction in a word. export type PriceChartKind = "line" | "candles" export interface PriceChartOverlay { id: string /** The word beside the swatch in the legend: what the line is, since its color alone says nothing. */ label: string /** One value per bar, in the bars' order; null leaves a gap. Called once per applied batch. */ values: (bars: readonly Bar[]) => readonly (number | null)[] /** Which chart token draws it, 1 to 8. Default: its place in the list. */ color?: number /** Line width in CSS px. Default 1. */ width?: number } export interface PriceChartLabels { noData: string open: string high: string low: string close: string volume: string overlays: string up: string down: string flat: string bars: string } export const DEFAULT_PRICE_CHART_LABELS: PriceChartLabels = { noData: "No data", open: "O", high: "H", low: "L", close: "C", volume: "V", overlays: "Overlays", up: "up", down: "down", flat: "flat", bars: "bars" } export interface PriceChartProps extends Omit<ComponentProps<"div">, "children"> { /** Bars keyed by `barId(time)`, fed with `foldTicks` or with whole bars. */ store: RowStore<Bar> /** Prints the prices on the axis, in the readout, and on the last-price tag, and sets the axis grid. */ convention: PriceConvention | InstrumentConvention /** What this is, for a screen reader: "ZN, today". The direction, last, change, and range are added. */ label: string /** A line through the closes, or a candle per bar. Default `line`. */ kind?: PriceChartKind /** A previous close: the change is measured from it and it is drawn as a dashed line. Without one, from the first bar's open. */ baseline?: number | null /** IANA zone for the time axis and the readout: the venue's. Default: the runtime's. */ zone?: string locale?: string /** Lines over the bars, each named in a legend and drawn in a chart token. */ overlays?: readonly PriceChartOverlay[] /** The crosshair, moved by the pointer and by the arrow keys. Default true. */ crosshair?: boolean /** The dashed line and the tag at the last close. Default true. */ lastLine?: boolean /** Height in px. Without it the chart fills its box, `h-64` unless your className says otherwise. */ height?: number labels?: Partial<PriceChartLabels> /** The bar under the crosshair, or null when it leaves. */ onCursor?: (bar: Bar | null) => void } /** The eight chart tokens as utilities, in Okabe and Ito's order, for a legend swatch or a consumer's own key. */ export const CHART_TOKEN_CLASS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5", "bg-chart-6", "bg-chart-7", "bg-chart-8"] as const const CHART_TOKENS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5", "--chart-6", "--chart-7", "--chart-8"] const FALLBACK_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" interface Palette { up: string down: string flat: string upSoft: string downSoft: string flatSoft: string foreground: string mutedForeground: string border: string background: string chart: string[] /** The mono stack, for the axis and the tag: tabular by construction, since a canvas has no numeric variant. */ font: string } /** The tokens the canvas draws with, resolved through a probe element so a var() chain and a mode both come out as one color. */ function readPalette(host: HTMLElement): Palette { const probe = document.createElement("i") probe.hidden = true host.append(probe) const read = (token: string) => { probe.style.color = `var(${token})` return getComputedStyle(probe).color } const palette: Palette = { up: read("--up"), down: read("--down"), flat: read("--flat"), upSoft: read("--up-soft"), downSoft: read("--down-soft"), flatSoft: read("--flat-soft"), foreground: read("--foreground"), mutedForeground: read("--muted-foreground"), border: read("--border"), background: read("--background"), chart: CHART_TOKENS.map(read), font: getComputedStyle(host).getPropertyValue("--tradecn-font-mono").trim() || FALLBACK_MONO, } probe.remove() return palette } /** Before the page has been read: grays that never reach the canvas, since the plot is made only after readPalette ran. */ const UNREAD_PALETTE: Palette = { up: "gray", down: "gray", flat: "gray", upSoft: "gray", downSoft: "gray", flatSoft: "gray", foreground: "gray", mutedForeground: "gray", border: "gray", background: "white", chart: CHART_TOKENS.map(() => "gray"), font: FALLBACK_MONO } const tone = (p: Palette, dir: Direction) => (dir === "up" ? p.up : dir === "down" ? p.down : p.flat) const soft = (p: Palette, dir: Direction) => (dir === "up" ? p.upSoft : dir === "down" ? p.downSoft : p.flatSoft) let drawable: boolean | null = null /** Whether this document can draw on a canvas at all. A test environment without a 2D context gets the readout and the keys and no plot. */ function canDraw(): boolean { if (drawable === null) drawable = typeof document !== "undefined" && document.createElement("canvas").getContext("2d") !== null return drawable } /** uPlot's columns for the kind: time and closes for a line, time and all four for candles, then one column per overlay. */ function alignedData(columns: BarColumns, kind: PriceChartKind, overlays: readonly PriceChartOverlay[]): uPlot.AlignedData { const extra = overlays.map((overlay) => { const values = overlay.values(columns.bars) // The column must be exactly as long as the time column, whatever the overlay returned. return columns.time.map((_, i) => { const v = values[i] return typeof v === "number" && Number.isFinite(v) ? v : null }) }) return kind === "candles" ? [columns.time, columns.open, columns.high, columns.low, columns.close, ...extra] : [columns.time, columns.close, ...extra] } interface Live { palette: Palette columns: BarColumns summary: SeriesSummary baseline: number | null } interface PlotArgs { width: number height: number kind: PriceChartKind crosshair: boolean lastLine: boolean zone: string | undefined convention: PriceConvention | InstrumentConvention overlays: readonly PriceChartOverlay[] live: () => Live onCursor: (index: number | null) => void } const noPaths = () => null function plotOptions(a: PlotArgs): uPlot.Options { const price = priceOf(a.convention) const format = (v: number) => formatPrice(v, price) const font = (px: number) => `${12 * px}px ${a.live().palette.font}` const axisStroke = () => a.live().palette.mutedForeground const gridStroke = () => a.live().palette.border // The time axis in the zone, 24-hour, with the day in front once the range runs past one. const clock = timeFormatter(a.zone, undefined, false) const day = dayFormatter(a.zone) const timeValues: uPlot.Axis.Values = (u, splits) => { const longer = (u.scales.x?.max ?? 0) - (u.scales.x?.min ?? 0) > DAY_MS return splits.map((ts) => (longer ? `${day(ts)} ${clock(ts)}` : clock(ts))) } const priceAxisSize: uPlot.Axis.Size = (u, values, _axis, cycle) => { if (cycle > 1 || !values) return 64 u.ctx.font = font(uPlot.pxRatio) let widest = 0 for (const v of values) widest = Math.max(widest, u.ctx.measureText(v).width) return Math.ceil(widest / uPlot.pxRatio) + 18 } const overlaySeries: uPlot.Series[] = a.overlays.map((overlay, i) => ({ label: overlay.label, stroke: () => a.live().palette.chart[Math.min(8, Math.max(1, overlay.color ?? i + 1)) - 1]!, width: overlay.width ?? 1, points: { show: false }, spanGaps: false, })) const series: uPlot.Series[] = a.kind === "candles" ? [{}, { paths: noPaths, points: { show: false } }, { paths: noPaths, points: { show: false } }, { paths: noPaths, points: { show: false } }, { paths: noPaths, points: { show: false } }, ...overlaySeries] : [{}, { stroke: () => tone(a.live().palette, a.live().summary.direction), fill: () => soft(a.live().palette, a.live().summary.direction), width: 1.5, points: { show: false }, spanGaps: false }, ...overlaySeries] return { width: a.width, height: a.height, ms: 1, // The option wants a timestamp-to-Date function, and uPlot.tzDate(date, zone) returns the Date read in that zone; with ms: 1 the timestamp is milliseconds. ...(a.zone ? { tzDate: (ts: number) => uPlot.tzDate(new Date(ts), a.zone!) } : {}), legend: { show: false }, select: { show: false, left: 0, top: 0, width: 0, height: 0 }, cursor: a.crosshair ? { x: true, y: true, lock: false, drag: { x: false, y: false, setScale: false }, focus: { prox: -1 }, points: { // A dot on the line's close under the crosshair; nothing on an overlay or a candle. size: (_u, si) => (a.kind === "line" && si === 1 ? 7 : 0), width: 1, stroke: () => a.live().palette.background, fill: () => tone(a.live().palette, a.live().summary.direction), }, } : { show: false }, scales: { x: { time: true }, y: { range: (_u, min, max) => { const b = a.live().baseline let lo = min let hi = max if (b !== null) { lo = Math.min(lo, b) hi = Math.max(hi, b) } if (!(hi > lo)) { const step = priceIncrements(a.convention)[0] ?? 1 lo -= step hi += step } const pad = (hi - lo) * 0.08 return [lo - pad, hi + pad] }, }, }, axes: [ { stroke: axisStroke, font: font(1), grid: { stroke: gridStroke, width: 1 }, ticks: { stroke: gridStroke, width: 1, size: 4 }, space: 80, gap: 6, size: 26, values: timeValues }, { side: 1, stroke: axisStroke, font: font(1), grid: { stroke: gridStroke, width: 1 }, ticks: { stroke: gridStroke, width: 1, size: 4 }, space: 28, gap: 6, incrs: priceIncrements(a.convention), values: (_u, splits) => splits.map(format), size: priceAxisSize }, ], series, hooks: { init: [ (u) => { // The canvas and its overlays are one picture; the words are on the element around them. u.root.setAttribute("aria-hidden", "true") styleCursor(u, a.live().palette) }, ], setCursor: [(u) => a.onCursor(u.cursor.idx ?? null)], draw: [(u) => drawMarks(u, a)], }, } } /** uPlot's stylesheet colors the crosshair lines itself; the page's border token goes on inline, where it wins. */ function styleCursor(u: uPlot, palette: Palette) { for (const line of u.over.querySelectorAll<HTMLElement>(".u-cursor-x, .u-cursor-y")) line.style.borderColor = palette.mutedForeground } /** Candles for that kind, the baseline, and the last close's line and tag, drawn after the series. */ function drawMarks(u: uPlot, a: PlotArgs) { const { palette, summary, baseline } = a.live() const ctx = u.ctx const px = uPlot.pxRatio const { left, top, width, height } = u.bbox const yOf = (v: number) => u.valToPos(v, "y", true) const dashed = (y: number, color: string) => { if (y < top || y > top + height) return ctx.save() ctx.beginPath() ctx.setLineDash([4 * px, 4 * px]) ctx.lineWidth = px ctx.strokeStyle = color ctx.moveTo(left, Math.round(y) + 0.5 * px) ctx.lineTo(left + width, Math.round(y) + 0.5 * px) ctx.stroke() ctx.restore() } ctx.save() ctx.beginPath() ctx.rect(left, top, width, height) ctx.clip() if (a.kind === "candles") drawCandles(u, palette, px) if (baseline !== null) dashed(yOf(baseline), palette.border) const last = summary.last if (a.lastLine && last) dashed(yOf(last.close), tone(palette, summary.direction)) ctx.restore() if (a.lastLine && last) drawTag(u, formatPrice(last.close, priceOf(a.convention)), yOf(last.close), tone(palette, summary.direction), palette, px) } function drawCandles(u: uPlot, palette: Palette, px: number) { const ctx = u.ctx const [xs, opens, highs, lows, closes] = u.data if (!opens || !highs || !lows || !closes) return const [i0, i1] = u.series[0]?.idxs ?? [0, xs.length - 1] const xOf = (i: number) => u.valToPos(xs[i]!, "x", true) // Bars sit on one interval, so the room a candle has is the distance to its neighbour. const slot = i1 > i0 ? Math.abs(xOf(i0 + 1) - xOf(i0)) : u.bbox.width / 4 const body = Math.max(px, Math.floor(slot * 0.66)) const wick = Math.max(1, Math.round(px)) for (let i = i0; i <= i1; i++) { const o = opens[i] const h = highs[i] const l = lows[i] const c = closes[i] if (o == null || h == null || l == null || c == null) continue const x = Math.round(xOf(i)) const yo = u.valToPos(o, "y", true) const yc = u.valToPos(c, "y", true) ctx.fillStyle = c > o ? palette.up : c < o ? palette.down : palette.flat const yh = u.valToPos(h, "y", true) ctx.fillRect(x - wick / 2, yh, wick, Math.max(px, u.valToPos(l, "y", true) - yh)) ctx.fillRect(x - body / 2, Math.min(yo, yc), body, Math.max(px, Math.abs(yo - yc))) } } /** The last price on the price axis, in a box of its direction's color, over the tick it would have had. */ function drawTag(u: uPlot, text: string, y: number, fill: string, palette: Palette, px: number) { const { left, top, width, height } = u.bbox if (y < top || y > top + height) return const ctx = u.ctx ctx.save() ctx.font = `${12 * px}px ${palette.font}` const h = 16 * px const w = ctx.measureText(text).width + 8 * px const x = left + width + 1 * px ctx.fillStyle = fill ctx.fillRect(x, Math.round(y - h / 2), w, h) ctx.fillStyle = palette.background ctx.textAlign = "left" ctx.textBaseline = "middle" ctx.fillText(text, x + 4 * px, y) ctx.restore() } const clamp = (n: number, max: number) => Math.max(0, Math.min(max, n)) const DAY_MS = 86_400_000 export function PriceChart({ store, convention, label, kind = "line", baseline = null, zone, locale, overlays, crosshair = true, lastLine = true, height, labels: labelsProp, onCursor, className, style, ...props }: PriceChartProps) { const labels = { ...DEFAULT_PRICE_CHART_LABELS, ...labelsProp } const ref = typeof baseline === "number" && Number.isFinite(baseline) ? baseline : null const overlayList = useMemo(() => overlays ?? [], [overlays]) // Structure, not identity: an inline overlays array is a new object per render and must not remake the plot. const overlayKey = overlayList.map((o) => `${o.id}:${o.color ?? ""}:${o.width ?? ""}`).join("|") const conventionKey = JSON.stringify(convention) const [plotEl, setPlotEl] = useState<HTMLDivElement | null>(null) const [size, setSize] = useState<{ width: number; height: number } | null>(null) const [cursor, setCursorState] = useState<number | null>(null) // Bumped when the page's mono stack changes (the accessibility remap): the axis font is fixed at construction, so the plot is remade. const [fontEpoch, setFontEpoch] = useState(0) // The store's columns, once per applied batch: the meta's version is the one dependency, so the memo reruns on // a batch and on nothing else, and a batch with no bar change gives the same columns back. const meta = useStoreMeta(store) const columns = useMemo(() => { void meta.version return columnsOf(store) }, [store, meta.version]) const summary = useMemo(() => summarize(columns, ref), [columns, ref]) const plot = useRef<uPlot | null>(null) const live = useRef<Live>({ palette: UNREAD_PALETTE, columns: EMPTY_COLUMNS, summary, baseline: ref }) const overlaysRef = useRef(overlayList) const conventionRef = useRef(convention) const onCursorRef = useRef(onCursor) const cursorFromPlot = useRef(false) /** Moves the crosshair and tells the consumer, from a key, the focus, or the plot's own pointer; never on mount. */ const moveCursor = (index: number | null, fromPlot: boolean) => { cursorFromPlot.current = fromPlot setCursorState((previous) => { if (previous === index) return previous onCursorRef.current?.(index === null ? null : (live.current.columns.bars[index] ?? null)) return index }) } useLayoutEffect(() => { overlaysRef.current = overlayList conventionRef.current = convention onCursorRef.current = onCursor }) useLayoutEffect(() => { live.current.summary = summary live.current.baseline = ref plot.current?.redraw(false, false) }, [summary, ref]) // The plot takes the columns before the browser paints, so the picture and the header move in one frame. useLayoutEffect(() => { live.current.columns = columns plot.current?.setData(alignedData(columns, kind, overlaysRef.current), true) // overlayKey stands for the overlays' structure; their functions are read through the ref. }, [columns, kind, overlayKey]) // The box's size, from a ResizeObserver; nothing is drawn before the first measurement. useLayoutEffect(() => { if (!plotEl) return const observer = new ResizeObserver(([entry]) => { const rect = entry?.contentRect if (rect && rect.width > 0 && rect.height > 0) setSize((s) => (s && s.width === rect.width && s.height === rect.height ? s : { width: rect.width, height: rect.height })) }) observer.observe(plotEl) return () => observer.disconnect() }, [plotEl]) // The plot: made once the box has a size and the store a bar, remade for a new kind, zone, or convention, never per update. const ready = size !== null && columns.bars.length > 0 useEffect(() => { if (!plotEl || !ready || !canDraw()) return live.current.palette = readPalette(plotEl) const box = plotEl.getBoundingClientRect() const u = new uPlot( plotOptions({ width: box.width, height: box.height, kind, crosshair, lastLine, zone, convention: conventionRef.current, overlays: overlaysRef.current, live: () => live.current, onCursor: (index) => moveCursor(index, true), }), alignedData(live.current.columns, kind, overlaysRef.current), plotEl, ) plot.current = u // A mode or a theme is a class on <html> and the accessibility remap a data attribute; the tokens are read again // and the picture redrawn, or, when the font stack itself changed, the plot remade with the new axis font. const observer = new MutationObserver(() => { const next = readPalette(plotEl) const fontChanged = next.font !== live.current.palette.font live.current.palette = next if (fontChanged) { setFontEpoch((n) => n + 1) return } styleCursor(u, next) u.redraw(false, false) }) observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-accessibility"] }) return () => { observer.disconnect() u.destroy() plot.current = null } // conventionKey and overlayKey stand for the objects' structure; the objects themselves are read through refs, so an inline one does not remake the plot every render. }, [plotEl, ready, kind, crosshair, lastLine, zone, conventionKey, overlayKey, fontEpoch]) useEffect(() => { if (size && plot.current) plot.current.setSize(size) }, [size]) // The keyboard's cursor reaches the plot; the pointer's came from it and stays where the hand put it. useEffect(() => { const u = plot.current if (!u) return if (cursorFromPlot.current) { cursorFromPlot.current = false return } const bar = cursor === null ? undefined : live.current.columns.bars[cursor] if (!bar) u.setCursor({ left: -10, top: -10 }) else u.setCursor({ left: u.valToPos(bar.time, "x"), top: u.valToPos(bar.close, "y") }) }, [cursor]) const count = columns.bars.length const at = cursor === null ? null : (columns.bars[clamp(cursor, count - 1)] ?? null) const price = priceOf(convention) const time = timeFormatter(zone, locale) const direction = summary.direction const word = labels[direction] const last = summary.last const readout = at ? `${time(at.time)} ${kind === "candles" ? `${labels.open} ${formatPrice(at.open, price)} ${labels.high} ${formatPrice(at.high, price)} ${labels.low} ${formatPrice(at.low, price)} ${labels.close} ${formatPrice(at.close, price)}` : formatPrice(at.close, price)}${typeof at.volume === "number" ? ` ${labels.volume} ${formatQuantity(at.volume)}` : ""}` : "" const sentence = last ? `${label}: ${word}, last ${formatPrice(last.close, price)}, ${formatChange(summary.change, convention)} (${formatPercent(summary.changePct, { signed: true })}), low ${formatPrice(summary.low, price)}, high ${formatPrice(summary.high, price)}, ${count} ${labels.bars}` : `${label}: ${labels.noData}` const interactive = crosshair && count > 0 function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { if (event.defaultPrevented || !interactive || event.metaKey || event.ctrlKey || event.altKey) return const from = cursor === null ? count - 1 : clamp(cursor, count - 1) // The slider pattern's two pairs: Right and Up go forward a bar, Left and Down back one. const to = { ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: from + 1, ArrowUp: from + 1, PageDown: from - 10, PageUp: from + 10, Home: 0, End: count - 1 }[event.key] if (to === undefined && event.key !== "Escape") return // Claimed, so a hotkey registry or a panel further out leaves the key alone. event.preventDefault() moveCursor(to === undefined ? null : clamp(to, count - 1), false) } return ( <div {...props} data-slot="tradecn-price-chart" data-kind={kind} data-direction={direction} data-empty={count === 0 ? "" : undefined} role="group" aria-label={label} className={cn("flex h-64 w-full min-h-0 flex-col gap-1 text-xs text-foreground lining-nums tabular-nums", className)} style={height === undefined ? style : { ...style, height }} > <div data-chart-header="" className={cn("flex min-h-5 shrink-0 flex-wrap items-baseline gap-x-3 gap-y-0.5 px-1", numericFontClass(convention))}> <span data-chart-last="" className={cn("text-sm font-semibold", directionClass(direction))}> {last ? formatPrice(last.close, price) : labels.noData} </span> {last && ( <span data-chart-change="" className={cn(directionClass(direction))}> {formatChange(summary.change, convention)} <span className={NUMERIC_CLASS}>({formatPercent(summary.changePct, { signed: true })})</span> </span> )} <span data-chart-readout="" className="ml-auto text-muted-foreground"> {readout} </span> </div> <div ref={setPlotEl} role={interactive ? "slider" : "img"} aria-label={sentence} {...(interactive ? { tabIndex: 0, "aria-orientation": "horizontal" as const, "aria-valuemin": 0, "aria-valuemax": count - 1, "aria-valuenow": cursor === null ? count - 1 : clamp(cursor, count - 1), "aria-valuetext": readout || sentence } : {})} onKeyDown={onKeyDown} onFocus={() => { if (interactive && cursor === null) moveCursor(count - 1, false) }} onBlur={() => moveCursor(null, false)} data-chart-plot="" className={cn("relative min-h-0 flex-1 overflow-hidden rounded-sm outline-none", interactive && "cursor-crosshair focus-visible:ring-2 focus-visible:ring-ring/50")} > {count === 0 && ( <p data-chart-empty="" className="absolute inset-0 flex items-center justify-center text-muted-foreground"> {labels.noData} </p> )} </div> {overlayList.length > 0 && ( <ul data-chart-legend="" aria-label={labels.overlays} className="flex shrink-0 flex-wrap gap-x-3 gap-y-0.5 px-1 text-muted-foreground"> {overlayList.map((overlay, i) => ( <li key={overlay.id} className="flex items-center gap-1"> <span aria-hidden className={cn("inline-block size-2 rounded-full", CHART_TOKEN_CLASS[Math.min(8, Math.max(1, overlay.color ?? i + 1)) - 1])} /> {overlay.label} </li> ))} </ul> )} </div> ) }lib/price-series.ts // Bars for a price chart and the arithmetic around them, with no React and no canvas in it. // // A bar is a row in a row store keyed by its start time (`barId`), so a feed of ticks folds into the // open bar with `foldTicks` and lands once per frame like every other store, and the chart reads the // store's columns once per applied batch. A tick that belongs to a bar the feed already moved past // folds into that bar, so a late print is a correction to one row, never a row out of order. import type { Direction } from "@/hooks/use-flash" import { decimalsFromTick, formatPrice, formatSigned, NULL_TOKEN, type InstrumentConvention, type Nullable, type PriceConvention } from "@/lib/format" import type { DeltaBatch, RowId, RowStore } from "@/lib/row-store" export interface Bar { /** When the bar opened, ms since the epoch. Its row id is `barId(time)`. */ time: number open: number high: number low: number close: number /** Size traded in the bar; null or absent when the feed has none. */ volume?: number | null /** * When the earliest and the latest prints folded into the bar happened. `foldTick` writes them, so a print * that arrives late corrects the high, the low, and the volume, and moves the open or the close only when it * is the earliest or the latest print the bar has seen. A bar that came in whole has neither and keeps its open. */ firstAt?: number lastAt?: number } export interface PriceTick { /** When it printed, ms since the epoch. */ at: number price: number /** Size, when the feed has one. */ size?: number | null } /** The row id of a bar: its start time as a string. */ export const barId = (time: number): RowId => String(time) /** The start of the bar a moment falls in, on a grid of `intervalMs` from the epoch. */ export function barStart(at: number, intervalMs: number): number { return Math.floor(at / intervalMs) * intervalMs } /** * A tick folded into the bar it belongs to: a new bar when none is open at its start, else that bar with the * high, the low, and the volume extended, the close moved if the tick is the latest the bar has seen, and the * open moved if it is the earliest. So a late print corrects the bar and never rewrites its close. */ export function foldTick(bar: Bar | undefined, tick: PriceTick, intervalMs: number): Bar { const time = barStart(tick.at, intervalMs) const size = typeof tick.size === "number" && Number.isFinite(tick.size) ? tick.size : null if (!bar || bar.time !== time) return { time, open: tick.price, high: tick.price, low: tick.price, close: tick.price, volume: size, firstAt: tick.at, lastAt: tick.at } const volume = size === null ? (bar.volume ?? null) : (bar.volume ?? 0) + size const earliest = bar.firstAt !== undefined && tick.at < bar.firstAt const latest = bar.lastAt === undefined || tick.at >= bar.lastAt return { ...bar, open: earliest ? tick.price : bar.open, high: Math.max(bar.high, tick.price), low: Math.min(bar.low, tick.price), close: latest ? tick.price : bar.close, volume, ...(bar.firstAt !== undefined ? { firstAt: Math.min(bar.firstAt, tick.at) } : {}), lastAt: bar.lastAt === undefined ? tick.at : Math.max(bar.lastAt, tick.at), } } /** * The batch that folds a frame's ticks into a store of bars: one upsert per bar touched, each bar read from the * store first so the fold continues where the last batch left it. The ticks are folded in time order whatever * order they arrived in, so a shuffled frame folds as it happened. Apply it with `applyDeltas`. */ export function foldTicks(store: RowStore<Bar>, ticks: readonly PriceTick[], intervalMs: number): DeltaBatch<Bar> { const bars = new Map<number, Bar>() const ordered = ticks.filter((tick) => Number.isFinite(tick.price) && Number.isFinite(tick.at)).sort((a, b) => a.at - b.at) for (const tick of ordered) { const time = barStart(tick.at, intervalMs) bars.set(time, foldTick(bars.get(time) ?? store.getRow(barId(time)), tick, intervalMs)) } return { upsert: [...bars.values()] } } /** The store's bars as the columns a canvas wants, in time order. */ export interface BarColumns { bars: readonly Bar[] time: number[] open: number[] high: number[] low: number[] close: number[] volume: (number | null)[] } export const EMPTY_COLUMNS: BarColumns = { bars: [], time: [], open: [], high: [], low: [], close: [], volume: [] } const finiteBar = (bar: Bar | undefined): bar is Bar => bar !== undefined && [bar.time, bar.open, bar.high, bar.low, bar.close].every(Number.isFinite) /** * Every finite bar in the store, in time order. The store's own order is trusted while it is already by time, * which a feed that appends is; a backfill that arrived out of order is sorted, once, here. */ export function columnsOf(store: RowStore<Bar>): BarColumns { const ids = store.getIds() let bars: Bar[] = [] let sorted = true for (const id of ids) { const bar = store.getRow(id) if (!finiteBar(bar)) continue if (bars.length && bar.time < bars[bars.length - 1]!.time) sorted = false bars.push(bar) } if (!sorted) bars = bars.sort((a, b) => a.time - b.time) if (!bars.length) return EMPTY_COLUMNS const n = bars.length const out: BarColumns = { bars, time: new Array<number>(n), open: new Array<number>(n), high: new Array<number>(n), low: new Array<number>(n), close: new Array<number>(n), volume: new Array<number | null>(n) } for (let i = 0; i < n; i++) { const b = bars[i]! out.time[i] = b.time out.open[i] = b.open out.high[i] = b.high out.low[i] = b.low out.close[i] = b.close out.volume[i] = typeof b.volume === "number" && Number.isFinite(b.volume) ? b.volume : null } return out } /** What the chart says about its series in words and numbers: the last close against a reference, the range, the count. */ export interface SeriesSummary { count: number first: Bar | null last: Bar | null /** The lowest low and the highest high over every bar; 0 with no bars. */ low: number high: number /** What the change is measured from: the baseline when given, else the first bar's open. */ reference: number | null change: number | null changePct: number | null direction: Direction } export const EMPTY_SUMMARY: SeriesSummary = { count: 0, first: null, last: null, low: 0, high: 0, reference: null, change: null, changePct: null, direction: "flat" } /** The direction of `to` against `from`: equal is flat, never up, the flash cell's rule. */ export function directionBetween(from: number, to: number): Direction { return to > from ? "up" : to < from ? "down" : "flat" } /** The summary of some columns against a baseline (a previous close) or, without one, against the first bar's open. */ export function summarize(columns: BarColumns, baseline?: number | null): SeriesSummary { const n = columns.bars.length if (!n) return EMPTY_SUMMARY let low = Infinity let high = -Infinity for (let i = 0; i < n; i++) { if (columns.low[i]! < low) low = columns.low[i]! if (columns.high[i]! > high) high = columns.high[i]! } const first = columns.bars[0]! const last = columns.bars[n - 1]! const reference = typeof baseline === "number" && Number.isFinite(baseline) ? baseline : first.open const change = last.close - reference return { count: n, first, last, low, high, reference, change, changePct: reference === 0 ? null : (change / Math.abs(reference)) * 100, direction: directionBetween(reference, last.close) } } /** The price convention of either shape: an instrument's, or a bare one. */ export function priceOf(convention: PriceConvention | InstrumentConvention): PriceConvention { return "price" in convention ? convention.price : convention } /** The smallest step a convention prints: the instrument's tick, a fraction's 32nd or 64th, a decimal's last place. */ export function priceStep(convention: PriceConvention | InstrumentConvention): number { if ("price" in convention && convention.tick > 0) return convention.tick const price = priceOf(convention) switch (price.kind) { case "fraction": return 1 / price.denominator case "tick": return price.tick case "decimal": return 10 ** -price.decimals } } /** * The steps a price axis may split on, from the convention's grid up: a fraction doubles (a 32nd, a 16th, * an 8th, a quarter, a half, a point, two points, ...), so every label prints as a clean fraction; a decimal * goes 1, 2, 5 per decade. In price units, for a canvas axis that picks the smallest step that fits. */ export function priceIncrements(convention: PriceConvention | InstrumentConvention): number[] { const step = priceStep(convention) const out: number[] = [] if (priceOf(convention).kind === "fraction") { for (let i = 0; i < 20; i++) out.push(step * 2 ** i) } else { for (let decade = 0; decade < 9; decade++) for (const m of [1, 2, 5]) out.push(step * m * 10 ** decade) } // Float noise off the doubling and the decades, so 1/64 * 64 is 1, not 0.9999... return out.map((v) => Number(v.toPrecision(12))) } /** How many decimals a change in this convention prints with: a decimal's own, a tick's derived, none for a fraction (which prints as a fraction). */ export function priceDecimals(convention: PriceConvention | InstrumentConvention): number { const price = priceOf(convention) return price.kind === "decimal" ? price.decimals : price.kind === "tick" ? decimalsFromTick(price.tick) : 0 } const MINUS = "−" /** A change in price with its sign: "+0-02+" for a fraction, "+0.125" for a decimal, "0-00" or "0.00" for none. */ export function formatChange(change: Nullable, convention: PriceConvention | InstrumentConvention): string { if (change === null || change === undefined || !Number.isFinite(change)) return NULL_TOKEN const price = priceOf(convention) if (price.kind !== "fraction") return formatSigned(change, { decimals: priceDecimals(convention) }) const sign = change > 0 ? "+" : change < 0 ? MINUS : "" return `${sign}${formatPrice(Math.abs(change), price)}` } const timeFormats = new Map<string, Intl.DateTimeFormat>() const dayFormats = new Map<string, Intl.DateTimeFormat>() /** A formatter for a zone, cached by its options, falling back to the runtime's zone for one the runtime does not know. */ function zonedFormat(cache: Map<string, Intl.DateTimeFormat>, key: string, locale: string | undefined, options: Intl.DateTimeFormatOptions, zone: string | undefined): Intl.DateTimeFormat { let format = cache.get(key) if (!format) { try { format = new Intl.DateTimeFormat(locale ?? "en-US", { ...options, timeZone: zone }) } catch { format = new Intl.DateTimeFormat(locale ?? "en-US", options) } cache.set(key, format) } return format } /** * A clock reading in a zone, `14:32:05`, in the 24-hour cycle whatever the locale's habit. A zone the runtime * does not know falls back to the runtime's own zone rather than throwing, since a chart must still draw. */ export function timeFormatter(zone?: string, locale?: string, seconds = true): (ms: number) => string { const format = zonedFormat(timeFormats, `${zone ?? ""}|${locale ?? ""}|${seconds ? "s" : "m"}`, locale, { hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}), hourCycle: "h23" }, zone) return (ms) => format.format(ms) } /** The month and day in the zone, `01/15`, for an axis whose range runs past one day. Same fallback as `timeFormatter`. */ export function dayFormatter(zone?: string, locale?: string): (ms: number) => string { const format = zonedFormat(dayFormats, `${zone ?? ""}|${locale ?? ""}`, locale, { month: "2-digit", day: "2-digit" }, zone) return (ms) => format.format(ms) }hooks/use-row-store.ts import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view this component owns: made for the options given, disposed on the way out, and made again for * new options. Keep the options object's identity stable (a `useMemo`), since a new object is a new view. * Null options give null, for a component that takes a view from its props instead. * * Why a hook and not a memo with a dispose effect: React's StrictMode mounts, unmounts, and mounts a * component again in development, and the cleanup of that rehearsal disposes the memo's view while the * memo keeps handing it out, so the grid stopped following its store until something remade the view. * The effect here notices a disposed view and makes another. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [view, setView] = useState<RowView<T> | null>(() => (options ? store.createView(options) : null)) const made = useRef({ store, options, view }) useEffect(() => { let current = made.current.view if (made.current.store !== store || made.current.options !== options || (current !== null && current.isDisposed())) { current = options ? store.createView(options) : null made.current = { store, options, view: current } // A view the rehearsal disposed, or new options: the replacement is state so the render that reads it re-runs. setView(current) } return () => current?.dispose() }, [store, options]) return view }hooks/use-flash.ts import { useLayoutEffect, useRef, type RefObject } from "react" // Flash a cell when its value changes, colored by direction, without a React re-render per tick. // // The previous value lives outside React (per cell, or in a shared FlashMemory keyed by cell so a // virtualized row that scrolls back within the window resumes its flash instead of losing it). // The flash itself is a Web Animations API animation on the existing element: retriggering cancels // the previous one for free, and nothing is remounted. export type Direction = "up" | "down" | "flat" export interface FlashRecord { value: unknown /** Clock time of the last flash, or -Infinity when the cell has never flashed. */ at: number dir: Direction } export interface FlashMemory { get(key: string): FlashRecord | undefined set(key: string, record: FlashRecord): void /** Drop every record whose key starts with `prefix` (a grid calls it with the row id on remove). */ forget(prefix: string): void readonly size: number } /** Bounded, least-recently-set memory. `max` records; oldest are evicted first. */ export function createFlashMemory(max = 50_000): FlashMemory { const map = new Map<string, FlashRecord>() return { get: (key) => map.get(key), set(key, record) { if (map.has(key)) map.delete(key) map.set(key, record) if (map.size > max) map.delete(map.keys().next().value!) }, forget(prefix) { for (const key of map.keys()) if (key.startsWith(prefix)) map.delete(key) }, get size() { return map.size }, } } export type Compare = (prev: unknown, next: unknown) => number | null /** Numeric difference when both sides are finite numbers; 0 when identical; null for any other change. */ export const compareValues: Compare = (prev, next) => { if (typeof prev === "number" && typeof next === "number" && Number.isFinite(prev) && Number.isFinite(next)) return next - prev return Object.is(prev, next) ? 0 : null } /** Direction of a change. Zero is flat, never up. A non-numeric change is flat. */ export function directionOf(prev: unknown, next: unknown, compare: Compare = compareValues): Direction { const d = compare(prev, next) if (d === null || d === 0) return "flat" return d > 0 ? "up" : "down" } export interface FlashOptions { /** How long a flash lasts, and how long a scrolled-away cell remembers it. Default 900. */ windowMs?: number /** `fill` tints the background; `ring` draws an inset outline, for cells that already carry a bar. */ variant?: "fill" | "ring" /** Paint with this instead of the direction's token: `var(--primary)` for an acknowledgement that has no direction. */ color?: string compare?: Compare /** * Flash flat when the same value arrives again. Needs `revision` to know it arrived: pass the row object * or the batch version, since an equal `value` alone looks unchanged to React. Default false. */ flashOnEqual?: boolean /** Anything that changes identity when a new reading arrives (the row object, a store version). */ revision?: unknown /** Shared memory across mounts; requires `cellKey`. */ memory?: FlashMemory cellKey?: string disabled?: boolean /** Injectable clock in ms; defaults to performance.now. */ now?: () => number } export const FILL_COLORS: Record<Direction, string> = { up: "var(--up-soft)", down: "var(--down-soft)", flat: "var(--flat-soft)", } export const RING_COLORS: Record<Direction, string> = { up: "var(--up)", down: "var(--down)", flat: "var(--flat)", } /** Tailwind classes for coloring a value by direction: text-up, text-down, text-flat. */ export function directionClass(dir: Direction | null | undefined): string { return dir === "up" ? "text-up" : dir === "down" ? "text-down" : "text-flat" } const animations = new WeakMap<Element, Animation>() const clearTimers = new WeakMap<Element, ReturnType<typeof setTimeout>>() let reducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (reducedMotion === null) { reducedMotion = typeof matchMedia === "function" ? matchMedia("(prefers-reduced-motion: reduce)").matches : false } return reducedMotion } /** Test hook: forget the cached media query. */ export function resetReducedMotionCache() { reducedMotion = null } function keyframes(dir: Direction, variant: "fill" | "ring", color?: string): Keyframe[] { if (variant === "ring") return [{ boxShadow: `inset 0 0 0 1px ${color ?? RING_COLORS[dir]}` }, { boxShadow: "inset 0 0 0 1px transparent" }] return [{ backgroundColor: color ?? FILL_COLORS[dir] }, { backgroundColor: "transparent" }] } /** Start (or resume, `elapsed` ms in) a flash on an element. */ export function playFlash(el: HTMLElement, dir: Direction, opts: { windowMs: number; variant: "fill" | "ring"; color?: string; elapsed?: number }) { const elapsed = Math.max(0, Math.min(opts.elapsed ?? 0, opts.windowMs)) animations.get(el)?.cancel() const pending = clearTimers.get(el) if (pending) clearTimeout(pending) el.dataset.direction = dir const done = () => { if (el.dataset.direction === dir) delete el.dataset.direction } if (prefersReducedMotion() || typeof el.animate !== "function") { // Static color from the data-direction styles, cleared when the window ends. clearTimers.set(el, setTimeout(done, opts.windowMs - elapsed)) return } const anim = el.animate(keyframes(dir, opts.variant, opts.color), { duration: opts.windowMs, easing: "ease-out", fill: "none" }) anim.currentTime = elapsed anim.onfinish = () => { if (animations.get(el) === anim) animations.delete(el) done() } animations.set(el, anim) } const NEVER: FlashRecord = { value: undefined, at: -Infinity, dir: "flat" } export function useFlash<E extends HTMLElement>(ref: RefObject<E | null>, value: unknown, opts: FlashOptions = {}): void { const { windowMs = 900, variant = "fill", color, compare = compareValues, flashOnEqual = false, memory, cellKey, disabled = false, revision } = opts const now = opts.now ?? (() => performance.now()) const local = useRef<FlashRecord | undefined>(undefined) const key = memory ? cellKey : undefined if (memory && !cellKey) throw new Error("useFlash: a shared memory needs a cellKey") const read = (): FlashRecord | undefined => (key !== undefined ? memory!.get(key) : local.current) const write = (record: FlashRecord) => { if (key !== undefined) memory!.set(key, record) else local.current = record } useLayoutEffect(() => { const el = ref.current if (!el || disabled) return const t = now() const prev = read() if (!prev) { // First sight of this cell: remember, do not flash. write({ ...NEVER, value }) return } const changed = !Object.is(prev.value, value) if (changed || flashOnEqual) { const dir = changed ? directionOf(prev.value, value, compare) : "flat" write({ value, at: t, dir }) playFlash(el, dir, { windowMs, variant, color }) return } // Same value on a fresh mount (a virtualized row scrolled back): resume a flash still in its window. const elapsed = t - prev.at if (elapsed < windowMs) playFlash(el, prev.dir, { windowMs, variant, color, elapsed }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, revision, ref, disabled, windowMs, variant, color, flashOnEqual, compare, memory, key]) useLayoutEffect(() => { const el = ref.current return () => { if (!el) return animations.get(el)?.cancel() animations.delete(el) const pending = clearTimers.get(el) if (pending) clearTimeout(pending) } }, [ref]) }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments once per applied batch. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] let idsSnapshot: readonly RowId[] = ids let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, createView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.delete(view)) views.add(view) return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = ids meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements RowView<T> { private ids: readonly RowId[] = [] private readonly listeners = new Set<Listener>() private holdUntil = 0 private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly onDispose: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, onDispose: () => void) { this.store = store this.opts = opts this.onDispose = onDispose this.now = opts.now ?? Date.now this.ids = this.compute() } getIds() { return this.ids } subscribe(cb: Listener) { this.listeners.add(cb) return () => { this.listeners.delete(cb) } } isHeld() { return this.now() < this.holdUntil } touch() { const hold = this.opts.reorderHoldMs ?? 0 if (hold <= 0) return this.holdUntil = this.now() + hold if (this.timer) clearTimeout(this.timer) // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.set(this.compute()) }, hold) } dispose() { if (this.disposed) return this.disposed = true if (this.timer) clearTimeout(this.timer) this.listeners.clear() this.onDispose() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean) { if (!touched.size && !orderChanged) return if (this.isHeld()) { // Frozen order: keep what is there, drop removals and rows that no longer pass the filter, append newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) const next = kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] this.set(next) return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) this.set(this.compute()) } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private set(next: readonly RowId[]) { if (sameOrder(this.ids, next)) return this.ids = next for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { if (handle !== null) { caf(handle) handle = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), gap: Boolean(meta?.gap) || Boolean(delta.meta.gap), } } if (handle === null) { handle = raf(() => { handle = null flush() }) } }, flush, cancel() { if (handle !== null) caf(handle) handle = null reset() }, pending: () => handle !== null, } }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, whose dash, ticks, and tail must line up down a column. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * 99-16+ over 99-17 keeps its dash and its tail in the same place; every other price, and every quote in * another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --chart-1: oklch(0.56 0.15 65); --chart-2: oklch(0.52 0.12 230); --chart-3: oklch(0.52 0.13 165); --chart-4: oklch(0.55 0.13 95); --chart-5: oklch(0.5 0.14 250); --chart-6: oklch(0.5 0.18 40); --chart-7: oklch(0.52 0.15 345); --chart-8: var(--color-foreground); --down: oklch(0.46 0.19 40); --down-soft: oklch(0.46 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.55 0 0 / 14%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.52 0.13 165); --up-soft: oklch(0.52 0.13 165 / 18%); } .dark { --chart-1: oklch(0.8 0.15 70); --chart-2: oklch(0.78 0.11 230); --chart-3: oklch(0.74 0.15 165); --chart-4: oklch(0.88 0.15 100); --chart-5: oklch(0.72 0.12 250); --chart-6: oklch(0.7 0.18 40); --chart-7: oklch(0.76 0.13 345); --chart-8: var(--color-foreground); --down: oklch(0.66 0.19 40); --down-soft: oklch(0.66 0.19 40 / 18%); --flat: var(--color-muted-foreground); --flat-soft: oklch(0.7 0 0 / 14%); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-numeric: var(--tradecn-font-sans); --tradecn-font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --up: oklch(0.74 0.15 165); --up-soft: oklch(0.74 0.15 165 / 18%); } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { barId, type Bar } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart } from "@/components/ui/price-chart"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const start = Date.parse("2026-09-22T14:00:00Z")
const minute = 60_000
const bars: Bar[] = [
{ time: start, open: 110.5, high: 110.53125, low: 110.484375, close: 110.515625, volume: 200 },
{ time: start + minute, open: 110.515625, high: 110.53125, low: 110.46875, close: 110.484375, volume: 100 },
{ time: start + 2 * minute, open: 110.484375, high: 110.546875, low: 110.484375, close: 110.53125, volume: 300 },
{ time: start + 3 * minute, open: 110.53125, high: 110.5625, low: 110.515625, close: 110.546875, volume: 200 },
{ time: start + 4 * minute, open: 110.546875, high: 110.546875, low: 110.5, close: 110.515625, volume: 400 },
{ time: start + 5 * minute, open: 110.515625, high: 110.578125, low: 110.515625, close: 110.5625, volume: 100 },
]
function SampleChart() {
const [store] = useState(() => {
const store = createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" })
store.applyDeltas({ upsert: bars })
return store
})
return <div className="w-xl max-w-full"><PriceChart store={store} convention={ZN} label="ZN sample, one-minute bars" zone="America/Chicago" baseline={110.5} className="h-72" /></div>
}
This fixed sample contains six one-minute bars from September 22, 2026, starting at 09:00 in Chicago. The line joins their closes; baseline is the sample previous close. Move the pointer over the plot, or focus it and use the arrow keys, to inspect a bar's close and volume. Home and End reach the first and last bars.
Keep the store stable across renders and key each bar by its start time with barId. This example loads complete bars with upsert; the incoming-ticks example shows how a feed can build them.
Candles
Set kind="candles" for a candle per bar: the body from open to close, the wick from low to high. These six fixed ES bars span five-minute intervals and include rising, falling and unchanged closes relative to their opens. Each candle's color compares its own close with its open. The crosshair readout prints all four prices and volume.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { barId, type Bar } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart } from "@/components/ui/price-chart"
const ES: InstrumentConvention = { price: { kind: "decimal", decimals: 2 }, tick: 0.25 }
const start = Date.parse("2026-09-22T14:00:00Z")
const interval = 5 * 60_000
const bars: Bar[] = [
{ time: start, open: 5010, high: 5011.5, low: 5009.5, close: 5011, volume: 1500 },
{ time: start + interval, open: 5011, high: 5011.25, low: 5009.75, close: 5010.25, volume: 1800 },
{ time: start + 2 * interval, open: 5010.25, high: 5012, low: 5010, close: 5011.75, volume: 2200 },
{ time: start + 3 * interval, open: 5011.75, high: 5012.25, low: 5011.25, close: 5011.75, volume: 1200 },
{ time: start + 4 * interval, open: 5011.75, high: 5012, low: 5010.5, close: 5011, volume: 1600 },
{ time: start + 5 * interval, open: 5011, high: 5013, low: 5010.75, close: 5012.5, volume: 2400 },
]
export default function PriceChartCandlesDemo() {
const [store] = useState(() => {
const store = createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" })
store.applyDeltas({ upsert: bars })
return store
})
return <div className="w-xl max-w-full"><PriceChart store={store} convention={ES} kind="candles" label="ES sample, five-minute candles" zone="America/Chicago" baseline={5010} className="h-72" /></div>
}
Overlays
overlays supplies one value per bar, with a label and a chart token for each line. This example reuses the opening chart's six bars. The three-bar close average leaves the first two values missing until there is enough history. Bar VWAP weights each bar's typical price, (high + low + close) / 3, by its volume; it covers this sample only, rather than a full trading session or every individual trade.
Keep overlay functions pure and their definitions stable. The legend names both lines so color is not their only identifier.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { barId, type Bar } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart, type PriceChartOverlay } from "@/components/ui/price-chart"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const start = Date.parse("2026-09-22T14:00:00Z")
const minute = 60_000
const bars: Bar[] = [
{ time: start, open: 110.5, high: 110.53125, low: 110.484375, close: 110.515625, volume: 200 },
{ time: start + minute, open: 110.515625, high: 110.53125, low: 110.46875, close: 110.484375, volume: 100 },
{ time: start + 2 * minute, open: 110.484375, high: 110.546875, low: 110.484375, close: 110.53125, volume: 300 },
{ time: start + 3 * minute, open: 110.53125, high: 110.5625, low: 110.515625, close: 110.546875, volume: 200 },
{ time: start + 4 * minute, open: 110.546875, high: 110.546875, low: 110.5, close: 110.515625, volume: 400 },
{ time: start + 5 * minute, open: 110.515625, high: 110.578125, low: 110.515625, close: 110.5625, volume: 100 },
]
const average: PriceChartOverlay["values"] = (bars) => bars.map((bar, i) => i < 2 ? null : (bars[i - 2]!.close + bars[i - 1]!.close + bar.close) / 3)
const vwap: PriceChartOverlay["values"] = (bars) => {
let weighted = 0
let volume = 0
return bars.map((bar) => {
const size = bar.volume ?? 0
weighted += ((bar.high + bar.low + bar.close) / 3) * size
volume += size
return volume ? weighted / volume : null
})
}
const overlays: PriceChartOverlay[] = [
{ id: "sma3", label: "3-bar average", values: average },
{ id: "vwap", label: "Bar VWAP", values: vwap, color: 5 },
]
export default function PriceChartOverlaysDemo() {
const [store] = useState(() => {
const store = createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" })
store.applyDeltas({ upsert: bars })
return store
})
return <div className="w-xl max-w-full"><PriceChart store={store} convention={ZN} label="ZN sample with overlays" zone="America/Chicago" baseline={110.5} overlays={overlays} className="h-72" /></div>
}
Incoming ticks
The Apply next tick batch button stands in for a batch from a trade feed. foldTicks builds five-minute bars and returns deltas to apply to the store. The first batch opens and extends one bar, then opens the next. The second batch extends that second bar. The third contains a late print for the first bar: its low and volume change, while its close stays at the later print's price. Focus the plot and press Home to inspect that corrected bar.
The last state remains visible, with at most two bars. Clear bars returns to the empty chart so you can replay the batches. There is no background publisher. In a feed integration, call the same fold/apply pair for each received batch and choose a retention policy for old bars. A print for a missing historical bar inserts it; the chart sorts bars for display.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { barId, foldTicks, type Bar, type PriceTick } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart } from "@/components/ui/price-chart"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const start = Date.parse("2026-09-22T14:00:00Z")
const batches: { message: string; ticks: PriceTick[] }[] = [
{ message: "Three prints form two bars; their volumes are 5 and 4.", ticks: [{ at: start + 10_000, price: 110.5, size: 2 }, { at: start + 40_000, price: 110.515625, size: 3 }, { at: start + 310_000, price: 110.546875, size: 4 }] },
{ message: "Another print extends the second bar; its volume is 5.", ticks: [{ at: start + 340_000, price: 110.53125, size: 1 }] },
{ message: "A late print lowers the first bar's low and adds 6 volume. Its close stays put.", ticks: [{ at: start + 20_000, price: 110.484375, size: 6 }] },
]
export default function PriceChartTicksDemo() {
const [store] = useState(() => createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" }))
const [step, setStep] = useState(0)
const advance = () => {
const batch = batches[step]
if (!batch) return
store.applyDeltas(foldTicks(store, batch.ticks, 5 * 60_000))
setStep(step + 1)
}
const reset = () => {
store.applyDeltas({ remove: store.getIds() })
setStep(0)
}
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={step === batches.length} onClick={advance}>Apply next tick batch</button>
<button type="button" className="rounded border px-2 py-1" onClick={reset}>Clear bars</button>
</div>
<div className="w-xl max-w-full space-y-2 text-xs lining-nums tabular-nums">
<PriceChart store={store} convention={ZN} kind="candles" label="ZN sample tick batches" zone="America/Chicago" baseline={110.5} className="h-72" />
<p role="status" className="text-muted-foreground">{batches[step - 1]?.message ?? "No ticks received."}</p>
</div>
</>
)
}
API Reference
The chart draws with uPlot on a canvas after ResizeObserver reports a positive box size and the store has a finite bar. It reads the store through useStoreMeta and refreshes columns when the store or its batch version changes. React can combine several synchronous batches into one render. Size changes resize the plot; clearing the bars or unmounting destroys it.
Changing kind, crosshair, lastLine, zone, the serialized convention, or the overlays' ids, colors, widths, or order remakes the plot. Colors are read from your tokens when the plot is created and when <html>'s class, style, data-theme, or data-accessibility changes. A changed --tradecn-font-mono stack also remakes the plot because its axis font is fixed at creation.
Changing only baseline updates the readout without recalculating the price scale, so a new reference outside the current range can stay offscreen. A data update or plot recreation recalculates the scale.
Props
PriceChartProps extends the root div's props except children. The component owns the root's role="group", aria-label, and chart data attributes; other div props and handlers pass through.
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<Bar> |
Required | Bars keyed by barId(time). |
convention |
PriceConvention | InstrumentConvention |
Required | Prints every price and sets the axis grid. |
label |
string |
Required | Accessible name of the chart. |
kind |
"line" | "candles" |
"line" |
A line through the closes, or a candle per bar. |
baseline |
number | null |
null |
A finite previous close: the change is measured from it and it is drawn as a dashed line. Otherwise, the change is from the first bar's open. |
zone |
string |
The runtime's | A runtime-supported IANA zone for the time axis and readout: the venue's. |
locale |
string |
en-US |
Locale of the readout's clock. |
overlays |
readonly PriceChartOverlay[] |
None | Lines over the bars, named in a legend. |
crosshair |
boolean |
true |
The crosshair, from the pointer and the keys. Off, the plot is an image. |
lastLine |
boolean |
true |
The dashed line and the tag at the last close. |
height |
number |
Omitted | Root height in px, overriding style.height. Otherwise, h-64 unless styled differently. |
labels |
Partial<PriceChartLabels> |
DEFAULT_PRICE_CHART_LABELS |
Override the words listed below. |
onCursor |
(bar: Bar | null) => void |
None | Called when the selected bar index changes or is cleared; receives the bar or null. |
className |
string |
None | Classes on the root. |
style |
CSSProperties |
None | Inline styles on the root. |
Bars
| Field | Type | Required | Purpose |
|---|---|---|---|
time |
number |
Yes | When the bar opened, ms since the epoch; the row id is barId(time). |
open / high / low / close |
number |
Yes | The bar's prices. The chart skips a bar if its time or any of these prices is not finite. |
volume |
number | null |
No | Size traded in the bar; printed in the readout when present. |
firstAt / lastAt |
number |
No | When the earliest and the latest prints folded into the bar happened. foldTick writes them; a bar you pass whole has neither. |
PriceTick is the input to foldTick and foldTicks:
| Field | Type | Required | Purpose |
|---|---|---|---|
at |
number |
Yes | Print time in ms since the epoch. |
price |
number |
Yes | The traded price. |
size |
number | null |
No | Traded size; absent, null, and non-finite sizes add no volume. |
The helpers are in price-series.ts, with no React runtime dependency, so a feed layer or a worker can fold bars without the component. Use a positive finite intervalMs in milliseconds; the helpers do not validate it. foldTicks skips ticks with non-finite at or price, while foldTick expects valid inputs. Each convention parameter accepts PriceConvention | InstrumentConvention.
| Function | Returns | Purpose |
|---|---|---|
barId(time: number) |
RowId |
The time as a string, the store's row id. |
barStart(at: number, intervalMs: number) |
number |
Bar start in epoch ms, on the interval grid aligned to the epoch. |
foldTick(bar: Bar | undefined, tick: PriceTick, intervalMs: number) |
Bar |
Open or extend the tick's bar. |
foldTicks(store: RowStore<Bar>, ticks: readonly PriceTick[], intervalMs: number) |
DeltaBatch<Bar> |
One upsert per bar touched; does not apply the batch. |
columnsOf(store: RowStore<Bar>) |
BarColumns |
Finite bars and one array per field, in time order. |
summarize(columns: BarColumns, baseline?: number | null) |
SeriesSummary |
Count, first and last bar, low, high, reference, change, percent, and direction. |
directionBetween(from: number, to: number) |
Direction |
"up", "down", or "flat" by comparing to with from. |
priceOf(convention) |
PriceConvention |
Extract an instrument's price convention, or return a bare one. |
priceStep(convention) |
number |
Positive instrument tick when supplied; otherwise the fraction denominator's step, tick convention's tick, or decimal convention's last place. |
priceIncrements(convention) |
number[] |
Axis steps from the convention's grid up: doubling for fractions, 1/2/5 per decade otherwise. |
priceDecimals(convention) |
number |
Decimal places, a tick convention's derived places, or 0 for a fraction. |
formatChange(change: number | null | undefined, convention) |
string |
Signed change in the convention: +0-02+, −0.50; zero has no sign and missing/non-finite values print –. |
timeFormatter(zone?: string, locale?: string, seconds?: boolean) |
(ms: number) => string |
A 24-hour clock reading, with seconds by default and locale en-US. An unknown zone falls back to the runtime's. |
dayFormatter(zone?: string, locale?: string) |
(ms: number) => string |
A month/day formatter with the same locale and zone defaults. Used on the axis when its span exceeds one day. |
foldTicks returns a DeltaBatch<Bar>; apply it to the store as in the example. It sorts a frame's ticks by time before folding. A late print corrects its bar's high, low, and volume; an earlier print moves its open, and a print at or after its latest timestamp moves its close. A whole bar without firstAt or lastAt keeps its open, but the first tick folded into it can replace its close.
columnsOf returns BarColumns: the filtered bars and arrays for time, open, high, low, close, and volume. It sorts on each call if the store's order is not chronological; it does not reorder the store. Invalid or absent volume becomes null in the volume column. summarize returns a SeriesSummary, measuring percent change as change / Math.abs(reference) * 100; a zero reference gives null. Empty input returns the exported EMPTY_COLUMNS or EMPTY_SUMMARY respectively, with a flat summary and no first or last bar.
Overlays
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | The key. |
label |
string |
Required | The word in the legend. |
values |
(bars: readonly Bar[]) => readonly (number | null)[] |
Required | One value per bar in time order; null leaves a gap. |
color |
number |
Its place in the list, starting at 1 | Chart token index. Supply an integer; values outside 1–8 are clamped to that range. |
width |
number |
1 |
Line width in CSS px. |
CHART_TOKEN_CLASS is the eight tokens as utilities, bg-chart-1 to bg-chart-8, for a key of your own.
Keep values pure: it runs when the plot is created or recreated and when its data or overlay structure changes, including store batches that change only metadata. Replacing only a values function does not immediately recompute the line; the new function is used on the next such update. Missing or non-finite values become gaps, and extra values are ignored.
The readout
The header prints the last close in the direction's color, then the change from the reference with its sign in the convention (+0-02 for a fraction, +1.25 for a decimal) and in percent. Under the crosshair it prints the bar: the time in the zone, the close for a line or all four prices for candles, and the volume when there is one.
The direction is the last close against the reference: up, down, or flat when equal, never up. It is on the root as data-direction, and the plot's accessible name says it in a word with the last, the change, the range, and the count: ZN, today: up, last 110-18, +0-02 (+0.06%), low 110-15, high 110-19, 3 bars.
Keyboard
With the crosshair on and at least one finite bar, the plot is a horizontal slider over the bars. Focus selects the last bar if no bar is selected; an existing pointer selection stays selected. With no bars or crosshair={false}, the plot is an img without a tab stop.
| Key | Action |
|---|---|
| Left / Down | Back one bar. |
| Right / Up | Forward one bar. |
| PageDown / PageUp | Move ten bars. |
| Home / End | The first and the last bar. |
| Escape | Put the crosshair away. Leaving does too. |
| Ctrl, Cmd, or Alt with any key | Left to listeners above the chart, such as a hotkey registry. |
The pointer moves the crosshair too, and the readout and onCursor follow whichever moved it last. Your own onKeyDown, onFocus, and the rest go on the root, around the plot, and see every key after it: a claimed key arrives with defaultPrevented set, every other key clean.
onCursor follows index changes, not changes to the bar at that index. Updating a selected bar or inserting bars before it can change the readout without a callback. It is not called simply because the component mounted, and unmount does not send null.
Marks
| Attribute | Where | Meaning |
|---|---|---|
data-kind |
The root | line or candles. |
data-direction |
The root | The last close against the reference. |
data-empty |
The root | No finite bars to display. |
data-slot="tradecn-price-chart" |
The root | Component marker. |
data-chart-header |
The header | Last price, change, and cursor readout. |
data-chart-last, data-chart-change, data-chart-readout |
The header | The three readings, for a test or a style. |
data-chart-plot |
The plot box | Where the canvas lives. |
data-chart-empty |
The plot box's placeholder | No finite bars to display. |
data-chart-legend |
The legend | One item per overlay. |
Labels
labels merges partial overrides into DEFAULT_PRICE_CHART_LABELS:
| Label | Default | Where |
|---|---|---|
noData |
No data |
The header and plot when there are no finite bars |
open / high / low / close |
O / H / L / C |
The candle readout |
volume |
V |
Volume in either readout |
overlays |
Overlays |
The legend's name |
up / down / flat |
up / down / flat |
The direction word in the accessible name |
bars |
bars |
The count in the accessible name |
The accessible name's words last, low, and high are fixed English text. locale changes the readout's clock; it does not translate those words or change the axis locale.
Tokens
The install adds up, down, and flat with their soft variants if you do not have them, plus chart-1 to chart-8. The item palette adapts Okabe and Ito's palette: orange, sky blue, bluish green, yellow, blue, vermilion, reddish purple, and your foreground for black, with lightness adjusted for each mode.
A shadcn project already carries the first five chart tokens, which an install leaves alone. Each tradecn theme sets all eight in its own palette. An overlay uses your palette's value at that slot; its legend label names the line. The axis and the tag use your --tradecn-font-mono stack for tabular canvas text.
What it does not do
Zoom, pan, indicators, a volume pane, or a second instrument on the same axis. It aggregates nothing: foldTicks is the one interval you give it, and a different interval is a different store. It decides nothing about the session: pass the venue's zone and the previous close, and it prints them.
This page is docs/price-chart.md at v1.4.13.