PriceChart
Line and candlestick charts with composable readings, legends, and keyboard navigation.
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, PriceChartHeader, PriceChartLast, PriceChartChange, PriceChartReadout, PriceChartPlot, PriceChartEmpty } 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">
<PriceChartHeader>
<PriceChartLast />
<PriceChartChange />
<PriceChartReadout />
</PriceChartHeader>
<PriceChartPlot>
<PriceChartEmpty />
</PriceChartPlot>
</PriceChart>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/price-chart#v2.0.0
npx shadcn@latest add tradecn/ui/price-chart#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/price-chart#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/price-chart#v2.0.0
-
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 { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode } 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" // The root reads the store once per batch. Public readings share that snapshot; the plot owns its // canvas, observers and keyboard crosshair. Moving surrounding content never remakes the plot. // Canvas colors are read from the page tokens at mount and whenever the page theme changes. // Direction has channels beyond hue (contract rule 15): Last and Change carry data-direction, // Change prints a sign, and the plot's accessible name says the direction in a word. Compose // Change or your own sign beside Last so its visible direction does not depend on color. 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"> { /** Compose the plot and any readings, legend or application content explicitly. */ children: ReactNode /** 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. Compose their legend with PriceChartLegend and PriceChartOverlaySwatch. */ 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) const zones = new Map<string, boolean>() /** Whether the runtime knows the zone. uPlot reads the axis in it and throws inside its own update for one it does not, so the axis falls back to the runtime's zone, as the readout does. */ function knownZone(zone: string | undefined): zone is string { if (!zone) return false let known = zones.get(zone) if (known === undefined) { try { new Intl.DateTimeFormat("en-US", { timeZone: zone }) known = true } catch { known = false } zones.set(zone, known) } return known } 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: (plot: uPlot) => 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. ...(knownZone(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: [a.onCursor], 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 /** Silent writes update uPlot's crosshair without echoing a programmatic move to the caller. */ function syncPlotCursor(plot: uPlot, bar: Bar | null) { plot.setCursor(bar ? { left: plot.valToPos(bar.time, "x"), top: plot.valToPos(bar.close, "y") } : { left: -10, top: -10 }, false) } export interface PriceChartState { readonly bars: readonly Bar[] readonly summary: SeriesSummary readonly cursor: number | null readonly bar: Bar | null readonly readout: string readonly overlays: readonly PriceChartOverlay[] readonly convention: PriceConvention | InstrumentConvention readonly labels: PriceChartLabels } interface ChartContext extends PriceChartState { store: object columns: BarColumns label: string sentence: string kind: PriceChartKind baseline: number | null zone: string | undefined locale: string | undefined crosshair: boolean lastLine: boolean select: (index: number | null, fromPointer?: boolean) => void } const PriceChartContext = createContext<ChartContext | null>(null) function useChartContext() { const context = useContext(PriceChartContext) if (!context) throw new Error("PriceChart parts require PriceChart") return context } /** Shared readings for custom content; does not add a store subscription. */ export function usePriceChart(): PriceChartState { return useChartContext() } export function PriceChart({ store, convention, label, kind = "line", baseline = null, zone, locale, overlays, crosshair = true, lastLine = true, height, labels: labelsProp, onCursor, className, style, children, ...props }: PriceChartProps) { const labels = { ...DEFAULT_PRICE_CHART_LABELS, ...labelsProp } const ref = typeof baseline === "number" && Number.isFinite(baseline) ? baseline : null const overlayList = useMemo(() => overlays ?? [], [overlays]) const [selection, setSelection] = useState<number | null>(null) const selectionRef = useRef<number | null>(null) const interactionRef = useRef<number | null>(null) // 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. const meta = useStoreMeta(store) const columns = useMemo(() => { void meta.version return columnsOf(store) }, [store, meta.version]) const summary = useMemo(() => summarize(columns, ref), [columns, ref]) // Notify in the event, never inside a state updater (which StrictMode may run twice). const select = useCallback((index: number | null, fromPointer = false) => { // A data clamp is silent, but the pointer must still report a newly resolved index. const previous = fromPointer ? interactionRef.current : selectionRef.current const changed = selectionRef.current !== index interactionRef.current = index if (changed) { selectionRef.current = index setSelection(index) } if (changed || previous !== index) onCursor?.(index === null ? null : (columns.bars[index] ?? null)) }, [columns, onCursor]) const count = columns.bars.length const cursor = selection === null || count === 0 ? null : clamp(selection, count - 1) // Keep the clamped index when bars return; an empty store still retains its selection. if (cursor !== null && cursor !== selection) setSelection(cursor) useLayoutEffect(() => { if (count > 0 && selectionRef.current !== null) selectionRef.current = clamp(selectionRef.current, count - 1) }, [count]) const at = cursor === null ? null : columns.bars[cursor]! 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" && Number.isFinite(at.volume) ? ` ${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 context: ChartContext = { store, bars: columns.bars, columns, summary, cursor, bar: at, readout, overlays: overlayList, convention, labels, label, sentence, kind, baseline: ref, zone, locale, crosshair, lastLine, select } return ( <PriceChartContext.Provider value={context}> <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 }} > {children} </div> </PriceChartContext.Provider> ) } export function PriceChartHeader({ className, ...props }: ComponentProps<"div">) { return <div {...props} data-chart-header="" className={cn("flex min-h-5 shrink-0 flex-wrap items-baseline gap-x-3 gap-y-0.5 px-1", NUMERIC_CLASS, className)} /> } export function PriceChartLast({ className, children, ...props }: ComponentProps<"span">) { const { summary, convention, labels } = usePriceChart() return <span {...props} data-chart-last="" data-direction={summary.direction} className={cn("text-sm font-semibold", numericFontClass(priceOf(convention)), directionClass(summary.direction), className)}>{children === undefined ? summary.last ? formatPrice(summary.last.close, priceOf(convention)) : labels.noData : children}</span> } export function PriceChartChange({ className, children, ...props }: ComponentProps<"span">) { const { summary, convention } = usePriceChart() if (!summary.last) return null return <span {...props} data-chart-change="" data-direction={summary.direction} className={cn(numericFontClass(priceOf(convention)), directionClass(summary.direction), className)}>{children === undefined ? <>{formatChange(summary.change, convention)} <span className={NUMERIC_CLASS}>({formatPercent(summary.changePct, { signed: true })})</span></> : children}</span> } export function PriceChartReadout({ className, children, ...props }: ComponentProps<"span">) { const { readout, convention } = usePriceChart() return <span {...props} data-chart-readout="" className={cn("ml-auto text-muted-foreground", numericFontClass(priceOf(convention)), className)}>{children === undefined ? readout : children}</span> } export function PriceChartEmpty({ className, children, ...props }: ComponentProps<"div">) { const { bars, labels } = usePriceChart() if (bars.length > 0) return null return <div {...props} data-chart-empty="" className={cn("absolute inset-0 flex items-center justify-center text-muted-foreground", className)}>{children === undefined ? labels.noData : children}</div> } export interface PriceChartLegendProps extends Omit<ComponentProps<"ul">, "children"> { children: ReactNode } export function PriceChartLegend({ className, ...props }: PriceChartLegendProps) { const { labels } = usePriceChart() return <ul aria-label={labels.overlays} {...props} data-chart-legend="" className={cn("flex shrink-0 flex-wrap gap-x-3 gap-y-0.5 px-1 text-muted-foreground", className)} /> } export interface PriceChartOverlaySwatchProps extends ComponentProps<"span"> { overlayId: string } /** Resolves the plot's token by id, independent of the caller's legend order. */ export function PriceChartOverlaySwatch({ overlayId, className, ...props }: PriceChartOverlaySwatchProps) { const { overlays } = usePriceChart() const index = overlays.findIndex((overlay) => overlay.id === overlayId) const overlay = overlays[index] if (!overlay) return null return <span {...props} aria-hidden="true" data-chart-swatch="" className={cn("inline-block size-2 shrink-0 rounded-full", CHART_TOKEN_CLASS[Math.min(8, Math.max(1, overlay.color ?? index + 1)) - 1], className)} /> } /** What a plot shows: a change to any of these under focus is read afresh. */ interface PlotSubject { store: object label: string kind: PriceChartKind notation: string /** The reference the change and the direction are measured from. */ baseline: number | null /** The zone, locale, and labels the readings are worded in. */ wording: string /** The series the store holds, known by its first bar's time and open. */ series: string } export function PriceChartPlot({ className, children, ref: forwardedRef, onKeyDown: onKeyDownProp, onFocus, onBlur, ...props }: ComponentProps<"div">) { const { store, columns, summary, cursor, bar, convention, overlays: overlayList, labels, label, sentence, readout, kind, baseline, zone, locale, crosshair, lastLine, select } = useChartContext() const [plotEl, setPlotEl] = useState<HTMLDivElement | null>(null) // The document the plot is in. A popout moves the plot into its own window without remounting it, so the // observers check it as they fire and bind again to the new window when it changed. const [doc, setDoc] = useState<Document | null>(null) const [size, setSize] = useState<{ width: number; height: number } | null>(null) // The last size the observers reported, for a plot made while its box has none to measure. const sizeRef = useRef<{ width: number; height: number } | null>(null) // Colors read while the plot was out of the document come back empty: read them again when it is back. const paletteStale = useRef(false) const repaintRef = useRef<() => void>(() => {}) const [fontEpoch, setFontEpoch] = useState(0) const plotKey = JSON.stringify([kind, crosshair, lastLine, zone, convention, overlayList.map((o) => [o.id, o.color, o.width]), fontEpoch]) const plot = useRef<uPlot | null>(null) const plotKeyRef = useRef<string | null>(null) const live = useRef<Live>({ palette: UNREAD_PALETTE, columns: EMPTY_COLUMNS, summary, baseline }) const overlaysRef = useRef(overlayList) const conventionRef = useRef(convention) const selectRef = useRef(select) const pointerOwnsCursor = useRef(false) const lastCursorEvent = useRef<uPlot.Cursor["event"]>(undefined) const cursorBar = useRef(bar) const moveCursor = (index: number | null) => { pointerOwnsCursor.current = false lastCursorEvent.current = plot.current?.cursor.event selectRef.current(index) if (index === cursor && plot.current) syncPlotCursor(plot.current, cursorBar.current) } useLayoutEffect(() => { overlaysRef.current = overlayList conventionRef.current = convention selectRef.current = select cursorBar.current = bar }) // What a screen reader is told while it is on the plot: the summary as of the moment it took focus, and the // selected bar's readout as of the moment the selection reached that bar. Ticks move the picture and the visible // readings, never these, so a focused plot is not read again at every update. Both are read afresh when the plot's // frame changes, its store, label, kind, notation, baseline, or wording, and then once more at the store's next batch, // so bars that land after their label are read too; both once when the series changes, known by its first bar's // time and open; and the value text alone when another bar comes under the selection, known by its place, time, and open. const count = columns.bars.length const first = columns.bars[0] const subject: PlotSubject = { store, label, kind, notation: JSON.stringify(priceOf(convention)), baseline, wording: JSON.stringify([zone ?? null, locale ?? null, labels]), series: first ? `${first.time}\u0000${first.open}` : "" } // The frame is everything but the series: a reading taken as the frame changed is taken again at the next batch. const isFrame = (s: PlotSubject) => s.store === subject.store && s.label === subject.label && s.kind === subject.kind && s.notation === subject.notation && s.baseline === subject.baseline && s.wording === subject.wording const isSubject = (s: PlotSubject) => isFrame(s) && s.series === subject.series const unsettled = (s: { settle: BarColumns | null }) => s.settle !== null && s.settle !== columns const [held, setHeld] = useState<(PlotSubject & { name: string; now: number; settle: BarColumns | null }) | null>(null) if (held !== null && (!isSubject(held) || unsettled(held))) setHeld({ ...subject, name: sentence, now: cursor ?? count - 1, settle: !isFrame(held) || held.settle === columns ? columns : null }) const heldName = held?.name ?? null // With no bar selected, the slider rests at the bar the held name was read at, inside the bars there are; the keys step from there. const rest = held === null ? count - 1 : clamp(held.now, count - 1) const heardKey = cursor === null || !bar ? null : `${cursor}\u0000${bar.time}\u0000${bar.open}` const [heard, setHeard] = useState<PlotSubject & { key: string | null; text: string; settle: BarColumns | null }>({ ...subject, key: null, text: "", settle: null }) if (heard.key !== heardKey || !isSubject(heard) || unsettled(heard)) setHeard({ ...subject, key: heardKey, text: readout, settle: !isFrame(heard) || heard.settle === columns ? columns : null }) useLayoutEffect(() => { live.current.summary = summary live.current.baseline = baseline plot.current?.redraw(false, false) }, [summary, baseline]) // The plot takes the columns before the browser paints, so the picture and the header move in one frame. useLayoutEffect(() => { live.current.columns = columns // Retire the old plot before its queued hooks can select against new data or incompatible columns. if (plotKeyRef.current !== plotKey) { plotKeyRef.current = null return } plot.current?.setData(alignedData(columns, kind, overlaysRef.current), true) }, [columns, kind, plotKey]) // The box's size, from ResizeObservers; nothing is drawn before the first measurement. The page's own observer // always watches, so the plot is measured again when a popout closes and hands it back; in a popout, that // window's observer watches too, since it reports while the popout draws. useLayoutEffect(() => { if (!plotEl || !doc) return const report: ResizeObserverCallback = ([entry]) => { if (plotEl.ownerDocument !== doc) return setDoc(plotEl.ownerDocument) const rect = entry?.contentRect if (!rect || rect.width <= 0 || rect.height <= 0) return sizeRef.current = { width: rect.width, height: rect.height } // The one path that resizes a plot: one made at another size than this report, as when its box was hidden or // out of the document, is brought to the report even when the report itself has not changed. const u = plot.current if (u && (u.width !== rect.width || u.height !== rect.height)) u.setSize({ width: rect.width, height: rect.height }) if (paletteStale.current && plotEl.isConnected) { paletteStale.current = false repaintRef.current() } setSize((s) => (s && s.width === rect.width && s.height === rect.height ? s : { width: rect.width, height: rect.height })) } const observers = [new ResizeObserver(report)] const Own = doc.defaultView?.ResizeObserver if (Own && Own !== ResizeObserver) observers.push(new Own(report)) for (const observer of observers) observer.observe(plotEl) return () => { for (const observer of observers) observer.disconnect() } }, [plotEl, doc]) // 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 || !doc || !ready || !canDraw()) return if (plotEl.isConnected) live.current.palette = readPalette(plotEl) else paletteStale.current = true // The content box from layout as it is now, which a transform does not scale and a report made before this // recreation cannot have seen; a box with none to measure (hidden, or out of the document) takes the last report. const style = (doc.defaultView ?? window).getComputedStyle(plotEl) // Lengths only: a box with no layout gives its declared values, which can be a percentage or auto. const pixels = (name: string) => { const value = style.getPropertyValue(name) return value.endsWith("px") ? parseFloat(value) || 0 : 0 } const inner = style.getPropertyValue("box-sizing") === "border-box" const laidOut = { width: pixels("width") - (inner ? pixels("padding-left") + pixels("padding-right") + pixels("border-left-width") + pixels("border-right-width") : 0), height: pixels("height") - (inner ? pixels("padding-top") + pixels("padding-bottom") + pixels("border-top-width") + pixels("border-bottom-width") : 0), } const box = laidOut.width > 0 && laidOut.height > 0 ? laidOut : (sizeRef.current ?? { width: 0, height: 0 }) pointerOwnsCursor.current = false lastCursorEvent.current = undefined 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: (u) => { if (plot.current !== u || plotKeyRef.current !== plotKey) return // uPlot also fires this hook after setData recalculates its scales. Only a new native // event transfers ownership to the pointer; keyboard selection follows the retained bar. if (u.cursor.event && u.cursor.event !== lastCursorEvent.current) { pointerOwnsCursor.current = true lastCursorEvent.current = u.cursor.event } if (pointerOwnsCursor.current) selectRef.current(u.cursor.idx ?? null, true) else syncPlotCursor(u, cursorBar.current) }, }), alignedData(live.current.columns, kind, overlaysRef.current), plotEl, ) plot.current = u plotKeyRef.current = plotKey syncPlotCursor(u, cursorBar.current) // 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. let queued = false const repaint = () => { queued = false if (plot.current !== u) return if (!plotEl.isConnected) { paletteStale.current = true return } 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) } repaintRef.current = repaint // Read once this round of observers has run: a popout copies the page's root onto its own in the same round, // and the plot in it reads its styles from there. const changed = () => { if (plotEl.ownerDocument !== doc) setDoc(plotEl.ownerDocument) if (queued) return queued = true queueMicrotask(repaint) } // The page's root, where a theme is switched, and the plot's own, which a popout keeps in step with it. const observer = new MutationObserver(changed) const watched = { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-accessibility"] } observer.observe(document.documentElement, watched) if (doc.documentElement !== document.documentElement) observer.observe(doc.documentElement, watched) // A theme that follows the system needs no attribute change, so the scheme flip is watched too, from the page's // window, which outlives a popout, and from the popout's while the plot is in one. const candidates: (Window | null)[] = [typeof window === "undefined" ? null : window, doc.defaultView] const views = candidates.filter((view, i): view is Window => view !== null && candidates.indexOf(view) === i) const schemes = views.map((view) => view.matchMedia?.("(prefers-color-scheme: dark)")).filter((scheme): scheme is MediaQueryList => Boolean(scheme)) for (const scheme of schemes) scheme.addEventListener("change", changed) return () => { for (const scheme of schemes) scheme.removeEventListener("change", changed) observer.disconnect() repaintRef.current = () => {} u.destroy() plot.current = null plotKeyRef.current = null } // plotKey includes convention, overlay structure and font epoch; equal inline options do not remake the plot. }, [plotEl, doc, ready, kind, crosshair, lastLine, zone, plotKey]) // Keys move the plot immediately. Its cursor hook repeats this after new scales are committed; // pointer-owned coordinates stay where the hand put them. Silent writes cannot echo onCursor. useEffect(() => { const u = plot.current if (u && !pointerOwnsCursor.current) syncPlotCursor(u, cursorBar.current) }, [cursor]) const attachPlot = useCallback((node: HTMLDivElement | null) => { setPlotEl(node) setDoc(node?.ownerDocument ?? null) if (typeof forwardedRef === "function") return forwardedRef(node) if (forwardedRef) forwardedRef.current = node }, [forwardedRef]) const interactive = crosshair && count > 0 function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { onKeyDownProp?.(event) if (event.defaultPrevented || !interactive || event.metaKey || event.ctrlKey || event.altKey) return const from = cursor ?? rest // 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)) } return ( <div {...props} ref={attachPlot} role={interactive ? "slider" : "img"} aria-label={heldName ?? sentence} // Focus stays where it is when the bars go: the plot keeps its tab stop until it is left. tabIndex={interactive || heldName !== null ? 0 : undefined} aria-orientation={interactive ? "horizontal" : undefined} aria-valuemin={interactive ? 0 : undefined} aria-valuemax={interactive ? count - 1 : undefined} aria-valuenow={interactive ? cursor ?? rest : undefined} // Held while the plot has focus; without it, the selection's readout as it is. aria-valuetext={interactive ? (heldName === null ? readout || sentence : heardKey === null ? heldName : heard.text) : undefined} onKeyDown={onKeyDown} onFocus={(event) => { onFocus?.(event) // A read still owed to the batch after a frame change stays owed through a refocus. setHeld({ ...subject, name: sentence, now: cursor ?? count - 1, settle: heard.settle === columns ? columns : null }) // A selection the pointer made before focus is read as it is now. setHeard({ ...subject, key: heardKey, text: readout, settle: heard.settle === columns ? columns : null }) if (!event.defaultPrevented && interactive && cursor === null) moveCursor(count - 1) }} onBlur={(event) => { onBlur?.(event) setHeld(null) if (!event.defaultPrevented) moveCursor(null) }} 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", className)} > {children} </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) { // The zone and the locale can each be bad on their own — a malformed tag throws the same // RangeError a wrong zone does — so the fallbacks drop one, then the other, then both, // always landing on the documented en-US default rather than the runtime's locale, which // would also poison the shared cache entry for charts that omit a locale. for (const [loc, tz] of [[locale ?? "en-US", zone], [locale ?? "en-US", undefined], ["en-US", zone]] as const) { try { format = new Intl.DateTimeFormat(loc, tz ? { ...options, timeZone: tz } : options) break } catch { continue } } format ??= new Intl.DateTimeFormat("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, useLayoutEffect, 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 whose connection belongs to this component. Preparation is pure, so abandoned renders and * server rendering leave no live resources. Cleanup disconnects; effect replay reconnects the same view. * Keep options stable (a `useMemo`): new store/options identities select a new view synchronously. * Null options give null, for a component that borrows a caller-owned view instead. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [prepared, setPrepared] = useState(() => ({ store, options, view: options ? store.prepareView(options) : null })) let current = prepared if (prepared.store !== store || prepared.options !== options) { current = { store, options, view: options ? store.prepareView(options) : null } setPrepared(current) } const view = current.view useLayoutEffect(() => view?.connect(), [view]) 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. // setTimeout's longest delay; a longer one fires at once. const MAX_TIMER_MS = 2 ** 31 - 1 // How long a batch waits for a frame before a timer flushes it: a hidden tab runs no frames. const FRAME_FALLBACK_MS = 250 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 per batch or clear, before any subscriber is notified. */ 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 /** When the current or most recent reorder hold lapses or lapsed, in Date.now() milliseconds; null before any hold. Optional: without it, parked arrival marks age from their arrival, so a hold longer than the highlight window swallows those flashes. */ holdExpiresAt?(): number | null dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } /** A render-safe snapshot with an explicitly owned, reversible connection to its store. */ export interface PreparedRowView<T> extends RowView<T> { /** Follow batches until the returned release runs. Multiple owners release independently. */ connect(): () => void } /** Publish current row, order and metadata snapshots before notifying any subscriber. */ 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 /** Prepare a current snapshot without subscriptions, registration or timers. Safe during render. */ prepareView(opts?: ViewOptions<T>): PreparedRowView<T> /** Follow the store immediately, even without subscribers. The caller must dispose the view. */ 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[] = [] // Snapshots never alias the live array: the first batch appends to ids in place. let idsSnapshot: readonly RowId[] = ids.slice() 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, meta.version) 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, prepareView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.add(view), () => views.delete(view)) return view }, createView(opts) { const view = store.prepareView(opts) view.connect() return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = [] meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true, meta.version) 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 === b) return true 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 PreparedRowView<T> { private ids: readonly RowId[] = [] private published: readonly RowId[] = [] private version: number private readonly listeners = new Set<Listener>() private holdUntil = 0 private settlePending = false private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private connections = 0 private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly attach: () => void private readonly detach: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, attach: () => void, detach: () => void) { this.store = store this.opts = opts this.attach = attach this.detach = detach this.now = opts.now ?? Date.now this.version = store.getMeta().version this.ids = this.compute() this.published = this.ids } getIds() { if (this.disposed) return this.ids const version = this.store.getMeta().version const expired = !this.connections && this.settlePending && !this.isHeld() if (version !== this.version || expired) { this.cache(this.isHeld() ? this.computeHeld() : this.compute()) this.version = version if (expired) this.settlePending = false } return this.ids } subscribe(cb: Listener) { if (this.disposed) return () => {} this.listeners.add(cb) return () => { this.listeners.delete(cb) } } connect() { if (this.disposed) return () => {} let released = false const release = () => { if (released || this.disposed) return released = true if (--this.connections === 0) { this.detach() this.clearTimer() } } if (this.connections === 0) { // Refresh while disconnected, including a hold that expired while effects were hidden. this.getIds() this.attach() } this.connections++ try { this.publish() this.armTimer() } catch (error) { release() throw error } return release } isHeld() { return this.now() < this.holdUntil } holdExpiresAt() { // The hold is kept in the view's own clock; the reported deadline is wall // time, so a grid can compare it with Date.now whatever clock was injected. return this.holdUntil > 0 ? Date.now() + (this.holdUntil - this.now()) : null } touch() { if (this.disposed) return // A hold that isn't a positive number holds nothing, and one longer than a timer can wait, Infinity included, holds // as long as one can, about 24.8 days: past that a timer fires at once. const asked = this.opts.reorderHoldMs ?? 0 const hold = asked > 0 ? Math.min(asked, MAX_TIMER_MS) : 0 if (hold <= 0) return if (!this.connections) this.getIds() this.holdUntil = this.now() + hold this.settlePending = true this.clearTimer() this.armTimer() } private armTimer() { if (this.disposed || !this.connections || !this.settlePending || this.timer !== null) return // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.settlePending = false this.cache(this.compute()) this.publish() }, Math.max(0, this.holdUntil - this.now())) } private clearTimer() { if (this.timer !== null) clearTimeout(this.timer) this.timer = null } dispose() { if (this.disposed) return this.disposed = true this.clearTimer() this.connections = 0 this.listeners.clear() this.detach() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean, version: number) { // Another view's listener may already have read our current snapshot. It cannot publish it. if (this.version === version) { this.publish() return } this.version = version if (!touched.size && !orderChanged) return if (this.isHeld()) { this.cache(this.computeHeld(removed)) this.publish() 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.cache(this.compute()) this.publish() } } private computeHeld(removed?: Set<RowId>): readonly RowId[] { // Frozen order: drop removals and rows outside the filter, then append sorted 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) return kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] } 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 cache(next: readonly RowId[]) { if (!sameOrder(this.ids, next)) this.ids = next } private publish() { if (sameOrder(this.published, this.ids)) return this.published = this.ids for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame, or after 250 ms when no frame comes. */ 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 // A hidden document runs no frames, so a timer stands in: whichever comes first flushes, and the other is cancelled. let timer: ReturnType<typeof setTimeout> | null = null // The frame scheduled now. A frame the flush cancelled can still run where it can't be cancelled, as with a `raf` // passed without its `caf`, and it finds another frame scheduled, or none, and flushes nothing. let scheduled: object | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { scheduled = null if (handle !== null) { caf(handle) handle = null } if (timer !== null) { clearTimeout(timer) timer = 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) { // Omitted fields keep their previous values all the way through: a frame that // never mentions gap must not write one, or a replay's recorded gap is // silenced mid-flight. Mentions apply in order, so a gap that opens and // closes inside one frame ends closed, exactly as two frames would. const gapMentioned = delta.meta.gap !== undefined || meta?.gap !== undefined meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), ...(gapMentioned ? { gap: delta.meta.gap ?? meta?.gap } : {}), } if (!gapMentioned) delete (meta as { gap?: boolean }).gap } if (handle === null && timer === null) { const frame = {} scheduled = frame const requested = raf(() => { if (scheduled !== frame) return handle = null flush() }) // A frame that ran as it was requested has flushed already, and leaves nothing to hold. if (scheduled === frame) { handle = requested timer = setTimeout(() => { timer = null flush() }, FRAME_FALLBACK_MS) } } }, flush, cancel() { scheduled = null if (handle !== null) caf(handle) if (timer !== null) clearTimeout(timer) handle = null timer = null reset() }, pending: () => handle !== null || timer !== 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 /** Whether a higher quote means a lower price, so a normal market quotes the bid above the offer. Defaults by basis: yield and discount invert; price and spread do not, since a CDS spread quotes bid below offer while cash credit quotes the other way — declare it for the instrument when the default reads wrong. */ quoteInverted?: boolean } 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>() /** True for a time a `Date` can hold: a finite number of milliseconds within 8.64e15 of the epoch either way. */ export const isInstant = (ms: unknown): ms is number => Number.isFinite(ms) && Math.abs(ms as number) <= 8.64e15 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) // From 1e21 a whole part prints in exponent form, and further up the scaling overflows: neither is the notation, // and parsePrice refuses the same range. if (!(whole < 1e21)) return NULL_TOKEN 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) } } /** * Numeric text, trimmed and with either minus sign read as "-", with its thousands separators removed, or null when a * comma in it isn't one. A comma groups * thousands in the whole part only: a first group of one to three digits with no leading zero, then groups of three, * and none after the point or among the 32nds, so "99,5" from a decimal-comma keyboard is no number, never 995. With * `decimals`, for text whose notation prints digits after the point, one comma with nothing after the whole part is * no number either, since "4,253" reads as 4253 grouped or as 4.253 typed with a decimal comma; two or more groups, * or a point or a fraction's dash after them, can only be grouping. Text without a comma comes back trimmed with the minus * read, and the rest of any text is left to the caller's own pattern, which decides what is a number. */ export function stripGrouping(input: string, options: { decimals: boolean }): string | null { const text = input.trim().replace(/−/g, "-") if (!text.includes(",")) return text const m = /^([+-]?)([1-9]\d{0,2}(?:,\d{3})+)((?:[.-].*)?)$/.exec(text) if (!m || m[3]!.includes(",")) return null const whole = m[2]! if (options.decimals && !m[3] && whole.indexOf(",") === whole.lastIndexOf(",")) return null return m[1]! + whole.replace(/,/g, "") + m[3]! } // Whether a convention's own text carries digits after the point, or a fraction's ticks. const printsDecimals = (c: PriceConvention) => (c.kind === "fraction" ? true : c.kind === "decimal" ? c.decimals > 0 : decimalsFromTick(c.tick) > 0) /** * 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 = stripGrouping(s, { decimals: printsDecimals(c) }) 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 // What cannot print back in the notation is refused: unit scaling must stay finite, the // whole part must stay out of exponent form (1e21 up), and "-0" stays plain zero. if (value >= 1e21 || !Number.isFinite(value * c.denominator * (c.eighths ? 8 : 2))) return null return value === 0 ? 0 : sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Every convention keeps the promise the reference row makes: what parses can print back. // Intl prints tick and decimal prices in full digits at any finite size, so only fraction // notation carries the 1e21 bound; tick scaling must stay finite, and zero stays plain zero. if (c.kind === "tick") { const rounded = roundToTick(n, c.tick) return Number.isFinite(rounded) ? (rounded === 0 ? 0 : rounded) : null } if (c.kind === "decimal") { const fixed = Number(n.toFixed(c.decimals)) return fixed === 0 ? 0 : fixed } // A decimal typed into a fraction convention snaps to the printable grid, so the value a field // reports is the price its formatted text shows. The magnitude rounds, as the formatter rounds, // so a negative tie snaps away from zero the way it prints. const unitsPerWhole = c.denominator * (c.eighths ? 8 : 2) const units = Math.round(Math.abs(n) * unitsPerWhole) // Scaling can overflow a finite input, a magnitude from 1e21 up prints in exponent form the // notation cannot parse back, and a sub-half-unit negative would make negative zero. if (!Number.isFinite(units) || units / unitsPerWhole >= 1e21) return null return units === 0 ? 0 : (n < 0 ? -units : units) / unitsPerWhole } /** 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; `grouping: false` drops the thousands separators, for an editor's text. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string; grouping?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero", ...(o.grouping === false ? { useGrouping: false } : {}) }).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, so digits and the dash take one width; tails still lengthen a quote. */ 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 * digits and the dash take one width down a column; a tailed quote still runs longer than an untailed one. * 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" } /** Whether a higher quote means a lower price for this instrument: declared, else by basis — yield and discount invert, price and spread do not. */ export function quoteInvertedOf(c: InstrumentConvention): boolean { if (c.quoteInverted !== undefined) return c.quoteInverted const basis = quoteBasisOf(c) return basis === "yield" || basis === "discount" } /** 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 = stripGrouping(s, { decimals: quoteDecimalsOf(c) > 0 }) if (text === null || !/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null // Snapping divides by the step, which can overflow where the raw number does not; what // cannot print back is not a quote. const snapped = roundToTick(n, quoteStepOf(c)) return Number.isFinite(snapped) ? snapped : 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,
PriceChartHeader,
PriceChartLast,
PriceChartChange,
PriceChartReadout,
PriceChartPlot,
PriceChartEmpty,
} 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">
<PriceChartHeader>
<PriceChartLast />
<PriceChartChange />
<PriceChartReadout />
</PriceChartHeader>
<PriceChartPlot>
<PriceChartEmpty />
</PriceChartPlot>
</PriceChart>
</div>
)
}
Keep the store stable across renders and key each bar by its start time with barId.
Use upsert to load complete bars, or build bars from incoming ticks.
Composition
Use the following composition to build a PriceChart:
PriceChart
├── PriceChartHeader
│ ├── PriceChartLast
│ ├── PriceChartChange
│ └── PriceChartReadout
├── PriceChartPlot
│ └── PriceChartEmpty
└── PriceChartLegend
└── PriceChartOverlaySwatch
Mount one PriceChartPlot per root. Move or omit the readings, header, and legend to fit your layout.
Use your own elements for headings, actions, and legend rows.
Sidebar and footer
Place the readings and legend in an aside and the readout in a footer.
Use usePriceChart to add custom content.
import { useState } from "react"
import { NUMERIC_CLASS, type InstrumentConvention } from "@/lib/format"
import { barId, type Bar } from "@/lib/price-series"
import { createRowStore } from "@/lib/row-store"
import { PriceChart, usePriceChart, PriceChartLegend, PriceChartOverlaySwatch, PriceChartLast, PriceChartChange, PriceChartReadout, PriceChartPlot, PriceChartEmpty, 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 },
]
function CursorDetails() {
const { bar, bars, readout } = usePriceChart()
return (
<footer className="space-y-1 border-t pt-2 text-xs sm:col-span-2">
<p className={`${NUMERIC_CLASS} text-muted-foreground`}>{bars.length} one-minute bars · Chicago time</p>
<PriceChartReadout className="ml-0 block">{bar ? readout : "Focus the plot or point at a bar to inspect it."}</PriceChartReadout>
</footer>
)
}
export default function PriceChartLayoutDemo() {
const [store] = useState(() => {
const store = createRowStore<Bar>({ getRowId: (bar) => barId(bar.time), lane: "ordered" })
store.applyDeltas({ upsert: bars })
return store
})
return (
<div className="w-2xl max-w-full">
<PriceChart store={store} convention={ZN} label="ZN · Session research" zone="America/Chicago" baseline={110.5} overlays={overlays} labels={{ noData: "Waiting for sample bars." }} className="grid h-auto grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_9rem]">
<h3 className="text-sm font-semibold sm:col-span-2">ZN · Session research</h3>
<PriceChartPlot className="h-56"><PriceChartEmpty /></PriceChartPlot>
<aside className="space-y-3">
<div className="flex flex-wrap items-baseline gap-2 sm:flex-col">
<span className="text-muted-foreground">Last / change</span>
<PriceChartLast />
<PriceChartChange />
</div>
<PriceChartLegend className="flex-col gap-2 px-0">
{[...overlays].reverse().map((overlay) => (
<li key={overlay.id} className="flex items-center gap-2">
<PriceChartOverlaySwatch overlayId={overlay.id} />
<span>{overlay.label}</span>
</li>
))}
</PriceChartLegend>
</aside>
<CursorDetails />
</PriceChart>
</div>
)
}
Candles
Use kind="candles" to display open, high, low, and close prices.
Each candle's color compares its close with its open.
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, PriceChartHeader, PriceChartLast, PriceChartChange, PriceChartReadout, PriceChartPlot, PriceChartEmpty } 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">
<PriceChartHeader>
<PriceChartLast />
<PriceChartChange />
<PriceChartReadout />
</PriceChartHeader>
<PriceChartPlot>
<PriceChartEmpty />
</PriceChartPlot>
</PriceChart>
</div>
)
}
Overlays
Use overlays to add lines and PriceChartLegend to render their labels.
The example's bar VWAP uses prices and volumes from the displayed sample, not a full trading session.
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, PriceChartLegend, PriceChartOverlaySwatch, PriceChartHeader, PriceChartLast, PriceChartChange, PriceChartReadout, PriceChartPlot, PriceChartEmpty, 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">
<PriceChartHeader>
<PriceChartLast />
<PriceChartChange />
<PriceChartReadout />
</PriceChartHeader>
<PriceChartPlot>
<PriceChartEmpty />
</PriceChartPlot>
<PriceChartLegend>{overlays.map((overlay) => <li key={overlay.id} className="flex items-center gap-1"><PriceChartOverlaySwatch overlayId={overlay.id} />{overlay.label}</li>)}</PriceChartLegend>
</PriceChart>
</div>
)
}
Incoming ticks
Use foldTicks to fold trade batches into bars.
Select Apply next tick batch three times. Focus the plot and press Home to inspect the late correction.
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, PriceChartHeader, PriceChartLast, PriceChartChange, PriceChartReadout, PriceChartPlot, PriceChartEmpty } 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">
<PriceChartHeader>
<PriceChartLast />
<PriceChartChange />
<PriceChartReadout />
</PriceChartHeader>
<PriceChartPlot>
<PriceChartEmpty />
</PriceChartPlot>
</PriceChart>
<p role="status" className="text-muted-foreground">{batches[step - 1]?.message ?? "No ticks received."}</p>
</div>
</>
)
}
API Reference
Props
PriceChartProps extends the native div props and requires children.
The root sets role="group", aria-label, and chart data attributes. Other props, refs, and handlers pass through.
The plot keeps its accessible summary and cursor value when you omit visible readings or the legend.
For v1 integrations, see the migration guide.
| Prop | Type | Default | Purpose |
|---|---|---|---|
children |
ReactNode |
Required | Your plot, readings, legend and application content. |
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. A zone the runtime does not know falls back to the runtime's own, on the axis as in the readout. |
locale |
string |
en-US |
Locale of the readout's clock. |
overlays |
readonly PriceChartOverlay[] |
None | Lines over the bars. Compose their legend separately. |
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 | Receives the selected bar or null on cursor interaction and pointer-driven index changes. Data clamping is silent. See Keyboard. |
className |
string |
None | Classes on the root. |
style |
CSSProperties |
None | Inline styles on the root. |
Public parts
Each part accepts its element's native props and ref. Use className to extend its styles.
Place the parts and usePriceChart inside PriceChart. PriceChartHeader can also be used on its own.
| Part | Element | Content and behavior |
|---|---|---|
PriceChartHeader |
div |
Your children in a wrapping row. No store or cursor subscription. |
PriceChartLast |
span |
Last close, or labels.noData. Direction color follows the move; the font follows the price notation (convention.price) — mono for a fraction, whatever the quote basis. |
PriceChartChange |
span |
Signed price and percentage change. Renders nothing with no bars. |
PriceChartReadout |
span |
Selected bar's formatted time, prices and optional volume. Blank with no selection. Defaults to ml-auto for a header row. Use ml-0 in other layouts. |
PriceChartPlot |
div |
Canvas, resize/theme observers and keyboard crosshair. Accepts children for an empty state. |
PriceChartEmpty |
div |
labels.noData, shown only with no finite bars and positioned over the plot. Custom children replace only the visible text. The plot's accessible name uses labels.noData. |
PriceChartLegend |
ul |
Requires caller-owned children, usually li rows. Defaults its accessible name to labels.overlays. No automatic rows or hiding. |
PriceChartOverlaySwatch |
span |
Requires overlayId: string. Decorative swatch resolved from the plot's overlay order and color. Unknown ids render nothing. |
Pass children to PriceChartLast, PriceChartChange, PriceChartReadout, or PriceChartEmpty to replace the default text. Use undefined for the default or null for no content.
Numeric readings keep the price notation's font outside the header — mono for a fraction, whatever the quote basis, since the readings print convention.price. Each part keeps its data-chart-* markers.
PriceChartPlot sets its role, accessible name, tab stop, and value attributes.
Its onKeyDown, onFocus, and onBlur call your handler first. Call preventDefault() to cancel the built-in behavior for that event; on focus and blur that is the cursor's move, and the plot still holds its readings on focus and lets them go on blur.
usePriceChart
Use usePriceChart() for custom readings. It returns the following read-only PriceChartState:
| Field | Type | Description |
|---|---|---|
bars |
readonly Bar[] |
Finite bars, sorted by time. |
summary |
SeriesSummary |
Summary from price-series.ts. |
cursor |
number | null |
Selected index clamped to the current bars. null when unselected or empty. |
bar |
Bar | null |
Selected bar, or null. |
readout |
string |
Formatted cursor text. |
overlays |
readonly PriceChartOverlay[] |
Overlay definitions from the root. |
convention |
PriceConvention | InstrumentConvention |
Price or instrument convention from the root. |
labels |
PriceChartLabels |
Labels with defaults and overrides merged. |
The hook adds no subscription or effect. All readings share the root's one store subscription.
Update data through the store and move the cursor through the plot.
Plot lifecycle
PriceChartPlot draws with uPlot on a canvas. It waits for a positive ResizeObserver size and at least one finite bar, then makes the plot at its content box as laid out, which a transform doesn't scale, or at the last observed size while the box is hidden or out of the document.
| Change | Behavior |
|---|---|
| Box size | Resizes the plot to each observed size it doesn't already have, so a plot made while its box was hidden fits the box once it shows. |
| Window | Recreates the plot in a popout's window once a resize or a theme change notices the move, and again when the popout closes and hands it back. |
kind, crosshair, lastLine, zone, or serialized convention |
Recreates the plot. |
| Overlay ids, colors, widths, or order | Recreates the plot. |
baseline alone |
Updates the readings without recalculating the price scale. An out-of-range reference can stay offscreen until data updates or the plot is recreated. |
| Theme or mode | Repaints the colors on an <html> class, style, data-theme, or data-accessibility change, and on a system color-scheme flip. A theme switched on a container alone keeps the painted colors until one of those changes. A chart in a popout watches its own window's root as well and reads the colors once the popout has copied the page's theme there. A plot out of the document, as in a background tab, keeps its colors and axis font until it's back, then reads them. |
--tradecn-font-mono stack |
Recreates the plot to update its axis font, on the same triggers. |
| Empty store or unmount | Destroys the plot. |
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. |
Use the helpers in price-series.ts to build bars in a feed layer or worker without React.
Pass a positive, finite intervalMs in milliseconds. The helpers do not validate it.
foldTicks skips ticks with non-finite at or price. 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. |
Apply the DeltaBatch<Bar> returned by foldTicks to your store for each received batch. Choose a retention policy for older bars.
foldTicks sorts each batch by time before folding. A tick for a missing historical bar inserts it.
Late ticks update the bar's high, low, and volume. Earlier ticks update its open, and ticks at or after its latest timestamp update its close.
A whole bar without firstAt keeps its open. Without lastAt, its first folded tick replaces its close.
columnsOf returns BarColumns with the filtered bars and arrays for time, open, high, low, close, and volume.
Bars are sorted by time on each call if needed, without reordering the store. Invalid or absent volume becomes null in the volume column.
summarize calculates percentage change as change / Math.abs(reference) * 100. A zero reference gives null.
Empty input returns EMPTY_COLUMNS and EMPTY_SUMMARY respectively. The summary has a flat direction and no first or last bar.
Overlays
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | The key. |
label |
string |
Required | The line name. Use it in your legend rows. |
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. |
Use CHART_TOKEN_CLASS for a custom key. It contains bg-chart-1 through bg-chart-8.
Use PriceChartOverlaySwatch to keep each legend row's color when you reorder rows. Matching a label to its plotted line still depends on color.
Keep values pure and overlay definitions stable. Values are recalculated when the plot is created or recreated, or its data or overlay structure changes.
Batches that change only metadata also recalculate values. Replacing only a values function takes effect on the next such update.
Return null to leave a gap. Missing and non-finite values also leave gaps, and extra values are ignored.
The readout
Use PriceChartLast to display the last close in the direction's color.
Use PriceChartChange for the signed change and percentage from the reference. It follows the convention, such as +0-02 for fractions or +1.25 for decimals.
Use PriceChartReadout for the selected bar's time, prices, and optional volume. It shows the close for a line, or all four prices for candles.
Keep PriceChartChange or your own sign beside PriceChartLast. Color alone does not tell every reader the direction.
The last close determines direction against the reference. Equal prices are flat.
The root, PriceChartLast, and PriceChartChange carry data-direction.
The plot's accessible name includes the direction, last price, change, range, and bar count. While the plot has focus, the name keeps the reading it took focus with, and the value text keeps the selected bar's readout as it was when the selection reached that bar, or the held reading with no bar selected, so a live feed doesn't make a screen reader read either again; moving the selection, or taking focus, reads the bar as it is then. Both are read afresh when the store, label, kind, notation, baseline, zone, locale, or labels change, and once more at the store's next batch, so bars that land after their label are read too. Both are read once when the series changes, which the plot knows by its first bar's time and open, and the value text alone when another bar comes under the selection, known by its place, time, and open: a reload, a window that drops old bars, a correction, and a late print earlier than a bar's first tick each can do either. A reload that keeps the first bar and the selected bar is held like a tick. To have every load read afresh, clear the store in the same update that changes the label. With no bar selected, the slider's value rests at the bar the held name was read at, inside the bars there are. Without focus, the value text is the selection's readout as it is:
ZN, today: up, last 110-18, +0-02 (+0.06%), low 110-15, high 110-19, 3 bars
Keyboard
With a crosshair and at least one finite bar, the plot is a horizontal slider.
Focus selects the last bar unless a bar is already selected. With no bars or crosshair={false}, the plot is an img without a tab stop, except that a focused plot whose bars go, or whose crosshair turns off, keeps its tab stop until focus leaves.
| 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 | Clear the crosshair. Blur also clears it. |
| Ctrl, Cmd, or Alt with any key | Left to listeners above the chart, such as a hotkey registry. |
Use the pointer or keyboard to move the crosshair. The readout and onCursor follow the most recent input.
Root bubbling handlers receive events after the plot handles them. Handled keys have defaultPrevented set, leaving other keys available to application hotkeys.
Keyboard selection follows its index as data updates.
If bars remain and the index is out of range, it clamps to the last bar without calling onCursor. Appended bars keep the clamped index.
Data updates keep a pointer-controlled crosshair at its pixel coordinates. A scale change can select a different index and call onCursor.
Updating a bar at the same index changes the readout without a callback. Mounting does not call onCursor, and unmounting does not send null.
Recreating the plot restores the selected bar silently, including pointer selections. The crosshair follows the selected index until the pointer takes control again.
Marks
| Attribute | Where | Meaning |
|---|---|---|
data-kind |
The root | line or candles. |
data-direction |
The root, Last and Change | 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 |
Header | Caller-owned header content. |
data-chart-last, data-chart-change, data-chart-readout |
Each reading | The three readings, wherever placed. |
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 |
Legend | Caller-owned rows. |
data-chart-swatch |
Overlay swatch | The plot token for its overlay id. |
Labels
labels merges partial overrides into DEFAULT_PRICE_CHART_LABELS:
| Label | Default | Where |
|---|---|---|
noData |
No data |
Last, Empty and the plot name 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 |
Use locale to change the readout's clock format. It does not change the axis locale or translate the fixed words last, low, and high in the accessible name.
Tokens
The install adds missing up, down, and flat tokens with their soft variants, plus chart-1 through chart-8, plus the shared font tokens and the hyperlegible remap.
The item palette adapts Okabe and Ito's palette with lightness adjusted for each mode. It uses orange, sky blue, bluish green, yellow, blue, vermilion, reddish purple, and your foreground for black.
Existing shadcn chart tokens are preserved during installation. Each tradecn theme sets all eight chart tokens.
Overlays use your palette's value at their assigned slot. Use legend labels to name the lines.
Set --tradecn-font-mono to change the axis and last-price tag's tabular canvas font. The canvas re-reads its palette on <html> attribute changes and system scheme flips, the triggers the lifecycle table names.
What it does not do
The chart does not provide zoom, pan, indicators, a volume pane, or multiple instruments on one axis.
Use foldTicks to aggregate one interval and a separate store for each additional interval.
Pass the venue's zone and previous close as baseline. The chart does not determine trading sessions.
This page is docs/price-chart.md at v2.0.0.