204 lines
7.4 KiB
TypeScript
204 lines
7.4 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
import { Tag } from '@douyinfe/semi-ui';
|
|
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
|
|
|
export type VehicleMapPoint = {
|
|
id: string;
|
|
label: string;
|
|
longitude: number;
|
|
latitude: number;
|
|
online?: boolean;
|
|
title?: string;
|
|
};
|
|
|
|
type AMapLike = {
|
|
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
|
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
|
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
|
Scale: new () => unknown;
|
|
};
|
|
|
|
type AMapMap = {
|
|
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
|
|
addControl: (control: unknown) => void;
|
|
setFitView: (overlays?: AMapOverlay[] | null, immediate?: boolean, padding?: number[]) => void;
|
|
destroy: () => void;
|
|
};
|
|
|
|
type AMapOverlay = {
|
|
setMap?: (map: AMapMap | null) => void;
|
|
on?: (eventName: string, handler: () => void) => void;
|
|
};
|
|
|
|
let amapLoaderPromise: Promise<AMapLike> | null = null;
|
|
|
|
function validPoint(point: VehicleMapPoint) {
|
|
return Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude !== 0 && point.latitude !== 0;
|
|
}
|
|
|
|
function loadScript(src: string) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
|
if (existing) {
|
|
existing.addEventListener('load', () => resolve(), { once: true });
|
|
existing.addEventListener('error', () => reject(new Error('高德地图 Loader 加载失败')), { once: true });
|
|
if (window.AMapLoader) resolve();
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.async = true;
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('高德地图 Loader 加载失败'));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function loadAMap() {
|
|
const config = getAMapConfig();
|
|
if (!isAMapConfigured(config)) {
|
|
return Promise.reject(new Error('高德地图未配置'));
|
|
}
|
|
if (!amapLoaderPromise) {
|
|
if (config.securityServiceHost) {
|
|
window._AMapSecurityConfig = { serviceHost: config.securityServiceHost };
|
|
} else if (config.securityJsCode) {
|
|
window._AMapSecurityConfig = { securityJsCode: config.securityJsCode };
|
|
}
|
|
amapLoaderPromise = loadScript('https://webapi.amap.com/loader.js').then(() => {
|
|
if (!window.AMapLoader) {
|
|
throw new Error('高德地图 Loader 不可用');
|
|
}
|
|
return window.AMapLoader.load({
|
|
key: config.webJsKey,
|
|
version: '2.0',
|
|
plugins: ['AMap.Scale']
|
|
}) as Promise<AMapLike>;
|
|
});
|
|
}
|
|
return amapLoaderPromise;
|
|
}
|
|
|
|
function pointToPixelStyle(point: VehicleMapPoint, index: number) {
|
|
if (!validPoint(point)) {
|
|
return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` };
|
|
}
|
|
const left = Math.min(88, Math.max(10, ((point.longitude + 180) / 360) * 100));
|
|
const top = Math.min(82, Math.max(12, ((90 - point.latitude) / 180) * 100));
|
|
return { left: `${left}%`, top: `${top}%` };
|
|
}
|
|
|
|
export function VehicleMap({
|
|
points,
|
|
mode = 'realtime',
|
|
heightClassName = 'vp-map-monitor',
|
|
fallbackLabel,
|
|
maxFallbackPoints = 120,
|
|
selectedId,
|
|
onPointSelect,
|
|
children
|
|
}: {
|
|
points: VehicleMapPoint[];
|
|
mode?: 'realtime' | 'track';
|
|
heightClassName?: string;
|
|
fallbackLabel?: string;
|
|
maxFallbackPoints?: number;
|
|
selectedId?: string;
|
|
onPointSelect?: (point: VehicleMapPoint) => void;
|
|
children?: ReactNode;
|
|
}) {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const mapRef = useRef<AMapMap | null>(null);
|
|
const overlaysRef = useRef<AMapOverlay[]>([]);
|
|
const [status, setStatus] = useState<'ready' | 'fallback' | 'loading'>('fallback');
|
|
const config = getAMapConfig();
|
|
const validPoints = useMemo(() => points.filter(validPoint), [points]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
if (!isAMapConfigured(config) || validPoints.length === 0 || !containerRef.current) {
|
|
setStatus('fallback');
|
|
return;
|
|
}
|
|
setStatus('loading');
|
|
loadAMap()
|
|
.then((AMap) => {
|
|
if (cancelled || !containerRef.current) return;
|
|
if (!mapRef.current) {
|
|
mapRef.current = new AMap.Map(containerRef.current, {
|
|
zoom: 11,
|
|
center: [validPoints[0].longitude, validPoints[0].latitude],
|
|
viewMode: '2D',
|
|
mapStyle: 'amap://styles/normal'
|
|
});
|
|
mapRef.current.addControl(new AMap.Scale());
|
|
}
|
|
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
|
|
const markers = validPoints.slice(0, 500).map((point, index) => {
|
|
const marker = new AMap.Marker({
|
|
position: [point.longitude, point.latitude],
|
|
title: point.title || point.label,
|
|
zIndex: point.id === selectedId ? 300 : mode === 'track' ? 100 + index : 100,
|
|
content: `<div class="vp-amap-marker ${point.online === false ? 'vp-amap-marker-offline' : 'vp-amap-marker-online'} ${point.id === selectedId ? 'vp-amap-marker-selected' : ''}">${mode === 'track' ? index + 1 : ''}</div>`
|
|
});
|
|
marker.on?.('click', () => onPointSelect?.(point));
|
|
return marker;
|
|
});
|
|
const nextOverlays: AMapOverlay[] = [...markers];
|
|
if (mode === 'track' && validPoints.length > 1) {
|
|
nextOverlays.push(new AMap.Polyline({
|
|
path: validPoints.map((point) => [point.longitude, point.latitude]),
|
|
strokeColor: '#1664ff',
|
|
strokeOpacity: 0.85,
|
|
strokeWeight: 5,
|
|
lineJoin: 'round'
|
|
}));
|
|
}
|
|
overlaysRef.current = nextOverlays;
|
|
mapRef.current.add(nextOverlays);
|
|
mapRef.current.setFitView(nextOverlays, false, [56, 56, 56, 56]);
|
|
setStatus('ready');
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setStatus('fallback');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [config.webJsKey, config.securityJsCode, config.securityServiceHost, mode, onPointSelect, selectedId, validPoints]);
|
|
|
|
useEffect(() => () => {
|
|
mapRef.current?.destroy();
|
|
mapRef.current = null;
|
|
overlaysRef.current = [];
|
|
}, []);
|
|
|
|
const fallbackPoints = (validPoints.length > 0 ? validPoints : points).slice(0, maxFallbackPoints);
|
|
const showFallback = status !== 'ready';
|
|
|
|
return (
|
|
<div className={`vp-map ${heightClassName}`}>
|
|
<div ref={containerRef} className="vp-amap-canvas" />
|
|
{showFallback ? (
|
|
<div className="vp-map-fallback">
|
|
{fallbackPoints.map((point, index) => (
|
|
<button
|
|
key={`${point.id}-${index}`}
|
|
type="button"
|
|
aria-label={`选择地图车辆 ${point.label}`}
|
|
className={`vp-map-dot ${mode === 'track' && index === 0 ? 'vp-map-dot-start' : mode === 'track' && index === fallbackPoints.length - 1 ? 'vp-map-dot-end' : point.online === false ? 'vp-map-dot-offline' : 'vp-map-dot-online'} ${point.id === selectedId ? 'vp-map-dot-selected' : ''}`}
|
|
title={point.title || point.label}
|
|
style={pointToPixelStyle(point, index)}
|
|
onClick={() => onPointSelect?.(point)}
|
|
/>
|
|
))}
|
|
<Tag className="vp-map-fallback-status" color={isAMapConfigured(config) ? 'blue' : 'orange'}>
|
|
{status === 'loading' ? '地图加载中' : fallbackLabel || '高德地图未配置,显示坐标预览'}
|
|
</Tag>
|
|
</div>
|
|
) : null}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|