feat: modernize deck.gl and map plugins with MapLibre/Mapbox dual renderer (#38035)

Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
This commit is contained in:
Birk Skyum
2026-04-09 02:14:59 +02:00
committed by GitHub
parent d915e4f3ff
commit c49641538d
234 changed files with 3402 additions and 2505 deletions

View File

@@ -0,0 +1,121 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useRef } from 'react';
import { useMap as useMapLibre } from 'react-map-gl/maplibre';
import { useMap as useMapbox } from 'react-map-gl/mapbox';
export interface RedrawParams {
width: number;
height: number;
ctx: CanvasRenderingContext2D;
isDragging: boolean;
project: (lngLat: [number, number]) => [number, number];
}
interface CanvasOverlayProps {
redraw: (params: RedrawParams) => void;
}
export default function CanvasOverlay({ redraw }: CanvasOverlayProps) {
const mapLibreContext = useMapLibre();
const mapboxContext = useMapbox();
const mapRef = (mapLibreContext.current ?? mapboxContext.current) as any;
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDraggingRef = useRef(false);
const project = useCallback(
(lngLat: [number, number]): [number, number] => {
if (!mapRef) return [0, 0];
const map = mapRef.getMap();
const point = map.project(lngLat);
return [point.x, point.y];
},
[mapRef],
);
const performRedraw = useCallback(() => {
const canvas = canvasRef.current;
const map = mapRef?.getMap();
if (!canvas || !map) return;
const container = map.getContainer();
const dpr = window.devicePixelRatio || 1;
const width = container.clientWidth;
const height = container.clientHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redraw({
width,
height,
ctx,
isDragging: isDraggingRef.current,
project,
});
}, [mapRef, redraw, project]);
useEffect(() => {
const map = mapRef?.getMap();
if (!map) return undefined;
const onMove = () => performRedraw();
const onDragStart = () => {
isDraggingRef.current = true;
};
const onDragEnd = () => {
isDraggingRef.current = false;
performRedraw();
};
const onResize = () => performRedraw();
map.on('move', onMove);
map.on('dragstart', onDragStart);
map.on('dragend', onDragEnd);
map.on('resize', onResize);
performRedraw();
return () => {
map.off('move', onMove);
map.off('dragstart', onDragStart);
map.off('dragend', onDragEnd);
map.off('resize', onResize);
};
}, [mapRef, performRedraw]);
return (
<canvas
ref={canvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
}}
/>
);
}

View File

