Charts
Examples

Footprint (chart type)

Bid and ask volume per price inside each bar, instead of candles.

Footprint (chart type)

Shows: ChartSeries. Order flow per bar, several layouts and content modes, value area, imbalances, auto row size from the zoom, g.priceValue for autoscale and magnet, its own last price. The built-in Footprint: pick it in the chart type menu and use "Duplicate and edit" to start from it.

See also: Create a chart type.

footprint.ts
import type { FootprintLevel } from "@tradingthings/tt-chart-lib";
import type { BarsView, ChartFrame, Graphics, InputValues, LegendValue, StreamStatus } from "@tradingthings/tt-create";
import { defineScript, format, group, input, profile, row, tab } from "@tradingthings/tt-create";

/**
 * Level of detail by zoom.
 *
 * - `Text`: numbers on colored cells.
 * - `Cells`: colored cells only.
 * - `Candles`: too small for cells, candles in the buying and selling colors.
 */
type Tier = "Text" | "Cells" | "Candles";

/** Px a row is higher than its font, so auto ticks per row fit the numbers. */
const ROW_PADDING = 3;
/** Numbers and stats need this much bar spacing, numbers this much row height in px. */
const TEXT_MIN_SPACING = 56;
const TEXT_MIN_ROW_HEIGHT = 10;
/** Cells need this much bar spacing and row height in px. */
const CELLS_MIN_SPACING = 4;
const CELLS_MIN_ROW_HEIGHT = 1;
/** Share of the bar spacing a bar's candle and cells cover. */
const COLUMN_WIDTH = 0.9;
/** Share of the column the candle next to the cells covers, and its max width in px. */
const CANDLE_SHARE = 0.12;
const MAX_CANDLE_WIDTH = 8;
/** Px between the candle and the cells. */
const CANDLE_GAP = 2;
/** Rows below this px height have no gap between them. */
const MIN_ROW_GAP_HEIGHT = 3;
/** Opacity steps of the cells and the weakest opacity in percent. */
const OPACITY_STEPS = 6;
const MIN_OPACITY_PCT = 20;
/** Opacity in percent of candles still waiting for their order flow. */
const WAITING_OPACITY_PCT = 35;
/** Px between numbers and the cell edge, and between "x" and the bid and ask numbers. */
const TEXT_PAD = 3;
const TIMES_GAP = 5;
/** Width of the imbalance marks at the cells' sides, wider for stacked imbalances, in px. */
const IMBALANCE_WIDTH = 2;
const STACKED_IMBALANCE_WIDTH = 4;
/** Px a stats row is higher than its font. */
const STATS_ROW_PADDING = 6;
/** Dash of the last price line, in px. */
const LAST_PRICE_DASH: readonly [number, number] = [2, 2];
const LAST_PRICE_TEXT_COLOR = "#ffffff";
const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";

const inputs = [
  tab("Content", [
    group("Layout", {
      layout: input.select({
        default: "Candle left",
        options: ["Candle left", "Candle middle"],
        label: "Layout",
        tooltip: "Candle left: numbers right of the candle. Candle middle: bid left, ask right",
      }),
      content: input.select({
        default: "Bid x Ask",
        options: ["Bid x Ask", "Delta", "Volume", "Volume + Delta"],
        label: "Numbers",
      }),
      showVolumeBars: input.bool({ default: true, label: "Volume bars" }),
      showStats: input.bool({ default: true, label: "Volume and delta per bar" }),
    }),
    group("Rows", {
      rowMode: input.select({
        default: "Auto",
        options: ["Auto", "Manual"],
        label: "Ticks per row",
        tooltip: "Auto merges ticks until rows are tall enough for numbers",
      }),
      ticksPerRow: input.int({ default: 4, min: 1, step: 1, label: "Ticks", visibleWhen: { rowMode: "Manual" } }),
    }),
  ]),
  tab("Style", [
    group("Cells", {
      coloring: input.select({ default: "Delta", options: ["Delta", "Volume", "Solid"], label: "Cells by" }),
    }),
    group("Colors", { columns: 2 }, {
      askColor: input.color({ default: "#26a69a", label: "Buying" }),
      bidColor: input.color({ default: "#ef5350", label: "Selling" }),
      lowVolumeColor: input.color({ default: "#1e3a8a", label: "Low volume" }),
      highVolumeColor: input.color({ default: "#f59e0b", label: "High volume" }),
      solidColor: input.color({ default: "rgba(120, 123, 134, 0.35)", label: "Solid" }),
      volumeBarColor: input.color({ default: "rgba(178, 181, 190, 0.25)", label: "Volume bars" }),
    }),
    group("Text", { columns: 2 }, {
      textColor: input.color({ default: "#e0e3eb", label: "Numbers" }),
      fontSize: input.int({ default: 11, min: 6, max: 20, step: 1, label: "Font size" }),
      abbreviate: input.bool({ default: true, label: "Thousands as K" }),
    }),
  ]),
  tab("Highlights", [
    group("Profile", {
      poc: row({
        showPoc: input.bool({ default: true, label: "POC" }),
        pocColor: input.color({ default: "#f5f5f5" }),
      }),
      valueArea: row({
        showValueArea: input.bool({ default: true, label: "Value area" }),
        valueAreaPct: input.int({ default: 70, min: 1, max: 100, step: 5, tooltip: "Value area %" }),
        valueAreaColor: input.color({ default: "#ffb74d" }),
      }),
    }),
    group("Imbalances", {
      imbalances: row({
        showImbalances: input.bool({ default: true, label: "Imbalances" }),
        buyImbalanceColor: input.color({ default: "#2962ff", label: "Buy" }),
        sellImbalanceColor: input.color({ default: "#ff9800", label: "Sell" }),
      }),
      imbalancePct: input.int({
        default: 300,
        min: 100,
        step: 50,
        label: "Imbalance ratio %",
        tooltip: "Ask over the bid one row below, or bid over the ask one row above",
      }),
      stackedImbalances: input.int({
        default: 3,
        min: 2,
        step: 1,
        label: "Stacked imbalance rows",
        tooltip: "Rows in a row with an imbalance of the same side that get a wider mark",
      }),
    }),
  ]),
];

type Inputs = InputValues<typeof inputs>;
type Layout = Inputs["layout"];
type Content = Inputs["content"];

/** Cell colors by step, weakest first. */
interface Ramps {
  readonly ask: readonly string[];
  readonly bid: readonly string[];
  readonly volume: readonly string[];
}

/**
 * Footprint chart type: per bar, a candle next to one cell per row of ticks,
 * the cells colored by delta, by volume or solid, with numbers, volume bars,
 * the POC, the value area and diagonal imbalances. Zoomed out the numbers go,
 * then the cells. Bars without order flow draw candles from the bars stream:
 * order-flow bars have no OHLC. Bars whose order flow is still loading are
 * dimmed candles. Volume and delta per bar show under the bars.
 */
