diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 51e91556..fdfd33f6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -39,17 +39,18 @@ type MonitorWorkspaceResponse struct { } type MonitorMapPoint struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Protocol string `json:"protocol"` - Protocols []string `json:"protocols"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - SpeedKmh float64 `json:"speedKmh"` - SOCPercent float64 `json:"socPercent"` - TotalMileageKm float64 `json:"totalMileageKm"` - LastSeen string `json:"lastSeen"` - Status string `json:"status"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Protocol string `json:"protocol"` + Protocols []string `json:"protocols"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + SpeedKmh float64 `json:"speedKmh"` + SOCPercent float64 `json:"socPercent"` + TotalMileageKm float64 `json:"totalMileageKm"` + LastSeen string `json:"lastSeen"` + ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"` + Status string `json:"status"` } type MonitorMapCluster struct { @@ -1024,6 +1025,7 @@ type VehicleRealtimeRow struct { SOCPercent float64 `json:"socPercent"` TotalMileageKm float64 `json:"totalMileageKm"` LastSeen string `json:"lastSeen"` + ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"` } type HistoryLocationRow struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 5f8e70ae..65debf03 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -394,6 +394,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { } groupSQL := `FROM vehicle_realtime_location l ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` + + `LEFT JOIN vehicle_realtime_snapshot s ON s.vin = l.vin AND s.protocol = l.protocol ` + `WHERE ` + strings.Join(where, " AND ") + ` ` + `GROUP BY l.vin, b.plate, b.phone, b.oem ` + havingSQL @@ -414,7 +415,8 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` + - `COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen ` + + `COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + + `CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` + groupSQL + `ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`, Args: args, diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index cd6e974a..7872bcb5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -401,7 +401,8 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) var onlineProtocols string var online int var longitude, latitude, speed, soc, mileage string - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen); err != nil { + var reportIntervalMs int64 + if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &longitude, &latitude, &speed, &soc, &mileage, &row.LastSeen, &reportIntervalMs); err != nil { return Page[VehicleRealtimeRow]{}, err } row.Protocols = splitCSV(protocols) @@ -413,6 +414,9 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) row.SpeedKmh = parseFloatString(speed) row.SOCPercent = parseFloatString(soc) row.TotalMileageKm = parseFloatString(mileage) + if reportIntervalMs > 0 { + row.ReportIntervalMs = &reportIntervalMs + } items = append(items, row) } if err := rows.Err(); err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go index b1dec9b0..e3cd02bb 100644 --- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go @@ -200,7 +200,7 @@ func TestBuildRealtimeLocationSQL(t *testing.T) { func TestBuildVehicleRealtimeSQL(t *testing.T) { query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}} built := buildVehicleRealtimeSQL(query) - for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} { + for _, want := range []string{"vehicle_realtime_location", "vehicle_identity_binding", "vehicle_realtime_snapshot", "access_report_interval_ms", "GROUP BY l.vin", "GROUP_CONCAT(DISTINCT l.protocol", "online_source_count", "vehicle_realtime_count"} { if !strings.Contains(built.Text+built.CountText, want) { t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText) } diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index a4848947..1412b18b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -606,7 +606,8 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols, Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh, SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen, - Status: monitorVehicleStatus(vehicle), + ReportIntervalMs: vehicle.ReportIntervalMs, + Status: monitorVehicleStatus(vehicle), }) } if zoom >= monitorPointZoom && len(points) <= monitorPointLimit { 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 e62b17c0..d26b612d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -312,8 +312,9 @@ func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) { } func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) { + reportIntervalMs := int64(30000) vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ - {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130}, + {VIN: "NEAR-1", Plate: "粤A00001", Online: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs}, {VIN: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135}, {VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500}, }, Total: 3, Limit: 3} @@ -333,6 +334,9 @@ func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testin if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 { t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail) } + if detail.Points[0].ReportIntervalMs == nil || *detail.Points[0].ReportIntervalMs != reportIntervalMs { + t.Fatalf("map point must preserve the primary protocol report interval: %+v", detail.Points[0]) + } } func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) { diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index ce4c0335..4e1e0240 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -36,6 +36,7 @@ export interface MonitorMapPoint { socPercent: number; totalMileageKm: number; lastSeen: string; + reportIntervalMs?: number; status: 'driving' | 'idle' | 'offline' | 'unknown'; } @@ -545,6 +546,7 @@ export interface VehicleRealtimeRow { socPercent: number; totalMileageKm: number; lastSeen: string; + reportIntervalMs?: number; } export interface HistoryLocationRow extends RealtimeLocationRow { 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 79282790..20110666 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 @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra import { afterEach, expect, test, vi } from 'vitest'; import type { MonitorMapResponse } from '../../api/types'; import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap'; -import { FleetMap, interpolateMassPoints, massPointFingerprint } from './FleetMap'; +import { FleetMap, interpolateMassPoints, interpolateMassPointsByElapsed, massPointFingerprint, pointMoveDurationMs } from './FleetMap'; const setData = vi.fn<(data: AMapMassPoint[]) => void>(); const setStyle = vi.fn(); @@ -118,6 +118,7 @@ const pointMap: MonitorMapResponse = { socPercent: 80, totalMileageKm: 1234, lastSeen: '2026-07-14T01:00:00Z', + reportIntervalMs: 10_000, status: 'driving' }, { vin: 'LTEST000000000002', @@ -130,6 +131,7 @@ const pointMap: MonitorMapResponse = { socPercent: 72, totalMileageKm: 2234, lastSeen: '2026-07-14T01:00:00Z', + reportIntervalMs: 30_000, status: 'idle' }] }; @@ -151,7 +153,7 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint); }); -test('interpolates existing mass points with easing while keeping new points stable', () => { +test('interpolates existing mass points at constant speed while keeping new points stable', () => { const previous: AMapMassPoint[] = [{ id: 'moving', label: 'A', style: 0, lnglat: [10, 20] }]; const target: AMapMassPoint[] = [ { id: 'moving', label: 'A', style: 2, lnglat: [14, 24] }, @@ -162,10 +164,34 @@ test('interpolates existing mass points with easing while keeping new points sta { ...target[0], lnglat: [10, 20] }, target[1] ]); - expect(interpolateMassPoints(previous, target, .5)[0]).toEqual({ ...target[0], lnglat: [13.5, 23.5] }); + expect(interpolateMassPoints(previous, target, .5)[0]).toEqual({ ...target[0], lnglat: [12, 22] }); expect(interpolateMassPoints(previous, target, 1)).toEqual(target); }); +test('uses each protocol report interval with bounded fallbacks for point motion', () => { + expect(pointMoveDurationMs(10_000)).toBe(10_000); + expect(pointMoveDurationMs(30_000)).toBe(30_000); + expect(pointMoveDurationMs(60_000)).toBe(60_000); + expect(pointMoveDurationMs(undefined)).toBe(15_000); + expect(pointMoveDurationMs(100)).toBe(1_000); + expect(pointMoveDurationMs(300_000)).toBe(120_000); +}); + +test('advances each vehicle independently according to its report interval', () => { + const previous: AMapMassPoint[] = [ + { id: 'JT808', label: 'A', style: 0, lnglat: [0, 0] }, + { id: 'GB32960', label: 'B', style: 0, lnglat: [0, 0] } + ]; + const target: AMapMassPoint[] = previous.map((point) => ({ ...point, lnglat: [30, 30] })); + const frame = interpolateMassPointsByElapsed(previous, target, 10_000, new Map([ + ['JT808', 10_000], + ['GB32960', 30_000] + ])); + + expect(frame[0].lnglat).toEqual([30, 30]); + expect(frame[1].lnglat).toEqual([10, 10]); +}); + function amapMock(): AMapLike { return { Map: TestMap as unknown as AMapLike['Map'], @@ -429,7 +455,7 @@ test('renders one selected plate and smoothly follows it until the map is dragge /> ); await waitFor(() => expect(markerSetPosition).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14)), { timeout: 1_800 }); - expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 5_000); + expect(panTo).toHaveBeenLastCalledWith(wgs84ToGcj02(113.27, 23.14), 10_000); expect(setZoomAndCenter).toHaveBeenCalledTimes(1); const follow = screen.getByRole('button', { name: '跟随车辆' }); 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 5f91283a..95c3a7b0 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -19,8 +19,10 @@ import type { MonitorViewport } from '../hooks/useMonitorData'; const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444']; const CLUSTER_VISUAL_CACHE_LIMIT = 256; -const POINT_MOVE_DURATION_MS = 5_000; -const POINT_MOVE_FRAME_MS = 50; +const DEFAULT_POINT_MOVE_DURATION_MS = 15_000; +const MIN_POINT_MOVE_DURATION_MS = 1_000; +const MAX_POINT_MOVE_DURATION_MS = 120_000; +const POINT_MOVE_FRAME_MS = 80; const MAP_INTERACTION_PAN_DURATION_MS = 650; const clusterVisualCache = new Map(); @@ -154,6 +156,7 @@ export function massPointFingerprint( fingerprint.add(point.latitude); fingerprint.add(point.status); fingerprint.add(point.plate || ''); + fingerprint.add(point.reportIntervalMs ?? ''); } } else { fingerprint.add(points.length); @@ -163,6 +166,7 @@ export function massPointFingerprint( fingerprint.add(point.latitude); fingerprint.add(vehicleStatus(point)); fingerprint.add(point.plate || ''); + fingerprint.add(point.reportIntervalMs ?? ''); } } return fingerprint.value(); @@ -172,25 +176,38 @@ function labelMarkerSignature(point: PlateLabelPoint, selected: boolean, placeme return `${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`; } -function interpolateMassPointsFrom(previousByID: Map, target: AMapMassPoint[], progress: number, eligibleIDs?: Set) { - const bounded = Math.min(1, Math.max(0, progress)); - const eased = 1 - (1 - bounded) ** 3; +function interpolateMassPointsFrom(previousByID: Map, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set) { return target.map((point) => { if (eligibleIDs && !eligibleIDs.has(point.id)) return point; const start = previousByID.get(point.id); if (!start || start.lnglat[0] === point.lnglat[0] && start.lnglat[1] === point.lnglat[1]) return point; + const progress = Math.min(1, Math.max(0, progressFor(point.id))); return { ...point, lnglat: [ - start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * eased, - start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * eased + start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * progress, + start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * progress ] as [number, number] }; }); } export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: number) { - return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, progress); + return interpolateMassPointsFrom(new Map(previous.map((point) => [point.id, point])), target, () => progress); +} + +export function pointMoveDurationMs(reportIntervalMs?: number) { + if (!Number.isFinite(reportIntervalMs) || Number(reportIntervalMs) <= 0) return DEFAULT_POINT_MOVE_DURATION_MS; + return Math.min(MAX_POINT_MOVE_DURATION_MS, Math.max(MIN_POINT_MOVE_DURATION_MS, Math.round(Number(reportIntervalMs)))); +} + +export function interpolateMassPointsByElapsed(previous: AMapMassPoint[], target: AMapMassPoint[], elapsedMs: number, durationByID: Map, eligibleIDs?: Set) { + return interpolateMassPointsFrom( + new Map(previous.map((point) => [point.id, point])), + target, + (id) => elapsedMs / (durationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS), + eligibleIDs + ); } function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) { @@ -449,6 +466,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect ] : fallbackPoints.map((vehicle) => ({ lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin })); + const moveDurationByID = new Map((monitorMode ? mapPoints : fallbackPoints) + .map((point) => [point.vin, pointMoveDurationMs(point.reportIntervalMs)])); massDataSignatureRef.current = dataSignature; if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current); pointAnimationFrameRef.current = undefined; @@ -465,6 +484,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect } let startedAt: number | undefined; let lastPaintAt = -Infinity; + const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS)); const paint = (frameData: AMapMassPoint[]) => { mass.setData(frameData); renderedMassDataRef.current = frameData; @@ -478,12 +498,13 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect }; const animate = (timestamp: number) => { startedAt ??= timestamp; - const progress = Math.min(1, (timestamp - startedAt) / POINT_MOVE_DURATION_MS); - if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || progress === 1) { - paint(interpolateMassPointsFrom(previousDataByID, data, progress, movingIDs)); + const elapsed = timestamp - startedAt; + const complete = elapsed >= longestMoveDuration; + if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || complete) { + paint(interpolateMassPointsFrom(previousDataByID, data, (id) => elapsed / (moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS), movingIDs)); lastPaintAt = timestamp; } - if (progress < 1) pointAnimationFrameRef.current = window.requestAnimationFrame(animate); + if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate); else { pointAnimationFrameRef.current = undefined; movingPointIDsRef.current.clear(); @@ -599,7 +620,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect else mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS); centeredVinRef.current = selectedVin; } else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) { - mapRef.current.panTo?.(mapPosition, POINT_MOVE_DURATION_MS); + mapRef.current.panTo?.(mapPosition, pointMoveDurationMs(target.reportIntervalMs)); } const previousPositionKey = selectionPositionRef.current; selectionPositionRef.current = positionKey;