perf(tracks): bound playback rendering work
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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="历史轨迹地图" />
|
||||
|
||||
@@ -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: /查询与明细/ }));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: <https://react.dev/reference/react/memo>, <https://react.dev/reference/react/useMemo>, <https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame>. 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: <https://developer.chrome.com/docs/performance/insights/dom-size>, <https://web.dev/articles/dom-size-and-interactivity>.
|
||||
|
||||
Reference in New Issue
Block a user