feat: add vehicle and hydrogen heatmaps
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
158
src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx
Normal file
158
src/modules/vehicle-heatmap/AmapHeatmapCanvas.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { AmapConfig, HeatmapMetric, HeatmapPoint } from './types';
|
||||
|
||||
type Props = {
|
||||
config: AmapConfig;
|
||||
points: HeatmapPoint[];
|
||||
max: number;
|
||||
metric: HeatmapMetric;
|
||||
focusQuery: string;
|
||||
onMapClick: (longitude: number, latitude: number) => void;
|
||||
};
|
||||
|
||||
type AmapInstance = {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => any;
|
||||
HeatMap: new (map: any, options: Record<string, unknown>) => any;
|
||||
ToolBar: new (options?: Record<string, unknown>) => any;
|
||||
Scale: new (options?: Record<string, unknown>) => any;
|
||||
Bounds: new (southWest: [number, number], northEast: [number, number]) => any;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_AMapSecurityConfig?: { securityJsCode: string };
|
||||
}
|
||||
}
|
||||
|
||||
function getBounds(points: HeatmapPoint[]) {
|
||||
if (points.length === 0) return null;
|
||||
let minLng = points[0].lng;
|
||||
let maxLng = points[0].lng;
|
||||
let minLat = points[0].lat;
|
||||
let maxLat = points[0].lat;
|
||||
for (let index = 1; index < points.length; index += 1) {
|
||||
const point = points[index];
|
||||
minLng = Math.min(minLng, point.lng);
|
||||
maxLng = Math.max(maxLng, point.lng);
|
||||
minLat = Math.min(minLat, point.lat);
|
||||
maxLat = Math.max(maxLat, point.lat);
|
||||
}
|
||||
return { minLng, maxLng, minLat, maxLat };
|
||||
}
|
||||
|
||||
export default function AmapHeatmapCanvas({ config, points, max, metric, focusQuery, onMapClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<any>(null);
|
||||
const heatmapRef = useRef<any>(null);
|
||||
const amapRef = useRef<AmapInstance | null>(null);
|
||||
const clickHandlerRef = useRef(onMapClick);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const intensity = useMemo(() => {
|
||||
const transform = metric === 'locations'
|
||||
? (value: number) => Math.log1p(value)
|
||||
: (value: number) => Math.sqrt(value);
|
||||
return {
|
||||
points: points.map((point) => ({ ...point, count: transform(point.count) })),
|
||||
max: transform(Math.max(1, max)),
|
||||
};
|
||||
}, [max, metric, points]);
|
||||
|
||||
useEffect(() => {
|
||||
clickHandlerRef.current = onMapClick;
|
||||
}, [onMapClick]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
window._AMapSecurityConfig = { securityJsCode: config.securityCode };
|
||||
const loaderModule = await import('@amap/amap-jsapi-loader');
|
||||
const AMap = await loaderModule.default.load({
|
||||
key: config.key,
|
||||
version: '2.0',
|
||||
plugins: ['AMap.HeatMap', 'AMap.ToolBar', 'AMap.Scale'],
|
||||
}) as unknown as AmapInstance;
|
||||
if (cancelled || !container) return;
|
||||
|
||||
const map = new AMap.Map(container, {
|
||||
viewMode: '2D',
|
||||
zoom: 5,
|
||||
center: [105.4, 34.4],
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
resizeEnable: true,
|
||||
showLabel: true,
|
||||
});
|
||||
map.addControl(new AMap.ToolBar({ position: { right: '20px', bottom: '76px' } }));
|
||||
map.addControl(new AMap.Scale({ position: { right: '18px', bottom: '24px' } }));
|
||||
const heatmap = new AMap.HeatMap(map, {
|
||||
radius: 25,
|
||||
opacity: [0.12, 0.82],
|
||||
gradient: {
|
||||
0.1: '#2563eb',
|
||||
0.3: '#0891b2',
|
||||
0.5: '#16a34a',
|
||||
0.68: '#eab308',
|
||||
0.84: '#f97316',
|
||||
1: '#dc2626',
|
||||
},
|
||||
});
|
||||
map.on('click', (event: any) => {
|
||||
clickHandlerRef.current(event.lnglat.getLng(), event.lnglat.getLat());
|
||||
});
|
||||
mapRef.current = map;
|
||||
heatmapRef.current = heatmap;
|
||||
amapRef.current = AMap;
|
||||
setStatus('ready');
|
||||
} catch (error) {
|
||||
console.error('AMap initialization failed', error);
|
||||
if (!cancelled) setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
initialize();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
heatmapRef.current?.setMap?.(null);
|
||||
mapRef.current?.destroy?.();
|
||||
heatmapRef.current = null;
|
||||
mapRef.current = null;
|
||||
amapRef.current = null;
|
||||
};
|
||||
}, [config.key, config.securityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== 'ready' || !heatmapRef.current) return;
|
||||
heatmapRef.current.setDataSet({ data: intensity.points, max: intensity.max });
|
||||
|
||||
if (!focusQuery || points.length === 0 || !mapRef.current || !amapRef.current) return;
|
||||
const bounds = getBounds(points);
|
||||
if (!bounds) return;
|
||||
if (points.length === 1) {
|
||||
mapRef.current.setZoomAndCenter(12, [points[0].lng, points[0].lat]);
|
||||
return;
|
||||
}
|
||||
mapRef.current.setBounds(new amapRef.current.Bounds(
|
||||
[bounds.minLng, bounds.minLat],
|
||||
[bounds.maxLng, bounds.maxLat],
|
||||
), false, [80, 80, 80, 80]);
|
||||
}, [focusQuery, intensity, points, status]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-[#eef3f7]">
|
||||
<div ref={containerRef} className="h-full w-full" aria-label="车辆运营热力图地图" />
|
||||
{status === 'loading' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-white/76 text-sm font-medium text-slate-500 backdrop-blur-[2px]">
|
||||
<span className="flex items-center gap-2.5"><i className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />正在加载高德地图</span>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-slate-50 text-center">
|
||||
<div><p className="text-sm font-semibold text-slate-700">地图加载失败</p><p className="mt-1 text-xs text-slate-400">请检查高德 Key、域名白名单与网络连接</p></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user