DocumentationDrawing Tools

Drawing Tools

openalgo-charts/draw is a lazy tier (34.53 KB Brotli) with 85 tools and a headless controller. It ships no UI: no toolbar, no dialogs. It owns the model and the interactions; your app supplies the buttons.

Try the drawing tools

Pick any tool from the full catalogue or use the quick rail and draw directly on the chart. The three sample drawings are editable: select one on the chart or from the drawing list, then move it, drag a handle, or delete it. Undo and redo let you try changes; Reset demo restores the examples.

live
Rendering live chart…
Synthetic hourly bars. Pick a tool, then click to place anchors. Cursor selects and moves drawings; Delete removes a selection, Esc cancels placement, and Ctrl/Cmd+Z undoes. Reset restores the three examples.
View the running example source
// In your app: import { createChart, generateBars } from 'openalgo-charts';
// import { DrawingController, getDrawingTool } from 'openalgo-charts/draw';
// The website supplies these exports as lib and a sized container as el.
const root = document.createElement('div');
root.className = 'oac-draw-playground';
root.innerHTML = `
  <div class="oac-draw-playground__rail" role="group" aria-label="Drawing tools"></div>
  <div class="oac-draw-playground__main">
    <div class="oac-draw-playground__actions" role="group" aria-label="Drawing actions">
      <select aria-label="Select a drawing"></select>
    </div>
    <div class="oac-draw-playground__plot" tabindex="0" aria-label="Interactive drawing chart"></div>
    <div class="oac-draw-playground__status" role="status" aria-live="polite"></div>
  </div>`;
el.appendChild(root);
const rail = root.querySelector('.oac-draw-playground__rail');
const actions = root.querySelector('.oac-draw-playground__actions');
const plot = root.querySelector('.oac-draw-playground__plot');
const status = root.querySelector('[role="status"]');
const selection = root.querySelector('select');
const listeners = new AbortController();
const listen = (node, event, handler) => node.addEventListener(event, handler, { signal: listeners.signal });

const chart = lib.createChart(plot);
const bars = lib.generateBars(1700000000, 160, 3600);
chart.addSeries('candlestick').setData(bars);
chart.timeScale.fitContent(bars.length);
const draw = new lib.DrawingController(chart, { magnet: 'weak', stayInDrawingMode: false });
const point = (index, price) => ({ time: bars[index].time, price });

// Seed three editable drawings, then start with an empty undo history.
draw.add({ id: 'demo-trend', tool: 'trend-line', paneIndex: 0,
  points: [point(15, bars[15].low), point(75, bars[75].low)],
  style: { color: '#4f8cff', lineWidth: 2 } });
draw.add({ id: 'demo-zone', tool: 'rectangle', paneIndex: 0,
  points: [point(93, bars[93].high), point(137, bars[137].low)],
  style: { color: '#14b8a6', fill: true, fillOpacity: 0.14, lineWidth: 2 },
  text: { value: 'Supply zone', color: '#14b8a6', position: 'inside', valign: 'top' } });
draw.add({ id: 'demo-level', tool: 'horizontal-line', paneIndex: 0,
  points: [point(0, bars[45].low)],
  style: { color: '#f5a623', lineStyle: 'dashed', lineWidth: 2 } });
const initial = draw.toJSON();
draw.fromJSON(initial);

