Skip to content

Engines

d3gl gives you three engines:

  • geoMap() — geographic. Layers are GeoJSON features drawn through a d3 GeoProjection, with rotation, re-projection, and a GPU globe.
  • plot() — 2D Cartesian. Layers are drawn with a draw(ctx, datum) callback or x/y point accessors in plot coordinates (trees, charts, link diagrams, scatter plots).
  • network() — node–link graphs. Nodes and links are GPU-instanced glyphs — built for millions of elements — placed by the in-library force layout (main-thread, worker, or GPU) or your own positions, with an adaptive level-of-detail cut for maps of networks.

They are siblings over a shared base (BaseEngine, internal). They differ only in how their geometry is defined. Everything else — interaction, backends, view transform, sizing, and export — lives in the base, so it behaves identically on all of them. Pick the engine that matches your data; the rest of the API is the same.

import { geoMap } from "@mapequation/d3gl/map"; // geographic
import { plot } from "@mapequation/d3gl/map"; // 2D Cartesian
import { network } from "@mapequation/d3gl/network"; // node–link graphs

Once you have an engine instance, these work the same regardless of which one you created:

ConcernAPI
Eventson("hover", …), on("click", …) — hit: { layer, id, datum } | null
Hit-testingpick(x, y) (clip-aware; retained layers are pickable by default)
Highlighthighlight(layer, idOrIds, styleOrDraw)
Selectionselect(layer, ids)
Style overridessetStyle(layer, ids, …), clearStyle(layer, ids)
ViewenableZoom(extent) (wheel ticks and pan moves draw once per animation frame, at the latest transform), setTransform(t) (a programmatic view, drawn at once: keeps the zoom gesture in step and never counts as a gesture; with zoom enabled it settles like a gesture’s end), render()
Sizingwidth / height / aspectRatio options, setSize(w, h) — see Sizing
BackendsetBackend(type), whenReady() — see Rendering backends
ExporttoPNG(), toSVG()
Teardowndestroy()

Retained layers on the geoMap and plot engines also accept the same declarative interaction options (InteractiveLayerOptions), forwarded into the layer the same way:

engine.layer("things", data, {
hover: true, // or a style, or a (datum, HighlightBuilder) draw fn
tooltip: (d, id) => `${d.name}`, // string | HTMLElement | null
selection: { others: { opacity: 0.3 } },
pickable: true, // default; false skips the hit index
});

See the Interaction guide for what each one does and what it costs — every example there reads identically whether engine is a geoMap or a plot. The network engine’s glyphs live on a GPU-instanced lane rather than retained layers, so they opt into the same hover/selection machinery with one call — net.interactive({ hover: true, selectable: true }) — and resolve through the same on("hover" | "click" | "select") events.

All three engines are responsive by default and resize in place — when their container changes size they reconcile the rendering surface and re-render without tearing down, so the view transform, layers, hover, and selection are all preserved. Three modes, picked by which options you pass (to the plot() / geoMap() / network() constructors or the React <Plot> / <GeoMap> props):

You passModeBehavior
aspectRatiowidth-drivenFills the parent’s width and keeps width ÷ height = aspectRatio.
nothingfill-parentTracks the parent box. The parent must give it a height (from your CSS).
both width & heightfixedA static pixel size — the opt-out (the pre-responsive behavior).
<Plot aspectRatio={16 / 9} /> {/* fills width, keeps the ratio */}
<GeoMap /> {/* fills the parent box */}
<Plot width={800} height={400} /> {/* fixed size */}
// Bare engines take the same options; the engine observes its host element.
const chart = plot(host, { aspectRatio: 2 });
// Resize imperatively at any time (no recreate):
chart.setSize(1024, 512);

A resized geoMap also refits its projection to the new box, so the map keeps filling it. A uniform resize (always the case in aspectRatio mode) preserves your original framing exactly; an aspect-ratio change (possible in fill-parent mode) re-letterboxes the map to the new shape.

