mirror of
https://github.com/apache/superset.git
synced 2026-05-12 19:35:17 +00:00
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:
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user