Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
2026-07-16 10:35:52 +08:00

745 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, { diameter: number; url: string }>();
function dotDataUrl(color: string) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="54" height="54" viewBox="0 0 18 18"><circle cx="9" cy="9" r="6.5" fill="${color}" stroke="white" stroke-width="2.5"/></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 = `<svg xmlns="http://www.w3.org/2000/svg" width="${diameter * 3}" height="${diameter * 3}" viewBox="0 0 ${diameter} ${diameter}"><circle cx="${center}" cy="${center}" r="${center - 2}" fill="#1268f3" stroke="white" stroke-width="2.5"/><circle cx="${center}" cy="${center}" r="${center - 5}" fill="none" stroke="rgba(255,255,255,.22)" stroke-width="1"/><text x="${center}" y="${center + fontSize * 0.34}" text-anchor="middle" font-family="Inter,Arial,sans-serif" font-size="${fontSize}" font-weight="800" fill="white">${label}</text></svg>`;
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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
})[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<string, AMapMassPoint>, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set<string>) {
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<string>();
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<string, PlateLabelPoint[]>();
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<string, {
direction: 'left' | 'right';
textOffset: [number, number];
}>();
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<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const massRef = useRef<AMapMassMarks | null>(null);
const labelsRef = useRef<AMapLabelsLayer | null>(null);
const denseLabelsRef = useRef<AMapLabelsLayer | null>(null);
const selectionRef = useRef<AMapOverlay | null>(null);
const labelMarkersRef = useRef(new Map<string, LabelMarkerEntry>());
const labelLayerModeRef = useRef<'normal' | 'dense' | ''>('');
const labelLayerStateRef = useRef('');
const massDataSignatureRef = useRef('__unset__');
const massStyleSignatureRef = useRef('__unset__');
const renderedMassDataRef = useRef<AMapMassPoint[]>([]);
const pointAnimationFrameRef = useRef<number | undefined>(undefined);
const pointAnimationTimerRef = useRef<number | undefined>(undefined);
const movingPointIDsRef = useRef(new Set<string>());
const onSelectRef = useRef(onSelect);
const onSelectVinRef = useRef(onSelectVin);
const onViewportChangeRef = useRef(onViewportChange);
const vehiclesRef = useRef(new Map<string, VehicleRealtimeRow>());
const clustersRef = useRef(new Map<string, { longitude: number; latitude: number }>());
const viewportTimerRef = useRef<number | undefined>(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<string, LabelMarkerEntry>();
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: `<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;
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 (
<div className="v2-fleet-map">
<div ref={containerRef} className="v2-fleet-map-canvas" aria-label="车辆全局监控地图" />
<div className="v2-map-controls">
{selectedVin ? (
<button
type="button"
className={`v2-map-follow-control${followSelected ? ' is-active' : ''}`}
aria-label="跟随车辆"
aria-pressed={followSelected}
title={followSelected ? '车辆移动时保持居中;拖动地图可暂停' : '恢复车辆居中跟随'}
onClick={toggleFollow}
>
<IconMapPin />
<span><strong></strong><small>{followSelected ? '实时居中' : '已暂停'}</small></span>
</button>
) : null}
<button
type="button"
className="v2-map-layer-control"
aria-label="悬浮车牌"
aria-pressed={showLabels}
title={monitorMap?.mode === 'clusters' ? '放大地图后显示车辆车牌' : '显示或隐藏车辆悬浮车牌'}
onClick={() => setShowLabels((current) => !current)}
>
{showLabels ? <IconEyeOpened /> : <IconEyeClosed />}
<span><strong></strong><small>{monitorMap?.mode === 'clusters' ? '放大后显示' : '仅明细点'}</small></span>
<i className={showLabels ? 'is-on' : ''} />
</button>
</div>
{state !== 'ready' ? (
<div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
{state === 'error' ? <><span> Key</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh /></button></> : null}
</div>
) : null}
<div className="v2-map-legend" aria-label="车辆状态图例">
<span><i className="is-driving" /></span>
<span><i className="is-idle" /></span>
<span><i className="is-offline" />线</span>
<span><i className="is-alert" /></span>
<b>{mapComposition}</b>
</div>
</div>
);
}