Curves you draw through a layer’s draw callback — arc, arcTo, bezierCurveTo, quadraticCurveTo, and anything a d3-shape generator emits — are flattened to a polyline once, when the layer is registered, in world units. Zooming only scales that baked polyline, so a facet measuring t world units measures t x k screen px at zoom k: at the default tolerance of 0.25, a k = 40 view shows ~10px flats on an arc rim.

Set curveTolerance to the deviation you can accept at your deepest zoom:

const MAX_ZOOM = 40;
const chart = plot(host, { width, height, curveTolerance: 0.25 / MAX_ZOOM });
chart.enableZoom([0.5, MAX_ZOOM]);

It is opt-in, and it is a quality/size dial rather than a free win:

  • Zero per-frame cost. The refinement is paid entirely in the one-time bake; nothing about setTransform, hover, or drag changes.
  • Segment count grows as 1 / sqrt(tolerance) — so 0.25 / 40 is ~6.3x the vertices, and 0.25 / 100 ~10x, of the world-scaled curved drawables only. Straight paths, rects, and points() circles cost nothing extra (every backend draws a circle analytically).
  • Anchored sizeMode: "screen" glyphs are exempt, automatically. They are drawn as constant-pixel offsets from their anchor, so their bake is already in screen pixels and cannot facet under zoom. They keep the default’s 0.25px bake (or a coarser one, if you set a curveTolerance above 0.25), so a chart that mixes screen pies or symbols with world-space curves pays only for the curves. The rule is per drawable: a screen sizeMode layer drawn without anchors is world-scaled geometry, so it does facet and it does refine. One consequence: a glyph’s bake is sub-pixel on screen, but a toSVG() export magnified far past 1:1 shows it as the polygon it is (a 16px pie is a ~13-gon).

Measured on the ancestral ranges example in the rectangular layout with 512 tips (2,208 drawables): the geometry the layers retain (vertex, index, and anchor buffers) at the default tolerance and at curveTolerance: 0.25 / 40:

layoutcoordslinksgeometry at the defaultat 0.25 / 40
rectangularscreen (pies are anchored glyphs)step (straight)1.18 MB1.18 MB — nothing refines
radialscreen (pies are anchored glyphs)step (arcs)1.18 MB1.29 MB — only the link arcs refine
rectangularscreen (pies are anchored glyphs)bump (curves)1.49 MB4.87 MB — only the links refine
rectangularworld (pies scale with zoom)bump (curves)0.98 MB5.54 MB — links and pies refine

Pick the smallest MAX_ZOOM your chart actually allows.

What’s unique to each engine is the geometry definition plus a few domain methods:

geoMap()plot()network()
Constructor optionprojection (required)——
Geometrylayer(name, features, opts) — GeoJSON + projectionlayer(name, data, { draw }) and points(name, data, { x, y })data(graph) — nodes + links from buildGraph(edges)
Domain methodssetProjection(), enableRotation()—style(), layout(), lod(), labels(), interactive()
Coordinatesgeographic [lon, lat], projected each frameplot coordinates, drawn directlylayout coordinates (force layout or supplied positions)
// geoMap: GeoJSON features through a projection
const map = geoMap(host, { width, height, projection });
map.layer("land", [world.land], { fill: "#e3e6ea", hover: true });
// plot: a draw callback in plot coordinates
const chart = plot(host, { width, height });
chart.layer("boxes", rects, {
draw: (ctx, d) => ctx.rect(d.x, d.y, 40, 40),
fill: "#4477aa",
hover: { fill: "#ee6677" },
});
// network: a graph from an edge list — the layout runs in-library
const net = network(host, { width, height });
net.data(buildGraph({ nodeCount, source, target })).layout({ backend: "worker" });
  • Mapping anything geographic (GeoJSON, projections, a globe) → geoMap().
  • A node–link graph (networks, maps of networks, state networks) → network().
  • Everything else in a 2D plane (trees, scatter, links, custom marks) → plot().

Because the shared base is the same object, you can move between them without relearning interaction, styling, or backends — only the geometry-definition call changes.