const quickTools = [
  [null, 'Cursor', 'Click a drawing to select it; drag its body or handles.'],
  ['trend-line', 'Trend line', 'Click a start and end point, or drag across the chart.'],
  ['horizontal-line', 'Horizontal line', 'Click once to mark a price level.'],
  ['ray', 'Ray', 'Click two points to project a line to the right.'],
  ['rectangle', 'Rectangle', 'Click two opposite corners, or drag to mark a zone.'],
  ['fib-retracement', 'Fibonacci', 'Click the swing low and high to place a retracement.'],
  ['long-position', 'Long position', 'Click entry, then target; drag the stop or target to adjust.'],
  ['measure', 'Measure', 'Click two points to measure price, time and volume.'],
  ['brush', 'Brush', 'Press, draw a stroke, and release.'],
];
const tools = [quickTools[0], ...lib.registeredDrawingTools().map(tool => [
  tool.id, tool.name, tool.freehand ? 'Press, draw, and release.' :
    tool.points === 0 ? 'Click each point, then double-click to finish.' :
    'Click ' + tool.points + ' point(s) to place the drawing.',
])];
const catalog = document.createElement('select');
catalog.setAttribute('aria-label', 'All drawing tools');
catalog.add(new Option('All drawing tools...', ''));
for (const [id, name] of tools.slice(1)) catalog.add(new Option(name, id));
actions.prepend(catalog);
listen(catalog, 'change', () => {
  draw.setTool(catalog.value || null);
  plot.focus({ preventScroll: true });
  refresh();
});
const toolButtons = new Map();
function button(parent, label, action) {
  const node = document.createElement('button');
  node.type = 'button';
  node.textContent = label;
  listen(node, 'click', () => { action(); refresh(); });
  parent.appendChild(node);
  return node;
}
for (const [id, label, instruction] of quickTools) {
  const node = button(rail, label, () => { draw.setTool(id); plot.focus({ preventScroll: true }); });
  node.title = instruction;
  toolButtons.set(id, node);
}
const undo = button(actions, 'Undo', () => draw.undo());
const redo = button(actions, 'Redo', () => draw.redo());
const remove = button(actions, 'Delete', () => draw.removeMany(draw.selection()));
button(actions, 'Reset demo', () => {
  draw.setTool(null);
  draw.fromJSON(initial);
  chart.timeScale.fitContent(bars.length);
});

function refresh() {
  const active = draw.activeTool();
  catalog.value = active || '';
  const picked = draw.get(draw.selected());
  const label = drawing => drawing.text?.value || lib.getDrawingTool(drawing.tool).name;
  const rows = draw.drawings();
  selection.replaceChildren(new Option('Select a drawing...', ''));
  rows.forEach(drawing => selection.add(new Option(label(drawing), drawing.id)));
  selection.value = picked?.id || '';
  toolButtons.forEach((node, id) => node.setAttribute('aria-pressed', String(id === active)));
  undo.disabled = !draw.canUndo();
  redo.disabled = !draw.canRedo();
  remove.disabled = draw.selection().length === 0;
  const tool = tools.find(([id]) => id === active);
  status.textContent = rows.length + ' drawings · ' +
    (active ? tool[1] + ': ' + tool[2] : picked ? 'Selected: ' + label(picked) + '. Drag its body or handles.' : tool[2]);
}
listen(selection, 'change', () => {
  const id = selection.value || null;
  draw.setTool(null);
  draw.select(id);
  refresh();
});

// Shortcuts belong to this playground, so another chart or a text field keeps its keys.
listen(root, 'keydown', event => {
  if (event.target.closest('input, textarea, select, [contenteditable="true"]')) return;
  if (event.key === 'Escape') draw.setTool(null);
  else if (event.key === 'Delete' || event.key === 'Backspace') draw.removeMany(draw.selection());
  else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
    event.shiftKey ? draw.redo() : draw.undo();
  } else return;
  event.preventDefault();
  event.stopPropagation();
  refresh();
});
const off = ['draw:tool', 'drawing:select', 'drawing:change'].map(event => chart.on(event, refresh));
refresh();

// Unmounts and site-theme changes release the controller and every host listener.
const destroy = chart.destroy.bind(chart);
chart.destroy = () => {
  listeners.abort();
  off.forEach(unsubscribe => unsubscribe());
  draw.destroy();
  destroy();
  root.remove();
};
return chart;

This playground supplies a small toolbar around DrawingController. For the complete packaged rail, properties dialogs and shortcuts, use the widget tier.

Open the full-size drawing gallery for a prepared, editable sample of every tool, phone controls and simulated NIFTY prices near 23800.

Start with the controller

import { createChart } from 'openalgo-charts';
import { DrawingController } from 'openalgo-charts/draw';
 
const chart = createChart(el);
chart.addSeries('candlestick').setData(bars);
 
const draw = new DrawingController(chart, { magnet: true });
draw.setTool('trend-line');   // the next two clicks place it

The tools

85 built-in tools, all registered when the tier is imported.

