Skip to content

Highlight

The hover-highlight, tooltip, and click-selection API lives on the engines’ shared base, so it works identically on both geoMap and plot. The same interaction is shown below on each — see the Interaction guide for the full API and Engines for the universal-vs-engine-specific split.

A synthetic value field on a grid, clipped to the land outline. Hover any cell to highlight it and read its value. Click a cell to select it together with every cell within ±0.1 of its value — all other cells dim to 30% opacity. Click open ocean to clear the selection.

Hover redraws only the hovered cell into a tiny overlay layer, and selection rewrites only the per-cell color tables — neither touches the grid’s geometry, so both stay instant at 1° (≈ 65k cells). Scroll to zoom; the cell-size slider rebuilds the grid (which clears the selection).

fps 0frame 0 ms
Cell size
draw.ts
import { geoNaturalEarth1 } from "d3-geo";
import { scaleSequential } from "d3-scale";
import { interpolateViridis } from "d3-scale-chromatic";
import { geoMap } from "@mapequation/d3gl/map";
import { fitProjection } from "@mapequation/d3gl/geo";
import type { ImperativeSetup } from "../types.js";
import { makeCells, loadWorld, type Cell } from "../shared/geo-data.js";
const heat = scaleSequential(interpolateViridis).domain([0, 1]);
/**
* Hover + click interaction on a land-clipped value grid, all through core d3gl:
* the `hover` option outlines the hovered cell in a tiny overlay layer (the grid's
* buffers are untouched), `tooltip` reads out its value, and a click selects the
* cell plus every cell within ±0.1 of its value — `select()` dims the rest to 30%
* via the `selection` option (one style-table write, no re-tessellation). Clicking
* open ocean clears the selection (picking is clip-aware, so a cell only counts
* where it is visibly painted on land). The `cells` slider rebuilds only the grid
* layer, preserving zoom/pan; a rebuilt grid starts unselected (its ids change).
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const world = loadWorld();
const projection = fitProjection(geoNaturalEarth1(), { type: "Sphere" }, width, height);
// The cells the click handler resolves against, kept current as render rebuilds them.
let cells: Cell[] = [];
let cellById = new Map<string, Cell>();
const map = geoMap(host, {
width, height, projection, backend,
tooltipClass:
"rounded border border-border bg-card/95 px-1.5 py-0.5 text-xs text-foreground",
});
map.layer("ocean", [world.sphere], { fill: "#d4e6f5" });
map.layer("land", [world.land], { fill: "#e7e7e0" });
map.on("click", (hit) => {
if (hit?.layer !== "cells") {
map.select("cells", null); // clicked outside the grid: clear
return;
}
const v = cellById.get(hit.id as string)?.value;
if (v === undefined) return;
map.select("cells", (_g, i) => Math.abs(cells[i]!.value - v) <= 0.1);
});
map.enableZoom([1, 50]); // scroll to zoom, drag to pan (clicks still fire — drags don't)
return {
engine: map,
// Rebuild only the "cells" layer at the chosen grid size; re-pushed at the
// map's CURRENT transform, so zoom/pan survives a slider change.
render: (options) => {
const exp = (options.cells as number) ?? 2; // grid-size exponent from the slider
const step = 2 ** exp; // degrees: 0→1°, 1→2°, 2→4°, 3→8°
cells = makeCells(step);
cellById = new Map(cells.map((c) => [c.id, c]));
map.layer("cells", cells.map((c) => c.geometry), {
id: (_g, i) => cells[i]!.id,
fill: (_g, i) => heat(cells[i]!.value),
clipTo: "land", // clip the grid to the land outline
hover: { stroke: "#fff", lineWidth: 1 },
tooltip: (_g, id) => {
const c = cellById.get(id as string);
return c ? `value ${c.value.toFixed(3)}` : null;
},
selection: { others: { opacity: 0.3 } },
});
map.render();
},
};
};

The same hover-highlight + click-selection, on a Cartesian scatter instead of a map. Hover a point or a cluster region to outline it and read a tooltip; click either to select its whole cluster (everything else dims); click empty space to clear. Scroll to zoom, drag to pan.

This one uses two layer kinds: drawn rectangles (layer with a draw callback) outline each cluster, with the scatter points on top — both carry the same declarative options. The only difference from the map version is the engine factory and the layer-definition calls.

fps 0frame 0 ms
draw.ts
import { schemeCategory10 } from "d3-scale-chromatic";
import { plot } from "@mapequation/d3gl/map";
import type { ImperativeSetup } from "../types.js";
import { makeData, type Dot, type Region } from "./data.js";
const color = (group: number): string => schemeCategory10[group % 10] ?? "#888";
/** A translucent tint of a #rrggbb hex, for the region fills. */
const tint = (hex: string, alpha: number): string =>
`rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${alpha})`;
/**
* The same interaction as the geographic Highlight example, but on a `plot` engine —
* proof that `hover` / `tooltip` / `selection` are universal (they live on the shared
* base, not on `geoMap`). Two layer kinds drive it: drawn rectangles (`layer` with a
* `draw` callback) outline each cluster, and `points` are the scatter on top. Both carry
* the same declarative options: **hover** a point or a region to outline it in a tiny
* overlay and read a tooltip; **click** either to select the whole cluster — every other
* point and region dims via `select()` + the `selection` option. Click empty space to
* clear. Scroll to zoom, drag to pan.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const { dots, regions } = makeData(width, height);
const groupOf = new Map(dots.map((d) => [d.id, d.group]));
const chart = plot(host, {
width, height, backend,
tooltipClass:
"rounded border border-border bg-card/95 px-1.5 py-0.5 text-xs text-foreground",
});
// Drawn rectangles (the `draw` callback) — behind the points, so points win the pick
// where they overlap and a region is hit only in the gaps between its dots.
chart.layer("regions", regions, {
draw: (ctx, r) => ctx.rect(r.x, r.y, r.w, r.h),
fill: (r) => tint(color(r.group), 0.1),
stroke: (r) => tint(color(r.group), 0.6),
lineWidth: 1,
id: (r) => `r${r.group}`,
hover: { stroke: "#fff", lineWidth: 2 },
tooltip: (r) => `cluster ${r.group}`,
selection: { others: { opacity: 0.15 } },
});
chart.points("dots", dots, {
x: (d) => d.x,
y: (d) => d.y,
radius: 5,
fill: (d) => color(d.group),
id: (d) => d.id,
hover: { stroke: "#fff", lineWidth: 2, radiusScale: 1.4 },
tooltip: (d) => `cluster ${d.group} · ${d.value.toFixed(2)}`,
selection: { others: { opacity: 0.25 } },
});
chart.on("click", (hit) => {
if (hit?.layer !== "dots" && hit?.layer !== "regions") {
chart.select("dots", null); // clicked empty space: clear both layers
chart.select("regions", null);
return;
}
const g = hit.layer === "dots" ? groupOf.get(hit.id as string) : Number((hit.id as string).slice(1));
chart.select("dots", (d: Dot) => d.group === g);
chart.select("regions", (r: Region) => r.group === g);
});
chart.enableZoom([0.5, 20]); // scroll to zoom, drag to pan (clicks still fire — drags don't)
chart.render();
return chart;
};

How well do hover and selection hold up as the point count climbs? The points slider grows the scatter from 32 to ~1M points, each with a random category 1–10 (color) and a random value (radius). Hover any point for a tooltip of all its properties; click a point to select its whole category — every other point dims to 20%. Click empty space to clear. Scroll to zoom, drag to pan. On the WebGL backend it stays smooth at the top of the range.

The layer’s hit index keeps hover instant even at the top of the range, and selection only rewrites the per-point color tables — no re-upload of geometry. The rich tooltip is built with d3gl’s h hyperscript helper. The coords toggle picks world (radii scale with zoom) vs screen (constant-pixel radii), exactly as in the ancestral ranges example.

fps 0frame 0 ms
Points1,024
Coords
draw.ts
import { schemeCategory10 } from "d3-scale-chromatic";
import { plot, h } from "@mapequation/d3gl/map";
import type { ImperativeSetup } from "../types.js";
import { makePoints, type Point } from "./data.js";
type SizeMode = "world" | "screen";
const color = (category: number): string => schemeCategory10[(category - 1) % 10] ?? "#888";
/** Hover-tooltip body: every property of the point, as a labelled table. The header is a
* color swatch + the category; rows show id, x/y, value, and radius. Built with d3gl's `h`
* hyperscript so the engine's shared tooltip renders it. */
function pointTooltip(d: Point): HTMLElement {
const row = (key: string, value: string): HTMLElement =>
h("tr", null, [
h("td", { class: "pr-2 opacity-60" }, key),
h("td", { class: "text-right tabular-nums" }, value),
]);
return h("div", null, [
h("div", { class: "mb-1 flex items-center gap-1.5 font-semibold" }, [
h("span", {
class: "inline-block h-2.5 w-2.5 rounded-sm",
style: `background:${color(d.category)}`,
}),
`category ${d.category}`,
]),
h("table", { class: "border-collapse" }, [
row("id", d.id),
row("x", d.x.toFixed(1)),
row("y", d.y.toFixed(1)),
row("value", d.value.toFixed(3)),
row("radius", d.radius.toFixed(2)),
]),
]);
}
/**
* A hover/selection stress test on a `plot` scatter: the **points** slider grows the cloud
* from 32 to ~1M points, each with a random category 1–10 (color) and a random value
* (radius). **Hover** any point for a tooltip of all its properties; **click** a point to
* select its whole category — every other point dims.
* Click empty space to clear. Scroll to zoom, drag to pan. The **coords** toggle picks
* `world` (radii scale with zoom) vs `screen` (constant-pixel radii). The hit index keeps
* hover instant even at the top of the range. Pure d3gl; the harness owns the controls.
*/
export const setup: ImperativeSetup = (host, { width, height, backend }) => {
const W = width, H = height;
const chart = plot(host, {
width: W, height: H, backend,
// Themed (dark-mode aware) tooltip card — now honored on `plot` via BaseEngineOptions.
tooltipClass:
"rounded border border-border bg-card/95 px-1.5 py-0.5 text-xs text-foreground",
});
// Click selection is wired once: it reads the clicked point's category from a lookup that
// `render` refreshes whenever the point cloud is rebuilt, then selects the whole category.
let categoryOf = new Map<string, number>();
chart.on("click", (hit) => {
if (hit?.layer !== "dots") {
chart.select("dots", null); // clicked empty space: clear the selection
return;
}
const cat = categoryOf.get(hit.id as string);
chart.select("dots", (d: Point) => d.category === cat);
});
chart.enableZoom([0.5, 20]); // scroll to zoom, drag to pan (clicks still fire — drags don't)
return {
engine: chart,
// Rebuild the point cloud when `points` or `coords` change; never touches the transform,
// so the current zoom/pan survives an option change.
render: (options) => {
const count = 2 ** ((options.points as number) ?? 10); // 2^exp points (exp 5..16)
const sizeMode = (options.coords as SizeMode) ?? "world";
const points = makePoints(count, W, H);
categoryOf = new Map(points.map((p) => [p.id, p.category]));
chart.points("dots", points, {
x: (d) => d.x,
y: (d) => d.y,
radius: (d) => d.radius,
fill: (d) => color(d.category),
id: (d) => d.id,
sizeMode,
hover: { stroke: "#fff", lineWidth: 2, radiusScale: 1.4 },
tooltip: (d) => pointTooltip(d),
selection: { others: { opacity: 0.2 } },
});
chart.render();
},
};
};