export default defineScript({
  name: "Footprint",
  surface: { kind: "ChartSeries" },
  inputs,
  subscribe: (ctx) => ({
    symbol: ctx.market.symbol(),
    bars: ctx.market.bars(),
    orderFlow: ctx.market.orderFlow(),
  }),
  // Inputs restart the script: the ramps follow its color inputs.
  state: (ctx): Ramps => ({
    ask: mapColorToRamp(ctx.inputs.askColor),
    bid: mapColorToRamp(ctx.inputs.bidColor),
    volume: mapColorsToHeatRamp(ctx.inputs.lowVolumeColor, ctx.inputs.highVolumeColor),
  }),
  draw: (ramps, frame, g, ctx) => {
    const { tickSize } = ctx.streams.symbol.info;
    const tickHeight = Math.abs(frame.priceToY(tickSize) - frame.priceToY(0));
    const ticksPerRow = mapInputsToTicksPerRow(ctx.inputs, tickHeight);
    const tier = mapZoomToTier(frame.barSpacing, tickHeight * ticksPerRow);
    const { bars } = ctx.streams;
    const flow = ctx.streams.orderFlow.bars;
    const { from, to } = frame.visibleTime;
    const { status } = ctx.streams.orderFlow;
    const options: DrawOptions = {
      ...ctx.inputs,
      ramps,
      tickSize,
      ticksPerRow,
      withText: tier === "Text",
      waitingAskColor: mapColorToOpacity(ctx.inputs.askColor, WAITING_OPACITY_PCT),
      waitingBidColor: mapColorToOpacity(ctx.inputs.bidColor, WAITING_OPACITY_PCT),
    };
    const withStats = ctx.inputs.showStats && tier !== "Candles" && frame.barSpacing >= TEXT_MIN_SPACING;
    const stats: BarStats[] = [];
    let k = 0;

    for (let i = lowerBound(bars.time, bars.length, frame.barTimeAt(from) ?? from); i < bars.length; i += 1) {
      const time = bars.time[i]!;

      if (time > to) break;

      // Autoscale and the magnet follow the bars.
      g.priceValue(time, bars.high[i]!);
      g.priceValue(time, bars.low[i]!);
      g.priceValue(time, bars.close[i]!);

      while (k < flow.length && flow[k]!.time < time) k += 1;

      const bar = flow[k]?.time === time ? flow[k]! : null;

      if (isWaitingForOrderFlow(status, time)) drawFullCandle(g, frame, bars, i, options, true);
      else if (tier === "Candles" || !bar || bar.levels.length === 0) drawFullCandle(g, frame, bars, i, options, false);
      else {
        drawBar(g, frame, bars, i, mapLevelsToRows(bar.levels, tickSize, ticksPerRow), options);

        if (withStats) stats.push({ time, ...mapLevelsToStats(bar.levels) });
      }
    }

    if (withStats) drawStats(g, frame, stats, options);

    drawLastPrice(g, frame, bars, options);
  },
  legend: (_ramps, crosshairTime, ctx) => {
    const { bars } = ctx.streams;
    const i = crosshairTime === null ? bars.length - 1 : lowerBound(bars.time, bars.length, crosshairTime);

    if (i < 0 || i >= bars.length || (crosshairTime !== null && bars.time[i] !== crosshairTime)) return [];

    const decimals = format.tickDecimals(ctx.streams.symbol.info.tickSize);
    const price = (value: number) => value.toFixed(decimals);
    const color = bars.close[i]! >= bars.open[i]! ? ctx.inputs.askColor : ctx.inputs.bidColor;
    const flow = ctx.streams.orderFlow.bars;
    const bar = flow.find((it) => it.time === bars.time[i]);
    const values: LegendValue[] = [
      { label: "O", value: price(bars.open[i]!), color },
      { label: "H", value: price(bars.high[i]!), color },
      { label: "L", value: price(bars.low[i]!), color },
      { label: "C", value: price(bars.close[i]!), color },
      { label: "Vol", value: format.volume(bars.volume[i]!) },
    ];

    if (!bar) return values;

    const { delta } = mapLevelsToStats(bar.levels);

    return [
      ...values,
      { label: "Δ", value: format.volume(delta), color: delta >= 0 ? ctx.inputs.askColor : ctx.inputs.bidColor },
    ];
  },
});

type DrawOptions = Inputs & {
  readonly ramps: Ramps;
  readonly tickSize: number;
  readonly ticksPerRow: number;
  readonly withText: boolean;
  /** Candle colors of bars waiting for their order flow. */
  readonly waitingAskColor: string;
  readonly waitingBidColor: string;
};

/** Horizontal extent in px. */
interface Span {
  readonly left: number;
  readonly right: number;
}

/** The cells of a bar: one column, or bid left and ask right of the candle. */
type Cells =
  | { readonly type: "Single"; readonly cell: Span }
  | { readonly type: "Split"; readonly bid: Span; readonly ask: Span };

export interface BarGeometry {
  readonly candle: Span;
  readonly cells: Cells;
  /** From the left edge to the right edge of the cells. */
  readonly area: Span;
}

/** Numbers of a row by content mode. `One` is drawn on `side` when bid and ask are split. */
export type RowNumbers =
  | { readonly type: "BidAsk"; readonly bid: string; readonly ask: string }
  | { readonly type: "One"; readonly text: string; readonly side: "Bid" | "Ask" }
  | { readonly type: "VolumeDelta"; readonly volume: string; readonly delta: string };

interface Imbalance {
  readonly buy: boolean;
  readonly sell: boolean;
}

interface BarStats {
  readonly time: number;
  readonly volume: number;
  readonly delta: number;
}

export function mapZoomToTier(barSpacing: number, rowHeight: number): Tier {
  if (barSpacing >= TEXT_MIN_SPACING && rowHeight >= TEXT_MIN_ROW_HEIGHT) return "Text";

  if (barSpacing >= CELLS_MIN_SPACING && rowHeight >= CELLS_MIN_ROW_HEIGHT) return "Cells";

  return "Candles";
}

