DocumentationEvents

Events

OpenAlgo Charts gives you one small, predictable event surface. A single chart.on(name, cb) bus covers interaction, viewport, data, and lifecycle events, and it returns an unsubscribe function so cleanup is one call. The trading layer routes its trading:* events through the same bus, so you never juggle two APIs.

Subscribing

// Subscribe. The return value unsubscribes.
const off = chart.on('crosshair:move', (e) => {
  // handle event
});
 
// Later: stop listening.
off();
 
// Or drop one listener / every listener for a name.
chart.off('crosshair:move', handler);
chart.off('crosshair:move');
 
// Fire once, then auto-unsubscribe.
chart.once('ready', () => console.log('chart is live'));

Every on(...) returns its own unsubscribe. Keep the returned function and call it on teardown (component unmount, route change) so listeners never leak.

What the engine emits

EventPayloadFires when
ready{}Once, on the microtask after createChart returns.
destroy{}Once, at the end of chart.destroy(), with the chart already torn down. Anything holding a chart it did not create lets go of it here.
crosshair:move{ time, index, price, bar, point, paneIndex, pressed, modifiers, pointerType, pressure, samples? }The pointer moves over the plot (all-null when it leaves). pressed is true while a pointer is held: the only way to observe a drag in progress, since placement mode suppresses panning. samples is present only then and lists every coalesced position since the last move.
click{ id, price, time, paneIndex, point, modifiers, pointerType, pressure }The plot was clicked. id is the hit primitive’s externalId, or null for a click on empty chart space. point is container-relative media px. pressure is the press pressure, since a release always reads 0.
hover{ id }The pointer enters (id = externalId) or leaves (id = null) a hit-testable primitive.
dblclick{}The plot is double-clicked. The chart also resets the scale on this, unless a drawing tool is armed. There a double-click finishes the shape instead.
contextmenu{ paneIndex, point, price, time, index, target, preventDefault }The plot is right-clicked. target classifies what is under the pointer, so an app can raise its own menu. See below.
drag{ id, price, time, paneIndex, fromPrice, fromTime, point, samples, modifiers, pointerType, pressure }A draggable primitive is being moved. from* is the grab origin, so a delta starts at the press rather than the first move. point is container x with pane-local y; samples lists every coalesced position since the last move in that space, the last equal to point.
drag:end{ id, price, time, paneIndex, point, modifiers, pointerType, pressure }The drag gesture finished. Pair with drag to snapshot once per gesture (undo wants the pre-drag state, not an intermediate frame).
pan{ from, to, logicalFrom, logicalTo }The user pans (drag, two-finger, kinetic glide).
zoom{ from, to, logicalFrom, logicalTo }The user zooms (wheel or pinch).
resize{ width, height }The container size changes (CSS px).
lazy-load{ from, to, direction }The viewport nears the oldest bar and history paging should run.
priceAxisMoved{ paneIndex, from, to }A pane’s price axis moved to the other strip, via chart.movePriceAxis. The scale object travels with it, so a menu holding a scaleId should follow.
paneRemoved / paneMaximized / paneResized{ paneIndex }The pane layout changed through a legend button or a divider drag. paneMoved reports { from, to } instead.
indicatorRemoved / indicatorSettings{ instanceId, indicatorId, paneIndex }An indicator legend’s close or gear button was pressed.
indicator:alertIndicatorAlertPayloadA condition an indicator descriptor declared came true on a new bar. See Indicator alerts.
pick:start / pick:end{ kind } / { kind, value }chart.beginPick armed, and then resolved or was cancelled (value is null when cancelled). See Picking a price or a bar.
replay:*ReplayStateThe market replay playhead starts, moves, plays, pauses, ends or stops.
trading:*see Trading eventsThe user interacts with a position, order, or bracket.

from / to on the viewport and data events are UTC seconds (or null when the edge falls outside loaded data). logicalFrom / logicalTo are the raw logical bar indices, handy when you drive your own paging math.

