feat(platform): add trajectory playback workspace

This commit is contained in:
lingniu
2026-07-04 10:38:10 +08:00
parent 89c4fffd5d
commit be5a0cf92c
3 changed files with 200 additions and 5 deletions

View File

@@ -43,6 +43,28 @@ function isIncludeFieldsEnabled(value?: boolean | string) {
return value === true || value === 'true';
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function hasValidCoordinate(row: HistoryLocationRow) {
return isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
}
function mapPointStyle(row: HistoryLocationRow, index: number) {
if (!hasValidCoordinate(row)) {
return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` };
}
const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100));
const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100));
return { left: `${left}%`, top: `${top}%` };
}
function formatNumber(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
}
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
return {
...defaultFilters,
@@ -166,6 +188,13 @@ export function History({
};
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
const locationItems = locations.items;
const validLocations = locationItems.filter(hasValidCoordinate);
const mileageValues = locationItems.map((item) => item.totalMileageKm).filter(isFiniteNumber);
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
const maxSpeed = locationItems.map((item) => item.speedKmh).filter(isFiniteNumber).reduce<number | undefined>((max, item) => max == null ? item : Math.max(max, item), undefined);
const firstLocation = locationItems[0];
const lastLocation = locationItems[locationItems.length - 1];
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
const currentProtocol = filters.protocol?.trim() ?? '';
const selectedFieldCount = splitFields(filters.fields).length;
@@ -184,8 +213,8 @@ export function History({
return (
<div className="vp-page">
<PageHeader
title="历史数据"
description="按车辆查询位置历史和 RAW 帧历史,数据来源只作为过滤和诊断维度"
title="轨迹回放"
description="按车辆查询历史位置、轨迹回放和 RAW 帧证据,数据来源只作为过滤和诊断维度"
actions={(
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
@@ -233,6 +262,63 @@ export function History({
</Space>
</Card>
) : null}
<Card bordered title="轨迹回放作业台" style={{ marginTop: 16 }}>
<div className="vp-playback-layout">
<div className="vp-playback-map">
<div className="vp-monitor-map-header">
<Space wrap>
<Tag color="blue">{validLocations.length.toLocaleString()} </Tag>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部来源'}</Tag>
<Tag color="green">{formatNumber(mileageDelta, ' km')}</Tag>
</Space>
</div>
<div className="vp-map vp-map-monitor">
{validLocations.slice(0, 120).map((row, index) => (
<span
key={`${row.vin}-${row.deviceTime}-${index}`}
className={`vp-map-dot ${index === 0 ? 'vp-map-dot-start' : index === validLocations.length - 1 ? 'vp-map-dot-end' : 'vp-map-dot-online'}`}
title={`${row.deviceTime} ${row.speedKmh ?? '-'} km/h`}
style={mapPointStyle(row, index)}
/>
))}
</div>
</div>
<div className="vp-playback-side">
{[
{ label: '轨迹点', value: locations.total.toLocaleString(), color: 'blue' as const },
{ label: '有效定位', value: validLocations.length.toLocaleString(), color: 'green' as const },
{ label: '区间里程', value: formatNumber(mileageDelta, ' km'), color: 'green' as const },
{ label: '最高速度', value: formatNumber(maxSpeed, ' km/h'), color: 'orange' as const }
].map((item) => (
<div key={item.label} className="vp-monitor-metric">
<Tag color={item.color}>{item.label}</Tag>
<div className="vp-monitor-metric-value">{item.value}</div>
</div>
))}
</div>
</div>
<div className="vp-playback-timeline">
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
<div key={`${row.deviceTime}-${index}`} className="vp-playback-step">
<Tag color={index === 0 ? 'green' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? 'orange' : 'blue'}>
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `${index + 1}`}
</Tag>
<div className="vp-evidence-value">{row.deviceTime || row.serverTime || '-'}</div>
<Typography.Text type="secondary">
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
</Typography.Text>
</div>
))}
{locationItems.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</div>
<Space wrap style={{ marginTop: 12 }}>
<Tag color="grey">{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
<Tag color="grey">{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
<Tag color="grey"> JS API </Tag>
</Space>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs activeKey={activeTab} onChange={(key) => changeTab(String(key))}>
<Tabs.TabPane tab="位置历史" itemKey="location">