perf(tracks): coalesce address lookups
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TrackPlaybackResponse } from '../../api/types';
|
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', () => {
|
describe('track domain', () => {
|
||||||
it('formats durations and maps original event indices to sampled points', () => {
|
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.startsWith('\uFEFFVIN,')).toBe(true);
|
||||||
expect(csv).toContain('"粤A,001"');
|
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';
|
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) {
|
export function formatDuration(seconds: number) {
|
||||||
const safe = Math.max(0, Math.floor(seconds || 0));
|
const safe = Math.max(0, Math.floor(seconds || 0));
|
||||||
const hours = Math.floor(safe / 3600);
|
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-playback'] })).toHaveLength(0));
|
||||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).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 { useSearchParams } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
|
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 { TrackMap } from '../map/TrackMap';
|
||||||
import { InlineError } from '../shared/AsyncState';
|
import { InlineError } from '../shared/AsyncState';
|
||||||
import { QUERY_MEMORY } from '../queryPolicy';
|
import { QUERY_MEMORY } from '../queryPolicy';
|
||||||
@@ -221,19 +221,21 @@ export default function TrackPage() {
|
|||||||
const railSelection = useMemo(() => buildTrackRailSelection(track), [track]);
|
const railSelection = useMemo(() => buildTrackRailSelection(track), [track]);
|
||||||
const activeStopIndexes = railSelection.stopIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
|
const activeStopIndexes = railSelection.stopIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
|
||||||
const activeEventIndexes = railSelection.eventIndexesByPoint[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(() => {
|
useEffect(() => {
|
||||||
if (playing || !current) return;
|
if (playing || !currentAddressPoint) return;
|
||||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
|
const timer = window.setTimeout(() => setAddressPoint(currentAddressPoint), TRACK_ADDRESS_SETTLE_MS);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [current?.latitude, current?.longitude, playing]);
|
}, [currentAddressPoint?.key, playing]);
|
||||||
const addressQuery = useQuery({
|
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,
|
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
|
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(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -299,7 +301,7 @@ export default function TrackPage() {
|
|||||||
<article className="v2-track-current-card">
|
<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>
|
<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>
|
<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>
|
</article>
|
||||||
</> : <div className="v2-track-empty-state"><IconMapPin /><strong>先选择车辆,再开始轨迹回放</strong><p>地图会显示完整路径、停留点、事件点和播放位置。</p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch />选择车辆</button></div>}
|
</> : <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