function mapInputsToTicksPerRow(inputs: Inputs, tickHeight: number): number {
  switch (inputs.rowMode) {
    case "Auto":
      return mapTickHeightToTicksPerRow(tickHeight, inputs.fontSize + ROW_PADDING);
    case "Manual":
      return inputs.ticksPerRow;
    default:
      return inputs.rowMode satisfies never;
  }
}

/** Fewest ticks per row that make a row at least `minRowHeight` px high. */
export function mapTickHeightToTicksPerRow(tickHeight: number, minRowHeight: number): number {
  if (tickHeight <= 0) return 1;

  return Math.max(1, Math.ceil(minRowHeight / tickHeight));
}

/**
 * `levels`, sorted by price, merged into rows of `ticksPerRow` ticks. A row's
 * price is its lowest tick, a multiple of `ticksPerRow` ticks.
 */
export function mapLevelsToRows(
  levels: readonly FootprintLevel[],
  tickSize: number,
  ticksPerRow: number
): readonly FootprintLevel[] {
  if (ticksPerRow <= 1) return levels;

  const rows: { price: number; bid: number; ask: number }[] = [];

  for (const level of levels) {
    const price = Math.floor(Math.round(level.price / tickSize) / ticksPerRow) * ticksPerRow * tickSize;
    const row = rows[rows.length - 1];

    if (row?.price === price) {
      row.bid += level.bid;
      row.ask += level.ask;
    } else rows.push({ price, bid: level.bid, ask: level.ask });
  }

  return rows;
}

/**
 * Whether the order flow of the bar at `time` is still loading: nothing has
 * loaded yet, or the bar is older than the loaded range while older history
 * loads.
 */
export function isWaitingForOrderFlow(status: StreamStatus, time: number): boolean {
  switch (status.type) {
    case "Loading":
      return true;
    case "LoadingOlder":
      return time < status.from;
    case "Ready":
    case "Unavailable":
      return false;
    default:
      return status satisfies never;
  }
}

/**
 * Candle and cells of a bar centered at `center` in a column `width` px wide.
 * `Candle left` puts the candle at the column's left edge and the cells right
 * of it, `Candle middle` puts it at the center with bid cells left and ask
 * cells right.
 */
export function mapLayoutToGeometry(layout: Layout, center: number, width: number, dpr: number): BarGeometry {
  const left = center - width / 2;
  const right = center + width / 2;
  const candleWidth = Math.max(1 / dpr, Math.min(MAX_CANDLE_WIDTH, width * CANDLE_SHARE));

  switch (layout) {
    case "Candle left": {
      const cell = { left: left + candleWidth + CANDLE_GAP, right };

      return { candle: { left, right: left + candleWidth }, cells: { type: "Single", cell }, area: cell };
    }

    case "Candle middle": {
      const candle = { left: center - candleWidth / 2, right: center + candleWidth / 2 };

      return {
        candle,
        cells: {
          type: "Split",
          bid: { left, right: candle.left - CANDLE_GAP },
          ask: { left: candle.right + CANDLE_GAP, right },
        },
        area: { left, right },
      };
    }

    default:
      return layout satisfies never;
  }
}

export function mapRowToNumbers(level: FootprintLevel, content: Content, abbreviate: boolean): RowNumbers {
  const text = (value: number) => mapVolumeToText(value, abbreviate);
  const delta = level.ask - level.bid;

  switch (content) {
    case "Bid x Ask":
      return { type: "BidAsk", bid: text(level.bid), ask: text(level.ask) };
    case "Delta":
      return { type: "One", text: text(delta), side: delta >= 0 ? "Ask" : "Bid" };
    case "Volume":
      return { type: "One", text: text(level.bid + level.ask), side: level.ask >= level.bid ? "Ask" : "Bid" };
    case "Volume + Delta":
      return { type: "VolumeDelta", volume: text(level.bid + level.ask), delta: text(delta) };
    default:
      return content satisfies never;
  }
}

