745 lines
33 KiB
TypeScript
745 lines
33 KiB
TypeScript
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) => ({
|
||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||
})[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>
|
||
);
|
||
}
|