---
title: "Drawing objects"
description: "A wrun indicator has three forms of drawing. A box or a segment is declared once over two outputs and evaluated on every bar; a run-level drawing declaration (…"
order: 35
section: "presentation"
---

<!-- source: docs/indicators/presentation/drawing-objects.md; generated by packages/cli/scripts/gen-indicator-docs.ts, do not edit -->

# Drawing objects

A wrun indicator has three forms of drawing. A `box` or a `segment` is
declared once over two outputs and evaluated on every bar; a run-level
drawing declaration (`draw.label` and its kin) is placed from the newest bar's outputs; and a
**handle** is an object the module creates under an integer id and
moves, restyles, or deletes on a later bar. This page is the
vocabulary, the form each kind of object takes, and the rules each form
runs under.

## The objects

| You want | wrun form | Lifecycle |
| --- | --- | --- |
| a line | `segment(name, { yFrom, yTo, from, to })` per bar; `draw.line(id).set(x1, y1, x2, y2)` as a handle | per bar, or a handle the module owns |
| a box | `box(name, { top, bottom, from, to, when })` per bar; `draw.box(id).set(left, top, right, bottom)` as a handle | per bar, or a handle the module owns |
| a label | `draw.label(id).set(x, y).text(str_<slot>_sb)` as a handle; `render.label(name, { x, y, text })` for one label the newest bar wins | a handle, or one label |
| a polyline | `draw.polyline(id).setPoints(points, count)` as a handle; `draw.polyline(name, { points })` declared over output pairs | a handle of up to 100,000 points, or 64 output pairs from the newest bar |
| a fill between two lines | `draw.line(id).fillTo(otherId, color)` between two line handles; `fill("a", "b", { color })` between two drawn outputs ([Styling](styling.md)); a `box` per bar between two outputs | a handle the module owns, per output, or per bar |
| a table | `render.table(name, { rows, cols, cells, position })` over string slots | the newest complete row wins |
| a mark (a dot, a square, a diamond) | `draw.label(id).set(x, y).mark().style(LabelStyle.Emblem).emblem(Emblem.Diamond).size(10)` as a handle; `draw.box(id).shape(BoxShape.Ellipse)` for an ellipse | a label with no text, or an ellipse inscribed in a box |
| a tooltip on an object | `tooltip: "slope {{slope:0.00}}"` on a declared drawing; `handle.tooltip(str_<slot>_sb)` on a handle | shown when the cursor rests on the object |
| to restyle, move, or delete an object later | the handle's setters (`set`, `setXy2`, `setRightBottom`, `color`, `fill`, `fillTo`, `width`, `style`, `extend`, `size`, `glow`, `arrow`, `cornerRadius`, `gradient`, `text`, ...) and `delete()` | a setter stamps the bar it ran on; `delete()` frees the id |

Every piece is a decoration over numbers the module already computes:
outputs are the values, and boxes, segments, drawings, and handles never
change an output's value ([Execution model](../core-concepts/execution-model.md)).
Two forms are both called `draw`: `draw.line(name, {...})` as a top-level
statement declares a run-level drawing, and `draw.line(id)` returns a
handle; a name or an options object as the first argument is what makes
the call a declaration. A handle does everything a run-level declaration
does (create it under `bar.isLast()`).

## Boxes

A box is declared once and evaluated on every bar. On bar `i` it spans
bars `i + from` to `i + to` (inclusive) and prices `min(top, bottom)` to
`max(top, bottom)`. Nothing is drawn on a bar where any referenced output
is `NaN`, or where the optional `when` gate is `0` or `NaN`.

