feat: build vehicle data platform and production pipeline
This commit is contained in:
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal file
351
vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx
Normal 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();
|
||||
});
|
||||
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal file
442
vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[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>
|
||||
);
|
||||
}
|
||||
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal file
108
vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user