/** `value` as `format.volume`, or as `1.2K` or `12K` from 1,000 and `1.2M` from 1,000,000 when `abbreviate`. */
export function mapVolumeToText(value: number, abbreviate: boolean): string {
  const abs = Math.abs(value);

  if (!abbreviate || abs < 1_000) return format.volume(value);

  const [scaled, suffix] = abs < 1_000_000 ? [value / 1_000, "K"] : [value / 1_000_000, "M"];

  return `${Math.abs(scaled) < 10 ? scaled.toFixed(1) : Math.round(scaled)}${suffix}`;
}

/**
 * Diagonal imbalances of `level`: buying when its ask is `ratio` times the
 * bid one row below, selling when its bid is `ratio` times the ask one row
 * above. `below` and `above` are the neighboring rows of the bar, a
 * row without trades between them counts as 0.
 */
export function mapLevelToImbalance(
  below: FootprintLevel | undefined,
  level: FootprintLevel,
  above: FootprintLevel | undefined,
  rowSize: number,
  ratio: number
): Imbalance {
  const bidBelow = below && isNextRow(below.price, level.price, rowSize) ? below.bid : 0;
  const askAbove = above && isNextRow(level.price, above.price, rowSize) ? above.ask : 0;

  return {
    buy: below !== undefined && level.ask > 0 && level.ask >= ratio * bidBelow,
    sell: above !== undefined && level.bid > 0 && level.bid >= ratio * askAbove,
  };
}

/** Per row of `rows`, whether it is in a run of at least `min` adjacent rows whose `flags` are set. */
export function mapImbalancesToStacked(
  flags: readonly boolean[],
  rows: readonly FootprintLevel[],
  rowSize: number,
  min: number
): boolean[] {
  const stacked = flags.map(() => false);
  let start = -1;

  for (let k = 0; k <= flags.length; k += 1) {
    const set = k < flags.length && flags[k]!;

    if (set && start >= 0 && isNextRow(rows[k - 1]!.price, rows[k]!.price, rowSize)) continue;

    if (start >= 0 && k - start >= min) stacked.fill(true, start, k);

    start = set ? k : -1;
  }

  return stacked;
}

function mapLevelsToStats(levels: readonly FootprintLevel[]): { readonly volume: number; readonly delta: number } {
  let volume = 0;
  let delta = 0;

  for (const level of levels) {
    volume += level.bid + level.ask;
    delta += level.ask - level.bid;
  }

  return { volume, delta };
}

/** Whether `upper` is one row above `lower`. */
function isNextRow(lower: number, upper: number, rowSize: number): boolean {
  return Math.abs(upper - lower - rowSize) < rowSize / 2;
}

/** `color` at `OPACITY_STEPS` opacities from `MIN_OPACITY_PCT` to opaque. */
function mapColorToRamp(color: string): string[] {
  return Array.from({ length: OPACITY_STEPS }, (_, step) => mapColorToOpacity(color, mapStepToPct(step)));
}

/** `OPACITY_STEPS` colors from `low` to `high`. */
function mapColorsToHeatRamp(low: string, high: string): string[] {
  return Array.from({ length: OPACITY_STEPS }, (_, step) => {
    const pct = (100 * step) / Math.max(1, OPACITY_STEPS - 1);

    return `color-mix(in srgb, ${high} ${Math.round(pct)}%, ${low})`;
  });
}

function mapStepToPct(step: number): number {
  return MIN_OPACITY_PCT + ((100 - MIN_OPACITY_PCT) * step) / Math.max(1, OPACITY_STEPS - 1);
}

/** `color` at `pct` percent opacity. */
function mapColorToOpacity(color: string, pct: number): string {
  return `color-mix(in srgb, ${color} ${Math.round(pct)}%, transparent)`;
}

/** Color step, 0 to `OPACITY_STEPS - 1`, of `value` as a share of `max`. */
function mapShareToStep(value: number, max: number): number {
  if (max <= 0) return 0;

  return Math.min(OPACITY_STEPS - 1, Math.max(0, Math.floor((value / max) * OPACITY_STEPS)));
}

/** Index of the first of `length` times at or after `time`. */
function lowerBound(times: Float64Array, length: number, time: number): number {
  let lo = 0;
  let hi = length;

  while (lo < hi) {
    const mid = (lo + hi) >> 1;

    if (times[mid]! < time) lo = mid + 1;
    else hi = mid;
  }

  return lo;
}

/** `y` on a device pixel. */
function snapY(y: number, dpr: number): number {
  return Math.round(y * dpr) / dpr;
}