@@ -0,0 +1,400 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useCallback } from 'react';
import CanvasOverlay, { type RedrawParams } from './CanvasOverlay';
import { kmToPixels, MILES_PER_KM } from '../utils/geo';
import roundDecimal from '../utils/roundDecimal';
import luminanceFromRGB from '../utils/luminanceFromRGB';
// Shared radius bounds keep cluster and point sizing in sync.
export const MIN_CLUSTER_RADIUS_RATIO = 1 / 6;
export const MAX_POINT_RADIUS_RATIO = 1 / 3;
interface GeoJSONLocation {
geometry: {
coordinates: [number, number];
};
properties: Record<string, number | string | boolean | null | undefined>;
}
interface DrawTextOptions {
fontHeight?: number;
label?: string | number;
radius?: number;
rgb?: (string | number)[];
shadow?: boolean;
}
interface ScatterPlotOverlayProps {
aggregation?: string;
compositeOperation?: string;
dotRadius?: number;
globalOpacity?: number;
lngLatAccessor?: (location: GeoJSONLocation) => [number, number];
locations: GeoJSONLocation[];
pointRadiusUnit?: string;
renderWhileDragging?: boolean;
rgb?: (string | number)[];
zoom?: number;
}
const IS_DARK_THRESHOLD = 110;
const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
];
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
aggregation: string | undefined,
): number | string => {
const count = properties.point_count as number;
if (!aggregation) {
return count;
}
if (aggregation === 'sum' || aggregation === 'min' || aggregation === 'max') {
return properties[aggregation] as number;
}
const { sum } = properties as { sum: number };
const mean = sum / count;
if (aggregation === 'mean') {
return Math.round(100 * mean) / 100;
}
const { squaredSum } = properties as { squaredSum: number };
const variance = squaredSum / count - (sum / count) ** 2;
if (aggregation === 'var') {
return Math.round(100 * variance) / 100;
}
if (aggregation === 'std' || aggregation === 'stdev') {
return Math.round(100 * Math.sqrt(variance)) / 100;
}
// fallback to point_count
return count;
};
function drawText(
ctx: CanvasRenderingContext2D,
pixel: [number, number],
compositeOperation: string,
options: DrawTextOptions = {},
) {
const {
fontHeight = 0,
label = '',
radius = 0,
rgb = [0, 0, 0],
shadow = false,
} = options;
const maxWidth = radius * 1.8;
const luminance = luminanceFromRGB(
rgb[1] as number,
rgb[2] as number,
rgb[3] as number,
);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
ctx.font = `${fontHeight}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (shadow) {
ctx.shadowBlur = 15;
ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
const textWidth = ctx.measureText(String(label)).width;
if (textWidth > maxWidth) {
const scale = fontHeight / textWidth;
ctx.font = `${scale * maxWidth}px sans-serif`;
}
ctx.fillText(String(label), pixel[0], pixel[1]);
ctx.globalCompositeOperation = compositeOperation as GlobalCompositeOperation;
ctx.shadowBlur = 0;
ctx.shadowColor = '';
}
function ScatterPlotOverlay({
aggregation,
compositeOperation = 'source-over',
dotRadius = 4,
globalOpacity = 1,
lngLatAccessor = defaultLngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging = true,
rgb,
zoom,
}: ScatterPlotOverlayProps) {
const redraw = useCallback(
({ width, height, ctx, isDragging, project }: RedrawParams) => {
const radius = dotRadius;
const clusterLabelMap: (number | string)[] = [];
locations.forEach((location, i) => {
if (location.properties.cluster) {
clusterLabelMap[i] = computeClusterLabel(
location.properties,
aggregation,
);
}
});
const finiteClusterLabels = clusterLabelMap
.map(value => Number(value))
.filter(value => Number.isFinite(value));
const safeMaxAbsLabel =
finiteClusterLabels.length > 0
? Math.max(
Math.max(...finiteClusterLabels.map(value => Math.abs(value))),
1,
)
: 1;
// Calculate min/max radius values for Pixels mode scaling
let minRadiusValue = Infinity;
let maxRadiusValue = -Infinity;
if (pointRadiusUnit === 'Pixels') {
locations.forEach(location => {
if (
!location.properties.cluster &&
location.properties.radius != null
) {
const radiusValueRaw = location.properties.radius;
const radiusValue =
typeof radiusValueRaw === 'string' && radiusValueRaw.trim() === ''
? null
: Number(radiusValueRaw);
if (radiusValue != null && Number.isFinite(radiusValue)) {
minRadiusValue = Math.min(minRadiusValue, radiusValue);
maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
}
});
}
ctx.clearRect(0, 0, width, height);
ctx.globalCompositeOperation =
compositeOperation as GlobalCompositeOperation;
if ((renderWhileDragging || !isDragging) && locations) {
locations.forEach((location: GeoJSONLocation, i: number) => {
const pixel = project(lngLatAccessor(location));
const pixelRounded: [number, number] = [
roundDecimal(pixel[0], 1),
roundDecimal(pixel[1], 1),
];
if (
pixelRounded[0] + radius >= 0 &&
pixelRounded[0] - radius < width &&
pixelRounded[1] + radius >= 0 &&
pixelRounded[1] - radius < height
) {
ctx.beginPath();
if (location.properties.cluster) {
const clusterLabel = clusterLabelMap[i];
const numericLabel = Number(clusterLabel);
const safeNumericLabel = Number.isFinite(numericLabel)
? numericLabel
: 0;
const minClusterRadius =
pointRadiusUnit === 'Pixels'
? radius * MAX_POINT_RADIUS_RATIO
: radius * MIN_CLUSTER_RADIUS_RATIO;
const ratio = Math.abs(safeNumericLabel) / safeMaxAbsLabel;
const scaledRadius = roundDecimal(
minClusterRadius + ratio ** 0.5 * (radius - minClusterRadius),
1,
);
const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
const [x, y] = pixelRounded;
const gradient = ctx.createRadialGradient(
x,
y,
scaledRadius,
x,
y,
0,
);
gradient.addColorStop(
1,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * globalOpacity})`,
);
gradient.addColorStop(
0,
`rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
);
ctx.arc(
pixelRounded[0],
pixelRounded[1],
scaledRadius,
0,
Math.PI * 2,
);
ctx.fillStyle = gradient;
ctx.fill();
if (Number.isFinite(safeNumericLabel)) {
let label: string | number = clusterLabel;
const absLabel = Math.abs(safeNumericLabel);
const sign = safeNumericLabel < 0 ? '-' : '';
if (absLabel >= 10000) {
label = `${sign}${Math.round(absLabel / 1000)}k`;
} else if (absLabel >= 1000) {
label = `${sign}${Math.round(absLabel / 100) / 10}k`;
}
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight,
label,
radius: scaledRadius,
rgb,
shadow: true,
});
}
} else {
const defaultRadius = radius * MIN_CLUSTER_RADIUS_RATIO;
const rawRadius = location.properties.radius;
const numericRadiusProperty =
rawRadius != null &&
!(typeof rawRadius === 'string' && rawRadius.trim() === '')
? Number(rawRadius)
: null;
const radiusProperty =
numericRadiusProperty != null &&
Number.isFinite(numericRadiusProperty)
? numericRadiusProperty
: null;
const pointMetric = location.properties.metric ?? null;
let pointRadius: number = radiusProperty ?? defaultRadius;
let pointLabel: string | number | undefined;
if (radiusProperty != null) {
const pointLatitude = lngLatAccessor(location)[1];
if (pointRadiusUnit === 'Kilometers') {
pointLabel = `${roundDecimal(pointRadius, 2)}km`;
pointRadius = kmToPixels(
pointRadius,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Miles') {
pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
pointRadius = kmToPixels(
pointRadius * MILES_PER_KM,
pointLatitude,
zoom ?? 0,
);
} else if (pointRadiusUnit === 'Pixels') {
const MIN_POINT_RADIUS = radius * MIN_CLUSTER_RADIUS_RATIO;
const MAX_POINT_RADIUS = radius * MAX_POINT_RADIUS_RATIO;
if (
Number.isFinite(minRadiusValue) &&
Number.isFinite(maxRadiusValue) &&
maxRadiusValue > minRadiusValue
) {
const numericPointRadius = Number(pointRadius);
if (!Number.isFinite(numericPointRadius)) {
pointRadius = MIN_POINT_RADIUS;
} else {
const normalizedValueRaw =
(numericPointRadius - minRadiusValue) /
(maxRadiusValue - minRadiusValue);
const normalizedValue = Math.max(
0,
Math.min(1, normalizedValueRaw),
);
pointRadius =
MIN_POINT_RADIUS +
normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
}
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else if (
Number.isFinite(minRadiusValue) &&
minRadiusValue === maxRadiusValue
) {
pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
pointRadius = Math.max(
MIN_POINT_RADIUS,
Math.min(pointRadius, MAX_POINT_RADIUS),
);
pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
}
if (pointMetric !== null) {
const numericMetric = parseFloat(String(pointMetric));
pointLabel = Number.isFinite(numericMetric)
? roundDecimal(numericMetric, 2)
: String(pointMetric);
}
if (!pointRadius) {
pointRadius = defaultRadius;
}
ctx.arc(
pixelRounded[0],
pixelRounded[1],
roundDecimal(pointRadius, 1),
0,
Math.PI * 2,
);
ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
ctx.fill();
if (pointLabel !== undefined) {
drawText(ctx, pixelRounded, compositeOperation, {
fontHeight: roundDecimal(pointRadius, 1),
label: pointLabel,
radius: pointRadius,
rgb,
shadow: false,
});
}
}
}
});
}
},
[
aggregation,
compositeOperation,
dotRadius,
globalOpacity,
lngLatAccessor,
locations,
pointRadiusUnit,
renderWhileDragging,
rgb,
zoom,
],
);
return <CanvasOverlay redraw={redraw} />;
}
export default memo(ScatterPlotOverlay);