feat(platform): flag trajectory playback anomalies
This commit is contained in:
@@ -119,6 +119,53 @@ function formatDurationMinutes(value?: number, suffix = '') {
|
||||
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`;
|
||||
}
|
||||
|
||||
type TrajectoryAnomalySummary = {
|
||||
gapCount: number;
|
||||
maxGapMinutes?: number;
|
||||
mileageRollbackCount: number;
|
||||
maxMileageRollbackKm?: number;
|
||||
overspeedCount: number;
|
||||
maxSpeedKmh?: number;
|
||||
};
|
||||
|
||||
function analyzeTrajectoryAnomalies(rows: HistoryLocationRow[]): TrajectoryAnomalySummary {
|
||||
const summary: TrajectoryAnomalySummary = {
|
||||
gapCount: 0,
|
||||
mileageRollbackCount: 0,
|
||||
overspeedCount: 0
|
||||
};
|
||||
let previousTime: number | undefined;
|
||||
let previousMileage: number | undefined;
|
||||
for (const row of rows) {
|
||||
const currentTime = timeValue(row);
|
||||
if (currentTime != null && previousTime != null) {
|
||||
const gapMinutes = Math.abs(currentTime - previousTime) / 60000;
|
||||
if (gapMinutes > 30) {
|
||||
summary.gapCount += 1;
|
||||
summary.maxGapMinutes = summary.maxGapMinutes == null ? gapMinutes : Math.max(summary.maxGapMinutes, gapMinutes);
|
||||
}
|
||||
}
|
||||
if (currentTime != null) {
|
||||
previousTime = currentTime;
|
||||
}
|
||||
|
||||
if (isFiniteNumber(row.totalMileageKm) && previousMileage != null && row.totalMileageKm < previousMileage) {
|
||||
const rollback = previousMileage - row.totalMileageKm;
|
||||
summary.mileageRollbackCount += 1;
|
||||
summary.maxMileageRollbackKm = summary.maxMileageRollbackKm == null ? rollback : Math.max(summary.maxMileageRollbackKm, rollback);
|
||||
}
|
||||
if (isFiniteNumber(row.totalMileageKm)) {
|
||||
previousMileage = row.totalMileageKm;
|
||||
}
|
||||
|
||||
if (isFiniteNumber(row.speedKmh) && row.speedKmh > 120) {
|
||||
summary.overspeedCount += 1;
|
||||
summary.maxSpeedKmh = summary.maxSpeedKmh == null ? row.speedKmh : Math.max(summary.maxSpeedKmh, row.speedKmh);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
|
||||
const hasExplicitFilters = Object.keys(initialFilters).length > 0;
|
||||
return {
|
||||
@@ -434,6 +481,8 @@ export function History({
|
||||
const playbackEndMs = timeValue(lastLocation);
|
||||
const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined;
|
||||
const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined;
|
||||
const trajectoryAnomalies = useMemo(() => analyzeTrajectoryAnomalies(locationItems), [locationItems]);
|
||||
const hasTrajectoryAnomaly = trajectoryAnomalies.gapCount > 0 || trajectoryAnomalies.mileageRollbackCount > 0 || trajectoryAnomalies.overspeedCount > 0;
|
||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||
const currentProtocol = filters.protocol?.trim() ?? '';
|
||||
const pageTitle = mode === 'query' ? '历史查询' : '轨迹回放';
|
||||
@@ -790,6 +839,42 @@ export function History({
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered title="轨迹异常判读" style={{ marginTop: 12 }}>
|
||||
<div className="vp-trajectory-anomaly-grid">
|
||||
{[
|
||||
{
|
||||
label: '数据断点',
|
||||
value: trajectoryAnomalies.gapCount > 0 ? `${trajectoryAnomalies.gapCount.toLocaleString()} 处` : '无',
|
||||
detail: trajectoryAnomalies.gapCount > 0 ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}` : '相邻轨迹点未发现超过 30 分钟断点。',
|
||||
color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const
|
||||
},
|
||||
{
|
||||
label: '里程回退',
|
||||
value: trajectoryAnomalies.mileageRollbackCount > 0 ? formatNumber(trajectoryAnomalies.maxMileageRollbackKm, ' km') : '无',
|
||||
detail: trajectoryAnomalies.mileageRollbackCount > 0 ? `${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} 处总里程低于上一点。` : '当前页总里程单调不回退。',
|
||||
color: trajectoryAnomalies.mileageRollbackCount > 0 ? 'red' as const : 'green' as const
|
||||
},
|
||||
{
|
||||
label: '速度异常',
|
||||
value: trajectoryAnomalies.overspeedCount > 0 ? formatNumber(trajectoryAnomalies.maxSpeedKmh, ' km/h') : '无',
|
||||
detail: trajectoryAnomalies.overspeedCount > 0 ? `${trajectoryAnomalies.overspeedCount.toLocaleString()} 个点超过 120 km/h。` : '当前页未发现超过 120 km/h 的速度点。',
|
||||
color: trajectoryAnomalies.overspeedCount > 0 ? 'orange' as const : 'green' as const
|
||||
}
|
||||
].map((item) => (
|
||||
<div key={item.label} className="vp-trajectory-anomaly-item">
|
||||
<Tag color={item.color}>{item.label}</Tag>
|
||||
<div className="vp-monitor-metric-value">{item.value}</div>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="vp-trajectory-anomaly-action">
|
||||
<Tag color={hasTrajectoryAnomaly ? 'orange' : 'green'}>{hasTrajectoryAnomaly ? '建议核对 RAW 帧和当日里程统计' : '轨迹质量可用'}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
{hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合 RAW 解析字段与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
<Space wrap style={{ marginTop: 12 }}>
|
||||
<Tag color="grey">起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
|
||||
<Tag color="grey">终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
|
||||
|
||||
@@ -866,6 +866,35 @@ button.vp-realtime-command-item:focus-visible {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vp-trajectory-anomaly-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vp-trajectory-anomaly-item {
|
||||
min-height: 112px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #fbfcff;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.vp-trajectory-anomaly-action {
|
||||
margin-top: 12px;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
background: #f5f9ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vp-playback-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 260px;
|
||||
@@ -1233,6 +1262,7 @@ button.vp-realtime-command-item:focus-visible {
|
||||
.vp-stat-workspace,
|
||||
.vp-stat-insight-grid,
|
||||
.vp-stat-definition-grid,
|
||||
.vp-trajectory-anomaly-grid,
|
||||
.vp-vehicle-map-layout,
|
||||
.vp-playback-layout,
|
||||
.vp-playback-timeline {
|
||||
|
||||
@@ -5737,6 +5737,54 @@ test('shows trajectory evidence quality on history playback workspace', async ()
|
||||
expect(screen.getByText('10 分钟/点')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('flags trajectory playback anomalies for gap mileage and speed review', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-ANOMALY&protocol=JT808');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/history/locations')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
items: [
|
||||
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 18, socPercent: 0, totalMileageKm: 300, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' },
|
||||
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 132, socPercent: 0, totalMileageKm: 318, lastSeen: '2026-07-03 10:40:00', deviceTime: '2026-07-03 10:40:00', serverTime: '2026-07-03 10:40:01' },
|
||||
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.4, latitude: 23.3, speedKmh: 22, socPercent: 0, totalMileageKm: 316, lastSeen: '2026-07-03 10:45:00', deviceTime: '2026-07-03 10:45:00', serverTime: '2026-07-03 10:45:01' }
|
||||
],
|
||||
total: 3,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/history/raw-frames/query')) {
|
||||
expect(init?.method).toBe('POST');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect(await screen.findByText('轨迹异常判读')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据断点')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 处')).toBeInTheDocument();
|
||||
expect(screen.getByText('里程回退')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 km')).toBeInTheDocument();
|
||||
expect(screen.getByText('速度异常')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('132 km/h').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('建议核对 RAW 帧和当日里程统计')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens amap route from trajectory playback workspace', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-AMAP&protocol=JT808');
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
|
||||
Reference in New Issue
Block a user