GroupidAnchors
Linestrend-line, ray, extended-line, arrow, info-line, trend-angle2
Horizontal / verticalhorizontal-line, horizontal-ray, vertical-line, cross-line1
Shapesrectangle, ellipse, circle2
Shapestriangle, rotated-rectangle3
Pathspath, polyline; arc, curve, double-curven; 3
Channelsparallel-channel, fib-channel, flat-top-bottom; disjoint-channel; regression-channel3; 4; 2
Pitchforkspitchfork, schiff-pitchfork, modified-schiff-pitchfork, inside-pitchfork3
Fibonacci levelsfib-retracement, fib-extension-two-point; fib-extension2; 3
Fibonacci timefib-time-zone; trend-fib-time2; 3
Fibonacci geometryfib-fan, fib-speed-resistance-fan, fib-circles, fib-speed-resistance-arcs, fib-spiral; fib-wedge2; 3
Ganngann-fan, gann-box, gann-square2
Geometric studiesdedekind-tessellation, sonic, supersonic, golden-sonic, golden-supersonic2
Harmonic patternsxabcd-pattern, gartley, bat, butterfly, crab, shark, cypher; abcd-pattern5; 4
Wave patternselliott-impulse; elliott-correction; head-shoulders5; 3; 7
Forecastinglong-position, short-position; forecast3; 2
Measurersprice-range, date-range, measure2
Marksarrow-up, arrow-down, arrow-left, arrow-right, icon-stamp1
Cyclescyclic-lines, time-cycles, sine-line2
Text and notestext, price-label, flag-mark, note, balloon, comment, signpost, price-note, table; callout1; 2
Brushesbrush, highlightern

long-position and short-position place in two clicks: the entry, then the target. The second click is also the direction: release above the entry and the trade is a long, below it and it is a short, whichever tool was armed (the armed tool only decides which way a bare click faces). The stop lands opposite the entry at one part risk to two parts reward, sized on screen (64 px of risk, 150 px wide) rather than as a fraction of price, so the box reads the same on a 3 rupee stock and a 24,000 point index. Every level stays a handle, and the stop and target are held on opposite sides of the entry: drag one through the entry and the other reflects, so the trade turns around with its ratio intact. The readouts are a header of direction and R:R, money at risk and position size (from accountSize and risk in the style bag), and each zone’s move in percent, with the level price on request; props.showHeader, showLossSize, showTargetLabel, showStopLabel, showPrices, profitColor and lossColor are the toggles.

Anchor counts of n mean the tool collects anchors until you end it. Freehand tools (brush, highlighter) sample the cursor while the pointer is held and finish on release: one press-drag-release is one stroke, and only the two ends get grab handles. The other n tools (path, polyline) take one anchor per click and finish on double-click, or on controller.finish() if you want to bind a key to it.

measure draws price and time arrows across its box and a chip carrying the change, percentage, bar count, calendar span, and the volume traded over the span (that last one via rc.bars(), so it appears only when the pane has a price series with volume).

The three measurers differ only in what they report. measure gives price change, percent and bar count; price-range drops the time axis; date-range drops the price axis, which is what you want when the other axis is noise. All three take their bar count from logical indices, so it matches what the gapless axis shows rather than raw elapsed time.

circle measures its radius in pixels, so it stays round on screen instead of becoming the ellipse that differing axis scales would otherwise produce. arc passes through its middle anchor; curve treats that anchor as a control handle, so the path only leans toward it. highlighter is path with a fat translucent stroke, and is grabbable anywhere across its width.

Advanced drawings in 2.2.0

The additional 34 tools use the same selection, settings, undo/redo and saved Drawing document as the existing catalogue. Multi-point placement shows the anchors already chosen and a dashed guide until the final point is placed.

regression-channel fits the loaded primary series closes within the selected time range. The deviation multiplier controls the outer bands and the readout shows R-squared and the number of valid bars. Moving or updating a forming bar recalculates the fit. A range without loaded bars shows a clear empty-range label.

The four pitchforks offer standard, Schiff, modified Schiff and inside geometry. Levels, fills and labels remain editable. disjoint-channel has four independent endpoints; flat-top-bottom takes a trend edge and a horizontal opposite edge.

