import { IconEyeClosed, IconEyeOpened, IconMapPin, IconRefresh } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { getAMapConfig, isAMapConfigured } from '../../config/appConfig'; import { gcj02ToWgs84, isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapMap, type AMapLabelsLayer, type AMapLike, type AMapMassMarks, type AMapMassPoint, type AMapOverlay } from '../../integrations/amap'; import type { MonitorMapResponse, VehicleRealtimeRow } from '../../api/types'; import { vehicleStatus } from '../domain/monitor'; import type { MonitorViewport } from '../hooks/useMonitorData'; const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444']; const CLUSTER_VISUAL_CACHE_LIMIT = 256; const DEFAULT_POINT_MOVE_DURATION_MS = 15_000; const MIN_POINT_MOVE_DURATION_MS = 1_000; const MAX_POINT_MOVE_DURATION_MS = 120_000; const MIN_POINT_MOVE_FRAME_MS = 100; const MAX_POINT_MOVE_FRAME_MS = 220; const POINT_MOVE_EPSILON = 0.000003; const MAP_FOLLOW_FRAME_MS = 500; const MAP_INTERACTION_PAN_DURATION_MS = 650; const clusterVisualCache = new Map(); function dotDataUrl(color: string) { const svg = ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } function clusterVisual(count: number) { const cached = clusterVisualCache.get(count); if (cached) { clusterVisualCache.delete(count); clusterVisualCache.set(count, cached); return cached; } const label = count.toLocaleString('en-US'); const diameter = Math.min(54, 34 + Math.max(0, label.length - 1) * 4); const center = diameter / 2; const fontSize = label.length >= 6 ? 9 : label.length >= 4 ? 10 : 11; const svg = `${label}`; const visual = { diameter, url: `data:image/svg+xml,${encodeURIComponent(svg)}` }; if (clusterVisualCache.size >= CLUSTER_VISUAL_CACHE_LIMIT) { const oldest = clusterVisualCache.keys().next().value; if (oldest !== undefined) clusterVisualCache.delete(oldest); } clusterVisualCache.set(count, visual); return visual; } function viewportFromMap(map: AMapMap): MonitorViewport | null { const zoom = Math.round(map.getZoom?.() ?? 5); const bounds = map.getBounds?.(); const southWest = bounds?.getSouthWest?.(); const northEast = bounds?.getNorthEast?.(); const values = [southWest?.getLng?.(), southWest?.getLat?.(), northEast?.getLng?.(), northEast?.getLat?.()]; if (values.some((value) => !Number.isFinite(value))) return { zoom, bounds: '' }; const west = Number(values[0]); const south = Number(values[1]); const east = Number(values[2]); const north = Number(values[3]); const wgsCorners = [ gcj02ToWgs84(west, south), gcj02ToWgs84(west, north), gcj02ToWgs84(east, south), gcj02ToWgs84(east, north) ]; const longitudes = wgsCorners.map(([longitude]) => longitude); const latitudes = wgsCorners.map(([, latitude]) => latitude); return { zoom, bounds: [Math.min(...longitudes), Math.min(...latitudes), Math.max(...longitudes), Math.max(...latitudes)] .map((value) => value.toFixed(6)).join(',') }; } function styleIndex(vehicle: VehicleRealtimeRow) { const status = vehicleStatus(vehicle); return statusStyleIndex(status); } function statusStyleIndex(status: string) { if (status === 'driving') return 2; if (status === 'idle') return 0; if (status === 'offline') return 1; if (status === 'alert') return 4; return 3; } function escapeHtml(value: string) { return value.replace(/[&<>'"]/g, (character) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' })[character] ?? character); } type PlateLabelPoint = { vin: string; plate: string; longitude: number; latitude: number; }; type LabelMarkerEntry = { marker: AMapOverlay; signature: string; position: [number, number]; }; const NO_VEHICLE_POINTS: VehicleRealtimeRow[] = []; function createPointFingerprint() { let left = 0x811c9dc5; let right = 0x9e3779b9; let fields = 0; const add = (value: unknown) => { const text = String(value ?? ''); for (let index = 0; index < text.length; index += 1) { const code = text.charCodeAt(index); left = Math.imul(left ^ code, 0x01000193); right = Math.imul(right ^ code, 0x85ebca6b); } left = Math.imul(left ^ 31, 0x01000193); right = Math.imul(right ^ 127, 0x85ebca6b); fields += 1; }; const value = () => `${fields}:${(left >>> 0).toString(36)}:${(right >>> 0).toString(36)}`; return { add, value }; } export function massPointFingerprint( mode: MonitorMapResponse['mode'] | undefined, clusters: MonitorMapResponse['clusters'] | undefined, mapPoints: MonitorMapResponse['points'] | undefined, points: VehicleRealtimeRow[] ) { const fingerprint = createPointFingerprint(); fingerprint.add(mode ?? 'vehicles'); if (mode) { const clusterRows = clusters ?? []; const pointRows = mapPoints ?? []; fingerprint.add(clusterRows.length); for (const cluster of clusterRows) { fingerprint.add(cluster.id); fingerprint.add(cluster.longitude); fingerprint.add(cluster.latitude); fingerprint.add(cluster.count); } fingerprint.add(pointRows.length); for (const point of pointRows) { fingerprint.add(point.vin); fingerprint.add(point.longitude); fingerprint.add(point.latitude); fingerprint.add(point.status); fingerprint.add(point.plate || ''); fingerprint.add(point.reportIntervalMs ?? ''); } } else { fingerprint.add(points.length); for (const point of points) { fingerprint.add(point.vin); fingerprint.add(point.longitude); fingerprint.add(point.latitude); fingerprint.add(vehicleStatus(point)); fingerprint.add(point.plate || ''); fingerprint.add(point.reportIntervalMs ?? ''); } } return fingerprint.value(); } function labelMarkerSignature(point: PlateLabelPoint, selected: boolean, placement?: { direction: 'left' | 'right'; textOffset: [number, number] }) { return `${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`; } function interpolateMassPointsFrom(previousByID: Map, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set) { return target.map((point) => { if (eligibleIDs && !eligibleIDs.has(point.id)) return point; const start = previousByID.get(point.id); if (!start || start.lnglat[0] === point.lnglat[0] && start.lnglat[1] === point.lnglat[1]) return point; const progress = Math.min(1, Math.max(0, progressFor(point.id))); return { ...point, lnglat: [ start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * progress, start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * progress ] as [number, number] }; }); } export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: number) { return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, () => progress); } export function pointMoveDurationMs(reportIntervalMs?: number) { if (!Number.isFinite(reportIntervalMs) || Number(reportIntervalMs) <= 0) return DEFAULT_POINT_MOVE_DURATION_MS; return Math.min(MAX_POINT_MOVE_DURATION_MS, Math.max(MIN_POINT_MOVE_DURATION_MS, Math.round(Number(reportIntervalMs)))); } export type MutablePointMotion = { point: AMapMassPoint; start: [number, number]; target: [number, number]; durationMs: number; }; export function advancePointMotions(motions: MutablePointMotion[], elapsedMs: number) { for (const motion of motions) { const progress = Math.min(1, Math.max(0, elapsedMs / motion.durationMs)); motion.point.lnglat[0] = motion.start[0] + (motion.target[0] - motion.start[0]) * progress; motion.point.lnglat[1] = motion.start[1] + (motion.target[1] - motion.start[1]) * progress; } } export function pointMoveFrameMs(totalPoints: number, movingPoints: number) { if (totalPoints <= 2) return MIN_POINT_MOVE_FRAME_MS; const pressure = Math.max(totalPoints / 800, movingPoints / 120); return Math.round(Math.min(MAX_POINT_MOVE_FRAME_MS, MIN_POINT_MOVE_FRAME_MS + pressure * 120)); } function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) { const previousByID = new Map(previous.map((point) => [point.id, point.lnglat])); const moving = new Set(); for (const point of target) { const start = previousByID.get(point.id); if (start && (Math.abs(start[0] - point.lnglat[0]) >= POINT_MOVE_EPSILON || Math.abs(start[1] - point.lnglat[1]) >= POINT_MOVE_EPSILON)) moving.add(point.id); } return moving; } function selectionStatus(target: PlateLabelPoint & { status?: string; online?: boolean; speedKmh?: number }) { const status = target.status || vehicleStatus(target as VehicleRealtimeRow); return ['driving', 'idle', 'offline', 'alert'].includes(status) ? status : 'unknown'; } function densePlatePlacements(points: PlateLabelPoint[]) { const buckets = new Map(); for (const point of points) { const key = `${Math.round(point.longitude / 0.00018)}:${Math.round(point.latitude / 0.00008)}`; const bucket = buckets.get(key); if (bucket) bucket.push(point); else buckets.set(key, [point]); } const placements = new Map(); for (const bucket of buckets.values()) { bucket.sort((left, right) => left.vin.localeCompare(right.vin)); bucket.forEach((point, index) => { const column = Math.floor(index / 7); const row = index % 7; const rowsInColumn = Math.min(7, bucket.length - column * 7); const direction = column % 2 === 0 ? 'right' : 'left'; placements.set(point.vin, { direction, textOffset: [8 + Math.floor(column / 2) * 78, (row - (rowsInColumn - 1) / 2) * 23] }); }); } return placements; } export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange }: { vehicles: VehicleRealtimeRow[]; selectedVin?: string; onSelect: (vehicle: VehicleRealtimeRow) => void; monitorMap?: MonitorMapResponse; onSelectVin?: (vin: string) => void; onViewportChange?: (viewport: MonitorViewport) => void; }) { const containerRef = useRef(null); const mapRef = useRef(null); const amapRef = useRef(null); const massRef = useRef(null); const labelsRef = useRef(null); const denseLabelsRef = useRef(null); const selectionRef = useRef(null); const labelMarkersRef = useRef(new Map()); const labelLayerModeRef = useRef<'normal' | 'dense' | ''>(''); const labelLayerStateRef = useRef(''); const massDataSignatureRef = useRef('__unset__'); const massStyleSignatureRef = useRef('__unset__'); const renderedMassDataRef = useRef([]); const pointAnimationFrameRef = useRef(undefined); const pointAnimationTimerRef = useRef(undefined); const movingPointIDsRef = useRef(new Set()); const onSelectRef = useRef(onSelect); const onSelectVinRef = useRef(onSelectVin); const onViewportChangeRef = useRef(onViewportChange); const vehiclesRef = useRef(new Map()); const clustersRef = useRef(new Map()); const viewportTimerRef = useRef(undefined); const selectionKeyRef = useRef(''); const selectionPositionRef = useRef(''); const centeredVinRef = useRef(''); const followSelectedRef = useRef(true); const selectedVinRef = useRef(selectedVin); const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading'); const [loadAttempt, setLoadAttempt] = useState(0); const [showLabels, setShowLabels] = useState(true); const [followSelected, setFollowSelected] = useState(true); const [mapZoom, setMapZoom] = useState(5); const points = useMemo(() => vehicles.filter((vehicle) => isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)), [vehicles]); const monitorMode = monitorMap?.mode; const monitorPoints = monitorMap?.points; const monitorClusters = monitorMap?.clusters; const fallbackPoints = monitorMode ? NO_VEHICLE_POINTS : points; const selectedTarget = useMemo(() => selectedVin ? monitorPoints?.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin) : undefined, [monitorPoints, points, selectedVin]); const renderedPointCount = monitorPoints?.length ?? fallbackPoints.length; const renderedClusterCount = monitorClusters?.length ?? 0; const mapComposition = monitorMap && renderedClusterCount > 0 ? `${renderedClusterCount} 个聚合 · ${renderedPointCount} 个车辆点 · ${monitorMap.total} 辆` : `${renderedPointCount} 个有效点位`; const initialSelectionRef = useRef(points.find((vehicle) => vehicle.vin === selectedVin)); useEffect(() => { vehiclesRef.current = new Map(points.map((vehicle) => [vehicle.vin, vehicle])); }, [points, state]); useEffect(() => { onSelectRef.current = onSelect; onSelectVinRef.current = onSelectVin; onViewportChangeRef.current = onViewportChange; selectedVinRef.current = selectedVin; }, [onSelect, onSelectVin, onViewportChange, selectedVin]); useEffect(() => { if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; } let cancelled = false; let resizeTimer: number | undefined; let resizeObserver: ResizeObserver | undefined; let mapInstance: AMapMap | undefined; let massInstance: AMapMassMarks | undefined; let selectMassPoint: ((event: { data: AMapMassPoint }) => void) | undefined; let notifyViewport: (() => void) | undefined; let stopFollowing: (() => void) | undefined; setState('loading'); loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => { if (cancelled || !containerRef.current) return; const initialSelection = initialSelectionRef.current; const initialCenter = initialSelection ? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude) : wgs84ToGcj02(105.4, 35.9); const map = new AMap.Map(containerRef.current, { zoom: initialSelection ? 13 : 5, center: initialCenter, viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true }); mapInstance = map; map.addControl(new AMap.Scale()); if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } })); const styles = [ ...COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) })) ]; const mass = new AMap.MassMarks([], { opacity: 0.96, zIndex: 120, cursor: 'pointer', style: styles, zooms: [3, 20] }); massInstance = mass; selectMassPoint = (event: { data: AMapMassPoint }) => { const cluster = clustersRef.current.get(event.data.id); if (cluster) { map.setZoomAndCenter?.(Math.min(20, (map.getZoom?.() ?? 5) + 2), wgs84ToGcj02(cluster.longitude, cluster.latitude)); return; } if (onSelectVinRef.current) { onSelectVinRef.current(event.data.id); return; } const vehicle = vehiclesRef.current.get(event.data.id); if (vehicle) onSelectRef.current(vehicle); }; mass.on('click', selectMassPoint); mass.setMap(map); const labels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [11, 18.99], zIndex: 110, collision: true, allowCollision: false }) : null; const denseLabels = AMap.LabelsLayer ? new AMap.LabelsLayer({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }) : null; labels?.setMap(map); notifyViewport = () => { setMapZoom(map.getZoom?.() ?? 5); window.clearTimeout(viewportTimerRef.current); viewportTimerRef.current = window.setTimeout(() => { const viewport = viewportFromMap(map); if (viewport) onViewportChangeRef.current?.(viewport); }, 300); }; map.on?.('moveend', notifyViewport); map.on?.('zoomend', notifyViewport); stopFollowing = () => { if (!centeredVinRef.current) return; followSelectedRef.current = false; setFollowSelected(false); }; map.on?.('dragstart', stopFollowing); mapRef.current = map; amapRef.current = AMap; massRef.current = mass; labelsRef.current = labels; denseLabelsRef.current = denseLabels; if (typeof ResizeObserver !== 'undefined') { resizeObserver = new ResizeObserver(() => { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(() => map.resize?.(), 80); }); resizeObserver.observe(containerRef.current); } setState('ready'); notifyViewport(); }).catch(() => { if (!cancelled) setState('error'); }); return () => { cancelled = true; resizeObserver?.disconnect(); window.clearTimeout(resizeTimer); window.clearTimeout(viewportTimerRef.current); if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); window.clearTimeout(pointAnimationTimerRef.current); pointAnimationFrameRef.current = undefined; pointAnimationTimerRef.current = undefined; movingPointIDsRef.current.clear(); if (selectMassPoint) massInstance?.off?.('click', selectMassPoint); if (notifyViewport) { mapInstance?.off?.('moveend', notifyViewport); mapInstance?.off?.('zoomend', notifyViewport); } if (stopFollowing) mapInstance?.off?.('dragstart', stopFollowing); massRef.current?.setMap(null); labelsRef.current?.clear(); denseLabelsRef.current?.clear(); labelsRef.current?.setMap(null); denseLabelsRef.current?.setMap(null); selectionRef.current?.setMap?.(null); mapRef.current?.destroy(); massRef.current = null; labelsRef.current = null; denseLabelsRef.current = null; selectionRef.current = null; labelMarkersRef.current.clear(); labelLayerModeRef.current = ''; labelLayerStateRef.current = ''; massDataSignatureRef.current = '__unset__'; massStyleSignatureRef.current = '__unset__'; renderedMassDataRef.current = []; amapRef.current = null; mapRef.current = null; }; }, [loadAttempt]); useEffect(() => { const mass = massRef.current; const AMap = amapRef.current; if (!mass || !AMap) return; const clusters = monitorClusters ?? []; const mapPoints = monitorPoints ?? []; clustersRef.current = new Map(clusters.map((cluster) => [cluster.id, cluster])); const clusterCounts = [...new Set(clusters.map((cluster) => cluster.count))].sort((a, b) => a - b); const styleSignature = clusterCounts.join(','); if (massStyleSignatureRef.current !== styleSignature) { const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) })); const clusterStyles = clusterCounts.map((count) => { const visual = clusterVisual(count); return { url: visual.url, anchor: new AMap.Pixel(visual.diameter / 2, visual.diameter / 2), size: new AMap.Size(visual.diameter, visual.diameter) }; }); mass.setStyle?.([...baseStyles, ...clusterStyles]); massStyleSignatureRef.current = styleSignature; } const dataSignature = massPointFingerprint(monitorMode, monitorClusters, monitorPoints, fallbackPoints); if (massDataSignatureRef.current === dataSignature) return; const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index])); const data: AMapMassPoint[] = monitorMode ? [ ...clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count} 辆` })), ...mapPoints.map((point) => ({ lnglat: wgs84ToGcj02(point.longitude, point.latitude), style: point.status === 'driving' ? 2 : point.status === 'idle' ? 0 : point.status === 'offline' ? 1 : 3, id: point.vin, label: point.plate || point.vin })) ] : fallbackPoints.map((vehicle) => ({ lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin })); const moveDurationByID = new Map((monitorMode ? mapPoints : fallbackPoints) .map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)])); massDataSignatureRef.current = dataSignature; if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); window.clearTimeout(pointAnimationTimerRef.current); pointAnimationFrameRef.current = undefined; pointAnimationTimerRef.current = undefined; const previousData = renderedMassDataRef.current; const previousDataByID = new Map(previousData.map((point) => [point.id, point])); const vehiclePointIDs = new Set(monitorMode ? mapPoints.map((point) => point.vin) : fallbackPoints.map((point) => point.vin)); const movingIDs = new Set([...movingPointIDs(previousData, data)].filter((id) => vehiclePointIDs.has(id))); movingPointIDsRef.current = movingIDs; if (!movingIDs.size || previousData.length === 0 || typeof window.requestAnimationFrame !== 'function' || document.visibilityState === 'hidden') { mass.setData(data); renderedMassDataRef.current = data; movingPointIDsRef.current.clear(); return; } const dataByID = new Map(data.map((point) => [point.id, point])); const motions: MutablePointMotion[] = []; for (const id of movingIDs) { const point = dataByID.get(id); const previous = previousDataByID.get(id); if (!point || !previous) continue; const target: [number, number] = [point.lnglat[0], point.lnglat[1]]; point.lnglat = [previous.lnglat[0], previous.lnglat[1]]; motions.push({ point, start: [previous.lnglat[0], previous.lnglat[1]], target, durationMs: moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS }); } let startedAt: number | undefined; const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS)); const frameMs = pointMoveFrameMs(data.length, motions.length); let lastFollowAt = 0; let selectedFollowComplete = false; const paint = (elapsed: number) => { advancePointMotions(motions, elapsed); mass.setData(data); renderedMassDataRef.current = data; for (const motion of motions) { const label = labelMarkersRef.current.get(motion.point.id); label?.marker.setPosition?.(motion.point.lnglat); if (label) label.position = motion.point.lnglat; if (selectedVinRef.current === motion.point.id) { selectionRef.current?.setPosition?.(motion.point.lnglat); const motionComplete = elapsed >= motion.durationMs; if (followSelectedRef.current && !selectedFollowComplete && (elapsed - lastFollowAt >= MAP_FOLLOW_FRAME_MS || motionComplete)) { mapRef.current?.setCenter?.(motion.point.lnglat); lastFollowAt = elapsed; selectedFollowComplete = motionComplete; } } } }; const schedule = () => { pointAnimationTimerRef.current = window.setTimeout(() => { pointAnimationTimerRef.current = undefined; pointAnimationFrameRef.current = window.requestAnimationFrame(animate); }, frameMs); }; const animate = (timestamp: number) => { pointAnimationFrameRef.current = undefined; startedAt ??= timestamp; const elapsed = timestamp - startedAt; const complete = elapsed >= longestMoveDuration || document.visibilityState === 'hidden'; paint(complete ? longestMoveDuration : elapsed); if (!complete) schedule(); else { movingPointIDsRef.current.clear(); } }; pointAnimationFrameRef.current = window.requestAnimationFrame(animate); }, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, state]); useEffect(() => { const labels = labelsRef.current; const denseLabels = denseLabelsRef.current; const AMap = amapRef.current; const map = mapRef.current; if (!labels || !denseLabels || !AMap?.LabelMarker || !map) return; const mapLabelPoints = (monitorPoints ? monitorPoints : fallbackPoints.map((vehicle) => ({ ...vehicle, status: vehicleStatus(vehicle) }))); const allLabelPoints = selectedTarget && !mapLabelPoints.some((point) => point.vin === selectedTarget.vin) ? [...mapLabelPoints, selectedTarget] : mapLabelPoints; const labelPoints = showLabels ? allLabelPoints : allLabelPoints.filter((point) => point.vin === selectedVin); const showEveryPlate = mapZoom >= 19; const layerMode = showEveryPlate ? 'dense' : 'normal'; const activeLabels = showEveryPlate ? denseLabels : labels; if (labelLayerModeRef.current && labelLayerModeRef.current !== layerMode) { (labelLayerModeRef.current === 'dense' ? denseLabels : labels).clear(); labelMarkersRef.current.clear(); } labelLayerModeRef.current = layerMode; const layerState = `${layerMode}:${labelPoints.length > 0 ? 'visible' : 'hidden'}`; if (labelLayerStateRef.current !== layerState) { labels.setMap(layerMode === 'normal' && labelPoints.length ? map : null); denseLabels.setMap(layerMode === 'dense' && labelPoints.length ? map : null); labelLayerStateRef.current = layerState; } const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null; const nextMarkers = new Map(); const markersToAdd: AMapOverlay[] = []; for (const point of labelPoints) { const placement = densePlacements?.get(point.vin); const signature = labelMarkerSignature(point, point.vin === selectedVin, placement); const existing = labelMarkersRef.current.get(point.vin); if (existing?.signature === signature) { const position = wgs84ToGcj02(point.longitude, point.latitude); if (!movingPointIDsRef.current.has(point.vin) && (existing.position[0] !== position[0] || existing.position[1] !== position[1])) { existing.marker.setPosition?.(position); existing.position = position; } nextMarkers.set(point.vin, existing); continue; } const position = wgs84ToGcj02(point.longitude, point.latitude); const marker = new AMap.LabelMarker!({ name: point.plate || point.vin, position, rank: point.vin === selectedVin ? 100 : 1, zIndex: point.vin === selectedVin ? 10 : 1, text: { content: point.plate || point.vin, direction: placement?.direction ?? 'right', offset: placement?.textOffset ?? [8, 0], style: { fontSize: 11, fontWeight: 700, fillColor: '#174d9f', strokeColor: 'transparent', strokeWidth: 0, padding: [5, 9], backgroundColor: '#eef5ff', borderColor: '#7fb0fa', borderWidth: 1, borderRadius: 6, shadowColor: 'rgba(18, 104, 243, 0.18)', shadowBlur: 14, shadowOffsetY: 5 } } }); nextMarkers.set(point.vin, { marker, signature, position }); markersToAdd.push(marker); } const markersToRemove = [...labelMarkersRef.current.entries()] .filter(([vin, entry]) => nextMarkers.get(vin) !== entry) .map(([, entry]) => entry.marker); if (markersToRemove.length) activeLabels.remove(markersToRemove); if (markersToAdd.length) activeLabels.add(markersToAdd); labelMarkersRef.current = nextMarkers; }, [fallbackPoints, mapZoom, monitorPoints, selectedTarget, selectedVin, showLabels, state]); useEffect(() => { if (!selectedVin || !mapRef.current || !amapRef.current) { selectionRef.current?.setMap?.(null); selectionRef.current = null; selectionKeyRef.current = ''; selectionPositionRef.current = ''; centeredVinRef.current = ''; followSelectedRef.current = false; setFollowSelected(false); return; } const target = selectedTarget; if (!target) return; const label = escapeHtml(target.plate || target.vin); const status = selectionStatus(target); const mapPosition = wgs84ToGcj02(target.longitude, target.latitude); const selectionKey = `${selectedVin}|${label}|${status}`; const positionKey = `${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`; if (centeredVinRef.current !== selectedVin) { followSelectedRef.current = true; setFollowSelected(true); const currentZoom = mapRef.current.getZoom?.() ?? 15; if (currentZoom < 15) mapRef.current.setZoomAndCenter?.(15, mapPosition); else mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS); centeredVinRef.current = selectedVin; } else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current && !movingPointIDsRef.current.has(selectedVin)) { mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS); } const previousPositionKey = selectionPositionRef.current; selectionPositionRef.current = positionKey; if (selectionKeyRef.current === selectionKey && selectionRef.current) { if (previousPositionKey !== positionKey && !movingPointIDsRef.current.has(selectedVin)) selectionRef.current.setPosition?.(mapPosition); return; } selectionRef.current?.setMap?.(null); selectionRef.current = null; const marker = new amapRef.current.Marker({ position: mapPosition, offset: new amapRef.current.Pixel(-24, -24), zIndex: 300, content: `
` }); marker.setMap?.(mapRef.current); selectionRef.current = marker; selectionKeyRef.current = selectionKey; }, [selectedTarget, selectedVin, state]); const toggleFollow = () => { const next = !followSelected; followSelectedRef.current = next; setFollowSelected(next); if (next && selectedTarget && mapRef.current) { mapRef.current.panTo?.(wgs84ToGcj02(selectedTarget.longitude, selectedTarget.latitude), MAP_INTERACTION_PAN_DURATION_MS); } }; return (
{selectedVin ? ( ) : null}
{state !== 'ready' ? (
{state === 'loading' ? <>高德地图加载中 : null} {state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null} {state === 'error' ? <>地图加载失败,请检查高德 Key、域名白名单和网络 : null}
) : null}
行驶 静止 离线 告警 {mapComposition}
); }