Skip to content

Streaming data

layer.append(batch) streams new features into an existing layer without re-projecting the features already on screen — the key to live data like species occurrences. Points land in patchy, multi-scale clusters (a Thomas-like point process) and are clipped to land; the polygon “ranges” are large and rendered very transparently, so overlapping ranges build up species-richness hotspots. A live readout (top-right) shows the record count, percentage of the chosen total, and the average append speed so far.

Each example keeps its own retained GeoJSON array alongside the layer. Every feature starts the same color; Randomize colors picks a new color, applies it to all future features, and rewrites the retained features’ properties.color then recolor()s them — so streaming and the retained-for-redraw data stay in sync. Use Stream to pause/resume, Restart for a fresh stream, Data size for the total to stream (100k / 1M / 10M), Batch size for how many features each tick emits — including adaptive (the default), which auto-tunes the batch each frame to fit a ~30fps budget so the stream runs as fast as the backend allows while staying responsive — and Batch delay (ms) for an artificial per-batch delay that mirrors loading from a file. The readout’s batch line shows the live (adaptive) batch size.

The source (makeStreamingPoints / makeStreamingPolygons in shared/geo-data.ts) is an async generator that yields batches lazily up to the chosen total, so nothing is materialized up front.

Backend matters. The point examples default to the canvas backend (the polygons example defaults to auto), where append is truly O(new) (new geometry is drawn on top, no full redraw) — so adaptive batching ramps up and the stream stays fast. On WebGL, append currently rebuilds the whole layer renderer per batch (O(total)); adaptive then backs off the batch to hold the frame budget (you’ll see the batch readout fall toward 1) and throughput stays modest. A follow-up adds true O(new) WebGL sub-buffer uploads; until then, use canvas for high streaming throughput.

