ChartnautDocs

Painting a layer

The render half of a layer runs on the main thread inside one host primitive per pane. You register functions on the layer handle; the host owns scheduling, culling, memoisation, hit testing and the frame budget. Everything here is typed in globals/render.d.ts, which the editor loads and read_indicator_dsl serves. Declaring layers and writing rows is on Layers.

The registrations

tpo.layout((p, rows) => ({ bounds: { [id]: { time, price, time2, price2 } }, ...yourGeometry }))
tpo.paint((p, rows) => { /* Canvas 2D */ })
tpo.paintGL((g, rows) => { /* WebGL 2 */ }, { layer: "underlay" })
tpo.hit((p, rows, x, y) => ({ id, part, cursor }) || null)      // optional; default hit-tests layout bounds
tpo.autoscale((p, rows) => ({ min, max, marginTop, marginBottom }) || null)
tpo.legend((p, rows) => "12 sessions" || null)
tpo.gesture({ create(g), drag(g, row), click(g, row), dblclick(g, row) })

Call each once, at module scope. paint, paintGL, hit, autoscale and legend receive the visible rows. layout receives every row.

Culling and the frame budget

A row is visible when its start is at or before the right edge and its end is at or after the left edge, with two bars of slack on each side. Start is the time.start field (else the key when it holds a time, else createdAt); end is the time.end field, else closedAt. A row with no finite end counts as reaching the right edge, which is right for a live level and wrong for a per-bar row: give per-bar rows an end field. p.all() returns every row and is reported as an escape hatch.

Budget

Limit

Over it

paint + paintGL per layer

4 ms per frame

Three frames over in a row (or three throws) pause the layer.

All layers on one pane

6 ms per frame

Three frames over in a row pause the costliest layer.

layout

16 ms per run

Three runs over in a row pause the layer.

A paused layer stops painting, shows layer "<id>" paused — frame budget exceeded (or the error it threw) over its area, and raises a runtime error with the frame time and its draw counts, such as "N rect and M text calls across R visible rows on an H px pane". The remedy is always the same: draw at most one item per pixel row, gate labels on size, and move dense cells to paintGL. Saving the script in the builder clears the pause and keeps rows and p.state.

layout: memoised geometry

layout runs when rows, pane size, bar spacing, theme, params, instrument or pixel ratio change. It never runs on scroll, so a pixel position computed there is stale after a pan, and so is anything built from p.visible. Times and prices stay valid. Return chart-space geometry and bounds, and convert to pixels in paint.

bounds maps a row id, or "<rowId>#<part>", to an anchored box: time, price, optional time2 / price2, and dx / dy / w / h pixel offsets. The host builds its hit index from them and resolves them per frame, so they cost nothing on scroll. Omit price and price2 on a subpane with no price axis; the box then anchors to the top of the pane. Anything else you return is p.layout in paint and g.layout in paintGL.

Coordinates

Everything on p is in CSS pixels. The canvas is pre-scaled so one unit is one CSS pixel.

Call

Gives

p.x(time)

x of a bar time. Placed by bar position, so a weekend gap takes no width. Extrapolates past the loaded bars; NaN only when the chart has no bars.

p.y(price)

y of a price, on the pane's scale (log scales included).

p.timeAt(x) / p.priceAt(y)

The inverses.

p.box(anchoredBox)

A { left, top, right, bottom } rect for this frame.

p.snap(v)

v rounded to the device pixel grid, for crisp 1 px lines.

p.spanX(t0, t1 \| "edge")

{ x0, x1, w }, clamped to the pane, w >= 0. "edge" runs to the right edge.

p.spanY(p0, p1)

{ top, bottom, h }, ordered.

p.dx(seconds) / p.dy(delta, atPrice?)

The width of a duration and the height of a price delta at the current zoom.

p.bitmap((ctx, hpr, vpr) => …)

Device-pixel space: identity transform, own save / restore. An escape hatch.

p.hpr and p.vpr are the device pixel ratios. p.bars lists the visible bars with time, OHLCV, index, centre x and barWidth. p.visible is { from, to, fromIndex, toIndex }.

A number literal for a bin height, a column width or a profile width is right at one zoom and wrong at every other. Take every pixel quantity from the scale.

paint: Canvas 2D

What p carries

Group

Members

Shapes

p.rect(x, y, w, h, fill?, stroke?, lineWidth?), p.line(x1, y1, x2, y2, color, lineWidth?, dash?), p.hline(y, color, lineWidth?, dash?, gaps?), p.polyline(points, color, lineWidth?, close?), p.path(build, fill?, stroke?, lineWidth?), p.roundedRect(x, y, w, h, radius, fill?, stroke?), p.alpha(a, fn)

Text

p.text(text, x, y, opts?), p.textBox(text, x, y, opts?), p.measure(text, font?), p.font(sizePx, weight?, family?), p.fit(text, w, h, opts?)

Levels

p.priceLine(price, opts?), p.axisLabel(price, text, opts?)

Bins and cells

p.rowsOf(bins, { minPx, reduce }), p.cellsOf(grid, { x0, colW, minPx, merge }), p.hbar(row, { at, w, anchor, fill, stroke, lineWidth }), p.norm(v, max, w)

Rasters and time

p.offscreen(key, w, h, draw), p.blit(image, x, y, w?, h?), p.animate(ms?)

Raw context

p.ctx: the real 2D context, with gradients, arcs, Béziers, clipping and transforms

Frame

width, height, hpr, vpr, visible, bars, params, instrument, chart (with barSpacing), theme, state, cache, hover, selected, layout, log(...), inRect(x, y, rect)

p.theme has mode, fontFamily, fontSize, textColor, background, upColor, downColor, gridColor, crosshairColor and selectionColor. p.state survives frames for this instance; p.cache is a Map cleared whenever rows or the theme change. p.log writes to the builder's log, deduplicated per frame.

Every callback runs inside a fence: the host saves the context, and afterwards resets the transform, dash, alpha and composite mode, even if you threw or popped one restore too many. There is no DOM, no timer and no requestAnimationFrame; drawing outside the pane is clipped.

Text

p.text and p.textBox take { color, font, align, baseline, maxWidth, background, padding, shadow } and return the width or the pill rect. Default font is the chart's. p.fit(text, w, h) returns a font that fits the slot, or null below 7 px (minPx and maxPx change the bounds): gate every label on it instead of a pixel threshold. p.priceLine(price, { color, width, dash, label, span, axisLabel, textColor, background }) draws a level with a label pill after your paint returns, leaving gaps around other price-line labels on the same y; axisLabel defaults to true when there is a label. p.axisLabel(price, text, { color, textColor, key }) shows a tag on the price axis for this frame; call it every frame you want it.

Paths, polylines and gradients

p.polyline takes a flat array: [x0, y0, x1, y1, …]. An array of { x, y } objects compiles, passes the bench's paint checks and draws nothing, because every coordinate is NaN; the bench reports it as nanCoords. p.path((c) => { c.moveTo(…); c.bezierCurveTo(…); }, fill, stroke, lineWidth) builds one path and fills or strokes it. For gradients use the raw context: p.ctx.createLinearGradient(...) or createRadialGradient(...), then p.ctx.fillStyle = gradient.

Bins and cells

Loop over p.rowsOf and p.cellsOf, never over raw bins. p.rowsOf(bins, { minPx: 1 }) yields pixel rows { top, h, value, max, lo, hi, price } with bins merged so none is under minPx, and never more rows than the pane has pixel rows. p.cellsOf(grid, { x0, colW, minPx: 3 }) yields cells { x, top, w, h, col, bin, letter } only when a column and a row both reach minPx, and nothing otherwise. p.hbar(row, { at, w, anchor: "right" }) draws one pixel row as a bar ending at at.

for (const row of p.rowsOf(r.vol, { minPx: 1 })) p.hbar(row, { at: x1, w: p.norm(row.value, r.vol.max * (row.hi - row.lo + 1), span * 0.3), anchor: "right", fill });
for (const c of p.cellsOf(r.tpo, { x0, colW: p.dx(r.periodSec), minPx: 3 })) p.rect(c.x, c.top, c.w, c.h, fill);

