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.
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 itThe tools
85 built-in tools, all registered when the tier is imported.
| Group | id | Anchors |
|---|---|---|
| Lines | trend-line, ray, extended-line, arrow, info-line, trend-angle | 2 |
| Horizontal / vertical | horizontal-line, horizontal-ray, vertical-line, cross-line | 1 |
| Shapes | rectangle, ellipse, circle | 2 |
| Shapes | triangle, rotated-rectangle | 3 |
| Paths | path, polyline; arc, curve, double-curve | n; 3 |
| Channels | parallel-channel, fib-channel, flat-top-bottom; disjoint-channel; regression-channel | 3; 4; 2 |
| Pitchforks | pitchfork, schiff-pitchfork, modified-schiff-pitchfork, inside-pitchfork | 3 |
| Fibonacci levels | fib-retracement, fib-extension-two-point; fib-extension | 2; 3 |
| Fibonacci time | fib-time-zone; trend-fib-time | 2; 3 |
| Fibonacci geometry | fib-fan, fib-speed-resistance-fan, fib-circles, fib-speed-resistance-arcs, fib-spiral; fib-wedge | 2; 3 |
| Gann | gann-fan, gann-box, gann-square | 2 |
| Geometric studies | dedekind-tessellation, sonic, supersonic, golden-sonic, golden-supersonic | 2 |
| Harmonic patterns | xabcd-pattern, gartley, bat, butterfly, crab, shark, cypher; abcd-pattern | 5; 4 |
| Wave patterns | elliott-impulse; elliott-correction; head-shoulders | 5; 3; 7 |
| Forecasting | long-position, short-position; forecast | 3; 2 |
| Measurers | price-range, date-range, measure | 2 |
| Marks | arrow-up, arrow-down, arrow-left, arrow-right, icon-stamp | 1 |
| Cycles | cyclic-lines, time-cycles, sine-line | 2 |
| Text and notes | text, price-label, flag-mark, note, balloon, comment, signpost, price-note, table; callout | 1; 2 |
| Brushes | brush, highlighter | n |
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 neededBefore 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
| Member | Description |
|---|---|
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' }(ortrue) 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:
| Shortcut | Tool |
|---|---|
Alt+T | Trend line |
Alt+H | Horizontal line |
Alt+J | Horizontal ray |
Alt+V | Vertical line |
Alt+C | Cross 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 key | Applies to | Notes |
|---|---|---|
value | text tool, shapes | \n starts a new line |
color | both | falls back to style.color |
fontSize | both | media px, default 12 (the text tool’s defaultText sets 14) |
fontFamily | both | defaults to the UI sans stack |
bold, italic | both | booleans |
align | both | 'left' | 'center' | 'right' |
valign | both | 'top' | 'middle' | 'bottom' |
position | shapes only | 'inside' the outline or 'outside' just above it |
background, backgroundColor, backgroundOpacity | text tool | filled plate behind the text |
border, borderColor | text tool | stroked plate border |
wrap, wrapWidth | both | soft-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 stateSee 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:
| Field | Effect |
|---|---|
freehand: true | Sample 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.