feat(monitor): animate live vehicle positions
This commit is contained in:
@@ -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<number, { diameter: number; url: string }>();
|
||||
|
||||
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<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[]) {
|
||||
@@ -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<AMapMassPoint[]>([]);
|
||||
const pointAnimationFrameRef = useRef<number | undefined>(undefined);
|
||||
const movingPointIDsRef = useRef(new Set<string>());
|
||||
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: `<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);
|
||||
selectionRef.current = marker;
|
||||
|
||||
Reference in New Issue
Block a user