Move hexgrid heatmap computation to Web Worker

R-tree building, hex grid generation, and percentile sorting now run
off the main thread, eliminating 20s+ UI freezes on large datasets.
The old bundled HexgridHeatmap.js is replaced by a typed worker +
main-thread client with dual R-trees (worker for grid gen, main
thread for synchronous click queries).
This commit is contained in:
Viktor Barzin 2026-02-22 15:04:37 +00:00
parent c6f7b47446
commit 81ff9d9e41
No known key found for this signature in database
GPG key ID: 0EB088298288D958
10 changed files with 708 additions and 2541 deletions

View file

@ -0,0 +1,229 @@
/// <reference lib="webworker" />
import RBush from 'rbush';
import type {
FeatureItem,
WorkerRequest,
GenerateGridMessage,
ComputeColorScaleMessage,
ComputeBoundsMessage,
} from './types';
import { toFeatureItem } from './types';
const tree = new RBush<FeatureItem>();
let features: FeatureItem[] = [];
// Track latest requestId per message type to discard stale results
const latestRequestId: Record<string, number> = {};
function isStale(type: string, requestId: number): boolean {
return (latestRequestId[type] ?? 0) > requestId;
}
// Average non-NaN values (same reduce function as original HexgridHeatmap)
function reduceAverage(data: number[]): number {
let sum = 0;
let count = 0;
for (let i = 0; i < data.length; i++) {
if (!isNaN(data[i])) {
sum += data[i];
count++;
}
}
return count > 0 ? sum / count : NaN;
}
function generateGrid(msg: GenerateGridMessage): void {
const { requestId, zoom, bounds, config } = msg;
const { cellDensity, spread, propertyName } = config;
const quantizedZoom = Math.floor(zoom);
const cellSize = Math.max(500 / Math.pow(2, quantizedZoom) / cellDensity, 0.01);
const extents = bounds; // [minLng, minLat, maxLng, maxLat]
// Convert cell size from km to degrees
const centerLat = (extents[1] + extents[3]) / 2;
const kmPerDegLon = 111.32 * Math.cos(centerLat * Math.PI / 180);
const kmPerDegLat = 110.574;
const rx = cellSize / kmPerDegLon;
const ry = cellSize / kmPerDegLat * Math.sqrt(3) / 2;
// Flat-top hex grid spacing
const xStep = rx * 1.5;
const yStep = ry * 2;
// Compute grid indices from fixed origin (0,0)
const xStart = Math.floor(extents[0] / xStep) - 1;
const xEnd = Math.ceil(extents[2] / xStep) + 1;
const yStart = Math.floor(extents[1] / yStep) - 1;
const yEnd = Math.ceil(extents[3] / yStep) + 1;
// Scale search radius with cell size
const searchDegLon = Math.max(cellSize * 0.75, spread * 4) / kmPerDegLon;
const searchDegLat = Math.max(cellSize * 0.75, spread * 4) / kmPerDegLat;
const cellsToSave = [];
for (let xi = xStart; xi <= xEnd; xi++) {
for (let yi = yStart; yi <= yEnd; yi++) {
const cx = xi * xStep;
let cy = yi * yStep;
// Odd columns offset by half a row
if (xi % 2 !== 0) {
cy -= yStep / 2;
}
const pois = tree.search({
minX: cx - searchDegLon,
minY: cy - searchDegLat,
maxX: cx + searchDegLon,
maxY: cy + searchDegLat,
});
if (pois.length > 0) {
const values = pois.map(d => d.properties[propertyName] as number);
const strength = reduceAverage(values);
if (!isNaN(strength)) {
const coords = [
[cx + rx, cy],
[cx + rx / 2, cy + ry],
[cx - rx / 2, cy + ry],
[cx - rx, cy],
[cx - rx / 2, cy - ry],
[cx + rx / 2, cy - ry],
[cx + rx, cy],
];
cellsToSave.push({
type: 'Feature' as const,
geometry: { type: 'Polygon' as const, coordinates: [coords] },
properties: {
count: strength,
searchMinX: cx - searchDegLon,
searchMinY: cy - searchDegLat,
searchMaxX: cx + searchDegLon,
searchMaxY: cy + searchDegLat,
},
});
}
}
}
}
if (isStale('GENERATE_GRID', requestId)) return;
self.postMessage({
type: 'GRID_RESULT',
requestId,
hexgrid: { type: 'FeatureCollection', features: cellsToSave },
});
}
function computeColorScale(msg: ComputeColorScaleMessage): void {
const { requestId, metricMode, percentileConfig } = msg;
const values: number[] = [];
for (let i = 0; i < features.length; i++) {
const v = features[i].properties[metricMode];
if (typeof v === 'number' && isFinite(v) && v > 0) {
values.push(v);
}
}
values.sort((a, b) => a - b);
if (isStale('COMPUTE_COLOR_SCALE', requestId)) return;
if (values.length > 0) {
const minIndex = Math.min(Math.round(values.length * percentileConfig.minBound), values.length - 1);
const maxIndex = Math.min(Math.round(values.length * percentileConfig.maxBound), values.length - 1);
const min = values[minIndex];
const max = Math.max(values[maxIndex], min + 1);
self.postMessage({
type: 'COLOR_SCALE_RESULT',
requestId,
min,
max,
hasValues: true,
});
} else {
self.postMessage({
type: 'COLOR_SCALE_RESULT',
requestId,
min: 0,
max: 1,
hasValues: false,
});
}
}
function computeBounds(msg: ComputeBoundsMessage): void {
const { requestId, boundsPercentileConfig } = msg;
const lngs: number[] = [];
const lats: number[] = [];
for (let i = 0; i < features.length; i++) {
lngs.push(features[i].geometry.coordinates[0]);
lats.push(features[i].geometry.coordinates[1]);
}
lngs.sort((a, b) => a - b);
lats.sort((a, b) => a - b);
if (isStale('COMPUTE_BOUNDS', requestId)) return;
const { clipMin, clipMax } = boundsPercentileConfig;
self.postMessage({
type: 'BOUNDS_RESULT',
requestId,
minLng: percentile(lngs, clipMin),
maxLng: percentile(lngs, clipMax),
minLat: percentile(lats, clipMin),
maxLat: percentile(lats, clipMax),
});
}
function percentile(arr: number[], p: number): number {
if (arr.length === 0) return 0;
if (p <= 0) return arr[0];
if (p >= 1) return arr[arr.length - 1];
const index = arr.length * p;
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= arr.length) return arr[lower];
return arr[lower] * (1 - weight) + arr[upper] * weight;
}
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
const msg = e.data;
latestRequestId[msg.type] = msg.requestId;
switch (msg.type) {
case 'SET_DATA': {
features = msg.features.map(toFeatureItem);
tree.clear();
tree.load(features);
self.postMessage({
type: 'DATA_READY',
requestId: msg.requestId,
featureCount: features.length,
});
break;
}
case 'GENERATE_GRID':
generateGrid(msg);
break;
case 'COMPUTE_COLOR_SCALE':
computeColorScale(msg);
break;
case 'COMPUTE_BOUNDS':
computeBounds(msg);
break;
}
};