Layers
A layer is a chart primitive you script yourself: keyed rows computed in onBar, drawn by your own paint (Canvas 2D) or paintGL (WebGL). Profiles, footprints, heatmaps, drawing tools and whole chart types are layers. One file holds both halves; rows are the only thing that crosses from the worker to the chart.
The row model, lifecycle and reading rows from other scripts are on Layers & rows. The render context, coordinates and WebGL are on Painting a layer. This page is the indicator surface and four complete layers.
What you register decides what the layer is
You register | It becomes |
|---|---|
rows only | A data output other scripts read with |
| Something drawn on the chart. |
| A drawing tool under Layers in the drawing toolbar. Rows it creates get |
| A chart type: candles hidden, an entry under Library in the chart-type picker. |
| Rows with a lifetime ( |
The rules
Write rows in the worker.
upsert,patch,sealandremovework inonBar,onRunand gesture handlers. Inlayout,paint,paintGL,hit,autoscaleandlegendthey throwframe hooks are read-only. Never write inside atimeframe(...)handler.Rows are plain data. Numbers, strings, booleans, plain objects and arrays,
Float32Array,bins,grid.Renderers see rows and params, never
ctx. They run on the main thread. No module-level mutable state: usep.stateper instance andp.cachefor memoised work. A pure helper function at module scope is fine.
Where each computation goes
The worker sees every bar and no viewport. The main thread sees the viewport and only rows. The forming bar re-runs on every tick, and paint runs every frame under a 4 ms budget. So:
A value that depends only on bars is a row field, written in
onBar.A value that depends on pixels (a bin's height, whether a label fits) is decided in
paintthrough thephelpers.A value that depends on what is on screen is merged on the main thread from rows the worker wrote at a smaller unit (a session, a bar).
layoutreceives every row and does not re-run on scroll, so a merge of "what is visible" belongs inpaint, memoised inp.cacheon the visible bar range. The first example does this.
Anything per price level is a container: bins(step) is a number per price bin, grid(step) is presence per price bin per column. step is ctx.instrument.tickSize * n, always. Keep the container in ctx.accum, call begin(ctx.barIndex) before add so a replay of the forming bar counts once, and upsert the same row every bar. Never key an object by String(price) and never loop price += tick.
Read a container with at(price), atIndex(i), lo, hi, count, max, sum, argmax() (lower edge of the fullest bin), valueArea(poc, pct) ([val, vah]), priceOf(i), indexOf(price) and entries(). A grid adds cols, has(col, price), isSet(col, i), marks() and counts, a read-only bins of how many columns touched each bin. On the main thread and downstream, rows carry read-only views; add, mark and begin throw there.
Claims
Every layer that paints declares claims: { rowsPer, seals }: what one row is and whether rows seal. The bench checks the dry run's rows against the claim before it grades the paint, and a mismatch fails the save.
| One row is | The bench fails when |
|---|---|---|
| one session | there are no rows, or more rows than days + 1 |
| one bar | rows are fewer than 80% of bars |
| something the script detected | there are no rows, or more rows than half the bars |
| something a person drew | there is no |
| the unit a per-screen result is merged from | there is no |
seals: true needs at least one sealed row once there are two; seals: false needs none.
Chart types
meta({ kind: "chart" }) lists the script under Library in the chart-type picker. It needs at least one layer with paint or paintGL. Choosing it hides the candles (the eye toggle on the primary chart-type row still works), keeps the time scale, crosshair and autoscale, and scopes one instance of the script to the chart. Chart-type layers are exclusive, like footprint. If the script is deleted, the chart falls back to candlesticks on the next load with a toast. Register autoscale so the price scale follows your rows once the candles are gone.
Instrument and chart
ctx.instrument (symbol, displaySymbol, tickSize, precision, multiplier, assetClass, category, sessions) and ctx.chart (timeframe, timeframeSeconds, chartType) exist in onBar and in the Go runtime. Renderers get the same objects as p.instrument and p.chart, plus p.chart.barSpacing. tickSize falls back to the asset class default, then 0.01, so it never throws.
Example: a visible-range profile in the right gutter
Volume at price for whatever is on screen, drawn against the right edge of the pane. The worker writes one row per session holding a bins, because it cannot see the viewport. paint merges the visible sessions and caches the merge per visible bar range, because layout does not re-run on scroll.
meta({ shortName: "VRVP", kind: "overlay", dataSource: "volume_candles" });
dialog({ title: "Visible range profile", description: "Volume at price for the sessions on screen, drawn in the right gutter." });
const rowTicks = input.number({ id: "rowTicks", label: "Ticks per row", default: 4, min: 1, step: 1 });
const widthPct = input.number({ id: "widthPct", label: "Width (% of pane)", default: 25, min: 5, max: 60, step: 1, unit: "%" });
const vaPct = input.number({ id: "vaPct", label: "Value area %", default: 70, min: 1, max: 100, step: 1, unit: "%" });
const color = input.color({ id: "color", label: "Profile", default: "#5c6bc0" });
warmup((w) => w.day()); // one session per row
layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, rowTicks, widthPct, vaPct, color)));
const days = output.layer({
id: "days",
key: "start",
row: { start: "number" },
derived: { end: "number", vol: "bins" },
name: "Visible range profile",
time: { start: "start", end: "end" },
claims: { rowsPer: "session", seals: true },
});
/** @typedef {{ start: number, end: number, vol: Bins }} DayRow */
/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
const step = ctx.instrument.tickSize * Number(ctx.params.rowTicks);
const s = ctx.session({ period: "daily", tz: "UTC" });
const previous = days.last();
if (previous && previous.start !== s.start && previous.closedAt === undefined) days.seal(previous.start);
if (!s.isOpen) return;
const st = ctx.accum("day", () => ({ row: /** @type {DayRow | null} */ (null) }), (x) => x);
if (!st.row || st.row.start !== s.start) st.row = { start: s.start, end: ctx.time, vol: bins(step) };
const r = st.row;
r.vol.begin(ctx.barIndex);
for (const lvl of ctx.volumePriceLevels) r.vol.add(lvl.price, lvl.totalVolume);
r.end = ctx.time;
days.upsert(r);
}
days.paint((p, rows) => {
const first = rows.find((r) => r.vol && r.vol.count > 0);
if (!first || !first.vol) return;
const key = `${p.visible.fromIndex}:${p.visible.toIndex}`;
let merged = /** @type {Bins | undefined} */ (p.cache.get(key));
if (!merged) {
merged = bins(first.vol.step, first.vol.origin);
for (const r of rows) if (r.vol) for (const [price, v] of r.vol.entries()) merged.add(price, v);
p.cache.clear();
p.cache.set(key, merged);
}
const m = merged;
if (m.count === 0) return;
const poc = m.argmax();
const [val, vah] = m.valueArea(poc, Number(p.params.vaPct));
const fill = String(p.params.color);
const maxW = p.width * Number(p.params.widthPct) / 100;
for (const row of p.rowsOf(m, { minPx: 1 })) {
const inVa = row.price >= val && row.price < vah;
p.alpha(inVa ? 0.7 : 0.35, () => {
p.hbar(row, { at: p.width, w: p.norm(row.value, m.max * (row.hi - row.lo + 1), maxW), anchor: "right", fill });
});
}
p.priceLine(poc + m.step / 2, { color: fill, width: 2, label: "VPOC" });
});
days.legend((p, rows) => `${rows.length} session${rows.length === 1 ? "" : "s"} in view`);Example: a volume heatmap drawn with GL cells
Every bar becomes a column of cells, one per price bin, shaded by the volume traded there. There are tens of thousands of cells on screen, so they go to paintGL as packed rectangles; g.packed needs no custom shader and is not an escape hatch.
meta({ shortName: "Heat", kind: "overlay", dataSource: "volume_candles" });
dialog({ title: "Volume heatmap", description: "Volume at price per bar, as shaded cells behind the candles." });
const rowTicks = input.number({ id: "rowTicks", label: "Ticks per cell", default: 4, min: 1, step: 1 });
const keep = input.number({ id: "keep", label: "Bars kept", default: 3000, min: 500, max: 4500, step: 500 });
const hot = input.color({ id: "hot", label: "Colour", default: "#ff7043" });
warmup((w) => w.window(keep)); // the bars it keeps
layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, rowTicks, keep, hot)));
const heat = output.layer({
id: "heat",
key: "start",
row: { start: "number" },
derived: { end: "number", vol: "bins" },
name: "Volume heatmap",
time: { start: "start", end: "end" }, // a per-bar row needs an end, or every older row stays "visible"
claims: { rowsPer: "bar", seals: false },
});
/**
* "#rrggbb" to [r, g, b] in 0..1. Pure, so module scope is fine.
* @param {unknown} hex
*/
function rgb(hex) {
const n = parseInt(String(hex).slice(1, 7), 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
const st = ctx.accum("heat", () => ({
t: NaN,
vol: /** @type {Bins | null} */ (null),
keys: /** @type {number[]} */ ([]),
}), (s) => s);
if (st.t !== ctx.time || !st.vol) {
st.t = ctx.time;
st.vol = bins(ctx.instrument.tickSize * Number(ctx.params.rowTicks));
st.keys.push(ctx.time);
if (st.keys.length > Number(ctx.params.keep)) heat.remove(st.keys.shift());
}
st.vol.begin(ctx.barIndex);
for (const lvl of ctx.volumePriceLevels) st.vol.add(lvl.price, lvl.totalVolume);
heat.upsert({ start: ctx.time, end: ctx.time + ctx.chart.timeframeSeconds, vol: st.vol });
}
// GLCtx has no params: hand paintGL what it needs through layout.
heat.layout((p) => ({ rgb: rgb(p.params.hot) }));
heat.paintGL((g, rows) => {
const L = /** @type {{ rgb?: number[] }} */ (g.layout);
const c = L.rgb;
if (!c) return;
let max = 0;
let cells = 0;
for (const r of rows) if (r.vol) { max = Math.max(max, r.vol.max); cells += r.vol.count; }
if (max <= 0) return;
// One scratch buffer per instance, grown when needed, instead of a new array every frame.
const scratch = g.resource("scratch", () => ({ f32: new Float32Array(0) }), () => {});
if (scratch.f32.length < cells * 8) scratch.f32 = new Float32Array(cells * 16);
const out = scratch.f32;
const { hpr, vpr } = g.viewport;
let i = 0;
for (const r of rows) {
if (!r.vol || r.end === undefined) continue;
const half = (g.x(r.end) - g.x(r.start)) / 2;
const left = (g.x(r.start) - half) * hpr;
const width = Math.max(1, 2 * half * hpr);
for (const [price, v] of r.vol.entries()) {
const top = g.y(price + r.vol.step) * vpr;
const bottom = g.y(price) * vpr;
const a = 0.85 * (v / max);
out[i++] = left; out[i++] = top; out[i++] = width; out[i++] = Math.max(1, bottom - top);
out[i++] = c[0] * a; out[i++] = c[1] * a; out[i++] = c[2] * a; out[i++] = a; // premultiplied
}
}
g.packed(out.subarray(0, i), i / 8);
}, { layer: "underlay" });Example: Heikin-Ashi as a chart type
A chart type draws the whole chart itself. Each Heikin-Ashi bar depends on the previous one, so the worker computes one row per bar and remembers the previous bar's time, which keeps the forming bar's replays from reading its own row.
meta({ shortName: "HA", kind: "chart" });
dialog({ title: "Heikin-Ashi", description: "Heikin-Ashi candles in place of the chart's own." });
const keep = input.number({ id: "keep", label: "Bars kept", default: 4000, min: 500, max: 4500, step: 500 });
warmup((w) => w.forever()); // each bar's open depends on every bar before it
layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, keep)));
const candles = output.layer({
id: "ha",
key: "start",
row: { start: "number" },
derived: { end: "number", open: "number", high: "number", low: "number", close: "number" },
name: "Heikin-Ashi",
time: { start: "start", end: "end" },
claims: { rowsPer: "bar", seals: false },
});
/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
const st = ctx.accum("ha", () => ({ t: NaN, prevT: NaN, keys: /** @type {number[]} */ ([]) }), (s) => s);
if (st.t !== ctx.time) {
st.prevT = st.t;
st.t = ctx.time;
st.keys.push(ctx.time);
if (st.keys.length > Number(ctx.params.keep)) candles.remove(st.keys.shift());
}
const prev = Number.isNaN(st.prevT) ? undefined : candles.get(st.prevT);
const close = (ctx.open + ctx.high + ctx.low + ctx.close) / 4;
const open = prev && prev.open !== undefined && prev.close !== undefined ? (prev.open + prev.close) / 2 : (ctx.open + ctx.close) / 2;
candles.upsert({
start: ctx.time,
end: ctx.time + ctx.chart.timeframeSeconds,
open,
close,
high: Math.max(ctx.high, open, close),
low: Math.min(ctx.low, open, close),
});
}
candles.paint((p, rows) => {
const bodyW = Math.max(1, p.chart.barSpacing * 0.7);
for (const r of rows) {
if (r.open === undefined || r.close === undefined || r.high === undefined || r.low === undefined) continue;
const color = r.close >= r.open ? p.theme.upColor : p.theme.downColor;
const x = p.snap(p.x(r.start));
p.line(x, p.y(r.high), x, p.y(r.low), color, 1);
const { top, h } = p.spanY(r.open, r.close);
p.rect(x - bodyW / 2, top, bodyW, Math.max(1, h), color);
}
});
candles.autoscale((p, rows) => {
let min = Infinity;
let max = -Infinity;
for (const r of rows) {
if (r.low !== undefined) min = Math.min(min, r.low);
if (r.high !== undefined) max = Math.max(max, r.high);
}
return Number.isFinite(min) && Number.isFinite(max) ? { min, max, marginTop: 0.08, marginBottom: 0.08 } : null;
});
candles.legend((p, rows) => {
const last = rows[rows.length - 1];
return last && last.close !== undefined ? `HA close ${last.close.toFixed(p.instrument.precision)}` : null;
});Example: a risk-reward tool you draw
A drawing tool: drag from the entry price to the stop, and the layer draws the risk and the target at your R multiple. Everything it draws comes from the row's own fields and the settings, so onBar has nothing to compute. Parts in layout bounds (#stop, #edge) let a drag move the stop or the right edge instead of the whole box.
meta({ shortName: "R:R", kind: "overlay" });
dialog({ title: "Risk-reward", description: "Drag from entry to stop; the target is drawn at your R multiple." });
const rr = input.number({ id: "rr", label: "Target (R)", default: 2, min: 0.5, step: 0.5 });
const riskColor = input.color({ id: "riskColor", label: "Risk", default: "#ef5350" });
const rewardColor = input.color({ id: "rewardColor", label: "Reward", default: "#26a69a" });
layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, rr, riskColor, rewardColor)));
const trades = output.layer({
id: "trades",
row: {
entry: { type: "number", label: "Entry" }, // the object form labels the double-click dialog
stop: { type: "number", label: "Stop" },
t1: "number",
t2: "number",
},
name: "Risk-reward",
time: { start: "t1", end: "t2" },
persist: { version: 1 },
claims: { rowsPer: "gesture", seals: false },
});
/** Every script exports a hook; this tool's rows need no worker work. */
export function onBar() {}
trades.gesture({
// One gesture keeps one id across start, move and end, so each call replaces the same row.
create(g) {
trades.upsert({
id: g.newId(),
entry: g.start.price,
stop: g.end.price,
t1: Math.min(g.start.time, g.end.time),
t2: Math.max(g.start.time, g.end.time),
});
},
// `row` is the row as it was when the drag began; g.dt and g.dprice are cumulative from there.
drag(g, row) {
const part = g.hit ? g.hit.part : undefined;
if (part === "stop") trades.patch(row.id, { stop: row.stop + g.dprice });
else if (part === "edge") trades.patch(row.id, { t2: Math.max(row.t1, row.t2 + g.dt) });
else trades.patch(row.id, { entry: row.entry + g.dprice, stop: row.stop + g.dprice, t1: row.t1 + g.dt, t2: row.t2 + g.dt });
},
});
trades.layout((p, rows) => {
/** @type {Record<string, AnchoredBox>} */
const bounds = {};
const mult = Number(p.params.rr);
for (const r of rows) {
const target = r.entry + (r.entry - r.stop) * mult;
bounds[r.id] = { time: r.t1, price: Math.max(r.stop, target), time2: r.t2, price2: Math.min(r.stop, target) };
bounds[`${r.id}#stop`] = { time: r.t2, price: r.stop, dx: -5, dy: -5, w: 10, h: 10 };
bounds[`${r.id}#edge`] = { time: r.t2, price: r.entry, dx: -4, dy: -8, w: 8, h: 16 };
}
return { bounds };
});
trades.paint((p, rows) => {
const mult = Number(p.params.rr);
const risk = String(p.params.riskColor);
const reward = String(p.params.rewardColor);
for (const r of rows) {
const target = r.entry + (r.entry - r.stop) * mult;
const { x0, w } = p.spanX(r.t1, r.t2);
const riskBand = p.spanY(r.entry, r.stop);
const rewardBand = p.spanY(r.entry, target);
p.alpha(0.2, () => {
p.rect(x0, riskBand.top, w, riskBand.h, risk);
p.rect(x0, rewardBand.top, w, rewardBand.h, reward);
});
const active = p.hover === r.id || p.selected === r.id;
p.line(x0, p.y(r.entry), x0 + w, p.y(r.entry), p.theme.textColor, active ? 2 : 1);
const ticks = Math.round(Math.abs(r.entry - r.stop) / p.instrument.tickSize);
const label = `${mult}R ${target.toFixed(p.instrument.precision)} (${ticks} ticks risk)`;
const font = p.fit(label, w, p.theme.fontSize + 8);
if (font) p.textBox(label, x0 + w / 2, p.y(target), { font, align: "center", baseline: "middle", background: reward, color: p.theme.background, padding: 2 });
if (active) {
const stopBox = p.box(p.layout.bounds[`${r.id}#stop`]);
p.rect(stopBox.left, stopBox.top, stopBox.right - stopBox.left, stopBox.bottom - stopBox.top, risk);
}
}
});With the tool active, a click-and-drag on an empty part of the chart calls create. A drag on an existing row calls drag; a click calls click; a double-click calls dblclick and opens a dialog generated from the row schema. Escape during a gesture restores the rows as they were. Backspace removes a selected row, and Remove drawings clears them. Drawn rows are filed per instrument, mirror across panes on the same instrument, and survive reload. Changing one re-runs the script from its first bar, so onBar can compute derived fields for drawn rows; hooks may fill derived fields of a drawn row but never change its row fields or remove it.
Verifying without eyes
typecheck, then dry_run_indicator (layers[id]: row_count, live_count, sealed_count, a row tail), then bench_layer (the paint executed and graded at three zooms; details on Painting a layer), then wait_for_preview and inspect_preview with include: ["layers"] for rows, frame time and breaker state on the real chart. See Agents.
