---
title: "Cards, frames and panels"
description: "Tables, cards, panels and widgets show one snapshot of your indicator rather than one value per bar: a styled table in a corner, a status card, a small chart…"
order: 36
section: "presentation"
---

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

# Cards, frames and panels

Tables, cards, panels and widgets show one snapshot of your indicator
rather than one value per bar: a styled table in a corner, a status card,
a small chart in its own pane, a ladder, a feed or a meter.

Each is a declaration over outputs, string slots or frames the module
already writes, and each selects one snapshot per run: levels docked on
the price axis, a heatmap, a table or tiles in their own pane below the
chart, and the widgets on the price pane. The per-bar vocabulary lives on
other pages: marks, tints and fills on [Plotting](plotting.md),
coordinate drawings and handles on [Drawing objects](drawing-objects.md),
the canvases drawn from cells (heatmaps, footprints, letter profiles,
strike matrices) on [Price canvases](price-canvases.md).

Every colour word on this page takes `"#rrggbb"`, `"#rrggbbaa"` or a
theme token (`theme.up`, `theme.down`, `theme.text`, `theme.muted`,
`theme.bg`, `theme.grid`, `theme.accent`); a token follows the chart's
theme and re-resolves on a theme switch without a rerun. A number prints
through one `format` word (`price`, `%`, `si`, `int`, `0`, `0.0`,
`0.00`, `0.000`, `usd`, `auto`) with `decimals` (0..8), `signed` and
`unit` (0..8 characters) beside it ([Styling](styling.md)).

## Marks, tints and fills

The three per-bar visuals that sit beside a card, in one line each:

- A shaped mark on some bars is `render.shape(name, { output, shape, where?,
  color?, color_by?, colors?, width?, location?, glow?, fill?, fill_opacity?,
  char?, font_family?, tooltip? })`, one of ten shapes (`circle`, `cross`,
  `triangle_up`, `triangle_down` with its apex down, `diamond`, `arrow_up`,
  `arrow_down`, `flag`, `square`, and `char`, one character in
  `font_family`) drawn at the output's value where the gate is nonzero, so
  the mark sits where the output points (`low` for below the bar, `high`
  for above, a level for an absolute price), or where `location` puts it
  (`"above_bar"`, `"below_bar"`, `"top"`, `"bottom"`) without a second
  output; `width` (px, any positive number) sizes it, `glow` halos it,
  `fill: false` keeps the outline only, `fill_opacity` fades the interior
  and `tooltip` is a template shown on the mark:
  [Plotting](plotting.md#text-labels-tables-strips-tints).
- A tinted candle is `render.barcolor(name, { where, color?, color_by?, colors? })`,
  body and wick, and a tint behind the bar is `render.bgcolor` with the
  same options; the two stack (a regime background under trend-colored
  candles). With `width` (0.5..10 px) and `line_style` a `bgcolor` draws
  one vertical line per gated bar instead of a band:
  [Plotting](plotting.md#text-labels-tables-strips-tints).
- A fill between two lines is a `range()` over two drawn outputs
  (`edge_width: 0` keeps the band and drops the edge lines), a
  `fill("upper", "lower", { color?, opacity?, color_by?, colors?,
  color_packed_by?, z? })` that tints the interior only on one pane, or a
  one-bar `box` on every bar, gated by `when` for a conditional fill:
  [Plotting](plotting.md#lines-areas-columns-dots-marks),
  [Styling](styling.md) and [Drawing objects](drawing-objects.md#boxes).

## Styled tables

`render.table(name, { rows, cols, cells, position?, ...look, styles? })`
draws a grid of words in a corner of the chart, and you style it in the
same declaration: column widths, fills with opacity and gradients, a
frame apart from the lines between cells, bold type, merged cells and
rounded corners. Each cell is a string slot your file writes in `onBar()`,
and the table shows the newest bar where every cell was written.

```typescript sample=fn-styled-table-board
// A styled board in the top right corner: a title across both columns, then a trend row and a close row.
output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" });
output("trend_state", none);
string("title", { max_bytes: 24 });
string("under_title", { max_bytes: 1 });
string("trend_label", { max_bytes: 16 });
string("trend_word", { max_bytes: 16 });
string("close_label", { max_bytes: 16 });
string("close_text", { max_bytes: 32 });

// The title spans both columns of row one, over the cell under_title holds. The label column is held at 90px and
// the value column is measured. Dotted lines sit between the rows; a solid, rounded frame sits around the table.
render.table("board", {
  rows: 3,
  cols: 2,
  cells: ["title", "under_title", "trend_label", "trend_word", "close_label", "close_text"],
  position: "top_right",
  width: 220,
  column_widths: [90, 0],
  cell_padding: 6,
  font_size: 11,
  align: "right",
  text_color: "#e2e8f0",
  background_gradient: ["#0f172a", "#1e293b"],
  background_opacity: 0.9,
  border_color: "#475569",
  border_width: 1,
  corner_radius: 8,
  grid_color: "#334155",
  grid_width: 1,
  grid_style: "dotted",
  grid_lines: "rows",
  styles: [
    { cell: "title", colspan: 2, align: "center", font_weight: "bold", gradient: ["#1d4ed8", "#0f172a"], gradient_direction: "horizontal" },
    { cell: "trend_label", align: "left" },
    { cell: "close_label", align: "left" },
    { cell: "trend_word", font_weight: "bold", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.5 },
  ],
});

let average = new Sma(20);

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  const mean = average.update(close);
  const up = !isNaN(mean) && close > mean;
  out_close_line(close);
  out_trend_state(up ? 1.0 : 0.0);
  str_title("Trend board");
  // A covered cell is never drawn, but the table waits for a bar that wrote every cell, so write it empty.
  str_under_title("");
  str_trend_label("Trend");
  str_trend_word(up ? "up" : "down");
  str_close_label("Close");
  sb_clear();
  sb_auto(close);
  str_close_text_sb();
}
```

- **The title spans both columns.** `colspan: 2` on the `title` cell
  covers the cell to its right. That cell keeps a slot of its own,
  `under_title`, written empty every bar: a covered cell is never drawn,
  but the table still waits for a bar that wrote all six cells.
- **Two gradients.** The table blends top to bottom at 90% opacity; the
  title cell has its own gradient, left to right.
- **A frame and a grid.** `border_*` is the line around the table, here
  solid with rounded corners. `grid_*` is the lines between cells, here
  dotted and between rows only.
- **A cell that follows the data.** The trend cell's fill is picked by the
  `trend_state` output every bar: 0 is the first colour, 1 the second.

### Table options

Every key is optional except `rows`, `cols` and `cells`.

| Key | What it sets | Values |
| --- | --- | --- |
| `rows`, `cols`, `cells` | the grid, and one slot name per cell, row by row | at most 32 rows and 8 columns; `rows * cols` names |
| `position`, `offset` | the corner, and a nudge from it | one of the nine anchors, `top_left` to `bottom_right`; `[x, y]` in px, each within 200 |
| `width`, `column_widths` | the table's width, and each column's | px, up to 4096; one width per column, `0` fits the words, and spare width goes to the `0` columns |
| `header_rows`, `header_row`, `header_column` | which rows and column are headers | `header_rows` up to `rows`; `header_row` is the one-row switch; `header_column` makes the first column a header |
| `font_size`, `font_family`, `font_weight` | the type | 6 to 64 px; a font name; `"normal"` or `"bold"` |
| `cell_padding`, `align`, `valign` | the space and the alignment in every cell | 0 to 64 px; `"left"`, `"center"`, `"right"`; `"top"`, `"middle"`, `"bottom"` |
| `text_color`, `header_text_color` | the text colour | a colour |
| `background_color`, `header_background_color`, `background_opacity` | the fills, and how solid they are | a colour; opacity 0 to 1 |
| `background_gradient`, `gradient_direction` | a fill that blends from colour to colour | 2 to 8 colours; `"vertical"` (the default) or `"horizontal"` |
| `border_color`, `border_width`, `border_style`, `corner_radius` | the frame around the table | width 0 to 10; `"solid"`, `"dashed"` or `"dotted"`; corners 0 to 32 px |
| `grid_color`, `grid_width`, `grid_style`, `grid_lines` | the lines between cells | width 0 to 10; the three styles; `"all"`, `"rows"`, `"cols"` or `"none"` |
| `position_by` + `positions`, `font_size_by` | a corner or a type size an output picks per bar | a list of anchors; an output holding the size |
| `rows_by` | how many rows paint: a table whose rows come and go | a data-only output; its value on the table's bar, rounded down and held to 0..`rows`, is the count of rows painted from the top, the rest left out |
| `styles` | one entry per styled cell, named by its slot | `{ cell, ... }` with the cell keys below |

The cell keys, inside a `styles` entry:

| Key | What it sets |
| --- | --- |
| `colspan`, `rowspan` | how many columns to the right, and rows below, the cell covers |
| `color`, `opacity` | the cell's fill, and how solid it is |
| `gradient`, `gradient_direction` | the cell's own gradient (2 to 8 colours), and its own direction |
| `text_color`, `font_size`, `font_weight` | the cell's type |
| `align`, `valign` | the cell's alignment |
| `tooltip` | the cell's hover text: a string slot, its words on the table's bar |

A table sized for its longest form can paint fewer rows, and a cell can
explain itself on hover:

```typescript
output("rows_shown", none); // 3 while the extra rows are on, else 2
string("label", { max_bytes: 16 });
string("value", { max_bytes: 16 });
string("note", { max_bytes: 96 });
// ... one slot per cell of rows 2 and 3 ...
render.table("board", { rows: 3, cols: 2, cells: ["label", "value", "a2", "b2", "a3", "b3"], rows_by: "rows_shown", styles: [{ cell: "value", tooltip: "note" }] });
```

### Colours that follow the data

Every colour key takes a ladder, the way `render.bgcolor` does. Name an
output with `<key>_by`, and its value on the table's bar picks an entry of
`<key>s`: `color_by: "trend_state", colors: ["#7f1d1d", "#14532d"]`. The
value is rounded down, a value outside the list picks the first entry,
and `NaN` keeps the static colour. An output that holds a packed colour is
named with `<key>_packed_by` instead. A colour reads one ladder, never
both.

Colours in a table are literals. A `param.color` paints an output, a
renderer's static colour or a legend entry, never a table.

A second board, with a header row and a tinted trend cell, in one literal:

```typescript sample=fn-styled-table
output("close_line", line, overlay, { color: "#94a3b8", description: "The close, drawn so the table has a chart" });
output("trend_state", none);
string("tf_label", { max_bytes: 16 });
string("trend_word", { max_bytes: 16 });
string("close_label", { max_bytes: 16 });
string("close_text", { max_bytes: 32 });
// A 2 x 2 board: a bold label column held at 80px, numbers right aligned, hairline row separators,
// and the trend cell tinted by the trend output every bar (index 0 = down, 1 = up).
render.table("board", { rows: 2, cols: 2, cells: ["tf_label", "trend_word", "close_label", "close_text"], position: "top_right", width: 220, column_widths: [80, 0], header_rows: 1, cell_padding: 5, font_size: 11, align: "right", background_color: "#0f172a", background_opacity: 0.92, text_color: "#e2e8f0", header_background_color: "#1e293b", border_color: "#334155", border_width: 1, grid_color: "#1e293b", grid_width: 1, grid_lines: "rows", styles: [{ cell: "tf_label", align: "left", font_weight: "bold" }, { cell: "close_label", align: "left" }, { cell: "trend_word", color_by: "trend_state", colors: ["#7f1d1d", "#14532d"], opacity: 0.35 }] });

let average = new Sma(20);

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  const mean = average.update(close);
  out_close_line(close);
  const up = !isNaN(mean) && close > mean;
  out_trend_state(up ? 1.0 : 0.0);
  str_tf_label("Trend");
  str_trend_word(up ? "up" : "down");
  str_close_label("Close");
  sb_clear();
  sb_auto(close);
  str_close_text_sb();
}
```

### Limits and refusals

| Rule | What the build says |
| --- | --- |
| one width per column | `render.table 'board' column_widths lists 1 widths for 2 columns (one per column; 0 = measured)` |
| a span stays inside the grid | `table 'board' cell 'title' spans 3 columns from column 1 of 2` |
| a slot that spans must fit at every cell that lists it, so a covered cell needs its own slot | `table 'board' cell 'title' spans 2 columns from column 2 of 2` |
| a style names one of the table's cells | `render.table 'board' styles names cell 'ghost', which is not one of its cells (title, under_title, trend_label, trend_word, close_label, close_text)` |
| `rows_by` names a data-only output | `render.table 'board' rows_by names 'close_line', which is a drawn output; rows_by takes a data-only output (plot none) holding how many rows paint` |
| a cell's `tooltip` names a string slot | `render.table 'board' styles cell 'title' tooltip names 'ghost', which is not a declared string slot (the cell's hover text is the slot's text)` |
| a ladder comes as a pair | `table 'board' cell 'trend_word' declares color_by without colors (both or neither)` |
| colours are literals | `render.table 'board' text_color references a param, but a param paints an output, a renderer's static colour or a legend entry, never a table; write a colour literal here` |
| a gradient has 2 to 8 colours | `gradient must list at least 2 color stops` |
| opacity is 0 to 1 | `opacity must be <= 1` |
| corners are 0 to 32 px | `corner_radius must be <= 32` |
| words come from the list | `option 'grid_lines' takes "all", "rows", "cols", "none" (a string literal), not 'diagonal'` |

The words in a cell are plain text: never a link, never markup. A table
declared without the look keys draws the chart's plain table. The chart draws
tables with its own table engine, so a styled table shows in screenshots
like every other plot. For inline bars and sparklines in a table under the
chart, declare a `panel.table` over a frame instead (Frames, panels and
compact widgets, below); it takes the same look words, and up to 128 rows
by 12 columns.

## Status cards

`draw.card(name, { title, anchor?, offset?, z?, state_by?, rows,
headline?, ...look })` declares a run-level status card in `drawings[]`,
available from `abi_version: "wrun-2"`. Names share the output, renderer,
drawing, box and segment namespace. A sheet accepts at most 8 cards within
its 64-drawing limit; each card has 1 to 12 rows, or 0 to 12 when it has a
headline. A pane holds 32 cards, feeds and meters across every indicator.

`title` takes 1 to 40 characters, 80 when it holds a `{{template}}`; each
row's `label` takes 1 to 24, 64 with a template. A template reads a
declared output as `{{name}}` (its declared format, else six significant
digits) or `{{name:format}}`, a string slot as `{{name}}`, and the chart's
names as `{{symbol}}`, `{{exchange}}` and `{{timeframe}}`; an unknown name
stays as written, and a templated output joins the card's complete-row
rule below. `anchor` uses the same nine positions as label renderers:
`top_left`, `top_center`, `top_right`, `middle_left`, `middle_center`,
`middle_right`, `bottom_left`, `bottom_center`, `bottom_right`. Defaults
are `top_right`, `offset: [0, 0]` and `z: 0`. Each offset is an integer
from -4096 to 4096; `z` is an integer.

Each row has an optional `value`: a literal string of 0 to 64 characters,
`{ text: "slot_name" }`, or `{ output: "output_name", format?, decimals?,
signed?, unit? }`. Output references always name declared numeric
outputs; `text` always names a declared string slot. Slot contents retain
their declared byte limit. The format words are the shared ten plus `pct`
(the value times 100, two decimals and `%`); without one a value prints
at six significant digits. `usd` prints a `$` with two decimals under
1,000 and `$1.2K`, `$1.2M`, `$1.2B`, `$1.2T` above; `auto` prints six
significant digits with `,` thousands; `price` prints at the chart's
price digits; `decimals` overrides the word's digits, `signed` puts a `+`
on positives, `unit` is appended as written. A row that declares `usd`,
`auto`, `int` or `pct` with no `decimals`, `signed` or `unit` keeps the
text it always printed (`$1234567.00`, six significant digits with no
thousands separators, the rounded integer, the value times 100 at two
decimals), so an older card reads exactly as before; the shared text above
applies to the other words and to any row that declares a companion.

A row's `color` is a colour word or an object containing both `color_by`
and `colors`, with at least two colour words. The output's floored value
picks a palette entry; a finite index outside the palette selects entry 0.
`countdown_to: { output: "deadline" }` carries the numeric output as raw
epoch milliseconds, without converting it to seconds. `clock: true`
asks the chart to show its clock. Both may appear beside a value.

A `headline` is one big number above the rows: `{ output | text, format?,
decimals?, signed?, unit?, color?, font_size?, font_weight?, align? }`,
exactly one of `output` or `text`, the format and colour words as a row's,
`font_size` 6..64 (default 24), `font_weight` `"bold"` unless declared,
`align` `"left"`, `"center"` or `"right"`. `rule` draws a hairline between
the headline and the rows (default on when both exist) in `rule_color`
(default the label ink at 0.4 alpha).

The look, every key optional, absent keeping today's card:

| Keys | What they set |
| --- | --- |
| `chrome` | `"state"` (the default: the state word, the stripe, the border in the state colour) or `"plain"` (no state word, no stripe, no border, a muted title, controls on hover); any key below declared beside it wins |
| `show_state`, `state_colors`, `stripe`, `controls` | the state word; four colours for ok, armed, fired and error (exclusive with `accent_color`); the 3 px stripe; the hide and collapse glyphs `"always"`, `"hover"` or `"none"` (with `"none"` the glyphs stay hidden at rest, and hovering the card still shows them, so a viewer can always hide or collapse it) |
| `accent_color`, `title_text_color`, `title_font_size`, `title_font_weight` | the accent (default the state ink), the title's ink (default the accent), size and weight |
| `background_color`, `background_opacity`, `background_gradient`, `gradient_direction` | the surface (default the theme card surface), its alpha (0..1), 2..8 stops `"vertical"` or `"horizontal"` |
| `border_color`, `border_width`, `border_style`, `corner_radius`, `padding` | the border (default the accent, 0..10 px, `"solid"`, `"dashed"` or `"dotted"`), the corners (0..32 px, default 6), the inset (0..24 px, default 6) |
| `width`, `opacity` | a fixed width (80..1200 px; absent measures the text under a 40% cap, a declared width drops the cap and clamps to the pane width minus 16), the whole card's alpha |
| `font_size`, `font_family`, `font_weight`, `text_color`, `label_text_color` | the type (6..64 px, default 12; `"ui"`, `"mono"`, `"serif"` or `"rounded"`; `"normal"`, `"medium"` or `"bold"`), the value ink (default the theme text) and the label ink (default the theme muted ink) |
| `above_drawings` | paint over every handle and user drawing, still under the legend (default under them) |
| `safe_area` | start the offset past the chart's own chrome: the legend stack at `top_left`, the pane action bar at `top_center` and `top_right`, the price-axis tags on the right anchors (default the pane's 8 px edge) |
| `panel` | `"overlay"` (the price pane) or `"lower"` (the indicator's own lower pane; an indicator with none keeps the card on price) |

The newest ready bar with every referenced numeric output finite and
every referenced slot present wins. Empty strings count as present. If
no bar is complete, the newest ready bar still supplies the card: missing
values become `""`, missing colours and countdowns are omitted, and
missing state reads `ok`. With no ready bar the selection is
`{ kind: "card", name, card: null }`. `state_by` maps exactly `0` to `ok`,
`1` to `armed`, `2` to `fired`, and `3` to `error`; other values read `ok`.

`rows` stays a literal object array, including the nested value,
colour and countdown objects. References are literal names, never
handles; variables, spreads and expressions are refused. **Run** reads
these options and removes the declaration before AssemblyScript
typechecks the file; no card accessor is needed. This
declaration assumes the named outputs and string slot have already been
declared:

```typescript
draw.card("session_pnl", {
  title: "{{symbol}} session",
  anchor: "top_left",
  safe_area: true,
  chrome: "plain",
  headline: { output: "pnl", format: "usd", signed: true, color: { color_by: "pnl_side", colors: ["theme.down", "theme.up"] } },
  rows: [
    { label: "Trades", value: { output: "trades", format: "int" } },
    { label: "Win rate", value: { output: "win_rate_pct", format: "%", decimals: 1 } },
    { label: "Status", value: { text: "status_text" } },
    { label: "Next close", countdown_to: { output: "close_at_ms" } },
  ],
  background_color: "#0f141bf2",
  corner_radius: 10,
  width: 220,
});
```

`draw.feed` and `draw.meter` take the same look, placement and `panel`
words, plus a `title`; a feed also takes `time_format`, a meter
`bar_height` and `track_color`, and a ladder its own list (Frames, panels
and compact widgets, below).

Cards share the selection's 8 MiB expanded-result budget. Accounting is
16 bytes per card and per card row, 8 per carried number (the two offsets,
`z`, and each countdown), plus the UTF-8 bytes of every carried string:
kind, name, title, headline, state, anchor, row labels, resolved values
and colours. Exceeding the budget refuses with
`wrun_render_result_too_large`.

## Everything rolls back correctly

Markers, tints and bands are per-bar records over outputs, so they follow
the engine's forming-bar rule: the chart snapshots the module after the
last closed bar and replays the revised forming bar from that snapshot
as live updates arrive, and that bar's marks, tints, and slices are
replaced, never stacked. Cards replace their selected snapshot on each
run. Handle drawings roll back the same way: the chart keeps the handles
beside the module state, puts both back before each replay, and re-runs
the closed bar once more when it closes, so a handle the forming bar
created or moved is never duplicated by an update
([Execution model](../core-concepts/execution-model.md)).

## Every primitive in one module

A marker on each bullish and bearish cross, a background tint by the
sign of the delta, a shaded band, and a two-cell dashboard, over one
moving-average pair.

```typescript sample=fn-primitives-tour
param("fast", 9, { min: 1, max: 200 });
param("slow", 21, { min: 2, max: 400 });
const fast = output("fast", line, overlay, { color: "#2563eb", width: 2, description: "Fast average" });
const slow = output("slow", line, overlay, { color: "#94a3b8", width: 1, description: "Slow average" });
output("high", none, overlay, { description: "The bar high: where a bearish mark sits" });
output("low", none, overlay, { description: "The bar low: where a bullish mark sits" });
output("bullish", none, overlay, { description: "1 on a bullish-cross bar" });
output("bearish", none, overlay, { description: "1 on a bearish-cross bar" });
output("delta_bucket", none, overlay, { description: "0 fast below slow, 1 fast above: the tint ladder index" });
string("regime_text", { max_bytes: 8 });
string("delta_text", { max_bytes: 16 });
// Markers: an arrow below the bar on a bullish cross, one above it on a bearish cross.
render.shape("buy_mark", { output: "low", shape: "arrow_up", where: "bullish" });
render.shape("sell_mark", { output: "high", shape: "arrow_down", where: "bearish" });
// Bar color: a background tint by regime; index 0 is red, index 1 green, on every bar.
render.bgcolor("regime_tint", { where: "fast", color_by: "delta_bucket", colors: ["#ef444418", "#22c55e18"] });
// Fill between: a slice per bar between the two averages.
box("ribbon", { top: fast, bottom: slow, color: "#2563eb", opacity: 0.1, borderWidth: 0 });
// A 1x2 dashboard: regime word, delta value.
render.table("dashboard", { rows: 1, cols: 2, cells: ["regime_text", "delta_text"], position: "top_right" });

let fastSma = new Sma(9);
let slowSma = new Sma(21);
const cross = new Cross();

function onStart(): void {
  fastSma = new Sma(i32(p_fast()));
  slowSma = new Sma(i32(p_slow()));
}

function onBar(): void {
  const close = bar.close();
  const fastValue = fastSma.update(close);
  const slowValue = slowSma.update(close);
  const crossed = cross.update(fastValue, slowValue);
  if (isNaN(slowValue)) return;
  const above = fastValue >= slowValue;
  out_fast(fastValue);
  out_slow(slowValue);
  out_high(bar.high());
  out_low(bar.low());
  out_bullish(crossed == 1 ? 1.0 : 0.0);
  out_bearish(crossed == -1 ? 1.0 : 0.0);
  out_delta_bucket(above ? 1.0 : 0.0);
  str_regime_text(above ? "long" : "short");
  sb_clear();
  sb_f64(fastValue - slowValue, 2);
  sb_text(" delta");
  str_delta_text_sb();
}
```

`regime_tint` gates on `fast`, which is finite on every ready bar, so
the tint appears everywhere and the ladder picks the color; gate on a
`0`/`1` output instead to tint only some bars. The 8-digit hex colors
carry the tint's alpha.

<!-- indicator-docs:wrun4:start -->
## Frames, panels and compact widgets

Use `wrun-4` frames for one JSON snapshot that survives the whole run. Declare
`frame("book")`, then write it from `onBar()`: a
frame rebuilt on every bar or live tick is built in the generated frame
buffer with `fb_clear()`, `fb_text(s)`, `fb_str(s)`, `fb_int(n)`,
`fb_f64(x, decimals)` and `fb_num(x)` and sent with
`writeFrameBuffer(FRAME_BOOK)`, allocation-free; a small frame the module
already holds as a string goes through `writeFrame(FRAME_BOOK, json)`. Each
frame holds its last write, up to 96 KiB; up to eight frames share a 2 MiB
transport budget with strings.

| Declaration | Snapshot |
| --- | --- |
| `plot.levels({ name, frame, dock, width_frac?, poc?, labels?, color?, span?, ...style })` | `{ prices, values, colors?, ...fields }`; 1..512 monotonic prices and equally sized values, null for gaps; with `span: "time"`, `{ spans: [{ start, end, prices, values, colors? }] }`, 1..64 spans and at most 4096 rows in all |
| `panel.bars`, `panel.line`, `panel.scatter`, `panel.histogram`, `panel.pie`, `panel.heatmap`, `panel.table`, `panel.tiles` | `{ rows, ...chrome }`; kind-specific tuples, at most 2000 rows (a table 128), empty allowed; a chrome field beside `rows` overrides the declared word for that run |
| `plot.matrix({ name, frame, dock?, columns?, ...style })` | `{ prices, cells, cols?, title?, highlight?, range? }`; 1..128 prices, one cell row per price of 1..12 columns, at most 1536 cells ([Price canvases](price-canvases.md)) |
| `draw.ladder({ name, frame, side, divider?, title?, ...style })` | `{ rows, divider?, title? }`; 1..256 price/value/fraction/color rows |
| `draw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style })` | `{ lines, title? }`; 1..50 millisecond-time/text/color rows |
| `draw.minicharts({ name, frame, anchor?, x?, y?, columns?, panel_width?, panel_height?, gap?, ... })` | `{ title?, panels }`; 1..12 labelled panels of 0..100 candles |

Levels default to a width fraction of 0.12.

Every colour in a declaration or a frame takes `#rrggbb`, `#rrggbbaa` or a
theme token (`theme.up`, `theme.down`, `theme.text`, `theme.muted`,
`theme.bg`, `theme.grid`, `theme.accent`), resolved by the chart at paint
time and again on a theme switch. The levels style words (placement, bars,
labels, the point of control, hover and the frame fields they read) are on
[Docked profiles](cards-frames-panels.md#docked-profiles).

Panels declare name, title, x (`time`, `index`, `number`, `category`),
place (`below`, `side`) and frame. Bars, line and table also declare
series (1..8, a table 1..12), each `{ name, color? }` plus its own look.
Histograms and heatmaps use category x; a `number` x takes decimal row
keys (a price grid) on a line, bars or scatter. An unwritten frame leaves
its consumer absent; a malformed written frame refuses with
`wrun_frame_invalid`.

### Panel words

Every word is optional and scoped by kind: a word on a kind it does not
list refuses by name ("`<word>` is only valid for <kinds>"). Absent words
keep the chart's look, except `font_family`, whose default `"ui"` is the
app font (the four words are `"ui"`, `"mono"`, `"serif"`, `"rounded"`).

| Kind | Words |
| --- | --- |
| every kind | `height_frac` (0.05..0.9, the pane's share of the chart), `maximize` (the legend's fullscreen button), `font_family`, `format` with `decimals` (0..8), `signed` and `unit` (1..8 characters) for every value the panel prints |
| line, bars, histogram, scatter, pie | `chrome` (`"box"`, `"grid"` or `"none"`; pie `"box"` or `"none"`), `hover_card` (a readout under the pointer, default on; not on pie or table), `legend_style` (`"none"`, `"title"`, `"pane"`, `"chips"` on a line; bars drop `"chips"`; scatter and pie take `"none"` or `"title"`), `x_title`, `y_title` (1..40), `y_min`, `y_max` (the value axis), `y_zero` (line and scatter), `x_min`, `x_max` (a line with x `index` or `number`, scatter) |
| line, bars, histogram, scatter | `badge` (`{ text: 1..24, color?, text_color? }`): a chip on the panel's title row, filled `theme.accent` unless `color` says otherwise, its text in whichever of light or dark reads on the fill unless `text_color` picks one |
| line | `stats_row` (with `maximize`), `smooth`, `points`, `labels` (each series' last value at its end), `glow`, `animate`, `legend_latest` (with `legend_style: "chips"`), `fill_mode` (`"flat"` or `"signed"`), `fill_positive_color`, `fill_negative_color`, `fill_fade`, `stroke_fade` (`{ pivot: number | "spot", edge_opacity?, left_color?, right_color? }`), `markers` (0..8 of `{ x: number | string | "spot", label?, color?, badge?, wash?, valign?: "top" | "middle" | "point", y?, series?, show_value?, line_style?, width? }`; `valign: "point"` makes the marker a callout pinned at a point on the curve instead of a line down the pane, `y` fixing the value or `series` naming the declared series whose value at `x` anchors it, the first series by default; `y` and `series` need `"point"`, and `series` must be a declared series), `x_format`, `x_decimals`, `x_unit` (with x `index` or `number`), `positive_color`, `negative_color` |
| a line series | `fill`, `fill_color`, `style` (`"line"`, `"bars"`, `"step"`), `smooth`, `points`, `labels`, `width` (0.5..20), `line_style`, `legend` |
| bars | `color_mode` (`"series"` or `"sign"`), `positive_color`, `negative_color`, `x_format`, `x_decimals`, `x_unit` (with x `index` or `number`); a series `legend` |
| histogram | `color`, `labels` (each bin's count), `bins` (2..200 slots reserved on the axis) |
| scatter | `color`, `guides` (0..8 of `{ axis: "x" | "y", value, label?, color?, line_style?, width? }`), `quadrants` (`{ x, y, colors: [4], labels?: [4] }`), `trails`, `trail_width` (0.5..6), `trail_fade`, `label_overlap` (`"hide"`, `"leader"`, `"show"`; with `labels`) |
| pie | `hole_total`, `hole_caption` (1..24; both need `hole > 0`), `slice_gap` (0..8 px), `border_color`, `border_width` (0..10) |
| heatmap | `scale` (`"palette"` or `"signed"`), `palette` (2..8 colours), `min`, `max`, `highlight` (`{ row?, col? }`), `row_title`, `col_title` (1..24), `positive_color`, `negative_color` |
| tiles | `columns` (1..8), `accent` (`"auto"` or `"neutral"`), `positive_color`, `negative_color` |
| table | the styled-table words (`position`, `offset`, `width`, `column_widths`, `cell_padding`, `font_size`, `font_weight`, `align`, `valign`, `text_color`, `header_text_color`, `background_color`, `background_opacity`, `background_gradient`, `gradient_direction`, `header_background_color`, `border_color`, `border_width`, `border_style`, `grid_color`, `grid_width`, `grid_style`, `grid_lines`, `header_column`) with the same ranges; a series `align`, `format`, `decimals`, `signed`, `unit` (the column's) |

A colour word of a panel declaration may name a setting instead of a
colour: `"@<name>"` for a declared `param.color` binds every colour word
above: a series' `color` and `fill_color`, `positive_color` and
`negative_color`, the `fill_*_color` pair, a heatmap's `palette` entries, a
histogram's or a scatter's `color`, the badge, marker, guide and quadrant
colours, the stroke fade's inks and the pie and table colours. The build
writes the setting's default in its place, so the sheet reads as if you
had written that colour, and the chart paints the trader's pick there
before every run; the setting's row is the colour's one control
([The Style page](../settings/style-page.md)). A reference to anything
but a `param.color` is refused by name (`panel.bars 'flow'
positive_color references "@side", which is not a param.color`), and an
`@` in a title or a series name stays text. A colour inside a frame's
rows (a pie slice, a scatter dot, a tile, a table cell) is run data: the
module reads its `param.color` and writes the colour into the row.

```typescript
param.color("bid_ink", "#22c55e", { label: "Bids" });
param.color("ask_ink", "#ef4444", { label: "Asks" });
panel.line({ name: "depth_curve", title: "Cumulative depth", x: "number", place: "below", frame: depth, chrome: "grid", series: [{ name: "Bids", color: "@bid_ink", fill: true, fill_color: "@bid_ink" }, { name: "Asks", color: "@ask_ink" }] });
```

The rich words of a line (`smooth`, `points`, `glow`, `animate`, the
fills, the stroke fade, `markers`, `stats_row`, `legend_latest`,
`hover_card`, `legend_style: "chips"`, a series `fill_color`, `style:
"step"`, `smooth`, `points` or `labels`) need a chrome of `"grid"` or
`"none"`: the declared word, else `"grid"` on an unstacked `index` or
`number` line, else `"box"`. A time line with `chrome: "grid"` spaces its
points by time and labels the axis in the chart's display timezone.

A rich line panel (a chrome of `"grid"` or `"none"`, or a `number` or
`index` x) keeps its own value axis on the left, so the pane's right-hand
value strip stays blank: the strip belongs to series that share the
chart's time axis, and a curve over strikes or distances does not. An
indicator's panels also stack above its time-series panes: the panes that
read the time axis sit at the bottom, next to the time axis, and the
panels sit above them, each group in declaration order.

### Panel frames

Beside `rows`, a frame may carry per-run chrome: `title` (1..40) and
`caption` (1..64) on every kind; `badge` (`{ text, color?, text_color? }`)
on line, bars, histogram and scatter; `markers`, `x_min`, `x_max`, `y_min`,
`y_max` on a line; `y_min`, `y_max` on bars and histogram; `x_min`,
`x_max`, `y_min`, `y_max`, `guides`, `quadrants` on scatter; `min`, `max`,
`highlight` and `summary` (0..5 of `{ label, values }`, the values following
the columns) on a heatmap; `hole_text` and `hole_caption` (1..24) on a pie.
A frame field overrides the declared word of the same name for that run,
`markers` replaces the declared list and `badge` replaces the declared
chip (a frame without the key leaves the declared chip standing); any
other key beside `rows` refuses by name. Frame markers take the same
words as declared ones, `valign: "point"` with `y` or `series` included,
so a module can point at the peak it just found.

Rows take colour words wherever they took a hex colour: a scatter row's
colour, a pie slice's, a tiles row's, a table cell's. A bars value may be
`{ value, color? }` to colour one bar. A tiles row may end with its own
format word (`[label, value, caption?, color?, spark?, format?]`, with null
to skip an element). A table cell may be a styled object with `text`,
`value` (printed through the column's or the table's format when text is
absent), `color`, `bar`, `spark`, `background_color`, `opacity`,
`gradient`, `text_color`, `font_size`, `font_weight`, `align`, `valign`,
`colspan` and `rowspan`; a table takes 128 rows and 12 columns, the other
kinds 2000 rows.

### Side placement and windows

`place: "side"` marks a panel as side content. On a chart it mounts below
the candles like every other panel, in a pane of its own with the same
options. What it adds is a **window**: the chart offers an Indicator with
side content a cell of its own, where the candles are hidden and the
Indicator's panels, tables and lower pane fill the whole height. The
window keeps the market and timeframe of its cell; that market is still
the file's first input and its clock, so the Indicator runs exactly as it
does on a chart.

How a reader opens one:

- The window icon beside the Indicator's name in the legend. It shows
  while the sheet places any panel `side`; once the reader has seen or
  used it, it moves in with the row's hover buttons.
- The legend menu: **Open as window** (a new cell beside the chart, which
  keeps its Indicator), **Show as window here** (this cell becomes the
  window) and, on a window, **Show chart**.
- **Add as window** in the Indicators picker, on a package page and in
  search.

In a window the header carries the Indicator's name, its settings and its
menu; the market chip still switches the market and the Indicator runs
again. Drawing tools, price alerts and replay are off there. An Indicator
with nothing to show beside the candles (price-pane plots only) gets a
"Nothing to show as a window" card with Show chart. Removing the
Indicator from a window that was opened into its own cell closes that
cell and the layout returns to what it was; a chart turned into a window
in place comes back as a chart.

The window sizes the panes itself: tables take the height their rows need
(together at most half the window), the other panels and the lower pane
share the rest. `width_px` (160..480) and `height_px` (80..800) are
accepted only with `place: "side"` and are reserved for a strip beside
the price pane that panels do not use today; the strip beside the price
axis is for `dock: "side"` matrices ([Price canvases](price-canvases.md)).

A window also remembers and answers:

- The pointer on a window's index or category panes (a curve over strikes,
  a bars panel): a guide snaps to the nearest key with a dot per series, a
  readout shows the values there, and the legend values follow the key;
  leaving the pane shows the last row again. `tooltipEnabled: false`
  removes the card only.
- The split a reader drags between a window's panes is remembered across
  reloads for that Indicator; it resets when the Indicator's set of panes
  changes.
- Closing a window cell can be undone: Ctrl+Z / Cmd+Z brings the cell back
  where it was, with the Indicator.
- A draft you are editing can open as a window: press Open as window
  beside Run in the editor; every later Run lands in that window, and
  stopping the draft or closing its tab closes the cell. A draft window is
  not saved: after a reload the cell is an empty chart.

`draw.meter({ name, label, fraction: { output }, ramp, text?, anchor?,
offset?, z? })` reads the last ready numeric fraction. Ramp has 2..5
colour words; text is a short literal or `{ slot }`. A card row's
`spark: { output, window }` carries its last 2..64 ready values, oldest first.

A ladder, feed or meter takes a `title` (1..40 characters; a ladder or
feed frame may carry its own `"title"` per run, and a meter title takes
the card templates), the card surface and chrome words (the background,
border, `corner_radius`, `padding`, `width`, `opacity`, the type words,
`chrome`, `stripe`, `controls`, `accent_color`, `above_drawings`,
`safe_area` and `panel`, with the card's ranges) and an `offset` of
-4096..4096 px. A ladder also takes `width_frac` (0.02..0.5, default
0.14), `offset: [x, 0]` (x 0..4096 px inward from the axis), `opacity`,
`color` (the row default), `labels`, `format`, `decimals`, `signed`,
`unit` (the value text), `text_color`, `font_size`, `font_family`,
`font_weight` and `divider_color`; its frame carries 1..256 rows with
colour words. A feed takes `time_format` (`"HH:mm:ss"`, `"HH:mm"`,
`"MM-dd HH:mm"` or `"none"`); a meter takes `bar_height` (2..40 px) and
`track_color`, and its ramp takes colour words.

`draw.minicharts({ name, frame, ... })` pins a grid of up to 12 small
candle charts to the pane, the grid a multi-timeframe matrix draws. Its
options: `anchor` (default `top_right`) and `x`, `y` pixel
offsets from it (-2000..2000, default -16 and 48), `z`, `columns` 1..4
(2), `panel_width` 72..320 (128), `panel_height` 48..220 (72), `gap` 0..48
(8), the colours `background_color`, `border_color`, `text_color`,
`bull_color`, `bear_color` and `wick_color`, `candle_style` (`candles`,
`hollow`, `bars`, `line`, `area`), two moving averages (`ma_fast_length`,
`ma_slow_length`, each drawn only when given, with `ma_fast_color`,
`ma_slow_color`, `ma_width` 0.5..5 and the `show_fast_ma` /
`show_slow_ma` switches) and `show_change` / `show_volume` for the change
badge and the volume strip, which the chart computes from the candles.
Every option takes a literal. The colours, the switches, the average
lengths, `ma_width` and `candle_style` also take an `"@<setting>"`
reference, so a setting changes that option: a `param.color` for a
colour, a `param.bool` for a switch, a `param.int` with `min` 1 or more
for a length, a `param.number` or `param.int` within 0.5 to 5 for
`ma_width`, a `param.choice` over the style words for `candle_style`.
The setting's default draws until it is changed, and one setting may
drive several options. The
frame carries the title and each panel's label and candles, `[time_ms,
open, high, low, close, volume]` oldest first, volume `null` for none.
Build it with the grid's writers: `mc_begin(title)` clears the frame
buffer and opens the grid, `mc_panel(label)` opens a panel,
`mc_bar(openSec, open, high, low, close, volume)` appends a candle (its
open in epoch seconds), `mc_candles(label, list, bars, formingSec, open,
high, low, close, volume)` writes a whole panel from a `CandleList` of
closed candles plus the forming candle (`NaN` for none; it is drawn last
when it opens after the newest closed one, `bars` candles at most), and
`writeMiniCharts(FRAME_GRID)` sends it. `mc_leg(label, list, bars,
in_x_view(), in_x_cells(), bar.time())` reads that forming candle off a
`candles` input declared with `view: "forming"`, the timeframe's live
candle on the live bar. A title of `{{symbol}}` reads as the chart's
market. A grid with no reference gets settings of its own: the look's
colours, candle style, averages and switches ("Panel background", "Bull
candle", "Fast MA length", "Candle style", "Show volume", ...), each
defaulting to the value written here; write colours as `"#rrggbb"` or
`"#rrggbbaa"`.

`out.inset("vol", { dock: "bottom", height_px: 28, shape: "histogram" })`
declares an ordinary numeric output in a compact strip. Every ready row
appears in its history. Insets work on every ABI. An inset also takes
`color`, `colors` (`[up, down]` by sign, or a ladder beside `color_by`)
and `opacity`.

The build extracts these object declarations before AssemblyScript type
checking. `frame()` returns its slot index; generated `FRAME_<NAME>`
constants provide the same index. The frame builder exists only when
frames are declared. Its UTF-8 scratch is one `StaticArray` sized to the
largest declared `max_bytes`, reserved during module initialization and
reused by every `fb_*` append and by `writeFrame`, so repeated frame writes
never grow guest memory; the string handed to `writeFrame` is the caller's
own allocation, which is why a frame rebuilt per bar builds through `fb_*`
instead. `fb_text` appends raw JSON text (punctuation, keys, words),
`fb_str` a quoted and escaped JSON string, `fb_int` an integer, `fb_f64` a
number with a fixed decimal count, `fb_num` a double's shortest spelling;
`fb_f64` and `fb_num` write `null` for a non-finite value, JSON's gap. Both
senders pass the bytes the frame requires, so an oversized frame refuses
host-side by the slot's `max_bytes`, never truncated.
The caps an author meets are on [Limits](../reference/limits.md).

### Pane pixel placement

Line, box, label and polyline handle declarations accept `anchor` as the
default for newly created handles: `handles.line({ anchor: "top_left" })`.
Absent means chart coordinates. The nine pane spots are `top_left`,
`top_center`, `top_right`, `middle_left`, `middle_center`, `middle_right`,
`bottom_left`, `bottom_center`, `bottom_right`; both coordinates become CSS
pixel offsets. `top` and `bottom` change only y; x remains chart time.
`left` and `right` change only x; y remains chart price.

Offsets start at the pane's content rectangle with no extra inset. Left
and top measure inward to the right and down; right and bottom measure
inward to the left and up. Centre offsets are signed, and negative offsets
are allowed everywhere. The renderer scales pixels once by device pixel
ratio and clips every drawing to its own pane. Time culling applies only
when x remains chart time. `safe_area: true` on a handle declaration
(`handles.box({ anchor: "top_right", safe_area: true })`) starts the
offsets past the chart's own chrome instead: the legend stack at the top
left, the pane action bar at the top centre and right, the price-axis tags
on the right.

After a handle's `set`, call `.anchor(ANCHOR_TOP_LEFT)` or
`style.anchor(handle, ANCHOR_TOP_LEFT)` with an `ANCHOR_*` constant. `ANCHOR_CHART` restores chart
coordinates and clears a declared default until the handle is recreated.
These helpers go through `style` with prop 7, integer values
0..13. Invalid values refuse as `wrun_draw_style_out_of_range`.

Label handles also take `align`: which edge of the text sits on x, with
(x, y) staying the anchor point. `handles.label({ align: "left" })` is the
default for new labels; `left` starts the text at x, `right` ends it there,
`center` or an omitted word centres it as before. After a label's `text`,
call `.align(ALIGN_RIGHT)` or `style.align(label, ALIGN_RIGHT)` with an
`ALIGN_*` constant; `ALIGN_DEFAULT` restores centred text
and clears a declared default. These use prop 8, integer values 0..3, on
labels and box text (a box takes `align`, `valign` and `padding` too);
other kinds refuse as `wrun_draw_prop_unsupported`.

<!-- indicator-docs:wrun4:end -->

## Docked profiles

`plot.levels({ name, frame, dock, ... })` draws a profile docked on the
price axis from a frame the module writes each run: one bar per price,
its length the row's value. The required words and today's optional ones
(`width_frac`, `poc`, `labels`, `color`) keep their meaning; everything
below is opt-in, erased before the file compiles, and takes `"@<param>"`
on any style key so the Style page can change it without a rerun of your
code ([The Style page](../settings/style-page.md)).

Where the profile sits:

| Word | Value | What it does |
| --- | --- | --- |
| `panel` | `"overlay"` (default), `"lower"` | on the price pane, or on the indicator's own lower pane, where the frame's prices read on that pane's value scale (an RSI or CVD profile beside the oscillator); `"lower"` needs a lower pane |
| `behind_candles` | boolean (default false) | the bar fills draw under the candles; labels, the POC, the value-area lines, borders and the outline stay above; refused on `"lower"`, beside `gradient`, or with `shape: "outline"` |
| `span` | `"pane"` (default), `"time"` | docked on the pane edge, or one profile per time span the frame carries ([Price canvases](price-canvases.md#profiles-anchored-in-time)) |
| `offset` | `[x, 0]`, x 0..4096 px | inward from the pane edge, or from the neighbour named by `beside` |
| `beside` | another declared level's name | dock right inside that level (open interest beside gamma); refused when it names itself, an undeclared level, a level on another panel or dock side, or closes a cycle |
| `width_px` | 16..600 px | a fixed dock width instead of `width_frac`; refused beside it |
| `scale`, `scale_max` | `"own"` (default), `"shared"`, `"fixed"`; a number > 0 | `"shared"` scales every `"shared"` level of the indicator by the largest row among them; `"fixed"` fills the dock at `scale_max` (required there, refused otherwise) and clamps longer rows; the frame's `scale_max` overrides it per run |
| `thickness_px` | 2..40 px | the tallest a bar may be, centred on its price |
| `step` | number > 0 (price units) | every row spans its price plus and minus half a step (a strike ladder) |

How the bars paint:

| Word | Value | What it does |
| --- | --- | --- |
| `color`, `opacity` | a colour word; 0..1 (default 0.65) | the bar colour and the alpha every bar colour's own alpha is multiplied by; `opacity: 1` paints the exact colour |
| `baseline` | `"edge"` (default), `"center"` | `"center"` puts zero mid-dock: positive values grow one way, negative the other (a net gamma profile); under `"edge"` a negative row draws nothing |
| `series` | 1..8 of `{ name, color }`, unique names | one row split into stacked segments (calls and puts), fed by the frame's `series` arrays |
| `gradient`, `gradient_direction` | 2..8 colour words; `"horizontal"` (default) or `"vertical"` | fading bars: horizontal runs from the bar's root to its tip; a row or segment with its own colour keeps it and takes the stops' alpha profile; refused beside `behind_candles` |
| `border_color`, `border_width`, `border_style` | a colour word; 0..10 px (1 when a colour is set, else 0); `"solid"`, `"dashed"`, `"dotted"` | an outline on every bar and segment |
| `shape` | `"bars"` (default), `"outline"` | `"outline"` draws no fills, one stepped line along the bar tips per side, broken at null rows |
| `outside_opacity` | 0..1 (default 0.5) | how far rows outside the frame's value area fade |
| `value_area_color`, `value_area_width`, `value_area_line_style`, `value_area_labels` | a colour word (default the level colour); 0..10 px (default 1, 0 draws no lines); `"dashed"` unless declared; boolean | the two lines across the dock at the value area's low and high prices, and the `VAH` and `VAL` tags beside them |

Labels, the point of control and hover:

| Word | Value | What it does |
| --- | --- | --- |
| `labels` | boolean | the row labels; a frame with `labels_text` turns them on unless the declaration says `false` |
| `format`, `decimals`, `signed`, `unit` | the shared number words | how a row without its own text (and its hover value) prints; without a format word the value prints as written |
| `text_color`, `font_size`, `font_weight`, `font_family` | a colour word (default the row colour at full alpha); 6..64 px (default 10); the weight word; `"ui"`, `"mono"`, `"serif"`, `"rounded"` (default the chart axis font) | the label type |
| `label_place` | `"outside"` (default), `"inside"`, `"axis"` | beyond the bar tip, inside the bar at its tip, or inside at its root |
| `hover` | boolean (default false) | hovering a row opens the chart's hover card: the price, the value, one line per series, the row's tooltip |
| `poc`, `poc_color`, `poc_width`, `poc_line_style`, `poc_extend`, `poc_label` | boolean; a colour word (default the POC row colour); 1..10 px; the line style; `"dock"` (default) or `"pane"` (across the whole pane); boolean | the point of control line (the largest row unless the frame names `poc_price`) and its tag (`POC` and the price, or the frame's `poc_label_text`) |

The frame carries the data and may carry per-run chrome beside `prices`,
`values` and `colors`:

| Field | Value | What it does |
| --- | --- | --- |
| `prices`, `values`, `colors` | 1..512 strictly ordered numbers; as many numbers or null (a gap that keeps its band); colour strings (`#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, a theme token; anything else falls back to the level colour) | the rows |
| `labels_text` | one string of 0..24 characters or null per row | that text on the row; `""` leaves the row bare; null prints the formatted value |
| `series` | one array of numbers or null per declared series, each the length of `prices` | the segments, stacking outward from the baseline in series order; `values` still names the row for its label and hover; never beside `colors` |
| `lows`, `highs` | one number or null per row, both arrays together, `low` below `high` | a row's own price band; a null pair falls back to `step`, else to the neighbours' midpoints |
| `tooltips` | one string of 0..64 characters or null per row | the hover card's hint |
| `value_area` | `[low, high]` prices | the value area: rows outside dim, two lines across the dock |
| `poc_price`, `poc_label_text` | a number; 1..24 characters | the price the POC line marks; the tag's text (turns the tag on) |
| `scale_max` | a number > 0 | the fixed scale for this run; `scale: "fixed"` only |
| `spans` | the time-anchored shape | with `span: "time"`, in place of the fields above |

A frame whose shape disagrees with the declaration (a `series` count that
differs, `colors` beside `series`, a `scale_max` on a level whose scale
is not `"fixed"`, a `spans` frame on `span: "pane"`) refuses the run as
`wrun_frame_invalid`. A two-sided profile of calls and puts, net at the
baseline, with a value area and a tagged point of control:

```typescript
param.choice("side", ["right", "left"], "right");
param.color("call_ink", "#38bdf8");
param.color("put_ink", "#a78bfa");
const gexFrame = frame("gex_levels", { max_bytes: 32768 });
plot.levels({ name: "gex", frame: gexFrame, dock: "@side", baseline: "center", width_frac: 0.18, series: [{ name: "Calls", color: "@call_ink" }, { name: "Puts", color: "@put_ink" }], labels: true, format: "usd", label_place: "inside", poc: true, poc_extend: "pane", poc_label: true, hover: true, outside_opacity: 0.4 });
```

```json
{
  "prices": [62000, 62500, 63000, 63500],
  "values": [41000, -18500, 25000, 9000],
  "series": [
    [52000, 11000, 30000, 12000],
    [-11000, -29500, -5000, -3000]
  ],
  "labels_text": ["C 52.0K / P 11.0K", null, "", null],
  "tooltips": ["Calls 1,240 OI / Puts 860 OI", null, null, null],
  "value_area": [62000, 63000],
  "poc_price": 62000,
  "poc_label_text": "Max pain"
}
```

A session volume profile the module bins itself, docked right: rows six
pixels thick, the value area marked by dotted amber lines with their `VAH`
and `VAL` tags, rows outside it faded, and the point of control drawn
across the dock.

```typescript
const sessionFrame = frame("session_rows", { max_bytes: 16384 });
plot.levels({
  name: "session_profile",
  frame: sessionFrame,
  dock: "right",
  width_frac: 0.2,
  color: "#38bdf8",
  thickness_px: 6,
  outside_opacity: 0.35,
  value_area_color: "#f59e0b",
  value_area_width: 1.5,
  value_area_line_style: "dotted",
  value_area_labels: true,
  poc: true,
});
```

Open interest by price with every bar outlined in a dotted hairline, the
fills at half strength, and a strong dashed point of control across the
whole pane with its tag on: the look for a profile that sits under other
overlays and still has to read.

```typescript
const oiRows = frame("oi_rows", { max_bytes: 16384 });
plot.levels({
  name: "oi_profile",
  frame: oiRows,
  dock: "left",
  width_frac: 0.15,
  color: "#a78bfa",
  opacity: 0.5,
  border_color: "#c4b5fd",
  border_width: 1,
  border_style: "dotted",
  poc: true,
  poc_color: "#f472b6",
  poc_width: 3,
  poc_line_style: "dashed",
  poc_extend: "pane",
  poc_label: true,
});
```

A liquidation map docked right inside that open interest profile, on a
fixed scale so a new cluster never rescales the dock, each row spanning the
price band its frame gives it instead of the midpoints between neighbours.

```typescript
const oiRows = frame("oi_rows", { max_bytes: 16384 });
const liqRows = frame("liq_rows", { max_bytes: 16384 });
plot.levels({ name: "oi_profile", frame: oiRows, dock: "left", width_frac: 0.15, color: "#a78bfa", format: "si" });
plot.levels({
  name: "liq_map",
  frame: liqRows,
  dock: "left",
  beside: "oi_profile",
  width_frac: 0.08,
  color: "#f97316",
  scale: "fixed",
  scale_max: 50000000,
  format: "usd",
  hover: true,
});
```

```json
{
  "prices": [61250, 61750, 62250, 62750],
  "values": [12500000, 48000000, 9000000, 31000000],
  "lows": [61000, 61500, 62000, 62500],
  "highs": [61500, 62000, 62500, 63000],
  "scale_max": 60000000
}
```

### Session profiles from cells

The chart can also draw a profile itself from `volume_profile` cells, one
per session, through `plot.profile`
([Price canvases](price-canvases.md#profiles-anchored-in-time)); it styles
the same parts in its own words. Here each row splits buy and sell volume,
rows outside the value area take a faded pair of colours, the point of
control is thick, the three levels stay drawn until price trades through
them, and hovering a cell reads it out.

```typescript
input("close", ohlcv.close);
input("profile", volume_profile.cells, { max_cells: 8192 });
plot.profile({
  name: "session_vp",
  cells: "profile",
  span: "session",
  session: "1d",
  mode: "split_bar",
  buy_color: "#34d399",
  sell_color: "#f87171",
  row_height: 25,
  value_area: 0.7,
  outside_buy_color: "#34d39955",
  outside_sell_color: "#f8717155",
  poc: true,
  poc_width: 3,
  naked: true,
  level_labels: true,
  cell_hover: true,
});
```

The caps a profile meets (four levels, 512 rows, 64 spans and 4096 rows
across them, 96 KiB per frame) are on [Limits](../reference/limits.md).

## Panel rows, kind by kind

A panel draws a small chart of its own in a pane below the chart
(`place: "below"`), or as side content (`"side"`) that mounts below on a
chart and fills a window ([Side placement and
windows](#side-placement-and-windows)). The file writes the panel's rows
into a frame, usually once, on the newest bar, and each kind reads its
rows in its own shape:

| Panel | One row | Options of its own |
| --- | --- | --- |
| `panel.bars`, `panel.line` | `[key, v1, ..., vN]`: one value per series, `null` for a gap | `series` (1 to 8, required), `stacked`; bars also `orientation` (`"vertical"` or `"horizontal"`) |
| `panel.scatter` | `[key, x, y, size?, color?, label?]`: a dot at (`x`, `y`), the label at most 24 characters | none |
| `panel.histogram` | `[label, count]`: a bin's label (1 to 24 characters) and its count | `bins` (2 to 200), `orientation`; `x: "category"` only |
| `panel.pie` | `[name, value, color?]`: one slice, at most 24 | `hole` (0 to 0.8) |
| `panel.heatmap` | `[x key, y key, value]`: one cell, `null` for an empty one | `x: "category"` only |
| `panel.table` | one cell per series and no key: words (at most 64 characters), a number, or a styled cell `{ text?, value?, color?, bar?, spark?, background_color?, opacity?, gradient?, text_color?, font_size?, font_weight?, align?, valign?, colspan?, rowspan? }`; at most 128 rows | `series` (1 to 12, required) |
| `panel.tiles` | `[label, value, caption?, color?, spark?]`: one tile, at most 24 | none |

Every panel also takes `name`, `title`, `x`, `place` and `frame`, and the
look words of its kind (Frames, panels and compact widgets, above). `x`
says what a key is: `"time"` an epoch second, `"index"` a whole number,
`"number"` a decimal (a price grid), `"category"` a word of 1 to 64
characters. A panel holds at most 2,000 rows unless the table says fewer
(128), and an empty `rows` list draws an empty panel. A color is a colour
word: a hex literal such as `"#16a34a"`, `"#rrggbbaa"` or a theme token.

Volume by weekday as stacked bars, one bar per day of the week:

```typescript sample=fn-panel-weekday-bars
// Volume by weekday in a pane below the chart: one bar per day of the week, up-bar and down-bar volume stacked.
output("weekday", none, overlay, { description: "The bar's weekday, 0 Sunday to 6 Saturday, UTC" });
const days = frame("days", { max_bytes: 1024 });
panel.bars({ name: "by_weekday", title: "Volume by weekday (UTC)", x: "category", place: "below", frame: days, stacked: true, series: [{ name: "Up bars", color: "#16a34a" }, { name: "Down bars", color: "#dc2626" }] });

const NAMES: StaticArray<string> = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const upVolume = new StaticArray<f64>(7);
const downVolume = new StaticArray<f64>(7);
let clock = new Clock();

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  clock.update(bar.time());
  const day = clock.weekday();
  if (close >= bar.open()) upVolume[day] += bar.volume();
  else downVolume[day] += bar.volume();
  out_weekday(f64(day));
  if (!bar.isLast()) return;
  // One row per weekday: [category, up volume, down volume], one value per series.
  fb_clear();
  fb_text('{"rows":[');
  for (let i = 0; i < 7; i++) {
    if (i > 0) fb_text(",");
    fb_text("[");
    fb_str(NAMES[i]);
    fb_text(",");
    fb_f64(upVolume[i], 0);
    fb_text(",");
    fb_f64(downVolume[i], 0);
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_DAYS);
}
```

- **One value per series.** `["Mon", up, down]` carries the two declared
  series in their order; `stacked: true` puts them in one bar.
- **Written once, on the newest bar.** The sums grow on every bar, and the
  frame is built on `bar.isLast()` with the `fb_*` writers, which never
  allocate.
- **The last write wins.** The panel shows what the newest bar wrote; a
  frame never written leaves the panel off the chart, and a row in the
  wrong shape stops the run with `wrun_frame_invalid`.

The looks below take the words of the Panel words table, kind by kind;
every declaration assumes `onBar()` writes the named frame.

### Line panels

A gamma exposure curve: net dealer gamma by strike, filled green where it
is positive and red where it is negative, the fill fading away from the
line, the spot strike marked with a badge that prints the value there, a
chip on the title row naming the regime, a callout pinned to the curve at
the peak strike, and a stats row for the maximised view. Reach for the
signed fill whenever a curve's sign is the story. The `badge` word is the
chip: its text, an optional fill (`theme.accent` when absent) and an
optional text colour (light or dark, whichever reads on the fill, when
absent). A marker with `valign: "point"` is the callout: instead of a line
down the pane it draws a dot on the curve with a short leader to its
label, anchored where the named `series` crosses `x` (the first series
when `series` is absent), or at an exact value when `y` is given.

```typescript
const gexRows = frame("gex_rows", { max_bytes: 16384 });
panel.line({
  name: "gex_curve",
  title: "Gamma exposure by strike",
  x: "number",
  place: "below",
  frame: gexRows,
  height_frac: 0.3,
  chrome: "grid",
  maximize: true,
  stats_row: true,
  y_zero: true,
  fill_mode: "signed",
  fill_positive_color: "#22c55e",
  fill_negative_color: "#ef4444",
  fill_fade: true,
  badge: { text: "LONG GAMMA", color: "#22c55e", text_color: "#0b0d12" },
  markers: [
    { x: "spot", label: "Spot", badge: true, show_value: true },
    { x: 64000, label: "Peak", valign: "point", series: "Net gamma", color: "#22c55e" },
  ],
  series: [{ name: "Net gamma", color: "#e2e8f0", width: 2, smooth: true }],
});
```

The frame carries the rows and, per run, the chrome that moves: a caption
naming the expiry window, a fixed value axis so the curve does not rescale
on every tick, the badge (a frame badge replaces the declared chip for
that run, so the regime flips to "SHORT GAMMA" in red when the module
finds dealers short), and the markers. The frame's list replaces the
declared one, so the spot marker is written again beside the flip strike,
which the module finds where the curve crosses zero and washes down the
pane in its colour, and two callouts pin the peak and the trough: the peak
reads its height off the `Net gamma` series, the trough is pinned at an
exact `y`.

```json
{
  "title": "Gamma exposure by strike",
  "caption": "Dealer gamma per 1% move, 0 to 7 days to expiry",
  "y_min": -4000000,
  "y_max": 6000000,
  "badge": {
    "text": "SHORT GAMMA",
    "color": "#ef4444",
    "text_color": "#f4f4f4"
  },
  "markers": [
    { "x": "spot", "label": "Spot", "badge": true, "show_value": true },
    {
      "x": 63500,
      "label": "Flip",
      "color": "#f59e0b",
      "line_style": "dashed",
      "valign": "middle",
      "wash": true
    },
    {
      "x": 64000,
      "label": "Peak",
      "valign": "point",
      "series": "Net gamma",
      "color": "#22c55e",
      "show_value": true
    },
    {
      "x": 61000,
      "label": "Trough",
      "valign": "point",
      "y": -2500000,
      "color": "#ef4444"
    }
  ],
  "rows": [
    [60000, -1200000],
    [61000, -2500000],
    [62000, -800000],
    [63000, 1500000],
    [64000, 3900000],
    [65000, 2200000]
  ]
}
```

A cumulative depth curve, bids and asks by distance from the mid price:
both axes titled, the x axis printed as a percentage with two decimals,
the value axis pinned from 0 to 500 so the two sides share one scale across
runs, and chips in the legend showing each side's latest value.

```typescript
const depth = frame("depth", { max_bytes: 16384 });
panel.line({
  name: "depth_curve",
  title: "Cumulative depth",
  x: "number",
  place: "below",
  frame: depth,
  x_title: "Distance from mid",
  y_title: "Size (BTC)",
  x_format: "auto",
  x_decimals: 2,
  x_unit: "%",
  y_min: 0,
  y_max: 500,
  legend_style: "chips",
  legend_latest: true,
  format: "0.0",
  series: [
    { name: "Bids", color: "#22c55e" },
    { name: "Asks", color: "#ef4444" },
  ],
});
```

A liquidation density curve around spot, glowing and animated as it
updates, its stroke fading toward the edges, red for the longs below spot
and green for the shorts above, with a tint under the line in the same
ink. Reach for the fade when the pivot is the point and the tails are
context.

```typescript
const liqs = frame("liqs", { max_bytes: 16384 });
panel.line({
  name: "liq_density",
  title: "Liquidation density",
  x: "number",
  place: "below",
  frame: liqs,
  glow: true,
  animate: true,
  stroke_fade: { pivot: "spot", edge_opacity: 0.15, left_color: "#ef4444", right_color: "#22c55e" },
  series: [{ name: "Liquidations", color: "#f59e0b", width: 1.5, fill: true, fill_color: "#f59e0b33" }],
});
```

### Bars

Funding by venue as bars coloured by sign, the axis pinned from -0.05% to
0.05% so venues compare across runs, and a readout under the pointer.

```typescript
const funding = frame("funding", { max_bytes: 4096 });
panel.bars({
  name: "funding_by_venue",
  title: "8h funding by venue",
  x: "category",
  place: "below",
  frame: funding,
  color_mode: "sign",
  positive_color: "#22c55e",
  negative_color: "#ef4444",
  y_title: "Funding",
  y_min: -0.05,
  y_max: 0.05,
  hover_card: true,
  format: "0.000",
  unit: "%",
  series: [{ name: "Funding", color: "#94a3b8" }],
});
```

### Scatter

Each venue as a dot of funding against open interest change: the plane cut
into four named quadrants at zero, a guide where funding turns rich, the
funding axis pinned so the quadrants keep their place, venue names on the
dots with leader lines where they would collide, and every dot trailing its
last positions, the trail fading as it ages.

```typescript
const venues = frame("venues", { max_bytes: 8192 });
panel.scatter({
  name: "funding_vs_oi",
  title: "Funding against OI change",
  x: "number",
  place: "below",
  frame: venues,
  x_title: "8h funding",
  y_title: "OI change, 24h",
  x_min: -0.05,
  x_max: 0.1,
  labels: true,
  label_overlap: "leader",
  quadrants: {
    x: 0,
    y: 0,
    colors: ["#22c55e22", "#3b82f622", "#ef444422", "#f59e0b22"],
    labels: ["Longs crowding", "Shorts crowding", "Shorts unwinding", "Longs unwinding"],
  },
  guides: [{ axis: "x", value: 0.03, label: "Rich", color: "#f59e0b", line_style: "dashed" }],
  trails: true,
  trail_width: 1.5,
  trail_fade: true,
});
```

### Pie

Open interest by expiry as a ring: the hole prints the total with a caption
under it, a small gap between slices and a thin border on each in the
pane's own ink.

```typescript
const expiries = frame("expiries", { max_bytes: 4096 });
panel.pie({
  name: "oi_by_expiry",
  title: "Open interest by expiry",
  x: "category",
  place: "below",
  frame: expiries,
  hole: 0.55,
  hole_total: true,
  hole_caption: "Total OI",
  slice_gap: 2,
  border_color: "#0f172a",
  border_width: 1,
  format: "usd",
  legend_style: "title",
});
```

The frame's rows are `[name, value, color?]` slices; for a run where the
hole should say something other than the total, the frame carries its own
words, `hole_text` with a `hole_caption` that replaces the declared one.

```json
{
  "hole_text": "$1.92B",
  "hole_caption": "Expiring this week",
  "rows": [
    ["04 Oct", 820000000, "#38bdf8"],
    ["11 Oct", 460000000, "#818cf8"],
    ["25 Oct", 390000000, "#a78bfa"],
    ["27 Dec", 250000000, "#c4b5fd"]
  ]
}
```

### Heatmap

Funding by venue and hour as a signed heatmap, positive cells warm and
negative cells cool around a neutral zero, the axes titled and a readout
under the pointer. Reach for the signed scale when the data has a
meaningful zero; a palette scale suits ranks and counts.

```typescript
const hours = frame("hours", { max_bytes: 16384 });
panel.heatmap({
  name: "funding_heat",
  title: "Funding by venue and hour",
  x: "category",
  place: "below",
  frame: hours,
  scale: "signed",
  positive_color: "#f97316",
  negative_color: "#38bdf8",
  row_title: "Venue",
  col_title: "Hour (UTC)",
  hover_card: true,
  format: "0.000",
  unit: "%",
});
```

The frame's rows are `[hour, venue, value]` cells; beside them it can light
the live hour's column and add summary rows under the grid, their values
following the columns in the order the rows first named them.

```json
{
  "caption": "8h funding, the last 7 days",
  "highlight": { "col": "16:00" },
  "summary": [{ "label": "Mean", "values": [0.0047, 0.0037] }],
  "rows": [
    ["08:00", "Binance", 0.01],
    ["08:00", "Bybit", 0.0062],
    ["08:00", "OKX", -0.002],
    ["16:00", "Binance", 0.0071],
    ["16:00", "Bybit", 0.0045],
    ["16:00", "OKX", -0.0004]
  ]
}
```

### Tiles

Tiles for the headline numbers, three across, each tile's accent following
the sign of its value: green ink on a positive flow, red on a negative one,
the numbers printed compact and signed.

```typescript
const summary = frame("summary", { max_bytes: 4096 });
panel.tiles({
  name: "flow_tiles",
  title: "24h flow",
  x: "category",
  place: "below",
  frame: summary,
  columns: 3,
  accent: "auto",
  positive_color: "#22c55e",
  negative_color: "#ef4444",
  hover_card: true,
  format: "si",
  signed: true,
  unit: "BTC",
});
```

### Table

A table beside the chart with its first column as the row headers: a
dashed frame, muted header ink, hairlines between rows, every cell centred
vertically, the two value columns right-aligned and each formatted its own
way.

```typescript
const board = frame("board", { max_bytes: 8192 });
panel.table({
  name: "venue_board",
  title: "Venue board",
  x: "category",
  place: "side",
  frame: board,
  width_px: 320,
  header_column: true,
  header_text_color: "#94a3b8",
  header_background_color: "#1e293b",
  text_color: "#e2e8f0",
  border_color: "#475569",
  border_width: 1,
  border_style: "dashed",
  grid_color: "#334155",
  grid_width: 1,
  grid_lines: "rows",
  valign: "middle",
  cell_padding: 6,
  column_widths: [96, 0, 0, 0],
  series: [
    { name: "Venue" },
    { name: "Market" },
    { name: "OI", align: "right", format: "usd" },
    { name: "Funding", align: "right", format: "0.000", unit: "%" },
  ],
});
```

The frame's cells are words, numbers or styled cells: a styled cell may
span rows, so a venue's name sits once beside its two markets, and a
`value` cell with `bar` draws an inline bar behind the number, its length
0 to 1 of the cell; `caption` prints a line under the table.

```json
{
  "caption": "USDT perpetuals, 15:04 UTC",
  "rows": [
    [
      {
        "text": "Binance",
        "rowspan": 2,
        "valign": "middle",
        "font_weight": "bold"
      },
      "BTCUSDT",
      { "value": 4210000000, "bar": 1 },
      0.01
    ],
    ["", "ETHUSDT", { "value": 1830000000, "bar": 0.43 }, 0.0082],
    [
      { "text": "Bybit", "font_weight": "bold" },
      "BTCUSDT",
      { "value": 2640000000, "bar": 0.63 },
      -0.0041
    ]
  ]
}
```

## Ladder, feed and meter

Three small widgets sit on the price pane. A ladder is a column of bars at
their prices along one side, a feed is a few lines of text with their
times, and a meter is one bar filled from 0 to 1.

| Widget | Declare | Write |
| --- | --- | --- |
| Ladder | `draw.ladder({ name, frame, side, divider?, title?, ...style })`, `side` `"left"` or `"right"` | a frame `{ rows, divider?, title? }`: 1 to 256 rows `[price, value, fraction, color?]`, the fraction (0 to 1) the bar's length; `divider` `{ label, price }` marks one price, and a divider in the frame replaces the declared one |
| Feed | `draw.feed({ name, frame, anchor?, offset?, z?, title?, time_format?, ...style })` | a frame `{ lines, title? }`: 1 to 50 lines `[time, text, color?]`, the time in epoch milliseconds and the text at most 80 characters |
| Meter | `draw.meter({ name, label, fraction: { output }, ramp, text?, anchor?, offset?, z?, title?, bar_height?, track_color?, ...style })` | no frame: the meter reads the output on the newest ready bar, a fraction from 0 to 1, colored along `ramp` (2 to 5 colors); `text` is a literal or `{ slot }` |

The `title`, `time_format`, `bar_height` and `track_color` words, and the
card surface and chrome a widget shares, are listed under Frames, panels
and compact widgets above; a ladder's frame rows and a meter's ramp take
colour words.

The last bars' volume by price as a ladder on the right of the pane:

```typescript sample=fn-volume-ladder
// The last bars' volume by price as a ladder on the right of the pane: eight rows from the window's low to its high, the busiest in amber.
param.int("window", 100, { min: 10, max: 500, label: "Bars in the window" });
output("close_line", line, overlay, { color: "#94a3b8", description: "The close" });
const rows = frame("rows", { max_bytes: 2048 });
draw.ladder({ name: "volume_by_price", frame: rows, side: "right" });

const MAX = 500;
const ROWS = 8;
const closes = new StaticArray<f64>(MAX);
const volumes = new StaticArray<f64>(MAX);
const binned = new StaticArray<f64>(ROWS);
let window: i32 = 100;
let head: i32 = 0;
let count: i32 = 0;

function onStart(): void {
  window = i32(p_window());
}

function onBar(): void {
  const close = bar.close();
  if (isNaN(close)) return;
  out_close_line(close);
  closes[head] = close;
  volumes[head] = bar.volume();
  head = (head + 1) % window;
  if (count < window) count += 1;
  if (!bar.isLast()) return;
  let lo = Infinity;
  let hi = -Infinity;
  for (let i = 0; i < count; i++) {
    lo = Math.min(lo, closes[i]);
    hi = Math.max(hi, closes[i]);
  }
  if (!(hi > lo)) return;
  const step = (hi - lo) / f64(ROWS);
  for (let r = 0; r < ROWS; r++) binned[r] = 0.0;
  for (let i = 0; i < count; i++) {
    const r = i32(Math.min(f64(ROWS - 1), Math.floor((closes[i] - lo) / step)));
    binned[r] += volumes[i];
  }
  let busiest = 0.0;
  for (let r = 0; r < ROWS; r++) busiest = Math.max(busiest, binned[r]);
  if (busiest <= 0.0) return;
  // One row per price band: [price, volume, the bar's length 0 to 1, color]. The divider marks the last close.
  fb_clear();
  fb_text('{"rows":[');
  for (let r = 0; r < ROWS; r++) {
    if (r > 0) fb_text(",");
    fb_text("[");
    fb_num(lo + step * (f64(r) + 0.5));
    fb_text(",");
    fb_f64(binned[r], 0);
    fb_text(",");
    fb_f64(binned[r] / busiest, 3);
    fb_text(",");
    fb_str(binned[r] == busiest ? "#f59e0b" : "#64748b");
    fb_text("]");
  }
  fb_text('],"divider":{"label":"Last","price":');
  fb_num(close);
  fb_text("}}");
  writeFrameBuffer(FRAME_ROWS);
}
```

- **Rows at prices.** Each row is a price band's middle price, its volume,
  its bar's length against the busiest band, and its color.
- **The divider.** `"divider": { "label": "Last", "price": close }` in the
  frame marks the last close.
- **Sized once.** The window lives in two `StaticArray`s made at module
  start, so nothing allocates per bar.

An RSI meter in the corner, and a feed of the RSI's last five crossings of
70 and 30:

```typescript sample=fn-meter-and-feed
// An RSI meter in the top-left corner, and a feed of the last five times the RSI crossed 70 or 30.
param.int("length", 14, { min: 2, max: 100, label: "RSI length" });
output("rsi", line, lower, { color: "#a855f7", description: "RSI" });
output("rsi_fraction", none, lower, { description: "The RSI as a fraction, 0 to 1: the meter's fill" });
string("rsi_text", { max_bytes: 8 });
draw.meter({ name: "rsi_meter", label: "RSI", fraction: { output: "rsi_fraction" }, ramp: ["#3b82f6", "#64748b", "#ef4444"], text: { slot: "rsi_text" }, anchor: "top_left" });
const events = frame("events", { max_bytes: 2048 });
draw.feed({ name: "rsi_events", frame: events, anchor: "bottom_left" });

const KEEP = 5;
const times = new StaticArray<f64>(KEEP);
const ups = new StaticArray<bool>(KEEP);
let head: i32 = 0;
let count: i32 = 0;
let rsi = new Rsi(14);
let prev: f64 = NaN;

function onStart(): void {
  rsi = new Rsi(i32(p_length()));
}

function onBar(): void {
  const value = rsi.update(bar.close());
  if (isNaN(value)) return;
  out_rsi(value);
  out_rsi_fraction(value / 100.0);
  sb_clear();
  sb_f64(value, 1);
  str_rsi_text_sb();
  // A crossing joins the feed; the oldest of the five leaves.
  const above = prev <= 70.0 && value > 70.0;
  const below = prev >= 30.0 && value < 30.0;
  if (above || below) {
    times[head] = bar.time() * 1000.0;
    ups[head] = above;
    head = (head + 1) % KEEP;
    if (count < KEEP) count += 1;
  }
  prev = value;
  if (!bar.isLast() || count == 0) return;
  // One line per crossing, newest first: [time in milliseconds, text, color].
  fb_clear();
  fb_text('{"lines":[');
  for (let k = 0; k < count; k++) {
    const i = (head - 1 - k + KEEP) % KEEP;
    if (k > 0) fb_text(",");
    fb_text("[");
    fb_f64(times[i], 0);
    fb_text(",");
    fb_str(ups[i] ? "RSI crossed above 70" : "RSI crossed below 30");
    fb_text(",");
    fb_str(ups[i] ? "#ef4444" : "#3b82f6");
    fb_text("]");
  }
  fb_text("]}");
  writeFrameBuffer(FRAME_EVENTS);
}
```

- **The meter reads an output.** `fraction: { output: "rsi_fraction" }`
  takes the RSI over 100 on the newest bar, and `text: { slot: "rsi_text" }`
  prints the reading on it.
- **The feed is a frame.** Each line is `[time, text, color]`, the time
  `bar.time() * 1000` because a feed counts milliseconds, newest first.
- **Nothing to show, nothing written.** The feed is written only once a
  crossing exists; until then the frame stays unwritten and the feed is
  off the chart.

## Next

- **Plotting:** lines, marks, tints and fills, one value per bar ([Plotting](plotting.md))
- **Drawing objects:** lines, boxes and labels your file places and moves ([Drawing objects](drawing-objects.md))
- **HUD and hover cards:** tiles in a corner and a card under the cursor ([HUD and hover cards](hud-and-hover-cards.md))
- **Limits:** every cap on frames, panels, tables and widgets ([Limits](../reference/limits.md))
