Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
T

163 lines
9.2 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryLocationRow, TrackStop } from '../../api/types';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
import { Button } from '@douyinfe/semi-ui';
import { IconList } from '@douyinfe/semi-icons';
import { MapRetryAction } from '../shared/RecoveryActions';
function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker" aria-hidden="true"><i></i><span></span></div>';
return `<div class="v2-track-marker is-${kind}"><span>${label ?? ''}</span></div>`;
}
export function TrackMap({ points, stops, activeIndex, showStops, follow, followDurationMs = 180, onSelectIndex, onFollowChange, onOpenEvidence }: {
points: HistoryLocationRow[];
stops: TrackStop[];
activeIndex: number;
showStops: boolean;
follow: boolean;
followDurationMs?: number;
onSelectIndex: (index: number) => void;
onFollowChange: (follow: boolean) => void;
onOpenEvidence?: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const overlaysRef = useRef<AMapOverlay[]>([]);
const overlayListenersRef = useRef<Array<{ overlay: AMapOverlay; handler: () => void }>>([]);
const currentMarkerRef = useRef<AMapOverlay | null>(null);
const passedPathRef = useRef<AMapOverlay | null>(null);
const pathRef = useRef<[number, number][]>([]);
const selectRef = useRef(onSelectIndex);
const followChangeRef = useRef(onFollowChange);
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
const hasValidPoints = valid.length > 0;
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'fallback' | 'error'>(() => hasValidPoints ? 'loading' : 'idle');
const [loadAttempt, setLoadAttempt] = useState(0);
const passedCountByPoint = useMemo(() => {
const counts = new Uint16Array(points.length);
let validIndex = 0;
for (let pointIndex = 0; pointIndex < points.length; pointIndex += 1) {
while (validIndex < valid.length && valid[validIndex].index <= pointIndex) validIndex += 1;
counts[pointIndex] = validIndex;
}
return counts;
}, [points.length, valid]);
useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]);
useEffect(() => { followChangeRef.current = onFollowChange; }, [onFollowChange]);
useEffect(() => {
if (!hasValidPoints) { setState('idle'); return; }
setState('loading');
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
let cancelled = false;
let mapInstance: AMapMap | undefined;
let stopFollowing: (() => void) | undefined;
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return;
const first = valid[0]?.point;
const map = new AMap.Map(containerRef.current, {
zoom: first ? 13 : 5,
center: first ? wgs84ToGcj02(first.longitude, first.latitude) : wgs84ToGcj02(105.4, 35.9),
viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true,
zooms: [3, 20]
});
mapInstance = map;
map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '148px' } }));
stopFollowing = () => followChangeRef.current(false);
map.on?.('dragstart', stopFollowing);
mapRef.current = map;
amapRef.current = AMap;
setState('ready');
}).catch(() => { if (!cancelled) setState('error'); });
return () => {
cancelled = true;
if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing);
overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler));
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
mapRef.current?.destroy();
overlaysRef.current = [];
overlayListenersRef.current = [];
currentMarkerRef.current = null;
passedPathRef.current = null;
pathRef.current = [];
mapRef.current = null;
amapRef.current = null;
};
}, [hasValidPoints, loadAttempt]);
useEffect(() => {
const AMap = amapRef.current;
const map = mapRef.current;
if (state !== 'ready' || !map || !valid.length || !AMap) return;
overlayListenersRef.current.forEach(({ overlay, handler }) => overlay.off?.('click', handler));
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
overlayListenersRef.current = [];
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
pathRef.current = path;
const nextValidIndex = valid.findIndex(({ index }) => index >= activeIndex);
const activeValidIndex = nextValidIndex >= 0 ? nextValidIndex : valid.length - 1;
const fullPath = new AMap.Polyline({ path, strokeColor: '#2563eb', strokeWeight: 6, strokeOpacity: 0.82, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 70 });
const passedPath = new AMap.Polyline({ path: path.slice(0, activeValidIndex + 1), strokeColor: '#18a86b', strokeWeight: 7, strokeOpacity: 0.96, lineJoin: 'round', lineCap: 'round', zIndex: 80 });
passedPathRef.current = passedPath;
const first = valid[0];
const last = valid[valid.length - 1];
const start = new AMap.Marker({ position: path[0], anchor: 'center', content: markerContent('start', '始'), zIndex: 115 });
const end = new AMap.Marker({ position: path[path.length - 1], anchor: 'center', content: markerContent('end', '终'), zIndex: 115 });
const startHandler = () => selectRef.current(first.index);
const endHandler = () => selectRef.current(last.index);
start.on?.('click', startHandler);
end.on?.('click', endHandler);
const overlays: AMapOverlay[] = [fullPath, passedPath, start, end];
const overlayListeners = [{ overlay: start, handler: startHandler }, { overlay: end, handler: endHandler }];
if (showStops) stops.slice(0, 80).forEach((stop, index) => {
if (!isValidAMapCoordinate(stop.longitude, stop.latitude)) return;
const marker = new AMap.Marker({
position: wgs84ToGcj02(stop.longitude, stop.latitude), anchor: 'center',
content: markerContent('stop', String(index + 1)), zIndex: 105
});
const stopHandler = () => selectRef.current(Math.min(points.length - 1, Math.max(0, stop.sampledIndex)));
marker.on?.('click', stopHandler);
overlays.push(marker);
overlayListeners.push({ overlay: marker, handler: stopHandler });
});
const active = valid.find(({ index }) => index === activeIndex) ?? first;
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
currentMarkerRef.current = current;
overlaysRef.current = overlays;
overlayListenersRef.current = overlayListeners;
map.add([...overlays, current]);
map.setFitView([fullPath], false, [72, 72, 168, 72]);
}, [points, showStops, state, stops, valid]);
useEffect(() => {
const point = points[activeIndex];
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
const position = wgs84ToGcj02(point.longitude, point.latitude);
currentMarkerRef.current?.setPosition?.(position);
const passedCount = passedCountByPoint[activeIndex] ?? 0;
passedPathRef.current?.setPath?.(pathRef.current.slice(0, Math.max(1, passedCount)));
if (follow) mapRef.current?.panTo?.(position, followDurationMs);
}, [activeIndex, follow, followDurationMs, passedCountByPoint, points]);
return <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
{state !== 'ready' && (state !== 'idle' || points.length > 0) ? <div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" />轨迹地图加载中</> : null}
{state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null}
{state === 'fallback' ? <><strong>地图暂不可用</strong><span>已保留 {valid.length} 个有效轨迹点,可继续查看时间、来源与事件证据。</span>{onOpenEvidence ? <Button className="v2-map-evidence-action" theme="light" type="primary" icon={<IconList />} aria-label="查看轨迹证据" onClick={onOpenEvidence}>查看轨迹证据</Button> : null}</> : null}
{state === 'error' ? <><strong>地图加载失败</strong><span>轨迹证据仍可使用;可重试地图或直接查看来源与事件。</span><div className="v2-map-state-actions"><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} />{onOpenEvidence ? <Button className="v2-map-evidence-action" theme="light" type="primary" icon={<IconList />} aria-label="查看轨迹证据" onClick={onOpenEvidence}>查看轨迹证据</Button> : null}</div></> : null}
</div> : null}
</div>;
}