feat(platform): add trajectory playback workspace
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user