fps 0frame 0 ms
Stream
draw.ts
import { geoEquirectangular } from "d3-geo";
import { geoMap, type LayerHandle } from "@mapequation/d3gl/map";
import {
loadWorld,
makeStreamingPoints,
DEFAULT_STREAM_COLOR,
type StreamPoint,
} from "../shared/geo-data.js";
import { StreamController, randomHsl, DATA_SIZE_TOTALS, ADAPTIVE_SEED_BATCH } from "../shared/streaming.js";
import { createStatsOverlay } from "../shared/stats-overlay.js";
import type { ImperativeSetup } from "../types.js";
const OCEAN = "#dbe7f3";
const LAND = "#e9e7df";
/**
* Stream points (clustered around cities + rivers) and append them live with
* `layer.append(batch)` — only the NEW points project; existing ones are
* untouched and clipped to land. Every feature carries a `color` the example
* owns: all start red, and "Randomize colors" picks a new color for FUTURE
* points AND rewrites the retained ones' properties + `recolor()`s them — showing
* streaming and retained-for-redraw working together.
*/
export const setup: ImperativeSetup = (host, { width, height, backend, options }) => {
const world = loadWorld();
const projection = geoEquirectangular().fitSize([width, height], { type: "Sphere" });
const map = geoMap(host, { width, height, projection, backend });
map.layer("ocean", [world.sphere], { fill: OCEAN });
map.layer("land", [world.land], { fill: LAND, stroke: "#9aa3ad", lineWidth: 0.4 });
map.enableZoom([1, 40]);
const retained: StreamPoint[] = [];
let currentColor = DEFAULT_STREAM_COLOR; // the color new points get (until randomized)
const pointOpts = {
fill: (f: StreamPoint) => f.properties.color,
pointRadius: 0.5,
id: (f: StreamPoint) => f.properties.id,
clipTo: "land", // only show points over land
pickable: false, // millions of streamed points — skip the hit index (no hover) to save memory
};
let points: LayerHandle<StreamPoint> = map.layer("points", [], pointOpts);
map.render();
const stats = createStatsOverlay(host);
let count = 0; // records appended this session
let appendMs = 0; // total time spent in append() — gives the average speed
let seed = 1;
let total = DATA_SIZE_TOTALS[String(options.size)] ?? 1_000_000;
const ctrl = new StreamController<StreamPoint>({
source: (o) => makeStreamingPoints({ total, ...o }),
onBatch: (batch) => {
for (const f of batch) {
f.properties.color = currentColor; // new points get the current color
retained.push(f); // retain for redraw / recolor (loop, not push(...spread): batch can be 1M)
}
const t0 = performance.now();
points.append(batch); // incremental append — only the new points project
appendMs += performance.now() - t0;
count += batch.length;
stats.update(count, total, count / (appendMs / 1000), ctrl.batchSize);
},
onReset: () => {
retained.length = 0;
count = 0;
appendMs = 0;
points = map.layer("points", [], pointOpts); // re-register empty to clear
stats.update(0, total, 0, ctrl.batchSize, true);
},
});
// "adaptive" auto-tunes the batch to a frame budget; a number fixes it.
const applyBatch = (opt: string): void => {
ctrl.adaptive = opt === "adaptive";
ctrl.batchSize = ctrl.adaptive ? ADAPTIVE_SEED_BATCH : Number(opt);
};
// Stream runs only when the user wants it AND the canvas is on-screen (the harness
// calls setVisible). A manual pause persists across scroll-out/in.
let visible = true;
let userRunning = options.stream === "run";
let lastBatchOpt = String(options.batch);
applyBatch(lastBatchOpt);
ctrl.delayMs = Number(options.rate);
ctrl.setRunning(userRunning && visible);
ctrl.restart(seed);
let lastRandomize = Number(options.randomize) || 0;
let lastRestart = Number(options.restart) || 0;
return {
engine: map,
render: (o) => {
userRunning = o.stream === "run";
ctrl.setRunning(userRunning && visible);
if (o.randomize !== lastRandomize) {
lastRandomize = Number(o.randomize) || 0;
currentColor = randomHsl(); // future points...
for (const f of retained) f.properties.color = currentColor; // ...and already-stored ones
points.recolor(); // re-render from the retained, updated properties
}
const batchOpt = String(o.batch);
const rate = Number(o.rate);
const newTotal = DATA_SIZE_TOTALS[String(o.size)] ?? total;
if (
batchOpt !== lastBatchOpt ||
rate !== ctrl.delayMs ||
newTotal !== total ||
o.restart !== lastRestart
) {
lastRestart = Number(o.restart) || 0;
lastBatchOpt = batchOpt;
applyBatch(batchOpt);
ctrl.delayMs = rate;
total = newTotal;
ctrl.restart(++seed);
}
},
setVisible: (v) => {
visible = v;
ctrl.setRunning(userRunning && visible); // pause offscreen; resume only if user wants run
},
dispose: () => {
ctrl.dispose();
stats.destroy();
},
};
};
fps 0frame 0 ms
Stream
draw.ts
import { geoEquirectangular } from "d3-geo";
import { geoMap, type LayerHandle } from "@mapequation/d3gl/map";
import { loadWorld, makeStreamingPolygons, type StreamPolygon } from "../shared/geo-data.js";
import { StreamController, randomHsl, DATA_SIZE_TOTALS, ADAPTIVE_SEED_BATCH } from "../shared/streaming.js";
import { createStatsOverlay } from "../shared/stats-overlay.js";
import type { ImperativeSetup } from "../types.js";
const OCEAN = "#dbe7f3";
const LAND = "#e9e7df";
const RANGE_ALPHA = 0.05; // very transparent so overlapping ranges build up a density gradient
const DEFAULT_RANGE_COLOR = `hsla(8, 80%, 53%, ${RANGE_ALPHA})`; // translucent red
/**
* Stream small polygon cells (clustered around cities + rivers) and append them
* live with `layer.append`. Only new cells project/tessellate per batch; existing
* ones stay put, clipped to land. All cells start red; "Randomize colors" sets the
* color for future cells and rewrites + `recolor()`s the retained ones.
*/
export const setup: ImperativeSetup = (host, { width, height, backend, options }) => {
const world = loadWorld();
const projection = geoEquirectangular().fitSize([width, height], { type: "Sphere" });
const map = geoMap(host, { width, height, projection, backend });
map.layer("ocean", [world.sphere], { fill: OCEAN });
map.layer("land", [world.land], { fill: LAND, stroke: "#9aa3ad", lineWidth: 0.4 });
map.enableZoom([1, 40]);
const retained: StreamPolygon[] = [];
let currentColor = DEFAULT_RANGE_COLOR; // translucent; new ranges get this color
const cellOpts = {
fill: (f: StreamPolygon) => f.properties.color,
id: (f: StreamPolygon) => f.properties.id,
clipTo: "land", // only show ranges over land
pickable: false, // huge streamed layer — skip the hit index to save memory
};
let cells: LayerHandle<StreamPolygon> = map.layer("cells", [], cellOpts);
map.render();
const stats = createStatsOverlay(host);
let count = 0;
let appendMs = 0;
let seed = 1;
let total = DATA_SIZE_TOTALS[String(options.size)] ?? 1_000_000;
const ctrl = new StreamController<StreamPolygon>({
source: (o) => makeStreamingPolygons({ total, ...o }), // large ranges (default size)
onBatch: (batch) => {
for (const f of batch) {
f.properties.color = currentColor;
retained.push(f); // loop, not push(...spread): batch can be up to 1M
}
const t0 = performance.now();
cells.append(batch);
appendMs += performance.now() - t0;
count += batch.length;
stats.update(count, total, count / (appendMs / 1000), ctrl.batchSize);
},
onReset: () => {
retained.length = 0;
count = 0;
appendMs = 0;
cells = map.layer("cells", [], cellOpts);
stats.update(0, total, 0, ctrl.batchSize, true);
},
});
// "adaptive" auto-tunes the batch to a frame budget; a number fixes it.
const applyBatch = (opt: string): void => {
ctrl.adaptive = opt === "adaptive";
ctrl.batchSize = ctrl.adaptive ? ADAPTIVE_SEED_BATCH : Number(opt);
};
// Stream runs only when the user wants it AND the canvas is on-screen (the harness
// calls setVisible). A manual pause persists across scroll-out/in.
let visible = true;
let userRunning = options.stream === "run";
let lastBatchOpt = String(options.batch);
applyBatch(lastBatchOpt);
ctrl.delayMs = Number(options.rate);
ctrl.setRunning(userRunning && visible);
ctrl.restart(seed);
let lastRandomize = Number(options.randomize) || 0;
let lastRestart = Number(options.restart) || 0;
return {
engine: map,
render: (o) => {
userRunning = o.stream === "run";
ctrl.setRunning(userRunning && visible);
if (o.randomize !== lastRandomize) {
lastRandomize = Number(o.randomize) || 0;
currentColor = randomHsl(RANGE_ALPHA); // keep ranges translucent
for (const f of retained) f.properties.color = currentColor;
cells.recolor();
}
const batchOpt = String(o.batch);
const rate = Number(o.rate);
const newTotal = DATA_SIZE_TOTALS[String(o.size)] ?? total;
if (
batchOpt !== lastBatchOpt ||
rate !== ctrl.delayMs ||
newTotal !== total ||
o.restart !== lastRestart
) {
lastRestart = Number(o.restart) || 0;
lastBatchOpt = batchOpt;
applyBatch(batchOpt);
ctrl.delayMs = rate;
total = newTotal;
ctrl.restart(++seed);
}
},
setVisible: (v) => {
visible = v;
ctrl.setRunning(userRunning && visible); // pause offscreen; resume only if user wants run
},
dispose: () => {
ctrl.dispose();
stats.destroy();
},
};
};

