diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index cbefd295..7590e890 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -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"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 4dd4552c..ea872434 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -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 } diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 147e6af6..e7ce1b36 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -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"}} diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index e62ba748..75bea04f 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -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; } diff --git a/vehicle-data-platform/apps/web/src/integrations/amap.ts b/vehicle-data-platform/apps/web/src/integrations/amap.ts index 14853dff..99cfaace 100644 --- a/vehicle-data-platform/apps/web/src/integrations/amap.ts +++ b/vehicle-data-platform/apps/web/src/integrations/amap.ts @@ -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 = { + subFeatureIndex: number; + subFeature?: AMapDistrictFeature | null; + pointsIndexes: number[]; + points: T[]; +}; + +export type AMapAreaNode = { + getSubFeatures: () => AMapDistrictFeature[]; + groupByPosition: (points: T[], getPosition: (point: T, index: number) => [number, number]) => AMapDistrictGroup[]; +}; + +export type AMapDistrictExplorer = { + loadAreaNode: (adcode: number, callback: (error: Error | null, areaNode?: AMapAreaNode) => void) => void; +}; + +export type AMapDistrictExplorerConstructor = new (options?: Record) => AMapDistrictExplorer; + export type AMapLike = { Map: new (container: HTMLDivElement, options: Record) => AMapMap; Marker: new (options: Record) => 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((resolve, reject) => { + if (window.AMapUI) { + resolve(); + return; + } + const existing = document.querySelector(`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 { + 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 { if (!isValidAMapCoordinate(longitude, latitude)) { throw new Error('坐标不可用'); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 07a61fab..06c5c741 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -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> => ({})); const mapHandlers = new Map void>(); const markerOptions: Record[] = []; +const markerInstances: TestMarker[] = []; const mapOptions: Record[] = []; const labelLayerOptions: Record[] = []; @@ -78,8 +79,12 @@ class TestLabelMarker { class TestMarker { setMap = markerSetMap; setPosition = markerSetPosition; + handlers = new Map 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) { 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((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( 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( 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('3'); + expect(String(markerOptions[provinceIndex]?.content)).toContain('在线 2'); + + markerInstances[provinceIndex]?.handlers.get('click')?.(); + expect(setZoomAndCenter).toHaveBeenLastCalledWith(7, [113.266, 23.132]); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 11e83ef1..6f337e41 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -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(); +let chinaProvinceAreaNodePromise: Promise | undefined; function dotDataUrl(color: string) { const 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((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(); + 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(null); const selectionRef = useRef(null); const labelMarkersRef = useRef(new Map()); + const provinceMarkersRef = useRef(new Map()); 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([]); + const [unlocatedProvincePoints, setUnlocatedProvincePoints] = useState(0); + const [provinceWorldBounds, setProvinceWorldBounds] = useState(); 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(); + 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: `
${connectorLength ? '' : ''}
` + }); + 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 ? : } - 悬浮车牌{monitorMap?.mode === 'clusters' ? '放大后显示' : '仅明细点'} + 悬浮车牌{monitorMap?.mode === 'clusters' || monitorMap?.mode === 'provinces' ? '下钻后显示' : '仅明细点'} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index f97b47c0..7ea9e080 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -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; } diff --git a/vehicle-data-platform/apps/web/src/vite-env.d.ts b/vehicle-data-platform/apps/web/src/vite-env.d.ts index e6c33322..8c639025 100644 --- a/vehicle-data-platform/apps/web/src/vite-env.d.ts +++ b/vehicle-data-platform/apps/web/src/vite-env.d.ts @@ -31,4 +31,7 @@ interface Window { plugins?: string[]; }) => Promise; }; + AMapUI?: { + loadUI: (modules: string[], callback: (...constructors: unknown[]) => void) => void; + }; }