perf(tracks): bound playback rendering work
This commit is contained in:
@@ -3,11 +3,11 @@ import {
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
|
||||
import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, trackPlaybackInterval } from '../domain/track';
|
||||
import { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
@@ -16,9 +16,18 @@ 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) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
|
||||
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) {
|
||||
@@ -36,14 +45,14 @@ function alarm(value?: number) {
|
||||
function time(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
|
||||
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 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' }).format(parsed);
|
||||
if (!Number.isNaN(parsed.getTime())) return dateTimeFormatter.format(parsed);
|
||||
return value.replace('T', ' ').slice(5, 19);
|
||||
}
|
||||
|
||||
@@ -128,10 +137,11 @@ function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
|
||||
const TrackRail = memo(function TrackRail({ draft, track, activeStopIndexes, activeEventIndexes, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
|
||||
draft: Draft;
|
||||
track?: TrackPlaybackResponse;
|
||||
activeIndex: number;
|
||||
activeStopIndexes: readonly number[];
|
||||
activeEventIndexes: readonly number[];
|
||||
tab: PanelTab;
|
||||
onDraft: (draft: Draft) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
@@ -155,15 +165,15 @@ function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, o
|
||||
<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={Math.abs(stop.sampledIndex - activeIndex) < 2 ? '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={sampled === activeIndex ? '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 === '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>;
|
||||
}
|
||||
});
|
||||
|
||||
function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
|
||||
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
|
||||
@@ -173,7 +183,7 @@ function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; o
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
type="button"
|
||||
/>)}</div>;
|
||||
}
|
||||
});
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -208,6 +218,9 @@ export default function TrackPage() {
|
||||
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(() => {
|
||||
@@ -222,11 +235,11 @@ export default function TrackPage() {
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
|
||||
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 = Math.max(55, 300 / playbackSpeed);
|
||||
const interval = trackPlaybackInterval(playbackSpeed);
|
||||
const tick = (timestamp: number) => {
|
||||
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
|
||||
if (timestamp - lastFrameRef.current >= interval) {
|
||||
@@ -244,7 +257,7 @@ export default function TrackPage() {
|
||||
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
|
||||
}, [playing, playbackSpeed, points.length]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
const submit = useCallback((event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const keyword = draft.keyword.trim();
|
||||
if (!keyword) return;
|
||||
@@ -255,8 +268,9 @@ export default function TrackPage() {
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setSearchParams(url, { replace: true });
|
||||
};
|
||||
const selectIndex = (index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); };
|
||||
}, [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);
|
||||
@@ -266,9 +280,9 @@ export default function TrackPage() {
|
||||
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} activeIndex={boundedIndex} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={() => setRailCollapsed(true)} />
|
||||
<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} onSelectIndex={selectIndex} onFollowChange={setFollow} />
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user