The same point source, plotted with x = longitude, y = −latitude so the familiar world shape emerges — Plot.points().append() is the same incremental append as the map’s GeoMap.layer().append().

fps 0frame 0 ms
Stream
draw.ts
import { plot, type LayerHandle } from "@mapequation/d3gl/map";
import { makeStreamingPoints, DEFAULT_STREAM_COLOR, type StreamPoint } from "../shared/geo-data.js";
import { StreamController, randomHsl, DATA_SIZE_TOTALS, ADAPTIVE_SEED_BATCH } from "../shared/streaming.js";
import { createStatsOverlay } from "../shared/stats-overlay.js";
import type { ImperativeSetup } from "../types.js";
/**
* The same streaming point source as the world-map example, but plotted on a
* scatter chart: x = longitude, y = −latitude (so north is up). Reusing
* `makeStreamingPoints` shows `Plot.points().append()` is the same incremental
* append as the map's `GeoMap.layer().append()`. Points start red; "Randomize
* colors" recolors future + retained points.
*/
export const setup: ImperativeSetup = (host, { width, height, backend, options }) => {
const chart = plot(host, { width, height, backend });
// Fit lon∈[-180,180], lat∈[-90,90] into the canvas (x=lon, y=−lat, 0,0 centered).
const k = Math.min(width / 360, height / 180) * 0.92;
chart.setTransform({ k, x: width / 2, y: height / 2 });
chart.enableZoom([0.5, 40]);
const retained: StreamPoint[] = [];
let currentColor = DEFAULT_STREAM_COLOR;
const pointOpts = {
x: (f: StreamPoint) => f.geometry.coordinates[0], // longitude
y: (f: StreamPoint) => -f.geometry.coordinates[1], // −latitude (north up)
radius: 1,
fill: (f: StreamPoint) => f.properties.color,
id: (f: StreamPoint) => f.properties.id,
sizeMode: "screen" as const, // constant 1px dots regardless of zoom
pickable: false, // huge streamed layer — skip the hit index to save memory
};
let points: LayerHandle<StreamPoint> = chart.points("points", [], pointOpts);
chart.render();
const stats = createStatsOverlay(host);
let count = 0;
let appendMs = 0;
let seed = 1;
let total = DATA_SIZE_TOTALS[String(options.size)] ?? 1_000_000;
const ctrl = new StreamController<StreamPoint>({
source: (o) => makeStreamingPoints({ total, ...o }),
onBatch: (batch) => {
for (const f of batch) {
f.properties.color = currentColor;
retained.push(f); // loop, not push(...spread): batch can be up to 1M
}
const t0 = performance.now();
points.append(batch);
appendMs += performance.now() - t0;
count += batch.length;
stats.update(count, total, count / (appendMs / 1000), ctrl.batchSize);
},
onReset: () => {
retained.length = 0;
count = 0;
appendMs = 0;
points = chart.points("points", [], pointOpts);
stats.update(0, total, 0, ctrl.batchSize, true);
},
});
// "adaptive" auto-tunes the batch to a frame budget; a number fixes it.
const applyBatch = (opt: string): void => {
ctrl.adaptive = opt === "adaptive";
ctrl.batchSize = ctrl.adaptive ? ADAPTIVE_SEED_BATCH : Number(opt);
};
// Stream runs only when the user wants it AND the canvas is on-screen (the harness
// calls setVisible). A manual pause persists across scroll-out/in.
let visible = true;
let userRunning = options.stream === "run";
let lastBatchOpt = String(options.batch);
applyBatch(lastBatchOpt);
ctrl.delayMs = Number(options.rate);
ctrl.setRunning(userRunning && visible);
ctrl.restart(seed);
let lastRandomize = Number(options.randomize) || 0;
let lastRestart = Number(options.restart) || 0;
return {
engine: chart,
render: (o) => {
userRunning = o.stream === "run";
ctrl.setRunning(userRunning && visible);
if (o.randomize !== lastRandomize) {
lastRandomize = Number(o.randomize) || 0;
currentColor = randomHsl();
for (const f of retained) f.properties.color = currentColor;
points.recolor();
}
const batchOpt = String(o.batch);
const rate = Number(o.rate);
const newTotal = DATA_SIZE_TOTALS[String(o.size)] ?? total;
if (
batchOpt !== lastBatchOpt ||
rate !== ctrl.delayMs ||
newTotal !== total ||
o.restart !== lastRestart
) {
lastRestart = Number(o.restart) || 0;
lastBatchOpt = batchOpt;
applyBatch(batchOpt);
ctrl.delayMs = rate;
total = newTotal;
ctrl.restart(++seed);
}
},
setVisible: (v) => {
visible = v;
ctrl.setRunning(userRunning && visible); // pause offscreen; resume only if user wants run
},
dispose: () => {
ctrl.dispose();
stats.destroy();
},
};
};

