diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index c9a50ac5..038fe9d6 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, expect, test, vi } from 'vitest'; import type { MonitorMapResponse } from '../../api/types'; import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap'; -import { FleetMap, massPointFingerprint } from './FleetMap'; +import { FleetMap, interpolateMassPoints, massPointFingerprint } from './FleetMap'; const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setStyle = vi.fn(); @@ -12,6 +12,7 @@ const clearLabels = vi.fn(); const setLabelsMap = vi.fn(); const markerSetMap = vi.fn(); const markerSetPosition = vi.fn(); +const labelSetPosition = vi.fn(); const setZoomAndCenter = vi.fn(); const panTo = vi.fn(); const mapOff = vi.fn(); @@ -61,6 +62,7 @@ class TestLabelsLayer { setMap = setLabelsMap; } class TestLabelMarker { + setPosition = labelSetPosition; constructor(public options: Record) {} } class TestMarker { @@ -141,6 +143,21 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint); }); +test('interpolates existing mass points with easing while keeping new points stable', () => { + const previous: AMapMassPoint[] = [{ id: 'moving', label: 'A', style: 0, lnglat: [10, 20] }]; + const target: AMapMassPoint[] = [ + { id: 'moving', label: 'A', style: 2, lnglat: [14, 24] }, + { id: 'new', label: 'B', style: 1, lnglat: [30, 40] } + ]; + + expect(interpolateMassPoints(previous, target, 0)).toEqual([ + { ...target[0], lnglat: [10, 20] }, + target[1] + ]); + expect(interpolateMassPoints(previous, target, .5)[0]).toEqual({ ...target[0], lnglat: [13.5, 23.5] }); + expect(interpolateMassPoints(previous, target, 1)).toEqual(target); +}); + function amapMock(): AMapLike { return { Map: TestMap as unknown as AMapLike['Map'], @@ -168,6 +185,7 @@ afterEach(() => { setLabelsMap.mockReset(); markerSetMap.mockReset(); markerSetPosition.mockReset(); + labelSetPosition.mockReset(); setZoomAndCenter.mockReset(); panTo.mockReset(); mapOff.mockReset(); @@ -237,7 +255,25 @@ test('detaches AMap listeners and destroys the map on unmount', async () => { expect(mapDestroy).toHaveBeenCalledTimes(1); }); -test('skips unchanged refresh redraws and replaces only a moved vehicle label', async () => { +test('cancels an in-flight point animation when the map unmounts', async () => { + window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; + window.AMapLoader = { load: vi.fn(async () => amapMock()) }; + const requestFrame = vi.spyOn(window, 'requestAnimationFrame'); + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame'); + const view = render( undefined} />); + await waitFor(() => expect(setData).toHaveBeenCalled()); + + view.rerender( undefined} />); + await waitFor(() => expect(requestFrame).toHaveBeenCalled()); + view.unmount(); + + expect(cancelFrame).toHaveBeenCalledWith(expect.any(Number)); +}); + +test('skips unchanged refresh redraws and smoothly moves points without replacing their labels', async () => { window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' }; window.AMapLoader = { load: vi.fn(async () => amapMock()) }; const view = render( undefined} />); @@ -263,13 +299,15 @@ test('skips unchanged refresh redraws and replaces only a moved vehicle label', asOf: '2026-07-14T01:00:30Z', points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]] }} onSelect={() => undefined} />); - await waitFor(() => expect(setData).toHaveBeenCalledTimes(dataCalls + 1)); + await waitFor(() => { + const latest = setData.mock.calls[setData.mock.calls.length - 1]?.[0]; + expect(latest?.[0].lnglat).toEqual(wgs84ToGcj02(113.27, 23.13)); + }, { timeout: 1_500 }); + expect(setData.mock.calls.length).toBeGreaterThan(dataCalls + 1); expect(setStyle).toHaveBeenCalledTimes(styleCalls); - expect(removeLabels).toHaveBeenCalledTimes(removeCalls + 1); - expect(addLabels).toHaveBeenCalledTimes(addCalls + 1); - const replacementLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[]; - expect(replacementLabels).toHaveLength(1); - expect((replacementLabels[0].options.text as { content: string }).content).toBe('粤A12345'); + expect(removeLabels).toHaveBeenCalledTimes(removeCalls); + expect(addLabels).toHaveBeenCalledTimes(addCalls); + expect(labelSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.13)); }); test('does not traverse unchanged point arrays for an asOf-only refresh', async () => { @@ -362,6 +400,7 @@ test('renders one selected plate and smoothly follows it until the map is dragge content: expect.stringContaining('粤A12345') }))); expect(markerOptions[markerOptions.length - 1]?.content).not.toContain(''); + expect(markerOptions[markerOptions.length - 1]?.content).toContain('is-driving'); expect(markerSetMap).toHaveBeenCalled(); expect(renderedLabels).toHaveLength(2); expect(setZoomAndCenter).toHaveBeenCalledTimes(1); @@ -418,6 +457,7 @@ test('renders one selected plate and smoothly follows it until the map is dragge ); await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650)); expect(setZoomAndCenter).toHaveBeenCalledTimes(1); + expect(markerOptions[markerOptions.length - 1]?.content).toContain('is-idle'); const toggle = screen.getByRole('button', { name: '悬浮车牌' }); expect(toggle).toHaveAttribute('aria-pressed', 'true'); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 6d374ad0..311a2833 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -19,6 +19,8 @@ import type { MonitorViewport } from '../hooks/useMonitorData'; const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444']; const CLUSTER_VISUAL_CACHE_LIMIT = 256; +const POINT_MOVE_DURATION_MS = 650; +const POINT_MOVE_FRAME_MS = 32; const clusterVisualCache = new Map(); function dotDataUrl(color: string) { @@ -102,6 +104,7 @@ type PlateLabelPoint = { type LabelMarkerEntry = { marker: AMapOverlay; signature: string; + position: [number, number]; }; const NO_VEHICLE_POINTS: VehicleRealtimeRow[] = []; @@ -165,7 +168,41 @@ export function massPointFingerprint( } function labelMarkerSignature(point: PlateLabelPoint, selected: boolean, placement?: { direction: 'left' | 'right'; textOffset: [number, number] }) { - return `${point.longitude}:${point.latitude}:${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`; + return `${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`; +} + +function interpolateMassPointsFrom(previousByID: Map, target: AMapMassPoint[], progress: number, eligibleIDs?: Set) { + const bounded = Math.min(1, Math.max(0, progress)); + const eased = 1 - (1 - bounded) ** 3; + 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; + return { + ...point, + lnglat: [ + start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * eased, + start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * eased + ] as [number, number] + }; + }); +} + +export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: number) { + return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, progress); +} + +function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) { + const previousByID = new Map(previous.map((point) => [point.id, point.lnglat])); + return new Set(target.flatMap((point) => { + const start = previousByID.get(point.id); + return start && (start[0] !== point.lnglat[0] || start[1] !== point.lnglat[1]) ? [point.id] : []; + })); +} + +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[]) { @@ -216,6 +253,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const labelLayerStateRef = useRef(''); const massDataSignatureRef = useRef('__unset__'); const massStyleSignatureRef = useRef('__unset__'); + const renderedMassDataRef = useRef([]); + const pointAnimationFrameRef = useRef(undefined); + const movingPointIDsRef = useRef(new Set()); const onSelectRef = useRef(onSelect); const onSelectVinRef = useRef(onSelectVin); const onViewportChangeRef = useRef(onViewportChange); @@ -226,6 +266,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect 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); @@ -254,7 +295,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect onSelectRef.current = onSelect; onSelectVinRef.current = onSelectVin; onViewportChangeRef.current = onViewportChange; - }, [onSelect, onSelectVin, onViewportChange]); + selectedVinRef.current = selectedVin; + }, [onSelect, onSelectVin, onViewportChange, selectedVin]); useEffect(() => { if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { @@ -348,6 +390,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect resizeObserver?.disconnect(); window.clearTimeout(resizeTimer); window.clearTimeout(viewportTimerRef.current); + if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); + pointAnimationFrameRef.current = undefined; + movingPointIDsRef.current.clear(); if (selectMassPoint) massInstance?.off?.('click', selectMassPoint); if (notifyViewport) { mapInstance?.off?.('moveend', notifyViewport); @@ -370,6 +415,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect labelLayerStateRef.current = ''; massDataSignatureRef.current = '__unset__'; massStyleSignatureRef.current = '__unset__'; + renderedMassDataRef.current = []; amapRef.current = null; mapRef.current = null; }; @@ -402,8 +448,47 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect ] : fallbackPoints.map((vehicle) => ({ lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin })); - mass.setData(data); massDataSignatureRef.current = dataSignature; + if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); + pointAnimationFrameRef.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') { + mass.setData(data); + renderedMassDataRef.current = data; + movingPointIDsRef.current.clear(); + return; + } + let startedAt: number | undefined; + let lastPaintAt = -Infinity; + const paint = (frameData: AMapMassPoint[]) => { + mass.setData(frameData); + renderedMassDataRef.current = frameData; + for (const point of frameData) { + if (!movingIDs.has(point.id)) continue; + const label = labelMarkersRef.current.get(point.id); + label?.marker.setPosition?.(point.lnglat); + if (label) label.position = point.lnglat; + if (selectedVinRef.current === point.id) selectionRef.current?.setPosition?.(point.lnglat); + } + }; + const animate = (timestamp: number) => { + startedAt ??= timestamp; + const progress = Math.min(1, (timestamp - startedAt) / POINT_MOVE_DURATION_MS); + if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || progress === 1) { + paint(interpolateMassPointsFrom(previousDataByID, data, progress, movingIDs)); + lastPaintAt = timestamp; + } + if (progress < 1) pointAnimationFrameRef.current = window.requestAnimationFrame(animate); + else { + pointAnimationFrameRef.current = undefined; + movingPointIDsRef.current.clear(); + } + }; + pointAnimationFrameRef.current = window.requestAnimationFrame(animate); }, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, state]); useEffect(() => { @@ -441,12 +526,18 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect 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: wgs84ToGcj02(point.longitude, point.latitude), + position, rank: point.vin === selectedVin ? 100 : 1, zIndex: point.vin === selectedVin ? 10 : 1, text: { @@ -470,7 +561,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } } }); - nextMarkers.set(point.vin, { marker, signature }); + nextMarkers.set(point.vin, { marker, signature, position }); markersToAdd.push(marker); } const markersToRemove = [...labelMarkersRef.current.entries()] @@ -495,8 +586,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect 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}`; + const selectionKey = `${selectedVin}|${label}|${status}`; const positionKey = `${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`; if (centeredVinRef.current !== selectedVin) { followSelectedRef.current = true; @@ -511,7 +603,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect const previousPositionKey = selectionPositionRef.current; selectionPositionRef.current = positionKey; if (selectionKeyRef.current === selectionKey && selectionRef.current) { - if (previousPositionKey !== positionKey) selectionRef.current.setPosition?.(mapPosition); + if (previousPositionKey !== positionKey && !movingPointIDsRef.current.has(selectedVin)) selectionRef.current.setPosition?.(mapPosition); return; } selectionRef.current?.setMap?.(null); @@ -520,7 +612,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect position: mapPosition, offset: new amapRef.current.Pixel(-24, -24), zIndex: 300, - content: `
` + content: `
` }); marker.setMap?.(mapRef.current); selectionRef.current = marker; diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index de85ef81..a0839544 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -203,10 +203,14 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-map-follow-control small { color: #8a98aa; font-size: 8px; } .v2-map-follow-control.is-active { border-color: #9ec0f7; background: #eef5ff; color: #1268f3; } .v2-map-follow-control.is-active small { color: #4e82cf; } -.v2-map-selection-marker { position: relative; width: 48px; height: 48px; pointer-events: none; } -.v2-map-selection-marker > i { position: absolute; inset: 4px; border: 2px solid rgba(18,104,243,.6); border-radius: 50%; animation: v2-map-ripple 2s ease-out infinite; } +.v2-map-selection-marker { --v2-selection-color: #f59e0b; --v2-selection-rgb: 245,158,11; position: relative; width: 48px; height: 48px; pointer-events: none; } +.v2-map-selection-marker.is-driving { --v2-selection-color: #1677ff; --v2-selection-rgb: 22,119,255; } +.v2-map-selection-marker.is-idle { --v2-selection-color: #12a46f; --v2-selection-rgb: 18,164,111; } +.v2-map-selection-marker.is-offline { --v2-selection-color: #9aa6b7; --v2-selection-rgb: 154,166,183; } +.v2-map-selection-marker.is-alert { --v2-selection-color: #ef4444; --v2-selection-rgb: 239,68,68; } +.v2-map-selection-marker > i { position: absolute; inset: 4px; border: 2px solid rgba(var(--v2-selection-rgb),.6); border-radius: 50%; animation: v2-map-ripple 2s ease-out infinite; } .v2-map-selection-marker > i:nth-child(2) { animation-delay: 1s; } -.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 8px rgba(18,104,243,.45); } +.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-selection-color); box-shadow: 0 2px 8px rgba(var(--v2-selection-rgb),.45); } .v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; } .v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); } .v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }