perf(tracks): coalesce address lookups
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TrackPlaybackResponse } from '../../api/types';
|
||||
import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackCsv, trackPlaybackInterval } from './track';
|
||||
import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackAddressCoordinate, trackCsv, trackPlaybackInterval } from './track';
|
||||
|
||||
describe('track domain', () => {
|
||||
it('formats durations and maps original event indices to sampled points', () => {
|
||||
@@ -38,4 +38,11 @@ describe('track domain', () => {
|
||||
expect(csv.startsWith('\uFEFFVIN,')).toBe(true);
|
||||
expect(csv).toContain('"粤A,001"');
|
||||
});
|
||||
|
||||
it('coalesces GPS jitter into street-level address cells and rejects invalid coordinates', () => {
|
||||
expect(trackAddressCoordinate(113.123441, 23.123441)).toEqual({ longitude: 113.1234, latitude: 23.1234, key: '113.1234,23.1234' });
|
||||
expect(trackAddressCoordinate(113.123449, 23.123449)?.key).toBe('113.1234,23.1234');
|
||||
expect(trackAddressCoordinate(0, 0)).toBeUndefined();
|
||||
expect(trackAddressCoordinate(181, 23)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { HistoryLocationRow, TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
|
||||
|
||||
export const TRACK_ADDRESS_SETTLE_MS = 650;
|
||||
|
||||
export type TrackAddressCoordinate = { longitude: number; latitude: number; key: string };
|
||||
|
||||
export function trackAddressCoordinate(longitude?: number, latitude?: number): TrackAddressCoordinate | undefined {
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)
|
||||
|| Math.abs(longitude ?? 0) > 180 || Math.abs(latitude ?? 0) > 90
|
||||
|| (longitude === 0 && latitude === 0)) return undefined;
|
||||
const roundedLongitude = Number(longitude!.toFixed(4));
|
||||
const roundedLatitude = Number(latitude!.toFixed(4));
|
||||
return {
|
||||
longitude: roundedLongitude,
|
||||
latitude: roundedLatitude,
|
||||
key: `${roundedLongitude.toFixed(4)},${roundedLatitude.toFixed(4)}`
|
||||
};
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number) {
|
||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||
const hours = Math.floor(safe / 3600);
|
||||
|
||||
@@ -74,3 +74,20 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-playback'] })).toHaveLength(0));
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0));
|
||||
});
|
||||
|
||||
test('coalesces rapid track scrubbing into one address lookup for the final point', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
|
||||
|
||||
const progress = screen.getByRole('slider', { name: '轨迹播放进度' });
|
||||
fireEvent.change(progress, { target: { value: '1' } });
|
||||
fireEvent.change(progress, { target: { value: '2' } });
|
||||
|
||||
expect(screen.getByText('位置已变化,等待地址解析…')).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.reverseGeocode.mock.calls[1][0].get('longitude')).toBe('113.3000');
|
||||
expect(mocks.reverseGeocode.mock.calls[1][0].get('latitude')).toBe('23.3000');
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } fr
|
||||
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 { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, type TrackAddressCoordinate } from '../domain/track';
|
||||
import { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
@@ -221,19 +221,21 @@ export default function TrackPage() {
|
||||
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 }>();
|
||||
const currentAddressPoint = useMemo(() => trackAddressCoordinate(current?.longitude, current?.latitude), [current?.latitude, current?.longitude]);
|
||||
const [addressPoint, setAddressPoint] = useState<TrackAddressCoordinate>();
|
||||
|
||||
useEffect(() => {
|
||||
if (playing || !current) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
|
||||
if (playing || !currentAddressPoint) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint(currentAddressPoint), TRACK_ADDRESS_SETTLE_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [current?.latitude, current?.longitude, playing]);
|
||||
}, [currentAddressPoint?.key, playing]);
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
|
||||
queryKey: ['track-address', addressPoint?.key],
|
||||
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }), signal),
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(4), latitude: addressPoint!.latitude.toFixed(4) }), signal),
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const addressSettling = Boolean(!playing && currentAddressPoint && currentAddressPoint.key !== addressPoint?.key);
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
|
||||
useEffect(() => {
|
||||
@@ -299,7 +301,7 @@ export default function TrackPage() {
|
||||
<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>
|
||||
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : 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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user