Skip to content

Migration guide

If you’ve shipped charts with D3, Observable Plot, or Chart.js before, you’ll find plenty that feels familiar here — and a few deliberate differences worth knowing about upfront.

The short version: insomni-plot is a grammar-of-graphics library, so if you’ve touched Observable Plot or ggplot2 you’re already close to the mental model. You declare what your data means (which columns map to which visual properties), pick your marks, and let the library handle scales, axes, and layout. What’s different from everything else is the render target: it compiles that spec into insomni layers and draws on the GPU, so the same scatter you’d use for 200 rows works unchanged at 200,000.

Two things to anchor on before diving into the per-library sections:

  • The Chart builder is immutable. .layer(), .scale(), .axes(), and every other method return a new Chart. Your base spec is never mutated — you branch from it, hand it to faceting, fork it for variants.
  • .mount() needs a GPUDevice. Unlike D3 (pure DOM) or Chart.js (2D canvas), insomni-plot renders via WebGPU. You acquire a device once and pass it at mount time. The static .toSVG() path has no such requirement.

Observable Plot is the closest sibling — the mapping is almost one-to-one. Both libraries are grammar-of-graphics; both think in “marks” attached to data. The main structural differences are that insomni-plot’s builder is immutable (Plot’s is mutable) and that .plot() in Observable produces a DOM element immediately, while insomni-plot’s plot() returns an inert Chart you activate separately.

Observable Plotinsomni-plotNotes
Plot.dot(data, {x, y})point({x, y})color, size, shape, alpha channels all map.
Plot.line(data, {x, y})line({x, y})color splits into multi-series; order channel too.
Plot.barY(data, {x, y})bar({x, y})Orientation is auto-detected; or pass orientation.
Plot.barX(data, {x, y})bar({x, y})Flip by swapping which channel resolves to a band scale.
Plot.areaY(data, {x, y})area({x, y})For stacked, pass y as an array of column keys.
Plot.ruleX / Plot.ruleYrule({x}) / rule({y})Reference lines.
Plot.text(data, {x, y, text})text({x, y, text})Same idea.
Plot.boxX / Plot.boxYboxplot({x}) / boxplot({y})

Channels are spelled the same (x, y, color, size, shape) and accept the same three shapes: a column-name string, an accessor function, or a constant.

Observable Plot bundles marks into an array and calls .plot() on it. In insomni-plot you chain .layer() calls on the Chart builder.

Observable Plot — before

Plot.plot({
marks: [
Plot.dot(data, { x: "weight", y: "mpg", fill: "origin" }),
Plot.line(data, { x: "weight", y: "mpg", stroke: "gray" }),
],
x: { label: "Weight" },
y: { label: "MPG" },
});

insomni-plot — after

import { plot, point, line } from "insomni-plot";
const device = await (await navigator.gpu.requestAdapter())!.requestDevice();
const canvas = document.querySelector("canvas")!;
plot<Row>({ data })
.layer(point({ x: "weight", y: "mpg", color: "origin" }))
.layer(line({ x: "weight", y: "mpg" }, { stroke: "#aaa" }))
.axes({ x: { title: "Weight" }, y: { title: "MPG" } })
.mount(canvas, { device });

A few notes on the translation:

  • Plot uses fill / stroke as the color channel key; insomni-plot uses color for data-driven color and the options object for fixed styling ({ stroke: "#aaa" }).
  • .plot() returns a DOM element. .mount() returns a MountedPlot handle with setData(), update(), destroy(), and live interaction state.

Observable Plot’s fx / fy channels map to .facet({ by, ncol, nrow }) in insomni-plot.

Observable Plot — before

Plot.plot({
marks: [Plot.dot(data, { x: "weight", y: "mpg", fx: "origin" })],
});

insomni-plot — after

plot<Row>({ data })
.layer(point({ x: "weight", y: "mpg" }))
.facet({ by: "origin", ncol: 3 })
.mount(canvas, { device });

Observable Plot’s x:, y:, color: top-level keys map to .scale(channel, options). The option names are similar; the main difference is that insomni-plot infers the scale type from the data and you only override when the default isn’t right.

// Observable Plot
Plot.plot({
color: { type: "sequential", scheme: "viridis" },
// ...
});
// insomni-plot
import { viridis } from "insomni-plot/core";
plot<Row>({ data })
.layer(point({ x: "x", y: "y", color: "elevation" }))
.scale("color", { type: "continuous", palette: viridis });

What’s not 1:1: Observable Plot’s transform helpers (Plot.binX, Plot.normalizeY, etc.) are stat geoms in insomni-plot (histogram, smooth, statRolling). There’s no general data-transform pipeline in insomni-plot; you either use a stat geom or reshape the data yourself before passing it to plot().


D3 is a lower-level tool — it gives you building blocks (scales, axes, selections) and you assemble the chart yourself. insomni-plot does that assembly for you in the grammar layer, and exposes the same building blocks via insomni-plot/core when you need them.

The boilerplate D3 chart authors repeat on every project maps directly to insomni-plot’s grammar:

D3 patterninsomni-plot equivalent
d3.scaleLinear().domain([...]).range([...])Inferred automatically; override with .scale()
d3.axisBottom(xScale) + g.call(axis).axes({ x: { title: "..." } })
svg.selectAll("circle").data(data).join("circle").layer(point({ x: "col", y: "col" }))
d3.extent(data, d => d.value)Domain inferred; set explicitly via .scale("x", { domain: [min, max] })
d3.scaleBand().paddingInner(0.1)Band scale inferred for string x; tune padding with .scale("x", { padding: 0.1 })
Resize observer + re-renderBuilt into MountedPlot (autoResize: true)

A full before/after — scatter chart with axes and color

D3 — before

