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

View 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;
}
}
}

View 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;
}
};

View 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;