Rasters and animation

p.offscreen(key, w, h, draw) renders draw once into a cached raster and returns it; the cache is invalidated when rows, theme, size or pixel ratio change and destroyed when the layer is removed. Draw it with p.blit(image, x, y). Use it for something expensive that does not move with the chart, such as a legend badge. p.animate(ms) asks for more frames for up to ms; the host runs one animation loop for every layer.

Canvas 2D examples

An opening range with a gradient fill

The first 30 minutes of each session as a box shaded darker at its edges, with dashed extensions for the rest of the session and axis tags on the live one. The gradient comes from the raw context; everything else is a helper.

meta({ shortName: "OR", kind: "overlay" });

dialog({ title: "Opening range", description: "The session's opening range as a shaded box with extensions." });

const orMinutes = input.number({ id: "orMinutes", label: "Opening range (minutes)", default: 30, min: 5, step: 5 });
const color = input.color({ id: "color", label: "Colour (#rrggbb)", default: "#42a5f5" });
warmup((w) => w.day()); // one session

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, orMinutes, color)));

const ranges = output.layer({
  id: "or",
  key: "start",
  row: { start: "number" },
  derived: { end: "number", orEnd: "number", high: "number", low: "number" },
  name: "Opening range",
  time: { start: "start", end: "end" },
  claims: { rowsPer: "session", seals: true },
});

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const s = ctx.session({ period: "daily", open: { hour: 9, minute: 30 }, close: { hour: 16 }, tz: "America/New_York" });
  const previous = ranges.last();
  if (previous && previous.start !== s.start && previous.closedAt === undefined) ranges.seal(previous.start);
  if (!s.isOpen) return;

  const orEnd = s.start + Number(ctx.params.orMinutes) * 60;
  const cur = ranges.get(s.start);
  const hi = cur ? cur.high ?? ctx.high : ctx.high;
  const lo = cur ? cur.low ?? ctx.low : ctx.low;
  const inRange = ctx.time < orEnd;
  ranges.upsert({
    start: s.start,
    end: ctx.time + ctx.chart.timeframeSeconds,
    orEnd,
    high: inRange ? Math.max(hi, ctx.high) : hi,
    low: inRange ? Math.min(lo, ctx.low) : lo,
  });
}

ranges.paint((p, rows) => {
  const color = String(p.params.color);
  for (const r of rows) {
    if (r.high === undefined || r.low === undefined || r.orEnd === undefined || r.end === undefined) continue;
    const box = p.spanX(r.start, r.orEnd);
    const band = p.spanY(r.high, r.low);
    if (box.w > 0 && band.h > 0) {
      const grad = p.ctx.createLinearGradient(0, band.top, 0, band.bottom);
      grad.addColorStop(0, color + "80");
      grad.addColorStop(0.5, color + "14");
      grad.addColorStop(1, color + "80");
      p.ctx.fillStyle = grad;
      p.ctx.fillRect(box.x0, band.top, box.w, band.h);
    }

    const live = r.closedAt === undefined;
    const ext = p.spanX(r.orEnd, live ? "edge" : r.end);
    p.line(ext.x0, p.snap(band.top), ext.x1, p.snap(band.top), color, 1, [4, 4]);
    p.line(ext.x0, p.snap(band.bottom), ext.x1, p.snap(band.bottom), color, 1, [4, 4]);

    const label = `OR ${Math.round((r.high - r.low) / p.instrument.tickSize)} ticks`;
    const font = p.fit(label, box.w, p.theme.fontSize + 6);
    if (font) p.textBox(label, box.x0 + box.w / 2, band.top - 2, { font, align: "center", baseline: "bottom", background: color, color: p.theme.background, padding: 2 });
    if (live) {
      p.axisLabel(r.high, "ORH", { color, key: `${r.id}-h` });
      p.axisLabel(r.low, "ORL", { color, key: `${r.id}-l` });
    }
  }
});

A swing zigzag drawn as one segment per row