crosshair:move and pan / zoom fire at pointer/gesture rate. Do only light work in the handler (update a legend, a readout). Defer heavy work to requestAnimationFrame or a debounce.

Live demo

Move the crosshair, then drag and wheel-zoom. The readout is driven entirely by chart.on('crosshair:move') and chart.on('zoom' | 'pan').

live
Rendering live chart…
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 200, 3600);
chart.addSeries('candlestick').setData(bars);
chart.timeScale.fitContent(bars.length);

el.style.position = 'relative';
const out = document.createElement('div');
out.style.cssText = 'position:absolute;left:8px;top:8px;font:12px ui-monospace,monospace;background:rgba(0,0,0,.55);color:#e6edf3;padding:6px 9px;border-radius:6px;pointer-events:none;line-height:1.5';
el.appendChild(out);

let hover = 'move the crosshair';
let view = '';
const render = () => { out.innerHTML = hover + (view ? '<br>' + view : ''); };

chart.on('crosshair:move', (e) => {
hover = e.bar
  ? 'O ' + e.bar.open.toFixed(2) + '  H ' + e.bar.high.toFixed(2) + '  L ' + e.bar.low.toFixed(2) + '  C ' + e.bar.close.toFixed(2)
  : 'off chart';
render();
});
const fmt = (t) => t == null ? '-' : new Date(t * 1000).toISOString().slice(0, 16).replace('T', ' ');
chart.on('zoom', (r) => { view = 'zoom  ' + fmt(r.from) + ' -> ' + fmt(r.to); render(); });
chart.on('pan',  (r) => { view = 'pan   ' + fmt(r.from) + ' -> ' + fmt(r.to); render(); });
render();
return chart;

Interaction events

crosshair:move

Fires on every pointer move across the plot, and once with all-null fields when the pointer leaves. bar is the hovered bar of the primary price series (or null off the data); point is the crosshair position in container CSS px.

chart.on('crosshair:move', ({ time, price, bar, point }) => {
  if (!bar) { legend.hidden = true; return; }
  legend.hidden = false;
  legend.textContent = `O ${bar.open} H ${bar.high} L ${bar.low} C ${bar.close}`;
});

The typed helper chart.subscribeCrosshairMove(cb) delivers the same CrosshairMoveEvent if you prefer a single dedicated callback.

Every move also reports the pointer itself: modifiers ({ shift, alt, ctrl, meta }), pointerType ('mouse' | 'touch' | 'pen') and pressure (0..1 as the pointer events spec defines it: what the hardware measured, else 0.5 while a button is held, else 0). While pressed, samples carries every position the pointer passed through since the last move, with its pressure, so a freehand stroke inks a fast gesture whole rather than one point per frame. click, drag and drag:end carry the same three pointer facts; drag adds point and samples.

click

Fires on every plot click, not only on a hit. Null-check id: it is null when the click landed on empty space rather than a primitive. The payload id is the externalId you set on the marker, event marker, or price line, so you can route the click without a lookup.

chart.on('click', ({ id }) => {
  if (id === 'entry-line') openOrderPanel();
});

chart.subscribeClick(cb) is the typed equivalent (cb(externalId)).

hover

Fires when the pointer enters or leaves a hit-testable primitive (id is the primitive’s externalId, or null on leave), at state-change rate, not pointer rate. The chart already renders hover states (thicker line, brighter pill, cursor hint) by itself; use this event for app-side affordances such as an order tooltip or a details panel.

chart.on('hover', ({ id }) => {
  tooltip.hidden = id === null || !id.startsWith('ord:');
});

contextmenu

Fires on a right-click over the plot, with everything a menu needs: where the pointer is, what is under it, and the preventDefault that suppresses the browser’s own menu.

chart.on('contextmenu', (e) => {
  e.preventDefault();
  if (e.target.kind === 'drawing') return drawingMenu(e.point, e.target.id);
  if (e.target.kind === 'indicator') return indicatorMenu(e.point, e.target.instanceId);
  chartMenu(e.point, { price: e.price, time: e.time });
});

