feat(monitor): aggregate nationwide fleet by province
This commit is contained in:
@@ -25,13 +25,14 @@ type MonitorSummary struct {
|
||||
}
|
||||
|
||||
type MonitorMapResponse struct {
|
||||
Mode string `json:"mode"`
|
||||
Zoom int `json:"zoom"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Points []MonitorMapPoint `json:"points"`
|
||||
Clusters []MonitorMapCluster `json:"clusters"`
|
||||
AsOf string `json:"asOf"`
|
||||
Mode string `json:"mode"`
|
||||
Zoom int `json:"zoom"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Points []MonitorMapPoint `json:"points"`
|
||||
Clusters []MonitorMapCluster `json:"clusters"`
|
||||
ProvincePoints []MonitorMapProvincePoint `json:"provincePoints"`
|
||||
AsOf string `json:"asOf"`
|
||||
}
|
||||
|
||||
type MonitorWorkspaceResponse struct {
|
||||
@@ -69,6 +70,16 @@ type MonitorMapCluster struct {
|
||||
Unknown int `json:"unknown"`
|
||||
}
|
||||
|
||||
// MonitorMapProvincePoint is deliberately compact and identity-free. At the
|
||||
// nationwide zoom the browser groups these already-scoped coordinates with
|
||||
// AMap's cached administrative boundary node, while Clusters remain available
|
||||
// as a no-blank-map fallback if the boundary resource cannot be loaded.
|
||||
type MonitorMapProvincePoint struct {
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TrackPlaybackResponse struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
|
||||
@@ -373,6 +373,7 @@ const (
|
||||
monitorVehicleLimit = 10000
|
||||
monitorPointLimit = 2000
|
||||
monitorPointZoom = 11
|
||||
monitorProvinceMaxZoom = 5
|
||||
monitorClusterGridPixels = 64
|
||||
vehicleSearchKeywordLimit = 100
|
||||
)
|
||||
@@ -606,17 +607,19 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
if zoom > 20 {
|
||||
zoom = 20
|
||||
}
|
||||
provinceMode := zoom <= monitorProvinceMaxZoom
|
||||
bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds"))
|
||||
if err != nil {
|
||||
return MonitorMapResponse{}, err
|
||||
}
|
||||
result := MonitorMapResponse{
|
||||
Mode: "clusters",
|
||||
Zoom: zoom,
|
||||
Truncated: vehicles.Total > len(vehicles.Items),
|
||||
Points: []MonitorMapPoint{},
|
||||
Clusters: []MonitorMapCluster{},
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
Mode: "clusters",
|
||||
Zoom: zoom,
|
||||
Truncated: vehicles.Total > len(vehicles.Items),
|
||||
Points: []MonitorMapPoint{},
|
||||
Clusters: []MonitorMapCluster{},
|
||||
ProvincePoints: []MonitorMapProvincePoint{},
|
||||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
points := make([]MonitorMapPoint, 0, len(vehicles.Items))
|
||||
for _, vehicle := range vehicles.Items {
|
||||
@@ -627,7 +630,17 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
continue
|
||||
}
|
||||
result.Total++
|
||||
if hasBounds && !bounds.contains(vehicle.Longitude, vehicle.Latitude) {
|
||||
status := monitorVehicleStatus(vehicle)
|
||||
if provinceMode {
|
||||
result.ProvincePoints = append(result.ProvincePoints, MonitorMapProvincePoint{
|
||||
Longitude: vehicle.Longitude,
|
||||
Latitude: vehicle.Latitude,
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
// Nationwide province counts describe the complete filtered fleet, not
|
||||
// only the current slippy-map rectangle. Bounds resume after drill-down.
|
||||
if hasBounds && !provinceMode && !bounds.contains(vehicle.Longitude, vehicle.Latitude) {
|
||||
continue
|
||||
}
|
||||
points = append(points, MonitorMapPoint{
|
||||
@@ -637,7 +650,7 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
ReportIntervalMs: vehicle.ReportIntervalMs,
|
||||
LocationSource: vehicle.LocationSource,
|
||||
LocationConflict: vehicle.LocationConflict,
|
||||
Status: monitorVehicleStatus(vehicle),
|
||||
Status: status,
|
||||
})
|
||||
}
|
||||
if zoom >= monitorPointZoom && len(points) <= monitorPointLimit {
|
||||
@@ -708,6 +721,9 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
||||
result.Mode = "points"
|
||||
}
|
||||
}
|
||||
if provinceMode {
|
||||
result.Mode = "provinces"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -541,6 +541,33 @@ func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorMapNationwideReturnsScopedProvinceSeedsAndGridFallback(t *testing.T) {
|
||||
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
|
||||
{VIN: "GUANGDONG-1", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130},
|
||||
{VIN: "GUANGDONG-2", Online: false, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135, LastSeen: "2026-07-15T08:00:00+08:00"},
|
||||
{VIN: "OUTSIDE-STATUS", Online: true, LocationAvailable: true, Longitude: 121.47, Latitude: 31.23, SpeedKmh: 20},
|
||||
{VIN: "NO-LOCATION", Online: true},
|
||||
}, Total: 4, Limit: 4}
|
||||
|
||||
result, err := buildMonitorMapResponse(vehicles, url.Values{
|
||||
"zoom": {"5"},
|
||||
"status": {"offline"},
|
||||
"bounds": {"113,22,114,24"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Mode != "provinces" || result.Total != 1 || len(result.ProvincePoints) != 1 {
|
||||
t.Fatalf("nationwide response must expose only filtered, located province seeds: %+v", result)
|
||||
}
|
||||
if result.ProvincePoints[0].Status != "offline" || result.ProvincePoints[0].Longitude != 113.265 {
|
||||
t.Fatalf("unexpected province seed: %+v", result.ProvincePoints[0])
|
||||
}
|
||||
if len(result.Points)+len(result.Clusters) == 0 {
|
||||
t.Fatal("nationwide response must retain a grid fallback when AMap district data is unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) {
|
||||
vehicles := syntheticMonitorVehicles(10_000)
|
||||
query := url.Values{"zoom": {"5"}}
|
||||
|
||||
@@ -123,13 +123,20 @@ export interface MonitorMapCluster {
|
||||
unknown: number;
|
||||
}
|
||||
|
||||
export interface MonitorMapProvincePoint {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
status: 'driving' | 'idle' | 'offline' | 'unknown';
|
||||
}
|
||||
|
||||
export interface MonitorMapResponse {
|
||||
mode: 'clusters' | 'mixed' | 'points';
|
||||
mode: 'provinces' | 'clusters' | 'mixed' | 'points';
|
||||
zoom: number;
|
||||
total: number;
|
||||
truncated: boolean;
|
||||
points: MonitorMapPoint[];
|
||||
clusters: MonitorMapCluster[];
|
||||
provincePoints?: MonitorMapProvincePoint[];
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ export type AMapMap = {
|
||||
|
||||
export type AMapOverlay = {
|
||||
setMap?: (map: AMapMap | null) => void;
|
||||
on?: (eventName: string, handler: () => void) => void;
|
||||
off?: (eventName: string, handler: () => void) => void;
|
||||
on?: (eventName: string, handler: (event?: unknown) => void) => void;
|
||||
off?: (eventName: string, handler: (event?: unknown) => void) => void;
|
||||
setPosition?: (position: [number, number]) => void;
|
||||
setPath?: (path: [number, number][]) => void;
|
||||
};
|
||||
@@ -77,6 +77,32 @@ export type AMapAddress = {
|
||||
adcode?: string;
|
||||
};
|
||||
|
||||
export type AMapDistrictFeature = {
|
||||
properties: {
|
||||
adcode: number;
|
||||
name: string;
|
||||
center?: [number, number];
|
||||
};
|
||||
};
|
||||
|
||||
export type AMapDistrictGroup<T> = {
|
||||
subFeatureIndex: number;
|
||||
subFeature?: AMapDistrictFeature | null;
|
||||
pointsIndexes: number[];
|
||||
points: T[];
|
||||
};
|
||||
|
||||
export type AMapAreaNode = {
|
||||
getSubFeatures: () => AMapDistrictFeature[];
|
||||
groupByPosition: <T>(points: T[], getPosition: (point: T, index: number) => [number, number]) => AMapDistrictGroup<T>[];
|
||||
};
|
||||
|
||||
export type AMapDistrictExplorer = {
|
||||
loadAreaNode: (adcode: number, callback: (error: Error | null, areaNode?: AMapAreaNode) => void) => void;
|
||||
};
|
||||
|
||||
export type AMapDistrictExplorerConstructor = new (options?: Record<string, unknown>) => AMapDistrictExplorer;
|
||||
|
||||
export type AMapLike = {
|
||||
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
@@ -135,6 +161,43 @@ function loadScript(src: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadAMapUIScript() {
|
||||
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (window.AMapUI) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
|
||||
if (existing) {
|
||||
if (existing.dataset.loadState === 'loaded') {
|
||||
existing.remove();
|
||||
loadAMapUIScript().then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
existing.addEventListener('load', () => resolve(), { once: true });
|
||||
existing.addEventListener('error', () => {
|
||||
existing.remove();
|
||||
reject(new Error('高德行政区组件加载失败'));
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = true;
|
||||
script.dataset.loadState = 'loading';
|
||||
script.onload = () => {
|
||||
script.dataset.loadState = 'loaded';
|
||||
resolve();
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.remove();
|
||||
reject(new Error('高德行政区组件加载失败'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
function pluginKey(plugins: AMapPlugin[]) {
|
||||
return plugins.slice().sort().join('|') || 'base';
|
||||
}
|
||||
@@ -257,6 +320,24 @@ export function loadAMap(plugins: AMapPlugin[] = ['AMap.Scale']) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
export async function loadAMapDistrictExplorer(): Promise<AMapDistrictExplorerConstructor> {
|
||||
await loadAMapUIScript();
|
||||
if (!window.AMapUI) {
|
||||
throw new Error('高德行政区组件不可用');
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => reject(new Error('高德行政区组件加载超时')), 10_000);
|
||||
window.AMapUI!.loadUI(['geo/DistrictExplorer'], (constructor) => {
|
||||
window.clearTimeout(timeout);
|
||||
if (typeof constructor !== 'function') {
|
||||
reject(new Error('高德行政区组件返回异常'));
|
||||
return;
|
||||
}
|
||||
resolve(constructor as AMapDistrictExplorerConstructor);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function reverseGeocodeWithAMap(longitude: number, latitude: number): Promise<AMapAddress> {
|
||||
if (!isValidAMapCoordinate(longitude, latitude)) {
|
||||
throw new Error('坐标不可用');
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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 { advancePointMotions, FleetMap, interpolateMassPoints, massPointFingerprint, movingPointIDs, pointMoveDurationMs, pointMoveFrameMs } from './FleetMap';
|
||||
import { wgs84ToGcj02, type AMapAreaNode, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
|
||||
import { advancePointMotions, FleetMap, groupProvincePoints, interpolateMassPoints, massPointFingerprint, movingPointIDs, pointMoveDurationMs, pointMoveFrameMs, provinceMarkerPlacements } from './FleetMap';
|
||||
|
||||
const setData = vi.fn<(data: AMapMassPoint[]) => void>();
|
||||
const setStyle = vi.fn();
|
||||
@@ -23,6 +23,7 @@ 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 markerInstances: TestMarker[] = [];
|
||||
const mapOptions: Record<string, unknown>[] = [];
|
||||
const labelLayerOptions: Record<string, unknown>[] = [];
|
||||
|
||||
@@ -78,8 +79,12 @@ class TestLabelMarker {
|
||||
class TestMarker {
|
||||
setMap = markerSetMap;
|
||||
setPosition = markerSetPosition;
|
||||
handlers = new Map<string, (event?: unknown) => void>();
|
||||
on = vi.fn((event: string, handler: (value?: unknown) => void) => this.handlers.set(event, handler));
|
||||
off = vi.fn((event: string) => this.handlers.delete(event));
|
||||
constructor(options: Record<string, unknown>) {
|
||||
markerOptions.push(options);
|
||||
markerInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +143,35 @@ const pointMap: MonitorMapResponse = {
|
||||
}]
|
||||
};
|
||||
|
||||
const provinceMap: MonitorMapResponse = {
|
||||
...monitorMap,
|
||||
mode: 'provinces',
|
||||
total: 3,
|
||||
provincePoints: [
|
||||
{ longitude: 113.26, latitude: 23.13, status: 'driving' },
|
||||
{ longitude: 113.27, latitude: 23.14, status: 'idle' },
|
||||
{ longitude: 113.28, latitude: 23.15, status: 'offline' }
|
||||
]
|
||||
};
|
||||
|
||||
const provinceAreaNode: AMapAreaNode = {
|
||||
getSubFeatures: () => [],
|
||||
groupByPosition: (points) => [
|
||||
{
|
||||
subFeatureIndex: 0,
|
||||
subFeature: { properties: { adcode: 440000, name: '广东省', center: [113.266, 23.132] } },
|
||||
pointsIndexes: points.map((_, index) => index),
|
||||
points
|
||||
},
|
||||
{
|
||||
subFeatureIndex: 1,
|
||||
subFeature: { properties: { adcode: 110000, name: '北京市', center: [116.405, 39.905] } },
|
||||
pointsIndexes: [],
|
||||
points: []
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
test('keeps large fleet redraw fingerprints constant-size and sensitive to point changes', () => {
|
||||
const manyPoints = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
...pointMap.points[index % pointMap.points.length],
|
||||
@@ -232,6 +266,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
delete window.__LINGNIU_APP_CONFIG__;
|
||||
delete window.AMapLoader;
|
||||
delete window.AMapUI;
|
||||
setData.mockReset();
|
||||
setStyle.mockReset();
|
||||
addLabels.mockReset();
|
||||
@@ -253,11 +288,42 @@ afterEach(() => {
|
||||
getBounds.mockReturnValue({});
|
||||
mapHandlers.clear();
|
||||
markerOptions.length = 0;
|
||||
markerInstances.length = 0;
|
||||
mapOptions.length = 0;
|
||||
labelLayerOptions.length = 0;
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('groups nationwide seeds by official province features and preserves status counts', () => {
|
||||
const grouped = groupProvincePoints(provinceAreaNode, provinceMap.provincePoints ?? []);
|
||||
expect(grouped.unlocated).toBe(0);
|
||||
expect(grouped.aggregates).toEqual([expect.objectContaining({
|
||||
adcode: '440000',
|
||||
name: '广东省',
|
||||
longitude: 113.266,
|
||||
latitude: 23.132,
|
||||
count: 3,
|
||||
online: 2,
|
||||
offline: 1,
|
||||
driving: 1,
|
||||
idle: 1,
|
||||
unknown: 0
|
||||
})]);
|
||||
});
|
||||
|
||||
test('keeps dense province totals visible with deterministic pixel-space avoidance', () => {
|
||||
const base = groupProvincePoints(provinceAreaNode, provinceMap.provincePoints ?? []).aggregates[0];
|
||||
const placements = provinceMarkerPlacements([
|
||||
base,
|
||||
{ ...base, adcode: '310000', name: '上海市', count: 2 },
|
||||
{ ...base, adcode: '320000', name: '江苏省', count: 1 }
|
||||
], 5);
|
||||
expect(placements.get(base.adcode)).toEqual({ dx: 0, dy: 0 });
|
||||
expect(new Set([...placements.values()].map((placement) => `${placement.dx}:${placement.dy}`)).size).toBe(3);
|
||||
expect(provinceMarkerPlacements([base], 5)).toEqual(new Map([[base.adcode, { dx: 0, dy: 0 }]]));
|
||||
});
|
||||
|
||||
test('recovers in place after a transient AMap failure and renders data that arrived while retrying', async () => {
|
||||
let resolveAMap!: (value: AMapLike) => void;
|
||||
const delayedAMap = new Promise<AMapLike>((resolve) => {
|
||||
@@ -319,6 +385,17 @@ test('restores the saved monitor viewport without forcing the selected vehicle z
|
||||
expect(panTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts a fresh mobile nationwide map one zoom level wider', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 });
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
|
||||
render(<FleetMap vehicles={[]} monitorMap={monitorMap} onSelect={() => undefined} />);
|
||||
|
||||
await waitFor(() => expect(mapOptions).toHaveLength(1));
|
||||
expect(mapOptions[0]).toEqual(expect.objectContaining({ zoom: 4 }));
|
||||
});
|
||||
|
||||
test('detaches AMap listeners and destroys the map on unmount', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
@@ -604,3 +681,29 @@ test('renders every nearby plate with staggered positions at maximum zoom', asyn
|
||||
expect(selectedDenseMarkers.every((marker) => marker.options.icon == null)).toBe(true);
|
||||
expect(selectedDenseMarkers.find((marker) => marker.options.rank === 100)).toBeDefined();
|
||||
});
|
||||
|
||||
test('renders crisp province totals and drills into the existing cluster layer', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
const loadUI = vi.fn((_modules: string[], callback: (...constructors: unknown[]) => void) => {
|
||||
callback(class TestDistrictExplorer {
|
||||
loadAreaNode(_adcode: number, callbackArea: (error: Error | null, areaNode?: AMapAreaNode) => void) {
|
||||
callbackArea(null, provinceAreaNode);
|
||||
}
|
||||
});
|
||||
});
|
||||
window.AMapUI = { loadUI };
|
||||
|
||||
render(<FleetMap vehicles={[]} monitorMap={provinceMap} onSelect={() => undefined} />);
|
||||
|
||||
await waitFor(() => expect(markerOptions.some((options) => String(options.content).includes('v2-province-marker'))).toBe(true));
|
||||
expect(loadUI).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText(/1 个省级区域 · 3 辆/)).toBeInTheDocument();
|
||||
expect(setData).toHaveBeenLastCalledWith([]);
|
||||
const provinceIndex = markerOptions.findIndex((options) => String(options.content).includes('广东省'));
|
||||
expect(String(markerOptions[provinceIndex]?.content)).toContain('<strong>3</strong>');
|
||||
expect(String(markerOptions[provinceIndex]?.content)).toContain('在线 2');
|
||||
|
||||
markerInstances[provinceIndex]?.handlers.get('click')?.();
|
||||
expect(setZoomAndCenter).toHaveBeenLastCalledWith(7, [113.266, 23.132]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
gcj02ToWgs84,
|
||||
isValidAMapCoordinate,
|
||||
loadAMap,
|
||||
loadAMapDistrictExplorer,
|
||||
wgs84ToGcj02,
|
||||
type AMapAreaNode,
|
||||
type AMapMap,
|
||||
type AMapLabelsLayer,
|
||||
type AMapLike,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
type AMapMassPoint,
|
||||
type AMapOverlay
|
||||
} from '../../integrations/amap';
|
||||
import type { MonitorMapResponse, VehicleRealtimeRow } from '../../api/types';
|
||||
import type { MonitorMapProvincePoint, MonitorMapResponse, VehicleRealtimeRow } from '../../api/types';
|
||||
import { vehicleStatus } from '../domain/monitor';
|
||||
import type { MonitorViewport } from '../hooks/useMonitorData';
|
||||
|
||||
@@ -27,7 +29,9 @@ 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 PROVINCE_DRILL_ZOOM = 7;
|
||||
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
|
||||
let chinaProvinceAreaNodePromise: Promise<AMapAreaNode> | undefined;
|
||||
|
||||
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>`;
|
||||
@@ -121,8 +125,197 @@ type LabelMarkerEntry = {
|
||||
position: [number, number];
|
||||
};
|
||||
|
||||
type ProvinceAggregate = {
|
||||
adcode: string;
|
||||
name: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
count: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
driving: number;
|
||||
idle: number;
|
||||
unknown: number;
|
||||
};
|
||||
|
||||
type ProvinceMarkerEntry = {
|
||||
marker: AMapOverlay;
|
||||
signature: string;
|
||||
click: () => void;
|
||||
};
|
||||
|
||||
type ProvinceMarkerPlacement = {
|
||||
dx: number;
|
||||
dy: number;
|
||||
};
|
||||
|
||||
type ProvinceWorldBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
};
|
||||
|
||||
const NO_VEHICLE_POINTS: VehicleRealtimeRow[] = [];
|
||||
|
||||
function chinaProvinceAreaNode() {
|
||||
if (chinaProvinceAreaNodePromise) return chinaProvinceAreaNodePromise;
|
||||
chinaProvinceAreaNodePromise = loadAMapDistrictExplorer().then((DistrictExplorer) => {
|
||||
const explorer = new DistrictExplorer({ eventSupport: false });
|
||||
return new Promise<AMapAreaNode>((resolve, reject) => {
|
||||
explorer.loadAreaNode(100000, (error, areaNode) => {
|
||||
if (error || !areaNode) {
|
||||
reject(error ?? new Error('全国省级边界不可用'));
|
||||
return;
|
||||
}
|
||||
resolve(areaNode);
|
||||
});
|
||||
});
|
||||
}).catch((error) => {
|
||||
chinaProvinceAreaNodePromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
return chinaProvinceAreaNodePromise;
|
||||
}
|
||||
|
||||
export function groupProvincePoints(areaNode: AMapAreaNode, points: MonitorMapProvincePoint[]) {
|
||||
let unlocated = 0;
|
||||
const groups = areaNode.groupByPosition(points, (point) => wgs84ToGcj02(point.longitude, point.latitude));
|
||||
const aggregates: ProvinceAggregate[] = [];
|
||||
for (const group of groups) {
|
||||
if (group.points.length === 0) continue;
|
||||
const feature = group.subFeature;
|
||||
if (group.subFeatureIndex < 0 || !feature) {
|
||||
unlocated += group.points.length;
|
||||
continue;
|
||||
}
|
||||
const center = feature.properties.center;
|
||||
const positions = group.points.map((point) => wgs84ToGcj02(point.longitude, point.latitude));
|
||||
const longitude = center?.[0] ?? positions.reduce((sum, point) => sum + point[0], 0) / positions.length;
|
||||
const latitude = center?.[1] ?? positions.reduce((sum, point) => sum + point[1], 0) / positions.length;
|
||||
const aggregate: ProvinceAggregate = {
|
||||
adcode: String(feature.properties.adcode),
|
||||
name: feature.properties.name,
|
||||
longitude,
|
||||
latitude,
|
||||
count: group.points.length,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
driving: 0,
|
||||
idle: 0,
|
||||
unknown: 0
|
||||
};
|
||||
for (const point of group.points) {
|
||||
if (point.status === 'driving') {
|
||||
aggregate.driving += 1;
|
||||
aggregate.online += 1;
|
||||
} else if (point.status === 'idle') {
|
||||
aggregate.idle += 1;
|
||||
aggregate.online += 1;
|
||||
} else if (point.status === 'offline') {
|
||||
aggregate.offline += 1;
|
||||
} else {
|
||||
aggregate.unknown += 1;
|
||||
}
|
||||
}
|
||||
aggregates.push(aggregate);
|
||||
}
|
||||
aggregates.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, 'zh-CN'));
|
||||
return { aggregates, unlocated };
|
||||
}
|
||||
|
||||
function mercatorWorldPoint(longitude: number, latitude: number, zoom: number): [number, number] {
|
||||
const size = 256 * Math.pow(2, zoom);
|
||||
const constrainedLatitude = Math.max(-85.05112878, Math.min(85.05112878, latitude));
|
||||
const radianLatitude = constrainedLatitude * Math.PI / 180;
|
||||
return [
|
||||
(longitude + 180) / 360 * size,
|
||||
(1 - Math.log(Math.tan(radianLatitude) + 1 / Math.cos(radianLatitude)) / Math.PI) / 2 * size
|
||||
];
|
||||
}
|
||||
|
||||
export function provinceMarkerPlacements(provinces: ProvinceAggregate[], zoom: number, viewport?: ProvinceWorldBounds, compact = false) {
|
||||
const width = compact ? 68 : 92;
|
||||
const height = compact ? 42 : 52;
|
||||
const horizontalStep = compact ? 72 : 96;
|
||||
const verticalStep = compact ? 46 : 56;
|
||||
const candidates: ProvinceMarkerPlacement[] = [{ dx: 0, dy: 0 }];
|
||||
for (let radius = 1; radius <= 5; radius += 1) {
|
||||
const dx = horizontalStep * radius;
|
||||
const dy = verticalStep * radius;
|
||||
candidates.push(
|
||||
{ dx: 0, dy: -dy },
|
||||
{ dx, dy: 0 },
|
||||
{ dx: 0, dy },
|
||||
{ dx: -dx, dy: 0 },
|
||||
{ dx, dy: -dy },
|
||||
{ dx, dy },
|
||||
{ dx: -dx, dy },
|
||||
{ dx: -dx, dy: -dy }
|
||||
);
|
||||
}
|
||||
const boxes: Array<{ left: number; top: number; right: number; bottom: number }> = [];
|
||||
const placements = new Map<string, ProvinceMarkerPlacement>();
|
||||
for (const province of provinces) {
|
||||
const [anchorX, anchorY] = mercatorWorldPoint(province.longitude, province.latitude, zoom);
|
||||
let selected = candidates[0];
|
||||
let selectedBox: { left: number; top: number; right: number; bottom: number } | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const box = {
|
||||
left: anchorX + candidate.dx - width / 2,
|
||||
top: anchorY + candidate.dy - height / 2,
|
||||
right: anchorX + candidate.dx + width / 2,
|
||||
bottom: anchorY + candidate.dy + height / 2
|
||||
};
|
||||
const outsideViewport = viewport && (
|
||||
box.left < viewport.left + 8
|
||||
|| box.right > viewport.right - 8
|
||||
|| box.top < viewport.top + 8
|
||||
|| box.bottom > viewport.bottom - 8
|
||||
);
|
||||
if (outsideViewport) continue;
|
||||
const overlaps = boxes.some((placed) => (
|
||||
Math.max(box.left, placed.left) < Math.min(box.right, placed.right)
|
||||
&& Math.max(box.top, placed.top) < Math.min(box.bottom, placed.bottom)
|
||||
));
|
||||
if (!overlaps) {
|
||||
selected = candidate;
|
||||
selectedBox = box;
|
||||
break;
|
||||
}
|
||||
}
|
||||
boxes.push(selectedBox ?? {
|
||||
left: anchorX + selected.dx - width / 2,
|
||||
top: anchorY + selected.dy - height / 2,
|
||||
right: anchorX + selected.dx + width / 2,
|
||||
bottom: anchorY + selected.dy + height / 2
|
||||
});
|
||||
placements.set(province.adcode, selected);
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
function compactProvinceName(name: string) {
|
||||
return name
|
||||
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
|
||||
.replace(/特别行政区$/, '')
|
||||
.replace(/[省市]$/, '');
|
||||
}
|
||||
|
||||
function provinceWorldBoundsFromMap(map: AMapMap, zoom: number): ProvinceWorldBounds | undefined {
|
||||
const bounds = map.getBounds?.();
|
||||
const southWest = bounds?.getSouthWest?.();
|
||||
const northEast = bounds?.getNorthEast?.();
|
||||
const west = southWest?.getLng?.();
|
||||
const south = southWest?.getLat?.();
|
||||
const east = northEast?.getLng?.();
|
||||
const north = northEast?.getLat?.();
|
||||
if (![west, south, east, north].every((value) => Number.isFinite(value))) return undefined;
|
||||
const [left, bottom] = mercatorWorldPoint(Number(west), Number(south), zoom);
|
||||
const [right, top] = mercatorWorldPoint(Number(east), Number(north), zoom);
|
||||
return { left, top, right, bottom };
|
||||
}
|
||||
|
||||
function createPointFingerprint() {
|
||||
let left = 0x811c9dc5;
|
||||
let right = 0x9e3779b9;
|
||||
@@ -295,6 +488,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
const denseLabelsRef = useRef<AMapLabelsLayer | null>(null);
|
||||
const selectionRef = useRef<AMapOverlay | null>(null);
|
||||
const labelMarkersRef = useRef(new Map<string, LabelMarkerEntry>());
|
||||
const provinceMarkersRef = useRef(new Map<string, ProvinceMarkerEntry>());
|
||||
const labelLayerModeRef = useRef<'normal' | 'dense' | ''>('');
|
||||
const labelLayerStateRef = useRef('');
|
||||
const massDataSignatureRef = useRef('__unset__');
|
||||
@@ -321,19 +515,31 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [followSelected, setFollowSelected] = useState(true);
|
||||
const [mapZoom, setMapZoom] = useState(initialViewport?.zoom ?? 5);
|
||||
const [provinceState, setProvinceState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
||||
const [provinceAggregates, setProvinceAggregates] = useState<ProvinceAggregate[]>([]);
|
||||
const [unlocatedProvincePoints, setUnlocatedProvincePoints] = useState(0);
|
||||
const [provinceWorldBounds, setProvinceWorldBounds] = useState<ProvinceWorldBounds>();
|
||||
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 provincePoints = monitorMap?.provincePoints;
|
||||
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 provinceReady = monitorMode === 'provinces' && provinceState === 'ready' && provinceAggregates.length > 0;
|
||||
const mapComposition = monitorMode === 'provinces'
|
||||
? provinceReady
|
||||
? `${provinceAggregates.length} 个省级区域 · ${monitorMap?.total ?? 0} 辆${unlocatedProvincePoints ? ` · ${unlocatedProvincePoints} 辆待定位` : ''}`
|
||||
: provinceState === 'error'
|
||||
? `省级边界不可用 · 已降级为 ${renderedClusterCount} 个网格`
|
||||
: `省级归属加载中 · ${monitorMap?.total ?? 0} 辆`
|
||||
: monitorMap && renderedClusterCount > 0
|
||||
? `${renderedClusterCount} 个聚合 · ${renderedPointCount} 个车辆点 · ${monitorMap.total} 辆`
|
||||
: `${renderedPointCount} 个有效点位`;
|
||||
const initialSelectionRef = useRef(points.find((vehicle) => vehicle.vin === selectedVin));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -365,17 +571,21 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
if (cancelled || !containerRef.current) return;
|
||||
const initialSelection = initialSelectionRef.current;
|
||||
const restoredCenter = viewportCenter(initialViewportRef.current);
|
||||
const availableWidth = containerRef.current.clientWidth || window.innerWidth;
|
||||
const defaultNationwideZoom = availableWidth <= 600 ? 4 : 5;
|
||||
const initialZoom = initialViewportRef.current?.zoom ?? (initialSelection ? 13 : defaultNationwideZoom);
|
||||
const initialCenter = restoredCenter ?? (initialSelection
|
||||
? wgs84ToGcj02(initialSelection.longitude, initialSelection.latitude)
|
||||
: wgs84ToGcj02(105.4, 35.9));
|
||||
const map = new AMap.Map(containerRef.current, {
|
||||
zoom: initialViewportRef.current?.zoom ?? (initialSelection ? 13 : 5),
|
||||
zoom: initialZoom,
|
||||
center: initialCenter,
|
||||
viewMode: '2D',
|
||||
mapStyle: 'amap://styles/whitesmoke',
|
||||
showLabel: true,
|
||||
resizeEnable: true
|
||||
});
|
||||
setMapZoom(initialZoom);
|
||||
mapInstance = map;
|
||||
map.addControl(new AMap.Scale());
|
||||
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '18px', bottom: '76px' } }));
|
||||
@@ -404,7 +614,9 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
labels?.setMap(map);
|
||||
notifyViewport = () => {
|
||||
if (programmaticFollowRef.current) return;
|
||||
setMapZoom(map.getZoom?.() ?? 5);
|
||||
const currentZoom = map.getZoom?.() ?? defaultNationwideZoom;
|
||||
setMapZoom(currentZoom);
|
||||
setProvinceWorldBounds(provinceWorldBoundsFromMap(map, currentZoom));
|
||||
window.clearTimeout(viewportTimerRef.current);
|
||||
viewportTimerRef.current = window.setTimeout(() => {
|
||||
const viewport = viewportFromMap(map);
|
||||
@@ -460,12 +672,17 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
labelsRef.current?.setMap(null);
|
||||
denseLabelsRef.current?.setMap(null);
|
||||
selectionRef.current?.setMap?.(null);
|
||||
for (const entry of provinceMarkersRef.current.values()) {
|
||||
entry.marker.off?.('click', entry.click);
|
||||
entry.marker.setMap?.(null);
|
||||
}
|
||||
mapRef.current?.destroy();
|
||||
massRef.current = null;
|
||||
labelsRef.current = null;
|
||||
denseLabelsRef.current = null;
|
||||
selectionRef.current = null;
|
||||
labelMarkersRef.current.clear();
|
||||
provinceMarkersRef.current.clear();
|
||||
labelLayerModeRef.current = '';
|
||||
labelLayerStateRef.current = '';
|
||||
massDataSignatureRef.current = '__unset__';
|
||||
@@ -476,13 +693,98 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
};
|
||||
}, [loadAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (monitorMode !== 'provinces') {
|
||||
setProvinceState('idle');
|
||||
setProvinceAggregates([]);
|
||||
setUnlocatedProvincePoints(0);
|
||||
return;
|
||||
}
|
||||
const seeds = provincePoints ?? [];
|
||||
if (seeds.length === 0) {
|
||||
setProvinceState('ready');
|
||||
setProvinceAggregates([]);
|
||||
setUnlocatedProvincePoints(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setProvinceState('loading');
|
||||
chinaProvinceAreaNode().then((areaNode) => {
|
||||
if (cancelled) return;
|
||||
const grouped = groupProvincePoints(areaNode, seeds);
|
||||
setProvinceAggregates(grouped.aggregates);
|
||||
setUnlocatedProvincePoints(grouped.unlocated);
|
||||
setProvinceState('ready');
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setProvinceAggregates([]);
|
||||
setUnlocatedProvincePoints(0);
|
||||
setProvinceState('error');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [monitorMode, provincePoints]);
|
||||
|
||||
useEffect(() => {
|
||||
const AMap = amapRef.current;
|
||||
const map = mapRef.current;
|
||||
if (!AMap || !map) return;
|
||||
if (!provinceReady) {
|
||||
for (const entry of provinceMarkersRef.current.values()) {
|
||||
entry.marker.off?.('click', entry.click);
|
||||
entry.marker.setMap?.(null);
|
||||
}
|
||||
provinceMarkersRef.current.clear();
|
||||
return;
|
||||
}
|
||||
const next = new Map<string, ProvinceMarkerEntry>();
|
||||
const compactMarkers = (containerRef.current?.clientWidth || window.innerWidth) <= 600;
|
||||
const placements = provinceMarkerPlacements(provinceAggregates, mapZoom, provinceWorldBounds, compactMarkers);
|
||||
for (const province of provinceAggregates) {
|
||||
const placement = placements.get(province.adcode) ?? { dx: 0, dy: 0 };
|
||||
const connectorLength = Math.hypot(placement.dx, placement.dy);
|
||||
const connectorAngle = Math.atan2(placement.dy, placement.dx) * 180 / Math.PI;
|
||||
const signature = `${province.name}:${province.count}:${province.online}:${province.offline}:${province.driving}:${province.unknown}:${placement.dx}:${placement.dy}:${compactMarkers ? 1 : 0}`;
|
||||
const existing = provinceMarkersRef.current.get(province.adcode);
|
||||
if (existing?.signature === signature) {
|
||||
existing.marker.setPosition?.([province.longitude, province.latitude]);
|
||||
next.set(province.adcode, existing);
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
existing.marker.off?.('click', existing.click);
|
||||
existing.marker.setMap?.(null);
|
||||
}
|
||||
const onlinePercent = province.count ? Math.round(province.online / province.count * 100) : 0;
|
||||
const displayName = compactMarkers ? compactProvinceName(province.name) : province.name;
|
||||
const marker = new AMap.Marker({
|
||||
position: [province.longitude, province.latitude],
|
||||
offset: new AMap.Pixel(0, 0),
|
||||
zIndex: 180,
|
||||
content: `<div class="v2-province-placement" style="--province-x:${placement.dx}px;--province-y:${placement.dy}px;--province-line:${connectorLength}px;--province-angle:${connectorAngle}deg"><i class="v2-province-anchor"></i>${connectorLength ? '<i class="v2-province-connector"></i>' : ''}<button type="button" tabindex="-1" class="v2-province-marker${compactMarkers ? ' is-compact' : ''}" aria-label="${escapeHtml(province.name)} ${province.count} 辆"><span>${escapeHtml(displayName)}</span><strong>${province.count.toLocaleString('zh-CN')}</strong><small>在线 ${province.online.toLocaleString('zh-CN')}</small><i><b style="width:${onlinePercent}%"></b></i></button></div>`
|
||||
});
|
||||
const click = () => map.setZoomAndCenter?.(PROVINCE_DRILL_ZOOM, [province.longitude, province.latitude]);
|
||||
marker.on?.('click', click);
|
||||
marker.setMap?.(map);
|
||||
next.set(province.adcode, { marker, signature, click });
|
||||
}
|
||||
for (const [adcode, entry] of provinceMarkersRef.current) {
|
||||
if (next.has(adcode)) continue;
|
||||
entry.marker.off?.('click', entry.click);
|
||||
entry.marker.setMap?.(null);
|
||||
}
|
||||
provinceMarkersRef.current = next;
|
||||
}, [mapZoom, provinceAggregates, provinceReady, provinceWorldBounds, state]);
|
||||
|
||||
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]));
|
||||
clustersRef.current = provinceReady ? new Map() : 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) {
|
||||
@@ -494,10 +796,12 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
mass.setStyle?.([...baseStyles, ...clusterStyles]);
|
||||
massStyleSignatureRef.current = styleSignature;
|
||||
}
|
||||
const dataSignature = massPointFingerprint(monitorMode, monitorClusters, monitorPoints, fallbackPoints);
|
||||
const dataSignature = provinceReady
|
||||
? 'provinces-ready'
|
||||
: 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 ? [
|
||||
const data: AMapMassPoint[] = provinceReady ? [] : 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, sourceToken: point.locationSource || point.protocol }))
|
||||
] : fallbackPoints.map((vehicle) => ({
|
||||
@@ -575,7 +879,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
}
|
||||
};
|
||||
pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
|
||||
}, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, state]);
|
||||
}, [fallbackPoints, monitorClusters, monitorMode, monitorPoints, provinceReady, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const labels = labelsRef.current;
|
||||
@@ -737,11 +1041,11 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
className="v2-map-layer-control"
|
||||
aria-label="悬浮车牌"
|
||||
aria-pressed={showLabels}
|
||||
title={monitorMap?.mode === 'clusters' ? '放大地图后显示车辆车牌' : '显示或隐藏车辆悬浮车牌'}
|
||||
title={monitorMap?.mode === 'clusters' || monitorMap?.mode === 'provinces' ? '下钻地图后显示车辆车牌' : '显示或隐藏车辆悬浮车牌'}
|
||||
onClick={() => setShowLabels((current) => !current)}
|
||||
>
|
||||
{showLabels ? <IconEyeOpened /> : <IconEyeClosed />}
|
||||
<span><strong>悬浮车牌</strong><small>{monitorMap?.mode === 'clusters' ? '放大后显示' : '仅明细点'}</small></span>
|
||||
<span><strong>悬浮车牌</strong><small>{monitorMap?.mode === 'clusters' || monitorMap?.mode === 'provinces' ? '下钻后显示' : '仅明细点'}</small></span>
|
||||
<i className={showLabels ? 'is-on' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -241,6 +241,16 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-map-selection-marker > i { position: absolute; inset: 4px; border: 2px solid rgba(var(--v2-selection-rgb),.6); border-radius: 50%; animation: v2-map-ripple 2s ease-out infinite; }
|
||||
.v2-map-selection-marker > i:nth-child(2) { animation-delay: 1s; }
|
||||
.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-selection-color); box-shadow: 0 2px 8px rgba(var(--v2-selection-rgb),.45); }
|
||||
.v2-province-placement { position: relative; width: 0; height: 0; }
|
||||
.v2-province-anchor { position: absolute; top: -3px; left: -3px; z-index: 2; width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: #397fd7; box-shadow: 0 2px 6px rgba(31,92,167,.34); pointer-events: none; }
|
||||
.v2-province-connector { position: absolute; top: -.5px; left: 0; width: var(--province-line); height: 1px; background: rgba(72,111,158,.46); pointer-events: none; transform: rotate(var(--province-angle)); transform-origin: 0 50%; }
|
||||
.v2-province-marker { position: absolute; top: var(--province-y); left: var(--province-x); display: grid; width: 92px; min-height: 52px; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 2px 7px; overflow: hidden; border: 1px solid rgba(126,164,216,.78); border-radius: 9px; background: rgba(255,255,255,.96); padding: 6px 8px 5px; color: #27405f; box-shadow: 0 7px 22px rgba(31,65,112,.18); cursor: pointer; font-family: Inter,"PingFang SC","Microsoft YaHei",sans-serif; text-align: left; transform: translate(-50%,-50%) translateZ(0); transition: border-color .16s ease, box-shadow .16s ease, margin .16s ease; }
|
||||
.v2-province-marker:hover { margin-top: -1px; border-color: #397fd7; box-shadow: 0 9px 26px rgba(31,92,167,.25); }
|
||||
.v2-province-marker > span { min-width: 0; overflow: hidden; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-province-marker > strong { grid-row: 1 / span 2; grid-column: 2; color: #1268f3; font-size: 20px; font-variant-numeric: tabular-nums; letter-spacing: -.5px; line-height: 1; }
|
||||
.v2-province-marker > small { color: #75869b; font-size: 9px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.v2-province-marker > i { position: relative; height: 3px; grid-column: 1 / -1; overflow: hidden; border-radius: 2px; background: #e7edf5; }
|
||||
.v2-province-marker > i > b { position: absolute; inset: 0 auto 0 0; min-width: 2px; border-radius: inherit; background: linear-gradient(90deg,#12a46f,#35c98c); }
|
||||
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
|
||||
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
|
||||
.v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }
|
||||
@@ -1328,6 +1338,10 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-map-layer-control, .v2-map-follow-control { width: 44px; height: 44px; grid-template-columns: 1fr; justify-items: center; padding: 0; }
|
||||
.v2-map-layer-control span:not(.semi-icon), .v2-map-layer-control > i, .v2-map-follow-control span:not(.semi-icon) { display: none; }
|
||||
.v2-map-layer-control .semi-icon, .v2-map-follow-control .semi-icon { display: inline-flex; font-size: 18px; }
|
||||
.v2-province-marker.is-compact { width: 68px; min-height: 42px; grid-template-columns: minmax(0,1fr) auto; gap: 4px; border-radius: 8px; padding: 6px 7px; }
|
||||
.v2-province-marker.is-compact > span { font-size: 11px; }
|
||||
.v2-province-marker.is-compact > strong { grid-row: auto; grid-column: auto; font-size: 17px; }
|
||||
.v2-province-marker.is-compact > small, .v2-province-marker.is-compact > i { display: none; }
|
||||
.v2-map-legend { right: 8px; bottom: 8px; left: 8px; width: auto; max-width: none; height: 36px; justify-content: flex-start; gap: 13px; overflow-x: auto; padding: 0 12px; transform: none; white-space: nowrap; scrollbar-width: none; }
|
||||
.v2-map-legend::-webkit-scrollbar { display: none; }
|
||||
.v2-map-legend b { margin-left: auto; }
|
||||
|
||||
@@ -31,4 +31,7 @@ interface Window {
|
||||
plugins?: string[];
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
AMapUI?: {
|
||||
loadUI: (modules: string[], callback: (...constructors: unknown[]) => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user