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

@ -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);