target.kind is 'drawing' | 'indicator' | 'legend' | 'primitive' | 'series' | 'price-scale' | 'time-scale' | 'empty', and carries the hit-test id, an instanceId for an indicator, and a seriesType for a series. A canvas hands an app a pixel rather than an object, so this classification is the part it cannot work out for itself, and the part that decides which menu items make sense.

A 'price-scale' hit also carries side ('right' or 'left', the strip that was hit) and scaleId (which of the pane’s scales that strip acts on, '' when the pane’s values all sit on the hidden overlay scale). scaleId is the argument the priceAxis* calls take, so a menu should pass it through rather than deriving one from side:

if (e.target.kind === 'price-scale') {
  const state = chart.priceAxisState(e.paneIndex, e.target.scaleId);
  return openAxisMenu(e.point, state);
}

The bottom-left corner belongs to the time axis, not to a left price ladder, because the time axis spans the full width including the left column. The bottom-right corner stays the price axis’. Building the axis menu itself is covered on Settings & Menus.

With no contextmenu listener the chart keeps its save-image fallback: it composites the clicked pane’s layers so the browser’s own “Save image as” produces the visible chart rather than a blank overlay. The first listener takes that over; dropping the last one restores it. See Theming and chart options.

Viewport events

pan and zoom report the new visible window after the gesture. resize fires whenever the container changes size (the built-in ResizeObserver drives it, and an explicit chart.applySize(w, h) also emits it).

They are not gesture-only. setVisibleLogicalRange, fitContent, resetScale and the keyboard pan and zoom commands all announce themselves the same way, and only when the window actually moved: a clamped zoom, or a fitContent on an already-fitted chart, emits nothing. Which of the two you get is decided by whether the span changed, since a restored range can pan or zoom and the payload alone would not say.

chart.on('pan',  ({ from, to }) => syncOtherChart(from, to));
chart.on('zoom', ({ from, to }) => syncOtherChart(from, to));
chart.on('resize', ({ width, height }) => layoutOverlays(width, height));

Use pan / zoom to save the user’s camera. The window is not persisted for you by design.

For keeping a second chart in lockstep, prefer chart linking over copying the range across by hand: from and to are this chart’s logical indices, and a chart holding different bars needs the window converted through time at both ends.

Data events

lazy-load

Fires when the viewport nears the oldest loaded bar. Pair it with a history loader and series.prependData(...), then call chart.historyLoadComplete() to re-arm the trigger. See Data Loading for the full pattern.

chart.setHistoryLoader(() => {
  chart.on('lazy-load', async ({ to }) => {
    const older = await fetchOlderBars(before(to));
    series.prependData(older);
    chart.historyLoadComplete();
  });
});

setHistoryLoader is the direct hook: it fires the loader with no payload. lazy-load is the observable form of the same trigger, carrying the time window so a single handler can serve multiple series.

Replay events

ReplayController reports through the same bus, and every payload is the same ReplayState, so one handler can drive a whole transport bar.

EventFires
replay:startThe controller puts its first bar on the chart.
replay:frameEvery playhead move: seek, step, stepBack, and each played bar.
replay:play / replay:pausePlayback armed or halted.
replay:endThe playhead reached the last bar.
replay:stopReplay left; data and viewport restored.
chart.on('replay:frame', ({ index, total, bar }) => {
  scrub.max = String(total - 1);
  scrub.value = String(index);
  clock.textContent = new Date(bar.time * 1000).toLocaleString();
});

See Market Replay for the controller itself.

Lifecycle events

ready

Fires once, on the microtask after createChart returns. Because it is deferred, a subscription registered on the very next line still receives it.

const chart = createChart(el);
chart.on('ready', () => console.log('ready'));  // still fires

destroy

