159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|