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 ''; return `
${label ?? ''}
`; } 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(null); const mapRef = useRef(null); const amapRef = useRef(null); const overlaysRef = useRef([]); const overlayListenersRef = useRef void }>>([]); const currentMarkerRef = useRef(null); const passedPathRef = useRef(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
{state !== 'ready' && (state !== 'idle' || points.length > 0) ?
{state === 'loading' ? <>轨迹地图加载中 : null} {state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null} {state === 'fallback' ? <>地图暂不可用已保留 {valid.length} 个有效轨迹点,可继续查看时间、来源与事件证据。{onOpenEvidence ? : null} : null} {state === 'error' ? <>地图加载失败轨迹证据仍可使用;可重试地图或直接查看来源与事件。
setLoadAttempt((value) => value + 1)} />{onOpenEvidence ? : null}
: null}
: null}
; }