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
|
|
@ -8,25 +8,11 @@ import "../assets/Map.css";
|
|||
import { Metric, type ParameterValues } from "./FilterPanel";
|
||||
import { PropertyCard } from "./PropertyCard";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types";
|
||||
import type { GeoJSONFeatureCollection, PropertyProperties, POI } from "@/types";
|
||||
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
||||
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
||||
import { percentile, calculateColorStops } from "@/utils/mapUtils";
|
||||
|
||||
// Type declaration for the external HexgridHeatmap library
|
||||
declare class HexgridHeatmap {
|
||||
_tree: {
|
||||
search: (bounds: { minX: number; maxX: number; minY: number; maxY: number }) => PropertyWithCoords[];
|
||||
};
|
||||
constructor(map: mapboxgl.Map, id: string, beforeLayer: string);
|
||||
setIntensity(value: number): void;
|
||||
setSpread(value: number): void;
|
||||
setCellDensity(value: number): void;
|
||||
setPropertyName(name: string): void;
|
||||
setData(data: GeoJSONFeatureCollection): void;
|
||||
setColorStops(stops: [number, string][]): void;
|
||||
update(): void;
|
||||
}
|
||||
import { calculateColorStops } from "@/utils/mapUtils";
|
||||
import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient";
|
||||
|
||||
interface PropertyWithCoords {
|
||||
properties: PropertyProperties;
|
||||
|
|
@ -48,7 +34,7 @@ export function Map(props: MapProps) {
|
|||
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const heatmapRef = useRef<HexgridHeatmap | null>(null);
|
||||
const heatmapRef = useRef<HexgridHeatmapClient | null>(null);
|
||||
const updateTimeoutRef = useRef<number | null>(null);
|
||||
const isMapLoadedRef = useRef<boolean>(false);
|
||||
const lastDataLengthRef = useRef<number>(0);
|
||||
|
|
@ -77,12 +63,12 @@ export function Map(props: MapProps) {
|
|||
: 0;
|
||||
}, [data]);
|
||||
|
||||
const updateHeatmap = useCallback(() => {
|
||||
const updateHeatmap = useCallback(async () => {
|
||||
if (!mapRef.current || !isMapLoadedRef.current) return;
|
||||
|
||||
// Create heatmap if it doesn't exist
|
||||
if (!heatmapRef.current) {
|
||||
heatmapRef.current = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label");
|
||||
heatmapRef.current = new HexgridHeatmapClient(mapRef.current, "hexgrid-heatmap", "waterway-label");
|
||||
heatmapRef.current.setIntensity(HEATMAP_CONFIG.INTENSITY);
|
||||
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
|
||||
heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY);
|
||||
|
|
@ -94,23 +80,15 @@ export function Map(props: MapProps) {
|
|||
// Pass all features to the heatmap — filtering is done server-side
|
||||
heatmap.setData(data);
|
||||
|
||||
// Compute color scale from valid metric values only
|
||||
const values = data.features
|
||||
.map(function (d: PropertyFeature) {
|
||||
return (d.properties as unknown as Record<string, unknown>)[metricMode] as number;
|
||||
})
|
||||
.filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; })
|
||||
.sort(function (a: number, b: number) { return a - b; });
|
||||
// Compute color scale in worker (sorts + percentiles off main thread)
|
||||
const colorResult = await heatmap.computeColorScale(metricMode, {
|
||||
minBound: PERCENTILE_CONFIG.MIN_BOUND,
|
||||
maxBound: PERCENTILE_CONFIG.MAX_BOUND,
|
||||
});
|
||||
|
||||
if (values.length > 0) {
|
||||
const minIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND), values.length - 1);
|
||||
const maxIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND), values.length - 1);
|
||||
const min = values[minIndex];
|
||||
// Ensure max > min so color stops are strictly monotonic
|
||||
const max = Math.max(values[maxIndex], min + 1);
|
||||
|
||||
makeLegend(colorScheme, min, max);
|
||||
const colorStopsValue = calculateColorStops(colorScheme, min, max);
|
||||
if (colorResult.hasValues) {
|
||||
makeLegend(colorScheme, colorResult.min, colorResult.max);
|
||||
const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max);
|
||||
heatmap.setColorStops(colorStopsValue);
|
||||
} else {
|
||||
// Set safe default stops so stale stops from a previous metric don't cause
|
||||
|
|
@ -123,16 +101,14 @@ export function Map(props: MapProps) {
|
|||
|
||||
// Fit bounds only on first load or significant data change
|
||||
if (lastDataLengthRef.current === 0 && data.features.length > 0) {
|
||||
const longitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; });
|
||||
const latitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; });
|
||||
const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
||||
const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
||||
const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
||||
const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
|
||||
const boundsResult = await heatmap.computeBounds({
|
||||
clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN,
|
||||
clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX,
|
||||
});
|
||||
|
||||
mapRef.current?.fitBounds([
|
||||
[minlng, minlat],
|
||||
[maxlng, maxlat]
|
||||
[boundsResult.minLng, boundsResult.minLat],
|
||||
[boundsResult.maxLng, boundsResult.maxLat]
|
||||
], { duration: 0 });
|
||||
}
|
||||
|
||||
|
|
@ -194,8 +170,11 @@ export function Map(props: MapProps) {
|
|||
if (updateTimeoutRef.current) {
|
||||
clearTimeout(updateTimeoutRef.current);
|
||||
}
|
||||
// Remove heatmap layers and sources before destroying the map
|
||||
if (heatmapRef.current && mapRef.current) {
|
||||
// Destroy worker and remove heatmap layers/sources before destroying the map
|
||||
if (heatmapRef.current) {
|
||||
heatmapRef.current.destroy();
|
||||
}
|
||||
if (mapRef.current) {
|
||||
for (const layerId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) {
|
||||
if (mapRef.current.getLayer(layerId)) {
|
||||
mapRef.current.removeLayer(layerId);
|
||||
|
|
@ -370,7 +349,7 @@ export function Map(props: MapProps) {
|
|||
function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) {
|
||||
if (!heatmapRef.current || !mapRef.current) return;
|
||||
|
||||
const properties = heatmapRef.current._tree.search(searchBounds);
|
||||
const properties = heatmapRef.current.searchTree(searchBounds) as unknown as PropertyWithCoords[];
|
||||
if (properties.length > 0) {
|
||||
const container = document.createElement('div');
|
||||
const root = createRoot(container);
|
||||
|
|
|
|||
317
frontend/src/workers/HexgridHeatmapClient.ts
Normal file
317
frontend/src/workers/HexgridHeatmapClient.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import RBush from 'rbush';
|
||||
import type {
|
||||
FeatureItem,
|
||||
WorkerResponse,
|
||||
ColorScaleResultMessage,
|
||||
BoundsResultMessage,
|
||||
GridResultMessage,
|
||||
} from './types';
|
||||
import { toFeatureItem } from './types';
|
||||
|
||||
interface HexgridConfig {
|
||||
intensity: number;
|
||||
spread: number;
|
||||
cellDensity: number;
|
||||
propertyName: string;
|
||||
}
|
||||
|
||||
interface PendingPromise<T> {
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
export class HexgridHeatmapClient {
|
||||
private map: mapboxgl.Map;
|
||||
private worker: Worker;
|
||||
private mainTree = new RBush<FeatureItem>();
|
||||
private requestCounter = 0;
|
||||
|
||||
// Layer swap state (matches original HexgridHeatmap)
|
||||
private layerA: string;
|
||||
private layerB: string;
|
||||
private sourceA: mapboxgl.GeoJSONSource | null = null;
|
||||
private sourceB: mapboxgl.GeoJSONSource | null = null;
|
||||
private activeIsA = true;
|
||||
private lastZoom: number | null = null;
|
||||
private clearTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Grid calculation coalescing
|
||||
private calculatingGrid = false;
|
||||
private recalcWhenReady = false;
|
||||
|
||||
// Pending grid result tracking
|
||||
private pendingGridRequestId: number | null = null;
|
||||
|
||||
// Pending promises for async operations
|
||||
private pendingColorScale = new Map<number, PendingPromise<ColorScaleResultMessage>>();
|
||||
private pendingBounds = new Map<number, PendingPromise<BoundsResultMessage>>();
|
||||
|
||||
private config: HexgridConfig = {
|
||||
intensity: 8,
|
||||
spread: 0.1,
|
||||
cellDensity: 1,
|
||||
propertyName: 'count',
|
||||
};
|
||||
|
||||
private moveEndHandler: () => void;
|
||||
|
||||
constructor(map: mapboxgl.Map, layername: string, addBefore?: string) {
|
||||
this.map = map;
|
||||
this.layerA = layername;
|
||||
this.layerB = layername + '-back';
|
||||
|
||||
this.setupLayers(layername, addBefore);
|
||||
|
||||
// Create worker
|
||||
this.worker = new Worker(
|
||||
new URL('./hexgrid.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
this.worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
||||
this.handleWorkerMessage(e.data);
|
||||
};
|
||||
|
||||
// Bind moveend
|
||||
this.moveEndHandler = () => this.updateGrid();
|
||||
this.map.on('moveend', this.moveEndHandler);
|
||||
}
|
||||
|
||||
private setupLayers(layername: string, addBefore?: string): void {
|
||||
const defaultPaint: mapboxgl.FillPaint = {
|
||||
'fill-opacity': 0,
|
||||
'fill-opacity-transition': { duration: 200, delay: 0 },
|
||||
'fill-color': {
|
||||
property: 'count',
|
||||
stops: [
|
||||
[0, 'rgba(0,185,243,0)'],
|
||||
[50, 'rgba(0,185,243,0.24)'],
|
||||
[130, 'rgba(255,223,0,0.3)'],
|
||||
[200, 'rgba(255,105,0,0.3)'],
|
||||
],
|
||||
},
|
||||
};
|
||||
const emptyData: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
// Layer A: primary, starts active at opacity 1
|
||||
this.map.addLayer({
|
||||
id: layername,
|
||||
type: 'fill',
|
||||
source: { type: 'geojson', data: emptyData },
|
||||
paint: { ...defaultPaint, 'fill-opacity': 1.0 },
|
||||
}, addBefore);
|
||||
|
||||
// Layer B: back layer, starts inactive at opacity 0, renders below A
|
||||
this.map.addLayer({
|
||||
id: layername + '-back',
|
||||
type: 'fill',
|
||||
source: { type: 'geojson', data: emptyData },
|
||||
paint: { ...defaultPaint },
|
||||
}, layername);
|
||||
|
||||
this.sourceA = this.map.getSource(layername) as mapboxgl.GeoJSONSource;
|
||||
this.sourceB = this.map.getSource(layername + '-back') as mapboxgl.GeoJSONSource;
|
||||
}
|
||||
|
||||
private nextRequestId(): number {
|
||||
return ++this.requestCounter;
|
||||
}
|
||||
|
||||
private handleWorkerMessage(msg: WorkerResponse): void {
|
||||
switch (msg.type) {
|
||||
case 'DATA_READY':
|
||||
// Data loaded in worker, nothing to do on main thread
|
||||
break;
|
||||
|
||||
case 'GRID_RESULT':
|
||||
this.handleGridResult(msg);
|
||||
break;
|
||||
|
||||
case 'COLOR_SCALE_RESULT': {
|
||||
const pending = this.pendingColorScale.get(msg.requestId);
|
||||
if (pending) {
|
||||
this.pendingColorScale.delete(msg.requestId);
|
||||
pending.resolve(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'BOUNDS_RESULT': {
|
||||
const pending = this.pendingBounds.get(msg.requestId);
|
||||
if (pending) {
|
||||
this.pendingBounds.delete(msg.requestId);
|
||||
pending.resolve(msg);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleGridResult(msg: GridResultMessage): void {
|
||||
// Discard stale grid results
|
||||
if (msg.requestId !== this.pendingGridRequestId) return;
|
||||
this.pendingGridRequestId = null;
|
||||
|
||||
const hexgrid = msg.hexgrid as unknown as GeoJSON.FeatureCollection;
|
||||
const currentZoom = Math.floor(this.map.getZoom());
|
||||
const zoomChanged = this.lastZoom !== null && this.lastZoom !== currentZoom;
|
||||
this.lastZoom = currentZoom;
|
||||
|
||||
const activeSource = this.activeIsA ? this.sourceA! : this.sourceB!;
|
||||
const activeLayer = this.activeIsA ? this.layerA : this.layerB;
|
||||
const inactiveSource = this.activeIsA ? this.sourceB! : this.sourceA!;
|
||||
const inactiveLayer = this.activeIsA ? this.layerB : this.layerA;
|
||||
|
||||
// Cancel any pending cleanup timeout
|
||||
if (this.clearTimeout) {
|
||||
clearTimeout(this.clearTimeout);
|
||||
this.clearTimeout = null;
|
||||
}
|
||||
|
||||
if (zoomChanged) {
|
||||
// Cross-fade between layers
|
||||
this.map.moveLayer(activeLayer, inactiveLayer);
|
||||
inactiveSource.setData(hexgrid);
|
||||
this.map.setPaintProperty(inactiveLayer, 'fill-opacity', 1.0);
|
||||
this.map.setPaintProperty(activeLayer, 'fill-opacity', 0);
|
||||
this.activeIsA = !this.activeIsA;
|
||||
|
||||
// Clear old layer data after transition
|
||||
const src = activeSource;
|
||||
this.clearTimeout = setTimeout(() => {
|
||||
this.clearTimeout = null;
|
||||
src.setData({ type: 'FeatureCollection', features: [] });
|
||||
}, 250);
|
||||
} else {
|
||||
// Pan only: swap data directly on active source
|
||||
activeSource.setData(hexgrid);
|
||||
}
|
||||
|
||||
this.calculatingGrid = false;
|
||||
if (this.recalcWhenReady) {
|
||||
this.recalcWhenReady = false;
|
||||
this.updateGrid();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setData(data: { features: Array<{ properties: any; geometry: { type: 'Point'; coordinates: [number, number] } }> }): void {
|
||||
// Build main-thread R-tree for click queries
|
||||
const items = data.features.map(toFeatureItem);
|
||||
this.mainTree.clear();
|
||||
this.mainTree.load(items);
|
||||
|
||||
// Post to worker for grid generation tree
|
||||
const requestId = this.nextRequestId();
|
||||
this.worker.postMessage({
|
||||
type: 'SET_DATA',
|
||||
requestId,
|
||||
features: data.features,
|
||||
});
|
||||
}
|
||||
|
||||
setPropertyName(name: string): void {
|
||||
this.config.propertyName = name;
|
||||
}
|
||||
|
||||
setSpread(v: number): void {
|
||||
this.config.spread = v;
|
||||
}
|
||||
|
||||
setCellDensity(v: number): void {
|
||||
this.config.cellDensity = v;
|
||||
}
|
||||
|
||||
setIntensity(v: number): void {
|
||||
this.config.intensity = v;
|
||||
}
|
||||
|
||||
setColorStops(stops: [number, string][]): void {
|
||||
const colorProp = { property: 'count', stops };
|
||||
this.map.setPaintProperty(this.layerA, 'fill-color', colorProp);
|
||||
this.map.setPaintProperty(this.layerB, 'fill-color', colorProp);
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.updateGrid();
|
||||
}
|
||||
|
||||
searchTree(bounds: { minX: number; minY: number; maxX: number; maxY: number }): FeatureItem[] {
|
||||
return this.mainTree.search(bounds);
|
||||
}
|
||||
|
||||
computeColorScale(
|
||||
metricMode: string,
|
||||
percentileConfig: { minBound: number; maxBound: number }
|
||||
): Promise<ColorScaleResultMessage> {
|
||||
const requestId = this.nextRequestId();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingColorScale.set(requestId, { resolve, reject });
|
||||
this.worker.postMessage({
|
||||
type: 'COMPUTE_COLOR_SCALE',
|
||||
requestId,
|
||||
metricMode,
|
||||
percentileConfig,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
computeBounds(
|
||||
boundsPercentileConfig: { clipMin: number; clipMax: number }
|
||||
): Promise<BoundsResultMessage> {
|
||||
const requestId = this.nextRequestId();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pendingBounds.set(requestId, { resolve, reject });
|
||||
this.worker.postMessage({
|
||||
type: 'COMPUTE_BOUNDS',
|
||||
requestId,
|
||||
boundsPercentileConfig,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.map.off('moveend', this.moveEndHandler);
|
||||
if (this.clearTimeout) {
|
||||
clearTimeout(this.clearTimeout);
|
||||
}
|
||||
// Reject any pending promises
|
||||
for (const [, p] of this.pendingColorScale) {
|
||||
p.reject(new Error('HexgridHeatmapClient destroyed'));
|
||||
}
|
||||
for (const [, p] of this.pendingBounds) {
|
||||
p.reject(new Error('HexgridHeatmapClient destroyed'));
|
||||
}
|
||||
this.pendingColorScale.clear();
|
||||
this.pendingBounds.clear();
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private updateGrid(): void {
|
||||
if (!this.calculatingGrid) {
|
||||
this.calculatingGrid = true;
|
||||
|
||||
const zoom = this.map.getZoom();
|
||||
const b = this.map.getBounds()!;
|
||||
const bounds: [number, number, number, number] = [
|
||||
b.getWest(), b.getSouth(), b.getEast(), b.getNorth(),
|
||||
];
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
this.pendingGridRequestId = requestId;
|
||||
|
||||
this.worker.postMessage({
|
||||
type: 'GENERATE_GRID',
|
||||
requestId,
|
||||
zoom,
|
||||
bounds,
|
||||
config: this.config,
|
||||
});
|
||||
} else {
|
||||
this.recalcWhenReady = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
115
frontend/src/workers/types.ts
Normal file
115
frontend/src/workers/types.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { BBox } from 'rbush';
|
||||
|
||||
// RBush item adapter for GeoJSON Point features
|
||||
export interface FeatureItem extends BBox {
|
||||
properties: Record<string, unknown>;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
}
|
||||
|
||||
export function toFeatureItem(feature: {
|
||||
properties: Record<string, unknown>;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
}): FeatureItem {
|
||||
const [x, y] = feature.geometry.coordinates;
|
||||
return {
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
properties: feature.properties,
|
||||
geometry: feature.geometry,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Main → Worker messages ---
|
||||
|
||||
export interface SetDataMessage {
|
||||
type: 'SET_DATA';
|
||||
requestId: number;
|
||||
features: Array<{
|
||||
properties: Record<string, unknown>;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GenerateGridMessage {
|
||||
type: 'GENERATE_GRID';
|
||||
requestId: number;
|
||||
zoom: number;
|
||||
bounds: [number, number, number, number]; // [minLng, minLat, maxLng, maxLat]
|
||||
config: {
|
||||
cellDensity: number;
|
||||
spread: number;
|
||||
intensity: number;
|
||||
propertyName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComputeColorScaleMessage {
|
||||
type: 'COMPUTE_COLOR_SCALE';
|
||||
requestId: number;
|
||||
metricMode: string;
|
||||
percentileConfig: { minBound: number; maxBound: number };
|
||||
}
|
||||
|
||||
export interface ComputeBoundsMessage {
|
||||
type: 'COMPUTE_BOUNDS';
|
||||
requestId: number;
|
||||
boundsPercentileConfig: { clipMin: number; clipMax: number };
|
||||
}
|
||||
|
||||
export type WorkerRequest =
|
||||
| SetDataMessage
|
||||
| GenerateGridMessage
|
||||
| ComputeColorScaleMessage
|
||||
| ComputeBoundsMessage;
|
||||
|
||||
// --- Worker → Main messages ---
|
||||
|
||||
export interface DataReadyMessage {
|
||||
type: 'DATA_READY';
|
||||
requestId: number;
|
||||
featureCount: number;
|
||||
}
|
||||
|
||||
export interface GridResultMessage {
|
||||
type: 'GRID_RESULT';
|
||||
requestId: number;
|
||||
hexgrid: {
|
||||
type: 'FeatureCollection';
|
||||
features: Array<{
|
||||
type: 'Feature';
|
||||
geometry: { type: 'Polygon'; coordinates: number[][][] };
|
||||
properties: {
|
||||
count: number;
|
||||
searchMinX: number;
|
||||
searchMinY: number;
|
||||
searchMaxX: number;
|
||||
searchMaxY: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColorScaleResultMessage {
|
||||
type: 'COLOR_SCALE_RESULT';
|
||||
requestId: number;
|
||||
min: number;
|
||||
max: number;
|
||||
hasValues: boolean;
|
||||
}
|
||||
|
||||
export interface BoundsResultMessage {
|
||||
type: 'BOUNDS_RESULT';
|
||||
requestId: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
}
|
||||
|
||||
export type WorkerResponse =
|
||||
| DataReadyMessage
|
||||
| GridResultMessage
|
||||
| ColorScaleResultMessage
|
||||
| BoundsResultMessage;
|
||||
Loading…
Add table
Add a link
Reference in a new issue