From 8e8f3f9f5ce1989728fff6f707d98a3931284059 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 04:06:08 +0800 Subject: [PATCH] perf(tracks): bound playback rendering work --- .../apps/web/src/v2/domain/track.test.ts | 22 +++++++- .../apps/web/src/v2/domain/track.ts | 24 +++++++++ .../apps/web/src/v2/map/TrackMap.test.tsx | 36 +++++++++++++ .../apps/web/src/v2/map/TrackMap.tsx | 18 +++++-- .../apps/web/src/v2/pages/TrackPage.test.tsx | 8 ++- .../apps/web/src/v2/pages/TrackPage.tsx | 52 ++++++++++++------- .../docs/frontend-production-readiness.md | 8 +++ 7 files changed, 143 insertions(+), 25 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts index da69dd5d..592413ea 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts @@ -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' }, diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.ts index 7abd9ce8..887695f6 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.ts @@ -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 = new Array(pointCount); + const eventIndexesByPoint: Array = 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; diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx index ab7bf834..013912a9 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx @@ -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( undefined} + onFollowChange={() => undefined} + />); + + await waitFor(() => expect(mapInstances).toHaveLength(1)); + view.rerender( 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 () => { diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx index 89b8dce8..2e3022d7 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.tsx @@ -8,12 +8,13 @@ function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: strin return `
${label ?? ''}
`; } -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
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index 51331086..3c8b36ec 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -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 }) =>
})); +vi.mock('../map/TrackMap', () => ({ TrackMap: ({ activeIndex, followDurationMs }: { activeIndex: number; followDurationMs: number }) =>
})); 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: /查询与明细/ })); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index faf884de..66fb2492 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -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(); +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 }) {
; } -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
{!track ?
选择车辆后查询轨迹

停留点、轨迹事件与行程证据会在这里统一呈现。

: null} - {track && tab === 'stops' ?
{track.stops.map((stop, index) => )}{!track.stops.length ?

当前时间窗没有超过 3 分钟的停留点

: null}
: null} - {track && tab === 'events' ?
{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return ; })}
: null} + {track && tab === 'stops' ?
{track.stops.map((stop, index) => )}{!track.stops.length ?

当前时间窗没有超过 3 分钟的停留点

: null}
: null} + {track && tab === 'events' ?
{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return ; })}
: null} {track && tab === 'overview' ? : null}
; -} +}); -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
{segments.map((segment) =>
; -} +}); 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
- setRailCollapsed(true)} /> +
- +
{railCollapsed ? : null} diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index fdf4ffa2..cf66b1ed 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -8,6 +8,14 @@ The 15-second monitor refresh previously treated every new response object as a FleetMap now fingerprints only fields that affect map rendering. An unchanged refresh performs no AMap data/style/label mutation. A moving vehicle still updates MassMarks once, but the LabelsLayer removes and replaces only that vehicle's changed marker; full label-layer replacement is reserved for crossing the normal/dense zoom boundary. The selected ripple marker also skips redundant position writes. This uses the official AMap JS API 2.0 [`LabelsLayer.remove`](https://lbs.amap.com/api/javascript-api-v2/guide/amap-massmarker/label-marker) and `LabelMarker` update model instead of reconstructing all overlays. Unit tests assert zero map mutations for an `asOf`-only refresh and exactly one label replacement for one moved vehicle. +## 2026-07-16: bounded track-playback rendering + +The production replay for a 1,600-point day contained 84 interactive activity segments, nine stop rows and 64 event rows. Advancing `activeIndex` previously rendered the complete query/result rail and all 84 segment buttons on every playback step. It also rebuilt `Intl.NumberFormat` and `Intl.DateTimeFormat` instances during render, counted passed map points with a full-array reduction, and at 4× speed started a 180 ms map pan every 75 ms. The overlapping camera animations and repeated static work competed with the marker update on the main thread. + +Playback now precomputes sparse stop/event highlight indexes once per track. The memoized result rail receives a shared empty selection for ordinary points, while the memoized segment rail receives stable callbacks, so both static subtrees skip the vast majority of playback renders. Date and number formatters are module-level reusable instances, and the passed-path count is a precomputed O(1) lookup instead of an O(points) scan per step. Follow animation is cadence-bound: 4× playback uses 65 ms movement inside its 75 ms update interval, while direct operator selection retains the smoother 180 ms transition. + +This follows React's requirement that memoized children receive stable props and callbacks, and the browser animation model that schedules work against paint frames and calculates progress from timestamps: , , . The regression suite asserts sparse highlight mapping, the 55 ms minimum playback interval, 65 ms 4× follow duration, 180 ms paused duration, passed-path advancement and complete map/listener cleanup. + ## 2026-07-16: single responsive mileage matrix The mileage query rendered its desktop table and mobile card matrix at the same time, then hid one tree with CSS. A 90-day, 20-vehicle production page therefore retained 1,860 desktop cells and another 1,800 hidden mobile day cells. Chrome identifies excessive DOM size as a source of longer style calculation, layout and interaction work, and recommends creating nodes only when they are needed: , .