feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,351 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import type { MonitorMapResponse } from '../../api/types';
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
import { FleetMap } from './FleetMap';
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
const setStyle = vi.fn();
const addLabels = vi.fn();
const clearLabels = vi.fn();
const setLabelsMap = vi.fn();
const markerSetMap = vi.fn();
const markerSetPosition = vi.fn();
const setZoomAndCenter = vi.fn();
const panTo = vi.fn();
const getZoom = vi.fn(() => 5);
const getBounds = vi.fn((): ReturnType<NonNullable<AMapMap['getBounds']>> => ({}));
const mapHandlers = new Map<string, (event: unknown) => void>();
const markerOptions: Record<string, unknown>[] = [];
const mapOptions: Record<string, unknown>[] = [];
const labelLayerOptions: Record<string, unknown>[] = [];
class TestMap {
constructor(_container: HTMLDivElement, options: Record<string, unknown>) {
mapOptions.push(options);
}
add = vi.fn();
addControl = vi.fn();
destroy = vi.fn();
on = vi.fn((event: string, handler: (value: unknown) => void) => mapHandlers.set(event, handler));
getZoom = getZoom;
getBounds = getBounds;
setZoomAndCenter = setZoomAndCenter;
panTo = panTo;
}
class TestMassMarks {
on = vi.fn();
setMap = vi.fn();
setData = setData;
setStyle = setStyle;
}
class TestScale {}
class TestToolBar {}
class TestSize {}
class TestPixel {}
class TestLabelsLayer {
constructor(options: Record<string, unknown> = {}) {
labelLayerOptions.push(options);
}
add = addLabels;
clear = clearLabels;
setMap = setLabelsMap;
}
class TestLabelMarker {
constructor(public options: Record<string, unknown>) {}
}
class TestMarker {
setMap = markerSetMap;
setPosition = markerSetPosition;
constructor(options: Record<string, unknown>) {
markerOptions.push(options);
}
}
const monitorMap: MonitorMapResponse = {
mode: 'clusters',
zoom: 5,
total: 12,
truncated: false,
points: [],
clusters: [{
id: 'cluster-1',
longitude: 121.1,
latitude: 30.6,
count: 12,
online: 8,
offline: 4,
driving: 3,
idle: 5,
unknown: 0
}],
asOf: '2026-07-14T01:00:00Z'
};
const pointMap: MonitorMapResponse = {
...monitorMap,
mode: 'points',
zoom: 13,
total: 2,
clusters: [],
points: [{
vin: 'LTEST000000000001',
plate: '粤A12345',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.26,
latitude: 23.13,
speedKmh: 42,
socPercent: 80,
totalMileageKm: 1234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'driving'
}, {
vin: 'LTEST000000000002',
plate: '粤B67890',
protocol: 'JT808',
protocols: ['JT808'],
longitude: 113.28,
latitude: 23.15,
speedKmh: 0,
socPercent: 72,
totalMileageKm: 2234,
lastSeen: '2026-07-14T01:00:00Z',
status: 'idle'
}]
};
function amapMock(): AMapLike {
return {
Map: TestMap as unknown as AMapLike['Map'],
Marker: TestMarker as unknown as AMapLike['Marker'],
Polyline: class {} as unknown as AMapLike['Polyline'],
Scale: TestScale,
ToolBar: TestToolBar,
Size: TestSize as unknown as AMapLike['Size'],
Pixel: TestPixel as unknown as AMapLike['Pixel'],
MassMarks: TestMassMarks as unknown as AMapLike['MassMarks'],
LabelsLayer: TestLabelsLayer as unknown as NonNullable<AMapLike['LabelsLayer']>,
LabelMarker: TestLabelMarker as unknown as NonNullable<AMapLike['LabelMarker']>
};
}
afterEach(() => {
cleanup();
delete window.__LINGNIU_APP_CONFIG__;
delete window.AMapLoader;
setData.mockReset();
setStyle.mockReset();
addLabels.mockReset();
clearLabels.mockReset();
setLabelsMap.mockReset();
markerSetMap.mockReset();
markerSetPosition.mockReset();
setZoomAndCenter.mockReset();
panTo.mockReset();
getZoom.mockReset();
getZoom.mockReturnValue(5);
getBounds.mockReset();
getBounds.mockReturnValue({});
mapHandlers.clear();
markerOptions.length = 0;
mapOptions.length = 0;
labelLayerOptions.length = 0;
vi.restoreAllMocks();
});
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
let resolveAMap!: (value: AMapLike) => void;
const delayedAMap = new Promise<AMapLike>((resolve) => {
resolveAMap = resolve;
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
render(
<FleetMap
vehicles={[]}
monitorMap={monitorMap}
onSelect={() => undefined}
/>
);
expect(setData).not.toHaveBeenCalled();
await act(async () => {
resolveAMap(amapMock());
await delayedAMap;
});
await waitFor(() => expect(setData).toHaveBeenCalledWith([
expect.objectContaining({ id: 'cluster-1', lnglat: wgs84ToGcj02(121.1, 30.6), label: '12 辆' })
]));
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
});
test('converts AMap GCJ-02 bounds back to WGS-84 before requesting monitor data', async () => {
const [west, south] = wgs84ToGcj02(113, 22);
const [east, north] = wgs84ToGcj02(114, 24);
getZoom.mockReturnValue(13);
getBounds.mockReturnValue({
getSouthWest: () => ({ getLng: () => west, getLat: () => south }),
getNorthEast: () => ({ getLng: () => east, getLat: () => north })
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const onViewportChange = vi.fn();
render(<FleetMap vehicles={[]} monitorMap={pointMap} onSelect={() => undefined} onViewportChange={onViewportChange} />);
await waitFor(() => expect(onViewportChange).toHaveBeenCalled());
const viewport = onViewportChange.mock.calls[onViewportChange.mock.calls.length - 1]?.[0] as { zoom: number; bounds: string };
expect(viewport.zoom).toBe(13);
const bounds = viewport.bounds.split(',').map(Number);
expect(Math.abs(bounds[0] - 113)).toBeLessThan(0.0005);
expect(Math.abs(bounds[1] - 22)).toBeLessThan(0.0005);
expect(Math.abs(bounds[2] - 114)).toBeLessThan(0.0005);
expect(Math.abs(bounds[3] - 24)).toBeLessThan(0.0005);
});
test('renders one selected plate and smoothly follows it until the map is dragged', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(
<FleetMap
vehicles={[]}
monitorMap={pointMap}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
const renderedLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
const selectedPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤A12345');
const floatingPlate = renderedLabels.find((marker) => (marker.options.text as { content: string }).content === '粤B67890');
expect(selectedPlate).toBeDefined();
expect(floatingPlate).toBeDefined();
expect((selectedPlate!.options.text as { style: unknown }).style).toEqual((floatingPlate!.options.text as { style: unknown }).style);
expect(selectedPlate!.options).toEqual(expect.objectContaining({ rank: 100, zIndex: 10 }));
expect(floatingPlate!.options).toEqual(expect.objectContaining({ rank: 1, zIndex: 1 }));
expect((floatingPlate!.options.text as { style: Record<string, unknown> }).style).toEqual(expect.objectContaining({
fillColor: '#174d9f',
backgroundColor: '#eef5ff',
borderColor: '#7fb0fa',
borderWidth: 1,
borderRadius: 6,
padding: [5, 9],
fontSize: 11,
shadowColor: 'rgba(18, 104, 243, 0.18)',
shadowBlur: 14,
shadowOffsetY: 5
}));
expect(mapOptions).toContainEqual(expect.objectContaining({ mapStyle: 'amap://styles/whitesmoke' }));
await waitFor(() => expect(markerOptions).toContainEqual(expect.objectContaining({
content: expect.stringContaining('粤A12345')
})));
expect(markerOptions[markerOptions.length - 1]?.content).not.toContain('<span>');
expect(markerSetMap).toHaveBeenCalled();
expect(renderedLabels).toHaveLength(2);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
expect(setZoomAndCenter).toHaveBeenLastCalledWith(15, wgs84ToGcj02(113.26, 23.13));
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:15Z',
points: [{ ...pointMap.points[0], longitude: 113.27, latitude: 23.14 }]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 650);
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const follow = screen.getByRole('button', { name: '跟随车辆' });
expect(follow).toHaveAttribute('aria-pressed', 'true');
act(() => mapHandlers.get('dragstart')?.({}));
expect(follow).toHaveAttribute('aria-pressed', 'false');
const panCountAfterDrag = panTo.mock.calls.length;
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{
...pointMap,
asOf: '2026-07-14T01:00:30Z',
points: [{ ...pointMap.points[0], longitude: 113.29, latitude: 23.16 }, pointMap.points[1]]
}}
selectedVin="LTEST000000000001"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16)));
expect(panTo).toHaveBeenCalledTimes(panCountAfterDrag);
fireEvent.click(follow);
await waitFor(() => expect(follow).toHaveAttribute('aria-pressed', 'true'));
expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.29, 23.16), 650);
getZoom.mockReturnValue(20);
view.rerender(
<FleetMap
vehicles={[]}
monitorMap={{ ...pointMap, asOf: '2026-07-14T01:00:45Z' }}
selectedVin="LTEST000000000002"
onSelect={() => undefined}
/>
);
await waitFor(() => expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.28, 23.15), 650));
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
const toggle = screen.getByRole('button', { name: '悬浮车牌' });
expect(toggle).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(toggle);
await waitFor(() => expect(toggle).toHaveAttribute('aria-pressed', 'false'));
expect(clearLabels).toHaveBeenCalled();
const selectedOnlyLabels = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedOnlyLabels).toHaveLength(1);
expect((selectedOnlyLabels[0].options.text as { content: string }).content).toBe('粤B67890');
});
test('renders every nearby plate with staggered positions at maximum zoom', async () => {
getZoom.mockReturnValue(20);
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const crowdedMap: MonitorMapResponse = {
...pointMap,
zoom: 20,
total: 3,
points: [
pointMap.points[0],
{ ...pointMap.points[1], longitude: 113.26001, latitude: 23.13001 },
{ ...pointMap.points[1], vin: 'LTEST000000000003', plate: '粤C24680', longitude: 113.26002, latitude: 23.13002 }
]
};
const view = render(<FleetMap vehicles={[]} monitorMap={crowdedMap} onSelect={() => undefined} />);
await waitFor(() => expect(addLabels).toHaveBeenCalled());
expect(labelLayerOptions).toContainEqual(expect.objectContaining({ zooms: [19, 20], zIndex: 110, collision: false, allowCollision: true }));
const denseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(denseMarkers).toHaveLength(3);
const textOffsets = denseMarkers.map((marker) => JSON.stringify((marker.options.text as { offset: [number, number] }).offset));
expect(new Set(textOffsets).size).toBe(3);
expect(denseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
const labelRenderCount = addLabels.mock.calls.length;
view.rerender(<FleetMap vehicles={[]} monitorMap={crowdedMap} selectedVin="LTEST000000000002" onSelect={() => undefined} />);
await waitFor(() => expect(addLabels.mock.calls.length).toBeGreaterThan(labelRenderCount));
const selectedDenseMarkers = addLabels.mock.calls[addLabels.mock.calls.length - 1]?.[0] as TestLabelMarker[];
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
});

View File

@@ -0,0 +1,442 @@
import { IconEyeClosed, IconEyeOpened, IconMapPin } 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'];
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 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>`;
return { diameter, url: `data:image/svg+xml,${encodeURIComponent(svg)}` };
}
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;
};
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 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 [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
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 selectedTarget = useMemo(() => selectedVin
? monitorMap?.points.find((item) => item.vin === selectedVin) ?? points.find((item) => item.vin === selectedVin)
: undefined, [monitorMap, points, selectedVin]);
const renderedPointCount = monitorMap ? monitorMap.points.length : points.length;
const renderedClusterCount = monitorMap?.clusters.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;
}, [onSelect, onSelectVin, onViewportChange]);
useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) {
setState('fallback');
return;
}
let cancelled = false;
let resizeTimer: number | undefined;
let resizeObserver: ResizeObserver | 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
});
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] });
mass.on('click', (event) => {
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.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);
const 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);
map.on?.('dragstart', () => {
if (!centeredVinRef.current) return;
followSelectedRef.current = false;
setFollowSelected(false);
});
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);
massRef.current?.setMap(null);
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;
amapRef.current = null;
mapRef.current = null;
};
}, []);
useEffect(() => {
const mass = massRef.current;
const AMap = amapRef.current;
if (!mass || !AMap) return;
clustersRef.current = new Map((monitorMap?.clusters ?? []).map((cluster) => [cluster.id, cluster]));
const baseStyles = COLORS.map((color) => ({ url: dotDataUrl(color), anchor: new AMap.Pixel(9, 9), size: new AMap.Size(18, 18) }));
const clusterCounts = [...new Set((monitorMap?.clusters ?? []).map((cluster) => cluster.count))].sort((a, b) => a - b);
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]);
const clusterStyleIndexes = new Map(clusterCounts.map((count, index) => [count, COLORS.length + index]));
const data: AMapMassPoint[] = monitorMap ? [
...monitorMap.clusters.map((cluster) => ({ lnglat: wgs84ToGcj02(cluster.longitude, cluster.latitude), style: clusterStyleIndexes.get(cluster.count) ?? COLORS.length, id: cluster.id, label: `${cluster.count}` })),
...monitorMap.points.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 }))
] : points.map((vehicle) => ({
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
}));
mass.setData(data);
}, [monitorMap, points, 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;
labels.clear();
denseLabels.clear();
if (!showLabels && !selectedVin) {
labels.setMap(null);
denseLabels.setMap(null);
return;
}
const mapLabelPoints = (monitorMap
? monitorMap.points
: points.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 activeLabels = showEveryPlate ? denseLabels : labels;
labels.setMap(showEveryPlate ? null : map);
denseLabels.setMap(showEveryPlate ? map : null);
const densePlacements = showEveryPlate ? densePlatePlacements(labelPoints) : null;
const markers = labelPoints.map((point) => {
const placement = densePlacements?.get(point.vin);
return new AMap.LabelMarker!({
name: point.plate || point.vin,
position: wgs84ToGcj02(point.longitude, point.latitude),
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
}
}
});
});
if (markers.length) activeLabels.add(markers);
}, [mapZoom, monitorMap, points, 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 mapPosition = wgs84ToGcj02(target.longitude, target.latitude);
const selectionKey = `${selectedVin}|${label}`;
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, 650);
centeredVinRef.current = selectedVin;
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
mapRef.current.panTo?.(mapPosition, 650);
}
selectionPositionRef.current = positionKey;
if (selectionKeyRef.current === selectionKey && selectionRef.current) {
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" 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), 650);
}
};
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' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : 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>
);
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryLocationRow, TrackPlaybackEvent } from '../../api/types';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
function markerContent(kind: string, label?: string) {
if (kind === 'current') return '<div class="v2-track-current-marker"><span></span></div>';
return `<div class="v2-track-marker is-${kind}">${label ?? ''}</div>`;
}
export function TrackMap({ points, events, activeIndex, onSelectIndex }: {
points: HistoryLocationRow[];
events: TrackPlaybackEvent[];
activeIndex: number;
onSelectIndex: (index: number) => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<AMapMap | null>(null);
const amapRef = useRef<AMapLike | null>(null);
const overlaysRef = useRef<AMapOverlay[]>([]);
const currentMarkerRef = useRef<AMapOverlay | null>(null);
const selectRef = useRef(onSelectIndex);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]);
useEffect(() => {
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) { setState('fallback'); return; }
let cancelled = false;
loadAMap(['AMap.Scale', 'AMap.ToolBar']).then((AMap) => {
if (cancelled || !containerRef.current) return;
const first = valid[0]?.point;
const map = new AMap.Map(containerRef.current, {
zoom: first ? 13 : 5,
center: first ? wgs84ToGcj02(first.longitude, first.latitude) : wgs84ToGcj02(105.4, 35.9),
viewMode: '2D', mapStyle: 'amap://styles/whitesmoke', showLabel: true, resizeEnable: true
});
map.addControl(new AMap.Scale());
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '22px' } }));
mapRef.current = map;
amapRef.current = AMap;
setState('ready');
}).catch(() => { if (!cancelled) setState('error'); });
return () => {
cancelled = true;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
mapRef.current?.destroy();
overlaysRef.current = [];
currentMarkerRef.current = null;
mapRef.current = null;
amapRef.current = null;
};
}, []);
useEffect(() => {
const AMap = amapRef.current;
if (state !== 'ready' || !mapRef.current || !valid.length || !AMap) return;
overlaysRef.current.forEach((overlay) => overlay.setMap?.(null));
currentMarkerRef.current?.setMap?.(null);
const path = valid.map(({ point }) => wgs84ToGcj02(point.longitude, point.latitude));
const polyline = new AMap.Polyline({ path, strokeColor: '#1268f3', strokeWeight: 5, strokeOpacity: 0.92, lineJoin: 'round', lineCap: 'round', showDir: true, zIndex: 80 });
const first = valid[0];
const last = valid[valid.length - 1];
const overlays: AMapOverlay[] = [polyline];
const start = new AMap.Marker({ position: wgs84ToGcj02(first.point.longitude, first.point.latitude), anchor: 'center', content: markerContent('start', '始'), zIndex: 110 });
const end = new AMap.Marker({ position: wgs84ToGcj02(last.point.longitude, last.point.latitude), anchor: 'center', content: markerContent('end', '终'), zIndex: 110 });
start.on?.('click', () => selectRef.current(first.index));
end.on?.('click', () => selectRef.current(last.index));
overlays.push(start, end);
events.slice(1, -1).forEach((event, eventIndex) => {
if (!isValidAMapCoordinate(event.longitude, event.latitude)) return;
const exactIndex = points.findIndex((point) => point.deviceTime === event.time);
const targetIndex = exactIndex >= 0 ? exactIndex : points.reduce((closest, point, index) => {
const best = points[closest];
const distance = (point.longitude - event.longitude) ** 2 + (point.latitude - event.latitude) ** 2;
const bestDistance = (best.longitude - event.longitude) ** 2 + (best.latitude - event.latitude) ** 2;
return distance < bestDistance ? index : closest;
}, 0);
const marker = new AMap.Marker({ position: wgs84ToGcj02(event.longitude, event.latitude), anchor: 'center', content: markerContent('event', String(eventIndex + 1)), zIndex: 105 });
marker.on?.('click', () => selectRef.current(targetIndex));
overlays.push(marker);
});
const active = valid.find(({ index }) => index === activeIndex) ?? first;
const current = new AMap.Marker({ position: wgs84ToGcj02(active.point.longitude, active.point.latitude), anchor: 'center', content: markerContent('current'), zIndex: 130 });
currentMarkerRef.current = current;
overlaysRef.current = overlays;
mapRef.current.add([...overlays, current]);
mapRef.current.setFitView(overlays, false, [52, 52, 52, 52]);
}, [events, points, state, valid]);
useEffect(() => {
const point = points[activeIndex];
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
currentMarkerRef.current?.setPosition?.(wgs84ToGcj02(point.longitude, point.latitude));
}, [activeIndex, points]);
return <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />
{state !== 'ready' ? <div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
</div> : null}
<div className="v2-track-map-legend"><span><i className="is-start" /></span><span><i className="is-current" /></span><span><i className="is-end" /></span><b>{valid.length} </b></div>
</div>;
}