fib-extension retains its existing three-anchor projection. The new fib-extension-two-point extends one measured swing. fib-fan retains its saved price-ray geometry and is named Fib Fan; fib-speed-resistance-fan adds both time and price ray families. trend-fib-time measures logical bar spacing from its first two anchors and projects it from the third, including across session gaps.

Circles, radial arcs and wavefronts use screen radii; they remain circular when the time and price axes have different scales. fib-wedge uses three anchors for its centre and angle. Gann Square combines price/time divisions, fans and arcs. Dedekind Tessellation limits curvature and skips off-screen work to keep recursive geometry bounded.

The wavefront tools expose editable level ladders. For sonic and supersonic, Wave limit caps the enabled ladder at six by default and can increase to twelve. Golden variants use their Fibonacci-spaced levels directly. Mach number controls the supersonic cone angle; Max curvature controls tessellation detail.

Harmonic and wave tools are manually placed annotations. Their labels describe the chosen anchors and measured ratios; they do not detect patterns or generate orders. Named harmonic patterns compare measured ratios against their own expected ranges. When labels are enabled, they show measured values with OK or OUT and an overall status once all anchors are placed. Zero-length legs omit unavailable ratios instead of printing non-finite values. All anchors remain independently editable.

Existing layouts

Saved documents remain version 2. Existing tool IDs and anchor formats are preserved, including forward projections beyond the latest candle. Drawing bodies, guides and handles stay clipped to the plot, including with a left price axis. The new descriptor arrays ADVANCED_LINE_TOOLS, ADVANCED_GEOMETRY_TOOLS and PATTERN_DRAWING_TOOLS are exported for hosts that inspect tool families; normal imports still register the full catalogue automatically.

Anchors live in data space

A drawing’s points are { time, price }, never pixels. The time axis is gapless (weekends, holidays, and session breaks collapse), so a pixel anchor would slide the moment the viewport changed. Anchors map through DataLayer.timeToIndexFloat, which also resolves positions between bars (inside a collapsed gap) and past the last bar, where trend projections and forecasts live.

Using it from TypeScript

DrawingController takes a DrawingChartHost, a structural interface covering the members it uses, which the chart from createChart() satisfies directly:

import { createChart } from 'openalgo-charts';
import { DrawingController } from 'openalgo-charts/draw';
 
const chart = createChart(el);
const draw = new DrawingController(chart);   // no cast needed

Before 1.0.14 this failed with “Types have separate declarations of a private property”: each tier inlined its own copy of Chart, and private members make a class nominal, so the two copies were different types. If you are pinned below 1.0.14 and see that error, upgrade. There is no workaround from outside the package.

The host contract, if you are wrapping the chart rather than passing it directly:

interface DrawingChartHost {
  on(event: string, handler: (payload: unknown) => void): () => void;
  emit(event: string, payload: unknown): void;
  addPrimitive(primitive: IPrimitive, paneIndex?: number): void;
  removePrimitive(primitive: IPrimitive): void;
  readonly dataLayer: DataLayer;
  getVisibleLogicalRange(): { from: number; to: number } | null;
  drawingState(): unknown;
  setDrawingState(state: unknown): void;
  setPlacementMode?(active: boolean): void;
  // Optional, and used only by paste: the first two make its vertical nudge a screen
  // translation that survives a log scale, the third keeps a drawing copied out of an
  // indicator pane from conjuring an empty pane in a chart that has fewer.
  priceToCoordinate?(price: number, paneIndex?: number): number | null;
  coordinateToPrice?(y: number, paneIndex?: number): number | null;
  panes?(): readonly unknown[];
}

Surviving a chart rebuild

A host that recreates the chart (on a timeframe switch, a chart-type change, a theme change) must carry the drawings across, because the controller and its layers belong to the chart that is going away:

// before chart.destroy()
const saved = draw.toJSON();
draw.destroy();
 
// after the new chart exists
const next = new DrawingController(newChart);
next.fromJSON(saved);

Anchors are { time, price }, so they land on the same bars even if the new chart has a different interval loaded.

Controller API