Each confirmed swing is a row that carries the previous swing too, so each row draws its own leg. time: { start: "from", end: "start" } makes a leg visible whenever any part of it is on screen, which a polyline over the visible pivots would miss at the edges.

meta({ shortName: "ZigZag", kind: "overlay" });

dialog({ title: "Swing zigzag", description: "Confirmed swing highs and lows joined into legs, labelled HH, LH, HL and LL." });

const depth = input.number({ id: "depth", label: "Bars each side of a swing", default: 5, min: 1, step: 1 });
const lineColor = input.color({ id: "lineColor", label: "Line", default: "#90a4ae" });
warmup((w) => w.forever()); // each label compares with the previous swing, however far back

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, depth, lineColor)));

const legs = output.layer({
  id: "legs",
  key: "start",
  row: { start: "number", from: "number" },
  derived: { price: "number", fromPrice: "number", side: { enum: ["high", "low"] }, label: "string" },
  name: "Swing legs",
  time: { start: "from", end: "start" },
  claims: { rowsPer: "event", seals: false },
});

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const n = Number(ctx.params.depth);
  const st = ctx.accum("zz", () => ({
    t: /** @type {number[]} */ ([]),
    h: /** @type {number[]} */ ([]),
    l: /** @type {number[]} */ ([]),
    keys: /** @type {number[]} */ ([]),
    last: /** @type {{ t: number, price: number } | null} */ (null),
    lastHigh: NaN,
    lastLow: NaN,
  }), (s) => s);

  const k = st.t.length - 1;
  if (k >= 0 && st.t[k] === ctx.time) return;            // forming bar again: nothing new has closed
  st.t.push(ctx.time); st.h.push(ctx.high); st.l.push(ctx.low);
  if (st.t.length > 2 * n + 2) { st.t.shift(); st.h.shift(); st.l.shift(); }
  if (st.t.length < 2 * n + 2) return;

  // Entries 0..2n are closed; the candidate is the middle one.
  let isHigh = true;
  let isLow = true;
  for (let i = 0; i <= 2 * n; i++) {
    if (i === n) continue;
    if (st.h[i] >= st.h[n]) isHigh = false;
    if (st.l[i] <= st.l[n]) isLow = false;
  }
  if (isHigh === isLow) return;                           // neither, or an outside bar that is both

  const start = st.t[n];
  const price = isHigh ? st.h[n] : st.l[n];
  let label = "";
  if (isHigh) { label = Number.isNaN(st.lastHigh) ? "" : price > st.lastHigh ? "HH" : "LH"; st.lastHigh = price; }
  else { label = Number.isNaN(st.lastLow) ? "" : price > st.lastLow ? "HL" : "LL"; st.lastLow = price; }

  const prev = st.last;
  legs.upsert({ start, from: prev ? prev.t : start, price, fromPrice: prev ? prev.price : price, side: isHigh ? "high" : "low", label });
  st.last = { t: start, price };
  st.keys.push(start);
  if (st.keys.length > 2000) legs.remove(st.keys.shift());
}

legs.paint((p, rows) => {
  const color = String(p.params.lineColor);
  const font = p.fit("HH", p.dx(p.chart.timeframeSeconds * 6), p.theme.fontSize + 4);
  const arrow = p.theme.fontSize / 2;
  for (const r of rows) {
    if (r.price === undefined || r.fromPrice === undefined) continue;
    const x = p.x(r.start);
    const y = p.y(r.price);
    p.line(p.x(r.from), p.y(r.fromPrice), x, y, color, 1.5);
    const up = r.side === "high";
    p.path((c) => {
      c.moveTo(x, y + (up ? -arrow : arrow));
      c.lineTo(x - arrow / 2, y + (up ? -arrow * 2 : arrow * 2));
      c.lineTo(x + arrow / 2, y + (up ? -arrow * 2 : arrow * 2));
      c.closePath();
    }, up ? p.theme.downColor : p.theme.upColor);
    if (font && r.label) p.text(r.label, x, y + (up ? -arrow * 2.5 : arrow * 2.5), { font, align: "center", baseline: up ? "bottom" : "top", color });
  }
});

paintGL: WebGL 2