The examples above retain every feature in d3gl so it can re-project, recolor, and hit-test — which caps out around 4–16M features (storage, not drawing, is the limit). Pass-through mode (passThrough: true) retains nothing in d3gl: you own the data, and d3gl pulls from it, draws, and discards on each repaint. The ceiling becomes whatever your own array costs — 250M+ for a packed Float32Array. Pass features (or points data) as a callback that returns your current array; d3gl re-invokes it on each full repaint, and handle.append(batch) draws new arrivals immediately.

let nodes = []; // you own the data
const layer = map.layer("nodes", () => nodes, { // callback, re-pulled per repaint
passThrough: true,
fill: (f) => f.properties.color,
});
// stream in:
nodes.push(...batch);
layer.append(batch); // draws the new batch on top, O(new)

It works for all GeoJSON geometry on both Canvas and WebGL (points → analytic circles; polygons/lines → projected paths, re-tessellated per settle on WebGL). The trade-off:

Retained (default)Pass-through
Ceiling~4–16Myour array (250M+)
Memory in d3gl~130–480 B/feature~0
During pan/zoomalways crispslightly stale raster, re-crisp on settle
Picking (pick/hover)yesno
Per-feature recolor / mutateyesre-pull from your data
Clip to another layer (clipTo)yesnot yet (ignored)
Backendsall (incl. SVG)Canvas + WebGL (SVG rejects it)

