DepthLadder
A composable price ladder with virtual rows, size flashes, and order staging.
import { useState } from "react"
import { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import {
DepthLadder,
DepthLadderHeader,
DepthLadderColumnHeader,
DepthLadderViewport,
DepthLadderEmpty,
DepthLadderRows,
DepthLadderRow,
DepthLadderSizeCell,
DepthLadderPriceCell,
DepthLadderRecenter,
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 renderLadderRow() {
return (
<DepthLadderRow>
<DepthLadderSizeCell side="bid" />
<DepthLadderPriceCell />
<DepthLadderSizeCell side="ask" />
</DepthLadderRow>
)
}
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}>
<DepthLadderHeader>
<DepthLadderColumnHeader column="bid" />
<DepthLadderColumnHeader column="price" />
<DepthLadderColumnHeader column="ask" />
</DepthLadderHeader>
<DepthLadderViewport>
<DepthLadderEmpty />
<DepthLadderRows>{renderLadderRow}</DepthLadderRows>
</DepthLadderViewport>
<DepthLadderRecenter className="absolute bottom-2 left-1/2 z-30 -translate-x-1/2" />
</DepthLadder>
</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#v2.0.0
npx shadcn@latest add tradecn/ui/depth-ladder#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/depth-ladder#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/depth-ladder#v2.0.0
-
Install the dependencies:
pnpm add cn @tanstack/react-virtualnpm install cn @tanstack/react-virtualyarn add cn @tanstack/react-virtualbun add cn @tanstack/react-virtual -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add buttonnpx shadcn@latest add buttonyarn dlx shadcn@latest add buttonbunx --bun shadcn@latest add button -
Copy the files into your project:
components/ui/ depth-ladder.tsx import { defaultRangeExtractor, useVirtualizer, type Range, type VirtualItem } from "@tanstack/react-virtual" import { cn } from "cn" import { createContext, memo, useCallback, useContext, useId, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, type Ref, type RefObject, type UIEvent } from "react" import { Button } from "@/components/ui/button" 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" /** 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 extends Omit<ComponentProps<"div">, "role" | "tabIndex" | "aria-label" | "aria-rowcount" | "aria-colcount" | "aria-activedescendant"> { children: ReactNode /** Visual column order. Render cells and headers in this order. */ columns?: readonly LadderColumn[] /** Price order from top to bottom. Default descending. */ order?: "ascending" | "descending" /** Header rows included in the grid indices. Use 0 when omitting the header. Default 1. */ headerRows?: number /** 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> /** 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 DEFAULT_COLUMNS: readonly LadderColumn[] = ["bid", "price", "ask"] 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 Focus { tick: number col: LadderColumn } interface Configuration { store: RowStore<DepthLevel> columns: readonly LadderColumn[] rowHeight: number headerRows: number labels: DepthLadderLabels formatSize: (size: number) => string flashWindowMs: number convention: InstrumentConvention format: ReturnType<typeof createInstrumentFormatter> onCell: (tick: number, col: LadderColumn) => void } export interface DepthLadderState { following: boolean hasMarket: boolean hasRows: boolean labels: DepthLadderLabels hold: () => void recenter: () => void focus: () => void } interface LadderContextValue extends DepthLadderState { config: Configuration scrollRef: RefObject<HTMLDivElement | null> items: VirtualItem[] totalSize: number tickAt: (index: number) => number domId: (tick: number) => string midTick: number | null selected: Focus | null onScroll: (event: UIEvent<HTMLDivElement>) => void } const LadderContext = createContext<LadderContextValue | null>(null) const ConfigurationContext = createContext<Configuration | null>(null) function useLadderContext() { const state = useContext(LadderContext) if (!state) throw new Error("DepthLadder parts must be inside DepthLadder") return state } function useConfiguration() { const config = useContext(ConfigurationContext) if (!config) throw new Error("DepthLadder headers must be inside DepthLadder") return config } /** Shared following and recenter behavior for caller-owned controls. */ export function useDepthLadder(): DepthLadderState { return useLadderContext() } function assignRef<T>(ref: Ref<T> | undefined, value: T | null) { if (typeof ref === "function") return ref(value) if (ref) ref.current = value } function useLadderRef<T>(localRef: RefObject<T | null>, forwarded: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignRef(forwarded, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignRef(forwarded, null) } }, [localRef, forwarded]) } /** Where the scroll box sits when the rung at `index` is in its middle, within its scrollable bounds. */ function centeredTop(el: HTMLElement, index: number, rowHeight: number): number { const max = Math.max(0, el.scrollHeight - el.clientHeight) return Math.min(max, Math.max(0, index * rowHeight - (el.clientHeight - rowHeight) / 2)) } /** Coordinates a caller-owned grid. Store updates are subscribed to by mounted rows only. */ export function DepthLadder({ store, convention, mid, label, depth = 200, rowHeight = 22, overscan = 8, onStage, formatSize = defaultSize, flashWindowMs = 900, labels: labelsProp, initialRect, columns = DEFAULT_COLUMNS, order = "descending", headerRows = 1, children, className, style, ref, onKeyDown, ...props }: DepthLadderProps) { const labels = useMemo<DepthLadderLabels>(() => ({ ...DEFAULT_DEPTH_LADDER_LABELS, ...labelsProp }), [labelsProp]) const format = useMemo(() => createInstrumentFormatter(convention), [convention]) const tickSize = convention.tick // A tick that is not a finite positive size builds no ladder: the empty state, as before the first mid, so no // price the ladder cannot print reaches `onStage`. const usable = Number.isFinite(tickSize) && tickSize > 0 const midTick = !usable || mid == null || !Number.isFinite(mid) ? null : tickIndexOf(mid, tickSize) const [placed, setPlaced] = useState<number | null>(null) const anchor = usable ? placed : null const [following, setFollowing] = useState(true) const [focused, setFocused] = useState<Focus | null>(null) // Keep the range under the pointer fixed until following resumes. if (midTick !== null && (anchor === null || (following && Math.abs(midTick - anchor) > depth / 2))) setPlaced(midTick) const count = anchor === null ? 0 : depth * 2 + 1 const direction = order === "descending" ? -1 : 1 const indexOf = (tick: number) => depth + (tick - (anchor ?? 0)) * direction const tickAt = (index: number) => (anchor ?? 0) + (index - depth) * direction const selected = focused && columns.includes(focused.col) ? focused : null const selectedIndex = selected ? indexOf(selected.tick) : -1 const rangeExtractor = useCallback((range: Range) => { const indexes = defaultRangeExtractor(range) // Keep the IDREF valid in the selection commit, before the scroll event updates the visible range. if (selectedIndex >= 0 && selectedIndex < range.count && !indexes.includes(selectedIndex)) { indexes.push(selectedIndex) indexes.sort((a, b) => a - b) } return indexes }, [selectedIndex]) const root = useRef<HTMLDivElement>(null) const rootRef = useLadderRef(root, ref) const scrollRef = useRef<HTMLDivElement>(null) const focus = useCallback(() => root.current?.focus({ preventScroll: true }), []) const virtualizer = useVirtualizer({ count, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, rangeExtractor, getItemKey: tickAt, initialRect, // The mid starts at index `depth`, 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 centers the mid after each move and when following resumes. Handlers change the state. useLayoutEffect(() => { const el = scrollRef.current if (!el || !following || midTick === null || anchor === null) return el.scrollTop = centeredTop(el, depth + (midTick - anchor) * direction, rowHeight) }, [following, midTick, anchor, depth, rowHeight, direction]) const latest = useRef({ onStage, store, tickSize, anchor, depth }) // Insertion-phase publish: descendants' layout effects already see the committed range, so a // select retained by composed row content validates against the ladder as rendered. useInsertionEffect(() => { latest.current = { onStage, store, tickSize, anchor, depth } }) const hold = useCallback(() => setFollowing(false), []) const recenter = () => { if (midTick === null) return setPlaced(midTick) setFollowing(true) } const stage = useCallback((tick: number, col: LadderColumn) => { if (col === "price") return const { onStage, store, tickSize, anchor, depth } = latest.current // Only a price on the ladder stages, whichever path asked: a selection scrolled out of range // stays a selection. if (anchor === null || Math.abs(tick - anchor) > depth) return onStage?.({ price: priceAtTick(tick, tickSize), tick, side: col === "bid" ? "buy" : "sell", level: store.getRow(levelId(tick)) }) }, []) const onCell = useCallback((tick: number, col: LadderColumn) => { hold() setFocused({ tick, col }) focus() stage(tick, col) }, [hold, focus, stage]) const config = useMemo<Configuration>(() => ({ store, columns, rowHeight, headerRows, labels, formatSize, flashWindowMs, convention, format, onCell }), [store, columns, rowHeight, headerRows, labels, formatSize, flashWindowMs, convention, format, onCell]) const onScroll = (event: UIEvent<HTMLDivElement>) => { if (!following || midTick === null || anchor === null) return const el = event.currentTarget // A scroll away from the centered offset is a hand on the ladder; allow subpixel rounding. if (Math.abs(el.scrollTop - centeredTop(el, indexOf(midTick), rowHeight)) > 1) hold() } const handleKey = (event: KeyboardEvent<HTMLDivElement>) => { // Nested controls keep their own keyboard behavior, including native Enter activation. if (event.target !== event.currentTarget || event.nativeEvent.isComposing || event.defaultPrevented || anchor === null || event.altKey || event.ctrlKey || event.metaKey) return if (event.key === "Home") { event.preventDefault() recenter() return } if (event.key === "Enter") { if (selected && selected.col !== "price") { event.preventDefault() stage(selected.tick, selected.col) } return } const clampTick = (tick: number) => Math.min(anchor + depth, Math.max(anchor - depth, tick)) const current: Focus = { tick: clampTick(focused?.tick ?? midTick ?? anchor), col: selected?.col ?? (columns.includes("price") ? "price" : columns[0] ?? "price") } const pageRows = Math.max(1, Math.floor((scrollRef.current?.clientHeight || initialRect?.height || rowHeight * 10) / rowHeight)) let next: Focus switch (event.key) { case "ArrowUp": next = { ...current, tick: clampTick(current.tick - direction) }; break case "ArrowDown": next = { ...current, tick: clampTick(current.tick + direction) }; break case "PageUp": next = { ...current, tick: clampTick(current.tick - direction * pageRows) }; break case "PageDown": next = { ...current, tick: clampTick(current.tick + direction * pageRows) }; break case "ArrowLeft": next = { ...current, col: columns[Math.max(0, columns.indexOf(current.col) - 1)] ?? current.col }; break case "ArrowRight": next = { ...current, col: columns[Math.min(columns.length - 1, columns.indexOf(current.col) + 1)] ?? current.col }; break default: return } event.preventDefault() hold() setFocused(next) virtualizer.scrollToIndex(indexOf(next.tick), { align: "auto" }) } const selectedMounted = selected !== null && items.some((item) => item.index === selectedIndex) const state: LadderContextValue = { following, hasMarket: midTick !== null, hasRows: count > 0, labels, hold, recenter, focus, config, scrollRef, items, totalSize: virtualizer.getTotalSize(), tickAt, domId, midTick, selected, onScroll } return <LadderContext value={state}><ConfigurationContext value={config}> <div {...props} role="grid" tabIndex={0} aria-label={label} data-slot="tradecn-depth-ladder" data-following={following ? "true" : "false"} aria-rowcount={count + headerRows} aria-colcount={columns.length} aria-activedescendant={selectedMounted ? `${domId(selected.tick)}-${selected.col}` : undefined} 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`, "--depth-ladder-columns": columns.map((col) => `minmax(0, ${col === "price" ? 1.2 : 1}fr)`).join(" "), ...style } as CSSProperties} ref={rootRef} onKeyDown={(event) => { onKeyDown?.(event); handleKey(event) }}>{children}</div> </ConfigurationContext></LadderContext> } export function DepthLadderHeader({ className, style, ...props }: ComponentProps<"div">) { const { rowHeight } = useConfiguration() return <div role="row" aria-rowindex={1} data-slot="tradecn-depth-ladder-header" className={cn("grid shrink-0 border-b border-border text-muted-foreground", className)} style={{ gridTemplateColumns: "var(--depth-ladder-columns)", height: rowHeight, ...style }} {...props} /> } export function DepthLadderColumnHeader({ column, className, children, ...props }: ComponentProps<"div"> & { column: LadderColumn }) { const { columns, labels } = useConfiguration() return <div role="columnheader" aria-colindex={columns.indexOf(column) + 1 || undefined} className={cn("flex min-w-0 items-center truncate px-2", column === "bid" ? "justify-end" : column === "ask" ? "justify-start" : "justify-center", className)} {...props}>{children === undefined ? labels[column] : children}</div> } export function DepthLadderViewport({ ref, className, onPointerDownCapture, onWheel, onScroll, ...props }: ComponentProps<"div">) { const state = useLadderContext() const viewportRef = useLadderRef(state.scrollRef, ref) return <div data-slot="tradecn-depth-ladder-viewport" className={cn("relative min-h-0 flex-1 overflow-auto", className)} {...props} ref={viewportRef} onPointerDownCapture={(event) => { onPointerDownCapture?.(event); if (!event.defaultPrevented) state.hold() }} onWheel={(event) => { onWheel?.(event); if (!event.defaultPrevented) state.hold() }} onScroll={(event) => { onScroll?.(event); if (!event.defaultPrevented) state.onScroll(event) }} /> } export function DepthLadderEmpty({ children, className, ...props }: ComponentProps<"div">) { const { hasRows, labels } = useDepthLadder() if (hasRows) return null return <div className={cn("flex h-full items-center justify-center p-4 text-muted-foreground", className)} {...props}>{children === undefined ? labels.noMarket : children}</div> } export interface DepthLadderRowState { tick: number index: number price: number priceText: string level: DepthLevel | undefined bidSize: number | null askSize: number | null myBid: number | null myAsk: number | null isMid: boolean focusedColumn: LadderColumn | null select: (column: LadderColumn) => void } interface RowContextValue extends DepthLadderRowState { config: Configuration start: number height: number domId: string } const RowContext = createContext<RowContextValue | null>(null) function useRowContext() { const row = useContext(RowContext) if (!row) throw new Error("DepthLadder row parts must be inside DepthLadderRows") return row } /** Read the current virtual row without adding a store subscription. */ export function useDepthLadderRow(): DepthLadderRowState { return useRowContext() } interface RowScopeProps { config: Configuration tick: number index: number start: number height: number domId: string isMid: boolean focusedColumn: LadderColumn | null children: (row: DepthLadderRowState) => ReactNode } const RowScope = memo(function RowScope({ children, ...props }: RowScopeProps) { const { config, tick } = props const level = useRow(config.store, levelId(tick)) const price = priceAtTick(tick, config.convention.tick) const row: RowContextValue = { ...props, level, price, priceText: config.format.price(price), bidSize: sizeOf(level?.bidSize), askSize: sizeOf(level?.askSize), myBid: sizeOf(level?.myBid), myAsk: sizeOf(level?.myAsk), select: (col) => config.onCell(tick, col) } return <RowContext value={row}>{children(row)}</RowContext> }) export function DepthLadderRows({ children, className, style, ...props }: Omit<ComponentProps<"div">, "children"> & { children: (row: DepthLadderRowState) => ReactNode }) { const state = useLadderContext() return <div role="rowgroup" className={cn("relative", className)} style={{ ...style, height: state.totalSize }} {...props}> {state.items.map((item) => { const tick = state.tickAt(item.index) return <RowScope key={item.key} config={state.config} tick={tick} index={item.index} start={item.start} height={item.size} domId={state.domId(tick)} isMid={tick === state.midTick} focusedColumn={state.selected?.tick === tick ? state.selected.col : null}>{children}</RowScope> })} </div> } export function DepthLadderRow({ className, style, ...props }: Omit<ComponentProps<"div">, "id"> & { children: ReactNode }) { const row = useRowContext() const mine = row.myBid !== null ? row.myAsk !== null ? "both" : "bid" : row.myAsk !== null ? "ask" : undefined return <div role="row" data-tick={row.tick} data-mid={row.isMid ? "" : undefined} data-mine={mine} data-focused={row.focusedColumn !== null || undefined} aria-rowindex={row.index + row.config.headerRows + 1} aria-description={row.isMid ? row.config.labels.mid : undefined} className={cn("absolute top-0 left-0 grid w-full items-stretch border-b border-border/60", row.isMid && "border-y border-primary/60 bg-muted/60", row.focusedColumn !== null && "outline-1 -outline-offset-1 outline-ring", className)} style={{ gridTemplateColumns: "var(--depth-ladder-columns)", ...style, height: row.height, transform: `translateY(${row.start}px)` }} {...props} id={row.domId} /> } type SideProps = { side: "bid" | "ask" } export function DepthLadderSize({ side, className, ...props }: Omit<ComponentProps<"span">, "children"> & SideProps) { const row = useRowContext() const value = side === "bid" ? row.bidSize : row.askSize if (value === null) return null return <span data-numeric="" className={cn(NUMERIC_CLASS, className)} {...props}>{row.config.formatSize(value)}</span> } export function DepthLadderOwnSize({ side, className, ...props }: Omit<ComponentProps<"span">, "children"> & SideProps) { const row = useRowContext() const value = side === "bid" ? row.myBid : row.myAsk if (value === null) return null return <span data-numeric="" data-mine-size="" className={cn("rounded-sm bg-primary/15 px-1 text-foreground", NUMERIC_CLASS, className)} {...props}>{row.config.formatSize(value)}<span className="sr-only"> {row.config.labels.mine}</span></span> } function ownsCellClick(event: MouseEvent<HTMLDivElement>) { const target = event.target as Element const control = target.closest("button, a, input, select, textarea, label, summary, [contenteditable]:not([contenteditable='false']), [tabindex], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='slider'], [role='spinbutton'], [role='textbox'], [role='link']") return !control || control === event.currentTarget || !event.currentTarget.contains(control) } type CellOwnedProps = "id" | "aria-selected" // The rendered role, explicit undefined included; aria-selected is inherited into the header // roles from gridcell, and no other rendered role takes it. function cellRoleTakesSelected(props: { role?: ComponentProps<"div">["role"] }): boolean { const role = "role" in props ? props.role : "gridcell" return role === "gridcell" || role === "rowheader" || role === "columnheader" } export function DepthLadderSizeCell({ side, ref, className, children, onClick, ...props }: Omit<ComponentProps<"div">, CellOwnedProps> & Partial<Record<CellOwnedProps, never>> & SideProps) { const row = useRowContext() const local = useRef<HTMLDivElement>(null) const cellRef = useLadderRef(local, ref) const size = side === "bid" ? row.bidSize : row.askSize const mine = side === "bid" ? row.myBid : row.myAsk const selectable = cellRoleTakesSelected(props) useFlash(local, size, { windowMs: row.config.flashWindowMs, variant: "fill" }) // Only the reserved pair lands after the spread: the generated id is what aria-activedescendant // points at, and the selected state is what the keyboard contract rests on. Everything else // stays overridable — and the selected state follows the rendered role, emitted only where // ARIA takes it: gridcell, rowheader, or columnheader. return <div role="gridcell" aria-colindex={row.config.columns.indexOf(side) + 1 || undefined} data-col={side} data-side={side} data-numeric="" data-mine={mine !== null ? "" : undefined} data-focused-col={row.focusedColumn === side || undefined} className={cn("flex h-full min-w-0 cursor-pointer items-center gap-1 truncate px-2", NUMERIC_CLASS, FILL_CLASSES, side === "bid" ? "justify-end text-up" : "justify-start text-down", row.focusedColumn === side && (row.isMid ? "shadow-[inset_0_0_0_1px_var(--ring)]" : "bg-muted/50"), className)} {...props} ref={cellRef} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented && ownsCellClick(event)) row.select(side) }} id={`${row.domId}-${side}`} aria-selected={(selectable && row.focusedColumn === side) || undefined}>{children === undefined ? <><DepthLadderOwnSize side={side} /><DepthLadderSize side={side} /></> : children}</div> } export function DepthLadderPriceCell({ className, children, onClick, ...props }: Omit<ComponentProps<"div">, CellOwnedProps> & Partial<Record<CellOwnedProps, never>>) { const row = useRowContext() const selectable = cellRoleTakesSelected(props) return <div role="gridcell" aria-colindex={row.config.columns.indexOf("price") + 1 || undefined} data-col="price" data-numeric="" data-focused-col={row.focusedColumn === "price" || undefined} className={cn("flex h-full min-w-0 items-center justify-center truncate px-2 text-foreground", numericFontClass(row.config.convention.price), row.focusedColumn === "price" && "bg-muted/50", className)} {...props} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented && ownsCellClick(event)) row.select("price") }} id={`${row.domId}-price`} aria-selected={(selectable && row.focusedColumn === "price") || undefined}>{children === undefined ? row.priceText : children}</div> } /** Hides while following or without a market; keep it mounted to retain focus recovery. */ export function DepthLadderRecenter({ ref, children, className, onClick, onFocus, onBlur, disabled, ...props }: ComponentProps<typeof Button>) { const { following, hasMarket, recenter, focus, labels } = useDepthLadder() const visible = !following && hasMarket const button = useRef<HTMLButtonElement>(null) const buttonRef = useLadderRef(button, ref) const wasFocused = useRef(false) useLayoutEffect(() => { if ((!visible || disabled) && wasFocused.current) { wasFocused.current = false focus() } }, [visible, disabled, focus]) if (!visible) return null return <Button type="button" size="sm" data-ladder-recenter="" className={cn("h-auto rounded-full px-2.5 py-0.5 text-xs", className)} {...props} ref={buttonRef} disabled={disabled} onFocus={(event) => { wasFocused.current = true; onFocus?.(event) }} onBlur={(event) => { wasFocused.current = false; onBlur?.(event) }} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) recenter() }}>{children === undefined ? labels.recenter : children}</Button> }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, 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 }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 { --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,
DepthLadderHeader,
DepthLadderColumnHeader,
DepthLadderViewport,
DepthLadderEmpty,
DepthLadderRows,
DepthLadderRow,
DepthLadderSizeCell,
DepthLadderPriceCell,
DepthLadderRecenter,
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 renderLadderRow() {
return (
<DepthLadderRow>
<DepthLadderSizeCell side="bid" />
<DepthLadderPriceCell />
<DepthLadderSizeCell side="ask" />
</DepthLadderRow>
)
}
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}>
<DepthLadderHeader>
<DepthLadderColumnHeader column="bid" />
<DepthLadderColumnHeader column="price" />
<DepthLadderColumnHeader column="ask" />
</DepthLadderHeader>
<DepthLadderViewport>
<DepthLadderEmpty />
<DepthLadderRows>{renderLadderRow}</DepthLadderRows>
</DepthLadderViewport>
<DepthLadderRecenter className="absolute bottom-2 left-1/2 z-30 -translate-x-1/2" />
</DepthLadder>
</div>
<p role="status" className="text-muted-foreground">{staged ? `Staged: ${staged.side} at ${format.price(staged.price)}.` : "Nothing staged."}</p>
</div>
)
}
Key levels with levelId(tickIndexOf(price, convention.tick)). Click a size cell or select it with the arrows and press Enter to stage its price and side. Your application handles onStage.
Composition
Use the following composition to build a DepthLadder:
DepthLadder
├── DepthLadderHeader
│ ├── DepthLadderColumnHeader (bid)
│ ├── DepthLadderColumnHeader (price)
│ └── DepthLadderColumnHeader (ask)
├── DepthLadderViewport
│ ├── DepthLadderEmpty
│ └── DepthLadderRows
│ └── DepthLadderRow
│ ├── DepthLadderSizeCell (bid)
│ │ ├── DepthLadderOwnSize
│ │ └── DepthLadderSize
│ ├── DepthLadderPriceCell
│ └── DepthLadderSizeCell (ask)
│ ├── DepthLadderOwnSize
│ └── DepthLadderSize
└── DepthLadderRecenter
DepthLadderRows calls your render function for each mounted tick. Return one DepthLadderRow with cells in the same order as columns, each declared column rendered once by its tradecn cell part: the generated cell ids the descendant points at exist only through those parts.
Parts whose column is absent from columns omit aria-colindex. They do not change the declared navigation order.
Keep the render function, convention, columns, labels, and formatSize stable when the parent receives frequent updates. New object or function identities re-render every mounted row.
Book updates
Apply book deltas to update the affected rows. Only market-size changes flash.
import { useState } from "react"
import type { InstrumentConvention } from "@/lib/format"
import { createRowStore, type DeltaBatch } from "@/lib/row-store"
import { DepthLadder, DepthLadderHeader, DepthLadderColumnHeader, DepthLadderViewport, DepthLadderEmpty, DepthLadderRows, DepthLadderRow, DepthLadderSizeCell, DepthLadderPriceCell, DepthLadderRecenter, 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)] } },
]
function renderLadderRow() {
return (
<DepthLadderRow>
<DepthLadderSizeCell side="bid" />
<DepthLadderPriceCell />
<DepthLadderSizeCell side="ask" />
</DepthLadderRow>
)
}
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}>
<DepthLadderHeader>
<DepthLadderColumnHeader column="bid" />
<DepthLadderColumnHeader column="price" />
<DepthLadderColumnHeader column="ask" />
</DepthLadderHeader>
<DepthLadderViewport>
<DepthLadderEmpty />
<DepthLadderRows>{renderLadderRow}</DepthLadderRows>
</DepthLadderViewport>
<DepthLadderRecenter className="absolute bottom-2 left-1/2 z-30 -translate-x-1/2" />
</DepthLadder>
</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
Use DepthLadderRecenter to resume following after a pointer, scroll, or navigation key holds the prices still.
import { useState } from "react"
import { createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DepthLadder, DepthLadderHeader, DepthLadderColumnHeader, DepthLadderViewport, DepthLadderEmpty, DepthLadderRows, DepthLadderRow, DepthLadderSizeCell, DepthLadderPriceCell, DepthLadderRecenter, 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 }]
function renderLadderRow() {
return (
<DepthLadderRow>
<DepthLadderSizeCell side="bid" />
<DepthLadderPriceCell />
<DepthLadderSizeCell side="ask" />
</DepthLadderRow>
)
}
export function DepthLadderContent() {
return (
<>
<DepthLadderHeader>
<DepthLadderColumnHeader column="bid" />
<DepthLadderColumnHeader column="price" />
<DepthLadderColumnHeader column="ask" />
</DepthLadderHeader>
<DepthLadderViewport>
<DepthLadderEmpty />
<DepthLadderRows>{renderLadderRow}</DepthLadderRows>
</DepthLadderViewport>
<DepthLadderRecenter className="absolute bottom-2 left-1/2 z-30 -translate-x-1/2" />
</>
)
}
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}>
<DepthLadderContent />
</DepthLadder>
</div>
<p role="status" className="text-muted-foreground">Market mid: {format.price(mid)}.</p>
</div>
</>
)
}
Custom layout
Set columns and order to match your layout, and compose size readings and controls where you need them.
import { useState } from "react"
import { NUMERIC_CLASS, createInstrumentFormatter, type InstrumentConvention } from "@/lib/format"
import { createRowStore } from "@/lib/row-store"
import { DepthLadder, DepthLadderColumnHeader, DepthLadderEmpty, DepthLadderHeader, DepthLadderOwnSize, DepthLadderPriceCell, DepthLadderRecenter, DepthLadderRow, DepthLadderRows, DepthLadderSize, DepthLadderSizeCell, DepthLadderViewport, levelId, tickIndexOf, type DepthLevel, type LadderColumn, 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)
const columns: readonly LadderColumn[] = ["price", "bid", "ask"]
function renderRow() {
return <DepthLadderRow>
<DepthLadderPriceCell className="justify-start font-medium" />
<DepthLadderSizeCell side="bid"><DepthLadderSize side="bid" /><DepthLadderOwnSize side="bid" /></DepthLadderSizeCell>
<DepthLadderSizeCell side="ask"><DepthLadderSize side="ask" /><DepthLadderOwnSize side="ask" /></DepthLadderSizeCell>
</DepthLadderRow>
}
export default function DepthLadderLayoutDemo() {
const [store] = useState(() => {
const store = createRowStore<DepthLevel>({ getRowId: (level) => levelId(level.tick) })
store.applyDeltas({ upsert: [{ tick: tick - 1, bidSize: 220, myBid: 25 }, { tick: tick + 1, askSize: 180, myAsk: 15 }] })
return store
})
const [staged, setStaged] = useState<LadderStage | null>(null)
return <div className="w-80 max-w-full space-y-2 text-xs lining-nums tabular-nums">
<DepthLadder store={store} convention={ZN} mid={mid} label="ZN ascending ladder" depth={4} columns={columns} order="ascending" rowHeight={28} onStage={setStaged} className="h-80">
<div className="flex h-10 shrink-0 items-center justify-between border-b px-2">
<span className="font-medium">ZN · Ascending prices</span>
<DepthLadderRecenter variant="outline">Follow mid</DepthLadderRecenter>
</div>
<DepthLadderHeader>
<DepthLadderColumnHeader column="price" className="justify-start" />
<DepthLadderColumnHeader column="bid" />
<DepthLadderColumnHeader column="ask" />
</DepthLadderHeader>
<DepthLadderViewport>
<DepthLadderEmpty>Waiting for ZN</DepthLadderEmpty>
<DepthLadderRows>{renderRow}</DepthLadderRows>
</DepthLadderViewport>
</DepthLadder>
<p role="status" className={`text-muted-foreground ${NUMERIC_CLASS}`}>{staged ? `Staged: ${staged.side} at ${format.price(staged.price)}.` : "Nothing staged."}</p>
</div>
}
API Reference
The ladder defines 2 × depth + 1 rungs around a center and mounts the visible rungs plus overscan and the selected rung while it remains in that range. 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, a finite positive number; with any other, the ladder shows its empty state and stages nothing. |
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. |
children |
ReactNode |
Required | The header, viewport, rows, controls, and application content. |
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. |
columns |
readonly LadderColumn[] |
["bid", "price", "ask"] |
Visual and keyboard column order. Use a nonempty array of distinct columns. |
order |
"ascending" | "descending" |
"descending" |
Price order from top to bottom. |
headerRows |
number |
1 |
Header rows included in accessible row indices. Set to 0 when omitting the header. |
className |
string |
None | Classes on the root. |
initialRect |
{ width: number; height: number } |
None | Viewport size in px before measurement, for tests or server rendering. |
The root accepts native div props and a ref, except for its managed role, tabIndex, aria-label, aria-rowcount, aria-colcount, and aria-activedescendant. Use label, columns, and headerRows to configure the grid.
Give it a bounded height and mount one DepthLadderViewport containing one DepthLadderRows. Keep the viewport and rows mounted when the market is missing. DepthLadderEmpty handles the initial empty state.
Use a nonnegative integer depth, a positive rowHeight, and nonnegative integer overscan.
Parts
| Part | Props | Default content |
|---|---|---|
DepthLadderHeader |
Native div props. |
Caller-owned column headers. |
DepthLadderColumnHeader |
Native div props and required column: LadderColumn. |
labels[column]. |
DepthLadderViewport |
Native div props. |
Caller-owned rows and empty state. |
DepthLadderRows |
Native div props with required children: (row: DepthLadderRowState) => ReactNode. |
One callback per mounted tick. |
DepthLadderRow |
Native div props except id, and required children. |
Caller-owned cells. |
DepthLadderSizeCell |
Native div props except id and aria-selected, and required side: "bid" | "ask". |
Own size followed by market size. |
DepthLadderPriceCell |
Native div props except id and aria-selected. |
The formatted price. |
DepthLadderSize |
Native span props except children, and required side. |
The formatted market size. Absent for a blank size. |
DepthLadderOwnSize |
Native span props except children, and required side. |
The own-size chip and labels.mine. Absent for a blank size. |
DepthLadderEmpty |
Native div props. |
labels.noMarket. Only before the first finite mid. |
DepthLadderRecenter |
The installed shadcn Button props. |
labels.recenter. Only while held with a finite mid. |
All rendered parts forward refs and accept className.
Native event handlers run first. preventDefault() cancels the corresponding ladder action. Cell clicks on nested buttons, links, and form controls keep their own behavior.
Explicit cell children replace the default readings, including null to suppress them.
Keep visible side labels and an own-size description in custom content so color is not the only cue.
Place Recenter in your toolbar or position it with classes as in Usage. Keep the part mounted so it can hide itself and return focus to the grid when its focused button disappears or becomes disabled.
The row height and transform, and the rows container height, are reserved for virtualization. Set rowHeight on the root.
Set --depth-ladder-columns on the root's style to customize track widths consistently across headers and rows.
Row and cell IDs are generated and reserved: the grid's active descendant names the focused cell, which also carries aria-selected where its rendered role takes the state — gridcell, rowheader, or columnheader. Use a data attribute to identify an application row.
Hooks
useDepthLadder() returns shared root state and actions for custom controls.
| Field | Type | Purpose |
|---|---|---|
following |
boolean |
Whether the viewport follows the mid. |
hasMarket |
boolean |
Whether the current mid is finite. |
hasRows |
boolean |
Whether a range has been built. |
labels |
DepthLadderLabels |
Default labels with caller overrides. |
hold |
() => void |
Stop following and retain the current price range. |
recenter |
() => void |
Rebuild around a finite mid and follow it. Retains the selected tick and column. |
focus |
() => void |
Focus the grid without scrolling it. |
A replacement control owns its native activation and any focus recovery when it disappears.
recenter() does nothing without a finite mid and retains the selected tick and column.
useDepthLadderRow() reads the current subscribed row inside DepthLadderRows.
The render callback receives the same state. Neither adds a store subscription.
| Row field | Type | Purpose |
|---|---|---|
tick, index, price |
number |
Tick identity, zero-based visual index, and price. |
priceText |
string |
Price formatted through the convention. |
level |
DepthLevel | undefined |
The current store row. |
bidSize, askSize, myBid, myAsk |
number | null |
Normalized size readings. |
isMid |
boolean |
Whether this tick is the current market mid. |
focusedColumn |
LadderColumn | null |
The selected column when this row is selected. |
select |
(column: LadderColumn) => void |
Hold the ladder, focus the grid, select a cell, and stage a bid or ask. |
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 by default. order="ascending" reverses them. While following, the ladder centers tickIndexOf(mid, convention.tick) when that tick changes, within the scrollable bounds. Remount with a key per instrument: a new store or convention keeps the range, the selection, and the flash history.
The mid rung carries data-mid and is described as Mid. The root always carries data-following, as "true" or "false".
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 mounted DepthLadderRecenter becomes visible. 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. Headers belong to the caller. DepthLadderColumnHeader defaults to labels.bid and labels.ask. |
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. A focused cell is tinted, except a size cell on the mid row, which is ringed, since a second tint would darken the row's own under its up or down text; a focused price cell there keeps its tint under foreground text. |
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 one visual row up or down, starting from the mid. Stops following. |
| PageUp / PageDown | Move the focus by a viewport of rungs. |
| Left / Right | Move between the declared columns, in visual order. |
| 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 named by label.
Its column count follows columns. aria-rowcount includes headerRows and the full anchored range.
Mounted rungs report their row indices. aria-activedescendant names the focused cell, <rung id>-bid, -price, or -ask, and the cell carries aria-selected, so a screen reader hears which side is selected before Enter stages it. The selected rung stays mounted outside the viewport while it remains in the anchored range, so the reference stays valid during page jumps. The cells' id is managed; pass your own ids on surrounding content instead.
A cell click focuses the grid.
Nested controls keep their native keys, and composing keys are left alone. Removing a selected column hides its focus marks and reports focusedColumn: null to custom row content.
Recenter does not reset keyboard focus. Whenever the selected tick sits outside the anchored range — after a recenter, a drift while following, or a smaller depth — its focus mark and aria-activedescendant disappear, and staging refuses it from every path, Enter and kept select references alike. The refused Enter is consumed and gives no feedback. Navigation clamps the selection back onto the ladder, and a selection on the ladder keeps staging as before.
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
Feed the aggregated book and market mid from your application. Handle onStage in your ticket, including permissions and any order submission. The ladder sends no orders and makes no live announcements. Compose a status region when staging needs one.
Tokens
The install adds up, down, and flat with their soft variants if you do not have them, plus the shared font tokens and the hyperlegible remap. The bid and ask columns use the first two, and the flashes use all three.
Built on shadcn's button.
This page is docs/depth-ladder.md at v2.0.0.