Scripts get WebGL through paintGL, never through a canvas of their own. Registering it makes the pane create one WebGL 2 context on a canvas that is not in the DOM. The pane renders every layer's GL into it and copies the result into its ordinary 2D canvas, because a WebGL canvas composited by the browser turns black or flickers on some Windows machines. The context uses premultiplied alpha and blends with ONE, ONE_MINUS_SRC_ALPHA.

paintGL(fn, { layer: "underlay" }) draws below the candles, the default. { layer: "overlay" } draws above them. If the pane has no GL context (lost and not recovered after its retries), paintGL does not run and paint still does.

What g carries

Member

What it is

g.viewport

{ width, height } in CSS px, { bitmapWidth, bitmapHeight } in device px, hpr, vpr.

g.x(time) / g.y(price)

CSS px, as on p. Multiply by hpr / vpr for device px.

g.visible, g.layout

The visible window and the memoised layout result.

g.rects([{ left, top, width, height, color }])

The host's instanced rectangle painter. Device px; any CSS colour, premultiplied for you.

g.packed(f32, count)

The same painter with 8 floats per rectangle: left, top, width, height, then premultiplied r, g, b, a in 0..1. Cheapest for thousands of cells.

g.program(key, spec)

Your own shader. An escape hatch.

g.raw((gl, host) => …)

The real WebGL 2 context inside a state fence. An escape hatch.

g.resource(key, create, destroy)

An object that lives across frames: created once, destroyed when the layer goes, recreated after a context loss.

g has no params, theme or chart. Hand paintGL what it needs through the layout result, which re-runs when params, theme or zoom change.

Custom shaders

g.program(key, { vs, fs, attribs, mode }) compiles once per key and returns { upload(attrib, f32, usage?), uniform(name, value), draw({ vertexCount, instanceCount? }) }. The host owns the program, the vertex array and one buffer per attribute.

  • Shaders are GLSL ES 3.00 and start with #version 300 es and a precision line. The host injects uniform vec2 u_viewport; (the canvas in device px) and uniform vec2 u_pixelRatio; after the first precision line when your shader does not declare them, and sets both on every draw.

  • Attributes are floats of size 1 to 4, one buffer each. divisor: 1 makes an attribute per instance.

  • uniform(name, v) sets a float for a number, vec2 / vec3 / vec4 for arrays of 2 / 3 / 4, a mat4 for 16 and a float array otherwise. Integer and sampler uniforms need g.raw.

  • mode is "triangle-strip" (default), "triangles", "lines" or "points".

  • The pane's y axis points down in pixels and up in clip space: flip y in the vertex shader.

  • Output premultiplied colour, vec4(rgb * a, a), or it blends too bright.

  • The key caches the compiled program. After changing a shader's source, change its key.

g.raw saves and restores the program, vertex array, buffers, active texture and binding, framebuffer, blend, scissor, viewport, depth, stencil, cull, clear colour and colour mask around your callback, even if you throw. host gives viewport, canvasWidth and canvasHeight.

The bench counts g.rects and g.packed as drawing but not g.program or g.raw. A layer that draws only through a custom shader fails the bench's painted check unless its paint draws something too. Both examples below pair the shader with a small Canvas 2D paint that is useful on its own.

WebGL examples

A session density heatmap with a colour ramp shader

Each session's volume at price becomes a column of cells, coloured from cold to hot on the GPU by density within that session. paintGL converts times and prices to pixels every frame because the time axis has gaps; paint adds each session's POC.

meta({ shortName: "Density", kind: "overlay", dataSource: "volume_candles" });

dialog({ title: "Session density", description: "Volume at price per session, coloured by density." });

const rowTicks = input.number({ id: "rowTicks", label: "Ticks per cell", default: 4, min: 1, step: 1 });
const cold = input.color({ id: "cold", label: "Low volume", default: "#1e88e5" });
const hot = input.color({ id: "hot", label: "High volume", default: "#ffca28" });
const opacity = input.number({ id: "opacity", label: "Opacity", default: 60, min: 10, max: 100, step: 5, unit: "%" });
warmup((w) => w.day()); // one session

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, rowTicks, cold, hot, opacity)));

