DocumentationPrimitives & Plugins

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

live
Rendering live chart…
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.

live
Rendering live chart…
Label plates: the tail points at the anchor price, the body clears it.
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[]): void

Call 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.

live
Rendering live chart…
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;
MethodDescription
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.

live
Rendering live chart…
LogoWatermark: a colored badge and a tinted monochrome mark.
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;
OptionDefaultDescription
src / image-image URL / data URI, or a preloaded image / bitmap
positionbottom-righttop-left / top-right / bottom-left / bottom-right / center
height28logo height in px (width follows the source aspect)
margin12gap from the plot edges in px
opacity0.70..1
tint-recolor opaque pixels (e.g. a faint theme gray)
zOrdertopbottom / normal / top
label-text revealed to the right of the mark on hover
labelColortheme textcolour of the mark and label together
fontSize12label size in media px
backgroundtranslucent theme bgrounded plate behind the lockup; 'none' to omit
borderColortheme axis lineplate border; 'none' to omit
radius6plate corner radius
padding{ x: 7, y: 4 }plate padding in media px; a number pads both axes
revealSeconds0.18reveal duration
href-marks it clickable; the host opens watermark.href()
utmMedium / utmCampaignoac-link / oac-chartattribution 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 | null

null 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]);
}