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
|
|
@ -6,7 +6,6 @@
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Wrongmove</title>
|
<title>Wrongmove</title>
|
||||||
<script src="/HexgridHeatmap.js"> </script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -36,6 +36,7 @@
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mapbox-gl": "^3.12.0",
|
"mapbox-gl": "^3.12.0",
|
||||||
"oidc-client-ts": "^3.2.1",
|
"oidc-client-ts": "^3.2.1",
|
||||||
|
"rbush": "^4.0.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.7.0",
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"@types/node": "^24.0.1",
|
"@types/node": "^24.0.1",
|
||||||
|
"@types/rbush": "^4.0.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@types/turf": "^3.5.32",
|
"@types/turf": "^3.5.32",
|
||||||
|
|
@ -3627,6 +3629,13 @@
|
||||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/rbush": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.8",
|
"version": "19.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||||
|
|
@ -7031,6 +7040,15 @@
|
||||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/rbush": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quickselect": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mapbox-gl": "^3.12.0",
|
"mapbox-gl": "^3.12.0",
|
||||||
"oidc-client-ts": "^3.2.1",
|
"oidc-client-ts": "^3.2.1",
|
||||||
|
"rbush": "^4.0.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-day-picker": "^9.7.0",
|
"react-day-picker": "^9.7.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
|
@ -60,6 +61,7 @@
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/mapbox-gl": "^3.4.1",
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"@types/node": "^24.0.1",
|
"@types/node": "^24.0.1",
|
||||||
|
"@types/rbush": "^4.0.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@types/turf": "^3.5.32",
|
"@types/turf": "^3.5.32",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -8,25 +8,11 @@ import "../assets/Map.css";
|
||||||
import { Metric, type ParameterValues } from "./FilterPanel";
|
import { Metric, type ParameterValues } from "./FilterPanel";
|
||||||
import { PropertyCard } from "./PropertyCard";
|
import { PropertyCard } from "./PropertyCard";
|
||||||
import { ScrollArea } from "./ui/scroll-area";
|
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 { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
|
||||||
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
|
||||||
import { percentile, calculateColorStops } from "@/utils/mapUtils";
|
import { calculateColorStops } from "@/utils/mapUtils";
|
||||||
|
import { HexgridHeatmapClient } from "@/workers/HexgridHeatmapClient";
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PropertyWithCoords {
|
interface PropertyWithCoords {
|
||||||
properties: PropertyProperties;
|
properties: PropertyProperties;
|
||||||
|
|
@ -48,7 +34,7 @@ export function Map(props: MapProps) {
|
||||||
|
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null);
|
const mapRef = useRef<mapboxgl.Map | null>(null);
|
||||||
const mapContainerRef = useRef<HTMLDivElement | 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 updateTimeoutRef = useRef<number | null>(null);
|
||||||
const isMapLoadedRef = useRef<boolean>(false);
|
const isMapLoadedRef = useRef<boolean>(false);
|
||||||
const lastDataLengthRef = useRef<number>(0);
|
const lastDataLengthRef = useRef<number>(0);
|
||||||
|
|
@ -77,12 +63,12 @@ export function Map(props: MapProps) {
|
||||||
: 0;
|
: 0;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const updateHeatmap = useCallback(() => {
|
const updateHeatmap = useCallback(async () => {
|
||||||
if (!mapRef.current || !isMapLoadedRef.current) return;
|
if (!mapRef.current || !isMapLoadedRef.current) return;
|
||||||
|
|
||||||
// Create heatmap if it doesn't exist
|
// Create heatmap if it doesn't exist
|
||||||
if (!heatmapRef.current) {
|
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.setIntensity(HEATMAP_CONFIG.INTENSITY);
|
||||||
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
|
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
|
||||||
heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY);
|
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
|
// Pass all features to the heatmap — filtering is done server-side
|
||||||
heatmap.setData(data);
|
heatmap.setData(data);
|
||||||
|
|
||||||
// Compute color scale from valid metric values only
|
// Compute color scale in worker (sorts + percentiles off main thread)
|
||||||
const values = data.features
|
const colorResult = await heatmap.computeColorScale(metricMode, {
|
||||||
.map(function (d: PropertyFeature) {
|
minBound: PERCENTILE_CONFIG.MIN_BOUND,
|
||||||
return (d.properties as unknown as Record<string, unknown>)[metricMode] as number;
|
maxBound: PERCENTILE_CONFIG.MAX_BOUND,
|
||||||
})
|
});
|
||||||
.filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; })
|
|
||||||
.sort(function (a: number, b: number) { return a - b; });
|
|
||||||
|
|
||||||
if (values.length > 0) {
|
if (colorResult.hasValues) {
|
||||||
const minIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND), values.length - 1);
|
makeLegend(colorScheme, colorResult.min, colorResult.max);
|
||||||
const maxIndex = Math.min(Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND), values.length - 1);
|
const colorStopsValue = calculateColorStops(colorScheme, colorResult.min, colorResult.max);
|
||||||
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);
|
|
||||||
heatmap.setColorStops(colorStopsValue);
|
heatmap.setColorStops(colorStopsValue);
|
||||||
} else {
|
} else {
|
||||||
// Set safe default stops so stale stops from a previous metric don't cause
|
// 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
|
// Fit bounds only on first load or significant data change
|
||||||
if (lastDataLengthRef.current === 0 && data.features.length > 0) {
|
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 boundsResult = await heatmap.computeBounds({
|
||||||
const latitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; });
|
clipMin: PERCENTILE_CONFIG.BOUNDS_CLIP_MIN,
|
||||||
const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
|
clipMax: PERCENTILE_CONFIG.BOUNDS_CLIP_MAX,
|
||||||
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);
|
|
||||||
|
|
||||||
mapRef.current?.fitBounds([
|
mapRef.current?.fitBounds([
|
||||||
[minlng, minlat],
|
[boundsResult.minLng, boundsResult.minLat],
|
||||||
[maxlng, maxlat]
|
[boundsResult.maxLng, boundsResult.maxLat]
|
||||||
], { duration: 0 });
|
], { duration: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,8 +170,11 @@ export function Map(props: MapProps) {
|
||||||
if (updateTimeoutRef.current) {
|
if (updateTimeoutRef.current) {
|
||||||
clearTimeout(updateTimeoutRef.current);
|
clearTimeout(updateTimeoutRef.current);
|
||||||
}
|
}
|
||||||
// Remove heatmap layers and sources before destroying the map
|
// Destroy worker and remove heatmap layers/sources before destroying the map
|
||||||
if (heatmapRef.current && mapRef.current) {
|
if (heatmapRef.current) {
|
||||||
|
heatmapRef.current.destroy();
|
||||||
|
}
|
||||||
|
if (mapRef.current) {
|
||||||
for (const layerId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) {
|
for (const layerId of ['hexgrid-heatmap', 'hexgrid-heatmap-back']) {
|
||||||
if (mapRef.current.getLayer(layerId)) {
|
if (mapRef.current.getLayer(layerId)) {
|
||||||
mapRef.current.removeLayer(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 }) {
|
function openListingsDialog(longitude: number, latitude: number, searchBounds: { minX: number; minY: number; maxX: number; maxY: number }) {
|
||||||
if (!heatmapRef.current || !mapRef.current) return;
|
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) {
|
if (properties.length > 0) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
const root = createRoot(container);
|
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;
|
||||||
|
|
@ -33,7 +33,6 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
"public/HexgridHeatmap.js",
|
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/__tests__/**",
|
"src/**/__tests__/**",
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/poimanager.tsx","./src/components/propertycard.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingservice.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts"],"version":"5.8.3"}
|
{"root":["./src/app.tsx","./src/appsidebar.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/authservice.ts","./src/auth/config.ts","./src/auth/errors.ts","./src/auth/passkeyservice.ts","./src/auth/types.ts","./src/components/activequery.tsx","./src/components/alerterror.tsx","./src/components/authcallback.tsx","./src/components/favoritesview.tsx","./src/components/filterpanel.tsx","./src/components/header.tsx","./src/components/healthindicator.tsx","./src/components/listview.tsx","./src/components/listingdetail.tsx","./src/components/listingdetailsheet.tsx","./src/components/loginmodal.tsx","./src/components/map.tsx","./src/components/mobilebottomsheet.tsx","./src/components/mobilemenu.tsx","./src/components/poimanager.tsx","./src/components/photocarousel.tsx","./src/components/propertycard.tsx","./src/components/propertycardcompact.tsx","./src/components/savedview.tsx","./src/components/spinner.tsx","./src/components/statsbar.tsx","./src/components/streamingprogressbar.tsx","./src/components/swipecard.tsx","./src/components/swipereviewmode.tsx","./src/components/swipeablecardrow.tsx","./src/components/swipeablepropertycard.tsx","./src/components/taskindicator.tsx","./src/components/taskprogressdrawer.tsx","./src/components/visualizationcard.tsx","./src/components/ui/datepicker.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/form.tsx","./src/components/ui/hover-card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/range-slider-field.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/tabs.tsx","./src/components/ui/tooltip.tsx","./src/constants/colorschemes.ts","./src/constants/index.ts","./src/hooks/use-mobile.ts","./src/hooks/usedecisions.ts","./src/hooks/uselistingdetail.ts","./src/hooks/usetaskprogress.ts","./src/lib/utils.ts","./src/services/apiclient.ts","./src/services/decisionservice.ts","./src/services/healthservice.ts","./src/services/index.ts","./src/services/listingdetailservice.ts","./src/services/listingservice.ts","./src/services/poiservice.ts","./src/services/streamingservice.ts","./src/services/taskservice.ts","./src/types/index.ts","./src/utils/maputils.ts","./src/utils/poiutils.ts","./src/workers/hexgridheatmapclient.ts","./src/workers/hexgrid.worker.ts","./src/workers/types.ts"],"version":"5.8.3"}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue