Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
2026-07-16 04:06:08 +08:00

328 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query';
import {
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, trackPlaybackInterval } from '../domain/track';
import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number];
type PanelTab = 'stops' | 'events' | 'overview';
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
const EMPTY_INDEXES: readonly number[] = [];
const numberFormatters = new Map<number, Intl.NumberFormat>();
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
function number(value: number, digits = 1) {
let formatter = numberFormatters.get(digits);
if (!formatter) {
formatter = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits });
numberFormatters.set(digits, formatter);
}
return formatter.format(Number.isFinite(value) ? value : 0);
}
function direction(value?: number) {
if (value === undefined || !Number.isFinite(value)) return '方向 —';
const normalized = ((value % 360) + 360) % 360;
const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
return `${number(normalized, 0)}° ${names[Math.round(normalized / 45) % names.length]}`;
}
function alarm(value?: number) {
if (value === undefined) return '报警 —';
return value === 0 ? '无报警' : `报警 0x${Math.trunc(value).toString(16).toUpperCase().padStart(8, '0')}`;
}
function time(value?: string) {
if (!value) return '—';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return timeFormatter.format(parsed);
return value.split(' ').pop()?.slice(0, 8) || '—';
}
function dateTime(value?: string) {
if (!value) return '—';
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) return dateTimeFormatter.format(parsed);
return value.replace('T', ' ').slice(5, 19);
}
function localDateTime(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
function trackWindow(daysBack = 0, fullDay = false) {
const end = new Date();
end.setDate(end.getDate() - daysBack);
if (fullDay) end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setHours(0, 0, 0, 0);
return { dateFrom: localDateTime(start), dateTo: localDateTime(end) };
}
function defaultTrackWindow() {
return trackWindow();
}
function eventTone(type: string) {
if (type === 'start') return 'start';
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
if (type === 'acceleration' || type === 'stop') return 'warning';
return 'info';
}
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
const [open, setOpen] = useState(false);
const [debounced, setDebounced] = useState(value.trim());
const closeTimerRef = useRef<number>();
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openPicker = () => {
window.clearTimeout(closeTimerRef.current);
setOpen(true);
};
const closePicker = () => {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = window.setTimeout(() => setOpen(false), 140);
};
const params = useMemo(() => {
const next = new URLSearchParams({ limit: '10', offset: '0' });
if (debounced) next.set('keyword', debounced);
return next;
}, [debounced]);
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch />
<input
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={openPicker} onBlur={closePicker}
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
/>
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
{open ? <div className="v2-track-vehicle-options" role="listbox">
<header><span></span><em> VIN</em></header>
{candidates.isFetching ? <p><span className="v2-spinner" /></p> : null}
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
type="button" role="option" aria-selected={false} key={vehicle.vin}
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em></em></button>)}
{!candidates.isFetching && !(candidates.data?.items.length) ? <p></p> : null}
</div> : null}
</div>;
}
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
<div><dt></dt><dd>{dateTime(track.summary.startTime)}</dd></div>
<div><dt></dt><dd>{dateTime(track.summary.endTime)}</dd></div>
<div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div>
<div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
<div><dt> / </dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
<div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div>
</dl></section>
<section><header><strong></strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} </span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} </span><small>{time(source.startTime)}{time(source.endTime)}</small></article>)}</div></section>
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></section>
</div>;
}
const TrackRail = memo(function TrackRail({ draft, track, activeStopIndexes, activeEventIndexes, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
activeStopIndexes: readonly number[];
activeEventIndexes: readonly number[];
tab: PanelTab;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onCollapse: () => void;
}) {
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<form className="v2-track-query" onSubmit={onSubmit}>
<header><div><strong></strong><span> 7 </span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}></button><button type="button" onClick={() => choosePreset(1)}></button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </button></div>
<div className="v2-track-date-grid"><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
<label><span></span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value=""></option><option value="GB32960">GB32960 · </option><option value="JT808">JT808 · GPS </option><option value="YUTONG_MQTT">YUTONG · </option></select></label>
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch /></button>
</form>
<div className="v2-track-rail-result">
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}> <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}> <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}></button></nav>
<div className="v2-track-rail-scroll">
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong></strong><p></p></div> : null}
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={activeStopIndexes.includes(index) ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty"> 3 </p> : null}</div> : null}
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={activeEventIndexes.includes(index) ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</div>
</aside>;
});
const SegmentRail = memo(function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, 160);
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <button
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
onClick={() => onSelectIndex(segment.sampledStartIndex)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
/>)}</div>;
});
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const fallback = useMemo(defaultTrackWindow, []);
const initialDraft = useMemo<Draft>(() => ({
keyword: searchParams.get('vin') || searchParams.get('keyword') || '',
dateFrom: searchParams.get('dateFrom') || fallback.dateFrom,
dateTo: searchParams.get('dateTo') || fallback.dateTo,
protocol: searchParams.get('protocol') || ''
}), []);
const [draft, setDraft] = useState(initialDraft);
const [criteria, setCriteria] = useState(initialDraft);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1);
const [follow, setFollow] = useState(true);
const [showStops, setShowStops] = useState(true);
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
const [railCollapsed, setRailCollapsed] = useState(false);
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
return next;
}, [criteria]);
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const track = query.data;
const points = track?.points ?? [];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
const current = points[boundedIndex];
const railSelection = useMemo(() => buildTrackRailSelection(track), [track]);
const activeStopIndexes = railSelection.stopIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
const activeEventIndexes = railSelection.eventIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
useEffect(() => {
if (playing || !current) return;
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
return () => window.clearTimeout(timer);
}, [current?.latitude, current?.longitude, playing]);
const addressQuery = useQuery({
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }), signal),
gcTime: QUERY_MEMORY.highVolumeGcTime
});
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
lastFrameRef.current = 0;
const interval = trackPlaybackInterval(playbackSpeed);
const tick = (timestamp: number) => {
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
if (timestamp - lastFrameRef.current >= interval) {
const steps = Math.max(1, Math.floor((timestamp - lastFrameRef.current) / interval));
lastFrameRef.current = timestamp;
setActiveIndex((index) => {
const next = Math.min(points.length - 1, index + steps);
if (next >= points.length - 1) setPlaying(false);
return next;
});
}
animationRef.current = window.requestAnimationFrame(tick);
};
animationRef.current = window.requestAnimationFrame(tick);
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
}, [playing, playbackSpeed, points.length]);
const submit = useCallback((event: FormEvent) => {
event.preventDefault();
const keyword = draft.keyword.trim();
if (!keyword) return;
const next = { ...draft, keyword };
setCriteria(next);
const url = new URLSearchParams({ keyword });
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
if (next.dateTo) url.set('dateTo', next.dateTo);
if (next.protocol) url.set('protocol', next.protocol);
setSearchParams(url, { replace: true });
}, [draft, setSearchParams]);
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
const collapseRail = useCallback(() => setRailCollapsed(true), []);
const togglePlayback = () => {
if (points.length < 2) return;
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
setFollow(true);
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
<TrackRail draft={draft} track={track} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={collapseRail} />
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
<div className="v2-track-stage-tools">
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span></span></button> : null}
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span></span></button>
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span></span></button>
</div>
{track?.points.length ? <>
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
</div>
<article className="v2-track-current-card">
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
<div><span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small></small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
<p title={addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</article>
</> : <div className="v2-track-empty-state"><IconMapPin /><strong></strong><p></p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch /></button></div>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<footer className="v2-track-playback-dock">
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
<div><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
</div>
<div className="v2-track-dock-controls">
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
<label><span></span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
</div>
<div className="v2-track-dock-metrics"><span><small></small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small></small><strong>{current?.protocol || '—'}</strong></span><span><small></small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
</footer>
</section>
</div>;
}