feat(monitor): animate live vehicle positions
This commit is contained in:
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import type { MonitorMapResponse } from '../../api/types';
|
import type { MonitorMapResponse } from '../../api/types';
|
||||||
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
|
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 setData = vi.fn<(data: AMapMassPoint[]) => void>();
|
||||||
const setStyle = vi.fn();
|
const setStyle = vi.fn();
|
||||||
@@ -12,6 +12,7 @@ const clearLabels = vi.fn();
|
|||||||
const setLabelsMap = vi.fn();
|
const setLabelsMap = vi.fn();
|
||||||
const markerSetMap = vi.fn();
|
const markerSetMap = vi.fn();
|
||||||
const markerSetPosition = vi.fn();
|
const markerSetPosition = vi.fn();
|
||||||
|
const labelSetPosition = vi.fn();
|
||||||
const setZoomAndCenter = vi.fn();
|
const setZoomAndCenter = vi.fn();
|
||||||
const panTo = vi.fn();
|
const panTo = vi.fn();
|
||||||
const mapOff = vi.fn();
|
const mapOff = vi.fn();
|
||||||
@@ -61,6 +62,7 @@ class TestLabelsLayer {
|
|||||||
setMap = setLabelsMap;
|
setMap = setLabelsMap;
|
||||||
}
|
}
|
||||||
class TestLabelMarker {
|
class TestLabelMarker {
|
||||||
|
setPosition = labelSetPosition;
|
||||||
constructor(public options: Record<string, unknown>) {}
|
constructor(public options: Record<string, unknown>) {}
|
||||||
}
|
}
|
||||||
class TestMarker {
|
class TestMarker {
|
||||||
@@ -141,6 +143,21 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point
|
|||||||
expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint);
|
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 {
|
function amapMock(): AMapLike {
|
||||||
return {
|
return {
|
||||||
Map: TestMap as unknown as AMapLike['Map'],
|
Map: TestMap as unknown as AMapLike['Map'],
|
||||||
@@ -168,6 +185,7 @@ afterEach(() => {
|
|||||||
setLabelsMap.mockReset();
|
setLabelsMap.mockReset();
|
||||||
markerSetMap.mockReset();
|
markerSetMap.mockReset();
|
||||||
markerSetPosition.mockReset();
|
markerSetPosition.mockReset();
|
||||||
|
labelSetPosition.mockReset();
|
||||||
setZoomAndCenter.mockReset();
|
setZoomAndCenter.mockReset();
|
||||||
panTo.mockReset();
|
panTo.mockReset();
|
||||||
mapOff.mockReset();
|
mapOff.mockReset();
|
||||||
@@ -237,7 +255,25 @@ test('detaches AMap listeners and destroys the map on unmount', async () => {
|
|||||||
expect(mapDestroy).toHaveBeenCalledTimes(1);
|
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(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
|
||||||
|
await waitFor(() => expect(setData).toHaveBeenCalled());
|
||||||
|
|
||||||
|
view.rerender(<FleetMap vehicles={[]} monitorMap={{
|
||||||
|
...pointMap,
|
||||||
|
points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]]
|
||||||
|
}} onSelect={() => 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.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||||
const view = render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
|
const view = render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} />);
|
||||||
@@ -263,13 +299,15 @@ test('skips unchanged refresh redraws and replaces only a moved vehicle label',
|
|||||||
asOf: '2026-07-14T01:00:30Z',
|
asOf: '2026-07-14T01:00:30Z',
|
||||||
points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]]
|
points: [{ ...pointMap.points[0], longitude: 113.27 }, pointMap.points[1]]
|
||||||
}} onSelect={() => undefined} />);
|
}} 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(setStyle).toHaveBeenCalledTimes(styleCalls);
|
||||||
expect(removeLabels).toHaveBeenCalledTimes(removeCalls + 1);
|
expect(removeLabels).toHaveBeenCalledTimes(removeCalls);
|
||||||
expect(addLabels).toHaveBeenCalledTimes(addCalls + 1);
|
expect(addLabels).toHaveBeenCalledTimes(addCalls);
|
||||||
const replacementLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
|
expect(labelSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.13));
|
||||||
expect(replacementLabels).toHaveLength(1);
|
|
||||||
expect((replacementLabels[0].options.text as { content: string }).content).toBe('粤A12345');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not traverse unchanged point arrays for an asOf-only refresh', async () => {
|
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')
|
content: expect.stringContaining('粤A12345')
|
||||||
})));
|
})));
|
||||||
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
|
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
|
||||||
|
expect(markerOptions[markerOptions.length - 1]?.content).toContain('is-driving');
|
||||||
expect(markerSetMap).toHaveBeenCalled();
|
expect(markerSetMap).toHaveBeenCalled();
|
||||||
expect(renderedLabels).toHaveLength(2);
|
expect(renderedLabels).toHaveLength(2);
|
||||||
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
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));
|
await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650));
|
||||||
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(markerOptions[markerOptions.length - 1]?.content).toContain('is-idle');
|
||||||
|
|
||||||
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
|
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
|
||||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import type { MonitorViewport } from '../hooks/useMonitorData';
|
|||||||
|
|
||||||
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
||||||
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
|
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
|
||||||
|
const POINT_MOVE_DURATION_MS = 650;
|
||||||
|
const POINT_MOVE_FRAME_MS = 32;
|
||||||
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
|
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
|
||||||
|
|
||||||
function dotDataUrl(color: string) {
|
function dotDataUrl(color: string) {
|
||||||
@@ -102,6 +104,7 @@ type PlateLabelPoint = {
|
|||||||
type LabelMarkerEntry = {
|
type LabelMarkerEntry = {
|
||||||
marker: AMapOverlay;
|
marker: AMapOverlay;
|
||||||
signature: string;
|
signature: string;
|
||||||
|
position: [number, number];
|
||||||
};
|
};
|
||||||
|
|
||||||
const NO_VEHICLE_POINTS: VehicleRealtimeRow[] = [];
|
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] }) {
|
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<string, AMapMassPoint>, target: AMapMassPoint[], progress: number, eligibleIDs?: Set<string>) {
|
||||||
|
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[]) {
|
function densePlatePlacements(points: PlateLabelPoint[]) {
|
||||||
@@ -216,6 +253,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
const labelLayerStateRef = useRef('');
|
const labelLayerStateRef = useRef('');
|
||||||
const massDataSignatureRef = useRef('__unset__');
|
const massDataSignatureRef = useRef('__unset__');
|
||||||
const massStyleSignatureRef = useRef('__unset__');
|
const massStyleSignatureRef = useRef('__unset__');
|
||||||
|
const renderedMassDataRef = useRef<AMapMassPoint[]>([]);
|
||||||
|
const pointAnimationFrameRef = useRef<number | undefined>(undefined);
|
||||||
|
const movingPointIDsRef = useRef(new Set<string>());
|
||||||
const onSelectRef = useRef(onSelect);
|
const onSelectRef = useRef(onSelect);
|
||||||
const onSelectVinRef = useRef(onSelectVin);
|
const onSelectVinRef = useRef(onSelectVin);
|
||||||
const onViewportChangeRef = useRef(onViewportChange);
|
const onViewportChangeRef = useRef(onViewportChange);
|
||||||
@@ -226,6 +266,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
const selectionPositionRef = useRef('');
|
const selectionPositionRef = useRef('');
|
||||||
const centeredVinRef = useRef('');
|
const centeredVinRef = useRef('');
|
||||||
const followSelectedRef = useRef(true);
|
const followSelectedRef = useRef(true);
|
||||||
|
const selectedVinRef = useRef(selectedVin);
|
||||||
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
|
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
|
||||||
const [loadAttempt, setLoadAttempt] = useState(0);
|
const [loadAttempt, setLoadAttempt] = useState(0);
|
||||||
const [showLabels, setShowLabels] = useState(true);
|
const [showLabels, setShowLabels] = useState(true);
|
||||||
@@ -254,7 +295,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
onSelectRef.current = onSelect;
|
onSelectRef.current = onSelect;
|
||||||
onSelectVinRef.current = onSelectVin;
|
onSelectVinRef.current = onSelectVin;
|
||||||
onViewportChangeRef.current = onViewportChange;
|
onViewportChangeRef.current = onViewportChange;
|
||||||
}, [onSelect, onSelectVin, onViewportChange]);
|
selectedVinRef.current = selectedVin;
|
||||||
|
}, [onSelect, onSelectVin, onViewportChange, selectedVin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) {
|
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) {
|
||||||
@@ -348,6 +390,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
window.clearTimeout(resizeTimer);
|
window.clearTimeout(resizeTimer);
|
||||||
window.clearTimeout(viewportTimerRef.current);
|
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 (selectMassPoint) massInstance?.off?.('click', selectMassPoint);
|
||||||
if (notifyViewport) {
|
if (notifyViewport) {
|
||||||
mapInstance?.off?.('moveend', notifyViewport);
|
mapInstance?.off?.('moveend', notifyViewport);
|
||||||
@@ -370,6 +415,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
labelLayerStateRef.current = '';
|
labelLayerStateRef.current = '';
|
||||||
massDataSignatureRef.current = '__unset__';
|
massDataSignatureRef.current = '__unset__';
|
||||||
massStyleSignatureRef.current = '__unset__';
|
massStyleSignatureRef.current = '__unset__';
|
||||||
|
renderedMassDataRef.current = [];
|
||||||
amapRef.current = null;
|
amapRef.current = null;
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -402,8 +448,47 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
] : fallbackPoints.map((vehicle) => ({
|
] : fallbackPoints.map((vehicle) => ({
|
||||||
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
|
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
|
||||||
}));
|
}));
|
||||||
mass.setData(data);
|
|
||||||
massDataSignatureRef.current = dataSignature;
|
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]);
|
}, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -441,12 +526,18 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
const signature = labelMarkerSignature(point, point.vin === selectedVin, placement);
|
const signature = labelMarkerSignature(point, point.vin === selectedVin, placement);
|
||||||
const existing = labelMarkersRef.current.get(point.vin);
|
const existing = labelMarkersRef.current.get(point.vin);
|
||||||
if (existing?.signature === signature) {
|
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);
|
nextMarkers.set(point.vin, existing);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const position = wgs84ToGcj02(point.longitude, point.latitude);
|
||||||
const marker = new AMap.LabelMarker!({
|
const marker = new AMap.LabelMarker!({
|
||||||
name: point.plate || point.vin,
|
name: point.plate || point.vin,
|
||||||
position: wgs84ToGcj02(point.longitude, point.latitude),
|
position,
|
||||||
rank: point.vin === selectedVin ? 100 : 1,
|
rank: point.vin === selectedVin ? 100 : 1,
|
||||||
zIndex: point.vin === selectedVin ? 10 : 1,
|
zIndex: point.vin === selectedVin ? 10 : 1,
|
||||||
text: {
|
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);
|
markersToAdd.push(marker);
|
||||||
}
|
}
|
||||||
const markersToRemove = [...labelMarkersRef.current.entries()]
|
const markersToRemove = [...labelMarkersRef.current.entries()]
|
||||||
@@ -495,8 +586,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
const target = selectedTarget;
|
const target = selectedTarget;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
const label = escapeHtml(target.plate || target.vin);
|
const label = escapeHtml(target.plate || target.vin);
|
||||||
|
const status = selectionStatus(target);
|
||||||
const mapPosition = wgs84ToGcj02(target.longitude, target.latitude);
|
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)}`;
|
const positionKey = `${target.longitude.toFixed(6)},${target.latitude.toFixed(6)}`;
|
||||||
if (centeredVinRef.current !== selectedVin) {
|
if (centeredVinRef.current !== selectedVin) {
|
||||||
followSelectedRef.current = true;
|
followSelectedRef.current = true;
|
||||||
@@ -511,7 +603,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
const previousPositionKey = selectionPositionRef.current;
|
const previousPositionKey = selectionPositionRef.current;
|
||||||
selectionPositionRef.current = positionKey;
|
selectionPositionRef.current = positionKey;
|
||||||
if (selectionKeyRef.current === selectionKey && selectionRef.current) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
selectionRef.current?.setMap?.(null);
|
selectionRef.current?.setMap?.(null);
|
||||||
@@ -520,7 +612,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
position: mapPosition,
|
position: mapPosition,
|
||||||
offset: new amapRef.current.Pixel(-24, -24),
|
offset: new amapRef.current.Pixel(-24, -24),
|
||||||
zIndex: 300,
|
zIndex: 300,
|
||||||
content: `<div class="v2-map-selection-marker" aria-label="已选车辆 ${label}"><i></i><i></i><b></b></div>`
|
content: `<div class="v2-map-selection-marker is-${status}" aria-label="已选车辆 ${label}"><i></i><i></i><b></b></div>`
|
||||||
});
|
});
|
||||||
marker.setMap?.(mapRef.current);
|
marker.setMap?.(mapRef.current);
|
||||||
selectionRef.current = marker;
|
selectionRef.current = marker;
|
||||||
|
|||||||
@@ -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 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 { border-color: #9ec0f7; background: #eef5ff; color: #1268f3; }
|
||||||
.v2-map-follow-control.is-active small { color: #4e82cf; }
|
.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 { --v2-selection-color: #f59e0b; --v2-selection-rgb: 245,158,11; 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.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 > 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 { 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-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; }
|
.v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }
|
||||||
|
|||||||
Reference in New Issue
Block a user