/** Bar `i` with its order flow `rows`: candle, cells, numbers and highlights. */
function drawBar(
  g: Graphics,
  frame: ChartFrame,
  bars: BarsView,
  i: number,
  rows: readonly FootprintLevel[],
  options: DrawOptions
): void {
  const center = frame.timeToX(bars.time[i]!);

  if (center === null || rows.length === 0) return;

  const { dpr, height } = frame;
  const rowSize = options.tickSize * options.ticksPerRow;
  const geometry = mapLayoutToGeometry(options.layout, center, frame.barSpacing * COLUMN_WIDTH, dpr);
  const { area } = geometry;
  const gap = 1 / dpr;
  const totals = rows.map((row) => row.bid + row.ask);
  let poc = 0;
  let maxDelta = 0;
  let maxSide = 0;

  for (let k = 0; k < rows.length; k += 1) {
    if (totals[k]! > totals[poc]!) poc = k;

    maxDelta = Math.max(maxDelta, Math.abs(rows[k]!.ask - rows[k]!.bid));
    maxSide = Math.max(maxSide, rows[k]!.bid, rows[k]!.ask);
  }

  const maxVolume = totals[poc]!;
  const ratio = options.imbalancePct / 100;
  const imbalances = rows.map((row, k) =>
    options.showImbalances
      ? mapLevelToImbalance(rows[k - 1], row, rows[k + 1], rowSize, ratio)
      : { buy: false, sell: false }
  );
  const stackedBuy = mapImbalancesToStacked(
    imbalances.map((it) => it.buy),
    rows,
    rowSize,
    options.stackedImbalances
  );
  const stackedSell = mapImbalancesToStacked(
    imbalances.map((it) => it.sell),
    rows,
    rowSize,
    options.stackedImbalances
  );
  const rowTop = (k: number) => snapY(frame.priceToY(rows[k]!.price + rowSize), dpr);
  const rowBottom = (k: number) => snapY(frame.priceToY(rows[k]!.price), dpr);

  drawCandle(g, frame, bars, i, geometry.candle, options.askColor, options.bidColor);

  for (let k = 0; k < rows.length; k += 1) {
    const row = rows[k]!;
    const top = rowTop(k);
    const bottom = rowBottom(k);

    if (bottom < 0 || top > height) continue;

    const rowHeight = bottom - top;
    const cellHeight = Math.max(gap, rowHeight >= MIN_ROW_GAP_HEIGHT ? rowHeight - gap : rowHeight);
    const color = mapRowToColor(row, totals[k]!, maxDelta, maxVolume, options);

    drawCells(g, geometry.cells, top, cellHeight, color);

    if (options.showVolumeBars) drawVolumeBars(g, geometry.cells, row, top, cellHeight, maxVolume, maxSide, options.volumeBarColor);

    const imbalance = imbalances[k]!;

    if (imbalance.sell) {
      const width = stackedSell[k] ? STACKED_IMBALANCE_WIDTH : IMBALANCE_WIDTH;

      g.rect(area.left, top, width, stackedSell[k] ? rowHeight : cellHeight, options.sellImbalanceColor);
    }

    if (imbalance.buy) {
      const width = stackedBuy[k] ? STACKED_IMBALANCE_WIDTH : IMBALANCE_WIDTH;

      g.rect(area.right - width, top, width, stackedBuy[k] ? rowHeight : cellHeight, options.buyImbalanceColor);
    }

    if (options.withText) {
      const numbers = mapRowToNumbers(row, options.content, options.abbreviate);

      drawNumbers(g, geometry.cells, numbers, top + rowHeight / 2, Math.min(options.fontSize, rowHeight - 2), imbalance, options);
    }
  }

  if (options.showValueArea) {
    const valueArea = profile.valueArea(totals, poc, options.valueAreaPct / 100);
    const style = { color: options.valueAreaColor, width: 1 };

    g.line(area.left, rowTop(valueArea.to), area.right, rowTop(valueArea.to), style);
    g.line(area.left, rowBottom(valueArea.from) - gap, area.right, rowBottom(valueArea.from) - gap, style);
  }

  if (!options.showPoc) return;

  const pocTop = rowTop(poc);
  const pocBottom = rowBottom(poc) - gap;

  g.polyline([area.left, pocTop, area.right, pocTop, area.right, pocBottom, area.left, pocBottom, area.left, pocTop], {
    color: options.pocColor,
    width: 1,
  });
}