const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const width = 640 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = d3
.select("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3
.scaleLinear()
.domain(d3.extent(data, (d) => d.weight))
.range([0, width]);
const y = d3
.scaleLinear()
.domain(d3.extent(data, (d) => d.mpg))
.range([height, 0]);
const color = d3.scaleOrdinal(d3.schemeTableau10).domain([...new Set(data.map((d) => d.origin))]);
svg.append("g").attr("transform", `translate(0,${height})`).call(d3.axisBottom(x).ticks(5));
svg.append("g").call(d3.axisLeft(y));
svg
.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", (d) => x(d.weight))
.attr("cy", (d) => y(d.mpg))
.attr("r", 4)
.attr("fill", (d) => color(d.origin));

insomni-plot — after

import { plot, point } from "insomni-plot";
const device = await (await navigator.gpu.requestAdapter())!.requestDevice();
plot<Row>({ data })
.layer(point({ x: "weight", y: "mpg", color: "origin" }, { radius: 4 }))
.axes({ x: { title: "Weight" }, y: { title: "MPG" } })
.mount(document.querySelector("canvas")!, { device });

You shed the margin math, scale wiring, axis calls, and selection boilerplate — insomni-plot handles all of it.

If you want hand-wired control — your own frame loop, custom scale wiring, or you just need a single utility (a formatter, a bin helper, a color palette) — reach for insomni-plot/core. It exposes the same primitives the grammar is built on: linearScale, bandScale, timeScale, logScale, bottomAxis, leftAxis, pointMark, lineMark, barMark, bin, kde, linearFit, and more.

import { linearScale, bandScale, bottomAxis, pointMark } from "insomni-plot/core";
// Use a scale in isolation — same shape as d3.scaleLinear().
const x = linearScale([0, 100], [0, 640]);
const y = linearScale([0, 50], [480, 0]);
const xPos = x(42); // → pixel value

This is also where you’d hand-wire a chart using .render(target) rather than .mount() — useful if you’re already running your own Renderer2D and want insomni-plot to fill your layers on each frame.

What’s not 1:1: D3 has no equivalent for GPU rendering, so charts that push hundreds of thousands of points simply aren’t comparable. On the other hand, D3’s general-purpose selection model and its layout algorithms (force, hierarchy, geo projections) have no built-in counterpart in insomni-plot. Those remain things you’d reach for D3 for, then feed the resulting data into insomni-plot’s grammar for rendering.


Chart.js uses a config-object model: one new Chart(canvas, { type, data, options }) call, with nested objects for datasets, scales, and plugins. insomni-plot is declarative and composable, but the surface-level concepts map reasonably cleanly.

Chart.js conceptinsomni-plot equivalent
type: "scatter", "line", "bar", …A geom factory: point(), line(), bar(), …
data.datasetsData in a flat array; color channel splits into series.
data.labelsCategories come from the data column on a band axis.
options.scales.x.title.text.axes({ x: { title: "..." } })
options.scales.y.min / .max.scale("y", { domain: [min, max] })
options.scales.y.type: "logarithmic".scale("y", { type: "log" })
options.plugins.legend.display.legend(false) to suppress; .legend({ position: "right" }) to tune.
options.plugins.tooltipmount(canvas, { interactions: { tooltip: true } })
chart.update()mounted.setData(newData) or mounted.update()
chart.destroy()mounted.destroy()

A before/after — multi-series line chart

Chart.js — before

new Chart(canvas, {
type: "line",
data: {
labels: data.map((d) => d.month),
datasets: [
{
label: "Revenue",
data: data.map((d) => d.revenue),
borderColor: "steelblue",
fill: false,
},
{
label: "Expenses",
data: data.map((d) => d.expenses),
borderColor: "tomato",
fill: false,
},
],
},
options: {
scales: {
x: { title: { display: true, text: "Month" } },
y: { title: { display: true, text: "USD" } },
},
},
});

insomni-plot — after

import { plot, line, pivotLonger } from "insomni-plot";
// Reshape: wide → long so the color channel can split series.
type Wide = { month: string; revenue: number; expenses: number };
type Long = { month: string; series: string; value: number };
const long = pivotLonger<Wide, Long>(data, {
keys: ["revenue", "expenses"],
keyColumn: "series",
valueColumn: "value",
});
const device = await (await navigator.gpu.requestAdapter())!.requestDevice();
plot<Long>({ data: long })
.layer(line({ x: "month", y: "value", color: "series" }))
.axes({ x: { title: "Month" }, y: { title: "USD" } })
.mount(canvas, { device });

The key conceptual shift from Chart.js is the data shape. Chart.js wants a dataset per series (one array per series, labeled). insomni-plot wants one flat array of rows. When you have wide data (one column per series), use pivotLonger from insomni-plot to reshape it first.

A before/after — bar chart

Chart.js — before

new Chart(canvas, {
type: "bar",
data: {
labels: ["Q1", "Q2", "Q3", "Q4"],
datasets: [{ label: "Sales", data: [120, 145, 132, 167], backgroundColor: "steelblue" }],
},
options: {
scales: { y: { beginAtZero: true } },
},
});

insomni-plot — after

import { plot, bar } from "insomni-plot";
type Row = { quarter: string; sales: number };
const data: Row[] = [
{ quarter: "Q1", sales: 120 },
{ quarter: "Q2", sales: 145 },
{ quarter: "Q3", sales: 132 },
{ quarter: "Q4", sales: 167 },
];
plot<Row>({ data })
.layer(bar({ x: "quarter", y: "sales" }, { fill: "#4682b4" }))
.scale("y", { domain: [0, 200] })
.mount(canvas, { device });

What’s not 1:1: Chart.js has a rich plugin ecosystem (zoom, annotation, data labels as plugins). In insomni-plot, pan/zoom is a mount option (panZoom: true), annotations are built in (.annotate()), and data labels are an option on individual geoms (showValues, showTotals on bar(); label on most other geoms). There’s no separate plugin registration step.


A few places where the insomni-plot API differs from what you might expect coming from these libraries:

Colors are objects, not hex strings — except for fixed styling options. The color channel accepts column names, accessors, or constants, and a constant can be a hex string like "#888". Fixed styling options on geoms (like fill, stroke) follow the same rule — pass a string for a hex color. However, if you ever interact with the Color type directly (e.g. when building a custom color scale via insomni-plot/core), use the typed color constructors. Don’t mix raw hex strings where a Color object is expected. See Scales & aesthetics for the color scale API.

String x values use a band axis. If your x channel resolves to strings, insomni-plot automatically uses a "band" scale. You don’t declare type: "category" like in Chart.js — it’s inferred. Tune band padding with .scale("x", { padding: 0.2 }).

Data must be flat rows. The grammar operates on T[] — one row per datum, one column per field. Wide data (multiple series columns) needs to be pivoted to long format. pivotLonger from insomni-plot does that. fromMatrix handles 2D value arrays for tile() heatmaps.

plot() takes an object, not an array. It’s plot({ data }), never plot(data). Easy to miss if you’re used to Observable Plot’s Plot.dot(data, channels) pattern where data is the first positional argument to each mark.

device is required for GPU rendering. .toSVG() works without one, but .mount() requires a GPUDevice. Acquire it once — ideally near your app’s entry point — and share it across charts.

Live updates go through mounted.setData() or mounted.update(). There’s no chart.update() call on the spec itself; the Chart builder is immutable. For reactive data, either call mounted.setData(newData) to swap the data array, or pass a reactive signal as data to plot() and let it update automatically. See Mount, render & export.