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.
On a geoMap: a geographic value grid
Section titled “On a geoMap: a geographic value grid”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).
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(); }, };};import { geoGraticule } from "d3-geo";import type { Feature, FeatureCollection, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon } from "geojson";import { feature } from "topojson-client";import land110m from "world-atlas/land-110m.json";
/** A synthetic grid cell with a continuous value and a categorical bioregion. */export interface Cell { id: string; geometry: Polygon; /** Cell centroid [lon, lat]. */ center: [number, number]; /** Continuous field in [0, 1] (value field). */ value: number; /** Categorical bioregion id in 0..7. */ bioregion: number;}
/** Base cell size in degrees; the example scales it by powers of two via a slider. */export const BASE_STEP = 1;
function clamp01(x: number): number { return Math.max(0, Math.min(1, x));}
/** Generate a global grid of `step`°×`step`° cells with smooth synthetic fields. */export function makeCells(step: number = BASE_STEP): Cell[] { const cells: Cell[] = []; let col = 0; for (let lon = -180; lon < 180; lon += step, col++) { let row = 0; for (let lat = -90; lat < 90; lat += step, row++) { const lonR = (lon * Math.PI) / 180; const latR = (lat * Math.PI) / 180; const value = clamp01(0.5 + 0.5 * Math.sin(lonR * 2) * Math.cos(latR * 3)); const field = (Math.sin(lon / 40) + Math.cos(lat / 30)) * 0.5 + 1; // ~[0,2] const bioregion = Math.min(7, Math.max(0, Math.floor((field / 2) * 8))); const geometry: Polygon = { type: "Polygon", coordinates: [ [ [lon, lat], [lon, lat + step], [lon + step, lat + step], [lon + step, lat], [lon, lat], ], ], }; cells.push({ id: `${col}-${row}`, geometry, center: [lon + step / 2, lat + step / 2], value, bioregion, }); } } return cells;}
/** A fine grid over the central third of the globe (lon ±60°, lat ±30°), 4° cells — * a "dense" demo layer (used clipped to land). */export function centreCells(): Cell[] { return makeCells(4).filter((c) => Math.abs(c.center[0]) <= 60 && Math.abs(c.center[1]) <= 30);}
/** Wrap cells as a FeatureCollection for projection fitting. */export function cellsToFeatureCollection(cells: readonly Cell[]): FeatureCollection { const features: Feature[] = cells.map((c) => ({ type: "Feature", properties: { id: c.id }, geometry: c.geometry, })); return { type: "FeatureCollection", features };}
/** A GeoJSON object d3-geo can fill that isn't part of the strict GeoJSON spec. */export type Sphere = { type: "Sphere" };
/** The land outline (Natural Earth 110m) plus a sphere to fill as ocean. */export interface World { sphere: Sphere; land: MultiPolygon;}
// Derive the topojson Topology type from feature()'s own signature so we don't// take a direct dependency on the (transitive) topojson-specification types.type Topology = Parameters<typeof feature>[0];
/** * Convert the bundled world-atlas TopoJSON into a land MultiPolygon and a sphere. */export function loadWorld(): World { const topo = land110m as unknown as Topology; const fc = feature(topo, topo.objects.land!) as unknown as FeatureCollection<MultiPolygon>; return { sphere: { type: "Sphere" }, land: fc.features[0]!.geometry };}
/** A few well-known cities to show point geometry rendered alongside the grid. */export interface City { id: string; name: string; geometry: Point;}
export function makeCities(): City[] { const places: [string, number, number][] = [ ["London", -0.13, 51.51], ["New York", -74.01, 40.71], ["Tokyo", 139.69, 35.69], ["Sydney", 151.21, -33.87], ["Cape Town", 18.42, -33.92], ["Rio de Janeiro", -43.2, -22.91], ["Nairobi", 36.82, -1.29], ["Mumbai", 72.88, 19.08], ]; return places.map(([name, lon, lat]) => ({ id: name!, name: name!, geometry: { type: "Point", coordinates: [lon!, lat!] }, }));}
/** A 20° graticule as one MultiLineString feature. */export function makeGraticule(): Feature<MultiLineString> { return { type: "Feature", properties: {}, geometry: geoGraticule().step([20, 20])() };}
/** A great-circle-ish route as a LineString feature (London -> New York -> Tokyo). */export function makeRoute(): Feature<LineString> { return { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: [[-0.13, 51.51], [-74.01, 40.71], [139.69, 35.69]] }, };}
/** A cluster of locations as one MultiPoint feature. */export function makeCluster(): Feature<MultiPoint> { return { type: "Feature", properties: {}, geometry: { type: "MultiPoint", coordinates: [[18.42, -33.92], [151.21, -33.87], [-43.2, -22.91], [36.82, -1.29], [72.88, 19.08]] }, };}
/** A standalone Polygon feature (a box over the Sahara) to showcase polygon geometry. * Wound CLOCKWISE so d3-geo fills the small box (not the sphere complement). */export function makeDemoPolygon(): Feature<Polygon> { return { type: "Feature", properties: { name: "demo-region" }, geometry: { type: "Polygon", coordinates: [[[0, 15], [0, 30], [30, 30], [30, 15], [0, 15]]] }, };}
/** A handful of major rivers as rough named polylines (the bundled world-atlas data * has no rivers), shown on the GeoJSON-features map and used as streaming cluster * centers. Coordinates are approximate [lon, lat] traces, mouth → source. */export function makeMajorRivers(): Feature<LineString, { name: string }>[] { const rivers: [string, [number, number][]][] = [ ["Amazon", [[-50.0, -0.7], [-55.5, -2.5], [-60.0, -3.1], [-67.9, -3.5], [-73.2, -4.5]]], ["Nile", [[31.3, 31.4], [32.9, 24.1], [32.5, 15.6], [32.5, 9.5], [31.6, 2.3]]], ["Mississippi", [[-89.2, 29.2], [-90.1, 32.3], [-90.2, 38.6], [-91.2, 43.5], [-95.0, 47.2]]], ["Yangtze", [[121.8, 31.4], [114.3, 30.6], [106.5, 29.6], [100.2, 26.9], [94.7, 33.5]]], ["Congo", [[12.4, -6.0], [16.2, -4.3], [20.0, -1.0], [25.2, 0.5], [27.2, 3.0]]], ["Volga", [[48.0, 46.3], [45.0, 48.7], [44.5, 51.6], [47.5, 54.3], [37.0, 57.3]]], ["Ganges", [[90.5, 22.5], [88.0, 24.5], [83.0, 25.4], [78.0, 26.5], [78.9, 30.1]]], ]; return rivers.map(([name, coordinates]) => ({ type: "Feature", properties: { name }, geometry: { type: "LineString", coordinates }, }));}
// ---------------------------------------------------------------------------// Streaming sources — async generators that emit batches of features lazily// (only `batchSize` are materialized per tick, never the whole `total`), so a// consumer can `await`-iterate and append them live. Points/cells are CLUSTERED// around cities + major-river vertices (not uniform), which the world-map// examples then clip to land. Used by the "streaming data" examples.// ---------------------------------------------------------------------------
/** A solid default color all streamed features start with (the example's * "randomize" button swaps in a new color for new + retained features). */export const DEFAULT_STREAM_COLOR = "#e23b2f";
/** Per-feature properties: a stable id (continues across batches) + a color the * example owns (constant by default; swapped by the randomize button). */export interface StreamProps { id: number; color: string;}export type StreamPoint = Feature<Point, StreamProps>;export type StreamPolygon = Feature<Polygon, StreamProps>;
export interface StreamOptions { /** Total features emitted before the generator completes. Default 10,000,000. */ total?: number; /** Features per yielded batch. A function is re-read every batch, so the caller can * resize adaptively (e.g. to fit a frame budget). Default 1000. */ batchSize?: number | (() => number); /** Artificial delay between batches (ms), to mirror loading from a file/network. * Even 0 yields a macrotask so the browser can paint between batches. Default 0. */ delayMs?: number; /** Seed for the deterministic PRNG (reproducible streams). Default 1. */ seed?: number; /** Cooperative cancellation: iteration stops once `signal.aborted` is true. */ signal?: { aborted: boolean };}
/** Small, fast, seedable PRNG (mulberry32) — deterministic so streams reproduce. */function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
const clamp = (x: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, x));
/** * A clustered point process (Thomas-like): many weighted "hotspot" parents at a * range of scales — tight, dense cities; medium clumps along rivers; and a field * of random global hotspots with varied spread/weight. Offspring fall around a * parent with an exponential (heavy-tailed) radius, so the result is patchy and * multi-scale — far closer to real species-occurrence density than a smooth * gaussian. Clip-to-land then carves the continental outline. */interface Parent { lon: number; lat: number; /** Mean offspring radius in degrees. */ spread: number; /** Relative share of points drawn from this parent. */ weight: number;}
function buildParents(rng: () => number): Parent[] { const parents: Parent[] = []; for (const c of makeCities()) parents.push({ lon: c.geometry.coordinates[0]!, lat: c.geometry.coordinates[1]!, spread: 1.5, weight: 3 }); for (const river of makeMajorRivers()) for (const p of river.geometry.coordinates) parents.push({ lon: p[0]!, lat: p[1]!, spread: 2.5, weight: 2 }); // Random global hotspots: rng()*rng() biases toward small spreads/weights, so // most clumps are tight with a few diffuse ones — a wide range of scales. for (let i = 0; i < 240; i++) { parents.push({ lon: rng() * 360 - 180, lat: rng() * 160 - 80, spread: 0.6 + 9 * rng() * rng(), weight: 0.2 + 3 * rng() * rng(), }); } return parents;}
function cumulativeWeights(parents: readonly Parent[]): number[] { const cum: number[] = []; let s = 0; for (const p of parents) { s += p.weight; cum.push(s); } return cum;}
/** Pick a parent by weight (binary search over cumulative weights). */function pickParent(rng: () => number, parents: readonly Parent[], cum: readonly number[]): Parent { const r = rng() * cum[cum.length - 1]!; let lo = 0; let hi = cum.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (cum[mid]! < r) lo = mid + 1; else hi = mid; } return parents[lo]!;}
/** Offspring location around a parent: exponential radius, uniform direction. */function clusteredLonLat(rng: () => number, p: Parent): [number, number] { const radius = -p.spread * Math.log(Math.max(1e-9, rng())); // exponential, mean = spread const ang = rng() * 2 * Math.PI; return [ clamp(p.lon + radius * Math.cos(ang), -180, 180), clamp(p.lat + radius * Math.sin(ang), -90, 90), ];}
/** * An irregular, star-convex polygon ring (3–10 vertices, varied per-vertex radius) * centered at [clon, clat] with overall extent ≤ ~`size`° — a rough species range. * Angles are evenly spaced with bounded jitter so they stay monotonic ⇒ the ring is * simple (non-self-intersecting) and closed. Longitude offsets are widened by * 1/cos(lat) so ranges don't look squished toward the poles. * * WINDING: built CLOCKWISE in [lon, lat] (note the NEGATIVE angle). d3-geo fills on * the sphere, and a small exterior ring wound counter-clockwise is treated as its * complement → it fills the whole map. See `AGENTS.md` and `geo/project.ts`. */function randomRangeRing(rng: () => number, clon: number, clat: number, size: number): [number, number][] { const verts = 3 + Math.floor(rng() * 8); // 3..10 // Strongly heavy-tailed size: the vast majority of ranges are TINY and only ~3% are // visibly large. At high counts the translucent fill then reads as a density gradient // (clustered richness hotspots) instead of saturating the whole map red. const base = rng() < 0.03 ? size * (0.1 + 0.15 * rng()) // ~3% larger ranges: 0.10..0.25 * size : size * (0.02 + 0.07 * rng() * rng()); // most tiny: 0.02..0.09 * size, biased small (rng²) const latScale = 1 / Math.max(0.25, Math.cos((clat * Math.PI) / 180)); const ring: [number, number][] = []; for (let i = 0; i < verts; i++) { const ang = -((i + 0.5 * rng()) / verts) * 2 * Math.PI; // NEGATIVE ⇒ clockwise ⇒ fills interior const r = base * (0.5 + rng()); // per-vertex radius variation ring.push([ clamp(clon + r * Math.cos(ang) * latScale, -180, 180), clamp(clat + r * Math.sin(ang), -90, 90), ]); } ring.push(ring[0]!); // close the ring return ring;}
const tick = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** Stream world points as GeoJSON `Feature<Point>` batches, clustered around * many multi-scale hotspots. All start with DEFAULT_STREAM_COLOR. */export async function* makeStreamingPoints(opts: StreamOptions = {}): AsyncGenerator<StreamPoint[]> { const { total = 10_000_000, batchSize = 1000, delayMs = 0, seed = 1, signal } = opts; const sizeOf = (): number => Math.max(1, Math.floor(typeof batchSize === "function" ? batchSize() : batchSize)); const rng = mulberry32(seed); const parents = buildParents(rng); const cum = cumulativeWeights(parents); let id = 0; while (id < total) { if (signal?.aborted) return; const n = Math.min(sizeOf(), total - id); const batch: StreamPoint[] = new Array(n); for (let k = 0; k < n; k++) { const [lon, lat] = clusteredLonLat(rng, pickParent(rng, parents, cum)); batch[k] = { type: "Feature", properties: { id: id++, color: DEFAULT_STREAM_COLOR }, geometry: { type: "Point", coordinates: [lon, lat] }, }; } yield batch; await tick(delayMs); }}
/** Stream irregular polygon "ranges" clustered around hotspots — each a 3–10 * vertex star-convex polygon of varied size ≤ ~`size`°, to mimic species ranges. * All start with DEFAULT_STREAM_COLOR; the example renders them very transparent * so overlapping ranges build up richness. */export async function* makeStreamingPolygons( opts: StreamOptions & { size?: number } = {},): AsyncGenerator<StreamPolygon[]> { const { total = 10_000_000, batchSize = 1000, delayMs = 0, seed = 1, size = 16, signal } = opts; const sizeOf = (): number => Math.max(1, Math.floor(typeof batchSize === "function" ? batchSize() : batchSize)); const rng = mulberry32(seed); const parents = buildParents(rng); const cum = cumulativeWeights(parents); let id = 0; while (id < total) { if (signal?.aborted) return; const n = Math.min(sizeOf(), total - id); const batch: StreamPolygon[] = new Array(n); for (let k = 0; k < n; k++) { const [clon, clat] = clusteredLonLat(rng, pickParent(rng, parents, cum)); batch[k] = { type: "Feature", properties: { id: id++, color: DEFAULT_STREAM_COLOR }, geometry: { type: "Polygon", coordinates: [randomRangeRing(rng, clon, clat, size)] }, }; } yield batch; await tick(delayMs); }}On a plot: a 2D scatter
Section titled “On a plot: a 2D scatter”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.
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;};export interface Dot { id: string; x: number; y: number; group: number; value: number }export interface Region { group: number; x: number; y: number; w: number; h: number }
/** Deterministic PRNG (mulberry32) so the scatter — and its screenshots — are stable. */function mulberry32(seed: number): () => number { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
/** Six gaussian clusters of points, plus a padded bounding-box region per cluster. */export function makeData(width: number, height: number): { dots: Dot[]; regions: Region[] } { const rnd = mulberry32(42); const gauss = () => { const u = Math.max(rnd(), 1e-9); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd()); }; const groups = 6; const perGroup = 70; const pad = 14; const dots: Dot[] = []; const regions: Region[] = []; for (let g = 0; g < groups; g++) { const cx = width * (0.15 + 0.7 * rnd()); const cy = height * (0.15 + 0.7 * rnd()); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (let k = 0; k < perGroup; k++) { const x = cx + gauss() * width * 0.05; const y = cy + gauss() * height * 0.05; dots.push({ id: `g${g}-${k}`, x, y, group: g, value: rnd() }); minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } regions.push({ group: g, x: minX - pad, y: minY - pad, w: maxX - minX + 2 * pad, h: maxY - minY + 2 * pad }); } return { dots, regions };}Stress test: hover & selection at scale
Section titled “Stress test: hover & selection at scale”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.
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(); }, };};export interface Point { id: string; x: number; y: number; /** A category 1–10, encoded as the fill color. */ category: number; /** A value in [0, 1], encoded as the radius. */ value: number; radius: number;}
/** Deterministic PRNG (mulberry32) so the scatter — and its screenshots — are stable. */function mulberry32(seed: number): () => number { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };}
const R_MIN = 2;const R_MAX = 9;
/** `count` points scattered uniformly over the canvas: each gets a random category (1–10, * drives color), a random value in [0, 1] (drives radius R_MIN…R_MAX), and a random x/y. */export function makePoints(count: number, width: number, height: number): Point[] { const rnd = mulberry32(count); // seed by count so each size is its own stable layout const points: Point[] = new Array(count); for (let i = 0; i < count; i++) { const value = rnd(); points[i] = { id: `p${i}`, x: rnd() * width, y: rnd() * height, category: 1 + Math.floor(rnd() * 10), value, radius: R_MIN + value * (R_MAX - R_MIN), }; } return points;}