230 lines
6.2 KiB
TypeScript
230 lines
6.2 KiB
TypeScript
|
|
/// <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;
|
||
|
|
}
|
||
|
|
};
|