function mapRowToColor(row: FootprintLevel, total: number, maxDelta: number, maxVolume: number, options: DrawOptions): string {
  switch (options.coloring) {
    case "Delta":
      return (row.ask >= row.bid ? options.ramps.ask : options.ramps.bid)[mapShareToStep(Math.abs(row.ask - row.bid), maxDelta)]!;
    case "Volume":
      return options.ramps.volume[mapShareToStep(total, maxVolume)]!;
    case "Solid":
      return options.solidColor;
    default:
      return options.coloring satisfies never;
  }
}

function drawCells(g: Graphics, cells: Cells, top: number, height: number, color: string): void {
  switch (cells.type) {
    case "Single":
      g.rect(cells.cell.left, top, cells.cell.right - cells.cell.left, height, color);

      return;
    case "Split":
      g.rect(cells.bid.left, top, cells.bid.right - cells.bid.left, height, color);
      g.rect(cells.ask.left, top, cells.ask.right - cells.ask.left, height, color);

      return;
    default:
      cells satisfies never;
  }
}

/**
 * Volume of `row` as a bar from the cell's left edge by its share of
 * `maxVolume`, or split: bid volume towards the left, ask volume towards the
 * right by their share of `maxSide`.
 */
function drawVolumeBars(
  g: Graphics,
  cells: Cells,
  row: FootprintLevel,
  top: number,
  height: number,
  maxVolume: number,
  maxSide: number,
  color: string
): void {
  switch (cells.type) {
    case "Single": {
      if (maxVolume <= 0) return;

      const { cell } = cells;

      g.rect(cell.left, top, ((cell.right - cell.left) * (row.bid + row.ask)) / maxVolume, height, color);

      return;
    }

    case "Split": {
      if (maxSide <= 0) return;

      const { bid, ask } = cells;
      const bidWidth = ((bid.right - bid.left) * row.bid) / maxSide;

      g.rect(bid.right - bidWidth, top, bidWidth, height, color);
      g.rect(ask.left, top, ((ask.right - ask.left) * row.ask) / maxSide, height, color);

      return;
    }

    default:
      cells satisfies never;
  }
}

function drawNumbers(
  g: Graphics,
  cells: Cells,
  numbers: RowNumbers,
  y: number,
  sizePx: number,
  imbalance: Imbalance,
  options: DrawOptions
): void {
  const put = (text: string, x: number, align: "left" | "center" | "right", highlight: string | null = null) =>
    g.text(text, x, y, {
      color: highlight ?? options.textColor,
      font: { family: FONT_FAMILY, sizePx, weight: highlight ? 700 : 400 },
      align,
      baseline: "middle",
    });
  const bidHighlight = imbalance.sell ? options.sellImbalanceColor : null;
  const askHighlight = imbalance.buy ? options.buyImbalanceColor : null;

  switch (cells.type) {
    case "Single": {
      const { cell } = cells;
      const mid = (cell.left + cell.right) / 2;

      switch (numbers.type) {
        case "BidAsk":
          put(numbers.bid, mid - TIMES_GAP, "right", bidHighlight);
          put("x", mid, "center");
          put(numbers.ask, mid + TIMES_GAP, "left", askHighlight);

          return;
        case "One":
          put(numbers.text, mid, "center");

          return;
        case "VolumeDelta":
          put(numbers.volume, cell.left + TEXT_PAD, "left");
          put(numbers.delta, cell.right - TEXT_PAD, "right");

          return;
        default:
          numbers satisfies never;
      }

      return;
    }

    case "Split": {
      const { bid, ask } = cells;

      switch (numbers.type) {
        case "BidAsk":
          put(numbers.bid, bid.right - TEXT_PAD, "right", bidHighlight);
          put(numbers.ask, ask.left + TEXT_PAD, "left", askHighlight);

          return;
        case "One":
          switch (numbers.side) {
            case "Bid":
              put(numbers.text, bid.right - TEXT_PAD, "right");

              return;
            case "Ask":
              put(numbers.text, ask.left + TEXT_PAD, "left");

              return;
            default:
              numbers.side satisfies never;
          }

          return;
        case "VolumeDelta":
          put(numbers.volume, bid.right - TEXT_PAD, "right");
          put(numbers.delta, ask.left + TEXT_PAD, "left");

          return;
        default:
          numbers satisfies never;
      }

      return;
    }

    default:
      cells satisfies never;
  }
}

