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>
|
||||
|
||||
Reference in New Issue
Block a user