const sessions = output.layer({
  id: "density",
  key: "start",
  row: { start: "number" },
  derived: { end: "number", vol: "bins", poc: "number" },
  name: "Session density",
  time: { start: "start", end: "end" },
  claims: { rowsPer: "session", seals: true },
});

/** @typedef {{ start: number, end: number, vol: Bins, poc: number }} DensityRow */

/**
 * @param {unknown} hex
 * @returns {number[]}
 */
function rgb(hex) {
  const n = parseInt(String(hex).slice(1, 7), 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

const UNIT_QUAD = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);

const VS = `#version 300 es
precision highp float;
in vec2 a_unit;     // corner of the unit quad
in vec4 a_rect;     // left, top, right, bottom in device px (per instance)
in float a_heat;    // 0..1 (per instance)
out float v_heat;
void main() {
  vec2 px = mix(a_rect.xy, a_rect.zw, a_unit);
  vec2 clip = px / u_viewport * 2.0 - 1.0;
  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
  v_heat = a_heat;
}`;

const FS = `#version 300 es
precision highp float;
in float v_heat;
uniform vec3 u_cold;
uniform vec3 u_hot;
uniform float u_opacity;
out vec4 fragColor;
void main() {
  vec3 c = mix(u_cold, u_hot, smoothstep(0.0, 1.0, v_heat));
  float a = u_opacity * (0.15 + 0.85 * v_heat);
  fragColor = vec4(c * a, a);
}`;

/** @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 = sessions.last();
  if (previous && previous.start !== s.start && previous.closedAt === undefined) sessions.seal(previous.start);
  if (!s.isOpen) return;

  const st = ctx.accum("density", () => ({ row: /** @type {DensityRow | null} */ (null) }), (x) => x);
  if (!st.row || st.row.start !== s.start) st.row = { start: s.start, end: ctx.time, vol: bins(step), poc: NaN };
  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 + ctx.chart.timeframeSeconds;
  r.poc = r.vol.argmax() + step / 2;
  sessions.upsert(r);
}

sessions.layout((p) => ({ cold: rgb(p.params.cold), hot: rgb(p.params.hot), opacity: Number(p.params.opacity) / 100 }));

sessions.paintGL((g, rows) => {
  const L = /** @type {{ cold?: number[], hot?: number[], opacity?: number }} */ (g.layout);
  if (!L.cold || !L.hot) return;
  let cells = 0;
  for (const r of rows) if (r.vol) cells += r.vol.count;
  if (cells === 0) return;

  const rects = new Float32Array(cells * 4);
  const heat = new Float32Array(cells);
  const { hpr, vpr } = g.viewport;
  let i = 0;
  for (const r of rows) {
    if (!r.vol || r.end === undefined || r.vol.max <= 0) continue;
    const left = g.x(r.start) * hpr;
    const right = g.x(r.end) * hpr;
    for (const [price, v] of r.vol.entries()) {
      rects[i * 4] = left;
      rects[i * 4 + 1] = g.y(price + r.vol.step) * vpr;
      rects[i * 4 + 2] = right;
      rects[i * 4 + 3] = g.y(price) * vpr;
      heat[i] = v / r.vol.max;
      i++;
    }
  }

  const prog = g.program("density-v1", {
    vs: VS,
    fs: FS,
    attribs: [{ name: "a_unit", size: 2 }, { name: "a_rect", size: 4, divisor: 1 }, { name: "a_heat", size: 1, divisor: 1 }],
    mode: "triangle-strip",
  });
  prog.upload("a_unit", UNIT_QUAD, "static");
  prog.upload("a_rect", rects.subarray(0, i * 4), "dynamic");
  prog.upload("a_heat", heat.subarray(0, i), "dynamic");
  prog.uniform("u_cold", L.cold);
  prog.uniform("u_hot", L.hot);
  prog.uniform("u_opacity", L.opacity ?? 0.6);
  prog.draw({ vertexCount: 4, instanceCount: i });
}, { layer: "underlay" });

