perf(tracks): bound playback rendering work

This commit is contained in:
lingniu
2026-07-16 04:06:08 +08:00
parent 982acb7730
commit 8e8f3f9f5c
7 changed files with 143 additions and 25 deletions

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import type { TrackPlaybackResponse } from '../../api/types';
import { formatDuration, sampledEventIndex, trackCsv } from './track';
import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackCsv, trackPlaybackInterval } from './track';
describe('track domain', () => {
it('formats durations and maps original event indices to sampled points', () => {
@@ -9,6 +9,26 @@ describe('track domain', () => {
expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7);
});
it('precomputes sparse rail highlights and bounds the playback cadence', () => {
const playback = {
points: Array.from({ length: 8 }, (_, index) => ({ longitude: index, latitude: index })),
stops: [{ sampledIndex: 3 }],
events: [{ index: 50 }, { index: 75, sampledIndex: 6 }],
summary: { pointCount: 101 }
} as TrackPlaybackResponse;
const selection = buildTrackRailSelection(playback);
expect(selection.stopIndexesByPoint[1]).toBeUndefined();
expect(selection.stopIndexesByPoint[2]).toEqual([0]);
expect(selection.stopIndexesByPoint[3]).toEqual([0]);
expect(selection.stopIndexesByPoint[4]).toEqual([0]);
expect(selection.eventIndexesByPoint[4]).toEqual([0]);
expect(selection.eventIndexesByPoint[6]).toEqual([1]);
expect(trackPlaybackInterval(1)).toBe(300);
expect(trackPlaybackInterval(4)).toBe(75);
expect(trackPlaybackInterval(100)).toBe(55);
});
it('exports the current result with UTF-8 BOM and escaped values', () => {
const track = {
plate: '粤A,001', vin: 'VIN', summary: { startTime: '2026-07-03 10:00:00' },

View File

@@ -16,6 +16,30 @@ export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: numbe
return Math.max(0, Math.min(sampledCount - 1, Math.round(event.index * (sampledCount - 1) / (originalCount - 1))));
}
export function trackPlaybackInterval(speed: number) {
return Math.max(55, 300 / Math.max(0.5, speed));
}
export function buildTrackRailSelection(track?: TrackPlaybackResponse) {
const pointCount = track?.points.length ?? 0;
const stopIndexesByPoint: Array<number[] | undefined> = new Array(pointCount);
const eventIndexesByPoint: Array<number[] | undefined> = new Array(pointCount);
if (!track || pointCount === 0) return { stopIndexesByPoint, eventIndexesByPoint };
track.stops.forEach((stop, stopIndex) => {
for (let offset = -1; offset <= 1; offset += 1) {
const pointIndex = stop.sampledIndex + offset;
if (pointIndex < 0 || pointIndex >= pointCount) continue;
(stopIndexesByPoint[pointIndex] ??= []).push(stopIndex);
}
});
track.events.forEach((event, eventIndex) => {
const pointIndex = sampledEventIndex(event, pointCount, track.summary.pointCount);
(eventIndexesByPoint[pointIndex] ??= []).push(eventIndex);
});
return { stopIndexesByPoint, eventIndexesByPoint };
}
function csvCell(value: unknown) {
const text = String(value ?? '');
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;

View File

@@ -9,8 +9,11 @@ const overlaySetMap = vi.fn();
const mapOff = vi.fn();
const mapDestroy = vi.fn();
const markerListeners: Array<{ overlay: TestOverlay; event: string; handler: () => void }> = [];
const overlayInstances: TestOverlay[] = [];
const mapInstances: TestMap[] = [];
class TestOverlay {
constructor() { overlayInstances.push(this); }
on = vi.fn((event: string, handler: () => void) => markerListeners.push({ overlay: this, event, handler }));
off = overlayOff;
setMap = overlaySetMap;
@@ -19,6 +22,7 @@ class TestOverlay {
}
class TestMap {
constructor() { mapInstances.push(this); }
add = vi.fn();
addControl = vi.fn();
setFitView = vi.fn();
@@ -90,6 +94,38 @@ afterEach(() => {
mapOff.mockReset();
mapDestroy.mockReset();
markerListeners.length = 0;
overlayInstances.length = 0;
mapInstances.length = 0;
});
test('uses the playback cadence for follow animation and advances the passed path', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
const view = render(<TrackMap
points={points}
stops={[]}
activeIndex={0}
showStops={false}
follow
followDurationMs={65}
onSelectIndex={() => undefined}
onFollowChange={() => undefined}
/>);
await waitFor(() => expect(mapInstances).toHaveLength(1));
view.rerender(<TrackMap
points={points}
stops={[]}
activeIndex={1}
showStops={false}
follow
followDurationMs={65}
onSelectIndex={() => undefined}
onFollowChange={() => undefined}
/>);
await waitFor(() => expect(mapInstances[0].panTo).toHaveBeenLastCalledWith(expect.any(Array), 65));
expect(overlayInstances.some((overlay) => overlay.setPath.mock.calls.some(([path]) => Array.isArray(path) && path.length === 2))).toBe(true);
});
test('detaches every marker listener and map listener on unmount', async () => {

View File

@@ -8,12 +8,13 @@ function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: strin
return `<div class="v2-track-marker is-${kind}"><span>${label ?? ''}</span></div>`;
}
export function TrackMap({ points, stops, activeIndex, showStops, follow, onSelectIndex, onFollowChange }: {
export function TrackMap({ points, stops, activeIndex, showStops, follow, followDurationMs = 180, onSelectIndex, onFollowChange }: {
points: HistoryLocationRow[];
stops: TrackStop[];
activeIndex: number;
showStops: boolean;
follow: boolean;
followDurationMs?: number;
onSelectIndex: (index: number) => void;
onFollowChange: (follow: boolean) => void;
}) {
@@ -29,6 +30,15 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
const followChangeRef = useRef(onFollowChange);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
const passedCountByPoint = useMemo(() => {
const counts = new Uint16Array(points.length);
let validIndex = 0;
for (let pointIndex = 0; pointIndex < points.length; pointIndex += 1) {
while (validIndex < valid.length && valid[validIndex].index <= pointIndex) validIndex += 1;
counts[pointIndex] = validIndex;
}
return counts;
}, [points.length, valid]);
useEffect(() => { selectRef.current = onSelectIndex; }, [onSelectIndex]);
useEffect(() => { followChangeRef.current = onFollowChange; }, [onFollowChange]);
@@ -127,10 +137,10 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, onSele
if (!point || !isValidAMapCoordinate(point.longitude, point.latitude)) return;
const position = wgs84ToGcj02(point.longitude, point.latitude);
currentMarkerRef.current?.setPosition?.(position);
const passedCount = valid.reduce((count, entry) => count + (entry.index <= activeIndex ? 1 : 0), 0);
const passedCount = passedCountByPoint[activeIndex] ?? 0;
passedPathRef.current?.setPath?.(pathRef.current.slice(0, Math.max(1, passedCount)));
if (follow) mapRef.current?.panTo?.(position, 180);
}, [activeIndex, follow, points, valid]);
if (follow) mapRef.current?.panTo?.(position, followDurationMs);
}, [activeIndex, follow, followDurationMs, passedCountByPoint, points]);
return <div className="v2-track-map">
<div ref={containerRef} className="v2-track-map-canvas" aria-label="历史轨迹地图" />

View File

@@ -8,7 +8,7 @@ import { ROUTER_FUTURE } from '../routing/routerConfig';
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
vi.mock('../../api/client', () => ({ api: mocks }));
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex }: { activeIndex: number }) => <div data-testid="track-map" data-active-index={activeIndex} /> }));
vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex, followDurationMs }: { activeIndex: number; followDurationMs: number }) => <div data-testid="track-map" data-active-index={activeIndex} data-follow-duration={followDurationMs} /> }));
const track = {
vin: 'LTEST000000000001', plate: '粤A12345', total: 3, truncated: false, sampled: false, asOf: '2026-07-15T08:00:00Z',
@@ -55,6 +55,12 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2));
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(1));
fireEvent.change(screen.getByRole('combobox', { name: '速度' }), { target: { value: '4' } });
fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '65');
fireEvent.click(screen.getByRole('button', { name: '暂停轨迹播放' }));
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '180');
fireEvent.click(screen.getByRole('button', { name: '收起查询面板' }));
expect(screen.getByRole('button', { name: /查询与明细/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /查询与明细/ }));

View File

@@ -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}