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:
parent
c6f7b47446
commit
81ff9d9e41
10 changed files with 708 additions and 2541 deletions
229
frontend/src/workers/hexgrid.worker.ts
Normal file
229
frontend/src/workers/hexgrid.worker.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue