DepthLadder
A price ladder centered on the market: bid and ask sizes per tick, the desk's own size marked, flashes when market sizes change, and a click that stages a price and a side.
import { useState } from "react"
import { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DepthLadder, levelId, tickIndexOf, type DepthLevel, type LadderStage } from "@/components/ui/depth-ladder"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const format = createInstrumentFormatter(ZN)
const mid = 110.5
const tick = tickIndexOf(mid, ZN.tick)
export default function DepthLadderDemo() {
const [store] = useState(() => {
const store = createRowStore<DepthLevel>({ getRowId: (level) => levelId(level.tick) })
store.applyDeltas({ upsert: [
{ tick: tick - 2, bidSize: 300, myBid: 25 },
{ tick: tick - 1, bidSize: 220 },
{ tick: tick + 1, askSize: 180 },
{ tick: tick + 2, askSize: 320, myAsk: 15 },
] })
return store
})
const [staged, setStaged] = useState<LadderStage | null>(null)
return (
<div className="w-72 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-56">
<DepthLadder store={store} convention={ZN} mid={mid} label="ZN ladder" depth={4} onStage={setStaged} />
</div>
<p role="status" className="text-muted-foreground">{staged ? `Staged: ${staged.side} at ${format.price(staged.price)}.` : "Nothing staged."}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/depth-ladder#v1.4.13
npx shadcn@latest add tradecn/ui/depth-ladder#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/depth-ladder#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/depth-ladder#v1.4.13
-
Install the dependencies:
pnpm add cn @tanstack/react-virtualnpm install cn @tanstack/react-virtualyarn add cn @tanstack/react-virtualbun add cn @tanstack/react-virtual -
Copy the files into your project:
components/ui/ depth-ladder.tsx import { useVirtualizer } from "@tanstack/react-virtual" import { cn } from "cn" import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, type UIEvent } from "react" import { useFlash } from "@/hooks/use-flash" import { useRow } from "@/hooks/use-row-store" import { NUMERIC_CLASS, createInstrumentFormatter, formatQuantity, numericFontClass, roundToTick, type InstrumentConvention } from "@/lib/format" import type { RowStore } from "@/lib/row-store" // A price ladder: one rung per tick over a range of prices, the bid size on the left of the price and the // ask size on its right, the desk's own resting size marked on its rung, and one flash per level change. // The levels come from a row store keyed by tick index, so a change at one price wakes one rung and // nothing else. The ladder keeps the market's mid at its center until a key, a pointer, or a scroll // touches it; then the prices stay where they are and a button or Home puts the mid back in the middle. // // A click in the bid column hands { price, side: "buy" } to the consumer through onStage, a click in the // ask column a sell; the ladder sends nothing anywhere. /** One price level of the book, keyed by its tick index so a change at one price wakes one rung. */ export interface DepthLevel { /** The price as a count of ticks from zero, `tickIndexOf(price, tickSize)`. The row's id is `levelId(tick)`. */ tick: number /** Size resting on the bid at this price. Absent, null, or zero prints nothing. */ bidSize?: number | null /** Size resting on the offer at this price. */ askSize?: number | null /** The desk's own size resting on the bid here. Marks the rung `data-mine`. */ myBid?: number | null /** The desk's own size resting on the offer here. */ myAsk?: number | null } /** A column of the ladder: the bid sizes, the prices, the ask sizes. */ export type LadderColumn = "bid" | "price" | "ask" /** What a click or Enter on a size cell hands the consumer. The ladder sends nothing itself. */ export interface LadderStage { price: number /** The bid column stages a buy, the ask column a sell. */ side: "buy" | "sell" tick: number /** The level at that price, if the store has one. */ level: DepthLevel | undefined } export interface DepthLadderLabels { /** Column headers. */ bid: string price: string ask: string /** The button that puts the mid back in the middle. */ recenter: string /** Said to a screen reader after the desk's own size. */ mine: string /** Said to a screen reader for the rung at the market's mid. */ mid: string /** Shown while there is no mid to build the ladder around. */ noMarket: string } export const DEFAULT_DEPTH_LADDER_LABELS: DepthLadderLabels = { bid: "Bid", price: "Price", ask: "Ask", recenter: "Recenter", mine: "yours", mid: "Mid", noMarket: "No market", } export interface DepthLadderProps { /** Levels keyed by tick index: `createRowStore<DepthLevel>({ getRowId: (l) => levelId(l.tick) })`. */ store: RowStore<DepthLevel> /** Prints the prices and sets the tick. */ convention: InstrumentConvention /** The market's mid. The ladder centers on it until touched. Null or undefined while there is no market. */ mid: number | null | undefined /** Accessible name of the ladder. */ label: string /** Ticks shown above and below the center. Default 200. */ depth?: number /** Rung height in px. Default 22. */ rowHeight?: number /** Extra rungs rendered beyond the viewport. Default 8. */ overscan?: number /** A click or Enter on a size cell. */ onStage?: (stage: LadderStage) => void /** Prints a size. `formatQuantity` by default. Keep it stable between renders. */ formatSize?: (size: number) => string /** Cell flash duration in ms. Default 900. */ flashWindowMs?: number labels?: Partial<DepthLadderLabels> /** Shown while there is no mid. `labels.noMarket` by default. */ emptyState?: ReactNode className?: string /** Viewport size in px before measurement, for tests or server rendering. */ initialRect?: { width: number; height: number } } /** The row id of a level: its tick index as a string. */ export function levelId(tick: number): string { return String(tick) } /** A price as a count of ticks from zero, rounded to the grid: `99.515625` on a `1 / 64` tick is `6369`. */ export function tickIndexOf(price: number, tickSize: number): number { return Math.round(price / tickSize) } /** The price at a tick index, cleaned of float noise: `6369` on a `1 / 64` tick is `99.515625`. */ export function priceAtTick(tick: number, tickSize: number): number { return roundToTick(tick * tickSize, tickSize) } const FILL_CLASSES = "data-[direction=up]:bg-up-soft data-[direction=down]:bg-down-soft data-[direction=flat]:bg-flat-soft" const TEMPLATE = "minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr)" const defaultSize = (size: number) => formatQuantity(size) function sizeOf(value: number | null | undefined): number | null { return typeof value === "number" && Number.isFinite(value) && value !== 0 ? value : null } interface SizeCellProps { side: "bid" | "ask" size: number | null mine: number | null focused: boolean format: (size: number) => string windowMs: number mineLabel: string onClick: () => void } function SizeCell(p: SizeCellProps) { const ref = useRef<HTMLDivElement>(null) // One flash per level change: the market's size at this price moved. useFlash(ref, p.size, { windowMs: p.windowMs, variant: "fill" }) return ( <div ref={ref} role="gridcell" aria-colindex={p.side === "bid" ? 1 : 3} data-col={p.side} data-side={p.side} data-numeric="" data-mine={p.mine !== null ? "" : undefined} data-focused-col={p.focused || undefined} className={cn( "flex h-full min-w-0 cursor-pointer items-center gap-1 truncate px-2", NUMERIC_CLASS, FILL_CLASSES, p.side === "bid" ? "justify-end text-up" : "justify-start text-down", p.focused && "bg-muted/50", )} onClick={p.onClick} > {p.mine !== null && ( <span data-mine-size="" className="rounded-sm bg-primary/15 px-1 text-primary"> {p.format(p.mine)} <span className="sr-only"> {p.mineLabel}</span> </span> )} {p.size !== null && <span>{p.format(p.size)}</span>} </div> ) } interface RungProps { store: RowStore<DepthLevel> tick: number index: number start: number height: number domId: string priceText: string priceClass: string mid: boolean /** The focused column when this rung holds the focus; null otherwise. */ focusedCol: LadderColumn | null formatSize: (size: number) => string flashWindowMs: number labels: DepthLadderLabels onCell: (tick: number, col: LadderColumn) => void } function RungInner(p: RungProps) { const level = useRow(p.store, levelId(p.tick)) const bid = sizeOf(level?.bidSize) const ask = sizeOf(level?.askSize) const myBid = sizeOf(level?.myBid) const myAsk = sizeOf(level?.myAsk) const mine = myBid !== null ? (myAsk !== null ? "both" : "bid") : myAsk !== null ? "ask" : undefined const focused = p.focusedCol !== null return ( <div role="row" id={p.domId} data-tick={p.tick} data-mid={p.mid ? "" : undefined} data-mine={mine} data-focused={focused || undefined} aria-rowindex={p.index + 2} aria-description={p.mid ? p.labels.mid : undefined} className={cn( "absolute top-0 left-0 grid w-full items-stretch border-b border-border/60", p.mid && "border-y border-primary/60 bg-muted/60", focused && "outline-1 -outline-offset-1 outline-ring", )} style={{ gridTemplateColumns: TEMPLATE, height: p.height, transform: `translateY(${p.start}px)` }} > <SizeCell side="bid" size={bid} mine={myBid} focused={p.focusedCol === "bid"} format={p.formatSize} windowMs={p.flashWindowMs} mineLabel={p.labels.mine} onClick={() => p.onCell(p.tick, "bid")} /> <div role="gridcell" aria-colindex={2} data-col="price" data-numeric="" data-focused-col={p.focusedCol === "price" || undefined} className={cn("flex h-full min-w-0 items-center justify-center truncate px-2 text-foreground", p.priceClass, p.focusedCol === "price" && "bg-muted/50")} onClick={() => p.onCell(p.tick, "price")} > {p.priceText} </div> <SizeCell side="ask" size={ask} mine={myAsk} focused={p.focusedCol === "ask"} format={p.formatSize} windowMs={p.flashWindowMs} mineLabel={p.labels.mine} onClick={() => p.onCell(p.tick, "ask")} /> </div> ) } const Rung = memo(RungInner) interface Focus { tick: number col: LadderColumn } /** Where the scroll box sits when the rung at `index` is in its middle, within what the box can scroll to. */ function centeredTop(el: HTMLElement, index: number, rowHeight: number): number { const max = Math.max(0, el.scrollHeight - el.clientHeight) const want = index * rowHeight - (el.clientHeight - rowHeight) / 2 return Math.min(max, Math.max(0, want)) } export function DepthLadder({ store, convention, mid, label, depth = 200, rowHeight = 22, overscan = 8, onStage, formatSize = defaultSize, flashWindowMs = 900, labels: labelsProp, emptyState, className, initialRect }: DepthLadderProps) { const labels = useMemo<DepthLadderLabels>(() => ({ ...DEFAULT_DEPTH_LADDER_LABELS, ...labelsProp }), [labelsProp]) const tickSize = convention.tick const format = useMemo(() => createInstrumentFormatter(convention), [convention]) const priceClass = numericFontClass(convention) const midTick = mid === null || mid === undefined || !Number.isFinite(mid) ? null : tickIndexOf(mid, tickSize) // The anchor is the tick in the middle of the range of rungs. It is taken from the first mid seen, and // while the ladder follows the market it moves with a mid that has drifted more than half the depth // away, so the range is always built around the market. Under a hand it stays put: the prices on // screen are the prices under the pointer. const [anchor, setAnchor] = useState<number | null>(null) const [following, setFollowing] = useState(true) const [focused, setFocused] = useState<Focus | null>(null) if (midTick !== null && (anchor === null || (following && Math.abs(midTick - anchor) > depth / 2))) setAnchor(midTick) const count = anchor === null ? 0 : depth * 2 + 1 const indexOf = (tick: number) => (anchor ?? 0) + depth - tick const tickAt = (index: number) => (anchor ?? 0) + depth - index const scrollRef = useRef<HTMLDivElement>(null) const virtualizer = useVirtualizer({ count, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, getItemKey: tickAt, initialRect, // The mid sits at index `depth` when the range is first built, so the first frame is already centered. initialOffset: () => (initialRect ? Math.max(0, depth * rowHeight - (initialRect.height - rowHeight) / 2) : 0), }) const items = virtualizer.getVirtualItems() const uid = useId() const domId = (tick: number) => `${uid}-${tick}` // Following: the box is scrolled so the mid rung is in the middle, after every mid and whenever // following resumes. The effect only reads the state; the handlers below are what change it. useLayoutEffect(() => { const el = scrollRef.current if (!el || !following || midTick === null || anchor === null) return el.scrollTop = centeredTop(el, anchor + depth - midTick, rowHeight) }, [following, midTick, anchor, depth, rowHeight]) const latest = useRef({ onStage, store, tickSize }) useEffect(() => { latest.current = { onStage, store, tickSize } }) const hold = useCallback(() => setFollowing(false), []) const recenter = () => { if (midTick === null) return setAnchor(midTick) setFollowing(true) } const stage = useCallback((tick: number, col: LadderColumn) => { if (col === "price") return const { onStage, store, tickSize } = latest.current onStage?.({ price: priceAtTick(tick, tickSize), tick, side: col === "bid" ? "buy" : "sell", level: store.getRow(levelId(tick)) }) }, []) const onCell = useCallback( (tick: number, col: LadderColumn) => { setFocused({ tick, col }) stage(tick, col) }, [stage], ) // A scroll the effect above did not ask for is a hand on the ladder. The effect puts the box exactly at // the centered offset, so a scroll event that reads anything else came from a wheel, a bar, or a touch. const onScroll = (e: UIEvent<HTMLDivElement>) => { if (!following || midTick === null || anchor === null) return const el = e.currentTarget if (Math.abs(el.scrollTop - centeredTop(el, anchor + depth - midTick, rowHeight)) > 1) hold() } const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (anchor === null) return // A modifier-held arrow belongs to a hotkey registry above the ladder. if (e.altKey || e.ctrlKey || e.metaKey) return if (e.key === "Home") { e.preventDefault() recenter() return } if (e.key === "Enter") { if (focused && focused.col !== "price") { e.preventDefault() stage(focused.tick, focused.col) } return } const lo = anchor - depth const hi = anchor + depth const clampTick = (tick: number) => Math.min(hi, Math.max(lo, tick)) const current: Focus = focused ?? { tick: clampTick(midTick ?? anchor), col: "price" } const pageRows = Math.max(1, Math.floor((scrollRef.current?.clientHeight || initialRect?.height || rowHeight * 10) / rowHeight)) let next: Focus switch (e.key) { case "ArrowUp": next = { ...current, tick: clampTick(current.tick + 1) } break case "ArrowDown": next = { ...current, tick: clampTick(current.tick - 1) } break case "PageUp": next = { ...current, tick: clampTick(current.tick + pageRows) } break case "PageDown": next = { ...current, tick: clampTick(current.tick - pageRows) } break case "ArrowLeft": next = { ...current, col: current.col === "ask" ? "price" : "bid" } break case "ArrowRight": next = { ...current, col: current.col === "bid" ? "price" : "ask" } break default: return } e.preventDefault() hold() setFocused(next) virtualizer.scrollToIndex(indexOf(next.tick), { align: "auto" }) } const focusedInRange = focused !== null && anchor !== null && Math.abs(focused.tick - anchor) <= depth return ( <div role="grid" data-slot="tradecn-depth-ladder" data-following={following ? "true" : "false"} tabIndex={0} aria-label={label} aria-rowcount={count + 1} aria-colcount={3} aria-activedescendant={focusedInRange ? domId(focused.tick) : undefined} onKeyDown={onKeyDown} className={cn("relative flex h-full min-h-0 flex-col overflow-hidden rounded-md border border-border bg-background text-xs text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/40 lining-nums tabular-nums", className)} style={{ lineHeight: `${rowHeight}px` } as CSSProperties} > <div role="row" aria-rowindex={1} className="grid shrink-0 border-b border-border text-muted-foreground" style={{ gridTemplateColumns: TEMPLATE, height: rowHeight }}> <div role="columnheader" aria-colindex={1} className="flex items-center justify-end truncate px-2"> {labels.bid} </div> <div role="columnheader" aria-colindex={2} className="flex items-center justify-center truncate px-2"> {labels.price} </div> <div role="columnheader" aria-colindex={3} className="flex items-center justify-start truncate px-2"> {labels.ask} </div> </div> <div ref={scrollRef} className="relative min-h-0 flex-1 overflow-auto" onPointerDownCapture={hold} onWheel={hold} onScroll={onScroll}> {count === 0 ? ( <div className="flex h-full items-center justify-center p-4 text-muted-foreground">{emptyState ?? labels.noMarket}</div> ) : ( <div role="rowgroup" className="relative" style={{ height: virtualizer.getTotalSize() }}> {items.map((item) => { const tick = tickAt(item.index) return ( <Rung key={item.key} store={store} tick={tick} index={item.index} start={item.start} height={item.size} domId={domId(tick)} priceText={format.price(priceAtTick(tick, tickSize))} priceClass={priceClass} mid={tick === midTick} focusedCol={focused?.tick === tick ? focused.col : null} formatSize={formatSize} flashWindowMs={flashWindowMs} labels={labels} onCell={onCell} /> ) })} </div> )} </div> {!following && midTick !== null && ( <button type="button" data-ladder-recenter="" onClick={recenter} className="absolute bottom-2 left-1/2 z-30 -translate-x-1/2 rounded-full bg-primary px-2.5 py-0.5 text-primary-foreground shadow-sm hover:bg-primary/90"> {labels.recenter} </button> )} </div> ) }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]) }hooks/use-row-store.ts import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react" import type { RowId, RowStore, RowView, StoreMeta, ViewOptions } from "@/lib/row-store" /** One row. The component re-renders when this row is replaced or patched, and for nothing else. */ export function useRow<T>(store: RowStore<T>, id: RowId): T | undefined { const subscribe = useCallback((cb: () => void) => store.subscribeRow(id, cb), [store, id]) const get = useCallback(() => store.getRow(id), [store, id]) return useSyncExternalStore(subscribe, get, get) } /** The ids of a store (insertion or authoritative order) or of a view (filtered, sorted, held). */ export function useRowIds<T>(source: RowStore<T> | RowView<T>): readonly RowId[] { const subscribe = useCallback((cb: () => void) => ("subscribeOrder" in source ? source.subscribeOrder(cb) : source.subscribe(cb)), [source]) const get = useCallback(() => source.getIds(), [source]) return useSyncExternalStore(subscribe, get, get) } /** Version, size, lane, drop count, sequence, gap, and timestamps. Changes once per batch. */ export function useStoreMeta<T>(store: RowStore<T>): StoreMeta { const subscribe = useCallback((cb: () => void) => store.subscribeMeta(cb), [store]) const get = useCallback(() => store.getMeta(), [store]) return useSyncExternalStore(subscribe, get, get) } /** * A view this component owns: made for the options given, disposed on the way out, and made again for * new options. Keep the options object's identity stable (a `useMemo`), since a new object is a new view. * Null options give null, for a component that takes a view from its props instead. * * Why a hook and not a memo with a dispose effect: React's StrictMode mounts, unmounts, and mounts a * component again in development, and the cleanup of that rehearsal disposes the memo's view while the * memo keeps handing it out, so the grid stopped following its store until something remade the view. * The effect here notices a disposed view and makes another. */ export function useView<T>(store: RowStore<T>, options: ViewOptions<T> | null): RowView<T> | null { const [view, setView] = useState<RowView<T> | null>(() => (options ? store.createView(options) : null)) const made = useRef({ store, options, view }) useEffect(() => { let current = made.current.view if (made.current.store !== store || made.current.options !== options || (current !== null && current.isDisposed())) { current = options ? store.createView(options) : null made.current = { store, options, view: current } // A view the rehearsal disposed, or new options: the replacement is state so the render that reads it re-runs. setView(current) } return () => current?.dispose() }, [store, options]) return view }lib/row-store.ts // A row store that React components subscribe to one row at a time. // // The consumer owns the feed. It calls `applyDeltas` once per frame with everything that changed // (a Tauri consumer straight from its event listener; a WebSocket consumer through `createFrameBatcher`). // A delta to one row wakes that row's subscribers and nobody else, and React batches every // notification fired inside one `applyDeltas` into one commit, so a batch of two thousand row // changes is one render pass over the rows that changed. // // Views sit on top: a filtered, sorted list of ids with an optional reorder hold so rows do not // move under a trader's cursor while they work. export type RowId = string /** How the lane loses data. `coalesced` drops stale ticks and counts them; `ordered` never drops. */ export type Lane = "coalesced" | "ordered" export interface DeltaBatch<T> { /** Whole rows. Replaces the row object, so every subscriber of that row wakes. */ upsert?: readonly T[] /** Partial rows merged into a new object. Unknown ids are ignored. */ patch?: readonly { id: RowId; fields: Partial<T> }[] remove?: readonly RowId[] /** Authoritative order for an ordered lane. Ids not present keep their previous relative order at the end. */ order?: readonly RowId[] meta?: { lane?: Lane /** Messages the producer dropped since the last batch (coalesced lane). Accumulated. */ dropped?: number /** Last sequence number applied (ordered lane). */ seq?: number /** A gap was detected and replay is in progress. */ gap?: boolean /** Producer timestamp of the newest message in the batch, ms since epoch. */ producedAt?: number } } export interface StoreMeta { /** Increments once per applied batch. */ version: number size: number lane: Lane dropped: number seq: number | null gap: boolean /** Wall clock of the last applied batch, ms since epoch. */ lastBatchAt: number | null producedAt: number | null } export interface ViewOptions<T> { comparator?: (a: T, b: T) => number filter?: (row: T) => boolean /** * After `touch()` (the grid calls it on every key and pointer interaction) the order is frozen for this * long: new rows append, removed rows vanish, nothing moves. 0 disables the hold. */ reorderHoldMs?: number /** Injectable clock, ms. Defaults to Date.now. */ now?: () => number } export interface RowView<T> { /** The store this view reads from. */ readonly store: RowStore<T> /** Stable array reference until the order actually changes. */ getIds(): readonly RowId[] subscribe(cb: () => void): () => void /** The user interacted; start or extend the reorder hold. */ touch(): void /** True while a hold is in force. */ isHeld(): boolean dispose(): void /** True after `dispose`: the view no longer follows the store. */ isDisposed(): boolean } export interface RowStore<T> { /** Stable object reference until the row is replaced or patched. */ getRow(id: RowId): T | undefined /** Stable array reference until the order changes. Insertion order unless a batch carries `order`. */ getIds(): readonly RowId[] /** Stable object reference until the next batch. */ getMeta(): StoreMeta subscribeRow(id: RowId, cb: () => void): () => void subscribeOrder(cb: () => void): () => void subscribeMeta(cb: () => void): () => void /** Apply one batch synchronously. Call it once per frame. */ applyDeltas(batch: DeltaBatch<T>): void createView(opts?: ViewOptions<T>): RowView<T> /** Drop every row (a re-snapshot is coming). Wakes every subscriber. */ clear(): void readonly getRowId: (row: T) => RowId } export interface RowStoreOptions<T> { getRowId: (row: T) => RowId lane?: Lane now?: () => number } type Listener = () => void export function createRowStore<T>(options: RowStoreOptions<T>): RowStore<T> { const { getRowId } = options const now = options.now ?? Date.now const rows = new Map<RowId, T>() let ids: RowId[] = [] let idsSnapshot: readonly RowId[] = ids let meta: StoreMeta = { version: 0, size: 0, lane: options.lane ?? "coalesced", dropped: 0, seq: null, gap: false, lastBatchAt: null, producedAt: null } const rowListeners = new Map<RowId, Set<Listener>>() const orderListeners = new Set<Listener>() const metaListeners = new Set<Listener>() const views = new Set<ViewImpl<T>>() function notifyRow(id: RowId) { const set = rowListeners.get(id) if (set) for (const cb of set) cb() } function applyDeltas(batch: DeltaBatch<T>) { const touched = new Set<RowId>() const removed = new Set<RowId>() let orderChanged = false if (batch.upsert) { for (const row of batch.upsert) { const id = getRowId(row) if (!rows.has(id)) { ids.push(id) orderChanged = true } rows.set(id, row) touched.add(id) } } if (batch.patch) { for (const { id, fields } of batch.patch) { const existing = rows.get(id) if (existing === undefined) continue rows.set(id, { ...existing, ...fields }) touched.add(id) } } if (batch.remove?.length) { const drop = new Set<RowId>() for (const id of batch.remove) { if (rows.delete(id)) { drop.add(id) removed.add(id) touched.add(id) } } if (drop.size) { ids = ids.filter((id) => !drop.has(id)) orderChanged = true } } if (batch.order) { const next: RowId[] = [] const seen = new Set<RowId>() for (const id of batch.order) { if (rows.has(id) && !seen.has(id)) { next.push(id) seen.add(id) } } for (const id of ids) if (!seen.has(id)) next.push(id) if (!sameOrder(ids, next)) { ids = next orderChanged = true } } const m = batch.meta meta = { version: meta.version + 1, size: rows.size, lane: m?.lane ?? meta.lane, dropped: meta.dropped + (m?.dropped ?? 0), seq: m?.seq ?? meta.seq, gap: m?.gap ?? meta.gap, lastBatchAt: now(), producedAt: m?.producedAt ?? meta.producedAt, } if (orderChanged) idsSnapshot = ids.slice() for (const view of views) view.onBatch(touched, removed, orderChanged) for (const id of touched) notifyRow(id) if (orderChanged) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() } const store: RowStore<T> = { getRowId, getRow: (id) => rows.get(id), getIds: () => idsSnapshot, getMeta: () => meta, subscribeRow(id, cb) { let set = rowListeners.get(id) if (!set) rowListeners.set(id, (set = new Set())) set.add(cb) return () => { set!.delete(cb) if (!set!.size) rowListeners.delete(id) } }, subscribeOrder(cb) { orderListeners.add(cb) return () => orderListeners.delete(cb) }, subscribeMeta(cb) { metaListeners.add(cb) return () => metaListeners.delete(cb) }, applyDeltas, createView(opts = {}) { const view = new ViewImpl<T>(store, opts, () => views.delete(view)) views.add(view) return view }, clear() { const all = ids rows.clear() ids = [] idsSnapshot = ids meta = { ...meta, version: meta.version + 1, size: 0, lastBatchAt: now() } for (const view of views) view.onBatch(new Set(all), new Set(all), true) for (const id of all) notifyRow(id) for (const cb of orderListeners) cb() for (const cb of metaListeners) cb() }, } return store } function sameOrder(a: readonly RowId[], b: readonly RowId[]): boolean { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } class ViewImpl<T> implements RowView<T> { private ids: readonly RowId[] = [] private readonly listeners = new Set<Listener>() private holdUntil = 0 private timer: ReturnType<typeof setTimeout> | null = null private disposed = false private readonly now: () => number readonly store: RowStore<T> private readonly opts: ViewOptions<T> private readonly onDispose: () => void constructor(store: RowStore<T>, opts: ViewOptions<T>, onDispose: () => void) { this.store = store this.opts = opts this.onDispose = onDispose this.now = opts.now ?? Date.now this.ids = this.compute() } getIds() { return this.ids } subscribe(cb: Listener) { this.listeners.add(cb) return () => { this.listeners.delete(cb) } } isHeld() { return this.now() < this.holdUntil } touch() { const hold = this.opts.reorderHoldMs ?? 0 if (hold <= 0) return this.holdUntil = this.now() + hold if (this.timer) clearTimeout(this.timer) // A quiet feed still settles: recompute when the hold lapses. this.timer = setTimeout(() => { this.timer = null this.set(this.compute()) }, hold) } dispose() { if (this.disposed) return this.disposed = true if (this.timer) clearTimeout(this.timer) this.listeners.clear() this.onDispose() } isDisposed() { return this.disposed } /** Called by the store inside applyDeltas, before row listeners fire. */ onBatch(touched: Set<RowId>, removed: Set<RowId>, orderChanged: boolean) { if (!touched.size && !orderChanged) return if (this.isHeld()) { // Frozen order: keep what is there, drop removals and rows that no longer pass the filter, append newcomers. const present = new Set(this.ids) const kept = this.ids.filter((id) => !removed.has(id) && this.passes(id)) const newcomers = this.store.getIds().filter((id) => !present.has(id) && this.passes(id)) if (this.opts.comparator) newcomers.sort(this.compare) const next = kept.length === this.ids.length && !newcomers.length ? this.ids : [...kept, ...newcomers] this.set(next) return } // Only recompute when the batch could have changed this view. let relevant = orderChanged if (!relevant) for (const id of touched) if (this.passes(id) || this.ids.includes(id)) { relevant = true; break } if (relevant) this.set(this.compute()) } private passes(id: RowId): boolean { const row = this.store.getRow(id) if (row === undefined) return false return this.opts.filter ? this.opts.filter(row) : true } private readonly compare = (a: RowId, b: RowId) => this.opts.comparator!(this.store.getRow(a)!, this.store.getRow(b)!) private compute(): readonly RowId[] { const all = this.store.getIds() const list = this.opts.filter ? all.filter((id) => this.passes(id)) : all.slice() if (this.opts.comparator) list.sort(this.compare) return list } private set(next: readonly RowId[]) { if (sameOrder(this.ids, next)) return this.ids = next for (const cb of this.listeners) cb() } } export interface FrameBatcher<T> { /** Queue a delta; the merged batch applies on the next animation frame. */ push(delta: DeltaBatch<T>): void /** Apply now, without waiting for the frame. */ flush(): void /** Drop everything queued. */ cancel(): void pending(): boolean } /** * Coalesce many small deltas into one batch per frame, last write wins per row. For consumers whose * feed delivers messages one at a time. A consumer whose core already batches per frame calls * `applyDeltas` directly and skips this: another frame of latency buys nothing. */ export function createFrameBatcher<T>( apply: (batch: DeltaBatch<T>) => void, options: { getRowId: (row: T) => RowId; raf?: (cb: () => void) => number; caf?: (handle: number) => void }, ): FrameBatcher<T> { const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((h) => (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame(h) : undefined)) const upserts = new Map<RowId, T>() const patches = new Map<RowId, Partial<T>>() const removes = new Set<RowId>() let order: readonly RowId[] | undefined let meta: NonNullable<DeltaBatch<T>["meta"]> | undefined let handle: number | null = null function reset() { upserts.clear() patches.clear() removes.clear() order = undefined meta = undefined } function flush() { if (handle !== null) { caf(handle) handle = null } if (!upserts.size && !patches.size && !removes.size && !order && !meta) return const batch: DeltaBatch<T> = {} if (upserts.size) batch.upsert = [...upserts.values()] if (patches.size) batch.patch = [...patches].map(([id, fields]) => ({ id, fields })) if (removes.size) batch.remove = [...removes] if (order) batch.order = order if (meta) batch.meta = meta reset() apply(batch) } return { push(delta) { for (const row of delta.upsert ?? []) { const id = options.getRowId(row) removes.delete(id) patches.delete(id) upserts.set(id, row) } for (const { id, fields } of delta.patch ?? []) { if (removes.has(id)) continue const pendingUpsert = upserts.get(id) if (pendingUpsert !== undefined) upserts.set(id, { ...pendingUpsert, ...fields }) else patches.set(id, { ...(patches.get(id) ?? {}), ...fields }) } for (const id of delta.remove ?? []) { upserts.delete(id) patches.delete(id) removes.add(id) } if (delta.order) order = delta.order if (delta.meta) { meta = { ...meta, ...delta.meta, dropped: (meta?.dropped ?? 0) + (delta.meta.dropped ?? 0), gap: Boolean(meta?.gap) || Boolean(delta.meta.gap), } } if (handle === null) { handle = raf(() => { handle = null flush() }) } }, flush, cancel() { if (handle !== null) caf(handle) handle = null reset() }, pending: () => handle !== null, } }lib/format.ts // Number formatting for trading screens. Pure functions, no React, no dependencies. // Every formatter returns NULL_TOKEN for null, undefined, NaN, and infinities, so a grid // never prints "NaN" or "undefined" while a feed is warming up. export const NULL_TOKEN = "–" const MINUS = "−" export type Nullable = number | null | undefined export interface Locale { /** BCP 47 tag; defaults to en-US. */ locale?: string } export type PriceConvention = /** Fixed decimals, e.g. equities at 2. */ | { kind: "decimal"; decimals: number } /** Decimals derived from the instrument's tick size, e.g. 0.001 gives 3. */ | { kind: "tick"; tick: number } /** * Fractional quoting. `denominator` 32 or 64; `half` renders a half tick as "+" (99-16+) or "5" (99-165); * `eighths` renders eighths of a tick as a trailing digit (99-162 is 99 and 16 2/8 thirty-seconds). */ | { kind: "fraction"; denominator: 32 | 64; half: "+" | "5"; eighths?: boolean } /** What a quote is typed and read in. Bills quote on discount, most bonds on price, some on yield, credit often on a spread in basis points. */ export type QuoteBasis = "price" | "yield" | "discount" | "spread" export interface InstrumentConvention { price: PriceConvention /** Smallest price increment, used for stepping and rounding. */ tick: number yieldDecimals?: number quantityUnit?: "notional" | "contracts" /** Default price. */ quoteBasis?: QuoteBasis /** What a quote steps by in its basis. Default: the tick for price, 0.001 for yield and discount, 0.1 for spread. */ quoteStep?: number /** Decimals for a yield, discount, or spread quote. Default 3, 3, and 1. Price quotes follow their convention. */ quoteDecimals?: number } function isNil(v: Nullable): v is null | undefined { return v === null || v === undefined || !Number.isFinite(v) } // One Intl.NumberFormat per locale and option set; construction is the expensive part. const formatters = new Map<string, Intl.NumberFormat>() export function numberFormat(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat { const key = `${locale ?? "en-US"}|${JSON.stringify(options)}` let nf = formatters.get(key) if (!nf) { nf = new Intl.NumberFormat(locale ?? "en-US", options) formatters.set(key, nf) } return nf } function fixed(v: number, decimals: number, locale?: string, extra: Intl.NumberFormatOptions = {}): string { return numberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, ...extra }).format(v) } function typographicMinus(s: string): string { return s.replace(/-/g, MINUS) } /** Decimal places needed to show a price on this tick grid. 0.001 gives 3, 1/32 gives 5, 1 gives 0. Capped at `max`. */ export function decimalsFromTick(tick: number, max = 8): number { if (!Number.isFinite(tick) || tick <= 0) return 0 for (let d = 0; d <= max; d++) { const scaled = tick * 10 ** d if (Math.abs(scaled - Math.round(scaled)) < 1e-9 * Math.max(1, scaled)) return d } return max } /** Nearest price on the tick grid, cleaned of float noise. */ export function roundToTick(v: number, tick: number): number { if (!Number.isFinite(v) || !Number.isFinite(tick) || tick <= 0) return v return Number((Math.round(v / tick) * tick).toFixed(decimalsFromTick(tick))) } /** Move `steps` ticks (negative allowed) from the nearest grid price. */ export function stepByTick(v: number, tick: number, steps: number): number { return roundToTick(roundToTick(v, tick) + steps * tick, tick) } /** Whole-number and sub-tick decomposition on an integer grid so 32nds never drift. */ function decomposeFraction(v: number, c: Extract<PriceConvention, { kind: "fraction" }>) { const sub = c.eighths ? 8 : 2 const unitsPerWhole = c.denominator * sub const total = Math.round(Math.abs(v) * unitsPerWhole) const whole = Math.floor(total / unitsPerWhole) const rem = total - whole * unitsPerWhole const ticks = Math.floor(rem / sub) const subticks = rem - ticks * sub return { negative: v < 0 && total > 0, whole, ticks, subticks, sub } } /** 99.515625 as "99-16+" (32nds, half "+"), "99-165" (half "5"), or "99-164" with eighths. */ export function formatFraction(v: Nullable, c: Extract<PriceConvention, { kind: "fraction" }>): string { if (isNil(v)) return NULL_TOKEN const { negative, whole, ticks, subticks, sub } = decomposeFraction(v, c) let tail = "" if (subticks !== 0) { if (c.eighths) tail = String(subticks) else tail = c.half } void sub return `${negative ? MINUS : ""}${whole}-${String(ticks).padStart(2, "0")}${tail}` } export function formatPrice(v: Nullable, c: PriceConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN switch (c.kind) { case "decimal": return typographicMinus(fixed(v, c.decimals, l?.locale)) case "tick": return typographicMinus(fixed(roundToTick(v, c.tick), decimalsFromTick(c.tick), l?.locale)) case "fraction": return formatFraction(v, c) } } /** * Inverse of formatPrice for ticket entry. Accepts the convention's own notation and a plain decimal. * Returns null when the text is not a price. Fraction input: "99-16+", "99-165", "99-162", "99-16", "99". */ export function parsePrice(s: string, c: PriceConvention): number | null { const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!text) return null if (c.kind === "fraction") { const m = /^([+-]?)(\d+)(?:-(\d{1,2})([+0-9])?)?$/.exec(text) if (m) { const [, sign, wholeText, ticksText, tail] = m const ticks = ticksText ? Number(ticksText) : 0 if (ticks >= c.denominator) return null let subFraction = 0 if (tail === "+") subFraction = 0.5 else if (tail !== undefined) { const digit = Number(tail) if (c.eighths) { if (digit > 7) return null subFraction = digit / 8 } else if (c.half === "5" && digit === 5) subFraction = 0.5 else if (digit === 0) subFraction = 0 else return null } const value = Number(wholeText) + (ticks + subFraction) / c.denominator return sign === "-" ? -value : value } } if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) if (!Number.isFinite(n)) return null if (c.kind === "tick") return roundToTick(n, c.tick) if (c.kind === "decimal") return Number(n.toFixed(c.decimals)) return n } /** 4.2531 as "4.253%". */ export function formatYield(v: Nullable, o: { decimals?: number; suffix?: "%" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + (o.suffix ?? "%") } /** 12.5 as "12.5 bp"; signed gives "+12.5 bp". */ export function formatBps(v: Nullable, o: { decimals?: number; signed?: boolean; unit?: "bp" | "bps" | "" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const unit = o.unit ?? "bp" const body = o.signed ? formatSigned(v, { decimals: o.decimals ?? 1, locale: o.locale }) : typographicMinus(fixed(v, o.decimals ?? 1, o.locale)) return unit ? `${body} ${unit}` : body } /** Dollar value of a basis point as whole currency; compact gives "$1.23K". */ export function formatDv01(v: Nullable, o: { currency?: string; compact?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const currency = o.currency ?? "USD" if (o.compact) { const symbol = numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }) .formatToParts(0) .find((p) => p.type === "currency")?.value ?? "" return `${v < 0 ? MINUS : ""}${symbol}${formatNotional(Math.abs(v), { compact: true, locale: o.locale })}` } return typographicMinus(numberFormat(o.locale, { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(v)) } /** 1250000 as "1.25M" (compact), "1.25mm" (unit "mm", the desk's word for millions, never scaled further), or "1,250,000.00". Compact suffixes K, M, B, T. */ export function formatNotional(v: Nullable, o: { compact?: boolean; decimals?: number; unit?: "mm" } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 if (o.unit === "mm") return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(v / 1e6)) + "mm" if (!o.compact) return typographicMinus(fixed(v, decimals, o.locale)) const abs = Math.abs(v) const [divisor, suffix] = abs >= 1e12 ? [1e12, "T"] : abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : abs >= 1e3 ? [1e3, "K"] : [1, ""] return typographicMinus(fixed(v / divisor, suffix ? decimals : 0, o.locale)) + suffix } /** "+0.12", "−0.12" (U+2212), and "0.00" for zero: flat carries no sign. */ export function formatSigned(v: Nullable, o: { decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(v, o.decimals ?? 2, o.locale, { signDisplay: "exceptZero" })) } /** 1.234 as "1.23%"; signed gives "+1.23%". */ export function formatPercent(v: Nullable, o: { decimals?: number; signed?: boolean } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const decimals = o.decimals ?? 2 return (o.signed ? formatSigned(v, { decimals, locale: o.locale }) : typographicMinus(fixed(v, decimals, o.locale))) + "%" } /** Whole quantities with grouping: 1000000 as "1,000,000". */ export function formatQuantity(v: Nullable, l?: Locale): string { if (isNil(v)) return NULL_TOKEN return typographicMinus(fixed(Math.round(v), 0, l?.locale)) } /** * A coupon the way a run prints it: 4.125 as "4 1/8", 4.5 as "4 1/2", 4 as "4", 0 as "0" (coupons step in eighths). * One off the eighths grid prints as a plain decimal, "4.1". `style: "decimal"` prints "4.125%". */ export function formatCoupon(v: Nullable, o: { style?: "fraction" | "decimal"; decimals?: number } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN if (o.style === "decimal") return typographicMinus(fixed(v, o.decimals ?? 3, o.locale)) + "%" const eighths = Math.round(Math.abs(v) * 8) if (Math.abs(eighths / 8 - Math.abs(v)) > 1e-9) return typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: o.decimals ?? 3 }).format(v)) const whole = Math.floor(eighths / 8) const rem = eighths % 8 const fraction = rem === 0 ? "" : rem % 4 === 0 ? "1/2" : rem % 2 === 0 ? `${rem / 2}/4` : `${rem}/8` const body = fraction ? (whole ? `${whole} ${fraction}` : fraction) : String(whole) return `${v < 0 && eighths > 0 ? MINUS : ""}${body}` } /** A date as an instant, a millisecond count, or a string `Date` can read. A date-only string ("2034-05-15") is that day in UTC. */ export type DateLike = Date | number | string function toDate(d: DateLike | null | undefined): Date | null { if (d === null || d === undefined) return null const date = d instanceof Date ? d : new Date(d) return Number.isNaN(date.getTime()) ? null : date } const dateFormatters = new Map<string, Intl.DateTimeFormat>() /** * A maturity as a run prints it: "05/15/34", or "05/15/2034" with `year: "numeric"`. Read in UTC, so a * date-only string prints as that day wherever the screen is. */ export function formatMaturity(d: DateLike | null | undefined, o: { year?: "2-digit" | "numeric" } & Locale = {}): string { const date = toDate(d) if (!date) return NULL_TOKEN const key = `${o.locale ?? "en-US"}|${o.year ?? "2-digit"}` let f = dateFormatters.get(key) if (!f) { f = new Intl.DateTimeFormat(o.locale ?? "en-US", { month: "2-digit", day: "2-digit", year: o.year ?? "2-digit", timeZone: "UTC" }) dateFormatters.set(key, f) } return f.format(date) } /** Whole UTC days from `now` (the moment, by default) to a maturity. Negative once it has passed; null when the date does not read. */ export function daysToMaturity(d: DateLike | null | undefined, now: DateLike = Date.now()): number | null { const maturity = toDate(d) const from = toDate(now) if (!maturity || !from) return null const day = (x: Date) => Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate()) return Math.round((day(maturity) - day(from)) / 86_400_000) } /** How many ticks `a` is from `b`, signed, to the nearest eighth of a tick: 99-17 against 99-16+ on a 1/64 tick is 1. NaN when a side is not a number or the tick is not positive. */ export function ticksBetween(a: number, b: number, tick: number): number { if (!Number.isFinite(a) || !Number.isFinite(b) || !(tick > 0)) return NaN return Math.round(((a - b) / tick) * 8) / 8 } /** A count of ticks: "+1", "−0.5", "0", up to three decimals and no trailing zeros. Signed unless told otherwise; `unit` adds a word. */ export function formatTicks(v: Nullable, o: { signed?: boolean; unit?: string } & Locale = {}): string { if (isNil(v)) return NULL_TOKEN const body = typographicMinus(numberFormat(o.locale, { minimumFractionDigits: 0, maximumFractionDigits: 3, signDisplay: o.signed === false ? "auto" : "exceptZero" }).format(v)) return o.unit ? `${body} ${o.unit}` : body } /** * The class a number wears (contract rule 14): lining, tabular figures whatever the font, in the numeric family. * `--tradecn-font-numeric` is the sans by default, so letters stay proportional and only the digits take one * width, which is what a column of prices wants. docs/typography.md has the reasoning and the tokens. */ export const NUMERIC_CLASS = "font-(family-name:--tradecn-font-numeric) lining-nums tabular-nums" /** The same figures in the mono stack, `--tradecn-font-mono`: for a fraction quote, whose dash, ticks, and tail must line up down a column. */ export const MONO_NUMERIC_CLASS = "font-(family-name:--tradecn-font-mono) lining-nums tabular-nums" /** * The class for a number printed under a convention: a fraction price (99-16+) sets in the mono stack, so * 99-16+ over 99-17 keeps its dash and its tail in the same place; every other price, and every quote in * another basis, keeps the numeric family. */ export function numericFontClass(c?: PriceConvention | InstrumentConvention | null): string { const price = c && "price" in c ? (quoteBasisOf(c) === "price" ? c.price : null) : c return price?.kind === "fraction" ? MONO_NUMERIC_CLASS : NUMERIC_CLASS } /** The word for a quote field's label, per basis. */ export const QUOTE_BASIS_LABELS: Record<QuoteBasis, string> = { price: "Price", yield: "Yield", discount: "Discount", spread: "Spread" } const QUOTE_DEFAULTS: Record<Exclude<QuoteBasis, "price">, { step: number; decimals: number }> = { yield: { step: 0.001, decimals: 3 }, discount: { step: 0.001, decimals: 3 }, spread: { step: 0.1, decimals: 1 }, } export function quoteBasisOf(c: InstrumentConvention): QuoteBasis { return c.quoteBasis ?? "price" } /** What a quote steps by in its basis: the tick for a price, else `quoteStep` or the basis default. */ export function quoteStepOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? c.tick : (c.quoteStep ?? QUOTE_DEFAULTS[basis].step) } function quoteDecimalsOf(c: InstrumentConvention): number { const basis = quoteBasisOf(c) return basis === "price" ? 0 : (c.quoteDecimals ?? QUOTE_DEFAULTS[basis].decimals) } /** A quote in the instrument's basis: a price by its convention, else a fixed decimal with the basis's decimals and no unit, since the field's label says the basis. */ export function formatQuote(v: Nullable, c: InstrumentConvention, l?: Locale): string { if (isNil(v)) return NULL_TOKEN if (quoteBasisOf(c) === "price") return formatPrice(v, c.price, l) return typographicMinus(fixed(roundToTick(v, quoteStepOf(c)), quoteDecimalsOf(c), l?.locale)) } /** Inverse of formatQuote for a quote field. A price takes its notation or a decimal; the others take a decimal and snap to the quote step. Null when the text is not a quote. */ export function parseQuote(s: string, c: InstrumentConvention): number | null { if (quoteBasisOf(c) === "price") return parsePrice(s, c.price) const text = s.trim().replace(/−/g, "-").replace(/,/g, "") if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(text)) return null const n = Number(text) return Number.isFinite(n) ? roundToTick(n, quoteStepOf(c)) : null } /** Move a quote by `steps` of its step, from the nearest grid value. */ export function stepQuote(v: number, c: InstrumentConvention, steps: number): number { return stepByTick(v, quoteStepOf(c), steps) } /** Bind a convention once per instrument; a grid column calls `formatters[row.instrumentId].price(v)`, a quote field its `quote`, `parseQuote`, and `stepQuote`. */ export function createInstrumentFormatter(c: InstrumentConvention, l?: Locale) { return { tick: c.tick, basis: quoteBasisOf(c), quoteStep: quoteStepOf(c), price: (v: Nullable) => formatPrice(v, c.price, l), yield: (v: Nullable) => formatYield(v, { decimals: c.yieldDecimals ?? 3, locale: l?.locale }), step: (v: number, steps: number) => stepByTick(v, c.tick, steps), parsePrice: (s: string) => parsePrice(s, c.price), quantity: (v: Nullable) => formatQuantity(v, l), quote: (v: Nullable) => formatQuote(v, c, l), parseQuote: (s: string) => parseQuote(s, c), stepQuote: (v: number, steps: number) => stepQuote(v, c, steps), } } -
Add the tokens to your stylesheet:
:root { --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 { --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 { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DepthLadder, levelId, tickIndexOf, type DepthLevel, type LadderStage } from "@/components/ui/depth-ladder"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const format = createInstrumentFormatter(ZN)
const mid = 110.5
const tick = tickIndexOf(mid, ZN.tick)
function Ladder() {
const [store] = useState(() => {
const store = createRowStore<DepthLevel>({ getRowId: (level) => levelId(level.tick) })
store.applyDeltas({ upsert: [
{ tick: tick - 2, bidSize: 300, myBid: 25 },
{ tick: tick - 1, bidSize: 220 },
{ tick: tick + 1, askSize: 180 },
{ tick: tick + 2, askSize: 320, myAsk: 15 },
] })
return store
})
const [staged, setStaged] = useState<LadderStage | null>(null)
return (
<div className="w-72 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-56">
<DepthLadder store={store} convention={ZN} mid={mid} label="ZN ladder" depth={4} onStage={setStaged} />
</div>
<p role="status" className="text-muted-foreground">{staged ? `Staged: ${staged.side} at ${format.price(staged.price)}.` : "Nothing staged."}</p>
</div>
)
}
Key levels by tick index, not by price: tickIndexOf rounds a price to the grid, and levelId is the row id the store and ladder agree on. The four levels surround an empty mid rung. The chips mark the desk's own sizes, 25 on the bid and 15 on the ask.
Click a bid cell to stage a buy or an ask cell to stage a sell. Empty size cells work too. For keyboard use, focus the ladder, choose a rung and size column with the arrows, then press Enter. The caption shows the callback's price and side; your application decides what to do with them.
Book updates
The Apply next book update button walks through four batches: increase a bid size and decrease an ask size, change only the desk's own sizes, add a level, then remove a level. The caption names each result. Market-size changes flash; own-size-only changes do not. Adding or removing a level changes its size cells while its price rung remains.
The last state stays visible. Restore book replaces the original levels and removes the added one so the sequence can run again. It restores the data without changing the ladder's focus or following state. These buttons stand in for batches from a coalesced book feed; they do not run a publisher or aggregate orders.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore, type DeltaBatch } from "@/lib/row-store"
import { DepthLadder, levelId, tickIndexOf, type DepthLevel } from "@/components/ui/depth-ladder"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const mid = 110.5
const tick = tickIndexOf(mid, ZN.tick)
const levels: DepthLevel[] = [
{ tick: tick - 2, bidSize: 300, myBid: 25 },
{ tick: tick - 1, bidSize: 220 },
{ tick: tick + 1, askSize: 180 },
{ tick: tick + 2, askSize: 320, myAsk: 15 },
]
const updates: { message: string; batch: DeltaBatch<DepthLevel> }[] = [
{ message: "Bid at 110-15+: 300. Ask at 110-16+: 120.", batch: { patch: [{ id: levelId(tick - 1), fields: { bidSize: 300 } }, { id: levelId(tick + 1), fields: { askSize: 120 } }] } },
{ message: "Own bid: 40. Own ask: cleared. Market sizes unchanged.", batch: { patch: [{ id: levelId(tick - 2), fields: { myBid: 40 } }, { id: levelId(tick + 2), fields: { myAsk: 0 } }] } },
{ message: "New ask: 90 at 110-17+.", batch: { upsert: [{ tick: tick + 3, askSize: 90 }] } },
{ message: "Bid at 110-15+ removed. Its price rung remains.", batch: { remove: [levelId(tick - 1)] } },
]
export default function DepthLadderUpdatesDemo() {
const [store] = useState(() => {
const store = createRowStore<DepthLevel>({ getRowId: (level) => levelId(level.tick) })
store.applyDeltas({ upsert: levels })
return store
})
const [step, setStep] = useState(0)
const advance = () => {
const update = updates[step]
if (!update) return
store.applyDeltas(update.batch)
setStep(step + 1)
}
const restore = () => {
store.applyDeltas({ upsert: levels, remove: [levelId(tick + 3)] })
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 === updates.length} onClick={advance}>Apply next book update</button>
<button type="button" className="rounded border px-2 py-1" onClick={restore}>Restore book</button>
</div>
<div className="w-72 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-56">
<DepthLadder store={store} convention={ZN} mid={mid} label="ZN book updates" depth={4} />
</div>
<p role="status" className="text-muted-foreground">{step === 0 ? "Initial book." : `Update ${step} of ${updates.length}. ${updates[step - 1]!.message}`}</p>
</div>
</>
)
}
Following the market
Move the market up or down four ticks while the ladder is untouched: the mid stays centered, and the bid and ask move with it. The controls bound this sample to eight ticks either side of its starting mid, and each snapshot replaces the previous book's two levels.
Click a price cell, scroll, or use an arrow key to stop following, then move the market again. The visible prices stay put while the mid moves. Press Recenter, or focus the ladder and press Home, to center on the current market and resume following. The mid caption shows the market even when its rung is out of view. Recenter changes the viewport; it does not move the keyboard's focused tick.
import { useState } from "react"
import { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DepthLadder, levelId, priceAtTick, tickIndexOf, type DepthLevel } from "@/components/ui/depth-ladder"
const ZN: InstrumentConvention = { price: { kind: "fraction", denominator: 32, half: "+" }, tick: 1 / 64 }
const format = createInstrumentFormatter(ZN)
const start = tickIndexOf(110.5, ZN.tick)
const levelsAt = (mid: number): DepthLevel[] => [{ tick: mid - 1, bidSize: 220 }, { tick: mid + 1, askSize: 180 }]
export default function DepthLadderFollowingDemo() {
const [store] = useState(() => {
const store = createRowStore<DepthLevel>({ getRowId: (level) => levelId(level.tick) })
store.applyDeltas({ upsert: levelsAt(start) })
return store
})
const [midTick, setMidTick] = useState(start)
const move = (by: number) => {
const next = midTick + by
const levels = levelsAt(next)
const ids = new Set(levels.map((level) => levelId(level.tick)))
store.applyDeltas({ upsert: levels, remove: store.getIds().filter((id) => !ids.has(id)) })
setMidTick(next)
}
const mid = priceAtTick(midTick, ZN.tick)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs lining-nums tabular-nums">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={midTick === start + 8} onClick={() => move(4)}>Move market up 4 ticks</button>
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={midTick === start - 8} onClick={() => move(-4)}>Move market down 4 ticks</button>
</div>
<div className="w-72 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<div className="h-64">
<DepthLadder store={store} convention={ZN} mid={mid} label="ZN moving market" depth={12} />
</div>
<p role="status" className="text-muted-foreground">Market mid: {format.price(mid)}.</p>
</div>
</>
)
}
API Reference
The ladder defines 2 × depth + 1 rungs around a center and mounts the visible rungs plus overscan. Each mounted rung subscribes to its own level through useRow, so a store batch that changes one price updates that rung without rendering its neighbors.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
store |
RowStore<DepthLevel> |
Required | Levels keyed by levelId(tick). |
convention |
InstrumentConvention |
Required | Prints prices and sets the tick size. |
mid |
number | null | undefined |
Required | The market's mid, as a price. Null, undefined, or nonfinite before the first finite mid shows the empty state. |
label |
string |
Required | Accessible name of the ladder. |
depth |
number |
200 |
Ticks above and below the center. |
rowHeight |
number |
22 |
Rung height in px. |
overscan |
number |
8 |
Extra rungs rendered beyond the viewport. |
onStage |
(stage: LadderStage) => void |
None | Receive a click or Enter on a size cell. |
formatSize |
(size: number) => string |
formatQuantity |
Print market and own sizes. Keep it stable between renders. |
flashWindowMs |
number |
900 |
Cell flash duration in ms. |
labels |
Partial<DepthLadderLabels> |
DEFAULT_DEPTH_LADDER_LABELS |
Override the words listed below. |
emptyState |
ReactNode |
labels.noMarket |
Content before the first finite mid. |
className |
string |
None | Classes on the root. |
initialRect |
{ width: number; height: number } |
None | Viewport size in px before measurement, for tests or server rendering. |
Levels
| Field | Type | Required | Purpose |
|---|---|---|---|
tick |
number |
Yes | The price as a count of ticks from zero; the row id is levelId(tick). |
bidSize |
number | null |
No | Size on the bid. |
askSize |
number | null |
No | Size on the offer. |
myBid |
number | null |
No | The desk's own size on the bid; marks the rung data-mine. |
myAsk |
number | null |
No | The desk's own size on the offer. |
Absent, null, zero, and nonfinite sizes print nothing and do not mark own size. Finite nonzero sizes, including negative values, pass to formatSize. Its default, formatQuantity, rounds to whole numbers with en-US grouping. Prices use convention.price; the ladder does not switch to quoteBasis or scale sizes by quantityUnit. See format for the conventions.
Three helpers convert between prices and ticks:
| Function | Return type | Result |
|---|---|---|
levelId(tick: number) |
string |
The tick as a string, the store's row id. |
tickIndexOf(price: number, tickSize: number) |
number |
Math.round(price / tickSize); 99.515625 on 1 / 64 is 6369. |
priceAtTick(tick: number, tickSize: number) |
number |
roundToTick(tick * tickSize, tickSize); 6369 on 1 / 64 is 99.515625. |
Use integer tick indices and a finite positive tick size. The helpers do not validate these inputs; priceAtTick inherits roundToTick's cleanup precision of at most eight decimal places.
A rung with no level prints its price and empty size cells. The ladder looks up levels by id, ignores store order, and never writes or removes a level. Aggregate each price before feeding the row store; upserting the same id replaces the level.
The center
Prices run high to low. While following, the ladder centers tickIndexOf(mid, convention.tick) when that tick changes, within the scrollable bounds. The mid rung carries data-mid and is described as Mid. The root carries data-following="true".
A pointer press or wheel event in the scrolling body, a scroll away from the centered offset, or an arrow or page key stops following. The anchored price range stays fixed while the market moves. With a finite mid, a Recenter button appears over the bottom edge; pressing it, or Home, centers the range on the current mid and follows again.
The range starts at the first finite mid and rebuilds around a mid that drifts more than half the depth while following. It stays fixed while held.
A missing or nonfinite mid after the ladder is built leaves the prices up, removes the mid mark, and hides Recenter. Home does nothing until a finite mid returns.
Staging
A click on a bid cell calls onStage with side: "buy", a click on an ask cell with side: "sell", at that rung's price:
| Field | Type | Purpose |
|---|---|---|
price |
number |
priceAtTick(tick, convention.tick). |
side |
"buy" | "sell" |
The bid column buys, the ask column sells. |
tick |
number |
The rung's tick index. |
level |
DepthLevel | undefined |
The store's level at that price, if any. |
An empty size cell stages too, with level: undefined when the store has no level. A click on a price cell moves the focus and stages nothing. The ladder sends nothing; a ticket or your application decides what a staged price becomes.
Marks
| Attribute | Where | Meaning |
|---|---|---|
data-side="bid" | "ask" |
Size cells | The side the size rests on. Bid sizes print in up and ask sizes in down, under headers that name them. |
data-mine="bid" | "ask" | "both" |
Rungs | The desk has size on this rung. The own size prints in a primary chip before the market's, followed by yours for a screen reader. |
data-mid |
One rung | The market's mid. |
data-focused and data-focused-col |
A rung and a cell | The keyboard focus. |
data-direction |
Size cells | up or down between nonzero market sizes; flat when changing to or from blank. |
Each mounted bid and ask cell flashes independently when its normalized market size changes. The first value, an unchanged size, and own-size-only changes do not flash. Changes on both sides flash both cells. Flash history is local to the mounted cell, so it is lost when virtualization unmounts the rung.
Flashes use a static tint for reduced motion or without Web Animations, cleared after flashWindowMs. See useFlash for the animation behavior.
Keyboard
With focus on the ladder:
| Key | Action |
|---|---|
| Up / Down | Move the focus one tick, starting from the mid. Stops following. |
| PageUp / PageDown | Move the focus by a viewport of rungs. |
| Left / Right | Move the focus between the bid, price, and ask columns. |
| Enter | Stage the focused size cell's price and side. |
| Home | Recenter on a finite mid and follow it again. Keeps the focused tick and column. |
| Ctrl, Cmd, or Alt with any key | Left to listeners above the ladder, such as a hotkey registry. |
The root is a focusable grid with three columns. aria-rowcount includes the header and the full anchored range; mounted rungs report their row indices. aria-activedescendant names the focused tick while it remains in that range, even if scrolling has unmounted its rung.
Recenter does not reset keyboard focus. If the new range excludes the focused tick, its focus mark and aria-activedescendant disappear, but a focused size cell still stages that stored tick and side on Enter. Select a rung in the new range before using Enter.
Labels
labels merges partial overrides into DEFAULT_DEPTH_LADDER_LABELS:
| Label | Default | Where |
|---|---|---|
bid / price / ask |
Bid / Price / Ask |
Column headers |
recenter |
Recenter |
The button shown while not following with a finite mid |
mine |
yours |
Screen-reader text after the desk's own size |
mid |
Mid |
Screen-reader description of the mid rung |
noMarket |
No market |
The empty state |
What it does not do
It does not build the book, aggregate sizes, work out the mid, or send an order. Feed the store from your book, pass the market's mid, and handle onStage in your ticket.
Tokens
The install adds up, down, and flat with their soft variants if you do not have them. The bid and ask columns use the first two, and the flashes use all three.
This page is docs/depth-ladder.md at v1.4.13.