/// import RBush from 'rbush'; import type { FeatureItem, WorkerRequest, GenerateGridMessage, ComputeColorScaleMessage, ComputeBoundsMessage, } from './types'; import { toFeatureItem } from './types'; const tree = new RBush(); let features: FeatureItem[] = []; // Track latest requestId per message type to discard stale results const latestRequestId: Record = {}; 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) => { 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; } };