MemberDescription
setTool(id | null)Arm a tool for placement, or return to the cursor.
activeTool()The armed tool id, or null.
drawings() / get(id)The model, in paint order.
add(drawing)Add one directly (import, or host-authored). zIndex, createdAt and id are filled in.
update(id, patch) / updateMany(patches)Patch points, style, text, props, locked, visible or zIndex. updateMany is one undo step.
remove(id) / removeMany(ids) / clear()Delete one, several (one undo step), or all.
finish()End a variable-anchor shape (path, polyline) at the anchors placed so far. Returns whether it committed. Double-click already calls it.
select(id | ids | null, additive?) / selected() / selection()Selection. additive toggles each id; selected() is the primary id, selection() the list in pick order.
duplicate(ids) / nudge(ids, dx, dy)Clone with the paste offset and select the clones; move by a screen distance. One undo step each.
setZIndex(id, z) / bringToFront(id) / sendToBack(id)Paint order within the drawing’s own side of the series.
sendBehindSeries(id) / bringAboveSeries(id)Cross the series: below zero paints under the candles.
copy(target?) / cut(target?) / paste()Clipboard, all async. See Drawing Clipboard.
clipboard()The DrawingClipboard behind those three, for reporting why a copy did not reach the OS clipboard.
undo() / redo() / canUndo() / canRedo()History. A paste of several drawings is one step.
toJSON() / fromJSON(data)Explicit serialisation: a { version: 2, drawings } document out; a document or a 1.9.x bare array in, upgraded by migrateDrawings.
setOptions(patch)magnet, stayInDrawingMode, historyLimit, defaultStyle, pasteOffsetBars, pasteOffsetPixels, clipboard.
cancel() / popAnchor()Drop the anchors placed so far (the tool disarms unless stayInDrawingMode keeps it); remove the last anchor of a variable-anchor shape still being placed. Escape and Backspace, once the host wires them.
hovered() / magnetMode()The unselected drawing under the pointer, or null (drawing:hover { id } fires when it changes); the resolved 'off' | 'weak' | 'strong'.
destroy()Detach layers and listeners.

Events

draw:tool, draw:add, draw:update, draw:remove, draw:select, draw:copy, draw:cut and draw:paste all fire on chart.on(...). The 2.0 pair drawing:select ({ ids }, the whole selection) and drawing:change ({ ids, kind }, one per mutation) fire alongside them for a host that tracks a multi-selection, and drawing:hover ({ id }) when the unselected drawing under the pointer changes.

The controller listens on the event bus (click, crosshair:move, drag, drag:end), not the single-slot subscribeClick / subscribeDrag callbacks, so your app keeps using those for its own order lines without the two fighting over one channel.

Interaction

  • Place: click once per anchor, or press-drag-release to lay down a two-point shape in one gesture. A live preview follows the cursor between clicks.
  • Draw beyond the candles: move into the blank space after the latest candle or before the first loaded bar. Previews and freehand strokes remain active there. Leave room by panning the chart or adjusting the visible logical range. Drawing times use the existing edge-spacing projection; they do not add market data.
  • Select: click a drawing; click empty space to deselect.
  • Move: drag the body to translate every anchor, or drag a handle to move one.
  • Magnet: { magnet: 'strong' } (or true) snaps new anchors to the nearest O/H/L/C of the hovered bar; 'weak' snaps only when one of the four is within a few pixels, so a click on open space lands where it was made. A ring shows where the next click will land.
  • Shift: locks the free end of a line to 45 degree steps on screen, while placing and while dragging a handle.
  • Hover: the drawing under the pointer shows its handles faintly before it is picked.
  • Freehand: the brush and highlighter ink every coalesced pointer sample, thin the trail on release and paint it as a spline; a pen’s pressure can drive the width (style.pressure).
  • Undo: a whole drag is one undo step, not one per frame.
  • Lock: a locked drawing renders but cannot be selected or dragged.
💡

While a tool is armed the controller puts the chart in placement mode (chart.setPlacementMode(true)), so a press starts a shape instead of panning. The chart reports the gesture as two click events: the press point, then the release point tagged viaDrag. DrawingController manages this for you; you only need it if you are placing things (alerts, annotations) without the draw tier.

Keyboard shortcuts

Each tool can declare a shortcut. The built-in line tools ship the standard chords:

ShortcutTool
Alt+TTrend line
Alt+HHorizontal line
Alt+JHorizontal ray
Alt+VVertical line
Alt+CCross line

