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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue