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
Chartbuilder is immutable..layer(),.scale(),.axes(), and every other method return a newChart. Your base spec is never mutated — you branch from it, hand it to faceting, fork it for variants. .mount()needs aGPUDevice. Unlike D3 (pure DOM) or Chart.js (2D canvas),insomni-plotrenders via WebGPU. You acquire a device once and pass it at mount time. The static.toSVG()path has no such requirement.
From Observable Plot
Section titled “From Observable Plot”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.
Mark vocabulary
Section titled “Mark vocabulary”| Observable Plot | insomni-plot | Notes |
|---|---|---|
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.ruleY | rule({x}) / rule({y}) | Reference lines. |
Plot.text(data, {x, y, text}) | text({x, y, text}) | Same idea. |
Plot.boxX / Plot.boxY | boxplot({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.
Marks array → .layer() chain
Section titled “Marks array → .layer() chain”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/strokeas the color channel key; insomni-plot usescolorfor data-driven color and the options object for fixed styling ({ stroke: "#aaa" }). .plot()returns a DOM element..mount()returns aMountedPlothandle withsetData(),update(),destroy(), and live interaction state.
Faceting
Section titled “Faceting”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 });Scales
Section titled “Scales”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 PlotPlot.plot({ color: { type: "sequential", scheme: "viridis" }, // ...});
// insomni-plotimport { 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().
From D3
Section titled “From D3”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.
What insomni-plot does for you
Section titled “What insomni-plot does for you”The boilerplate D3 chart authors repeat on every project maps directly to insomni-plot’s grammar:
| D3 pattern | insomni-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-render | Built 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.
When to drop down to insomni-plot/core
Section titled “When to drop down to insomni-plot/core”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 valueThis 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.
From Chart.js
Section titled “From Chart.js”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.
Config-object → declarative spec
Section titled “Config-object → declarative spec”| Chart.js concept | insomni-plot equivalent |
|---|---|
type: "scatter", "line", "bar", … | A geom factory: point(), line(), bar(), … |
data.datasets | Data in a flat array; color channel splits into series. |
data.labels | Categories 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.tooltip | mount(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.
Gotchas worth knowing
Section titled “Gotchas worth knowing”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.