Use retained for datasets up to a few million where you need crisp interaction, hit-testing, or per-feature updates. Use pass-through for huge or live-streaming sets where a brief raster during gestures is fine and picking isn’t needed. The demo below streams to 10M points with a flat memory footprint (default auto backend → upgrades to WebGL).

fps 0frame 0 ms
Stream
draw.ts
import { geoEquirectangular } from "d3-geo";
import { geoMap, type LayerHandle } from "@mapequation/d3gl/map";
import {
loadWorld,
makeStreamingPoints,
DEFAULT_STREAM_COLOR,
type StreamPoint,
} from "../shared/geo-data.js";
import { StreamController, randomHsl, DATA_SIZE_TOTALS, ADAPTIVE_SEED_BATCH } from "../shared/streaming.js";
import { createStatsOverlay } from "../shared/stats-overlay.js";
import type { ImperativeSetup } from "../types.js";
const OCEAN = "#dbe7f3";
const LAND = "#e9e7df";
/**
* Stream points (clustered around cities + rivers) using the pass-through path
* (`passThrough: true`). The engine re-reads `() => retained` each repaint so the
* full point set re-projects on pan/zoom — flat GPU memory, no retained ceiling.
* New batches are also drawn immediately via `points.append(batch)`.
*
* Contrast with the `streaming-points` example (retained path): that example caps
* at ~4–16M and builds a hit index; this one is uncapped, not pickable, and shows
* a slightly stale raster during pan/zoom (re-crisps on settle).
*/
export const setup: ImperativeSetup = (host, { width, height, backend, options }) => {
const world = loadWorld();
const projection = geoEquirectangular().fitSize([width, height], { type: "Sphere" });
const map = geoMap(host, { width, height, projection, backend });
map.layer("ocean", [world.sphere], { fill: OCEAN });
map.layer("land", [world.land], { fill: LAND, stroke: "#9aa3ad", lineWidth: 0.4 });
map.enableZoom([1, 40]);
const retained: StreamPoint[] = [];
let currentColor = DEFAULT_STREAM_COLOR; // the color new points get (until randomized)
const pointOpts = {
fill: (f: StreamPoint) => f.properties.color,
pointRadius: 0.5,
// pass-through layers are not pickable (no hit index); no id/clipTo needed
};
// passThrough: true — engine re-invokes `() => retained` each repaint (pan/zoom)
let points: LayerHandle<StreamPoint> = map.layer("points", () => retained, { passThrough: true, ...pointOpts });
map.render();
const stats = createStatsOverlay(host);
let count = 0; // records appended this session
let appendMs = 0; // total time spent in append() — gives the average speed
let seed = 1;
let total = DATA_SIZE_TOTALS[String(options.size)] ?? 10_000_000;
const ctrl = new StreamController<StreamPoint>({
source: (o) => makeStreamingPoints({ total, ...o }),
onBatch: (batch) => {
for (const f of batch) {
f.properties.color = currentColor; // new points get the current color
retained.push(f); // retain for repaint via the callback — loop (not spread) for large batches
}
const t0 = performance.now();
points.append(batch); // incremental draw — only the new batch projects immediately
appendMs += performance.now() - t0;
count += batch.length;
stats.update(count, total, count / (appendMs / 1000), ctrl.batchSize);
},
onReset: () => {
retained.length = 0;
count = 0;
appendMs = 0;
// Re-register the layer (clears the pass-through buffer); callback still points at retained
points = map.layer("points", () => retained, { passThrough: true, ...pointOpts });
stats.update(0, total, 0, ctrl.batchSize, true);
},
});
// "adaptive" auto-tunes the batch to a frame budget; a number fixes it.
const applyBatch = (opt: string): void => {
ctrl.adaptive = opt === "adaptive";
ctrl.batchSize = ctrl.adaptive ? ADAPTIVE_SEED_BATCH : Number(opt);
};
// Stream runs only when the user wants it AND the canvas is on-screen (the harness
// calls setVisible). A manual pause persists across scroll-out/in.
let visible = true;
let userRunning = options.stream === "run";
let lastBatchOpt = String(options.batch);
applyBatch(lastBatchOpt);
ctrl.delayMs = Number(options.rate);
ctrl.setRunning(userRunning && visible);
ctrl.restart(seed);
let lastRandomize = Number(options.randomize) || 0;
let lastRestart = Number(options.restart) || 0;
return {
engine: map,
render: (o) => {
userRunning = o.stream === "run";
ctrl.setRunning(userRunning && visible);
if (o.randomize !== lastRandomize) {
lastRandomize = Number(o.randomize) || 0;
currentColor = randomHsl(); // future points...
for (const f of retained) f.properties.color = currentColor; // ...and already-stored ones
points.recolor(); // re-render from the retained, updated properties
}
const batchOpt = String(o.batch);
const rate = Number(o.rate);
const newTotal = DATA_SIZE_TOTALS[String(o.size)] ?? total;
if (
batchOpt !== lastBatchOpt ||
rate !== ctrl.delayMs ||
newTotal !== total ||
o.restart !== lastRestart
) {
lastRestart = Number(o.restart) || 0;
lastBatchOpt = batchOpt;
applyBatch(batchOpt);
ctrl.delayMs = rate;
total = newTotal;
ctrl.restart(++seed);
}
},
setVisible: (v) => {
visible = v;
ctrl.setRunning(userRunning && visible); // pause offscreen; resume only if user wants run
},
dispose: () => {
ctrl.dispose();
stats.destroy();
},
};
};

