Candlestick chart
Sand pours into each candle's body and resolves into a solid
open-close block, while the high-low wicks are stroked solid throughout. Every
candle is colored rising or falling by a configurable
difference rule, and a dashed live-price line
tracks the latest actual.
data is an
OhlcDataSet — four prices per period, not one
y — so it gets its own additive type next to Point/MeshPoint.
(2) Color comes from a direction rule, not a series palette: the grain palette is
[rising, falling] and the legend's two entries describe those groups, so clicking one
isolates every rising (or every falling) candle. (3) Series are a selector — two
instruments can't share an x-slot legibly, so one series is drawn at a time and the
series slider picks which, exactly like the pie chart.Constructor
new CandlestickChart(host: HTMLElement, config: CandlestickChartConfig)
Mounts a canvas into host and boots the best backend (WebGPU → Canvas2D). Only
data is required.
Data — OhlcDataSet
interface Candle {
x: Scalar; // period start: a Date, a number, or a category label
open: number;
high: number;
low: number;
close: number;
actual?: number; // live / last traded price — drives the live-price line
}
interface CandleSeries { key?: Scalar; candles: Candle[]; }
interface OhlcDataSet { series: CandleSeries[]; xType?: FieldType; }
actual is orthogonal to close: on a streaming feed the newest candle's
close is still provisional while actual is the price right now. It never
affects a candle's own geometry — only the live-price line, the price-axis
domain (so the line always stays on screen) and the hover readout.
high/low is not an error:
the wick extent is taken from the min/max of all four prices, so a swapped pair still draws a
sensible candle rather than an inverted one.Config — top level
| Property | Type | Default | Description |
|---|---|---|---|
data required | OhlcDataSet | — | Instruments and their candles. See above. |
spacing | 'band' | 'time' | 'band' | How candles are positioned along X. See below. |
candles | CandleStyleConfig | — | Colors, color rule, body geometry, wicks and the solid layer. See below. |
actual | ActualConfig | off | Live-price line. See below. |
seriesIndex | number | 0 | Which instrument is drawn; clamped into range. |
maxCandles | number | 5000 | Cap on candles drawn per series; extras are dropped. |
maxSeries | number | 1000 | Cap on instruments the slider can address. |
slider | SliderConfig | on | The instrument picker. Same block as the pie chart's slider, and hidden anyway when there is one series. When it shares the bottom edge with a shown X axis it is placed below the axis labels, not on them. |
grainDensity | number | 0.8 | Grain budget multiplier; candles share grainDensity × 20 000 grains by body area, so adding candles subdivides the same sand. |
maxGrains | number | 100000 | Hard grain ceiling. |
background | string | transparent | Canvas clear color. |
grain | object | — | sizePx (2.4), shape ('disc'), jitter (0.6), settleJitter (0.004). See bar chart → grain. |
animation | object | — | Timing. See below. |
interaction | object | — | hover and dim as on every other chart, plus mouse hooks. |
axes | { x?, y? } | off | Same shape as bar chart. X is the period axis (and styles the slider's ticks); Y is the price axis. |
legend | LegendConfig | off | Two entries — Rising and Falling. With interactive, clicking one isolates that group. |
title | TitleConfig | off | Chart title; shares the legend's placement vocabulary. |
currentValue | object | off | Same as bar chart; the default readout is date · O H L C · live. |
fps | FpsConfig | off | Same as bar chart. |
panZoom | PanZoomConfig | off | Drag to pan, wheel/UI to zoom. Shared pan & zoom. axes: 'x' is the usual choice for a price series. |
backend | 'auto' | 'webgpu' | 'webgl2' | 'canvas2d' | 'auto' | Force a rendering backend. |
candles
One block for everything about a candle: what color it is, what its body spans, and the solid
layer that resolves as the sand fades out. Mirrors the bar chart's bars.
| Property | Type | Default | Description |
|---|---|---|---|
rising | string | '#2eb872' | Color of a rising candle (CSS). |
falling | string | '#e0555c' | Color of a falling candle (CSS). |
direction | CandleDirection | fn | 'openClose' | Which difference decides rising vs falling. See below. |
body | 'openClose' | 'lowHigh' | 'openClose' | Which prices the body spans. 'lowHigh' makes each candle a plain range bar and leaves no wick to draw. |
width | number | 0.62 | Body width as a fraction of the x step, 0..1. |
fill.opacity | number | 1 | Solid body fill opacity. The block's presence is what turns the fill on; the color is always the candle's own. |
border.show | boolean | true* | Body outline. *Default true when a border block is present. |
border.width / border.opacity | number | 1 / 1 | Outline stroke width (CSS px) and opacity. |
wick.show | boolean | true | High-low wicks. On even with no candles block at all — see the note below. |
wick.width / wick.opacity | number | 1 / 1 | Wick stroke width (CSS px) and opacity. |
reveal | RevealConfig | — | Timing of the particle→solid-body crossfade: start ('afterPour'), duration (500 ms), ease, grainsTo (0). Shared by every visual. |
reveal. Only the body's
fill and border resolve as the grains fade. Omit both and you get a
permanent sand chart with solid wicks (see the sand-only example).Color rules — candles.direction
Which difference decides a candle's color. All the rules that look backwards fall back
to 'openClose' for the first candle of a series, since nothing precedes it.
| Value | Rising when… |
|---|---|
'openClose' default | close >= open — the classic candle rule. |
'closeClose' | close >= the previous candle's close: did the level rise since the last period. |
'lowHigh' | this candle's low-high range midpoint is at or above the previous candle's midpoint: did the whole trading range shift up, regardless of where the period opened and closed. |
'closeInRange' | the close sits in the upper half of the candle's own low-high range. Depends on no other candle. |
(candle, previous, index) => boolean | Anything you like — a moving average, an external signal. Passing a function in place of a name plugs in a custom rule without the chart changing, the same way the scatter chart takes a custom Approximation. |
actual — the live-price line
A horizontal rule across the plot at the latest Candle.actual in the
drawn series (the last candle that carries one), with a value marker on the price axis. Off by
default: without an actual block the values still travel through hover and events,
but nothing is drawn.
| Property | Type | Default | Description |
|---|---|---|---|
show | boolean | true* | *True when an actual block is present. |
color | string | currentValue color | Line and marker color (CSS). |
width | number | 1 | Line width, CSS px. |
opacity | number | 0.9 | Line opacity. |
dash | number[] | false | [4, 4] | Dash pattern in CSS px; false for a solid rule. |
marker | boolean | true | Draw the value marker on the Y axis (needs axes.y.show). |
format | (v: number) => string | compact number | Format the price for that marker. |
The live price is folded into the price-axis domain, so a line that has run past every candle's range still stays on screen.
spacing
| Value | Behavior |
|---|---|
'band' default | Every candle gets an evenly-spaced slot, so weekends and holidays collapse instead of leaving holes — standard trading-chart behavior. Axis ticks are the periods themselves, thinned by axes.x.ticks exactly like the bar chart's category axis. |
'time' | Candles sit at their real x value on a continuous LinearScale/TimeScale, so gaps in the data show as gaps. Body width comes from the median gap between periods; ticks come from the scale. |
'time'
always degrades to 'band' for it. The layout reports what it actually used.animation
duration, stagger and
morphDuration are in milliseconds.Same as the bar chart, with the three-way twists named for candles:
| Property | Type | Default | Description |
|---|---|---|---|
duration | number (ms) | 900 | Per-grain travel time. |
stagger | number (ms) | 500 | Spread of pour start times. |
ease | 'linear' | 'easeOutCubic' | 'easeOutQuint' | 'easeOutCubic' | Grain travel easing. |
morphDuration | number (ms) | duration + stagger | Body-tween + grain-fade window for a data or instrument change. |
reflow | 'translate' | 'reshuffle' | 'withCandle' | 'translate' | How an unchanged period's grains move. 'withCandle' locks them to the body: rigid move, no delay, no grain-fade flash. |
morphGrains | boolean | true | false snaps grains to their new positions so only the solid body tweens. |
enter | 'pour' | 'rise' | 'continue' | 'pour' | How an added candle's grains arrive. 'continue' skips the emergence — the right choice for a streaming feed. |
exit | 'fall' | 'vanish' | 'fall' | How a removed candle's grains leave. |
x), not the array
position. That is what makes a rolling window animate correctly: dropping the oldest bar and
appending a new one shifts every index, but each surviving period keeps its identity, so
unchanged candles slide instead of being treated as removed-and-re-added. A candle with
no predecessor opens from a flat line at its own level.Overriding mouse events — interaction.mouse
Every built-in mouse reaction can be cancelled or replaced, through the
same hook contract the wind rose uses
(core/interaction/mouse.ts). Each hook receives a context —
{
meta: CandleMeta | null; // the mark under the pointer
native: PointerEvent; // modifier keys, coordinates, preventDefault
px: { x: number; y: number }; // CSS px, relative to the chart element
defaultAction(): void; // run the built-in behavior — idempotent
}
Return false to suppress the built-in behavior; return anything else — including
nothing — to let it run after the hook, unless the hook already called
defaultAction() itself.
| Hook | Mark | Built-in behavior it can cancel |
|---|---|---|
onCandleHover | CandleMeta | The hover highlight and the current-value readout. |
onCandleClick | CandleMeta | Nothing built in — the chart has no click behavior of its own, so this is a pure observation point. |
onLegendClick | number (entry index) | The isolate/dim toggle. |
onSliderSeek | number (target index) | The instrument change from a slider drag or click. |
hover, click, seriesChange and seriesFocus
fire even when a hook cancels the default — an observer is not an override, and a caller who
suppresses the built-in selection should still be able to hear about the gesture.Shared blocks
axes, legend, title, currentValue,
interaction.hover, interaction.dim and fps are the same
config the bar chart documents — see
bar chart → axes / legend / currentValue / fps. Pan & zoom
is the shared panZoom block.
axes.x does double duty: it styles the period axis
and the series slider's ticks (count, format, label, font, color), the same way
it styles the pie chart's slider.axes.y's domain is the candles' own low/high extent (plus the live price), padded
~6% on each side, rather than being anchored to 0.Methods
| Method | Description |
|---|---|
whenReady(): Promise<void> | Resolves once the backend is initialized and the first frame is scheduled. |
get backend | 'webgpu' | 'webgl2' | 'canvas2d' | null. |
on(event, fn) | Subscribe to hover, click, seriesChange or seriesFocus; returns an unsubscribe function. |
update(data) | Replace the whole OhlcDataSet; candles morph and the bodies tween (no re-pour). |
update(patches, seriesIndex?) | Set prices on existing candles in place, matched by x. Omitted fields keep their values, so a feed can patch just close/actual. Defaults to the series on screen. |
add(candles, seriesIndex?) | Append one or more Candles (a series index past the end creates one); new candles arrive per animation.enter. |
remove(refs, seriesIndex?) | Remove candles by index (negative = from the end) or { x } match; the rest reflow. |
setSeriesIndex(i) | Show another instrument — candles morph across and the slider handle eases over, the same code path a drag takes. Fires seriesChange. |
getSeriesIndex() / seriesCount / getSeriesKeys() | The current instrument, how many the slider addresses, and their keys. |
focusSeries(index) | Isolate a direction group — 0 rising, 1 falling — at full opacity while the other dims to interaction.dim.opacity; null clears it. Those groups are what the legend describes (the pie chart's focusSeries targets its slices the same way, not the slider's series). Fires seriesFocus. |
getFocusedSeries() | number | null — the isolated direction group, or null. |
repour() | Re-run the pour-in animation with the current data (no morph). |
getData(): OhlcDataSet | Deep-cloned snapshot of the current dataset. |
dispose() | Cancel the loop, remove listeners, free the backend and DOM. |
It also implements the shared PanZoomable
interface: getView(), setView(v), panBy(),
panTo(), zoomBy(), zoomTo(), resetView(),
isPanZoomEnabled().
Events
hover carries { candle: CandleMeta | null } and click
carries { candle, event }. Hit-testing is by column: anywhere in a
candle's slot selects it, the way a trading chart's crosshair behaves, rather than demanding a hit
on the thin body.
// CandleMeta — metadata for one drawn candle
{
candleId: number; // index within the drawn series; also the grain barId
indexInSeries: number;
seriesIndex: number;
seriesKey: Scalar | undefined;
xValue: Scalar;
open: number; high: number; low: number; close: number;
actual: number | undefined;
rising: boolean; // what the direction rule decided
x0: number; x1: number; cx: number; // body edges + center, layout space [0,1]
bodyLow: number; bodyHigh: number; // body extent, layout space
wickLow: number; wickHigh: number; // full price range, layout space
color: RGBA;
}
seriesChange carries { index, key } (the instrument the slider moved to)
and seriesFocus carries { index: number | null } (the isolated direction
group).
Examples
Solid candles with axes and a live price
new CandlestickChart(el, {
data: {
series: [{
key: 'ACME',
candles: [
{ x: new Date('2026-01-05'), open: 120, high: 124, low: 119, close: 123 },
{ x: new Date('2026-01-06'), open: 123, high: 125, low: 121, close: 121.5, actual: 122.2 },
],
}],
},
candles: {
fill: { opacity: 0.9 },
border: { width: 1 },
reveal: { duration: 500, grainsTo: 0.12 },
},
actual: { show: true },
axes: { x: { show: true, ticks: 6 }, y: { show: true, gridLines: true } },
});
Sand only — no solid bodies
// No fill and no border: the bodies stay sand forever, only the wicks are solid.
new CandlestickChart(el, {
data,
grainDensity: 2.2,
candles: { width: 0.8, reveal: { grainsTo: 1 } },
});
Coloring by the previous close, not the open
new CandlestickChart(el, {
data,
candles: { direction: 'closeClose', rising: '#2eb872', falling: '#e0555c' },
});
A custom color rule
// Rising while the close holds above a level your own code computes.
new CandlestickChart(el, {
data,
candles: {
direction: (candle, previous, index) => candle.close >= movingAverage(index),
},
});
Range bars instead of candles
new CandlestickChart(el, {
data,
candles: { body: 'lowHigh', width: 0.5, fill: { opacity: 0.7 } },
});
Streaming: tick the live price, roll the window
const chart = new CandlestickChart(el, {
data,
candles: { fill: {}, border: {} },
actual: { show: true },
animation: { enter: 'continue' }, // new bars appear settled, no pour
});
// Every tick: patch only what moved on the bar still being filled.
feed.on('price', ({ x, price }) => {
chart.update([{ x, close: price, actual: price }]);
});
// Every period: append the new bar and drop the oldest — surviving candles slide.
feed.on('bar', (bar) => {
chart.add(bar);
chart.remove(0);
});
Switching instruments
const chart = new CandlestickChart(el, {
data: { series: [{ key: 'ACME', candles: acme }, { key: 'GLOBEX', candles: globex }] },
slider: { show: true, position: 'bottom' },
axes: { x: { show: true, label: 'instrument' } },
});
chart.on('seriesChange', ({ index, key }) => console.log('now showing', key));
chart.setSeriesIndex(1); // same animation as dragging the slider
Isolating rising or falling candles
new CandlestickChart(el, {
data,
candles: { fill: {} },
legend: { show: true, interactive: true }, // entries: Rising / Falling
interaction: { dim: { opacity: 0.15 } },
});
chart.focusSeries(0); // show only rising candles at full opacity
Driving your own tooltip (overriding the mouse events)
new CandlestickChart(el, {
data,
interaction: {
mouse: {
onCandleHover({ meta, px }) {
myTooltip(meta, px);
return false; // suppress the built-in readout (the hover event still fires)
},
onCandleClick({ meta }) {
selectPeriod(meta?.xValue);
},
onSliderSeek({ meta: index, defaultAction }) {
if (isLoaded(index)) defaultAction(); // switch now…
else load(index).then(defaultAction); // …or once the data arrives
return false;
},
},
},
});
© 2026 · License · Data model →