Fires once, at the very end of chart.destroy(), and chart.isDestroyed is already true when it does. A listener is there to let go of the chart (unsubscribe, drop it from a link group, release a controller), not to read it: it sees the same dead object every other holder sees. destroy() is idempotent, so a second call re-emits nothing, and every listener is dropped afterwards, since subscriptions on a destroyed chart would otherwise retain their closures forever.

chart.on('destroy', () => group.remove(chart));
chart.isDestroyed;   // true once it has run

Host events on the same bus. chart.emit(name, payload) is public, so a host can put its own facts on the chart’s bus and have the engine’s own consumers pick them up. The symbol event is the documented case: the engine has no notion of an instrument, and a link group syncing symbols listens for exactly that.

chart.emit('symbol', 'RELIANCE');   // or { symbol: 'RELIANCE' }

Indicator alerts

An indicator descriptor can declare conditions the runtime watches on its behalf, because the indicator is the only thing that knows what a crossover of its own columns means. A trigger arrives on the ordinary bus:

import 'openalgo-charts/indicators';
 
chart.on('indicator:alert', ({ indicatorId, instanceId, alertId, title, message, time, index }) => {
  toast(`${title} on bar ${index}`);
});

IndicatorAlertPayload

FieldTypeDescription
indicatorIdstringThe descriptor id, e.g. 'macd'.
instanceIdstringThe instance id, so three EMAs are tellable apart.
alertIdstringThe id of the IndicatorAlertSpec that fired.
titlestringShort label from the spec.
messagestringLonger text, defaulting to title.
timenumberThe bar that triggered it, UTC seconds.
indexnumberThat bar’s index in the indicator’s bars.
⚠️

Alerts fire only on a live tail change. Adding an indicator to a loaded chart, changing a setting, paging history in, or switching symbol reseeds the watermark silently and emits nothing, so two years of bars cannot announce every crossover in them at once. The watermark is a bar time rather than a count, so older bars arriving at the left edge cannot re-fire the chart either. See Alerts for the descriptor side.

Trading events

Every trading event is mirrored onto the main bus, so chart.on('trading:order_modify', cb) and chart.trading.on('trading:order_modify', cb) are equivalent (both use the full trading: prefixed name). Use whichever reads better in your code.

EventPayload
trading:order_modify{ orderId, newPrice, previousPrice }
trading:order_cancel{ orderId }
trading:order_click{ order }
trading:position_close{ positionId }
trading:position_click{ position }
trading:bracket_modify{ parentId, bracketRole, newPrice }
chart.on('trading:order_modify', async ({ orderId, newPrice }) => {
  await broker.modifyOrder(orderId, { price: newPrice });
});
 
chart.on('trading:position_close', async ({ positionId }) => {
  await broker.closePosition(positionId);
});

See the Trading API page for the full data model.

Keyboard shortcut events

Shortcut triggers come off their own manager rather than the value bus, because the payload is a rich ShortcutTriggerEvent:

chart.shortcuts?.on(({ command, combo }) => {
  analytics.track('shortcut', { command, combo });
});

See Keyboard Shortcuts.

Design notes and scope

OpenAlgo Charts is a lean rendering engine, not a full white-label shell, so the event surface stays deliberately small and stable. Concerns that belong to your application layer are composed from the primitives above rather than shipped as built-in events:

  • Menus, toolbars, dialogs and transport bars are yours. The engine emits what it knows and draws none of the chrome: contextmenu classifies the right-click target, indicatorSettings fires when a legend’s gear is pressed, and the replay:* events drive a transport bar. You render the UI.
  • Annotations. Build them as primitives / plugins and hit-test with click.
  • Data-feed reconnection, gap-fill, timezone, theme families, undo/redo, state persistence. These live in your app or your feed. The engine exposes the hooks (setData, update, prependData, setHistoryLoader, setTheme-style options) so you own the policy. See Data Loading and Live Data.

This keeps the base bundle small and the API easy to reason about: a handful of events you will actually use, each with a plain payload and a one-call unsubscribe.