Pass-through works for polygons and lines too, not just points. On WebGL the full set re-tessellates on every pan/zoom settle (the documented cost of pass-through for path geometry); on Canvas it re-projects paths, same as retained. The demo below streams 1M translucent polygon ranges via the pass-through path — raise “Data size” to push the ceiling further. Unlike the retained polygons example above, these ranges are not clipped to landclipTo isn’t applied to pass-through layers yet (see the trade-off table), so ranges appear over the ocean too.

fps 0frame 0 ms
Stream
draw.ts
import { geoEquirectangular } from "d3-geo";
import { geoMap, type LayerHandle } from "@mapequation/d3gl/map";
import { loadWorld, makeStreamingPolygons, type StreamPolygon } from "../shared/geo-data.js";
import { StreamController, randomHsl, DATA_SIZE_TOTALS, ADAPTIVE_SEED_BATCH } from "../shared/streaming.js";
import { createStatsOverlay } from "../shared/stats-overlay.js";
import type { ImperativeSetup } from "../types.js";
const OCEAN = "#dbe7f3";
const LAND = "#e9e7df";
const RANGE_ALPHA = 0.05; // very transparent so overlapping ranges build up a density gradient
/**
* Stream polygon "ranges" using the pass-through path (`passThrough: true`).
* The engine re-reads `() => retained` on each repaint — so pan/zoom re-projects
* (and on WebGL re-tessellates) the full set, with no ceiling inside d3gl.
*
* Contrast with `streaming-polygons` (retained path): that example supports
* picking and per-feature recolor; this one is uncapped and not pickable.
* New batches draw immediately via `cells.append(batch)`; the callback
* covers full repaints (pan/zoom settle).
*
* WebGL re-tessellates the full set on every pan/zoom settle — the documented
* cost of pass-through for polygon/line geometry. Use a modest data-size default
* so the demo stays responsive; raise it if you want to test limits.
*/
export const setup: ImperativeSetup = (host, { width, height, backend, options }) => {
const world = loadWorld();
const projection = geoEquirectangular().fitSize([width, height], { type: "Sphere" });
const map = geoMap(host, { width, height, projection, backend });
map.layer("ocean", [world.sphere], { fill: OCEAN });
map.layer("land", [world.land], { fill: LAND, stroke: "#9aa3ad", lineWidth: 0.4 });
map.enableZoom([1, 40]);
const retained: StreamPolygon[] = [];
let currentColor = `hsla(8, 80%, 53%, ${RANGE_ALPHA})`; // translucent red; new ranges get this
const cellOpts = {
fill: (f: StreamPolygon) => f.properties.color,
// pass-through layers are not pickable (no hit index); no id/clipTo needed
};
// passThrough: true — engine re-invokes `() => retained` each repaint (pan/zoom)
let cells: LayerHandle<StreamPolygon> = map.layer("cells", () => retained, {
passThrough: true,
...cellOpts,
});
map.render();
const stats = createStatsOverlay(host);
let count = 0;
let appendMs = 0;
let seed = 1;
let total = DATA_SIZE_TOTALS[String(options.size)] ?? 100_000;
const ctrl = new StreamController<StreamPolygon>({
source: (o) => makeStreamingPolygons({ total, ...o }),
onBatch: (batch) => {
for (const f of batch) {
f.properties.color = currentColor; // new polygons get the current color
retained.push(f); // retain for repaint via the callback — loop (not spread) for large batches
}
const t0 = performance.now();
cells.append(batch); // incremental draw — only the new batch projects/tessellates immediately
appendMs += performance.now() - t0;
count += batch.length;
stats.update(count, total, count / (appendMs / 1000), ctrl.batchSize);
},
onReset: () => {
retained.length = 0;
count = 0;
appendMs = 0;
// Re-register the layer (clears the pass-through buffer); callback still points at retained
cells = map.layer("cells", () => retained, { passThrough: true, ...cellOpts });
stats.update(0, total, 0, ctrl.batchSize, true);
},
});
// "adaptive" auto-tunes the batch to a frame budget; a number fixes it.
const applyBatch = (opt: string): void => {
ctrl.adaptive = opt === "adaptive";
ctrl.batchSize = ctrl.adaptive ? ADAPTIVE_SEED_BATCH : Number(opt);
};
// Stream runs only when the user wants it AND the canvas is on-screen (the harness
// calls setVisible). A manual pause persists across scroll-out/in.
let visible = true;
let userRunning = options.stream === "run";
let lastBatchOpt = String(options.batch);
applyBatch(lastBatchOpt);
ctrl.delayMs = Number(options.rate);
ctrl.setRunning(userRunning && visible);
ctrl.restart(seed);
let lastRandomize = Number(options.randomize) || 0;
let lastRestart = Number(options.restart) || 0;
return {
engine: map,
render: (o) => {
userRunning = o.stream === "run";
ctrl.setRunning(userRunning && visible);
if (o.randomize !== lastRandomize) {
lastRandomize = Number(o.randomize) || 0;
currentColor = randomHsl(RANGE_ALPHA); // keep ranges translucent
for (const f of retained) f.properties.color = currentColor;
cells.recolor(); // re-render from the retained, updated properties
}
const batchOpt = String(o.batch);
const rate = Number(o.rate);
const newTotal = DATA_SIZE_TOTALS[String(o.size)] ?? total;
if (
batchOpt !== lastBatchOpt ||
rate !== ctrl.delayMs ||
newTotal !== total ||
o.restart !== lastRestart
) {
lastRestart = Number(o.restart) || 0;
lastBatchOpt = batchOpt;
applyBatch(batchOpt);
ctrl.delayMs = rate;
total = newTotal;
ctrl.restart(++seed);
}
},
setVisible: (v) => {
visible = v;
ctrl.setRunning(userRunning && visible); // pause offscreen; resume only if user wants run
},
dispose: () => {
ctrl.dispose();
stats.destroy();
},
};
};