---
title: "Marks, spans and rays"
description: "Three declarations draw many small things on each bar, in one batch: plot.marks (bubbles, dots, rings or squares, each at a price with a quantity), plot.spans…"
order: 40
section: "presentation"
---

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

# Marks, spans and rays

Three declarations draw many small things on each bar, in one batch:
`plot.marks` (bubbles, dots, rings or squares, each at a price with a
quantity), `plot.spans` (boxes, each a price range over a run of bars)
and `plot.rays` (segments, each from one price to another over a run of
bars). A section holds up to 64 records a bar. Each record is written on
its own bar and rides that bar's outputs, so the chart draws and hovers a
whole section at once: there are no ids to keep, nothing to delete and no
live-object cap to stay under. A span or a ray may also run between two
times your script knows, starting before the bar that writes it
([Timed spans and rays](#timed-spans-and-rays)). Anything drawn on most
bars belongs here. An object that must move, restyle or vanish on a later
bar is a [handle](drawing-objects.md#handles) instead, and so are a few
objects across a chart (a session's box, a broken zone, a swing-to-swing
line): a section spends its slots on every bar, record or not, and for
tens of objects a chart handles measured cheaper to paint.

```typescript
// One mark a bar on the close, sized by the bar's volume: an up bar takes the second colour.
plot.marks({ name: "prints", slots: 1, size: { law: "sqrt", unit: 1000, scale: 5 }, colors: ["theme.down", "theme.up"], tooltip: "{{qty}} at {{price}}" });

function onBar(): void {
  const side = bar.close() >= bar.open() ? 1 : 0;
  out_prints_mark(0, bar.close(), bar.volume(), side, 1, 0);
}
```

## How a section works

`plot.marks(options)`, `plot.spans(options)` and `plot.rays(options)`
are top-level declarations: an object literal the build reads and
removes before the file compiles, like every `plot` declaration. `name`
and `slots` are required; `slots` (1..64, a number written out) is how
many records the section holds on one bar. The build generates a record
writer for each section, and `out_<name>_clear()`, which empties every
slot:

| Kind | Record writer | A slot holds |
| --- | --- | --- |
| marks | `out_<name>_mark(slot, price, qty, side, fills, offsetMs)` | the price, the quantity and the integer fields |
| spans | `out_<name>_span(slot, top, bottom, lengthBars, style, word, extend)` | the top, the bottom, the length and the integer fields |
| rays | `out_<name>_ray(slot, y1, y2, lengthBars, style, word, extend)` | the two prices, the length and the integer fields |
| timed spans | `out_<name>_span_at(slot, top, bottom, startTs, endTs, style, word, extend)` | the top, the bottom, the start and end times and the integer fields |
| timed rays | `out_<name>_ray_at(slot, y1, y2, startTs, endTs, style, word, extend)` | the two prices, the start and end times and the integer fields |

A spans or rays section is timed when your file calls its `_at` writer
and runs a length of bars when it calls the other one; a section takes
one of the two, and calling both is refused by name.

- **Slots.** `slot` runs from 0 to `slots - 1`; any other slot stops the
  run with the section's name ("marks 'prints': a slot outside 0..0 was
  written").
- **Empty slots.** A slot not written on a bar is empty and draws
  nothing. Every bar starts empty, so a bar that writes no record draws
  none, and a forming bar the chart evaluates again on a live update
  shows what the last evaluation wrote: four records, then one, leaves
  one. A file with the four functions (`init`, `state`, `finalize`,
  `reset`) calls `out_<name>_clear()` in `finalize()` before its writes,
  since every output there is written on every row.
- **The integer fields.** `side` (0..3) picks a mark's colour, `fills`
  (0..65535) is a count the hover readout prints, and `offsetMs` (0 to
  1,073,741,824 ms, about 12.4 days) places a mark inside its bar under
  `x: 1`. `style` (0..7) picks a span's or a ray's colour, `word` (0..30)
  picks one of its `words` (a negative word, or one past the list, draws
  none), and a nonzero `extend` runs it to the chart's right edge.
  `lengthBars` counts the chart's bars after the record's own (0 is that
  bar alone; below 0 reads 0). A value past its range is clamped to it.
- **Outputs.** Each section adds `slots` x 3 (marks), `slots` x 4 (spans
  and rays) or `slots` x 5 (timed spans and rays) hidden outputs, named
  `<name>_<slot>_<field>`, after every output you declare. They count
  toward the 256 outputs a file may declare; you never write them one by
  one.
- **Per bar.** A section carries nothing from one bar to the next, so a
  [per-bar script](../core-concepts/per-bar-scripts.md) stays per bar
  with one: on a pan-back the chart computes only the bars it has not
  drawn.

## Marks

`plot.marks(options)` draws up to 64 marks a bar, each at a price with a
quantity. The size follows the quantity, the colour follows the side,
and a caption can print the quantity on the larger marks.

```typescript
// Up to 8 rings a bar, each placed at its own time inside the bar, the largest captioned.
plot.marks({ name: "trades", slots: 8, size: { law: "sqrt", unit: 10, scale: 5 }, opacity_pct: 30, look: 2, text: { mode: 1, field: "qty", format: "compact", min_px: 22 }, x: 1, colors: ["#F05351", "#27A69A"], tooltip: "{{side}} {{qty}} at {{price}}, {{fills}} fills", panel: "overlay" });
```

| Word | Value | What it does |
| --- | --- | --- |
| `slots` | 1..64, required | the marks one bar holds |
| `size` | `{ law, unit, scale }` | how big a mark draws (below) |
| `opacity_pct` | 10..80 (default 30) | the fill's opacity, in percent |
| `look` | 0..3 (default 0) | 0 a bubble (a fill with a solid edge), 1 a soft dot (a denser fill, no edge), 2 a ring (a solid edge over a faint fill), 3 a square (a denser filled square) |
| `text` | `{ mode, field, format, min_px }` | a caption: `mode` 0 off, 1 on the largest mark of each bar (default), 2 on every mark; `field` `"qty"`; `format` `"compact"` (default), `"price"` (the chart's price digits) or `"int"`; `min_px` (0..64, default 22) the smallest diameter that takes one |
| `x` | 0 or 1 (default 0) | 0 the bar's centre, 1 the mark's own time: the bar's open plus `offsetMs` |
| `colors` | 2..4 colours (default `["theme.down", "theme.up"]`) | `side` picks one: 0 the first, 1 the second |
| `tooltip` | a template of at most 200 characters, or `null` (default `null`, no readout) | the hover readout over a mark: `{{side}}`, `{{qty}}`, `{{price}}`, `{{fills}}` |
| `panel` | `"overlay"` | marks draw on the price pane |

**Size.** `unit` (above 0, default 1) is the quantity a base-size mark
holds and `scale` (1..10, default 5) sets the base: with
`k = 0.5 + 0.1 x scale`, a mark's diameter is `11 x k x sqrt(qty / unit)`
px, rounded and kept between `11 x k` and the smaller of 64 and
`50 x k`. `law: "linear"` drops the square root, so the diameter follows
the quantity instead of the area.

**Order.** Larger marks draw first, so a small mark is never hidden
under a large one, and each translucent mark composes over the marks
under it; captions draw last, over every mark.

## Spans

`plot.spans(options)` draws up to 64 boxes a bar. A box covers a price
range from its own bar across `lengthBars` more of the chart's bars, or
on to the right edge with `extend`.

```typescript
// The range of a bar whose body fills most of it, held for 10 bars: an up bar takes the first colour and word.
plot.spans({ name: "zones", slots: 1, colors: ["theme.up", "theme.down"], opacity: 0.15, border: { width: 1, opacity: 0.8 }, words: ["Up bar", "Down bar"], tooltip: "{{word}} {{bottom}} to {{top}} over {{length}} bars", panel: "overlay" });

function onBar(): void {
  if (Math.abs(bar.close() - bar.open()) < 0.7 * (bar.high() - bar.low())) return;
  const pick = bar.close() >= bar.open() ? 0 : 1;
  out_zones_span(0, bar.high(), bar.low(), 10, pick, pick, 0);
}
```

| Word | Value | What it does |
| --- | --- | --- |
| `slots` | 1..64, required | the boxes one bar holds |
| `colors` | 1..8 colours (default `["theme.accent"]`) | `style` picks one |
| `opacity` | 0..1 (default 0.2) | the fill's opacity |
| `border` | `{ width, opacity }` (default width 1, opacity 0.8), or `null` for none | the edge: `width` 0..4 px, `opacity` 0..1 |
| `words` | up to 16, each 1..32 characters | `word` picks one, drawn with the box |
| `tooltip` | a template of at most 200 characters, or `null` (default `null`, no readout) | the hover readout over a box: `{{top}}`, `{{bottom}}`, `{{length}}`, `{{word}}` |
| `panel` | `"overlay"` | spans draw on the price pane |

## Rays

`plot.rays(options)` draws up to 64 segments a bar, each from its own
bar at `y1` to `lengthBars` bars later at `y2`, or on to the right edge
with `extend`. A ray with a length of 0 and `extend` set is a level from
its bar to the edge.

```typescript
// A long upper wick's high, carried 20 bars; a long lower wick's low, run to the right edge.
plot.rays({ name: "wicks", slots: 2, colors: ["theme.muted", "#f59e0b"], width: 1.5, line_style: "dashed", opacity: 0.9, words: ["Wick high", "Wick low"], tooltip: "{{word}} at {{y1}}", panel: "overlay" });

function onBar(): void {
  const range = bar.high() - bar.low();
  if (range <= 0.0) return;
  if (bar.high() - Math.max(bar.open(), bar.close()) > 0.5 * range) out_wicks_ray(0, bar.high(), bar.high(), 20, 0, 0, 0);
  if (Math.min(bar.open(), bar.close()) - bar.low() > 0.5 * range) out_wicks_ray(1, bar.low(), bar.low(), 0, 1, 1, 1);
}
```

| Word | Value | What it does |
| --- | --- | --- |
| `slots` | 1..64, required | the segments one bar holds |
| `colors` | 1..8 colours (default `["theme.accent"]`) | `style` picks one |
| `width` | 0.5..8 px (default 1) | the line's width |
| `line_style` | `"solid"` (default), `"dashed"` or `"dotted"` | the line's pattern |
| `opacity` | 0..1 (default 1) | the line's opacity |
| `words` | up to 16, each 1..32 characters | `word` picks one, drawn with the segment |
| `tooltip` | a template of at most 200 characters, or `null` (default `null`, no readout) | the hover readout over a segment: `{{y1}}`, `{{y2}}`, `{{length}}`, `{{word}}` |
| `panel` | `"overlay"` | rays draw on the price pane |

## Timed spans and rays

`out_<name>_span_at(slot, top, bottom, startTs, endTs, style, word, extend)`
and `out_<name>_ray_at(slot, y1, y2, startTs, endTs, style, word, extend)`
write a record that runs between two times your script knows: a box from
the bar at `startTs` to the bar at `endTs`, or a segment from
(`startTs`, `y1`) to (`endTs`, `y2`). The start may lie before the bar
that writes the record, so a box over the bars a condition held, written
on the bar it ended, is one record on one bar. The rule above still
decides: timed records that land on most bars belong here; a session's
box, a broken zone or a line between two swings, a few a chart, stay
[handles](drawing-objects.md#handles).

```typescript
// Each run of 10 bars boxed from its first bar's open to its last bar's close, written on the run's last bar.
chart.interval_sec();
plot.spans({ name: "range", slots: 1, colors: ["theme.accent"], opacity: 0.1, tooltip: "{{bottom}} to {{top}}, {{start}} to {{end}}" });

let intervalSec = 0.0;
let firstTs = 0.0;
let hi = 0.0;
let lo = 0.0;
let count = 0;

function onStart(): void {
  intervalSec = p_chart_interval_sec();
}

function onBar(): void {
  if (count == 0) {
    firstTs = bar.time();
    hi = bar.high();
    lo = bar.low();
  }
  hi = Math.max(hi, bar.high());
  lo = Math.min(lo, bar.low());
  count += 1;
  if (count == 10) {
    out_range_span_at(0, hi, lo, firstTs, bar.time() + intervalSec, 0, -1, 0);
    count = 0;
  }
}
```

- **Times, not bar counts.** `startTs` and `endTs` are epoch seconds,
  the unit `bar.time()` reads, and the chart places both ends at their
  exact times, the way it places a handle's corners. A count of bars your
  script keeps is not the chart's count: the chart leaves out the bars it
  has no data for, a session market's grid skips its closed hours, and
  volume or range candles have no fixed width, so a box drawn back by a
  count lands on the wrong bar where a time lands exact. Take both ends
  from times your script stored (a bar's open is `bar.time()`, its close
  that plus `chart.interval_sec()`), and never convert them to
  milliseconds.
- **The end.** An end not after the start is written as the start: the
  record covers its start alone. A nonzero `extend` continues the record
  past its end to the chart's right edge, a box at its prices and a ray
  along its slope, so a ray needs a real second point even when it is
  extended.
- **The integer fields** are those of the run-of-bars writers: `style`
  picks a colour, `word` one of `words`, and a value past its range is
  clamped to it.
- **The hover readout** takes `{{start}}` and `{{end}}` in place of
  `{{length}}`.
- **A section's layout** follows the writer your file calls: a timed
  section holds `slots` x 5 outputs, `<name>_<slot>_top` (or `_y1`),
  `_bottom` (or `_y2`), `_start`, `_end` and `_meta`, and the sheet says
  `"stride": 5`.

A chart built before timed spans and rays reads the run-of-bars layout
only and refuses an indicator with a timed section by name ("stride must
be 4"), so an indicator that uses one needs the current chart.

## Settings

Every look key except `slots`, `panel` and `text.field` takes
`"@<param>"`, so a setting drives it ([Setting kinds](../settings/kinds.md)):
a colour takes a `param.color`; a number key a `param.number` or
`param.int` whose `min..max` lies inside the key's range; `size.law`,
`text.format` and `line_style` a `param.choice` over their words; and
`look`, `text.mode` and `x` a `param.choice` with at most as many options
as the key has, the picked option's position being the value ("Ring"
below sets `look` to 2), or a `param.int` inside the key's range.

```typescript
param.number("min_size", 10, { min: 0.0001, max: 1000000000 });
param.choice("look", ["Bubble", "Soft dot", "Ring", "Square"], "Ring");
param.int("bubble_size", 5, { min: 1, max: 10 });
param.int("opacity", 30, { min: 10, max: 80 });
param.choice("show_size", ["Off", "Big bubbles", "All"], "Big bubbles");
param.choice("position", ["Candle center", "Trade time"], "Trade time");
param.color("buy_color", "#27A69A");
param.color("sell_color", "#F05351");
plot.marks({ name: "trades", slots: 8, size: { unit: "@min_size", scale: "@bubble_size" }, look: "@look", opacity_pct: "@opacity", text: { mode: "@show_size" }, x: "@position", colors: ["@sell_color", "@buy_color"] });
```

A setting writes one value to every key it drives, so one `param.choice`
cannot set both a position key (`look`) and a word key (`line_style`):
that is refused by name; declare two settings.

## What the chart receives

Run writes each section into the sheet with the two numbers the chart
reads it by: `first_output`, the index of the section's first output,
and `stride`, the fields one slot holds (3 for marks, 4 for spans and
rays, 5 for timed spans and rays). Slot `i`'s fields are the outputs
whose indexes start at `first_output + stride x i`. A sheet always
carries every look key: Run
writes the default for each key a declaration leaves out (a nested key
of `size`, `text` or `border` too), and the chart draws what the sheet
says. The first example on this page derives:

```json
{
  "outputs": [
    { "index": 0, "name": "prints_0_price", "plot": "" },
    { "index": 1, "name": "prints_0_qty", "plot": "" },
    { "index": 2, "name": "prints_0_meta", "plot": "" }
  ],
  "marks": [
    { "name": "prints", "slots": 1, "first_output": 0, "stride": 3, "size": { "law": "sqrt", "unit": 1000, "scale": 5 }, "opacity_pct": 30, "look": 0, "text": { "mode": 1, "field": "qty", "format": "compact", "min_px": 22 }, "x": 0, "colors": ["theme.down", "theme.up"], "tooltip": "{{qty}} at {{price}}", "panel": "overlay" }
  ]
}
```

A slot's last field packs its integer fields into one number: a mark's
`side + 4 x fills + 262144 x offsetMs`, a span's or a ray's (timed or
not) `style + 8 x word + 256 x extend`. A timed slot's `start` and `end`
fields hold the two times in epoch seconds. An empty slot is NaN in every
field.

## What refuses

Every misuse is refused by name when Run derives the sheet:

- a fifth section of one kind ("marks must declare at most 4 entries"),
  or `slots` outside 1..64 ("plot.marks 'm' slots must be an integer
  from 1 to 64 (the records it holds a bar)");
- sections that take the file past 256 outputs ("plot.marks 'm2' derives
  66 outputs (22 slots x 3 fields), which takes the sheet to 259 outputs;
  a row carries at most 256, so declare fewer slots or fewer outputs");
- an output you declare under a derived name ("plot.marks 'm' derives
  output 'm_0_price', which is already declared; rename one"), and a
  section named like an output, a canvas or any other named part: names
  share one namespace;
- `colors` outside 2..4 on marks or 1..8 on spans and rays, a colour
  that is not `"#rrggbb"`, `"#rrggbbaa"` or a theme token, more than 16
  `words`, and a `tooltip` over 200 characters or naming a field its
  kind does not carry ("tooltip names {{word}}, which a marks record
  does not carry");
- a setting whose kind or range does not fit its key ("plot.marks 'm'
  look references "@look", which is not a param.choice of at most 4
  options or a param.int within 0..3");
- both writers of one section in one file ("plot.spans 'z' is written by
  both out_z_span() (line 5, a run of bars) and out_z_span_at() (line 6,
  two timestamps); a section holds one record layout, so call one of the
  two writers"), and a timed section's tooltip naming `{{length}}`.

Sections draw when the indicator runs in your browser and when it runs on
OpenMarket's servers as a **Protected** indicator: the server keeps the
section and sends its rows to your chart, which draws them. A timed section
needs a chart that draws stride-5 records (it advertises `wrun-batched-v2`);
an older chart keeps the indicator off its hosted lane by name. The caps are
on [Limits](../reference/limits.md).