drawingShortcuts() returns the id -> shortcut map, which is what a palette renders beside each tool’s name. matchDrawingShortcut(event) resolves a key event to a tool id:

import { matchDrawingShortcut } from 'openalgo-charts/draw';
 
window.addEventListener('keydown', (e) => {
  // Your call, not the library's: skip while typing or with a dialog open.
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
  const id = matchDrawingShortcut(e);
  if (id) {
    e.preventDefault();
    controller.setTool(id);
  }
});

The library installs no key listener. Only the host knows whether the chart has focus, a dialog is open, or the user is typing, so binding is left to you.

Modifiers must match exactly: Alt+T does not fire for Ctrl+Alt+T, so a tool cannot shadow a browser or host chord, and metaKey (Cmd) counts as Ctrl. A bare letter never matches, so ordinary typing is never swallowed.

Give a custom tool a chord the same way:

registerDrawingTool({ id: 'my-band', name: 'My band', points: 2, shortcut: 'Alt+B', draw, distance });

Copy, cut and paste

The controller has a clipboard, and like the shortcuts above it is an API your binding calls. All three are async, because navigator.clipboard is:

window.addEventListener('keydown', (e) => {
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
  const key = e.key.toLowerCase();
  if (key === 'c') { e.preventDefault(); void draw.copy(); }
  if (key === 'x') { e.preventDefault(); void draw.cut(); }
  if (key === 'v') { e.preventDefault(); void draw.paste(); }
});

A paste inserts fresh objects with fresh ids in a single undo step, offset from the original, and a cut deletes only once the clipboard write has succeeded. Foreign text pastes nothing rather than throwing. See Drawing Clipboard for the payload format, the validation rules and the failure modes.

Text and labels

The text tool draws a standalone text box, but shapes carry labels too: give a rectangle, ellipse, or parallel channel a text block and it renders inside the outline. That is one shape with two colours: style.color strokes the outline, text.color paints the label. Text is its own block on the drawing, drawing.text (a DrawingText), not a set of keys on style, so a host can tell a label colour from a stroke colour without knowing the tool.

text keyApplies toNotes
valuetext tool, shapes\n starts a new line
colorbothfalls back to style.color
fontSizebothmedia px, default 12 (the text tool’s defaultText sets 14)
fontFamilybothdefaults to the UI sans stack
bold, italicbothbooleans
alignboth'left' | 'center' | 'right'
valignboth'top' | 'middle' | 'bottom'
positionshapes only'inside' the outline or 'outside' just above it
background, backgroundColor, backgroundOpacitytext toolfilled plate behind the text
border, borderColortext toolstroked plate border
wrap, wrapWidthbothsoft-wrap instead of running off the pane
// `add` takes a single drawing object (id is generated when omitted).
draw.add({
  tool: 'rectangle',
  points: [a, b],
  paneIndex: 0,
  style: { color: '#8b5cf6' },
  text: {
    value: 'Supply zone',
    color: '#ffffff',
    fontSize: 13,
    bold: true,
    position: 'inside',
    valign: 'top',
    align: 'center',
  },
});
 
// Later edits patch the block: `text` merges, so send only the key that changed.
draw.update(id, { text: { value: 'Supply zone (tested)' } });
💡

With position: 'inside', valign x align give the nine placements a shape-text properties panel exposes. 'outside' parks the block above the shape and ignores valign. A tool merges its defaultText under the caller’s block the way defaultStyle merges under style (text and callout ship one), and drawingSettingsSchema(toolId).textIsContent is true for the tools that are their text, so a host can ask for the content on placement. The yfinance example wires all of it to a properties bar.

Coming from 1.9.x, where these lived on style as text, fontColor, fontWeight, fontStyle, textAlign, textVAlign and textPosition: a saved layout is upgraded on load by migrateDrawings, and the key-by-key mapping is in Migrating to 2.0.

Persistence

Drawings round-trip through the chart state with no extra plumbing:

localStorage.setItem('layout', JSON.stringify(chart.getState()));   // includes drawings
 
// later
chart.restoreState(JSON.parse(localStorage.getItem('layout')));
const draw = new DrawingController(chart);   // picks them up from the state

See Chart State for the rest of the payload.

Building your own tool

