The Plot Contract: What Survives What
A Plot is the chart. The rendering (Series) and the data get swapped out, but what you mounted onto the chart survives the swap. Two things are mounted:
- The range you're looking at — the x domain (the pan/zoom position)
- What you attached to the overlay — DOM elements like annotations and tooltips
This document collects in one place what each public method touches. When you add a new method, the default must be "touches nothing".
What each method touches
| Method | Data | x domain | y domain | Layers | Render scheduled |
|---|---|---|---|---|---|
pane.addSeries({ series, data }) | that registration owns it | fit, if it's the first data | fit | — | once |
handle.setData(data) | replaces that registration only | fit | fit | — | once |
handle.prepend(points) | prepends | — | — | — | once |
handle.append(points) | appends | — | — | — | once |
handle.updateLast(point) | replaces the last point (same x) or appends (larger x) | —¹ | — | — | once |
handle.upsert(points) | merges by x — replaces the bars it names, adds the ones it does not hold, keeps the rest | —¹ | — | — | once |
handle.dispose() | removes that registration | — | fit | — | once |
pane.syncSeries(specs) | replaced by each spec's data | — | fit | — | once |
mainPane.setSeries(reg) | only that one registration remains | — | fit | — | once |
mainPane.clearSeries() | drops everything | — | — | — | once |
pane.applyOptions(o) | — | — | —² | — | once |
addPane(o) / removePane(p) | — | — | — | redistributed | once |
addDecoration(d) / its disposer | — | — | — | — | once |
| dragging a divider | — | — | — | redistributed | every move |
fitDomains() | — | fit | fit, and every pane is autoScale again | — | once |
scrollToRealTime() | — | shifts (width preserved, right edge = live) | — | — | once |
applyOptions(patch) | — | — | — | — | once |
setViewport(size) | — | — | — | resized | once |
pan / panByPixels | — | shifts | — | — | once |
zoom / zoomAtPixel | — | zooms in/out³ | — | — | once |
claimCursor(c) / its disposer | — | — | — | cursor only⁴ | — |
requestRender() | — | — | — | — | once |
render() | — | — | — | — | draws now |
destroy() | — | — | — | torn down | everything after is ignored |
— means it doesn't touch it. Read-only calls (getOptions, getSeries, handle.xRange, pane.xRange(), on) touch nothing, so they aren't in the table.
¹ Two exceptions — if shiftVisibleRangeOnNewBar is on and you were looking at the last bar, the window shifts right with the new bar the moment it arrives (off by default); and the first data a chart gets is fitted, whichever door it came through — unless a visible range was supplied before the data arrived, which wins. Data arriving after every series went empty is a first arrival again (a value range set by hand on the old data does not carry over to it), and while the first fit covered a single x — or a history too short to fill the screen at the default spacing — the window keeps fitting each data change until something else moves it — filling the screen until the bars reach the default spacing, then following the newest bar at that width. When any series draws a bar body (candles, bars, histograms), every fit pads each end by half a bar; lines alone fit edge to edge.
² Options don't touch the points being drawn, so the value axis isn't refit on the spot (the branch where PaneChange.data is false). A pane with autoScale on refits to the visible range on every render anyway, and on a pane with it off, holding the range you set is the request. For the same reason the x index isn't rebuilt either — in bar-index coordinates, changing one padding value must not cost a merge sort over every point.
³ Zoom has limits (min/maxBarSpacing) — bar-index coordinates default to min 0.5 and max 200 (px per bar), continuous coordinates have no default, and 0 turns the limit off in that direction. At a limit the point under the cursor stays pinned and only the width is clipped, and when the width is clipped away entirely the domain is unchanged, so no xDomainChange goes out either. Turning the limits off doesn't buy you infinity — once the width reaches the floor of floating point (the ulp of the domain values), zooming in stops quietly. A fit or a window you set (fitDomains, setVisibleRange) doesn't pass through these limits.
Position has bounds too — a gesture can't push the data off screen. Pan travels only as far as domain.min ≤ last bar and domain.max ≥ first bar (a screen width of padding is left on both sides), and zoom's center is clamped to the data range — meaning you can keep zooming in on empty space and never lose the chart. From a window that points outside the bounds (one set with setVisibleRange pointing into empty space), only the direction that moves back through passes. With no data there are no bounds, and the programmatic path doesn't go through here either.
⁴ A claim on the cursor shape — nothing is drawn, so there's no render. The later claim wins, and releasing it falls back to the one underneath. To change the shape mid-drag, mount the new one first and release the old one after — that way the on-screen cursor doesn't flicker in between. It doesn't throw on a headless chart either — there's just no screen to show it on. The value is a CSS cursor value as-is ("grabbing", "crosshair", "ns-resize", …).
There is no plot.setData. Data belongs to the registration, not to the chart — on a chart with three series there's no way to define where that call would land. You hand it over at mount time (addSeries({ series, data })), and to swap it later you use the SeriesHandle you got back then.
const btc = pane.addSeries({ series: candleSeries(), data: btcCandles });
const eth = pane.addSeries({ series: lineSeries(), data: ethPrices });
btc.append([tick]); // only btc grows
eth.xRange; // { min, max } | null — answers for itself onlyWhen it draws
"Render scheduled" in the table doesn't mean it drew — it means it decided to draw. State changes the instant you call it; the canvas is drawn once on the next frame.
handle.setData(data);
handle.xRange; // already changed — state is synchronous
canvas.toDataURL(); // not drawn yetRequests within the same frame all coalesce into one. Twelve pointermoves during a pan, or the composition API mounting four series — one draw.
If you need it drawn now, call render() yourself. The scheduled frame is dropped, so the same picture is never drawn twice.
handle.setData(data);
plot.render(); // draws now
canvas.toDataURL(); // it's drawnTo change the timing wholesale, pass PlotDeps.createScheduler. The preset default is frameScheduler(), and where there's no requestAnimationFrame (node, SSR) it falls back to running immediately.
| Scheduler | When |
|---|---|
frameScheduler(view?) | once on the next frame (the preset default) |
immediateScheduler | the instant it's requested — when you need the result on the line after the change |
manualScheduler() | when a test calls flush() |
destroy() cancels the scheduled frame. No label ever gets drawn onto an overlay that's already been torn down.
Why it's built this way
Only three things refit x — the first data arrival, the imperative
handle.setData, andfitDomains()— plus the two cases that are a first arrival in disguise: data coming back after every series went empty, and a window still following a feed (a first fit to a single x or to a history too short to fill the screen), which refits on each data change until it is moved. Everything else keeps the range you were looking at.- The first one is needed because until then the x domain is the scale's default and nothing is in its place.
handle.setDatarefits because it's a new dataset. Ifprepend/append— which glue another page onto the same dataset — refit, the view would zoom out further with every page of history you load.- The declarative lane (
syncSeries'sdata) doesn't refit. Handing over an array again can't distinguish "replace" from "prepend", and a series mounted later must not jolt the window you're watching out to the union. That's why infinite scroll in React is, at bottom,setState(prev => [...older, ...prev])—useInfiniteHistorydoes that and also keeps the loader's place across a chart remount.
setSeriesrefits y only. Every series occupies a different value range (line = close, candle = low–high). x is the range you were looking at, so it's kept. Data belongs to the registration, so it isn't inherited from the previous one — you hand it to the new registration.applyOptionsdoesn't touch what you left out. Passaxis: { x }andystays as it was — the oldsetConfigwas a shallow merge, soyvanished quietly right here. The unit of replacement differs by slot:padding,axis.xandaxis.ymerge per field;styleis replaced wholesale (that's what letsstyle: { grid: {} }get you back to the defaults).applyOptionsdoesn't rebuild the layers. Recreating thePlotfor something like a grid toggle would create a new canvas, and the pan position and the overlay annotations would go with it. A host (the React wrapper included) has to be able to change configuration without recreating thePlot.Axis labels live in the overlay, not on the canvas. The DOM elements survive a re-render (principle: split by surface).
setViewportchanges state only. The canvas bitmap follows right before the draw — assigning tocanvas.widthwipes the picture, so if the wipe and the draw drift apart you see a blank frame in between (resize flicker). The size itself lands immediately, so layout math on the next line already uses the new value.The value axis follows the visible range (
PaneOptions.autoScale, true by default). Zoom in on x and the values in that range fill the pane.While it's on, the value domain is derived, not state. It's recomputed on every render, so anything you set by hand with
pane.yScale.setDomain()is overwritten on the next frame. To set it yourself, turn it off withautoScale: false— then it fits once against the whole source data, as before. Two things hand it back:pane.resetValueAxis()(a double-click on that pane's y-axis strip does this, whenaxisDragis on) andfitDomains(), which turns it on for every pane — "show me everything" includes following it again. The fit data changes take (the first data, an imperativesetData) refits the range but leaves the mode alone. To move x while keeping a manual range,scrollToRealTime()orsetVisibleRange().Nothing happens after
destroy(). Renders and state changes are ignored quietly — it doesn't throw because a late event handler calling in mid-unmount is the normal path.The exception is a door that has something to hand back.
addPane()can't be ignored — ignoring it would mean handing back a pane that pretends to be alive. That pane isn't inpaneListso nobody looks at it, yetaddSeriesworks on it, and nobody ever callsdisposeon the extensions installed on it. Instead of a quiet zombie it throws aContractError. The dividing line is whether there's something to hand back: for a notification (render,requestRender, a state change) ignoring really does mean nothing happens, and for a door that makes something, it doesn't.tsconst config = await fetchConfig(); if (!disposed) plot.addPane(config.rsi); // the late lander is the one that asks
Pane
A bundle of series sharing one value axis. Put several series in the same pane and they draw over each other, with the later one on top.
Plotowns x,Paneowns y. pan/zoom only ever touches x, so however many panes there are they move together on their own.plot.mainPaneis always there. If you never create a pane of your own every series lands here, so a single pane behaves exactly as it always has.pane.valueExtent()is the union over the series it holds. Each series occupies a different span (a line is one close, a candle is low–high), and only the union clips nothing. With no series there's nothing to fit against, so y isn't touched.With nothing to measure it's
null. If a series whose data hasn't arrived reported{0,0}, the union would be dragged down to 0 and candles in the 40k–70k range would be squashed against the edge of the screen.Pass the visible range (
Viewport) and it measures that range only. Each registration clips through its own manager, so a derived series measures the points it produced, not the source data, clipped to the same viewport.pane.xRange()is the union of the x values the series it holds draw.Plotuses it when fitting x and when asking "is there anything to draw" — since the data moved down, the registration is the only thing that can answer that.Registrations split three ways by where the points to draw come from. A wrong combination is caught at compile time — not by a runtime check.
Kind seriesData as-is Series<TSource>data(its own)derived Series<TPoint>data+deriveinput Series<TPoint>input(someone else's)tspane.addSeries({ series: lineSeries(), data: candles }); // ✗ compile error pane.addSeries({ series: lineSeries(), input, data }); // ✗ compile errorThere are two ways to mount a series.
addSeriesis the imperative registration that hands back a handle (disposeis safe to call twice), andsyncSeriestakes the whole list as an array. Mix the two and the later call throws aContractErrorbefore it detaches anything. To move from an imperative list tosyncSeries, callclearSeries()first; to empty a declarative list and return to imperative control, callsyncSeries([]). The roles split:syncSeriesowns the list, the handle owns the data.The six write doors on a detached handle (
setData,prepend,append,updateLast,upsert,swapSeries) throw aContractError. Letting them pass quietly moved the chart for real —setData's refit re-fitted the x window against the remaining series and the pan you had set jumped. Two doors detach a handle: adispose()you called yourself, or imperativesetSeries()replacing it. The latter is a removal you never called, so you ask withhandle.attached—read(),xRangeanddispose()are safe after detaching. Even an empty array (append([])) throws on a detached handle.tssocket.on("tick", (t) => { if (handle.attached) handle.updateLast(t); });Identity in
syncSeries(specs)isspec.id. Same id keeps the registration and swaps only the series reference, so the derive cache survives, andderiveKeydecides whether to recompute. Draw order is array order — a series that arrives late behind a condition still lands in its place. Build specs withseriesSpec().A
Paneholds neither a renderer nor data, so it can't redraw itself. When series or options change it notifies its subscribers (pane.subscribe) andPlotfits y and then draws. There can be several listeners, and each gets its own disposer.
Computed nodes
Run once, feed several drawings. MACD isn't one line but three (macd, signal, histogram), and hanging a derive on each branch runs the same EMA three times.
const price = pane.addSeries({ series: candleSeries(), data: candles });
const macd = computation({
inputs: [price], // values. you don't point at them by name
calc: (candles) => ({ macd, signal, histogram }),
});
lower.addSeries({ series: lineSeries(), input: macd.out.signal });
lower.addSeries({ series: lineSeries(), input: macd.out.histogram });- A reference is a value. A typo is a compile error, and you can't reference something that doesn't exist, so a cycle is structurally impossible. That's why there's no cycle check.
- It isn't
plot.addComputation. Inputs and outputs are both values, so there's nothing to register on the chart — it's a free function and thePlotAPI doesn't grow by a single member. - It pulls. When you read a branch, if the identity of the input array is unchanged it hands back the previous result. So however many branches there are, one data change means one computation, and there's no subscription to wire.
- Inputs are an array — some indicators read price and volume together, and widening from one to an array later would break every call site.
- An element can be a handle or a branch of another computation. Indicators on indicators come free.
- Branch names are fixed by the first computation. Even with empty inputs it has to produce the same keys.
- A registration that draws from
inputdoesn't own its data.setData,prependandappendon that handle throw — what you swap is the input.
Measured: recomputing MACD's three branches every frame, three derives at 32.6ms → a computed node at 16.1ms (−50.8%); cold start 32.1ms → 17.7ms. A paired comparison inside the same session.
Whitespace
y: null is "there's no value here". Keep the slot, empty only the value — drop the point instead and the line draws straight across that stretch, showing a value that isn't there.
const ma = source.map((c, i) => ({ x: c.x, y: i < 19 ? null : average(i) }));- The line is drawn separately for each unbroken run of values (it breaks at the holes)
valueExtentdoesn't count holes. All holes meansnull- Decimation doesn't swallow holes — consecutive holes fold into one
- x can't be empty. A non-finite x is a
DataError(the same type as an ordering violation)
Checking data before it goes in
Every data door runs one rule set (scanSeriesData), and two doors let you ask it before ingestion, as a value instead of a thrown DataError: validateSeriesData(data, accessor?, seam?) for an array and validateSeriesPoint(point, accessor?, { lastX }) for one tick. null means the matching door will not throw (identity registrations — a derived series re-checks its own output under its own accessor). lastX/firstX are the x you hold as the accessor reads it — point.x for every built-in one. Codes name the fact the data broke, not the fix and not our declaration:
| Code | The fact |
|---|---|
not-an-array | the payload is not an array (index −1) |
not-an-object | a point is not an object |
unreadable-y | the accessor reads no value from the first or last point — a wrong field name, which a .map() makes uniform |
non-finite-x | x is not a finite number |
non-finite-value | a value the accessor checks is not finite (for bars: a price is null/absent/NaN, or volume is present but not a number) |
unsorted-x | x goes backwards — inside the chunk, or against the seam you passed |
duplicate-x | x repeats where the accessor declared one point per x (uniqueX — bars do) |
| Door | Pre-check |
|---|---|
setData(data) | validateSeriesData(data, accessor) |
append(points) | validateSeriesData(points, accessor, { lastX }) — lastX is the tail you hold; with uniqueX a chunk starting on it is a duplicate |
prepend(points) | validateSeriesData(points, accessor, { firstX }) |
updateLast(point) | validateSeriesPoint(point, accessor, { lastX }) — the same x replaces the bar, an earlier x is the past |
swapSeries(next) | validateSeriesData(handle.read(), next's accessor) — a swap re-checks what you hold under the new rules |
| a derived series' own output | — ⁶ |
⁶ A derivation runs when its data changes — synchronously, inside the data door that changed it (setData, append, prepend, updateLast) — and its output is checked there, so a defect throws from that call; a registration fed by an external input re-reads and validates that input when it is next read. Validate the source you hand it.
The socket recipe (type-checked) is onTick in Getting started's real service. A page that overlaps what you hold (an inclusive REST bound) is dropped with page.filter((bar) => bar.x > lastX) — the filter infiniteHistory applies to its own pages; a door that merges overlap for you is not here yet.
Derived series
Sugar for one input and one output. If there's only one branch, use this instead of building a node.
pane.addSeries({ series, data, derive: (source) => points, coordinates });derivegets that registration's wholedata, not the visible range. That's what it takes for an indicator that has to look backwards — a moving average — not to break off at the left edge. A series withoutderivegets the clipped range, as before.- It recomputes only when the data changes. Calling it on the draw path would put the data size inside the frame budget — O(n) per indicator, so it multiplies as you add panes.
- The incremental doors —
calcLast/calcFirst/headLookbackon a computed node,deriveLast/deriveFirston a derivation whose output is one point per input point — are optional and live on the node or registration that declares them. A plainderivehas none: whenprependglues history on, the whole thing is recomputed, and that's what makes the values near the boundary correct on their own (a 20-day moving average only becomes right once the 20 points before it exist).deriveLast's contract is the shape of the changed tail — exactly one output point for a replaced last input, exactlycountforcountappended — so a derivation in which one input can create or destroy any number of outputs (a price-axis transform, below) has no honest way to declare it. - The derived result is clipped against the same viewport and goes through decimation too. Every registration, derived or not, has its own
DataManager— there's one path for clipping. - The managers come from one factory (
PlotDeps.createDataManager). Chart-wide policy — caps, tiers — is the same for all, and strategy and density are stated by whoever knows the point type: the registration (decimation) >Series.decimation> the factory default (M4Decimationat 4 points per pixel). They don't share an instance because each holds different data. valueExtentgoes by the derived values too — it measures what the derive produced, not the source data. WithautoScaleon it clips those values to the visible range again before measuring. Don't confuse this withderivegetting the whole source — compute over everything, measure over what's visible.
A good share of the other "chart types" aren't new series — they're combinations of these ingredients:
- Scatter — no new series type needed. Erase the line and keep the points:
lineSeries({ line: { color: "transparent" }, point: { radius: 3 } }). Parabolic SAR in@finchart/indicatorsalready plots points with exactly this trick. - Heikin-Ashi — this is precisely where
derivebelongs.heikinAshi(source): OHLC[](@finchart/indicators) is a pure function turning source OHLC into smoothed OHLC, socandleSeries()draws it unchanged:pane.addSeries({ series: candleSeries(), data, derive: heikinAshi }). - Renko (and its lineage — Line Break, Kagi, Point & Figure) is a derivation too, but not a one-to-one one: a bar doesn't correspond to one input point, bricks are born and die as price moves, so x can't inherit the source's x. Its x is an ordinal, and that has consequences — the next section.
Price-axis transforms
Renko throws time away and keeps price: a brick each time the close moves brickSize in the current direction, and two bricks' worth — a one-brick gap plus the brick — to reverse. renko(candles, { brickSize }) (@finchart/indicators) is a pure function OHLC[] → RenkoBrick[], the bricks are OHLC-shaped so candleSeries() draws them unchanged. Three rules make it a price-axis transform — the rules of the lineage (Line Break ships beside it; Kagi and Point & Figure belong to it too):
- x is an ordinal (0, 1, 2 …), never the source's x. When one candle closes several bricks they share that candle's x as
closedAt— the key that wins time back for the axis, the tooltip and the crosshair badge in one place, the axis format. Read the bricks from the handle — that is the array the chart accepted, atomically with its validation — and narrow the point, becausecandleSeries()types the handle's points asOHLC:ts(Keeping the derive's return value in a variable of your own is one step behind on a rejected output: the chart keeps its last accepted bricks, your variable holds the rejected ones.)const handle = pane.addSeries({ series: candleSeries(), data: candles, derive: (c) => renko(c, { brickSize }), }); plot.applyOptions({ axis: { x: { format: (x) => { // A tick inside the bricks' span takes the nearest brick's time; outside it there is no time — label nothing. const bricks = handle.read(); const brick = x >= 0 && x <= bricks.length - 1 ? bricks[Math.round(x)] : undefined; return brick && "closedAt" in brick && typeof brick.closedAt === "number" ? label(brick.closedAt) : ""; } } } }); - A transform is a
derive, and the tick goes in through the source door. Feed ticks withhandle.updateLast(candle)/handle.append([candle]), and validate those candles yourself first: the registration checks the bricks it draws, not the candles it derives them from (footnote ⁶ above) —updateLastchecks the one candle's shape and, once the source holds a candle, its x (finite, not earlier than the last); the array doors check nothing of the source — so a NaN close, or a duplicate x inside an appended array, passes through to the transform. On each tick the derivation re-runs, the x mapping follows (bricks may be born or die), and the x viewport is not reset: the visible range is not touched (an auto-scaled value axis still follows the visible values, as on every render), except thatshiftVisibleRangeOnNewBar, when on and you were looking at the last brick, follows a new brick as it would a new bar; when the open candle's bricks die the range keeps its numbers and its right side goes empty; and the tick that produces the very first brick, if it is the plot's first drawn data, fits the domains once, as any first data does.handle.setData(candles)is for a new dataset (it is the source door too — the bricks are re-derived), not a tick: it requests a refit on every call (an output with no bricks has nothing to fit), and a chart that jumps to its full range twenty times a second is unusable. A noisy feed goes throughconflated(). There is no incremental door — one candle can create or destroy any number of bricks, which is not the tail shapederiveLastpromises — so every tick re-derives the whole output and revalidates it: O(input + output) per tick, a long tape is linear even when it yields few bricks. - Ordinals are stable only over the closed prefix. A tick can only create or destroy the bricks the still-open candle is producing; every brick before it keeps its value and its ordinal. A history page (
prepend) re-derives from the new first candle and can renumber the whole output (whenever the page changes what the first brick is seeded from, or how many bricks precede the old head) — the drawing is right afterwards, but the window you were looking at may now show different bricks, so re-anchor it (fitDomains()orscrollToRealTime()).
Options are the consumer's. brickSize is required — knowledge of tick size and volatility is not the transform's. A helper that suggests a step from the data is something the consumer calls explicitly, so when the suggestion was taken stays visible in the call site; the transform itself never defaults it. atrPriceStep is that helper — take it once per symbol, when the symbol loads, and catch the tapes it has no step for:
import { ContractError, candleSeries } from "@finchart/core";
import type { OHLC, SeriesHandle } from "@finchart/core";
import { PlotBuilder, browserDeps } from "@finchart/dom";
import { atrPriceStep, renko } from "@finchart/indicators";
const ATR_PERIOD = 14;
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(900, 480)
.build(document.getElementById("renko")!);
let bricks: SeriesHandle<OHLC> | null = null;
/**
* The brick size a symbol's own volatility suggests, or `null` when its
* tape has none: fewer bars than the ATR's period, or a flat tape whose
* ATR is 0. A fixed size breaks the other way across symbols — the same
* 1,000 is a solid wall of bricks on one stock and an empty chart on another.
*/
function brickSizeFor(candles: readonly OHLC[]): number | null {
if (candles.length < ATR_PERIOD) return null;
try {
return atrPriceStep(candles, { period: ATR_PERIOD });
} catch (error) {
if (error instanceof ContractError) return null;
throw error;
}
}
/**
* Chosen once, when the symbol loads — not on every tick. A size that moved
* with each tick would lay every brick again on a new grid, and the chart
* would redraw from the first brick while you watched.
*/
export function showSymbol(candles: OHLC[]): boolean {
bricks?.dispose();
bricks = null;
const brickSize = brickSizeFor(candles);
// No step to size bricks with: leave the renko chart empty and say so, or
// fall back to a size of your own for that symbol.
if (brickSize === null) return false;
bricks = plot.mainPane.addSeries({
series: candleSeries(),
data: candles,
derive: (source: readonly OHLC[]) => renko(source, { brickSize }),
});
return true;
}
/** Ticks go in as candles; the size chosen at load stays. */
export function onTick(candle: OHLC): void {
bricks?.updateLast(candle);
}
/** When the renko view goes away: the plot's observers and listeners go with it. */
export function closeRenko(): void {
bricks?.dispose();
bricks = null;
plot.destroy();
}What does not fit beside an ordinal axis — and what you would see. The core does not know an ordinal from a timestamp (x is a number either way), so most of these are not refused; they draw wrong, and the table says what wrong looks like:
| Beside a transform | What you would see |
|---|---|
| Time-x candles on the same pane | under continuousX the second series sits off-screen until a fit, and after fitDomains() the union of a timestamp range and an ordinal one squeezes the ordinal one into the left edge; under barIndexX the two become adjacent blocks of slots — the bricks first, then the candles, as if they followed each other in time. Either way snapping and the bar measure attach to whichever series was registered first |
| A volume pane fed by the time source | panes share one x mapping, so the pane's bars cannot align under the bricks — they land wherever their timestamps map (bricks carrying their own volume is not implemented yet) |
infiniteHistory on the transform's handle (time-x candles as the source) | pass viewFrontier so the fetch cursor stays in source time while gap and prefetch judgments use the first drawn brick's ordinal x; re-anchor the view after each prepend because earlier bricks can renumber the entire output |
handle.updateLast(brick) — a tick on the transform's own points | wrong door: the handle's points are candles, so the brick is taken for one. Its ordinal x decides what happens next — below the last candle's x it is refused (DataError: must keep x >= …) and nothing changes; equal, it replaces the last candle; above, it is appended as a candle — and in the two accepted cases the bricks are re-derived from a tape that now holds a brick. Ticks go in as candles (rule 2) |
| Drawings and annotations | an anchor's x is an ordinal — valid only for one (transform, options, source) triple; the anchor's numbers stay put, but on the open candle's tail bricks the brick it points at can be replaced or vanish with a tick. An anchor's x is a number on that axis: a brick's ordinal when snapped, any value — between bricks, or in the empty space before the first or after the last — when placed free. To reload a drawing onto the same transform, options and source next session, take the nearest brick inside the span (clamp(round(x), 0, bricks.length − 1)), save that brick's closedAt and its rank among the bricks that share it (one candle can close several) plus the signed remainder x − index, and restore by finding that pair and adding the remainder back. Across a parameter change there is no faithful mapping — a rank can vanish, or survive pointing at another price — so treat a restore as best effort and handle a miss |
| The bar measure | counts bricks, and calls them bars |
The history bridge is explicit because the source and plotted x values are different coordinates. Return null from viewFrontier while the transform has no drawn point; no screen-edge judgment is possible then. This example chooses fitDomains() as its re-anchor policy after a page lands:
import { candleSeries, infiniteHistory, type OHLC } from "@finchart/core";
import { browserDeps, PlotBuilder } from "@finchart/dom";
import { renko } from "@finchart/indicators";
export function mountRenkoHistory(
element: HTMLElement,
initial: OHLC[],
brickSize: number,
fetchBefore: (before: number) => Promise<OHLC[]>,
) {
if (initial.length === 0) throw new Error("an initial page is required");
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(900, 480)
.build(element);
const handle = plot.mainPane.addSeries({
series: candleSeries(),
data: initial,
derive: (candles: readonly OHLC[]) => renko(candles, { brickSize }),
});
let firstSourceX = initial[0].x;
const loader = infiniteHistory(
plot,
(page: OHLC[]) => {
handle.prepend(page);
firstSourceX = page[0].x;
// Prepending can renumber every brick. Choose an explicit new anchor.
plot.fitDomains();
},
fetchBefore,
{
from: firstSourceX,
viewFrontier: () => {
const firstBrick = handle.read()[0];
return firstBrick ? { sourceX: firstSourceX, plottedX: firstBrick.x } : null;
},
},
);
return () => {
loader.dispose();
plot.destroy();
};
}Reading a brick back from a probe. probe(x) returns the value the series' accessor reads (a brick's close, low and high), and sample.index is the index into the derived array — the whole of it, not the decimated part drawn on screen — so anything the brick carries beyond price is one lookup away: handle.read()[sample.index].
Decimation. When the core's OHLC aggregation merges points it builds the merged candle from x, the four prices, label and volume; any other field a derived point carries (closedAt, a tone) is gone from that bucket (below the threshold the points pass through untouched). A series that draws such points declares its own decimation strategy.
Height
Space is split by flex ratio with minHeight as the floor. A pane that hits the floor is pinned and the rest re-split what's left. If the floors sum past the total, everything shrinks proportionally (plot/layout.ts). The gap between panes is PlotConfig.paneGap.
setArea changes the range only and never the domain. The value range you were looking at survives a height change, and with nothing to refit it's cheap.
Axes
- There is one x, shared by every pane. The tick labels sit only under the bottom pane.
- y is per pane.
PlotConfig.axis.yis the default andPaneOptions.axisoverrides it per pane — see one format per pane. - Tick density comes from each pane's pixel height — a short pane thins out on its own.
Axisreads the scale's range, so there's nothing to pass in. - The grid is drawn per pane as well. A vertical line crossing the gap between panes would make two separate regions read as one.
One format per pane
A price, a volume and an oscillator are three different numbers, and a single format prints at least two of them wrong: a won has no decimals, a volume is a count, an RSI reads in whole numbers. Set the price format on the chart — it is every pane's default — and give the panes that read something else their own. A pane's format is used everywhere that pane prints a value: its axis, its crosshair badge, its price lines, and the tooltip and legend rows it reads out.
import { candleSeries, histogramSeries, priceFormat } from "@finchart/core";
import type { HistogramPoint, OHLC } from "@finchart/core";
import { PlotBuilder, browserDeps } from "@finchart/dom";
import { attachRsi } from "@finchart/indicators";
declare const bars: OHLC[];
declare const currency: "KRW" | "USD";
// A price's decimals belong to its currency — a won has none, a dollar has
// cents. Set on the chart, this is every pane's default.
const PRICE_DECIMALS = { KRW: 0, USD: 2 };
const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
.setSize(900, 600)
.setAxis({ y: { position: "right", format: priceFormat({ precision: PRICE_DECIMALS[currency], locale: "ko-KR" }) } })
.build(document.getElementById("chart")!);
const price = plot.mainPane.addSeries({ series: candleSeries(), data: bars, name: "Price" });
// A pane's own format wins over the chart's, on its axis, its crosshair
// badge, its price lines, and the tooltip and legend rows it reads out.
// Volume is a count, not a price: shortened, never in cents.
const volume = plot.addPane({ flex: 0.35, axis: { format: priceFormat({ compact: true, locale: "ko-KR" }) } });
volume.addSeries({
series: histogramSeries(),
data: bars.map<HistogramPoint>((bar) => ({ x: bar.x, y: bar.volume ?? null })),
name: "Volume",
});
// RSI reads 0–100 in whole numbers whatever the currency. The pane it made
// is set the same way, after the fact.
const rsi = plot.use(attachRsi({ source: price }));
rsi.pane?.applyOptions({ axis: { format: priceFormat({ precision: 0 }) } });
// Switching currency changes the chart's default; the volume and RSI panes
// keep their own. Only the fields given change — the axis stays on the right.
export function showCurrency(next: "KRW" | "USD"): void {
plot.applyOptions({ axis: { y: { format: priceFormat({ precision: PRICE_DECIMALS[next], locale: "ko-KR" }) } } });
}
export { plot };In React, a <YAxis> outside every <ChartPane> is the default and one inside a pane is that pane's:
import { histogramSeries, priceFormat } from "@finchart/core";
import type { HistogramPoint, OHLC } from "@finchart/core";
import { browserDeps } from "@finchart/dom";
import { ChartCandles, ChartContainer, ChartPane, ChartSeries, YAxis } from "@finchart/react";
const deps = browserDeps({ autoSize: true });
// Built once, outside render: a new function each render would be a new
// format for the axis to apply.
const PRICE_FORMATS = {
KRW: priceFormat({ precision: 0, locale: "ko-KR" }),
USD: priceFormat({ precision: 2, locale: "ko-KR" }),
};
const VOLUME_FORMAT = priceFormat({ compact: true, locale: "ko-KR" });
export function Chart({ bars, currency }: { bars: OHLC[]; currency: "KRW" | "USD" }) {
const volumes = bars.map<HistogramPoint>((bar) => ({ x: bar.x, y: bar.volume ?? null }));
return (
<ChartContainer deps={deps} data={bars} height={600}>
{/* Outside every pane: the default for all of them. */}
<YAxis position="right" format={PRICE_FORMATS[currency]} />
<ChartPane>
<ChartCandles name="Price" />
</ChartPane>
<ChartPane flex={0.35}>
{/* Inside a pane: that pane only. */}
<YAxis format={VOLUME_FORMAT} />
<ChartSeries series={histogramSeries()} data={volumes} name="Volume" />
</ChartPane>
</ChartContainer>
);
}With no format anywhere, the axis prints values as they are and the badges, tooltip and legend print two decimals — 293053.22 on a won chart — or the tick step's digits when the step is finer than a cent, so a sub-cent price never reads 0.00. Two decimals stays the floor because changing it would change every chart that never set a format; set one and the surfaces agree.
Writing a tick strategy
axis.x.ticks (or axis.y.ticks) takes a TickStrategy: one required method, ticks(context), returning { value, label }[], and an optional format(value) — the label a decoration gives a data x in the strategy's own clock, which the plot reads when no axis.x.format is given (timeTicks offers one). When a strategy is present it owns placement, selection and labels together — the axis's own arithmetic and format go unused, and the frame draws what the strategy returns without choosing among it. timeTicks is the one shipped; a strategy of your own gets the same context:
| Field | What it is |
|---|---|
min, max | The visible domain, in scale space. |
span, minTickSpacing | The pixels available, and the least a pair of ticks may stand apart. |
xOf(value), domainOf(x) | Domain ↔ data x. In a bar-index coordinate system the domain is the bar index, and these recover the bar's x and the index a boundary falls on. |
positionOf(value) | Where a domain value is drawn, in pixels — the frame's own scale, so a strategy thins what will actually be drawn. |
snap(value) | Bar-index coordinates only. The bar a boundary is drawn on: its own, or the first after it. Absent where the domain is continuous. |
Do the three steps in this order, because each needs the one before it:
- Candidates — every boundary your rule offers inside
[min, max]. - Place and choose — if
snapis present, land each candidate onsnap(value)and keep one per bar; then keep no pair closer thanminTickSpacingbypositionOf. Choose by what a boundary stands for, not by which came first:timeTicksplaces a year before a month, a month before a day, a day before a time of day, and the earlier among equals. - Label the survivors, in order. A label that depends on the tick before it — "Mar" on the first tick of a month — can only be decided once it is known which ticks survived.
A strategy that skips step 2 in bar-index coordinates draws a tick for every boundary, including the weekend midnights that have no bar of their own; the frame no longer snaps them for you. Step 2 can be omitted where the domain is continuous and the boundaries already meet the requested pixel spacing; return the candidates with their labels.
Decorations
Things drawn on the chart that aren't a rendering of the data. One criterion separates them from Series — they don't take part in the value-axis fit. Register a target line as a series and valueExtent drags y toward it, flattening the price.
const offLine = pane.addDecoration(priceLine({ value: 100 })); // above the series
const offMark = plot.addDecoration(watermark(), { zIndex: BELOW_SERIES }); // belowDecoration or plugin — addDecoration or use
Try plot.use(watermark()) and you're stopped. Here's the line:
- A decoration is a single drawing description — just
draw(plusaxisBadges), no wiring. Mount it withaddDecorationand take it off with the returned function (when all you have to hand back is a disposer, hand back a function). - A plugin is a bundle of wiring — the wrapper for when you install several things at once, decoration plus subscription plus DOM, and have to tear them down as one. Install it with
useand take it off withapi.dispose(). - The names tell them apart: things that draw, like
watermark,priceLineandmarkers, are decorations; things that act, likecrosshair,tooltip,legendanddrawingTools, are plugins. Put a decoration intouseand the types stop you — the vocabularies aren't merged because PRINCIPLES.md principle 12, "extensions come wrapped", starts by separating the wrapper (plugin) from the ingredient (decoration).
Install on the chart or on a pane — plot.use or pane.use
Plugins are divided by one more line of the same kind. The criterion is what it hangs from.
| Extension | Where | Why |
|---|---|---|
crosshair, tooltip, legend | plot.use | they cut across the whole chart |
attachMacd | plot.use | it has to create a pane, so it needs PaneHost |
attachMovingAverage, attachBollingerBands | pane.use | one addSeries is enough |
drawingTools | pane.use | it draws on that pane |
paneMaximize | plot.use | it toggles the chart's maximized pane and hit-tests every pane (PaneHost) |
What you install on a pane dies with that pane. plot.removePane(rsi) cleans up the extensions attached to it — the old convention was passing a pane? option by hand, and removing a pane left the extension none the wiser, still holding onto a pane that had fallen away.
What a pane can't give — input, x coordinates, render requests — arrives as wiring; drawingTools({ plot }) is that shape (principle 11).
Where it belongs decides its context. Plot owns x and Pane owns y, so if you need y you belong to a pane.
| Registered on | Area | What it gets |
|---|---|---|
pane.addDecoration | that pane's slice | pane, yScale, ticks.x, ticks.y |
plot.addDecoration | the whole plot area | panes, ticks.x |
Both get data (the visible range) and readStyle. The ticks arrive already computed — computing them separately would put them out of step with the labels.
Formatting arrives already resolved too — the context's formatX (the chart's config.axis.x.format) and formatY (that pane's y formatting, pane.formatValue). The defaults for the crosshair badge, the tooltip, the legend and priceLine all read these, so settle the axis format in one place and five surfaces are stamped with the same ruler. A decoration's own option (crosshair({format}) and the like) remains as an override. Which x notation that is resolves in one order: the consumer's axis.x.format, then what the tick strategy offers — timeTicks({ timeZone, locale }) labels a data x in its own zone and language, to the second — then the rounded number. So one ticks={timeTicks({ timeZone })} sets the clock for the axis, the crosshair badge and the tooltip header at once; a format of your own still wins. Which zone to say, and why not the runtime's, is in Time zones and sessions.
Order = nesting
plot decorations with z < 0
for each pane:
pane decorations with z < 0 ← the grid is here (built in, BELOW_SERIES)
series ← among themselves, by zIndex again
pane decorations with z ≥ 0
plot decorations with z ≥ 0 ← the crosshair"Plot-owned goes outside, pane-owned goes close to the data" isn't a rule to memorize — it falls out of this nesting.
One value divides them: zIndex. The slot where series are drawn is SERIES_Z (= 0), and a decoration's z slips in before or after it. Two named constants mark the common spots.
| Constant | Value | Used for |
|---|---|---|
BELOW_SERIES | −1000 | below the data — grids, range shading, watermarks |
SERIES_Z | 0 | where series are drawn (the reference point) |
ABOVE_SERIES | 1000 | above the data — the default for decorations |
Within the same z it's registration order, and it inherits the same weakness as addSeries — take one off and put it back behind a condition and it goes to the back of that z. Series registrations take a zIndex too (0 by default), but that's the order among series — it's the slot where a band fill toggled on late gets laid under the candles. It's draw order and nothing more; the row order in the probe and the tooltip stays registration order.
Decorations that hold state
The crosshair's cursor position comes from input, not from the render. The decoration holds it as its own state and calls plot.requestRender() when it wants it on screen — unlike render(), requests within the same frame coalesce into one.
import { crosshair } from "@finchart/core";
const cursor = plot.use(crosshair()); // mounting + subscribing in one
cursor.dispose(); // teardown in one too (plot.destroy() takes it off as well)Axis labels and dividers aren't decorations
They're DOM, their elements survive across frames, and they have a render/clear/destroy lifecycle. They're injected separately through a PlotDeps factory.
Events
const off = plot.on("xDomainChange", (payload) => { ... }); // disposer| Event | When | What it carries |
|---|---|---|
render | after the picture actually went out | nothing |
xDomainChange | when the x range you're looking at changed | startX, endX, dataRange |
crosshair | when the cursor passes — during a pan drag too⁵ — and once with null when it leaves | position, x, pane, value, or null |
click · dblclick · contextmenu | when you press on what's under the cursor | a CrosshairPayload — never null |
panesChange | when the pane layout (a pane added, removed or reordered, flex, minHeight) or a pane's value-axis mode (autoScale, invert, the scale, a range set by hand) changed | nothing — read plot.panes |
⁵ The crosshair stays under the pointer during a pan too (since 2026-08-14) — anything subscribing to crosshair, a tooltip for instance, keeps getting updates mid-pan. Exceptions: touch pan (a crosshair under your finger tells you nothing) and pinch stay quiet. A drag a tool consumed is the tool's: the default pointer handler says nothing during it, and the tool may move the crosshair itself (the drawing tools do while drafting).
The three click events are the same echo as crosshair — the payload is the same shape (CrosshairPayload), so the crosshair section below applies verbatim, except that a click is always somewhere: only crosshair can be null. The plot says null once — a departure told twice, as a browser's pointercancel then pointerleave, is one — and the default pointer interactions say it when the pointer leaves the chart, or when a drag that left is released outside. They arrive even if you never mounted a crosshair.
xDomainChange doesn't wait for a frame. The range you're looking at changes synchronously, and so does the announcement.
- It fires on pan, zoom and fit. It fires only when the value actually changed.
handle.prepend/appendand declarativedataupdates don't touch the domain, so they're silent. That's why gluing history on from inside the handler doesn't recurse — infinite history loading hangs on this.startX/endXare always the data's x. Even in bar-index coordinates they aren't indices — they have to share units withdataRangefor "am I near the end" to be measurable.- The y domain isn't announced. It follows along with the series; it isn't a movement.
dataRangeis the x range of the data you hold. How close you are to the end is measurable from the payload alone.
Subscriptions guarantee three things.
- Unsubscribing yourself inside a handler still calls every subscriber behind you. One round runs over a copy of the list as it stood at that moment — effect cleanup is exactly this shape, and otherwise it gets swallowed quietly.
- A subscription made during an emit joins from the next round. Otherwise a handler could grow subscriptions without bound from inside itself.
- Register the same function twice and you get two disposers, each taking off one.
plot.on("xDomainChange", async ({ startX, dataRange }) => {
if (!dataRange || startX - dataRange.min > THRESHOLD) return;
handle.prepend(await fetchBefore(dataRange.min)); // setState if you're declarative
});panesChange — the pane layout or a value-axis mode moved
panesChange says that the panes changed; read what changed from plot.panes. It covers the layout — a pane added, removed or reordered, a pane's flex (a divider drag rewrites every pane's) or minHeight — and each pane's value-axis mode: autoScale, invert, the scale (setYScale with a different instance — a log toggle) and a value range set by hand (setValueDomain, an axis drag). The x window has its own event (xDomainChange), and its own reader:
plot.getVisibleRange(); // { min, max } in data x, or null before the first fit
plot.getDataRange(); // { min, max } of the data held, or null when empty
plot.on("panesChange", () => renderPaneList(plot.panes));- It doesn't ring on a data change (append, prepend, a declarative update), on the x window, or for a fit to the data: a manual range that
setDatarefits, that a scale swap can't hold and refits (the swap itself rings, once), or thatfitValueDomain()fits moves without a ring of its own. Readpane.yScale.getDomain()when you need the range itself. valuePaddingandaxisdon't ring: they shape how a pane draws, not where it sits or how its axis follows.- It doesn't ring for a setting restated with its current value — React pushing the same props on every render,
setValueDomainwith the range the pane already holds, orsetYScalewith the scale already installed, stays quiet. resetValueAxis(), a y-axis double-click andfitDomains()ring when a pane'sautoScaleflips (not when it was already on).fitDomains()rings at most once for the whole stack, after every pane has flipped — a follower never sees a half-reset stack, and an x-only fit stays quiet. A listener that throws mid-way (xDomainChange, a pane subscriber,panesChangeitself) does not stop the fit either: it completes, and the error comes out offitDomains()at the end — one alone as itself, several as anAggregateError.- A divider drag rewrites every pane's flex on every pointermove and rings once per move, not once per pane. Separate
pane.applyOptionscalls ring once each. - A maximize rings once, a lone pane included.
plot.maximizePane(pane)lets one pane fill the chart and lays the rest at theirminHeight;plot.maximizePane(null)gives the split back;plot.maximizedPanereads it. It is a layout mode, not a rewrite — no pane'sflexchanges, so the user's split is still there underneath, a pane added meanwhile collapses and comes back at its own flex, and a flex set while maximized shows when the split does. It goes on its own when its pane is removed, and when a divider is dragged (the heights on screen become the panes' flex).
The chart keeps no restore door for its view. A window chosen before the data arrives (setVisibleRange at mount, a date jump) lands in place of the first fit if it touches the data's x range (an endpoint in common counts); one that misses the data entirely is dropped and the first fit runs as usual.
A window set with data that starts before the data held — a jump to a date not loaded yet — is placed by extrapolating the held spacing (the bar index has no slot for an x it doesn't hold). The chart keeps the x you asked for and re-places the window each time the data changes, so it lands on that date once the history arrives. It lets go when you pan or zoom, when the window follows a new bar, or once the window sits inside the data.
crosshair
The crosshair event hands over meaning, not coordinates.
{ position, x, pane, value }xis a domain coordinate and is the same regardless of pane — there's only one x axis.valueis read off the scale of the pane the cursor is over. Every pane has a different value axis, so without knowing which pane you're over there's no right number to produce.- Over the padding or the gap between panes,
paneandvaluearenull. The check looks at both horizontal and vertical. position(screen coordinates) is still there.
Dividers
DOM handles sit in the overlay between each pair of panes (turn them off with PlotConfig.resizablePanes). They're DOM rather than canvas so the browser owns the cursor shape and the hit area, and so redrawing the canvas doesn't make the handle you're dragging disappear.
- The travel is clamped by the
minHeighton both sides. - The result is frozen by writing the current heights into
flex, scaled so the flex total stays near what it was (by a power of two, which is exact, so the heights on screen come back to the pixel).flexis relative, so what remains is the ratio, the proportions you set follow along when the window resizes, and a pane added afterwards at the defaultflex: 1gets a share in the same units as the rest — not squeezed to itsminHeightnext to shares in the hundreds. - Every pane's flex is rewritten, including the ones you didn't touch. A split pins some panes at their floor, so only the whole set, frozen together, reproduces the heights on screen.
- The value domain isn't touched — writing
pane.flexdirectly bypasses the subscriber notification. - The divider calls
stopPropagation()onpointerdown. Without that the container's pan handler gets dragged along with it, no hit test involved (pointer.tsin @finchart/dom).
Interaction — what works, and how to turn it off and on
The default interactions are wired by browserDeps() in @finchart/dom. Fine-tuning goes through the pointer option — the recipe bundles the pieces, so you never have to call a factory yourself:
const deps = browserDeps({ pointer: { kineticScroll: true, zoomSpeed: 1.2 } });| Option | Default | Meaning |
|---|---|---|
pan | true | drag to pan (touch included; listens on the document so it never loses the pointer); a mostly sideways wheel or trackpad swipe pans too |
zoom | true | wheel zoom (x under the cursor pinned); two-finger pinch uses the ratio of x distances |
crosshair | true | hover moves the crosshair |
doubleClickReset | true | double-click resets to the full view (fitDomains, so every pane is autoScale again). On a y-axis strip (with axisDrag on) the double-click is the axis drag consumer's instead — that one pane's axis comes back, nothing else moves; that gesture stays even with doubleClickReset: false |
kineticScroll | false | it coasts on release — off by default, it gets in the way of precise work |
keyboard | true | toggles only the floor gestures (←→ pan · +/− zoom) — see the section below |
zoomSpeed | 1.1 | the factor per wheel notch — 100 px of wheel travel, so a trackpad zooms by the distance scrolled, not the number of events |
Keyboard (accessibility)
The container element (the one you passed to build()) always takes focus (tabIndex 0). Not the canvas — a #chart canvas:focus-visible selector will never match. To take it out of the tab order, put tabindex="-1" on that element yourself. Don't erase the focus ring.
And keys only arrive while that element has focus — managing focus is the caller's job.
There's exactly one place this actually bites: the moment you turn a tool on from a toolbar button, focus goes to that button. The Esc pressed right after leaves the button and bubbles up into the toolbar without ever passing the chart's listener — every key in the table below is dead. Turning a tool on and then changing your mind with Esc is the most common cancel path, so without this one line "Esc cancels" is true of the API and false in the hand.
const chartEl = document.getElementById("chart")!;
plotBuilder.build(chartEl); // the key listener and tabIndex attach here
toolbarButton.addEventListener("click", () => {
tools.begin("trend");
chartEl.focus(); // ← give back the focus it took
});There's no separate plot.focus() because the caller already holds that element — it's the one you passed to build(). In React, usePlot returns it as containerRef, and <ChartContainer> hands it out through its own containerRef prop:
const chartEl = useRef<HTMLDivElement>(null);
<button onClick={() => { tools?.begin("trend"); chartEl.current?.focus(); }}>Trend</button>
<ChartContainer deps={deps} data={bars} containerRef={chartEl}>…</ChartContainer>It isn't something the core can't do; it's something the caller can already do, so it's written down here instead of carved into the core surface.
With several charts side by side, the element to give focus back to is "the one the chart you just operated passed to build()" — not an outer wrapper such as a cell or a card. Keys bubble upward only, so focusing the wrapper never reaches the listener inside. apps/demo-vanilla carries this distinction around as keyboardHost.
And the name is the caller's job too. The core makes that container a tab stop (the core writes tabIndex), but gives it neither a name nor a role — the core doesn't know what the chart draws. Leave it off and what a screen reader user reaches is an unnamed container, and what gets read out is the string of numbers the DOM overlay (axis labels, legend) exposes as-is. axe and Lighthouse flag it as "focusable element without accessible name".
<div id="chart" role="img" aria-label="AAPL daily, January–June 2024"></div>For exact values, mount dataTable from @finchart/dom in a sibling element outside the chart's role="img". It creates a native expandable table with column headers; screen readers can navigate rows and cells. Pass a rows getter backed by the series' read() view, a caption, and columns that format each value. It shows the latest 100 rows by default (limit changes that bound) and refreshes when the series view changes. While expanded, it keeps rows stable for navigation; close and reopen it to read later ticks. A caption change such as a symbol switch refreshes it immediately. The trading example uses this alongside a symbol-specific chart name.
Whether to hide the legend and tooltip with aria-hidden is the app's call — those numbers may be the only text alternative there is.
All keyboard: false turns off is the two "core" rows in the table below.tabIndex and the keydown listener attach regardless of the option, so even with it off ⑴ the element still takes focus and ⑵ the keys headed for the input stack (the drawing tools' Esc, Delete, ], [) stay alive. This option used to close the door itself, so an app that turned it off because "I don't want ←→ stealing page scroll" lost every drawing-edit key and couldn't even reach the chart by keyboard.
| Key | What it does | Owner |
|---|---|---|
← → | pan by 5% of the screen width | core |
+ − | zoom about the center | core |
Esc | cancel the drawing → undo the drag → clear the selection (in that order) | drawing tools |
Esc | (when the drawing tools have nothing to drop) restore the maximized pane | paneMaximize (default) |
Delete Backspace | delete the selected drawing | drawing tools |
] [ | cycle the drawing selection (next/previous, wrapping at the ends) | drawing tools |
| double-click a pane | toggle maximizing that pane | paneMaximize({ gestures: true }) — opt-in, off by default |
↑ ↓ (with Shift, 40px) | move a focused pane divider 8px | pane divider |
Home End | move a focused pane divider to its limit | pane divider |
The divider rows are the exception to "the container takes the keys": each handle between panes is a tab stop of its own ([data-chart-divider]), and the keys it handles stop at the handle — neither the drawing tools nor the ←→/+− gestures see them. Every other key pressed on a handle bubbles to the container as usual.
Ctrl/⌘+Z is not normalized into this stack yet. The DOM host keeps modifier combinations for the browser, so an application that wants drawing history binds the chart element and calls tools.undo() / tools.redo() directly. That is a single-toolbox recipe; an application with toolboxes on several panes must choose its active toolbox itself.
The owner is whoever competes under the cursor. Hang one set of drawing tools per pane and Delete, ] and [ belong to the pane the cursor last passed over. Over a spot where nobody competes (an axis, the padding, an indicator pane with no toolbox) the last owner stands — going to the axis to read a price label and then pressing Delete is a normal path. Only Esc doesn't ask this question (it's the escape key: press it anywhere and whatever you were doing ends).
Right-click picks but doesn't eat — on a line it selects that line, on empty space it clears the selection (the same rule as the left button). And it returns false, so the contextmenu event rings as usual. The app reads tools.selection() inside it and builds a menu:
plot.on("contextmenu", ({ position }) => {
const target = tools.selection(); // the drawing under the cursor, null if none
openMenu(position, target);
});We don't block the native menu. @finchart/dom calls preventDefault only when a consumer ate the event, and the toolbox deliberately doesn't, so the app has to block it with a contextmenu listener on its own container. Otherwise the browser menu comes up on top of the app menu.
A key passes through the input stack once — if a consumer doesn't eat it (false) it moves on, and since the drawing tools usually attach first, Esc chains from there to paneMaximize. A key nobody ate goes to the core's pan/zoom. Nobody takes Tab — moving focus belongs to the browser. To make a selection programmatically, it's tools.select(handle | null).
Why the pane double-click toggle isn't the default: doubleClickReset (true by default, the table above) already spends a double-click anywhere on a pane as a reset to the full view. routeInput is called before fitDomains(), so if maximize ate dblclick by default that reset would die quietly on every pane click — which is why you have to turn it on explicitly.
The React composition API
<ChartContainer deps={deps} data={candles} plotRef={ref}>
<XAxis />
<Crosshair />
<ChartPane flex={3}>
<YAxis />
<ChartCandles />
{showMa && (
<ChartLine
style={{ line: { color: "#f59e0b" }, point: { radius: 0 } }}
derive={movingAverage(period)}
deriveKey={[period]}
/>
)}
</ChartPane>
<ChartPane flex={1}>
<ChartLine derive={rsi(14)} deriveKey={[14]} />
</ChartPane>
</ChartContainer>The children render no DOM. During the render phase they claim a slot in the collector of the pane they belong to, and after commit the collector hands the whole list over with pane.syncSeries(). The core still knows nothing about the framework.
There are three series components. <ChartCandles> and <ChartLine> build the core series for you, and <ChartSeries series={...}> is the escape hatch for a Series you built yourself.
| What to use | When |
|---|---|
<ChartCandles style> | OHLC as candles — style is candleSeries(style)'s { up, down, wickWidth, bodyRatio } |
<ChartLine style coordinates derive deriveKey input> | points as a line, indicators included — style is lineSeries(style)'s { line: { color, width, dashArray }, point: { radius, color } } |
<ChartSeries series={...}> | when you wrote the drawing yourself |
Things that aren't series have components too. The built-in ones mount in two different ways — a plugin gets only its options swapped, while a decoration is taken off and put back when its reference changes. <PriceLine> and <Markers> are the exception: each is built once per pane and handed its new props in place, so a value that ticks every frame moves the line, not the registration.
| What to use | What it is |
|---|---|
<Crosshair vertical horizontal style badges format> | the crosshair (plugin) |
<Tooltip formatX formatValue formatRow offset> · <Legend formatValue formatRow> | cursor value boxes (plugins) — a <Legend> inside a <ChartPane> reads that pane |
<PriceLine> · <Markers items> · <Watermark> · <Span> | the standard decorations |
<ChartData value> | the data the series below it will see |
<Plugin install deps onApi> | any plugin of your own choosing — installed on the pane it sits in, reinstalled when deps change, its api handed to onApi after commit and null before it's disposed |
<InfiniteHistory history> | pages older data into a useInfiniteHistory — see Infinite history |
Color goes in as an argument, not as a CSS variable. A variable like
--chart-lineis the default for the whole chart, so it can't paint two lines in one pane differently — the canvas has no element to grab a series by. "This indicator is orange" goes in throughcolor.<Crosshair>is a decoration, not a series. It doesn't take part in the value axis, so inside a pane or outside is the same, and it cuts across both panes. It's separate from<ChartContainer onCrosshair>, so you can take the crosshair off and still receive cursor values.The first
<ChartPane>reusesmainPane. APlotalways has a mainPane, so making a new one would leave an empty pane taking up space at the top.A pane's value axis is props.
valueDomain={[0, 100]}pins a range (an oscillator's) and wins overautoScalewhile it's set; once removed, the pane does whatautoScalesays.yScaleis a factory called on updates; only a change in its scale's declaredkindinstalls it in place, keeping the pane, its series and its height. Inline factories are fine.A series left outside any
<ChartPane>goes tomainPane.<YAxis>inside a pane configures that pane; outside, it's the default for every pane.<XAxis>is shared, so you place exactly one.Identity is the component instance. The
idthe core demands is made by<ChartSeries>withuseIdand passed along, so it never appears on your side. Stay in the same slot and you're the same series; when you draw a list, React'skeyis what moves the slot.You don't have to hold a stable reference. Build
seriesfresh on every render and the derive cache survives as long as the slot is the same.useMemois optional now.If there's a
derive,deriveKeyis required too (caught at compile time). Same rule asuseMemo's dependency array — if the value is unchanged the derive isn't run again.Draw order is JSX order. An indicator toggled off and on behind a condition comes back to its place. But only the render phase knows the order, so when a child mounts on its own without the pane rendering (a component wedged in between changed its own state), that series is appended at the end. It finds its place on the next pane render.
The
dataprop flows straight through to the series. The container sends it down through context and<ChartSeries>puts it in its own spec — the chart has no slot to receive data. So gluing history on issetState(prev => [...older, ...prev])— oruseInfiniteHistorywith<InfiniteHistory>, which does that and keeps the loader's place — and since the declarative lane doesn't refit, the range you were looking at stays put.With several sources, each series names its own. The container's
datais the default for the whole chart, and two things override it — thedataprop for a single series,<ChartData value>when several look at the same thing.tsx<ChartContainer deps={deps} data={btc}> <ChartCandles /> {/* the container's data */} <ChartLine data={eth} /> {/* its own */} <ChartData value={eth}> <ChartLine derive={ma(20)} deriveKey={[20]} /> {/* everything below is eth */} </ChartData> </ChartContainer>The first fit goes to whichever series arrived first — each series mounts onto the chart from its own effect. To get everything in view, call
fitDomains().The effect is what attests to identity. The render phase settles only the slot (JSX order); admission to the list happens after commit — a render doesn't guarantee a commit. A series inside
<Activity mode="hidden">renders but never mounts onto the chart.If you need the imperative API (
fitDomains,pan), take it throughplotRef.The whole mount coalesces into one frame. Every child's effect runs separately and requests a render, but there's one draw. The cost is that the first draw is one frame late, so a test that reads the canvas right after mount calls
plotRef.current.render().
When you add a new method
- Is there a clear reason to change the domain? If not, don't touch it.
- Refitting the view is the caller's explicit act, through
fitDomains(). - Don't call the render yourself — schedule it with
scheduleRender(). However many steps you take, they coalesce into one frame. - Add a row to this table.
Related
- Principles: state is synchronous and drawing is per-frame, interactions are automatic, split by surface — PRINCIPLES.md
- glossary.md — what the terms mean