---
title: "Viewport-anchored market HUD"
description: "A regime readout the package draws itself: one box and four left-aligned rows pinned to the top-left corner of the price pane (the close's distance from EMA 50…"
order: 67
section: "cookbook"
---

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

# Viewport-anchored market HUD

A regime readout the package draws itself: one box and four left-aligned rows pinned to the top-left corner of the price pane (the close's distance from EMA 50, RSI 14, ATR as a percent of the close, and a BULLISH, BEARISH or NEUTRAL line coloured by regime), plus a meter for RSI at the top-right. The numbers come from three plain recurrences in `state()`; the readout is two handle kinds, an anchor word, an align word and one `draw.meter` declaration. The kScript (legacy) precedent is the MMT-style viewport HUD script, whose labels sat at a time and a price and scrolled away with the chart; here the readout is pinned to the pane. The [HUD terminal](hud-terminal.md) recipe is the sibling: it reads the tape (buy and sell prints) and shows a flow readout, while this one reads only the chart's candles and shows the regime its own indicator numbers describe.

Boxes and labels are handles: objects the Indicator creates, moves and re-words by an integer id ([Drawing objects](../functions/drawing-objects.md)). `anchor: "top_left"` on both declarations turns their coordinates into pixels from the pane's corner, so the HUD stays put when the chart pans or zooms; `align: "left"` makes every row's text start at its x, which is what lines the columns up. The two plotted outputs are `ema50` on the price pane and `rsi14` in the lower pane; `atr_pct` and `rsi_frac` are data-only (`none`), so the lower pane holds RSI alone and the meter reads `rsi_frac` without a second line being drawn. Three params (`ema_len`, `rsi_len`, `atr_len`) size the recurrences in `init()`, and the output names and the meter's label spell their defaults. This is also the `viewport-hud` template: `om wrun create` scaffolds it, and it compiles as written.

## The Indicator

```typescript
// A regime HUD from indicator numbers: one anchored box and four left-aligned rows pinned to the pane's top-left corner, plus an RSI meter.
import { draw, handles, input, line, lower, none, ohlcv, output, overlay, param, string } from "./sdk/declare"; // the declaring words; draw declares the meter here
import { ALIGN_LEFT, bar, draw as handle, LabelHandle, rgba } from "./gen/draw"; // the handle constructors under another name, the align word, colours, the last-bar signal
import { in_close, in_high, in_low } from "./gen/inputs"; // generated: an in_ reader for each input declared below
import { emitRow, out_atr_pct, out_ema50, out_rsi14, out_rsi_frac } from "./gen/outputs"; // generated: an out_ writer per output, plus emitRow
import { p_atr_len, p_ema_len, p_rsi_len } from "./gen/params"; // generated: a p_ reader for each param declared below
import { sb_clear, sb_f64, sb_int, sb_text, str_text_sb } from "./gen/strings"; // the allocation-free line builder and the slot's sender

param("ema_len", 50, { min: 2, max: 400 }); // the EMA's length in bars; the largest of the three periods is how many bars the daemon reads for a metric or a watch
param("rsi_len", 14, { min: 2, max: 200 }); // the RSI's Wilder length in bars
param("atr_len", 14, { min: 2, max: 200 }); // the ATR's Wilder length in bars
input("close", ohlcv.close); // the primary input: the chart's own candles define the grid; unpinned, so it follows the symbol selector
input("high", ohlcv.high); // the bar's high and low feed the true range
input("low", ohlcv.low);
output("ema50", line, overlay, { color: "#38bdf8", description: "EMA of the close over ema_len bars" }); // a line on the price pane; the name spells the default period
output("rsi14", line, lower, { color: "#a78bfa", description: "RSI over rsi_len bars with Wilder smoothing" }); // the only line in the lower pane
output("atr_pct", none); // data-only: ATR over atr_len bars as a percent of the close, a row in the HUD and a metric on your machine
output("rsi_frac", none); // data-only: rsi14 / 100, the fraction the meter reads
string("text", { max_bytes: 32 }); // one bounded slot every row is written through, one row at a time
handles.box({ anchor: "top_left", color: "#0f172a", borderColor: "#334155", opacity: 0.85, borderWidth: 1 }); // the box behind the rows, in pane pixels
handles.label({ anchor: "top_left", align: "left", color: "#e2e8f0", size: 12 }); // rows whose text starts at x, so the columns line up
draw.meter({ name: "rsi", label: "RSI 14", fraction: { output: "rsi_frac" }, ramp: ["#e5484d", "#8b8f98", "#30a46c"], anchor: "top_right" }); // the meter widget; its label spells the default too

// One recurrence serves all three numbers: a plain average over the first n values seeds it, then value += (x - value) * alpha.
// alpha = 1 / n is Wilder smoothing (what the Rma helper does); alpha = 2 / (n + 1) is the EMA. The value stays NaN until seeded.
class Smoother {
  n: i32; alpha: f64; sum: f64 = 0.0; count: i32 = 0; value: f64 = NaN;
  constructor(n: i32, alpha: f64) { this.n = n; this.alpha = alpha; }
  update(x: f64): f64 {
    if (this.count < this.n) { this.sum += x; this.count += 1; if (this.count == this.n) this.value = this.sum / f64(this.n); }
    else this.value += (x - this.value) * this.alpha;
    return this.value;
  }
  reset(): void { this.sum = 0.0; this.count = 0; this.value = NaN; }
}

let emaLen = 50; let rsiLen = 14; let atrLen = 14; // the periods, read from the params in init(); the row labels are built from them, so they never lie
let emaOfClose = new Smoother(emaLen, 2.0 / f64(emaLen + 1)); // EMA of the close; init() rebuilds it from the param
let gains = new Smoother(rsiLen, 1.0 / f64(rsiLen)); let losses = new Smoother(rsiLen, 1.0 / f64(rsiLen)); // RSI's two Wilder averages
let ranges = new Smoother(atrLen, 1.0 / f64(atrLen)); // ATR: Wilder smoothing of the true range
const backdrop = handle.box(0); // handle objects allocate once; ids are one space across boxes, labels, lines and polylines
const rows: LabelHandle[] = [handle.label(1), handle.label(2), handle.label(3), handle.label(4)]; // EMA, RSI, ATR%, the state row
let close: f64 = NaN; let prev: f64 = NaN; let ema50: f64 = NaN; let rsi14: f64 = NaN; let atrPct: f64 = NaN; // this bar's numbers

export function init(): void { // read the params (f64) as periods (i32) and build the smoothers from them
  emaLen = i32(p_ema_len()); rsiLen = i32(p_rsi_len()); atrLen = i32(p_atr_len());
  emaOfClose = new Smoother(emaLen, 2.0 / f64(emaLen + 1)); gains = new Smoother(rsiLen, 1.0 / f64(rsiLen)); losses = new Smoother(rsiLen, 1.0 / f64(rsiLen)); ranges = new Smoother(atrLen, 1.0 / f64(atrLen));
}
export function state(): i32 { // once per bar: read the bar, fold it into the three recurrences, say whether every number is ready
  prev = close; close = in_close(); const high = in_high(); const low = in_low();
  ema50 = emaOfClose.update(close);
  if (isNaN(prev)) return 0; // the first bar has no change and no true range
  const change = close - prev;
  const gain = gains.update(change > 0.0 ? change : 0.0); const loss = losses.update(change < 0.0 ? -change : 0.0);
  rsi14 = loss == 0.0 ? 100.0 : 100.0 - 100.0 / (1.0 + gain / loss); // NaN while the averages seed (NaN == 0.0 is false)
  const range = Math.max(high - low, Math.max(Math.abs(high - prev), Math.abs(low - prev))); // the true range
  atrPct = (ranges.update(range) / close) * 100.0;
  return isNaN(ema50) || isNaN(rsi14) || isNaN(atrPct) ? 0 : 1; // 0 = a warmup row: the chart draws nothing for this bar
}
// finalize() runs after state() returns 1: write the outputs on every bar; draw the HUD on the live bar only.
// A row is one line: build it with sb_* (numbers through sb_f64, periods through sb_int, never a locale), then text(...) sends the slot
// and draws the label at the last set(x, y); the slot is reused row by row, the host copies each draw.
export function finalize(): void {
  out_ema50(ema50); out_rsi14(rsi14); out_atr_pct(atrPct); out_rsi_frac(rsi14 / 100.0);
  if (bar.isLast()) { // the newest bar only: history bars never paint the HUD, and each refresh draws it once
    const bullish = close > ema50 && rsi14 > 50.0; const bearish = close < ema50 && rsi14 < 50.0; // the regime
    const tone = bullish ? rgba(48, 164, 108, 255) : bearish ? rgba(229, 72, 77, 255) : rgba(139, 143, 152, 255); // green, red, grey
    backdrop.set(12, 12, 172, 112).color(tone); // pane pixels from the top-left corner: left, top, right, bottom; the border takes the regime colour
    const distance = ((close - ema50) / ema50) * 100.0; // the close's distance from the EMA, sign kept
    sb_clear(); sb_text("EMA "); sb_int(emaLen); sb_text("  "); if (distance >= 0.0) sb_text("+"); sb_f64(distance, 1); sb_text("%"); // "EMA 50  +1.2%"
    rows[0].set(22, 30).text(str_text_sb).align(ALIGN_LEFT); // an explicit align call: it wins over the declared default for this label
    sb_clear(); sb_text("RSI "); sb_int(rsiLen); sb_text("  "); sb_f64(rsi14, 1); rows[1].set(22, 50).text(str_text_sb);
    sb_clear(); sb_text("ATR%    "); sb_f64(atrPct, 2); rows[2].set(22, 70).text(str_text_sb);
    sb_clear(); sb_text(bullish ? "BULLISH" : bearish ? "BEARISH" : "NEUTRAL"); rows[3].set(22, 94).text(str_text_sb).color(tone);
  }
  emitRow();
}
// reset() runs when the chart restarts the series: back to the start; the host rolls the handles back beside the module state.
export function reset(): void {
  emaOfClose.reset(); gains.reset(); losses.reset(); ranges.reset(); close = NaN; prev = NaN; ema50 = NaN; rsi14 = NaN; atrPct = NaN;
}
```

## How it works

**Anchor: pixels from the pane edge.** `handles.box({ anchor: "top_left", ... })` and `handles.label({ anchor: "top_left", ... })` enable the two kinds and set the defaults every new box and label starts from. With the anchor word, `backdrop.set(12, 12, 172, 112)` is a box from 12 px to 172 px across and 12 px to 112 px down from the pane's top-left corner, and `rows[1].set(22, 50)` puts a label 22 px in and 50 px down; the chart can pan to last year or zoom to a minute and the HUD does not move. Without the word the same numbers would be epoch seconds and prices, and the readout would scroll away with the bars. The nine corner and centre words pin both axes; `top`, `bottom`, `left` and `right` pin one axis and leave the other on chart time or price ([Drawing objects](../functions/drawing-objects.md) lists them).

**Align: the text starts at x.** A label is placed by one point, and by default its text is centred on that point, so `RSI 14  61.4` and `BULLISH` would centre on different widths and the columns would drift. `align: "left"` makes the text start at x instead; the four rows share x = 22, so their first characters sit on one vertical line and the gaps after the names read as columns. The declaration is the default every new label is created with. `rows[0].set(22, 30).text(str_text_sb).align(ALIGN_LEFT)` is the per-handle call (it needs a live label, so it follows `text(...)`), and the call wins for that handle: a new label is created with the sheet's word copied in, and the call overwrites the handle's own word from then on. Here both say left, so nothing changes; `ALIGN_RIGHT` on that row would right-align it while the other three stay left, and `ALIGN_DEFAULT` clears the word on that handle and centres it again, sheet default or not. `anchor(...)` works the same way (prop 7 on the style channel, beside align's prop 8): a per-handle call overrides the declared default for that one handle, and `ANCHOR_CHART` puts it back on chart time and price.

**The live bar only.** `bar.isLast()` is true on the newest bar the host holds (the last row of a full run, the forming bar live) and false on every earlier bar. The whole HUD sits behind it, so a history bar writes its four outputs, emits its row and draws nothing; only the newest bar creates the box and the four labels, and each refresh draws them once. When the forming bar ticks, the host rolls the handles back beside the module state and replays that bar, so the box is created again, never stacked. Without the guard every history bar would paint the same HUD on top of itself, once per bar, for nothing.

**Three numbers, one recurrence.** `Smoother` seeds with a plain average over the first `n` values, then folds each new value in with `value += (x - value) * alpha`. With `alpha = 1 / n` that is Wilder smoothing, what the `Rma` helper and every RSI and ATR use; with `alpha = 2 / (n + 1)` it is the EMA, seeded the way the `Ema` helper in `./sdk/ta` seeds. RSI is two of them, over the bar's gains and over its losses, then `100 - 100 / (1 + gain / loss)`; ATR is one of them over the true range, divided by the close and scaled to a percent. The periods are the three params, read in `init()` as `p_ema_len()`, `p_rsi_len()` and `p_atr_len()` (params are `f64`, periods `i32`) and used to build the smoothers there. A code-first source has no other way to say how many bars it needs: a metric read or a watch on your machine fetches the largest param default plus the ready row, 51 bars here, where a module without params would read three and never leave warmup. `state()` returns 0 until all three are seeded, so the bars before the `ema_len`th (the 50th by default) are warmup rows the chart leaves empty. The helpers in [Moving averages](../functions/moving-averages.md) and [Oscillators](../functions/oscillators.md) do the same in one line each; the recurrence is written out here so the numbers behind the HUD are visible.

**The meter.** `draw.meter({ name: "rsi", label: "RSI 14", fraction: { output: "rsi_frac" }, ramp: [...], anchor: "top_right" })` declares a meter the chart draws at the pane's top-right. It reads the last ready value of `rsi_frac`, which the module emits as `rsi14 / 100`, a fraction from 0 to 1 the host does not clamp, and colours it along the ramp from red through grey to green. The declaration is validated and erased before the AssemblyScript build, so there is no accessor to call: emitting the output is the whole job. Its keys are `name`, `label`, `fraction`, `ramp`, and the optional `text` (a short literal or `{ slot }`), `anchor` (one of the nine pane positions), `offset` and `z` ([Drawing primitives](../functions/drawing-primitives.md)).

**The regime row.** BULLISH when the close is above EMA 50 and RSI 14 is above 50, BEARISH when it is below both, NEUTRAL otherwise. `tone` is an `rgba(...)` value (green, red or grey) and `.color(tone)` restyles one handle on the bar it is drawn: the state row's text, and the box's border. The distance row keeps its sign: `sb_f64` writes the minus for a negative and the code adds the plus, so `+1.2%` and `-0.8%` read alike. The row names are built from the params (`sb_text("EMA "); sb_int(emaLen)`), so a chart user who sets `ema_len` to 100 reads `EMA 100` in the HUD, never a stale number. Every row goes through one 32-byte slot: build it with `sb_clear`, `sb_text`, `sb_int` and `sb_f64`, then `text(str_text_sb)` sends the slot and draws the label at its last `set(x, y)`; the host copies the bytes at each draw, so one slot serves four labels ([String functions](../functions/string-functions.md)).

## What kScript (legacy) could not do

- **No pixel-anchored drawings.** A label or box lived at a time and a price and scrolled away with the chart; the only thing that stayed put was a table with fixed cells. Here `anchor: "top_left"` turns a handle's coordinates into pixels from the pane edge, and `ANCHOR_CHART` puts the same handle back on chart coordinates.
- **No text alignment word.** Text centred on its point, so a column of numbers never lined up. `align: "left"` on the declaration, or `align(ALIGN_LEFT)` on one label, starts the text at x.
- **No meter widget.** A fraction was a number in a table cell. `draw.meter` draws it as a meter along a colour ramp, fed by one data-only output.
- **No per-handle style calls.** Colours were set once, on a plot or a table declaration. `.color(rgba(...))`, `.align(...)` and `.anchor(...)` restyle one live handle on the bar it changes, which is how the state row turns green, red or grey by regime.

## Customize it

- **Change the periods.** `ema_len` (2 to 400), `rsi_len` and `atr_len` (2 to 200) are params: the chart user sets them in the settings dialog and `om metric series --param ema_len=100` reads them on your machine, with no rebuild. The HUD's row names are built from them, so they always say what they show; the output names (`ema50`, `rsi14`) and the meter's `label` spell the defaults, so rename those if you change a default in the source.
- **Move the HUD.** Change the anchor word on both handle declarations to `top_right` or `bottom_left`; the pixel offsets are then measured from that corner. Move the meter with its own `anchor` and an `offset: [x, y]`.
- **Right-align the numbers.** Import `ALIGN_RIGHT`, draw each row's value as a second label at the box's right edge (x = 162) and call `.align(ALIGN_RIGHT)` on it after its `text(...)`; the names stay left-aligned at 22.
- **Plot ATR% too.** `output("atr_pct", line, lower)` draws it in the lower pane beside RSI; `none` keeps it data-only. Either way `om metric series` reads it.
- **Colours.** The box declaration takes `color`, `borderColor`, `opacity` and `borderWidth`, the label declaration `color` and `size`; the meter's `ramp` takes 2 to 5 hex colours; the three `rgba(...)` values in `finalize()` are the regime tones.

Scaffold, build, install, and read RSI 14 on your machine:

```bash
om wrun create @you/viewport-hud ./viewport-hud --template viewport-hud
om wrun build ./viewport-hud
om wrun install ./viewport-hud --replace
om metric series --metric wrun/@you/viewport-hud/rsi14 --symbol BTCUSDT --exchange BINANCE_FUTURES --bars 20
```

## Concepts used

- [Drawing objects](../functions/drawing-objects.md) for handles, the anchor and align words, the per-handle setters and `bar.isLast()`
- [Drawing primitives](../functions/drawing-primitives.md) for `draw.meter`, pane pixel placement and the style props behind `anchor(...)` and `align(...)`
- [HUD terminal](hud-terminal.md) for the sibling readout over the tape, framed once and refreshed on the live bar
- [String functions](../functions/string-functions.md) for the `sb_*` builder and the slot sender
- [Moving averages](../functions/moving-averages.md) and [Oscillators](../functions/oscillators.md) for the `Ema`, `Rma` and `Rsi` helpers this recipe writes out by hand
