BundledFlowmapLayer
The BundledFlowmapLayer is FlowmapLayer with GPU kernel-density edge bundling. Instead of drawing one straight tapered arrow per origin→destination pair, it relaxes geometrically-close flows into smooth bundled rivers — so a dense overview reads as flowing channels instead of a hairball of crossing arrows. The bundling runs entirely on the GPU, in the same ping-pong-float-texture style cosmos.gl uses for its force graph, and the bundled geometry never leaves the GPU.
It's a drop-in superset of FlowmapLayer: it consumes the same OD vertexValueMatrix tiles (stt-generate bixi), honors all the same flow* styling props (including the flowProperty static fallback for archives with no bucket matrix), and keeps the same node-circle overlay. On a device that can't additively blend into a float texture, or when the merged edge count exceeds maxBundledEdges or the device's maxTextureDimension2D, it transparently falls back to FlowmapLayer's straight arrows — never a throw, because this all runs inside deck's synchronous render callback.
One bundle for the whole visible set, not one per tile. Bundling each tile
independently produces bundles that break at tile boundaries (each tile relaxes
on its own); bundling the union keeps the rivers continuous the way flowmap.gl —
which does not tile — does. For this to be correct the OD tiles must be built
with --no-clip, so each corridor keeps its true station endpoints instead
of being cut at tile edges.
How it works#
The bundler implements KDEEB (Kernel-Density Edge Bundling — Hurter, Ersoy & Telea 2012) with a CUBu-style GPU pipeline (van der Zwan & Telea 2016) — the method behind the smooth, river-like bundles in the classic edge-bundling figures. It relaxes each edge's control points toward a shared kernel-density field, so geometrically-close flows converge into smooth bundled rivers entirely on the GPU.
Each edge is resampled to subdivisionPoints control points, then 15 annealed iterations run on the GPU, one per frame so the rivers visibly settle:
- Splat — additively rasterize an Epanechnikov kernel of bandwidth
hat every control point into a density texture (a kernel-density estimate of where edges are). - Advect — move each interior control point a step along the normalized density gradient (toward where neighbouring edges already are — this is mean-shift).
- Resample — redistribute each edge's points to uniform spacing (advection bunches them).
- Smooth — one 1D Laplacian pass along each edge. This is what makes the bundles smooth — advection alone is jagged.
- Anneal — shrink the kernel bandwidth and repeat, progressively tightening the bundles.
The bundle is a stable spatial skeleton: computed from the fixed edge set of the whole visible tile set (not weighted by the playhead, so the rivers don't writhe as you scrub) and kept resident on the GPU. As the time slider moves, only each ribbon's width animates — sampled on the GPU from a merged vertexValueMatrix texture at the live playhead, so the edges need zero per-frame CPU work. Direction reads from a source→target color gradient along each river. Node circles keep FlowmapLayer's cheap CPU aggregation.
When the bundle is rebuilt (and why it is debounced)#
The bundle is rebuilt only when the visible tile set changes (pan / zoom) — it is stable during playback, where the flowmap's tiles span the whole time range and never re-fetch. The first bundle for a view is built inline, so the opening frame has rivers; every later set change is debounced ~150 ms off the render path, so a pan that streams tiles in one at a time doesn't restart the relaxation on each arrival. The previous, converged bundle keeps drawing until the view settles. (Only a change of signature re-arms the timer — a re-render at the same pending signature must not postpone it, or a playhead tick every ~100 ms would defer the rebuild forever.)
Because the control points are seeded from each feature's full polyline, a 2-vertex OD pair bundles as a straight edge while an N-vertex routed trip / trajectory keeps its curve.
The kernel/smoothing/resampling math (epanechnikovWeight, laplacianStep, resampleInto, subdivide, annealRadius, plus the reference CPU bundleEdges) is defined once in @poopdeck.gl/core/edge-bundling and shared by all four renderer backends; the GLSL kernel here mirrors it and is pinned to it on the CPU by edge-bundler.test.ts. EdgeBundler re-exports those helpers, so the @poopdeck.gl/layers surface is unchanged.
Installation#
import { BundledFlowmapLayer } from '@poopdeck.gl/layers';
Usage#
const layer = new BundledFlowmapLayer({id: 'bixi-flowmap-bundled',data: '/data/bixi-flowmap/manifest.json',currentTime, // driven live from the TimeControllertimeController,// FlowmapLayer styling (identical):widthScale: 1.1,widthMaxPixels: 14,sourceColor: [56, 196, 232, 235],targetColor: [255, 142, 64, 245],gap: 0.5,nodeRadiusScale: 1.3,minFlow: 0.5,// KDEEB bundling tuning:subdivisionPoints: 48, // control points per edge (smoother curves)kernelRadius: 0.05, // kernel bandwidth as a fraction of the tilebundlingIterations: 15, // density-advection iterationssmoothingStrength: 0.5, // per-iteration Laplacian smoothing});
Properties#
Inherits everything from FlowmapLayer (and therefore SpatioTemporalLayer) — all widthScale / sourceColor / gap / nodeRadius* / minFlow props apply unchanged.
Bundling (KDEEB)#
| Prop | Type | Default | Description |
|---|---|---|---|
subdivisionPoints | number | 48 | Control points per edge (P). Higher = smoother, more sharply-defined rivers; more GPU work. |
kernelRadius | number | 0.05 | Initial kernel bandwidth as a fraction of the tile's extent — the headline knob. Larger bundles flows together more aggressively (the CUBu literature default is 5%). |
bundlingIterations | number | 15 | Number of density-advection iterations. More = tighter bundles (10–15 converges). |
smoothingStrength | number | 0.5 | Per-iteration Laplacian smoothing strength in [0,1]. Higher = smoother (but over-smoothing washes out structure). |
maxBundledEdges | number | 4000 | Above this many merged edges, skip bundling and render straight arrows (keeps the per-frame density splat bounded). See the two ceilings below. |
Two independent edge ceilings#
Both are checked before anything is allocated (the merged control-point buffer
alone is E · P · dims f64 — ~23 MB at 30k edges):
maxBundledEdgesbounds the per-frame density splat. It does not apply on thepreBundledpath, which has no splat.- the device's
maxTextureDimension2Dis a hard limit — both bundle textures areedgeCountrows tall — and applies to every path, baked included. Exceeding it would makecreateTexturethrow from insiderenderLayers, taking down the whole layer tree, so it can never be opt-out.
Crossing either cap warns once (naming which one bound) and renders straight
arrows. When the hardware ceiling is the binding one, raising maxBundledEdges
will not help — reduce the visible edge count instead (zoom in, or build a
coarser summary tier).
Baked bundling (preBundled)#
By default the bundling runs live on the GPU every time the visible tile set
changes. Alternatively you can bake the bundling into the tiles at build time
and have this layer render the precomputed geometry — set preBundled: true and
point it at tiles built with stt-generate bixi --bake-bundling:
const layer = new BundledFlowmapLayer({id: 'bixi-flowmap-baked',data: '/data/bixi-flowmap-baked/manifest.json',preBundled: true,subdivisionPoints: 24, // MUST match the build's --bundle-points// …all the same flow* styling props});
In this mode the build relaxes each zoom's clustered hub-pair corridors with a
deterministic CPU KDEEB pass (one global density field per zoom — never
per-tile, which would seam) and stores the rivers as ordinary multi-vertex
polylines. The layer skips the GPU bundler entirely: it uploads the baked control
points once (StaticBundle) and renders them. kernelRadius,
bundlingIterations and smoothingStrength are ignored, and so is
maxBundledEdges (a baked bundle has no splat cost) — but the device
texture ceiling still applies: a baked bundle has exactly the same
pointCount × edgeCount texture footprint.
| Live (default) | Baked (preBundled) | |
|---|---|---|
| Bundling cost | per-frame relaxation (~15 frames to settle) | none — final on load |
| Device support | needs EXT_float_blend (else straight-arrow fallback) | needs only float-texture sampling (isStaticBundleSupported) — works on more mobile GPUs |
| Stability | re-bundles when the visible tile set changes | fixed at build time — stable under pan/zoom |
| Tuning | interactive (kernelRadius etc.) | fixed at build (--bundle-* flags) |
| Reproducible | n/a | yes (uniform step, pinned density resolution) |
| Wire size | small (2-vertex OD tiles) | larger (multi-vertex polylines) |
subdivisionPoints must equal the build's --bundle-points so each baked vertex
is sampled exactly. When the device can't sample a float texture the layer
degrades to straight endpoint-to-endpoint arrows (the baked curve collapses to its
origin/destination), so the demo still renders.
When to use it vs FlowmapLayer#
Reach for BundledFlowmapLayer at overview zooms with many crossing corridors, where straight arrows pile into visual clutter — bundling reveals the dominant flow structure. At deep zooms (few corridors per tile) the straight-arrow FlowmapLayer is clearer and cheaper; this layer falls back to exactly that above either edge cap.
Picking and extensions#
- Picking is disabled on the merged bundle — one river is many corridors, so a hit has no single feature to report. The node overlay carries the dataset's hover affordance.
- A forwarded
extensionslist is stripped, with a one-time warning naming the extensions dropped. Both the arrow and the ribbon sublayers use custom shaders with noDECKGL_FILTER_*hooks, so extension injections never run — see deck.gl extensions on STT layers.
Device support#
The density splat additively blends into a float texture, which needs the WebGL2 EXT_color_buffer_float + EXT_float_blend capabilities (luma.gl features float32-renderable-webgl + texture-blend-float-webgl). Universal on desktop WebGL2 but absent on some mobile GPUs; there the layer degrades gracefully to straight arrows. The capability gate is exported as isBundlingSupported(device), and the bundling engine itself as EdgeBundler for callers who want to bundle their own OD edges directly.