/** Bar `i` as a candle over the full column: wick from high to low, body from open to close. `waiting` dims it. */
function drawFullCandle(
  g: Graphics,
  frame: ChartFrame,
  bars: BarsView,
  i: number,
  options: DrawOptions,
  waiting: boolean
): void {
  const center = frame.timeToX(bars.time[i]!);

  if (center === null) return;

  const width = Math.max(1 / frame.dpr, frame.barSpacing * COLUMN_WIDTH);
  const span = { left: center - width / 2, right: center + width / 2 };

  if (waiting) drawCandle(g, frame, bars, i, span, options.waitingAskColor, options.waitingBidColor);
  else drawCandle(g, frame, bars, i, span, options.askColor, options.bidColor);
}

/** Bar `i` as a candle in `span`: wick from high to low, body from open to close. */
function drawCandle(
  g: Graphics,
  frame: ChartFrame,
  bars: BarsView,
  i: number,
  span: Span,
  upColor: string,
  downColor: string
): void {
  const { dpr } = frame;
  const open = bars.open[i]!;
  const close = bars.close[i]!;
  const color = close >= open ? upColor : downColor;
  const x = Math.round(((span.left + span.right) / 2) * dpr) / dpr;
  const top = snapY(frame.priceToY(Math.max(open, close)), dpr);
  const bottom = snapY(frame.priceToY(Math.min(open, close)), dpr);

  g.line(x, frame.priceToY(bars.high[i]!), x, frame.priceToY(bars.low[i]!), { color, width: 1 });
  g.rect(span.left, top, Math.max(1 / dpr, span.right - span.left), Math.max(1 / dpr, bottom - top), color);
}

/**
 * Volume and delta of each bar in `stats` in two rows at the bottom of the
 * pane, labeled at the left. Above the cells' numbers: text paints above all
 * shapes of its layer.
 */
function drawStats(g: Graphics, frame: ChartFrame, stats: readonly BarStats[], options: DrawOptions): void {
  const rowHeight = options.fontSize + STATS_ROW_PADDING;
  const top = frame.height - 2 * rowHeight;
  const font = { family: FONT_FAMILY, sizePx: options.fontSize };
  const labels = [
    { text: "Volume", y: top + rowHeight / 2 },
    { text: "Delta", y: top + rowHeight * 1.5 },
  ];
  const labelWidth = Math.max(...labels.map((it) => g.measureText(it.text, font).width)) + 2 * TEXT_PAD;
  const put = (text: string, x: number, y: number, color: string, align: "left" | "center") =>
    g.text(text, x, y, { color, font, align, baseline: "middle" });

  g.layer("top", () => {
    g.rect(0, top, frame.width, 2 * rowHeight, frame.theme.background);
    g.line(0, top, frame.width, top, { color: frame.theme.grid, width: 1 });

    for (const label of labels) put(label.text, TEXT_PAD, label.y, frame.theme.text, "left");

    for (const bar of stats) {
      const x = frame.timeToX(bar.time);

      // Bars under the labels have no stats.
      if (x === null || x - frame.barSpacing / 2 < labelWidth) continue;

      put(mapVolumeToText(bar.volume, options.abbreviate), x, labels[0]!.y, options.textColor, "center");
      put(mapVolumeToText(bar.delta, options.abbreviate), x, labels[1]!.y, bar.delta >= 0 ? options.askColor : options.bidColor, "center");
    }
  });
}

/** The newest close as a dashed line across the pane and a price axis label. */
function drawLastPrice(g: Graphics, frame: ChartFrame, bars: BarsView, options: DrawOptions): void {
  const last = bars.length - 1;

  if (last < 0) return;

  const close = bars.close[last]!;
  const color = close >= bars.open[last]! ? options.askColor : options.bidColor;
  const y = frame.priceToY(close);

  g.line(0, y, frame.width, y, { color, width: 1, dash: LAST_PRICE_DASH });
  g.priceLabel(close, frame.formatPrice(close), color, LAST_PRICE_TEXT_COLOR);
}