Chart Template Documentation

Everything you need to write a custom chart template — the contract, the complete params reference, and the rules render(ctx) must follow.

How templates work

A template is a single JavaScript file that evaluates to an object. Files live in the repo's templates/ directory and are managed on the /templatespage (with a live preview fed by sample CSVs). To use one: add a plot to a project, pick the template as its type, then configure its params via the plot's settings (gear) dialog. The values are stored per-plot, so the same template can power many plots with different settings.

Template code runs only in the browser, inside try/catch and a React error boundary: a broken template shows an error card on its own chart and never affects the rest of the app. The backend stores the file but never executes it.

({
  name: "Human readable chart name",   // required-ish: falls back to "Unnamed Template"
  description: "One-line description", // optional
  params: [ /* see the params reference below */ ],
  render(ctx) {
    // ctx.versions: Array<{ id, label, columns, rows }> — every uploaded CSV version
    // ctx.params:   current values of the params declared above
    // ctx.dark:     true when dark mode is active
    return { data: [ /* Plotly traces */ ], layout: { /* optional */ } };
  },
})

Params reference

Each entry in paramsdeclares one control in the plot's settings dialog. Every param needs a unique string key; the current values arrive in render(ctx) as ctx.params[key].

typeUI controlvalue in ctx.paramsextra fields
string (default)text inputstring
numbernumeric inputnumber (or undefined while empty)
booleancheckboxboolean
columndropdown of CSV column names (union across all versions)string (column name)
versiondropdown of uploaded versionsnumber (version id)
selectdropdown of fixed choicesstringoptions: [...] (required)
listrepeatable rows with add / remove buttonsarray of objects keyed by field keyfields: [...] (required, non-empty)

Common optional fields on every param: label (control caption; falls back to the key) and default (initial value before the user touches the control).

List params

A list param declares a row schema via fields — each field is itself a full param definition of anytype above, including nested lists. The dialog renders it as repeatable rows; new rows are pre-filled from the fields' defaults. If no default is set on the list itself, the value is [] (never undefined), so render() can always iterate it.

{
  key: "lines",
  label: "Lines",
  type: "list",
  fields: [
    { key: "version", label: "Version",  type: "version" },
    { key: "column",  label: "Y Column", type: "column" },
    { key: "scalar",  label: "Scalar",   type: "number", default: 1 },
    { key: "dashed",  label: "Dashed",   type: "boolean", default: false },
  ],
}

// The value arriving in ctx.params.lines is an array of row objects:
// [
//   { version: 3, column: "price", scalar: 2, dashed: false },
//   { version: 5, column: "price", scalar: 1, dashed: true },
// ]

This is expressive enough to rebuild a multi-line chart — see templates/multi-line.js for a complete working example:

render(ctx) {
  const lines = Array.isArray(ctx.params.lines) ? ctx.params.lines : [];
  const data = lines.flatMap((line) => {
    // Version ids in params are plain JSON — the version may have been
    // deleted since the row was configured. Always look it up and skip.
    const v = ctx.versions.find((ver) => ver.id === line.version);
    if (!v || !line.column) return [];
    return [{
      type: "scatter",
      mode: "lines",
      name: `${v.label} · ${line.column}`,
      x: v.rows.map((r, i) => i),
      y: v.rows.map((r) => (typeof r[line.column] === "number" ? r[line.column] : null)),
    }];
  });
  return { data };
}

Defaults & persistence

  • Values are stored in the plot's metadata_json.params when you hit Save in the settings dialog.
  • On load, saved values are overlaid on the declared defaults — params you add to a template later automatically pick up their defaults on existing plots.
  • Values are plain JSON with no referential integrity: a saved version id may point at a deleted version. render() must tolerate that (skip the row / show an empty state) — never assume the id resolves.

render(ctx) rules

  • ctx.versions — one entry per uploaded CSV version: { id: number, label: string, columns: string[], rows: Array<Record<string, number|string|null>> }.
  • Cell values can be null (missing / non-finite data). Filter them or pass them through — Plotly renders null as a gap. Never coerce to 0.
  • Must be pure, synchronous and fast — it re-runs on every param change. Plain ES2020 only: no import/require, no network calls, no async, no DOM access.
  • Return { data, layout? }. Keep trace counts reasonable (<50).
  • Use ctx.dark only for extra theme-specific colors.
return {
  data: [ /* Plotly traces */ ],
  layout: {
    // Plotly v3: titles MUST be objects — plain strings are silently ignored
    title: { text: "My Chart" },
    xaxis: { title: { text: "time" } },
    yaxis: { title: { text: "value" } },
    // Do NOT set paper_bgcolor / plot_bgcolor / font:
    // the app injects the same theme-aware defaults used by the
    // built-in Line/Diff charts, so fonts and colors always match.
  },
}

Every chart in the app — including the built-in Line and Diff charts — renders through the same pipeline, which injects one shared theme (font family/size and transparent backgrounds). Anything your layout sets explicitly wins over those defaults, but leaving fonts and backgrounds alone keeps custom charts visually identical to the built-ins.

Errors & debugging

  • Compile errors (syntax, missing render, malformed params) and render errors (exceptions, wrong return shape) show an error card on the affected chart only.
  • The editor at /templates validates on every keystroke and previews against sample CSVs parsed entirely in the browser — nothing is uploaded.
  • Prefer generating templates with AI: the /templates page has a copyable prompt that encodes this whole contract.