---
url: /examples/drawing.md
description: >-
  Horizontal lines, trend lines and Fibonacci retracements, drawn by hand and
  round-tripped through localStorage.
---

# Drawing tools

## Source

`apps/examples/src/cases/drawing.ts` — the real thing, type-checked in CI.

```ts
import { PlotBuilder, browserDeps } from "@finchart/dom";
import type { OHLC } from "@finchart/core";
import { candleSeries, crosshair, LinearScale, LogScale, priceFormat, timeTicks } from "@finchart/core";
import { drawingTools } from "@finchart/tools";
import { fixtureCandles } from "./fixture";
import { chartHost } from "./stage";

export const title = "Drawing tools";
export const description =
  "Press a button and click the chart to draw — once for a horizontal line, twice for a trend line or a Fibonacci. Esc cancels, Delete removes the selection, dragging an endpoint edits. " +
  "`]` and `[` cycle the selection without a pointer (the keys only arrive once you've clicked the chart to give it focus). " +
  "For touch and anything else without a keyboard, the three buttons below (deselect · delete · clear all) do the same work. " +
  "What you draw is saved to localStorage — a serialize()/load() round trip, so it survives a refresh and a theme switch (a remount; a drawing styled by hand keeps its own color — that is the saved literal working). A drag-move carries reason \"move\", so only its save is deferred. " +
  "Turn the magnet on and drawing and endpoint drags snap to a bar's close, low, and high (and the bar's x) — you don't leave a peak to pixel luck. " +
  "Switch the price axis to log and a Fibonacci's levels bunch toward one end — by default they are spaced evenly in price. Select it and press Log levels: `levelSpacing: \"log\"` spaces them evenly in log price instead, which is what looks even there. It belongs to the drawing, not the axis — switch back to linear and the same lines stay at the same prices. (`drawingTools({ defaults })` gives hand-drawn Fibonaccis that spacing from birth.)";

/** Background for the pressed button — the case stands on its own without the shell's CSS. */
function paintPressed(el: HTMLButtonElement, pressed: boolean): void {
  el.style.background = pressed ? "#3b82f6" : "";
  el.style.color = pressed ? "#fff" : "";
}

export function mount(container: HTMLElement): () => void {
  const toolbar = document.createElement("div");
  toolbar.setAttribute("role", "group");
  toolbar.setAttribute("aria-label", "Drawing tools");
  toolbar.style.cssText = "display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap";
  container.append(toolbar);
  const host = chartHost(container, 480);

  const plot = PlotBuilder.create<OHLC>(browserDeps({ autoSize: true }))
    .setSize(container.clientWidth || 900, 480)
    .setAxis({
      x: { ticks: timeTicks({ timeZone: "UTC", locale: "en-US" }) },
      y: { position: "right", format: priceFormat({ compact: true, locale: "en-US" }) },
    })
    .build(host);

  plot.mainPane.addSeries({
    series: candleSeries(),
    data: fixtureCandles(),
    name: "Price",
  });
  plot.use(crosshair({ magnet: true }));

  const tools = plot.mainPane.use(drawingTools({ plot }));

  /**
   * The serialization round trip — an app only needs this upper layer
   * (`tools.serialize()`/`load()`). The low-level `serializeDrawings`/
   * `parseDrawings` (drawing array ↔ string) belong to apps with a storage
   * format of their own. If `load` can't read it, it returns false and keeps
   * the existing list — a stale save can't break the chart.
   */
  const STORAGE_KEY = "charts-case-drawings";
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) tools.load(saved);

  // A drag-move fires on every pointermove (reason "move"), so defer just that one.
  let saveTimer: number | undefined;
  tools.changes.subscribe(({ reason }) => {
    if (reason !== "move") {
      localStorage.setItem(STORAGE_KEY, tools.serialize());
      return;
    }
    clearTimeout(saveTimer);
    saveTimer = window.setTimeout(
      () => localStorage.setItem(STORAGE_KEY, tools.serialize()),
      200,
    );
  });

  const buttons = new Map<string, HTMLButtonElement>();
  for (const [label, kind] of [
    ["Horizontal line", "horizontal"],
    ["Vertical line", "vertical"],
    ["Trend line", "trend"],
    ["Ray", "ray"],
    ["Extended line", "extended"],
    ["Arrow", "arrow"],
    ["Fibonacci", "fib"],
    ["Rectangle", "rectangle"],
    ["Ellipse", "ellipse"],
    ["Price measure", "priceMeasure"],
    ["Bar measure", "barMeasure"],
    ["Parallel channel", "parallelChannel"],
    ["Pitchfork", "pitchfork"],
    ["Fib extension", "fibExtension"],
  ] as const) {
    const el = document.createElement("button");
    el.textContent = label;
    el.addEventListener("click", () => {
      if (tools.mode() === kind) tools.cancel();
      else tools.begin(kind);
    });
    toolbar.appendChild(el);
    buttons.set(kind, el);
  }
  tools.modeChanges.subscribe(({ mode }) => {
    for (const [kind, el] of buttons) {
      const pressed = mode === kind;
      el.setAttribute("aria-pressed", String(pressed));
      paintPressed(el, pressed);
    }
    host.style.cursor = mode ? "crosshair" : "";
  });

  // A door to selection and deletion for environments without a keyboard
  // (touch) — the spot the drawing-tools senior review (2026-08-13) caught as
  // "there is no way to erase a line you drew on a phone." Delete is not a new
  // API; it goes through the same door the real key does (routeInput) — with
  // nothing selected it simply does nothing.
  const deselect = document.createElement("button");
  deselect.textContent = "Deselect";
  deselect.addEventListener("click", () => tools.select(null));

  const remove = document.createElement("button");
  remove.textContent = "Delete";
  remove.title = "Delete the selected drawing (same as the Delete key)";
  remove.addEventListener("click", () => {
    plot.routeInput({ type: "keydown", key: "Delete" });
  });

  const clearAll = document.createElement("button");
  clearAll.textContent = "Clear all";
  clearAll.addEventListener("click", () => tools.clear());

  // Snapping — drawing and anchor drags stick to a bar's values (close, low,
  // high) and the bar's x. It's off by default, so it takes a button to turn on
  // before you can see the real thing (round 16).
  const magnet = document.createElement("button");
  magnet.textContent = "Magnet";
  magnet.title = "Snap to a bar's values — catch peaks and troughs exactly";
  magnet.addEventListener("click", () => {
    tools.setSnap(!tools.snapping());
    magnet.setAttribute("aria-pressed", String(tools.snapping()));
    paintPressed(magnet, tools.snapping());
  });

  // The price axis, linear or log. By default a Fibonacci's levels are evenly
  // spaced in price, so on a log axis they bunch — the reason the next button exists.
  let logAxis = false;
  const axis = document.createElement("button");
  axis.textContent = "Log axis";
  axis.title = "Switch the price axis between linear and log";
  axis.addEventListener("click", () => {
    logAxis = !logAxis;
    plot.mainPane.setYScale(logAxis ? new LogScale() : new LinearScale());
    axis.setAttribute("aria-pressed", String(logAxis));
    paintPressed(axis, logAxis);
  });

  // Log-spaced levels for the selected Fibonacci — `update` through its handle.
  // `selection()` is a copy, so the handle is found by id among `handles()`,
  // which come in `list()`'s order.
  const selectedFib = () => {
    const selected = tools.selection();
    if (!selected || (selected.type !== "fib" && selected.type !== "fibExtension")) return null;
    const handle = tools.handles()[tools.list().findIndex((drawing) => drawing.id === selected.id)];
    return handle ? { handle, log: selected.levelSpacing === "log" } : null;
  };
  const logLevels = document.createElement("button");
  logLevels.textContent = "Log levels";
  logLevels.title = "Space the selected Fibonacci's levels evenly in log price";
  logLevels.addEventListener("click", () => {
    const fib = selectedFib();
    fib?.handle.update({ levelSpacing: fib.log ? undefined : "log" });
  });
  const syncLogLevels = (): void => {
    const fib = selectedFib();
    logLevels.disabled = fib === null;
    logLevels.setAttribute("aria-pressed", String(fib?.log ?? false));
    paintPressed(logLevels, fib?.log ?? false);
  };
  tools.selectionChanges.subscribe(syncLogLevels);
  tools.changes.subscribe(syncLogLevels);
  syncLogLevels();

  toolbar.append(deselect, remove, clearAll, magnet, axis, logLevels);

  return Object.assign(
    () => {
      clearTimeout(saveTimer); // so a deferred save can't call into dead tools
      plot.destroy(); // destroy tears down the plugins (tools included)
      toolbar.remove();
      host.remove();
    },
    { requestRender: () => plot.requestRender() },
  );
}

```
