feat(platform): flag trajectory playback anomalies

This commit is contained in:
lingniu
2026-07-04 19:34:58 +08:00
parent 0b433c2592
commit ff04a5c93f
3 changed files with 163 additions and 0 deletions

View File

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

View File

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

View File

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