sessions.paint((p, rows) => {
  const color = String(p.params.hot);
  for (const r of rows) {
    if (r.poc === undefined || !Number.isFinite(r.poc) || r.end === undefined) continue;
    p.priceLine(r.poc, { color, width: 2, label: "POC", span: [r.start, r.end], axisLabel: r.closedAt === undefined });
  }
});

Thousands of volume bubbles, instanced

One circle per bar at its typical price, sized by volume and coloured by direction. Each circle is an instanced quad with a smooth edge computed in the fragment shader, so ten thousand of them cost one draw call; paint labels the biggest one in view.

meta({ shortName: "Bubbles", kind: "overlay" });

dialog({ title: "Volume bubbles", description: "A circle per bar at its typical price, sized by volume." });

const scale = input.number({ id: "scale", label: "Largest bubble (bars wide)", default: 3, min: 0.5, step: 0.5 });
const upColor = input.color({ id: "upColor", label: "Up bar", default: "#26a69a" });
const downColor = input.color({ id: "downColor", label: "Down bar", default: "#ef5350" });
warmup((w) => w.forever()); // the key list reaches back 4,000 bars

layout(tab({ id: "main", title: "Main" }, section({ id: "s", title: "Settings" }, scale, upColor, downColor)));

const bubbles = output.layer({
  id: "bubbles",
  key: "start",
  row: { start: "number" },
  derived: { end: "number", price: "number", vol: "number", up: "boolean" },
  name: "Volume bubbles",
  time: { start: "start", end: "end" },
  claims: { rowsPer: "bar", seals: false },
});

/**
 * @param {unknown} hex
 * @param {number} a
 * @returns {number[]}
 */
function rgba(hex, a) {
  const n = parseInt(String(hex).slice(1, 7), 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255, a];
}

const CORNERS = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]);

const VS = `#version 300 es
precision highp float;
in vec2 a_corner;   // -1..1 (per vertex)
in vec3 a_dot;      // centre x, centre y, radius, all device px (per instance)
in float a_up;      // 1 up, 0 down (per instance)
out vec2 v_local;
out float v_up;
void main() {
  vec2 px = a_dot.xy + a_corner * a_dot.z;
  vec2 clip = px / u_viewport * 2.0 - 1.0;
  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
  v_local = a_corner;
  v_up = a_up;
}`;

const FS = `#version 300 es
precision highp float;
in vec2 v_local;
in float v_up;
uniform vec4 u_up;
uniform vec4 u_down;
out vec4 fragColor;
void main() {
  float d = length(v_local);
  float cover = 1.0 - smoothstep(1.0 - fwidth(d), 1.0, d);
  vec4 c = mix(u_down, u_up, v_up);
  float a = c.a * cover;
  fragColor = vec4(c.rgb * a, a);
}`;

/** @param {ScriptedCtx} ctx */
export function onBar(ctx) {
  const st = ctx.accum("bubbles", () => ({ t: NaN, keys: /** @type {number[]} */ ([]) }), (s) => s);
  if (st.t !== ctx.time) {
    st.t = ctx.time;
    st.keys.push(ctx.time);
    if (st.keys.length > 4000) bubbles.remove(st.keys.shift());
  }
  bubbles.upsert({
    start: ctx.time,
    end: ctx.time + ctx.chart.timeframeSeconds,
    price: (ctx.high + ctx.low + ctx.close) / 3,
    vol: ctx.volume,
    up: ctx.close >= ctx.open,
  });
}

bubbles.layout((p) => ({
  up: rgba(p.params.upColor, 0.55),
  down: rgba(p.params.downColor, 0.55),
  maxR: Math.max(3, p.chart.barSpacing * Number(p.params.scale) / 2),   // CSS px; layout re-runs on zoom
}));

