Primitives & plugins
Everything drawn on the chart that is not a series is a primitive: markers, event badges, price lines, profiles, and the whole trading layer. They all use one small interface, and so can you.
Markers & event badges
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 120, 3600);
const series = chart.addSeries('candlestick');
series.setData(bars);
const markers = series.createMarkers();
markers.setMarkers([
{ time: bars[30].time, position: 'belowBar', shape: 'arrowUp', size: 'medium', color: '#26a69a', text: 'BUY' },
{ time: bars[82].time, position: 'aboveBar', shape: 'arrowDown', size: 'medium', color: '#ef5350', text: 'SELL' },
]);
chart.timeScale.fitContent(bars.length);
return chart;Shapes are arrowUp, arrowDown, circle, square, triangleUp, triangleDown,
diamond, flag, text, and the two label plates labelUp / labelDown.
A label plate is a rounded box with a tail that points at the anchor price, for a signal
with a name rather than a bare glyph. labelUp hangs its body below the anchor (tail
pointing up), labelDown sits above it. Both require text, and both read best with
position: 'atPrice' so the tail lands exactly on the level being called out. The text
colour is derived automatically to contrast with the plate fill.
View example code
const chart = lib.createChart(el);
const bars = lib.generateBars(1700000000, 120, 3600);
const series = chart.addSeries('candlestick');
series.setData(bars);
const markers = series.createMarkers();
markers.setMarkers([
{ time: bars[30].time, position: 'atPrice', price: bars[30].low, shape: 'labelUp', size: 'small', color: '#2962ff', text: 'Buy' },
{ time: bars[82].time, position: 'atPrice', price: bars[82].high, shape: 'labelDown', size: 'small', color: '#ef5350', text: 'Sell' },
]);
chart.timeScale.fitContent(bars.length);
return chart;drawLabel(ctx, up, cx, anchorY, text, color, fontPx) is exported if you want the same
plate inside a custom primitive. Coordinates are bitmap px. Multiply media px by dpr
first.
Corporate-action badges (earnings / dividend / split) attach at the chart level:
chart.addEventMarkers().setEvents([{ time, type: 'earnings', label: 'E' }]);EventMarkers
EventMarkers renders circular badges near the bottom of the plot. Each element in the array
passed to setEvents has this shape:
interface ChartEvent {
time: number; // UTCSeconds bar time
type: 'earnings' | 'dividend' | 'split' | 'news' | string;
label: string; // text inside the badge (first two characters are shown)
color?: string; // overrides the built-in type color
id?: string; // returned as externalId by hitTest for click routing
}Built-in type colors: earnings -> #f0a020, dividend -> #26a69a, split -> #4f8cff,
news -> #9aa0b4. An unknown type string falls back to #9aa0b4. Providing color
overrides the default for that individual event.
When id is set on an event, hitTest returns that value as externalId, letting a click
listener identify the specific event without inspecting canvas coordinates:
const em = chart.addEventMarkers();
em.setEvents([
{ time: bars[20].time, type: 'earnings', label: 'E', id: 'q2-earnings' },
{ time: bars[50].time, type: 'dividend', label: 'D', id: 'div-jun' },
]);SeriesMarkers
series.createMarkers() is the standard entry point; it wires the internal series identifier
automatically. SeriesMarkers is also exported for direct import, which is useful for unit
testing primitives in isolation or for subclassing:
import { SeriesMarkers } from 'openalgo-charts';The key method on any SeriesMarkers instance is setMarkers, which replaces the full
marker list and sorts it by time internally:
// signature
setMarkers(markers: readonly SeriesMarker[]): voidCall it again with an updated array whenever signal state changes; it calls requestUpdate()
on the host automatically.
Price lines
chart.addPriceLine({ price: 101.5, color: '#3b82f6', lineWidth: 2, dashed: false, id: 'vwap' });For the reference levels a chart is read against, previous close, session high and low,
extended hours, bid and ask, use PriceLevels rather than ten hand-managed price lines. It
computes each level from the session in the viewport, keeps the line and its axis tag as one
toggleable group, and reports which levels have no data so a host can grey their controls.
See Price Levels & Axis Chrome.
PriceLine direct construction
chart.addPriceLine(opts) is a convenience wrapper. For live updates (such as a moving
stop-loss or a real-time VWAP level), construct PriceLine directly, keep a reference, and
mutate it without going through the chart API:
import { PriceLine } from 'openalgo-charts';
const sl = new PriceLine({
id: 'stop-loss', price: 19750, color: '#ef5350',
lineWidth: 1, dashed: true,
badge: 'SL', qty: 100, cursor: 'ns-resize',
});
chart.addPrimitive(sl);
// move the line and trigger a repaint
sl.setPrice(19600);
// update the info segment, e.g. live P&L
sl.setLeftLabel('-150');
// restyle in place -- e.g. a last-price line following the candle's direction
sl.setOptions({ color: ltp >= bar.open ? '#26a69a' : '#ef5350' });setPrice(price: number) moves the horizontal line and calls requestUpdate() on the host.
setLeftLabel(text: string) updates the info segment of the pill group and repaints.
setDragGhost(price | null) shows/clears a dimmed reference line while dragging.
setOptions(patch) restyles in place (colour, width, dash, labels, badges) and repaints.
id is not patchable: it is the handle the chart routes clicks and drags through, so
swapping it under a live drag would strand the gesture.
See PriceLineOptions for the full option set including badge, qty,
extentFromRight, closeButton, and label.
Tables
ChartTable is a grid pinned to a corner of a pane. Like the watermark and the pane
legend it is screen-space: no time anchor, no part in autoscale, and a zoom leaves it
where it was. Seasonality heatmaps, performance summaries and signal scoreboards are all
the same shape, so they are all this primitive.
View example code
const chart = lib.createChart(el);
chart.addSeries('candlestick').setData(lib.generateBars(1700000000, 140, 3600));
const table = new lib.ChartTable({
position: 'top-right',
cellWidth: [78, 62],
cellHeight: 20,
borderColor: 'rgba(255,255,255,0.08)',
background: 'rgba(0,0,0,0.35)',
});
table.setRows([
[{ text: 'Metric', bold: true }, { text: 'Value', bold: true }],
[{ text: 'Trend' }, { text: 'Up', bgColor: '#089981' }],
[{ text: 'Momentum' }, { text: 'Weak', bgColor: '#f23745' }],
[{ text: 'Bars' }, { text: '140' }],
]);
chart.addPrimitive(table);
chart.timeScale.fitContent(140);
return chart;| Method | Description |
|---|---|
setRows(rows) | Replace the grid. Rows may be ragged; each is drawn to its own length. |
rows() | Read the current rows back. |
setOptions(patch) | Merge option changes and request a repaint. |
options() | The resolved options. |
Sizing has two modes. Fixed: cellWidth (a number, or an array for per-column widths)
and cellHeight, in media px. Proportional: widthPercent and heightPercent stretch
the grid to a share of the plot, preserving the column proportions declared in
cellWidth, with rowWeights giving individual rows a larger or smaller share. Font size
shrinks automatically rather than overflowing a stretched row that came out short.
Set id to make the table hit-testable, and it reports through chart.on('click') like
any other primitive.
Indicators get at the same thing through the table hook rather than constructing one
directly. See indicators.
Logo / brand watermark
Version 2.1.9 adds a chart-owned OpenAlgo corner logo by default.
Use branding: false when managing your own logo primitive. The optional background
text watermark remains off by default. See Branding and Watermarks
for shared settings, automatic symbol text and host customization.
LogoWatermark stamps a small image in a corner of the plot. It draws on the canvas (so it
is captured by takeScreenshot()), is source-agnostic (pass a src URL/data-URI or a
preloaded image), and an optional tint recolors the opaque pixels so a single-color logo
reads on any theme.
Chart furniture versus pane furniture
addPrimitive(p, paneIndex) attaches to a pane, which is right for a price line or a
band: it belongs to the thing it measures.
A watermark, a corner clock or a brand mark does not. It belongs to the chart, and it should stay at the chart’s edge as indicator panes come and go. Pass a placement instead of an index:
chart.addPrimitive(mark, { anchor: 'chart-bottom' }) // or 'chart-top'The engine re-homes it whenever a pane is added, removed, moved or maximized. Maximize is the case worth knowing about: it hides the other panes, so a mark pinned to pane 0 does not merely sit in the wrong place, it disappears with the pane. An anchored primitive follows the visible edge.
Before this existed a host had to do it by hand, removing and re-adding the primitive on
chart.panes().length - 1 after anything that might have created a pane, and listening to
paneRemoved and paneMoved to catch the rest. If you have that loop, delete it.
paneAdded also exists now, emitted once per pane created and after the relayout, so a
listener reads settled geometry. Use it if you keep your own chrome positioned; use the
anchor if the engine can do it for you.
View example code
const chart = lib.createChart(el, { branding: false });
chart.addSeries('candlestick').setData(lib.generateBars(1700000000, 140, 3600));
// colored badge, bottom-right (the default corner)
chart.addPrimitive(new lib.LogoWatermark({
src: '/openalgo-charts/openalgo-logo.svg', height: 34, opacity: 0.9,
}));
// same logo tinted to a faint gray, top-left
chart.addPrimitive(new lib.LogoWatermark({
src: '/openalgo-charts/openalgo-logo.svg', position: 'top-left',
height: 26, opacity: 0.5, tint: '#8b91a7',
}));
chart.timeScale.fitContent(140);
return chart;| Option | Default | Description |
|---|---|---|
src / image | - | image URL / data URI, or a preloaded image / bitmap |
position | bottom-right | top-left / top-right / bottom-left / bottom-right / center |
height | 28 | logo height in px (width follows the source aspect) |
margin | 12 | gap from the plot edges in px |
opacity | 0.7 | 0..1 |
tint | - | recolor opaque pixels (e.g. a faint theme gray) |
zOrder | top | bottom / normal / top |
label | - | text revealed to the right of the mark on hover |
labelColor | theme text | colour of the mark and label together |
fontSize | 12 | label size in media px |
background | translucent theme bg | rounded plate behind the lockup; 'none' to omit |
borderColor | theme axis line | plate border; 'none' to omit |
radius | 6 | plate corner radius |
padding | { x: 7, y: 4 } | plate padding in media px; a number pads both axes |
revealSeconds | 0.18 | reveal duration |
href | - | marks it clickable; the host opens watermark.href() |
utmMedium / utmCampaign | oac-link / oac-chart | attribution on the composed link |
Swap or restyle it live with watermark.setOptions({ ... }).
A hover-revealed, clickable brand mark
label turns the mark into a lockup: at rest only the mark shows, and hovering unrolls the
wording to its right. The mark and the label always share one colour (whichever of tint
or labelColor you set drives both), so the pair can never render in two unrelated shades.
const mark = new LogoWatermark({
src: '/openalgo-logo.svg', position: 'bottom-left', height: 26,
label: 'OpenAlgo Charts', labelColor: '#e4e8f4',
href: 'https://openalgo.in',
});
chart.addPrimitive(mark, 0);
// A canvas cannot hold an anchor, so the mark reports the hit and you navigate.
chart.subscribeClick((id) => {
if (id === 'watermark') window.open(mark.href(), '_blank', 'noopener,noreferrer');
});href() returns the URL with utm_medium, utm_campaign and a utm_source naming the
page the chart is embedded in (host and path only, never the query string, which is the
part most likely to carry something private). Pass an href that already has a query
string to compose your own and skip that entirely.
A mark with neither label nor href stays out of the hit path completely, so plain
decoration cannot swallow clicks meant for the chart.
Sizing the resting plate
height sizes the mark; padding sizes the plate around it. The two together set what the
corner looks like at rest: a 40px mark with padding: 2.5 sits in a 45x45 square, and the
plate widens from there as the label unrolls.
new LogoWatermark({ src: '/mark.svg', height: 40, padding: 2.5, label: 'OpenAlgo Charts' });Supply the symbol on its own, cropped tight to a square viewBox. An app-icon asset
(a full-bleed background plate with the mark inset and a wordmark beneath it) scales its own
padding along with the mark, so raising height grows the empty space rather than the logo.
Whatever tint/labelColor recolors, the whole opaque silhouette becomes one shade, so a
background baked into the file becomes a solid block.
watermarkRect placement helper
watermarkRect is the pure placement function that LogoWatermark uses internally to compute
the logo’s top-left pixel offset. Call it directly inside a custom primitive that needs
corner-aligned or centered layout at the same snap grid:
import { watermarkRect } from 'openalgo-charts';
// inside a primitive's draw():
const { x, y, w, h } = watermarkRect(
'bottom-left', 12, logoW, logoH, rc.plotWidth, rc.plotHeight,
);
ctx.drawImage(
img,
Math.round(x * rc.dpr), Math.round(y * rc.dpr),
Math.round(w * rc.dpr), Math.round(h * rc.dpr),
);Signature (all values in media/CSS pixels):
function watermarkRect(
position: WatermarkPosition,
margin: number,
w: number,
h: number,
plotW: number,
plotH: number,
): { x: number; y: number; w: number; h: number }The returned x/y are unscaled media pixels. Multiply by rc.dpr before drawing to a
device-pixel canvas.
A custom chart type
Register a renderer once and use it like any built-in type:
import { registerChartType } from 'openalgo-charts';
registerChartType('my-style', {
defaultStyle: { color: '#fff' },
isPriceSeries: true,
draw: (ctx, items, toY, barSpacing, dpr, style) => { /* paint to the canvas */ },
extents: (bar) => ({ min: bar.low, max: bar.high }),
});
chart.addSeries('my-style').setData(bars);A custom primitive
Implement IPrimitive and attach it - the same contract markers, profiles, and the trade
layer are built on:
import type { IPrimitive } from 'openalgo-charts';
const watermark: IPrimitive = {
zOrder: () => 'bottom' as const, // a method, not a property
draw(ctx, host) {
ctx.fillStyle = 'rgba(255,255,255,0.05)';
ctx.font = '700 48px system-ui';
ctx.fillText('OPENALGO', 24, host.plotHeight - 24);
},
// optional: hitTest(point), autoscaleInfo(), attached(host), detached()
};
chart.addPrimitive(watermark, 0);Primitives receive a host with pixel/price/time conversions and the plot rectangle, so they can draw anything and participate in hit-testing and autoscale.
Reading the bars
rc.bars() returns the pane’s primary price series when a primitive needs what price
actually did, not just the scales: scoring a projection, shading bars that met a
condition. It is lazy, so nothing pays for it unless called, and optional, so guard it:
a synthetic render context (a test fake) may not supply one.
draw(ctx, rc) {
const bars = rc.bars?.();
if (bars === undefined || bars.length === 0) return;
const last = bars[bars.length - 1];
// ...
}The array is the live one the data layer holds. Treat it as read-only.
bestHit helper
When a pane contains several independent primitives that each implement hitTest, the chart
collects all their results and calls bestHit to pick the winner. The rule is: smallest
distance wins; among equal distances the highest zOrder wins (top > normal > bottom).
Use it directly inside a composite primitive that delegates hit-testing to multiple sub-objects:
import { bestHit } from 'openalgo-charts';
import type { PrimitiveHit, PrimitiveRenderContext } from 'openalgo-charts';
const composite = {
zOrder: () => 'normal' as const,
draw(ctx: CanvasRenderingContext2D, rc: PrimitiveRenderContext) { /* ... */ },
hitTest(x: number, y: number, rc: PrimitiveRenderContext): PrimitiveHit | null {
return bestHit([
lineA.hitTest(x, y, rc),
lineB.hitTest(x, y, rc),
markerLayer.hitTest(x, y, rc),
]);
},
};Signature:
function bestHit(hits: readonly (PrimitiveHit | null)[]): PrimitiveHit | nullnull entries in the input array are skipped. Returns null if every entry is null.
InvalidationLevel
InvalidationLevel is a const object (not a TypeScript enum) that describes how much of a
pane needs repainting. The chart uses it inside InvalidateMask to coalesce multiple
invalidation requests per animation frame into the minimum necessary work:
const InvalidationLevel = {
None: 0, // nothing to do
Cursor: 1, // repaint only the overlay canvas (crosshair, hover, dragging primitives)
Light: 2, // repaint the base canvas at the current scales
Full: 3, // recompute scales and ticks, then repaint everything
} as const;host.requestUpdate() takes no arguments. Calling it signals the chart to schedule the next
animation frame; the chart decides the repaint scope internally via its InvalidateMask.
InvalidationLevel values are exported for advanced use cases such as constructing a custom
InvalidateMask, reading PaneInvalidation.level to inspect what a prior frame coalesced, or
passing constants to internal helpers that accept a level directly.
A single named import gives you both the value object and the companion type:
import { InvalidationLevel } from 'openalgo-charts';
import type { InvalidationLevel as Level } from 'openalgo-charts';
// compare numeric levels returned by PaneInvalidation.level
if (paneInval.level >= InvalidationLevel.Full) {
console.log('full repaint scheduled');
}
// type annotation for helpers that receive a level
function logLevel(level: Level): void {
const names: Record<Level, string> = { 0: 'None', 1: 'Cursor', 2: 'Light', 3: 'Full' };
console.log(names[level]);
}