A tool is a descriptor, like a chart type or an indicator. draw receives anchors already in device px; distance receives them in media px, the same space as the cursor.

import { registerDrawingTool, distToSegment } from 'openalgo-charts/draw';
 
registerDrawingTool({
  id: 'zigzag',
  name: 'Zig Zag',
  points: 3,
  defaultStyle: { color: '#f5a623', lineWidth: 2 },
  draw: ({ ctx, pts, style, rc }) => {
    ctx.strokeStyle = style.color;
    ctx.lineWidth = style.lineWidth * rc.dpr;
    ctx.beginPath();
    ctx.moveTo(pts[0].x, pts[0].y);
    for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
    ctx.stroke();
  },
  // Return a distance in media px, or null for a miss. 0 means "inside".
  distance: (x, y, { pts }) => Math.min(
    distToSegment(x, y, pts[0], pts[1]),
    distToSegment(x, y, pts[1], pts[2]),
  ),
});
 
draw.setTool('zigzag');

Geometry helpers are exported: distToSegment, distToLine, distToPolyline, distToRect, distToEllipse, rectOf, extendSegment.

Two optional fields change how a tool is placed:

FieldEffect
freehand: trueSample the cursor while the pointer is held and commit on release: one press-drag-release is one stroke. Only the first and last anchors get grab handles. Requires points: 0.
expand(clicked, ctx)Turn the anchors actually clicked into the tool’s full anchor set, so it can place a complete, editable default from fewer clicks. ctx carries barSeconds and visibleBars, and toPixel / fromPixel when the host can map coordinates, so a default can be sized in pixels and fall back to chart units when it cannot.
constrain(points, handle)Keep the anchors consistent after one moves: a handle drag (handle is its index) or a points patch (handle is null). Returns the anchors to store. Pure. The position tools use it to hold the stop and the target on opposite sides of the entry.

expand is how a tool drops a ready default from fewer clicks than its anchor count; the position tools derive the stop from the entry and target clicks this way. A one-click box:

registerDrawingTool({
  id: 'my-box',
  name: 'My Box',
  points: 1,                     // one click places it...
  expand: (clicked, { barSeconds, visibleBars }) => {
    const p = clicked[0];
    const span = barSeconds * Math.max(5, Math.round(visibleBars * 0.08));
    // ...but the drawing has three anchors, each a draggable handle.
    return [p, { time: p.time + span, price: p.price * 1.01 },
               { time: p.time + span, price: p.price * 0.99 }];
  },
  draw: (c) => { /* c.drawing.points has all three */ },
  distance: () => null,
});

Size the default against visibleBars, not a fixed bar count: a fixed count is a hairline when zoomed out and fills the pane when zoomed in.

Building a toolbar

The controller is deliberately headless, so a host toolbar is a handful of buttons. The yfinance example ships a full one: a vertical tool rail, a floating properties bar that appears on selection (colour grid, opacity, thickness, line style, lock, duplicate, delete) and can be dragged anywhere, plus Delete / Esc / Ctrl-Z key handling.

// tool rail
for (const tool of registeredDrawingTools()) {
  button(tool.name).onclick = () => draw.setTool(tool.id);
}
 
// properties bar on selection
chart.on('draw:select', ({ id }) => showPropertiesBar(id));
 
// keyboard
window.addEventListener('keydown', (e) => {
  if (e.key === 'Delete') { const id = draw.selected(); if (id) draw.remove(id); }
  if (e.key === 'Escape') draw.setTool(null);
  if ((e.ctrlKey || e.metaKey) && e.key === 'z') e.shiftKey ? draw.redo() : draw.undo();
});

Plot clipping and moved axes (2.1.6)

Drawing bodies, previews, selection handles and snap rings are clipped to their pane’s plot. Anchors remain valid beyond the newest candle; clipping does not truncate saved drawing coordinates. Moving the primary price scale to the left keeps drawing placement, rendering and hit testing on the same price scale. PrimitiveRenderContext.readoutPriceScale exposes that primary scale for custom primitives; priceScale retains the existing right-scale contract.

Dense geometry labels use separate rows to stay readable. At extreme density, labels without available space are omitted; zoom in or disable unneeded levels to expose their values. The saved geometry and its hit regions remain exact.