Skip to content

Events ​

Subscribe with chart.on(event, listener). It returns an unsubscribe function. Event names follow a noun:verb scheme.

ts
const off = chart.on('view:change', ({ from, to }) => console.log(from, to));
off();

// The next emission only; the returned function cancels it.
chart.once('data:loaded', ({ bars }) => console.log(bars.length));
EventPayloadWhen
crosshair:move{ time, index, price, pane, locked } | nullPointer moves over a pane; null when it leaves. price follows crosshair.mode snapping; locked is true while lockCrosshair holds it
plot:click{ time, index, price, pane, x, y }Press and release in a plot without dragging, unless a drawing took the press
plot:dblclick{ time, index, price, pane, x, y }Double-click in a plot, unless a drawing handled it (text editing)
chart:contextmenu{ target, x, y, clientX, clientY, pane, time, index, price, scale, drawing, series? }Right click or the keyboard menu key on a plot, axis or drawing, or a touch long press on a plot with touchInspect: 'off'. x, y are chart-root css px, like pointAt. target is 'plot', 'priceAxis', 'timeAxis', 'drawing' or 'legend'; a drawing under the pointer is selected. 'legend' comes from a legend row's "More" button, a right click on the row or a touch long press on it, with the row's series and its pane, and null time, price and scale. While the contextMenu feature is on and this has a listener, the browser's menu is suppressed
baseline:change{ series, baseLevel, price }The user dragged a baseline series' base level on the price axis. baseLevel is the series' new option, price where the base now sits. See Baseline base level
plus:click{ price, pane, scale, x, y }The price-axis "+" button was pressed; it shows only while this has a listener. See plus button
series:hover{ series, point, item? }The pointer moves onto a different candle, line, histogram, point or indicator series, or off all of them (series: null); on a point series also onto another item, given as item. point is null when no point is available: the pointer left, or the hovered series was removed. Mouse and pen hover only; touch does not emit it
selection:change{ selection }What is selected changed: { kind: 'drawings', ids }, { kind: 'indicator', indicator } or null. One selection covers drawings and indicators; see Selecting indicators
trade:hover{ trades, group, point }The pointer entered a fill marker, or tapped it. See account fills
trade:hover-end{}The pointer left the fill marker
trade:click{ trades, group, point }A fill marker was clicked or tapped; markers take presses only while this has listeners
marker:hover{ marker, series, point, members? }The pointer entered a bar marker, or tapped it. See bar markers
marker:hover-end{ marker, series }The pointer left the bar marker
marker:click{ marker, series, point, members? }A bar marker was clicked or tapped; markers take presses only while this has listeners. members lists a folded marker's markers
sourcemarkers:click{ markers, point }A data source marker was clicked or tapped, on its bar or as a time-axis badge: copies of the marker, or of the markers folded into it or its badge. They take presses only while this has listeners
annotations:cluster{ lines, anchor, defaultPrevented }A folded cluster of order labels was clicked, after onClusterClick; the UI shell's clusterList() part lists the orders unless prevented. See label clustering
draft:edit{ draft, segment, anchor, defaultPrevented }An order draft's quantity or type was clicked; the UI shell's draftEditor() part opens a keypad or a type menu unless prevented. See order drafts
status:change{ status, defaultPrevented }chart.setStatus set or cleared the in-pane status (the data source sets it while loading, for no data and for errors); status is null when cleared. Calling preventDefault() hides the chart's own text for that status; the UI shell does this for its loading and empty slots
ui:notify{ message, kind, durationMs, defaultPrevented }chart.notify() was called; the UI shell shows a toast unless prevented. See notifications
order:create{ side, type, price, quantity?, takeProfit?, stopLoss? }An order draft was placed (its place button or commit()). Send the order, then show it with addTradeLine
data:dropped{ reason, bar, series }A bar or point was left out: 'older' than the series' last one, 'invalid' from a data source or a normalized setData, or a 'duplicate' time. See bad values
context:lost{ reason }The GPU context was lost; the series stop drawing until it is restored, while axes, labels and drawings keep drawing. reason is 'budget' when the chart gave it up for the page's context budget, 'browser' otherwise. See failure modes
context:restored{ reason }The GPU context is back; the chart redraws
context:restore-failed{ error }The restored context could not be set up (ChartError code 'webgl-restore-failed'); recreate the chart
frame:renderRenderStatsA frame was drawn, with what it cost. Fires only while the renderStats or renderStatsOverlay option is on, with the statsOverlay() plugin; see Debugging and profiling
state:change{ reason }Something worth saving changed: drawings, indicators, series (main series style), panes (panes and price scales), symbol, options (theme or grid) or viewport. Loads never fire it. See Storage and autosave
frame:slow{ frameTime }Frames kept coming slower than about 29 a second for a while; frameTime is the smoothed gap in ms. Offer lowPower here. Fires once per slow spell, never while lowPower is on. See low-power mode
appearance:change{ theme, grid }applyOptions changed the theme or the grid; same as chart.appearance(). Persist on this
theme:change{ theme }applyOptions({ theme }) changed the resolved theme; setting the same values again is silent
timezone:change{ timezone, previous }The time axis timezone changed: the timezone option, or the symbol's own with applySymbolTimezone
localization:change{ locale }chart.localization was replaced: by applyOptions({ localization }), or by installing or uninstalling the localization() plugin; locale is undefined for the built-in English formats
chart:resize{ width, height }The chart's size changed (css px)
chart:destroy{}destroy() was called; the chart is still intact while listeners run
grouping:change{ level, bars } or nullThe view started or stopped grouping bars (with the grouping() plugin), or changed bars per bucket
viewport:clamp{ axis, requested, applied } (plus pane, scale for price)setVisibleTimeRange, fitContent, scrollToRealtime, resetView (axis: 'time', ranges in unix seconds) or setPriceRange (axis: 'price', ranges in prices) asked for a view outside the viewport bounds; both ranges are { from, to }
view:change{ barSpacing, fromIndex, toIndex, rightOffset, from, to }Anything chart coordinates depend on changed: pan, zoom, resize, pane layout, a price scale's range or mode, or the bars. At most once per frame, after the frame is drawn; fromIndex/toIndex are the fractional logical indices at the plot edges, rightOffset the space after the last bar in bars, from/to the times of the first and last visible bar (null without bars). Use it to re-place HTML over the chart
compare:error{ symbol, error }A compared symbol failed to load; error is a ChartError as in data:error
scale:change{ pane, scale, mode, autoScale, invert, noPositiveData, fixedRange, lockPriceToBarRatio }A price scale's mode, inversion, autoscale, fixed range or ratio lock changes, or a log scale gains or loses data above zero (noPositiveData)
history:request{ before }Viewport nears the oldest bar
panes:change{ heights, maximized, collapsed, hidden }Panes added, removed, reordered, resized, maximized, collapsed or hidden. heights are normalized stretch weights for setPaneHeights
indicator:add{ indicator }An indicator was added or restored (pending placeholders too)
indicator:remove{ indicator }An indicator was removed, directly or with its series or pane
indicator:change{ indicator }An indicator's inputs or visibility changed through its handle, or it went live or pending as its definition was registered or unregistered
indicator:error{ indicator, error }An indicator's compute or update failed, or restored inputs did not fit and defaults were used
indicator:status{ indicator, status, message }The load state of an indicator's own data changed, or its message did (an unsupported context, a fetch error, setError)
indicator:refuse{ type, reason }indicators.add() or duplicate() refused an indicator: the access rule said no ('blocked') or the chart is at its limit ('limit'); see access rules
indicators:access-change{ allow: AccessRule | null, limit: number | null }indicators.setAccess() or setLimit() was called, also through the access option
symbol:change{ symbol, previous }setSymbol (or a layout load) switched to another symbol, once per switch and before its load starts (previous is null on the first). A switch overtaken by another one is aborted and never emits data:loaded
data:loading{ symbol, interval, kind }A data source load starts (initial, history or reset)
data:loaded{ symbol, interval, kind, bars }A data source load finished; bars is the full series
data:error{ symbol, interval, kind, error }A data source load failed: kind is initial, history or reset (refetching after a source reset, a reconnect or chart.reload()). error is a ChartError (see errors) with the source's error as cause
data:warning{ symbol, interval, kind, error }A data source load landed with something missing: adjustments failed (code adjustments-failed), so those bars show unadjusted (see splits and dividends)
chart:error{ error }An event listener threw (ChartError code 'listener-failed'); see errors
interval:change{ interval, previous, requested, reason }A load landed at another interval than before, or the symbol did not support the requested one (reason: 'unsupported') and the nearest supported interval is shown
data:bar{ bar, appended }A live bar from the data source was merged
data:quote{ symbol, quote }The data source sent a bid/ask quote (see reference lines)
data:reset{ symbol, interval, range }Bars were refetched after the source reported a reset through onReset; range is undefined for the whole series (see corrections and reconnects)
data:reconnecting{ since }The source's live stream dropped and is reconnecting
data:reconnect{ gap }The live stream is back after gap seconds
data:stale{ gap, reason }Bars on screen may be out of date: an outage longer than maxGapFillSeconds (reason: 'outage', gap its length), or a failed load kept a cached snapshot on screen ('reconcile', gap its age); seconds
sourcemarkers:loaded{ symbol, interval, from, to, markers, dropped }The data source's markers answered for from to to; dropped counts invalid markers left out. See markers
sourcemarkers:error{ symbol, interval, error, retryIn }markers failed (code markers-failed); it is asked again after retryIn ms, or, when retryIn is null after five failures in a row, only after a reset of the shown bars, an option turned back on, or the next symbol or interval
sourcemarkers:change{ markers }chart.sourceMarkers changed: a batch landed, markers far from the view were let go, or they were dropped; markers is the new frozen list
data:snapshot{ symbol, interval, bars, savedAt }A cached snapshot was painted while its load runs; data:loaded follows
tool:change{ tool, kept }Active drawing tool changes (null = cursor); kept when set with setTool(type, { keep: true })
magnet:change{ mode }drawings.setMagnetMode() or setMagnet() changed the magnet ('off', 'weak' or 'strong')
drawing:select{ id, ids }The drawing selection changed; ids in the order they were selected, id the last (null when nothing is)
drawing:bounds{ id, rect }The selected drawing's on-screen box changed; rect is null when it is out of view
drawings:change{ drawings }Any drawing is created, updated or removed
drawings:invalid{ errors }drawings.load() skipped malformed drawings or dropped ill-typed fields; each error has its index, a reason, and the field when only that field was dropped
drawings:visibility{ hidden }drawings.setHidden() hid or showed every drawing (the rail's hide-all button calls it)
drawing:create{ drawing }One drawing was added (user, code, undo or redo)
drawing:update{ drawing, change, changes }One drawing changed; changes lists the kinds (points, style, text, lock, visibility, order, pane, flags)
drawing:update-progress{ id }A drawing changes on screen but is not committed yet: the user drags it, an anchor or a handle, or drawings.preview() was called. Fires on every step; the drawing:update at the end commits
drawing:remove{ id }One drawing was removed. drawings.load() fires none of these three; re-sync from drawings:change
drawing:click{ id }Press and release on a drawing without dragging
drawing:dblclick{ id }Double click on a drawing
access:change{ allow: AccessRule | null }drawings.setAccess() changed the allowed tools, also through the access option
tool:refuse{ tool }drawings.setTool() refused a denied tool, e.g. a press on a disabled rail button
tool:defaults-change{ tool, defaults }The style new drawings of tool start with changed: drawings.setToolDefaults(), loadToolDefaults() or a remembered style edit. defaults is drawings.toolDefaults(tool)
history:change{ canUndo, canRedo }Undo/redo availability of chart.history (drawings and indicators) changes
series:error{ series, hook, error }A custom series hook threw, once per hook until it succeeds again; or a follow mapper failed on a live bar (hook: 'follow')
series:add{ series, indicator }A series joined the chart: an add* call, a compared symbol, or an indicator's series (indicator set, before indicator:add)
series:options{ series, patch }series.applyOptions(patch) changed a series on the chart; an empty patch is silent
series:visibility{ series, visible }A series was shown or hidden: setVisible with a new value, or a legend row's hide/show button
series:settings{ series, indicator }A legend row's settings button was pressed, or chart.openSeriesSettings(series) was called; the button shows only while this has a listener and, once providers are declared with chart.provideSeriesSettings, on the rows they cover. indicator is null for plain series
replay:change{ active, playing, time, speed, remaining }A bar replay started, moved, stepped, played, paused, changed speed or stopped
series:remove{ series, indicator }A series left the chart: removeSeries, indicator.remove(), removing its pane, or a legend remove button

Errors ​

Errors the chart raises are ChartErrors with a stable code to branch on, a context object with details such as the symbol, and, when the error came from somewhere else, that original error as cause.

ts
import { ChartError } from '@chart-on/core';

chart.on('data:error', ({ error }) => {
  if (error.code === 'unknown-symbol') showNotFound(error.context.symbol);
  else console.warn(error.message, error.cause);
});

Errors and warnings lists every code with its cause and fix.

Mistakes in arguments (an unknown pane, a negative margin) still throw plain Errors at the call.

Listeners cannot break the chart. A listener that throws does not stop the other listeners of that event, nor the frame that emitted it. Its error is reported through the chart:error event, or logged with console.error when nothing listens there (and when a chart:error listener itself throws).

Promise or event, not both. A failed setSymbol emits data:error and rejects its promise. When a data:error listener exists at the time of the failure, the rejection is marked handled, so an app that reacts to the event can ignore the promise without unhandled-rejection warnings. Code that awaits setSymbol still sees the rejection. Without a data:error listener, handle the promise.

Load older history on scroll ​

history:request fires when the user pans within historyThreshold bars (default 20) of the oldest bar. Fetch older bars and prepend them with setData. With a data source the chart does this for you.

ts
chart.on('history:request', async ({ before }) => {
  const older = await fetchBars({ end: before });
  candles.setData([...older, ...candles.data]);
});

Sync a custom legend ​

ts
chart.on('crosshair:move', (e) => {
  if (!e) return legend.reset();
  legend.show(e.time, e.price);
});

Set features: { legend: false } to hide the built-in legend (see Feature flags).

Click on the chart ​

time and index are the nearest bar (past either end of the data, time is extrapolated by the bar interval), price is the price under the pointer in that pane, and x/y are css px from the chart's top-left corner. A double-click also fires two plot:click events first, as in the DOM.

ts
chart.on('plot:click', ({ time, price }) => placeAlert(time, price));

To convert any position yourself, chart.pointAt(x, y) returns the same payload, or null outside the plot areas. The reverse direction, chart.coordinateFor(time, price, pane), is described in Position HTML over the chart. Unlike crosshair:move, these report the price under the pointer, without crosshair.mode snapping.

Values at a bar and custom tooltips ​

chart.valuesAt(time?) returns every series' value at the bar nearest time, or at the crosshair's bar (else the newest bar) when called without one:

ts
const at = chart.valuesAt();
// { time, timeText, index, series: [{ series, pane, values, legend }, …] }
// values: { open, high, low, close, volume } for candles (raw bars, whatever the style), { value } for lines and histograms
// legend: the title and formatted parts the legend shows

timeText uses the chart's timezone, like the crosshair label. While a pane is maximized, the series of the panes it hides are left out, as in the legend.

series:hover fires only when the series under the pointer changes. Combine it with crosshair:move for a tooltip that follows the pointer:

ts
let hovered: Series | null = null;
chart.on('series:hover', ({ series }) => (hovered = series));
chart.on('crosshair:move', (e) => {
  const row = e && hovered && chart.valuesAt(e.time)?.series.find((s) => s.series === hovered);
  tooltip.show(row?.legend ?? null);
});

chart.seriesAt(x, y) answers the same question for any position. Candles count from high to low of the bar as drawn (Heikin Ashi bars for that style, without the open for HLC styles), close-only styles such as line and area count within a few px of the close, histograms from base to value, and lines within a few px of the value at the nearest bar. Indicators count the same way through their shown plots. A collapsed pane has no series under the pointer.

Trade lines, alert lines, drawings and price lines with pointer callbacks or a tooltip are drawn above the series and take the pointer first: while the pointer is over one, series:hover reports series: null. Plain price lines do not. seriesAt ignores them all.

Sync the crosshair of two charts ​

setCrosshairAt(time, price?, pane?) shows the crosshair without a pointer and keeps it on that time while the view pans, zooms or gets new data. It does not emit crosshair:move, so two charts can mirror each other without looping. Without a price only the vertical line is drawn; a null time hides it. The next pointer move over the chart takes over.

ts
a.on('crosshair:move', (e) => b.setCrosshairAt(e?.time ?? null));
b.on('crosshair:move', (e) => a.setCrosshairAt(e?.time ?? null));

To keep the visible range, symbol, interval or timezone in step too, put the charts in a link group.

Screenshot and data export ​

ts
import { toCSV } from '@chart-on/core';

// A PNG Blob at device pixel ratio: background, watermark, series, drawings, axes and legend.
// null when the chart has no size, the GPU context is lost or encoding fails.
const blob = await chart.captureImage();    // { legend: false } to leave the legend out, { crosshair: true } to include it
if (blob) download(blob, 'chart.png');

// Other formats go through the browser's encoder.
const jpeg = await chart.captureImage({ type: 'image/jpeg', quality: 0.9 });

// Twice the CSS size whatever the display, on a white card with a margin, without drawings.
const share = await chart.captureImage({ scale: 2, background: '#ffffff', padding: 16, drawings: false });

// A light image of a dark chart, with a header naming the symbol, interval, values and time.
const report = await chart.captureImage({ theme: 'light', caption: true });

// Rows for the visible range, or pass { from, to } (unix seconds, inclusive).
const data = chart.getDataTable();
// data.columns: ['time', 'open', 'high', 'low', 'close', 'volume', 'RSI', …]
download(new Blob([toCSV(data)], { type: 'text/csv' }), 'chart.csv');

// Dates and times a spreadsheet reads, on the chart's wall clock.
const csv = toCSV(data, { time: 'iso', timezone: chart.zone.name });
// time,open,…  then  2024-01-02 10:00:00,…

captureImage needs the imageCapture() plugin (in builtins(), and added by the UI shell); without it the call throws a not-registered error. Create the chart with createChart(el, { plugins: [imageCapture()] }) or call chart.install(imageCapture()). Data export (getDataTable, toCSV) needs no plugin.

The image is composited when you call captureImage, so it shows the chart at that moment even though the Blob arrives later. It leaves the crosshair out, with the legend on the newest bar, unless you pass crosshair: true. Where it goes (a download, the clipboard, an upload) is up to the app.

OptionDefaultEffect
legendthe legend featurePaint the series legend onto the image
crosshairfalseInclude the crosshair and its labels
scaledevice pixel ratioImage pixels per CSS pixel. Above the device pixel ratio the chart is redrawn at that ratio for the capture (the GPU series too, past maxGlDpr) and the screen is restored before the browser paints. The longest side is kept within 8192 pixels
watermarktrueInclude the watermark when the chart shows one
drawingstrueInclude drawings
annotationstrueInclude markers, price lines, trade lines and their axis labels
backgroundtheme backgroundA CSS color, or 'transparent' for an image with an alpha channel (PNG or WebP; JPEG shows black). A color the chart cannot read falls back to the theme background
padding0Margin around the chart in CSS pixels, filled with the background
themethe chart's themeDraw the image in another theme: a whole ChartTheme, or 'light' / 'dark' for the built-in themes with the chart's own fonts. The screen keeps its theme and pixel ratio: the capture switches the theme and back in the same task, before the browser paints, also when drawing fails, and emits no theme:change
captionfalseA header line above the chart. true: the symbol, the interval (with a data source), the newest bar's values and the export time in the chart timezone, e.g. DEMO · 1h · O 101 H 112 L 90 C 104 · 2024-01-15 21:00 Asia/Tokyo. A string: that text. Drawn in the image's theme over its background
type, qualityimage/pngPassed to the browser's encoder

Without background, padding or caption the image matches the screen, dark or light theme alike. With any of them, the background is painted once under the whole image, so a translucent one is not doubled where the chart is.

To copy the image, @chart-on/ui has copyChartImage(chart, options?). It takes the same options except type and quality (clipboards take PNG) and returns a promise that rejects when the browser cannot copy images, the user denies it, or the chart cannot be captured. Like captureImage, it needs the imageCapture() plugin (the UI shell adds it); without it the promise rejects with the not-registered ChartError. Call it from a click or key press, since browsers only allow clipboard writes that start from one. canCopyImage() says whether the browser supports it, for example to hide the button.

To print, @chart-on/ui has printChart(chart, options?). It captures the chart (the same options as copyChartImage, defaulting to theme: 'light', caption: true and scale: 2), adds a print-only view of the image to the page and opens the browser's print dialog. The page looks the same on screen; while printing, its stylesheet hides everything else and fits the image to the page. The view is removed when the dialog closes, and the promise resolves then; it rejects when the chart cannot be captured or the browser refuses to print. Outside the UI shell it needs the imageCapture() plugin: without it the promise rejects with the not-registered ChartError. It needs @chart-on/ui/style.css, which holds the print rules.

ts
import { printChart } from '@chart-on/ui';

printButton.onclick = () => printChart(chart).catch((e) => showToast(String(e)));
ts
import { canCopyImage, copyChartImage } from '@chart-on/ui';

copyButton.hidden = !canCopyImage();
copyButton.onclick = () => copyChartImage(chart, { scale: 2 }).catch((e) => showToast(String(e)));

getDataTable includes the main OHLCV, other candle series (columns prefixed with their title), and line and histogram series (one column each, named by title). Hidden series are included; heatmaps are skipped. Candles export their raw bars whatever the display style. Missing and non-finite values are null, written as empty CSV cells. toCSV prefixes text cells that start with =, +, -, @, a tab or a carriage return with ', so a series title cannot run as a spreadsheet formula; numbers, including negative ones, are written unchanged.

toCSV(data, options?) writes the time column as unix seconds by default. time: 'iso' writes YYYY-MM-DD HH:mm:ss, the wall-clock date and time in timezone (an IANA name or 'local'; default 'UTC'), which spreadsheets open as dates. The text carries no offset, so name the timezone wherever the file goes; chart.zone.name is the chart's. time may also be a function (time) => string; its text is escaped like any other text cell. An unknown timezone throws a RangeError.

Trade lines ​

Order and position lines report drags and button presses through callbacks on each line (onDrag, onChange, onAction), not through chart.on. See Annotations.