bubbles.paintGL((g, rows) => {
  const L = /** @type {{ up?: number[], down?: number[], maxR?: number }} */ (g.layout);
  if (!L.up || !L.down || !L.maxR) return;
  let max = 0;
  for (const r of rows) if (r.vol !== undefined) max = Math.max(max, r.vol);
  if (max <= 0) return;

  const dots = new Float32Array(rows.length * 3);
  const ups = new Float32Array(rows.length);
  const { hpr, vpr } = g.viewport;
  let n = 0;
  for (const r of rows) {
    if (r.price === undefined || r.vol === undefined) continue;
    dots[n * 3] = g.x(r.start) * hpr;
    dots[n * 3 + 1] = g.y(r.price) * vpr;
    dots[n * 3 + 2] = Math.max(1.5, Math.sqrt(r.vol / max) * L.maxR) * hpr;
    ups[n] = r.up ? 1 : 0;
    n++;
  }

  const prog = g.program("bubbles-v1", {
    vs: VS,
    fs: FS,
    attribs: [{ name: "a_corner", size: 2 }, { name: "a_dot", size: 3, divisor: 1 }, { name: "a_up", size: 1, divisor: 1 }],
  });
  prog.upload("a_corner", CORNERS, "static");
  prog.upload("a_dot", dots.subarray(0, n * 3), "dynamic");
  prog.upload("a_up", ups.subarray(0, n), "dynamic");
  prog.uniform("u_up", L.up);
  prog.uniform("u_down", L.down);
  prog.draw({ vertexCount: 4, instanceCount: n });
}, { layer: "overlay" });

bubbles.paint((p, rows) => {
  /** @type {typeof rows[number] | null} */
  let biggest = null;
  for (const r of rows) if (r.vol !== undefined && (!biggest || r.vol > (biggest.vol ?? 0))) biggest = r;
  if (!biggest || biggest.price === undefined || biggest.vol === undefined) return;
  const v = biggest.vol;
  const text = v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(Math.round(v));
  const font = p.fit(text, p.dx(p.chart.timeframeSeconds * 6), p.theme.fontSize + 6);
  const radius = Math.max(3, p.chart.barSpacing * Number(p.params.scale) / 2);
  if (font) p.textBox(text, p.x(biggest.start), p.y(biggest.price) - radius - 2, { font, align: "center", baseline: "bottom", background: p.theme.background, padding: 2 });
});

Hit testing, hover and selection

The host hit-tests the layout bounds with 3 px of padding, topmost box first, at most once every 16 ms. Register hit to replace it and return { id, part?, cursor? }. p.hover and p.selected hold the row id under the pointer and the selected row id, so paint can highlight them. Selection survives repaints. Gestures are covered on Layers, with a complete drawing tool.

The bench

bench_layer runs layout, paint and paintGL against a recording canvas on real rows, on a 1200 × 600 pane at 2, 6 and 14 px per bar, and grades the result. Writing a script with a painted layer runs it too and reports the verdict, the failing check and its remedy. The claims check from Layers comes first.

Check

Fails when

Remedy

error

a renderer threw

Fix the error it names.

painted

rows are visible, paint issued no canvas operations and paintGL drew no g.rects / g.packed

Draw the rows you receive.

opsPerPixelRow

rect and text calls per visible row exceed 4 × the pane height

Merge bins under 1 px, gate labels on p.fit, move cells to paintGL. Never a rect per tick.

subPixelShare

more than 25% of rectangles (once there are 20) are under 1 px tall or wide

Bin coarser or use p.rowsOf.

paintMs

the paint's JavaScript cost exceeds 8 ms of browser time with no real drawing

Work per pixel row, not per bin.

nanCoords (warn)

a draw had a NaN coordinate

A p.y of an undefined field, or p.polyline given objects.

outsidePane (warn)

more than 10% of draws land off the pane

Clamp spans with p.spanX.

paneAxisUnknown (warn)

a layer on a subpane with no declared price range drew off the bar range

Declare the pane's range or register autoscale.

escapes (warn)

p.all() was used

Paint only the rows you receive.

dry_run_layer_paint is the ungraded single-zoom run: op counts by kind, paintMs, outsidePane, unbalancedSave, GL call counts, the escape hatches used and the legend and autoscale results. Zero ops means the paint drew nothing. On the chart, wait_for_preview reports ready_over_budget with last_paint_ms and ops per layer when a painted layer is over the frame budget.