Coordinates are output HANDLES: `output(...)` returns one, so bind it with
a top-level `const` and pass the const. `from` and `to` are bar offsets
(negative = past, positive = ahead; literals in -500..500, whole or
fractional, or a handle whose per-bar value is truncated to the offset;
default `0`). A fraction places the edge inside its bar at that share of
the bar's interval: `from: -0.5, to: 0.5` draws a box one bar wide centred
on the bar, and `from: -0.15, to: 0.15` a narrow stick through it.
Options: `when` (a gate handle), `panel` (`"overlay"` or `"lower"`,
default: the panel of `top`'s output), `color`, `borderColor`, `opacity`
(0..1, default 0.2), `borderWidth` (0..10, default 1; `0` for no border),
`borderStyle` (`"solid"`, `"dashed"`, `"dotted"`), `z` (the paint order,
-10..10). The fill takes the opacity, so `color` must be a hex, `rgb()`,
`hsl()` or theme-token color; a named color is refused. A colour per bar
comes from a ladder on either part: `colorBy` (an output handle whose
floored value picks the entry) plus `colors` (1..64 colours) for the
fill, `borderColorBy` plus `borderColors` for the border, or
`colorPackedBy` / `borderColorPackedBy` naming an output that carries a
packed colour per bar. A finite out-of-range rung clamps to entry 0, a
non-finite rung (or a packed value that does not decode) draws no box on
that bar, and a rung colour's own alpha multiplies `opacity`. A ladder
half without the other, a packed key beside its `By` key, or a name that
is not a declared output is refused. `color`, `borderColor` and
`borderStyle` also take a `"@<param>"` setting
([The Style page](../settings/style-page.md)).
Every referenced output may be data-only (`none`), which is the usual
shape: compute the coordinates, never draw them as lines. A box or a
segment adds no data to the module (the referenced outputs already reach
the chart), and **Run** records its options in the sheet under
snake_case names (`x_from`, `border_color`, `border_style`, `color_by`,
`y_from`, and so on).

The last five bars' range, tinted only while the range is expanding:

```typescript sample=fn-range-box
param("bars", 5, { min: 2, max: 50, description: "Bars in the trailing range" });
input("high", ohlcv.high);
const rangeHi = output("range_hi", none);
const rangeLo = output("range_lo", none);
const expanding = output("expanding", none);
// Behind the last five bars, tinted only while the range is wider than it was one bar ago.
box("range_zone", { top: rangeHi, bottom: rangeLo, from: -4, to: 0, when: expanding, color: "#f59e0b", opacity: 0.15, borderColor: "#f59e0b", borderWidth: 1 });

const MAX_BARS = 50;
const highs = new StaticArray<f64>(MAX_BARS);
const lows = new StaticArray<f64>(MAX_BARS);
let n: i32 = 5;
let cursor: i32 = 0;
let count: i32 = 0;
let prevWidth: f64 = NaN;
let width: f64 = NaN;

function onStart(): void {
  n = i32(p_bars());
}

function onBar(): void {
  highs[cursor] = bar.high();
  lows[cursor] = bar.low();
  cursor = (cursor + 1) % n;
  if (count < n) count += 1;
  if (count < n) return;
  let hi = -Infinity;
  let lo = Infinity;
  for (let i = 0; i < n; i++) {
    if (highs[i] > hi) hi = highs[i];
    if (lows[i] < lo) lo = lows[i];
  }
  prevWidth = width;
  width = hi - lo;
  out_range_hi(hi);
  out_range_lo(lo);
  out_expanding(!isNaN(prevWidth) && width > prevWidth ? 1.0 : 0.0);
}
```

Two shapes that fall out of the per-bar rule:

- A shaded channel between two lines is a box
  on every bar with `from` and `to` left at `0`: each bar contributes a
  one-bar-wide slice, and the slices tile into a band. The
  [anchored VWAP recipe](../cookbook/anchored-vwap.md) shades its band
  this way.
- A zone that lives until price breaks it is the same one-bar box gated by
  a `when` output that stays `1` while the zone is alive: the band starts
  at the pivot and stops on the bar that mitigates it (the
  [zone tracker](../cookbook/zone-tracker.md)). That is one form of
  "create the box at the pivot, delete it when mitigated"; the other
  is a box handle, below, which is the same object from creation to
  deletion.

A fill between two lines has two forms, both drawn by the chart:
`range("upper", "lower", { color })` over two DRAWN outputs draws a band
with edge lines, a sign palette or a vertical gradient
([Styling](styling.md#decisions-to-looks)), and the one-bar box above,
whose `color` and `opacity` are the fill's. The box reads outputs, not
lines, so either side may be data-only, and a conditional fill (shading
only some bars) is a `when` gate on the box. A hand-written sheet's
`fills` list has no declaration form. Two boxes gated by opposite
outputs, one color each; the delta sits near zero, so it and its fills
live in a `lower` pane (a box follows its `top` output's panel) instead
of flattening the price scale under the candles:

```typescript sample=fn-fill-band
param("period", 20, { min: 2, max: 400 });
const delta = output("delta", line, lower, { color: "#e2e8f0", width: 1, description: "Close minus its average" });
const zero = output("zero", line, lower, { color: "#64748b", width: 1, description: "Zero baseline" });
const below = output("below", none, lower, { description: "1 while the delta is negative" });
const above = output("above", none, lower, { description: "1 while the delta is positive or zero" });
// Two fills, one per sign: each bar contributes one slice between the delta and zero,
// in the delta's own pane (each box takes the panel of its `top` output).
box("fill_up", { top: delta, bottom: zero, when: above, color: "#22c55e", opacity: 0.15, borderWidth: 0 });
box("fill_down", { top: zero, bottom: delta, when: below, color: "#ef4444", opacity: 0.15, borderWidth: 0 });

let sma = new Sma(20);

function onStart(): void {
  sma = new Sma(i32(p_period()));
}

function onBar(): void {
  const close = bar.close();
  const avg = sma.update(close);
  const value = isNaN(avg) ? NaN : close - avg;
  if (isNaN(value)) return;
  out_delta(value);
  out_zero(0.0);
  out_below(value < 0.0 ? 1.0 : 0.0);
  out_above(value >= 0.0 ? 1.0 : 0.0);
}
```

Both boundary series here are outputs the chart draws; the module could
also leave one of them `none` and the box would still tile, because a
box reads outputs, not lines (a `range()` needs both sides drawn).

## Segments

A segment is the straight line from `(i + from, yFrom)` to `(i + to,
yTo)`, evaluated on every bar `i` with the same offset, gate, and `NaN`
rules as a box. Options: `when`, `panel`, `color`, `width` (0.5..20,
default 1), `lineStyle` (`"solid"`, `"dashed"`, `"dotted"`). Absent
`color` and `panel` follow `yFrom`'s output; `color` and `lineStyle`
take a `"@<param>"` setting.

A dotted projection from each bar's average toward where the slope points,
its length decided per bar by an output-valued offset:

```typescript sample=fn-projection
param("period", 20, { min: 2, max: 200 });
const anchor = output("anchor", line, overlay, { color: "#38bdf8" });
const target = output("target", none);
const reach = output("reach", none);
// From this bar's average to the projected level, `reach` bars ahead: the offset is an output,
// so each bar decides its own length.
segment("projection", { yFrom: anchor, yTo: target, from: 0, to: reach, color: "#38bdf8", width: 1, lineStyle: "dotted" });

let ema = new Ema(20);
let value: f64 = NaN;
let prev: f64 = NaN;

function onStart(): void {
  ema = new Ema(i32(p_period()));
}

function onBar(): void {
  prev = value;
  value = ema.update(bar.close());
  if (isNaN(value)) return;
  const slope = isNaN(prev) ? 0.0 : value - prev;
  const bars = slope == 0.0 ? 0.0 : 5.0;
  out_anchor(value);
  out_target(value + slope * bars);
  out_reach(bars);
}
```

Horizontal levels are segments with `yFrom` and `yTo` on the same output
and `from: 0, to: 1`: each bar draws its level to the next bar, and the
pieces chain into one line that steps when the level changes (the
[key levels recipe](../cookbook/key-levels.md)). Offsets past the loaded
range clamp to its edge, so a segment cannot reach into empty space to
the right of the newest bar; a handle's absolute coordinates can.

### A box and a segment together

A moving average with a one-percent band drawn as a zone behind the last
five bars while the average rises, plus a dotted ray from each bar's
average to the band top three bars ahead. Both shapes take their color
and panel from the outputs they reference:

```typescript sample=fn-shapes-codefirst
param("period", 20, { min: 1, max: 200 });
const mid = output("mid", line, overlay, { color: "#38bdf8" });
const bandHi = output("band_hi", none);
const bandLo = output("band_lo", none);
const rising = output("rising", none);
box("band_zone", { top: bandHi, bottom: bandLo, from: -4, to: 0, when: rising, opacity: 0.15, borderWidth: 0 });
segment("mid_ray", { yFrom: mid, yTo: bandHi, from: 0, to: 3, lineStyle: "dotted" });

let sma = new Sma(20);
let value: f64 = NaN;
let prev: f64 = NaN;

function onStart(): void {
  sma = new Sma(i32(p_period()));
}

function onBar(): void {
  prev = value;
  value = sma.update(bar.close());
  if (isNaN(value)) return;
  out_mid(value);
  out_band_hi(value * 1.01);
  out_band_lo(value * 0.99);
  out_rising(!isNaN(prev) && value > prev ? 1.0 : 0.0);
}
```

## Run-level drawings

`draw.line`, `draw.box`, `draw.polyline`, and `draw.label` with a name
as the first argument are the declared kind of object: one per
declaration, its coordinates read from outputs on the NEWEST bar only.
Every coordinate finite there means the object exists; any `NaN` there
means no object, regardless of earlier bars. It is "draw from the last bar" as a
declaration: the chart evaluates the newest bar on its own, and
re-evaluates it as live data arrives.

- `draw.line(name, { x1, y1, x2, y2, color?, width?, line_style?,
  extend?, arrow?, glow?, glow_color?, sticky_right?, axis_label?,
  opacity?, tooltip? })`
- `draw.box(name, { left, top, right, bottom, color?, border_color?,
  opacity?, border_width?, border_style?, corner_radius?, extend?,
  shape?, gradient?, gradient_direction?, glow?, glow_color?, text?,
  text_color?, font_size?, font_weight?, font_family?, align?, valign?,
  padding?, tooltip? })`: `color` is the fill, painted at `opacity`
  (default 0.2), and the border too unless `border_color` is set
  (default `#38bdf8`, the box handle default); it takes any colour
  string, as it always has: a hex, `rgb()`, `hsl()` or theme-token fill
  takes the opacity, a named colour paints as given (only `handles.box`
  insists on an alpha-rewritable fill); `text` names a string slot drawn
  inside the box
- `draw.polyline(name, { points, color?, width?, line_style?, opacity?,
  fill_color?, closed?, smooth?, arrow?, glow?, glow_color?, tooltip? })`,
  where `points` is a flat `["x0", "y0", "x1", "y1", ...]` list of
  output-name pairs, at most 64 pairs
- `draw.label(name, { x, y, text, color?, style?, align?,
  valign?, background_color?, border_color?, border_width?,
  corner_radius?, font_weight?, font_family?, padding?, max_width?,
  angle?, glow?, glow_color?, emblem_shape?, emblem_color?,
  sticky_right?, axis_label?, opacity?, tooltip? })`, `text` a string slot

X coordinates are epoch SECONDS, not milliseconds: `bar.time()` feeds
them, and a point in the past is that time minus a bar count times the
interval in seconds.
Drawings reference outputs by NAME (strings), unlike per-bar boxes and
segments, which take handles. The style words are the handle defaults'
words (the table under [Handles](#handles)), spelled snake_case, and
every one is optional: absent keeps the object's look as it has always
been. `tooltip` is a template over the newest bar (`{{name}}`,
`{{name:format}}`, `{{label}}`, `{{value}}`) shown when the cursor rests
on the object. A `\n` inside a slot's text starts a new line on a label
and on box text.

Every declared kind at once: a high and a low line, a zone box
between them, a three-point path, and a label, all placed from the
newest bar, plus a per-bar horizontal level for contrast. The drawings
reach two bars ahead by adding two intervals to the bar time.

```typescript sample=fn-draw-objects
param("lookback", 20, { min: 2, max: 500, description: "Bars in the zone's range" });
output("close_line", line, overlay, { color: "#2563eb", width: 2, description: "Close, the anchor line" });
const level = output("level", none, overlay, { description: "The lookback high, a stepping level" });
output("zone_high", none, overlay, { description: "Lookback high: the top line and the box top" });
output("zone_low", none, overlay, { description: "Lookback low: the bottom line and the box bottom" });
output("zone_mid", none, overlay, { description: "The midpoint, the path's middle point" });
output("t0", none, overlay, { description: "This bar's open time, epoch seconds" });
output("t1", none, overlay, { description: "One bar ahead" });
output("t2", none, overlay, { description: "Two bars ahead" });
string("note", { max_bytes: 32 });
// Per bar: the lookback high carried one bar to the right, chaining into a stepped level line.
segment("level_line", { yFrom: level, yTo: level, from: 0, to: 1, color: "#94a3b8", width: 1, lineStyle: "dashed" });
// Run level, placed from the newest bar: two lines, a box, a path, and a label.
draw.line("top_line", { x1: "t0", y1: "zone_high", x2: "t2", y2: "zone_high", color: "#2563eb", width: 2 });
draw.line("bottom_line", { x1: "t0", y1: "zone_low", x2: "t2", y2: "zone_low", color: "#dc2626", width: 2 });
draw.box("zone", { left: "t0", top: "zone_high", right: "t2", bottom: "zone_low", color: "#dbeafe" });
draw.polyline("path", { points: ["t0", "zone_low", "t1", "zone_mid", "t2", "zone_high"], color: "#f97316", width: 2 });
draw.label("last", { x: "t1", y: "close_line", text: "note", color: "#111827" });

const MAX_BARS = 500;
const highs = new StaticArray<f64>(MAX_BARS);
const lows = new StaticArray<f64>(MAX_BARS);
let n: i32 = 20;
let cursor: i32 = 0;
let count: i32 = 0;
let t: f64 = NaN;
let prevT: f64 = NaN;
let intervalSec: f64 = NaN;

function onStart(): void {
  n = i32(p_lookback());
}

function onBar(): void {
  const close = bar.close();
  prevT = t;
  t = bar.time();
  // The interval is the spacing between consecutive bar opens; NaN on the first bar.
  if (!isNaN(prevT)) intervalSec = t - prevT;
  highs[cursor] = bar.high();
  lows[cursor] = bar.low();
  cursor = (cursor + 1) % n;
  if (count < n) count += 1;
  if (count < n || isNaN(intervalSec)) return;
  let hi = -Infinity;
  let lo = Infinity;
  for (let i = 0; i < n; i++) {
    if (highs[i] > hi) hi = highs[i];
    if (lows[i] < lo) lo = lows[i];
  }
  out_close_line(close);
  out_level(hi);
  out_zone_high(hi);
  out_zone_low(lo);
  out_zone_mid((hi + lo) / 2.0);
  out_t0(t);
  out_t1(t + intervalSec);
  out_t2(t + 2.0 * intervalSec);
  sb_clear();
  sb_text("last ");
  sb_f64(close, 2);
  str_note_sb();
}
```

After this runs you see one stepped level line (per bar), two lines, one
box, one path, and one label (all from the newest bar). A declared drawing
that should be REMOVED emits `NaN` through one of its coordinate outputs
on the newest bar; a handle, below, has a `delete()`.

## Handles

A handle is an object the module owns: it creates it, keeps its
identity from bar to bar, moves or restyles it later, and deletes it.
Four kinds: `line`, `box`, `label`, `polyline`.

**Declare the kinds you draw** at the top of the file, once per kind,
with the defaults every new handle of that kind starts from:
`handles.line({ panel, color, width, line_style, extend })`,
`handles.box({ panel, color, border_color, opacity, border_width })`,
`handles.label({ text, panel, color, size, align })`, and
`handles.polyline({ panel, color, width, line_style })`. There is no
name (handles are ids the module picks) and
every option is optional; `handles.box()` enables boxes with the
defaults. A label's `text` names the string slot its text is read from
(a label that only draws a mark needs none). A label's `align`
(`left`, `center` or `right`) is the text edge that sits on its x;
omitted centres the text, and (x, y) stays the point either way. The
legacy spellings `borderColor`, `borderWidth` and `lineStyle` still
work beside the snake_case ones (both spellings of one key at once are
refused), and every look below is a default here too: `glow`,
`glow_color`, `sticky_right`, `axis_label`, `arrow`, `opacity` on a
line; `border_style`, `corner_radius`, `extend`, `shape`, `gradient`,
`gradient_direction`, `text_color`, `font_size`, `font_weight`,
`font_family`, `align`, `valign`, `padding` on a box; `style`,
`background_color`, `border_color`, `border_width`, `corner_radius`,
`font_weight`, `font_family`, `valign`, `padding`, `max_width`, `angle`,
`emblem_shape`, `emblem_color` on a label; `fill_color`, `closed`,
`smooth` on a polyline. `safe_area: true` on any kind keeps its
pane-anchored handles clear of the chart's chrome (the legend, the pane
action bar, the price-axis tags). Declaring any kind,
or calling `bar.isLast()`, switches the derived sheet to the third
runtime contract (`abi_version: "wrun-3"`); the numeric outputs compute
exactly as before.

**Make the objects once**, at module level or in `onStart()`:
`draw.line(id)`, `draw.box(id)`, `draw.label(id)`,
`draw.polyline(id)`. An id is any integer from `0` up, in ONE space
across the four kinds: while a box holds id `3`, a label cannot. An
object per bar would allocate per bar, which the module never does.

**Draw in `onBar()`**, beside the outputs. The rules, each refused by
name when broken:

| Call | On an id nobody holds | On a live id of the same kind |
| --- | --- | --- |
| `set(...)` (`setPoints` on a polyline) | creates the handle: `createdBar` is this bar, the style is the kind's defaults | moves it: this bar becomes its `mutatedBar` |
| the partial setters `setXy1`, `setXy2`, `setLeftTop`, `setRightBottom` | refused (nothing to remember the other corner from) | moves one end; the object re-sends the remembered rest |
| `color`, `fill`, `fillTo`, `width`, `style`, `extend`, `opacity`, `size`, `border`, `zorder` and every other look setter below | refused (the handle does not exist) | restyles it; this bar becomes its `mutatedBar` |
| `delete()` | a no-op | removes it; the id is free for a later bar (a new handle, a new creation bar) |
| `set(...)` on an id a handle of ANOTHER kind holds | | refused: delete that handle first |

Coordinates are absolute: `x` in epoch seconds (`bar.time()`), `y` in
price. Nothing clamps: a box that should end one
bar past the newest bar sets its right edge to `t + interval`, and the
chart draws it there. Every coordinate must be finite. Colors on a
setter are `rgba(r, g, b, a)` values (`rgb(r, g, b)` for opaque) or a
theme token from `./sdk/color` (`theme.UP`, `theme.DOWN`, `theme.TEXT`,
`theme.MUTED`, `theme.BG`, `theme.GRID`, `theme.ACCENT`, with
`alpha(theme.BG, 0.85)` for a translucent one), which the chart resolves
when it paints ([Colors kit](../functions/colors-kit.md#theme-colours));
`style` on a line takes `LineStyle.Solid`, `Dashed`, `Dotted`;
`extend` takes `Extend.None`, `Left`, `Right`, `Both`. The core setters
by kind: `width` and `style` on lines and polylines, `extend` and
`fillTo` on lines, `border` and `fill` on boxes, `size` and `fill` on
labels, `opacity` and `zorder` on all four kinds. A `width` of `0.5`
draws a hairline, and a polyline sent one point draws a dot.

A label's text comes from a string slot: build the line with `sb_*`,
then `tag.set(x, y).text(str_<slot>_sb)` sends the slot and draws the
label with the bytes the slot holds on that bar (a `\n` in the text
starts a new line). Call it again on a later bar to move or re-word the
label. `tag.align(ALIGN_RIGHT)` (or `style.align(tag, ALIGN_RIGHT)`)
makes the text end at x instead of centring on it; `ALIGN_LEFT` starts
it there and `ALIGN_DEFAULT` restores centred text, clearing a declared
`align` default. `tag.set(x, y).mark()` draws a label with no text (no
slot is read): with `style(LabelStyle.Emblem)` and an `emblem(...)` it
is a dot, a square, a diamond or a triangle, `size` pixels across, at
(x, y).

### Every look a handle takes

Each setter below restyles a live handle like `color` does (a setter on
an id nobody holds is refused). Word arguments are the enums of
`./gen/draw`: `Arrow { None, Start, End, Both }`, `AxisLabel { None,
Price, Text }`, `BoxShape { Default, Rect, Ellipse }`, `GradientDirection
{ None, Vertical, Horizontal }`, `FontWeight { Default, Normal, Medium,
Bold }`, `FontFamily { Default, Ui, Mono, Serif, Rounded }`, `LabelStyle {
Default, Box, Plain, Knockout, Emblem, Pill, Badge, Callout }`, `Emblem {
Default, Dot, Square, Diamond, TriangleUp, TriangleDown }`, and the
`VALIGN_DEFAULT`, `VALIGN_TOP`, `VALIGN_MIDDLE`, `VALIGN_BOTTOM`
constants beside `ALIGN_*`. A `Default` word clears the setter back to
the declared default.

| Setter | Kinds | What it does |
| --- | --- | --- |
| `glow(px)`, `glowColor(c)` | line, box, label, polyline | a halo of 0..32 px around the stroke, border or text, in `glowColor` (default the object's own colour); `glow(0)` removes it |
| `opacity(f)`, `zorder(n)` | line, box, label, polyline | the object's opacity 0..1 (a box's fill opacity) and its paint order |
| `tooltip(send)`, `tooltipString(s, send)`, `clearTooltip()` | line, box, label, polyline | the tooltip text from a string slot sender (`tooltipString` sends `s` through it first), read on this bar; `clearTooltip()` removes it |
| `arrow(Arrow)` | line, polyline | an arrowhead at the start, the end or both (`Arrow.None` removes them) |
| `stickyRight(on)` | line, label | the object keeps its right end (a line) or its point (a label) beside the price axis as the chart scrolls |
| `axisLabel(AxisLabel)` | line, label | a pill on the price axis at the line's right endpoint price (`Price`) or the label's y (`Price`, or `Text` for the label's first line) |
| `fitTo(group)`, `padding(px)` | box | the box sizes itself to the union of every label in `group` (1..65535), grown by `padding` (0..64, default 6); `fitTo(0)` releases it; anchor and extend are ignored while fitted |
| `borderStyle(LineStyle)`, `cornerRadius(px)`, `extend(Extend)`, `shape(BoxShape)` | box | a dashed or dotted border, rounded corners (0..32), the box stretched to the pane's left or right edge, or the ellipse inscribed in the box |
| `gradient(start, end, GradientDirection)`, `clearGradient()` | box | a two-colour fill laid top to bottom or left to right in place of the flat fill |
| `text(send)`, `textString(s, send)`, `clearText()`, `textColor(c)`, `fontSize(px)`, `align(i32)`, `valign(i32)`, `padding(px)` | box | text inside the box from a string slot, in `textColor` at `fontSize` (6..64, default 12), aligned by `ALIGN_*` and `VALIGN_*` inside `padding` |
| `fontWeight(FontWeight)`, `fontFamily(FontFamily)` | label, box | the text's weight and family (`Ui` is the app font, `Mono`, `Serif` and `Rounded` system stacks) |
| `style(LabelStyle)` | label | the label's look: `Box` (the default rounded box), `Plain` (text alone), `Knockout` (a box in the background colour that hides what is behind the text), `Emblem` (a mark before the text), `Pill`, `Badge`, `Callout` |
| `emblem(Emblem)`, `emblemColor(c)` | label | the emblem's shape (`TriangleDown` points down) and colour (default the text colour) |
| `border(c)`, `borderWidth(px)`, `cornerRadius(px)` | label | the box's border colour and width (0..10) and its corner radius (0..32, default 4) |
| `valign(i32)`, `padding(px)`, `maxWidth(px)`, `angle(deg)` | label | the box placed above, on or below y; its padding (0..64, default 6); text wrapped at `maxWidth` (0..2000, 0 = no wrap); the whole label rotated -90..90 degrees about (x, y) |
| `group(id)`, `mark()` | label | the group a `fitTo` box measures (0 = none); a label with no text |
| `fill(c)`, `closed(on)`, `smooth(on)` | polyline | the polygon fill colour; the path closed back to its first point (and filled when `fill` is set); the path curved through its points |

`line.extend(Extend.Right).fillTo(otherId, c)` keeps working: the
shading between two lines follows their extensions. Two chart-level
flags belong beside the handle looks: `chart.contrast_guard(false)`
keeps the author's colours as written on a light chart, and
`chart.stack_handles(true)` stacks this indicator's corner-anchored
groups below other indicators' groups at the same corner instead of
overprinting them ([Styling](styling.md#theme-colours)).

Both flags are statements beside the declarations they govern. Readouts
pinned to the top-right corner as knockout labels (a theme-background
box behind the text), kept in the author's colours on a light chart and
stacked under other indicators' corner groups instead of over them:

```typescript
string("tag", { max_bytes: 16 });
handles.label({ text: "tag", anchor: "top_right", color: "#fde68a", size: 11, style: "knockout", padding: 6 });
chart.contrast_guard(false);
chart.stack_handles(true);
```

Every look in the table is also a default on a `handles.<kind>`
declaration and a key on a declared drawing, spelled snake_case. Swing
tags on a busy chart: the emblem look puts a diamond before the text, the
box sits above its point inside 6 px of padding, and a long note wraps at
120 px instead of running across the candles.

```typescript
string("tag", { max_bytes: 24 });
handles.label({ text: "tag", color: "#e5e7eb", size: 11, style: "emblem", emblem_shape: "diamond", emblem_color: "#f59e0b", valign: "top", padding: 6, max_width: 120 });
```

A level the viewer should never lose: the line's right end rides beside
the price axis as the chart scrolls, its price pilled onto the axis,
under a 4 px halo in its own colour.

```typescript
handles.line({ color: "#38bdf8", width: 1, line_style: "dashed", sticky_right: true, axis_label: "price", glow: 4, glow_color: "#38bdf8" });
```

A zone that explains itself: a declared box with its note printed inside,
a dashed border in the fill's colour and the text in the top-left corner
inside 8 px of padding.

```typescript
output("t0", none, overlay, { description: "The zone's first bar, epoch seconds" });
output("t1", none, overlay, { description: "Two bars past the newest bar" });
output("zone_hi", none, overlay, { description: "The zone's top" });
output("zone_lo", none, overlay, { description: "The zone's bottom" });
string("note", { max_bytes: 32 });
draw.box("supply", { left: "t0", top: "zone_hi", right: "t1", bottom: "zone_lo", color: "#ef4444", border_color: "#ef4444", border_style: "dashed", text: "note", text_color: "#fecaca", align: "left", valign: "top", padding: 8 });
```

A pattern outline that reads as a shape rather than a line: a closed,
curved path filled at 20% in its own colour, the stroke under a 6 px
halo.

```typescript
output("t0", none, overlay, { description: "This bar's open time, epoch seconds" });
output("t1", none, overlay, { description: "One bar ahead" });
output("t2", none, overlay, { description: "Two bars ahead" });
output("lo", none, overlay, { description: "The pattern's low" });
output("mid", none, overlay, { description: "The pattern's midpoint" });
output("hi", none, overlay, { description: "The pattern's high" });
draw.polyline("wedge", { points: ["t0", "lo", "t1", "mid", "t2", "hi"], color: "#f97316", width: 2, fill_color: "#f9731633", closed: true, smooth: true, glow: 6, glow_color: "#f97316" });
```

A measured move: a dotted arrow from the breakout to its target, drawn
half transparent so it reads as a projection, with the target's caption
standing upright beside the arrowhead, the text ending at the point.

```typescript
output("t0", none, overlay, { description: "The breakout bar, epoch seconds" });
output("t1", none, overlay, { description: "The target bar, ahead of the newest bar" });
output("y0", none, overlay, { description: "The breakout price" });
output("y1", none, overlay, { description: "The target price" });
string("target", { max_bytes: 24 });
draw.line("move", { x1: "t0", y1: "y0", x2: "t1", y2: "y1", color: "#a78bfa", width: 2, line_style: "dotted", arrow: "end", opacity: 0.6 });
draw.label("target_tag", { x: "t1", y: "y1", text: "target", color: "#a78bfa", style: "plain", angle: 90, align: "right" });
```

`render.text` and `render.label` take the label words too (`style`,
`emblem_shape`, `emblem_color`, and `align` and `valign` on plain text)
plus three ladders of their own, each naming a declared numeric output:
`background_color_by` with `background_colors` picks the tag's fill per
bar, `color_packed_by` reads a packed text colour per bar, `size_by` the
text size per bar. A tag to the right of every bar whose slot was
written, its fill from a regime ladder, inked and sized per bar:

```typescript
output("close_line", line, overlay, { color: "#2563eb", width: 2, description: "Close, the tag's anchor" });
output("regime", none, overlay, { description: "0 quiet, 1 trending, 2 stretched: picks the tag's fill" });
output("ink", none, overlay, { description: "The text colour per bar, packed with toPacked()" });
output("strength", none, overlay, { description: "The text size per bar, 8..14 px" });
string("readout", { max_bytes: 16 });
render.text("state_tag", { y: "close_line", text: "readout", style: "box", label_position: "right", background_color_by: "regime", background_colors: ["#334155", "#1d4ed8", "#b91c1c"], color_packed_by: "ink", size_by: "strength" });
```

Session zones: a box that grows with every bar of its session, a label on
it, four zones kept on the chart with the oldest deleted to make room,
and the open zone stretched one bar past the newest bar:

```typescript sample=fn-handle-zones
param("bars", 12, { min: 2, max: 500, description: "Bars per zone" });
input("high", ohlcv.high);
output("zone_hi", line, overlay, { color: "#38bdf8", description: "The open zone's high so far" });
output("zone_lo", line, overlay, { color: "#38bdf8", description: "The open zone's low so far" });
string("tag", { max_bytes: 16 });
// Enable box and label handles; every new box and label starts from these defaults.
handles.box({ color: "#38bdf8", opacity: 0.2, borderWidth: 1 });
handles.label({ text: "tag", color: "#e5e7eb", size: 11 });

// Four zones stay on the chart. Handle objects are made once; ids are one space across kinds.
const KEEP = 4;
const zones: BoxHandle[] = [draw.box(0), draw.box(1), draw.box(2), draw.box(3)];
const tags: LabelHandle[] = [draw.label(4), draw.label(5), draw.label(6), draw.label(7)];
let bars: i32 = 12;
let count: i32 = 0;
let slot: i32 = 0;
let serial: i32 = 0;
let t: f64 = NaN;
let prevT: f64 = NaN;
let start: f64 = NaN;
let hi: f64 = NaN;
let lo: f64 = NaN;

function onStart(): void {
  bars = i32(p_bars());
}

function onBar(): void {
  prevT = t;
  t = bar.time();
  if (count == 0) {
    start = t;
    hi = bar.high();
    lo = bar.low();
  } else {
    hi = Math.max(hi, bar.high());
    lo = Math.min(lo, bar.low());
  }
  count += 1;
  out_zone_hi(hi);
  out_zone_lo(lo);
  const zone = zones[slot];
  const tag = tags[slot];
  if (count == 1) {
    // The zone's first bar: set(...) on an id nobody holds creates the box; fill(...) tints it.
    serial += 1;
    zone.set(start, hi, t, lo).fill(rgba(56, 189, 248, 51));
  } else {
    // Every later bar grows the same box: the id is what makes it the same box.
    zone.setLeftTop(start, hi).setRightBottom(t, lo);
  }
  sb_clear();
  sb_text("zone ");
  sb_int(serial);
  tag.set(start, hi).text(str_tag_sb);
  // On the newest bar only, stretch the open zone one bar past the loaded range.
  if (bar.isLast() && !isNaN(prevT)) zone.setRightBottom(t + (t - prevT), lo);
  if (count >= bars) {
    // The zone is complete: move to the next slot and delete the zone that slot held.
    count = 0;
    slot = (slot + 1) % KEEP;
    zones[slot].delete();
    tags[slot].delete();
  }
}
```

Run over thirty hourly bars with twelve bars per zone, this leaves three
boxes and three labels: the first created on bar 0 and last moved on bar
11, the second on bars 12 and 23, the open one created on bar 24, moved
on bar 29, and reaching one hour past bar 29. The `delete()` calls on the
first three zone changes hit ids nobody holds and do nothing; the fourth
removes the oldest zone, and its id is created again on the next bar
with a new creation bar.

### Restyle later

A setter on a later bar is a mutation of the same object, and the bar it
ran on is recorded as the handle's last-mutation bar. The prior high as
a line that extends with
every bar, turns red and thick on the bar the close breaks it, stays for
a while, and is deleted, freeing the id for the next level:

```typescript sample=fn-handle-breakout
param("lookback", 20, { min: 2, max: 200, description: "Bars in the prior high" });
param("hold", 10, { min: 1, max: 100, description: "Bars a broken line stays before it is deleted" });
output("level", line, overlay, { color: "#94a3b8", description: "The prior high the line sits on" });
output("broke", none, overlay, { description: "1 on the bar the close breaks the line" });
handles.line({ color: "#94a3b8", width: 1, lineStyle: "dashed" });

const MAX_LOOKBACK = 200;
const highs = new StaticArray<f64>(MAX_LOOKBACK);
const stop = draw.line(0);
let n: i32 = 20;
let hold: i32 = 10;
let cursor: i32 = 0;
let count: i32 = 0;
let level: f64 = NaN;
let levelT: f64 = NaN;
let fresh: bool = false;
let heldBars: i32 = -1;

function onStart(): void {
  n = i32(p_lookback());
  hold = i32(p_hold());
}

function onBar(): void {
  const t = bar.time();
  const close = bar.close();
  // The highest high of the previous n bars, this bar excluded.
  let prior: f64 = NaN;
  if (count >= n) {
    prior = -Infinity;
    for (let i = 0; i < n; i++) if (highs[i] > prior) prior = highs[i];
  }
  highs[cursor] = bar.high();
  cursor = (cursor + 1) % n;
  if (count < n) count += 1;
  if (isNaN(prior)) return;
  // A new level while no broken line is being held: the line restarts here.
  if (heldBars < 0 && prior != level) {
    level = prior;
    levelT = t;
    fresh = true;
  }
  out_level(level);
  if (fresh) {
    // Create the line (or re-create it on the same id after a delete).
    stop.set(levelT, level, t, level);
    fresh = false;
  } else if (heldBars < 0) {
    // Extend the same line to this bar: a mutation, so its mutated bar moves with it.
    stop.setXy2(t, level);
  }
  if (heldBars < 0 && close > level) {
    // The break: recolor and thicken the existing line, then hold it for a while.
    heldBars = 0;
    stop.color(rgba(239, 68, 68, 255)).width(2);
  } else if (heldBars >= 0) {
    heldBars += 1;
    if (heldBars >= hold) {
      // Delete the held line; the next bar creates a fresh one on the same id.
      stop.delete();
      heldBars = -1;
      level = NaN;
    }
  }
  out_broke(heldBars == 0 ? 1.0 : 0.0);
}
```

### Fill between two lines

`line.fillTo(otherId, color)` shades the area between this line and
another line handle, the way a cloud fills between its two spans. The
color is an `rgba(...)` value whose alpha is the fill's opacity, and
`fillTo(-1, color)` removes the fill. The fill follows both lines as they
move, so set it once and keep moving the lines; it stops showing when the
other line is deleted. Handle coordinates are absolute times, so both
lines, and the fill between them, can reach past the newest bar: that is
how a cloud projected ahead of price is drawn.

```text
const spanA = draw.line(0);   // made once, at module level
const spanB = draw.line(1);
// onBar(), under bar.isLast():
spanA.set(t, a0, tAhead, a1);
spanB.set(t, b0, tAhead, b1);
spanA.fillTo(1, rgba(34, 197, 94, 51));   // a green fill, 20% opaque
```

The other line must be live when `fillTo` runs. A fill toward the line's
own id, toward an id no handle holds, or toward a box, label or polyline
is refused by name, and `fillTo` exists on lines only. When either line
is extended (`extend(Extend.Right)`, say), the shading follows the
extended segments to the canvas edge instead of stopping at the drawn
points.

### Paths built point by point

A polyline handle takes its points from a buffer the module owns: `x0,
y0, x1, y1, ...` in a `StaticArray<f64>`, and `setPoints(points, count)`
sends the first `count` pairs (1..100,000; a run's live polylines hold
524,288 points in all). Re-send as the path grows; the host copies the
points, so the buffer is yours to shift. `closed(true)`
joins the last point back to the first, `fill(c)` fills the polygon
under the stroke, `smooth(true)` curves the path through its points, and
`arrow(Arrow.End)` puts a head on the last segment; a path sent with one
point draws a dot. A zigzag through the last twelve confirmed swings:

```typescript sample=fn-handle-zigzag
param("strength", 3, { min: 1, max: 20, description: "Bars on each side that confirm a swing" });
input("high", ohlcv.high);
output("swing", none, overlay, { description: "The newest confirmed swing price" });
handles.polyline({ color: "#f97316", width: 2 });

const KEEP = 12;
const MAX_STRENGTH = 20;
const WINDOW = 2 * MAX_STRENGTH + 1;
const highs = new StaticArray<f64>(WINDOW);
const lows = new StaticArray<f64>(WINDOW);
const times = new StaticArray<f64>(WINDOW);
// The path's points, x0, y0, x1, y1, ... in one buffer the host reads count pairs from.
const points = new StaticArray<f64>(2 * KEEP);
const path = draw.polyline(0);
let k: i32 = 3;
let window: i32 = 7;
let cursor: i32 = 0;
let filled: i32 = 0;
let stored: i32 = 0;
let lastDir: i32 = 0;
let swing: f64 = NaN;
let changed: bool = false;

function append(x: f64, y: f64): void {
  if (stored == KEEP) {
    // Full: drop the oldest point, keep the newest KEEP - 1.
    for (let i = 0; i < 2 * (KEEP - 1); i++) points[i] = points[i + 2];
    stored -= 1;
  }
  points[2 * stored] = x;
  points[2 * stored + 1] = y;
  stored += 1;
  changed = true;
}

function onStart(): void {
  k = i32(p_strength());
  window = 2 * k + 1;
}

function onBar(): void {
  highs[cursor] = bar.high();
  lows[cursor] = bar.low();
  times[cursor] = bar.time();
  cursor = (cursor + 1) % window;
  if (filled < window) filled += 1;
  changed = false;
  if (filled < window) return;
  // The candidate is the bar k bars back, the center of the window.
  const center = (cursor + k) % window;
  let isHigh = true;
  let isLow = true;
  for (let i = 0; i < window; i++) {
    if (i == center) continue;
    if (highs[i] >= highs[center]) isHigh = false;
    if (lows[i] <= lows[center]) isLow = false;
  }
  // Swings alternate: a high after a high is skipped.
  if (isHigh && lastDir != 1) {
    lastDir = 1;
    swing = highs[center];
    append(times[center], swing);
  } else if (isLow && lastDir != -1) {
    lastDir = -1;
    swing = lows[center];
    append(times[center], swing);
  }
  out_swing(swing);
  // A new swing: re-emit the whole path under the same id.
  if (changed && stored >= 2) path.setPoints(points, stored);
}
```

### Declare every kind you draw

**Run** records the `handles.*` declarations in the sheet it derives as a
`handles` map, one entry per kind with the defaults every new handle of
that kind starts from, under their snake_case names. A kind you did not
declare refuses its draw calls by name when the indicator runs, so an
indicator that draws boxes and labels declares exactly those two, and a
`handles.label` whose labels carry text needs a `string(...)`
declaration. Field ranges are on the [Limits](../reference/limits.md)
page.

### What the chart receives

After a run the chart holds one record per LIVE handle, in creation
order: its kind, the bar it was created on, the bar it was last mutated
on, and its final geometry and style (`createdBar`, `mutatedBar`,
`props`). A deleted handle is
simply absent. On the live chart the newest bar is re-evaluated as
updates arrive (coalesced, at most about once a second): the chart puts
the module AND its handles back to the state after the last closed bar
and re-runs the bar, so an update never stacks a second copy of what the
forming bar drew; when the bar closes, the chart re-runs it once more as
a closed bar (with `bar.isLast()` false) before the new bar starts, so a
chart that has been open all day shows exactly what a fresh load shows
([Execution model](../core-concepts/execution-model.md)).

## Limits

| Family | Cap |
| --- | --- |
| `box` declarations | 16 per indicator, each drawn once per bar |
| `segment` declarations | 16 per indicator, each drawn once per bar |
| renderers | 64 per indicator |
| declared drawings | 64 per indicator; a declared polyline of at most 64 points |
| live handles | 500 per kind, 1500 in total; a polyline handle of at most 100,000 points and 524,288 across the live polylines; 4096 draw calls per bar |
| drawings on the chart | 2,000 per run; a run that would draw more keeps the newest 2,000, and the indicator's legend row says "Drawings capped" |
| string slots | 64, each at most 4096 bytes; 64 KiB of strings per row, 2 MiB per run for strings and frames together |
| bar offsets | literals in -500..500, whole or fractional; a box or segment offset clamps to the loaded range; a handle's absolute coordinates never clamp |

Handles run under the 500-per-kind ceiling, so a module that keeps
drawing deletes stale objects to stay under it; a tracker that evicts
its oldest zone with `delete()` stays under it by construction. The caps
on declared shapes are on declarations,
not objects: a repeating pattern is one `box` gated per bar, or one
handle per live occurrence.

## What you cannot do

- Move a declared shape later: only the forming bar is re-evaluated,
  where the chart replaces that one bar's shapes on each live update,
  never stacks them. Anything that must move, restyle, or vanish on a
  later bar is a handle.
- Draw an unbounded number of things. The caps above are the whole
  budget; a handle-drawing indicator deletes what it no longer needs.
- Reach more than 500 bars away with a literal offset on a box or
  segment; pass an output handle for a data-driven reach, and it still
  clamps to the loaded range. A handle takes an absolute time instead and
  never clamps.
- Fill a box with a named color: the fill takes the opacity, so `color`
  must be hex, `rgb()`, `hsl()` or a theme token.
- Reuse a name across families: outputs, boxes, segments, renderers, and
  declared drawings share one namespace (handles are ids and have no
  name).
- Let an output color, widen, or gate itself.
- Put text in an output, or a string in a param.
- Draw from `onStart()`: handle calls belong in `onBar()`, beside the
  outputs.
- Pin a label to the pane's right edge by coordinates alone: a line
  handle extends past its points with `extend(Extend.Right)`, a line or
  a label keeps beside the price axis with `stickyRight(true)` (or
  `sticky_right` on the declaration), and otherwise a label sits where
  its coordinates say unless it takes a pane `anchor`
  ([Cards, frames and panels](cards-frames-panels.md#pane-pixel-placement)).
- Draw a composite tool (a retracement ladder, a long-and-short box, a
  rotated text card) in one call: build it from handles, extended lines
  with `fillTo`, boxes with text, `extend` and a gradient, labels with
  the `Callout`, `Knockout` and `Plain` looks and an `angle`.
