feat(monitor): match motion to report intervals
This commit is contained in:
@@ -49,6 +49,7 @@ type MonitorMapPoint struct {
|
|||||||
SOCPercent float64 `json:"socPercent"`
|
SOCPercent float64 `json:"socPercent"`
|
||||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||||
LastSeen string `json:"lastSeen"`
|
LastSeen string `json:"lastSeen"`
|
||||||
|
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1024,6 +1025,7 @@ type VehicleRealtimeRow struct {
|
|||||||
SOCPercent float64 `json:"socPercent"`
|
SOCPercent float64 `json:"socPercent"`
|
||||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||||
LastSeen string `json:"lastSeen"`
|
LastSeen string `json:"lastSeen"`
|
||||||
|
ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryLocationRow struct {
|
type HistoryLocationRow struct {
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
|||||||
}
|
}
|
||||||
groupSQL := `FROM vehicle_realtime_location l ` +
|
groupSQL := `FROM vehicle_realtime_location l ` +
|
||||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin ` +
|
`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 ") + ` ` +
|
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||||
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
|
`GROUP BY l.vin, b.plate, b.phone, b.oem ` +
|
||||||
havingSQL
|
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.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.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(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 +
|
groupSQL +
|
||||||
`ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
|
`ORDER BY MAX(l.updated_at) DESC, l.vin ASC LIMIT ? OFFSET ?`,
|
||||||
Args: args,
|
Args: args,
|
||||||
|
|||||||
@@ -401,7 +401,8 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
|||||||
var onlineProtocols string
|
var onlineProtocols string
|
||||||
var online int
|
var online int
|
||||||
var longitude, latitude, speed, soc, mileage string
|
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
|
return Page[VehicleRealtimeRow]{}, err
|
||||||
}
|
}
|
||||||
row.Protocols = splitCSV(protocols)
|
row.Protocols = splitCSV(protocols)
|
||||||
@@ -413,6 +414,9 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
|
|||||||
row.SpeedKmh = parseFloatString(speed)
|
row.SpeedKmh = parseFloatString(speed)
|
||||||
row.SOCPercent = parseFloatString(soc)
|
row.SOCPercent = parseFloatString(soc)
|
||||||
row.TotalMileageKm = parseFloatString(mileage)
|
row.TotalMileageKm = parseFloatString(mileage)
|
||||||
|
if reportIntervalMs > 0 {
|
||||||
|
row.ReportIntervalMs = &reportIntervalMs
|
||||||
|
}
|
||||||
items = append(items, row)
|
items = append(items, row)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ func TestBuildRealtimeLocationSQL(t *testing.T) {
|
|||||||
func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||||
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
|
query := url.Values{"vin": {"粤A"}, "protocol": {"JT808"}, "online": {"online"}, "limit": {"10"}, "offset": {"20"}}
|
||||||
built := buildVehicleRealtimeSQL(query)
|
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) {
|
if !strings.Contains(built.Text+built.CountText, want) {
|
||||||
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
t.Fatalf("SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -606,6 +606,7 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values
|
|||||||
VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols,
|
VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols,
|
||||||
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
|
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
|
||||||
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
|
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
|
||||||
|
ReportIntervalMs: vehicle.ReportIntervalMs,
|
||||||
Status: monitorVehicleStatus(vehicle),
|
Status: monitorVehicleStatus(vehicle),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
|
func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) {
|
||||||
|
reportIntervalMs := int64(30000)
|
||||||
vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{
|
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: "NEAR-2", Plate: "粤A00002", Online: true, Longitude: 113.265, Latitude: 23.135},
|
||||||
{VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
|
{VIN: "SINGLE", Plate: "粤A00003", Online: true, Longitude: 113.600, Latitude: 23.500},
|
||||||
}, Total: 3, Limit: 3}
|
}, Total: 3, Limit: 3}
|
||||||
@@ -333,6 +334,9 @@ func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testin
|
|||||||
if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 {
|
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)
|
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) {
|
func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface MonitorMapPoint {
|
|||||||
socPercent: number;
|
socPercent: number;
|
||||||
totalMileageKm: number;
|
totalMileageKm: number;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
reportIntervalMs?: number;
|
||||||
status: 'driving' | 'idle' | 'offline' | 'unknown';
|
status: 'driving' | 'idle' | 'offline' | 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +546,7 @@ export interface VehicleRealtimeRow {
|
|||||||
socPercent: number;
|
socPercent: number;
|
||||||
totalMileageKm: number;
|
totalMileageKm: number;
|
||||||
lastSeen: string;
|
lastSeen: string;
|
||||||
|
reportIntervalMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryLocationRow extends RealtimeLocationRow {
|
export interface HistoryLocationRow extends RealtimeLocationRow {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
import { afterEach, expect, test, vi } from 'vitest';
|
||||||
import type { MonitorMapResponse } from '../../api/types';
|
import type { MonitorMapResponse } from '../../api/types';
|
||||||
import { wgs84ToGcj02, type AMapLike, type AMapMap, type AMapMassPoint } from '../../integrations/amap';
|
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 setData = vi.fn<(data: AMapMassPoint[]) => void>();
|
||||||
const setStyle = vi.fn();
|
const setStyle = vi.fn();
|
||||||
@@ -118,6 +118,7 @@ const pointMap: MonitorMapResponse = {
|
|||||||
socPercent: 80,
|
socPercent: 80,
|
||||||
totalMileageKm: 1234,
|
totalMileageKm: 1234,
|
||||||
lastSeen: '2026-07-14T01:00:00Z',
|
lastSeen: '2026-07-14T01:00:00Z',
|
||||||
|
reportIntervalMs: 10_000,
|
||||||
status: 'driving'
|
status: 'driving'
|
||||||
}, {
|
}, {
|
||||||
vin: 'LTEST000000000002',
|
vin: 'LTEST000000000002',
|
||||||
@@ -130,6 +131,7 @@ const pointMap: MonitorMapResponse = {
|
|||||||
socPercent: 72,
|
socPercent: 72,
|
||||||
totalMileageKm: 2234,
|
totalMileageKm: 2234,
|
||||||
lastSeen: '2026-07-14T01:00:00Z',
|
lastSeen: '2026-07-14T01:00:00Z',
|
||||||
|
reportIntervalMs: 30_000,
|
||||||
status: 'idle'
|
status: 'idle'
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -151,7 +153,7 @@ test('keeps large fleet redraw fingerprints constant-size and sensitive to point
|
|||||||
expect(massPointFingerprint('points', [], manyPoints, [])).toBe(fingerprint);
|
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 previous: AMapMassPoint[] = [{ id: 'moving', label: 'A', style: 0, lnglat: [10, 20] }];
|
||||||
const target: AMapMassPoint[] = [
|
const target: AMapMassPoint[] = [
|
||||||
{ id: 'moving', label: 'A', style: 2, lnglat: [14, 24] },
|
{ 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[0], lnglat: [10, 20] },
|
||||||
target[1]
|
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);
|
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 {
|
function amapMock(): AMapLike {
|
||||||
return {
|
return {
|
||||||
Map: TestMap as unknown as AMapLike['Map'],
|
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 });
|
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);
|
expect(setZoomAndCenter).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const follow = screen.getByRole('button', { name: '跟随车辆' });
|
const follow = screen.getByRole('button', { name: '跟随车辆' });
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import type { MonitorViewport } from '../hooks/useMonitorData';
|
|||||||
|
|
||||||
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
const COLORS = ['#12a46f', '#9aa6b7', '#1677ff', '#f59e0b', '#ef4444'];
|
||||||
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
|
const CLUSTER_VISUAL_CACHE_LIMIT = 256;
|
||||||
const POINT_MOVE_DURATION_MS = 5_000;
|
const DEFAULT_POINT_MOVE_DURATION_MS = 15_000;
|
||||||
const POINT_MOVE_FRAME_MS = 50;
|
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 MAP_INTERACTION_PAN_DURATION_MS = 650;
|
||||||
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
|
const clusterVisualCache = new Map<number, { diameter: number; url: string }>();
|
||||||
|
|
||||||
@@ -154,6 +156,7 @@ export function massPointFingerprint(
|
|||||||
fingerprint.add(point.latitude);
|
fingerprint.add(point.latitude);
|
||||||
fingerprint.add(point.status);
|
fingerprint.add(point.status);
|
||||||
fingerprint.add(point.plate || '');
|
fingerprint.add(point.plate || '');
|
||||||
|
fingerprint.add(point.reportIntervalMs ?? '');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fingerprint.add(points.length);
|
fingerprint.add(points.length);
|
||||||
@@ -163,6 +166,7 @@ export function massPointFingerprint(
|
|||||||
fingerprint.add(point.latitude);
|
fingerprint.add(point.latitude);
|
||||||
fingerprint.add(vehicleStatus(point));
|
fingerprint.add(vehicleStatus(point));
|
||||||
fingerprint.add(point.plate || '');
|
fingerprint.add(point.plate || '');
|
||||||
|
fingerprint.add(point.reportIntervalMs ?? '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fingerprint.value();
|
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'}`;
|
return `${point.plate || point.vin}:${selected ? 1 : 0}:${placement?.direction ?? 'right'}:${placement?.textOffset.join(',') ?? '8,0'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function interpolateMassPointsFrom(previousByID: Map<string, AMapMassPoint>, target: AMapMassPoint[], progress: number, eligibleIDs?: Set<string>) {
|
function interpolateMassPointsFrom(previousByID: Map<string, AMapMassPoint>, target: AMapMassPoint[], progressFor: (id: string) => number, eligibleIDs?: Set<string>) {
|
||||||
const bounded = Math.min(1, Math.max(0, progress));
|
|
||||||
const eased = 1 - (1 - bounded) ** 3;
|
|
||||||
return target.map((point) => {
|
return target.map((point) => {
|
||||||
if (eligibleIDs && !eligibleIDs.has(point.id)) return point;
|
if (eligibleIDs && !eligibleIDs.has(point.id)) return point;
|
||||||
const start = previousByID.get(point.id);
|
const start = previousByID.get(point.id);
|
||||||
if (!start || start.lnglat[0] === point.lnglat[0] && start.lnglat[1] === point.lnglat[1]) return point;
|
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 {
|
return {
|
||||||
...point,
|
...point,
|
||||||
lnglat: [
|
lnglat: [
|
||||||
start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * eased,
|
start.lnglat[0] + (point.lnglat[0] - start.lnglat[0]) * progress,
|
||||||
start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * eased
|
start.lnglat[1] + (point.lnglat[1] - start.lnglat[1]) * progress
|
||||||
] as [number, number]
|
] as [number, number]
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function interpolateMassPoints(previous: AMapMassPoint[], target: AMapMassPoint[], progress: 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<string, number>, eligibleIDs?: Set<string>) {
|
||||||
|
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[]) {
|
function movingPointIDs(previous: AMapMassPoint[], target: AMapMassPoint[]) {
|
||||||
@@ -449,6 +466,8 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
] : fallbackPoints.map((vehicle) => ({
|
] : fallbackPoints.map((vehicle) => ({
|
||||||
lnglat: wgs84ToGcj02(vehicle.longitude, vehicle.latitude), style: styleIndex(vehicle), id: vehicle.vin, label: vehicle.plate || vehicle.vin
|
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;
|
massDataSignatureRef.current = dataSignature;
|
||||||
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
|
if (pointAnimationFrameRef.current !== undefined) window.cancelAnimationFrame(pointAnimationFrameRef.current);
|
||||||
pointAnimationFrameRef.current = undefined;
|
pointAnimationFrameRef.current = undefined;
|
||||||
@@ -465,6 +484,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
}
|
}
|
||||||
let startedAt: number | undefined;
|
let startedAt: number | undefined;
|
||||||
let lastPaintAt = -Infinity;
|
let lastPaintAt = -Infinity;
|
||||||
|
const longestMoveDuration = Math.max(...[...movingIDs].map((id) => moveDurationByID.get(id) ?? DEFAULT_POINT_MOVE_DURATION_MS));
|
||||||
const paint = (frameData: AMapMassPoint[]) => {
|
const paint = (frameData: AMapMassPoint[]) => {
|
||||||
mass.setData(frameData);
|
mass.setData(frameData);
|
||||||
renderedMassDataRef.current = frameData;
|
renderedMassDataRef.current = frameData;
|
||||||
@@ -478,12 +498,13 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
|||||||
};
|
};
|
||||||
const animate = (timestamp: number) => {
|
const animate = (timestamp: number) => {
|
||||||
startedAt ??= timestamp;
|
startedAt ??= timestamp;
|
||||||
const progress = Math.min(1, (timestamp - startedAt) / POINT_MOVE_DURATION_MS);
|
const elapsed = timestamp - startedAt;
|
||||||
if (timestamp - lastPaintAt >= POINT_MOVE_FRAME_MS || progress === 1) {
|
const complete = elapsed >= longestMoveDuration;
|
||||||
paint(interpolateMassPointsFrom(previousDataByID, data, progress, movingIDs));
|
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;
|
lastPaintAt = timestamp;
|
||||||
}
|
}
|
||||||
if (progress < 1) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
|
if (!complete) pointAnimationFrameRef.current = window.requestAnimationFrame(animate);
|
||||||
else {
|
else {
|
||||||
pointAnimationFrameRef.current = undefined;
|
pointAnimationFrameRef.current = undefined;
|
||||||
movingPointIDsRef.current.clear();
|
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);
|
else mapRef.current.panTo?.(mapPosition, MAP_INTERACTION_PAN_DURATION_MS);
|
||||||
centeredVinRef.current = selectedVin;
|
centeredVinRef.current = selectedVin;
|
||||||
} else if (selectionPositionRef.current && selectionPositionRef.current !== positionKey && followSelectedRef.current) {
|
} 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;
|
const previousPositionKey = selectionPositionRef.current;
|
||||||
selectionPositionRef.current = positionKey;
|
selectionPositionRef.current = positionKey;
|
||||||
|
|||||||
Reference in New Issue
Block a user