Compare commits

..

2 Commits

Author SHA1 Message Date
lingniu
1243efc7dd docs(monitor): record province aggregation release 2026-07-16 18:36:35 +08:00
lingniu
21d1baada5 feat(monitor): aggregate nationwide fleet by province 2026-07-16 18:36:31 +08:00
13 changed files with 633 additions and 35 deletions

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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"}}

View File

@@ -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;
}

View File

@@ -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('坐标不可用');

View File

@@ -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]);
});

View File

@@ -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>

View File

@@ -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; }

View File

@@ -31,4 +31,7 @@ interface Window {
plugins?: string[];
}) => Promise<unknown>;
};
AMapUI?: {
loadUI: (modules: string[], callback: (...constructors: unknown[]) => void) => void;
};
}

View File

@@ -70,7 +70,11 @@ GET /api/v2/monitor/map?zoom=13&bounds=113,22,114,24&status=online
GET /api/v2/monitor/workspace?zoom=13&bounds=113,22,114,24&railLimit=200
```
`summary` and `map` share keyword, protocol and `online|offline|driving|idle` status semantics. `map` accepts `bounds=minLongitude,minLatitude,maxLongitude,maxLatitude`; malformed, non-finite, reversed or out-of-world bounds return `MONITOR_BOUNDS_INVALID`. Below zoom 11 the response remains clustered or mixed. At zoom 11 and above it returns lightweight MassMarks only when the visible point set is at most 2,000; otherwise it adaptively coarsens cells until no more than 2,000 clusters remain. It never returns full telemetry JSON in a point. The browser keeps the normal list at 200 rows, requests map data independently, waits 300ms after `moveend/zoomend`, and drops stale viewport bounds when a direct vehicle search must locate and focus its unique result.
`summary` and `map` share keyword, protocol and `online|offline|driving|idle` status semantics. `map` accepts `bounds=minLongitude,minLatitude,maxLongitude,maxLatitude`; malformed, non-finite, reversed or out-of-world bounds return `MONITOR_BOUNDS_INVALID`.
At zoom 5 and below, `map.mode=provinces`. The response includes `provincePoints`, a compact identity-free array containing only WGS-84 longitude, latitude and normalized status for every located vehicle in the filtered and authorized fleet. Nationwide province counts intentionally ignore the current slippy-map bounds; the browser converts the seeds to GCJ-02 and groups them against AMapUI `DistrictExplorer`'s official nationwide AreaNode. `clusters` remains populated as a visible fallback if that external administrative-boundary resource fails. No VIN, plate, terminal or full telemetry is present in a province seed.
From zoom 6 through 10 the response remains clustered or mixed. At zoom 11 and above it returns lightweight MassMarks only when the visible point set is at most 2,000; otherwise it adaptively coarsens cells until no more than 2,000 clusters remain. It never returns full telemetry JSON in a point. The browser keeps the normal list at 200 rows, requests map data independently, waits 300ms after `moveend/zoomend`, and drops stale viewport bounds when a direct vehicle search must locate and focus its unique result.
`workspace` is the production map-screen BFF response. It executes one bounded realtime snapshot read, then derives `summary`, the first `railLimit` realtime vehicle rows (hard-capped at 200), and the viewport-aware `map` payload from that same snapshot. The standalone `summary`, `map`, and realtime-vehicle APIs remain available for list mode and external compatibility, but the browser must not fan them out concurrently for one map refresh.

View File

@@ -261,7 +261,7 @@ The history-export gate must verify owner-bound listing and authenticated Blob d
Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second `数据间隔` segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise `model`/`provider` and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope.
Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`.
Global-monitor smoke must verify `zoom=5` returns `mode=provinces`, `provincePoints` equals the number of authorized/filtered vehicles with valid locations, and no province seed contains VIN, plate or terminal identity. A city `bounds` at `zoom=13` must return at most 2,000 lightweight points, and invalid/reversed bounds must return HTTP 400. In the browser, AMapUI `DistrictExplorer` must load the nationwide AreaNode, render only nonempty provinces with exact counts, and click through to zoom 7 where the existing grid aggregation resumes. Test desktop at DPR 1.5 and mobile at DPR 2 for overlap, clipping and crisp DOM labels. If AMapUI or the AreaNode fails, the backend-provided grid clusters must remain visible instead of producing a blank map. The rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`.
Source-evidence smoke must use a vehicle with at least two protocols and a vehicle with more than one JT808 terminal. Verify that the response returns every actual location/mileage candidate, exactly one current recommendation, protocol-internal selection, availability/quality state and cross-source differences. Raw `source_key`, source IP, endpoint, phone and device identifiers must not appear; terminal labels must remain masked. The browser must not call this endpoint before the operator expands coordinates or mileage. Production baseline for `沪A35898F` on 2026-07-16 returned two JT808 terminals with one recommendation; 20 sequential requests had a 26.6ms median and 29.1ms P95.

View File

@@ -2,6 +2,23 @@
This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.
## 2026-07-16: nationwide province aggregation
The nationwide monitor previously reused arbitrary pixel-grid clusters. At country scale that made the count labels visually dense and did not answer the operational question “how many authorized vehicles are in each province.” The production API now enters `provinces` mode at zoom 5 and below and returns a compact, identity-free location/status seed for every filtered vehicle with a valid position. Nationwide counts deliberately cover the complete filtered fleet rather than the current map rectangle; normal viewport filtering resumes after drill-down.
The browser loads AMapUI `DistrictExplorer` once, uses the official nationwide AreaNode and its pure-geometry `groupByPosition` operation, and converts the platform's WGS-84 coordinates to GCJ-02 before classification. Empty provinces are omitted. Province markers are DOM surfaces with exact vehicle count, online count and online ratio; mobile uses a compact form. A deterministic pixel-space collision pass, center connector and viewport constraint preserve the true province anchor while preventing labels from overlapping or clipping. Clicking a province moves to zoom 7, where the existing grid aggregation resumes. Timed data refresh never changes zoom or center.
AMapUI is an optional rendering dependency rather than the only map payload. The server still includes bounded grid clusters in nationwide responses. If the script, module or AreaNode fails, those clusters remain visible and the legend explicitly reports the fallback instead of leaving a blank map.
Production evidence for release `province-map-mobile-20260716182939`:
- 730 located vehicles were classified into 15 nonempty provinces against the real nationwide AreaNode; unlocated count was zero.
- `GET /api/v2/monitor/map?zoom=5` returned approximately 50 KB in about 61 ms on the ECS loopback, with `provincePoints=730` and no truncation.
- The final 10,000-point backend benchmark completed in approximately 2.23 ms/op.
- Automated rendered checks at 1440×900/DPR 1.5 and 390×844/DPR 2 found 15 markers, no pairwise overlap, no map-bound clipping and no relevant console error.
- Clicking a real province changed the monitor response to zoom 7/mixed mode and retained the same 730-vehicle filtered total.
- Go tests, 49 frontend files/258 tests, TypeScript and the verified Vite production build passed.
## 2026-07-16: pre-React boot recovery
Route Suspense and error boundaries can recover failures only after the React entry has loaded and committed. The production HTML previously contained an empty `#root`, so an entry-script fetch failure, synchronous initialization exception, unhandled startup rejection or permanently stalled module graph could leave a completely blank page with no operator action. React documents error boundaries as protection for errors in their descendant component tree; the browser `error` event separately reports resources that fail to load or scripts that cannot execute: <https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary>, <https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event>.

