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 592413ea..dcd0ad6d 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 { 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(); + }); }); 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 887695f6..e3382e0b 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.ts @@ -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); 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 3c8b36ec..05d68058 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 @@ -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(); + + 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'); +}); 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 66fb2492..41933c72 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -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(); 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() {
{track.plate || track.vin}{dateTime(current?.deviceTime)}
{number(progress, 0)}%
速度{number(current?.speedKmh ?? 0)} km/h方向{direction(current?.directionDeg)}SOC{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}
-

{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}

+

{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}

:
先选择车辆,再开始轨迹回放

地图会显示完整路径、停留点、事件点和播放位置。

}