View File

@@ -375,7 +375,18 @@
### P1-03 全国视角省级聚合
状态:`待开发`
状态:`已完成`
已上线结果release `province-map-mobile-20260716182939`
- 全国视角 `zoom <= 5` 使用省级聚合模式;后端在当前认证车辆 Scope、关键词、协议和状态筛选后返回身份无关的轻量坐标种子省级数量不会泄露未授权 VIN、车牌或终端信息。
- 浏览器使用高德 AMapUI `DistrictExplorer` 的全国行政区矢量边界和 `groupByPosition` 做纯几何归属只渲染实际有车的省份WGS-84 坐标先转为 GCJ-02 后再归属,生产 730 辆有有效位置车辆全部归入 15 个非空省级区域,未归属为 0。
- 省级卡片显示省名、精确车辆数、在线数及在线占比,移动端使用紧凑省名和精确数量;确定性的像素碰撞规避、中心连接线和视窗边缘约束避免密集省份标签重叠或被裁切。
- 点击省级卡片平滑下钻到 `zoom 7`,恢复现有网格聚合;`zoom 11` 及以上继续使用车辆明细点。定时数据刷新只更新数量和标记内容,不调用缩放或居中,不与用户平移、缩放操作争抢控制权。
- 高德行政区资源加载失败时保留后端网格聚合作为可见降级,不会出现空白地图;全国模式仍返回网格后备数据,进入非全国 Zoom 后恢复正常边界过滤。
- 省级标记使用 DOM/矢量样式而非低分辨率位图。1440×900、DPR 1.5 与 390×844、DPR 2 的真实生产页面均显示 15 个标记,自动检查无重叠、无越界、无相关控制台错误。
- 生产接口 `GET /api/v2/monitor/map?zoom=5` 返回 `mode=provinces``total=730``provincePoints=730`,响应约 50 KB、ECS 回环约 61 ms最终门禁的 10,000 点后端基准约 2.23 ms/op。
- Go 全量测试、前端 49 个文件/258 项测试、TypeScript/Vite 生产构建和 `git diff --check` 通过ECS 平台服务及两个告警评估器均为 active。
目标: