From fc25edf9f73a06accab0c9eace2ff23851a4dbf6 Mon Sep 17 00:00:00 2001 From: a-y-a-n-das Date: Sat, 28 Mar 2026 21:07:45 +0530 Subject: [PATCH 1/6] feat: polyline trace route added --- src/components/map/index.spec.tsx | 18 ++ src/components/map/index.tsx | 4 + src/components/map/parts/route-lines.spec.tsx | 128 ++++++++--- src/components/map/parts/route-lines.tsx | 29 ++- .../map/parts/trace-input-line.spec.tsx | 96 +++++++++ src/components/map/parts/trace-input-line.tsx | 59 +++++ .../map/parts/trace-route-markers.spec.tsx | 101 +++++++++ .../map/parts/trace-route-markers.tsx | 36 ++++ src/components/route-planner.spec.tsx | 61 ++++++ src/components/route-planner.tsx | 40 +++- .../trace-route/trace-route.spec.tsx | 111 ++++++++++ src/components/trace-route/trace-route.tsx | 202 ++++++++++++++++++ src/hooks/use-trace-route-query.spec.ts | 176 +++++++++++++++ src/hooks/use-trace-route-query.ts | 88 ++++++++ src/stores/trace-route-store.spec.ts | 124 +++++++++++ src/stores/trace-route-store.ts | 200 +++++++++++++++++ src/utils/parse-gpx.spec.ts | 58 +++++ src/utils/parse-gpx.ts | 21 ++ src/utils/route-schemas.spec.ts | 4 + src/utils/route-schemas.ts | 7 +- 20 files changed, 1527 insertions(+), 36 deletions(-) create mode 100644 src/components/map/parts/trace-input-line.spec.tsx create mode 100644 src/components/map/parts/trace-input-line.tsx create mode 100644 src/components/map/parts/trace-route-markers.spec.tsx create mode 100644 src/components/map/parts/trace-route-markers.tsx create mode 100644 src/components/trace-route/trace-route.spec.tsx create mode 100644 src/components/trace-route/trace-route.tsx create mode 100644 src/hooks/use-trace-route-query.spec.ts create mode 100644 src/hooks/use-trace-route-query.ts create mode 100644 src/stores/trace-route-store.spec.ts create mode 100644 src/stores/trace-route-store.ts create mode 100644 src/utils/parse-gpx.spec.ts create mode 100644 src/utils/parse-gpx.ts diff --git a/src/components/map/index.spec.tsx b/src/components/map/index.spec.tsx index 0cfe82db..50b425a4 100644 --- a/src/components/map/index.spec.tsx +++ b/src/components/map/index.spec.tsx @@ -198,6 +198,18 @@ vi.mock('./parts/route-lines', () => ({ RouteLines: vi.fn(() =>
Route Lines
), })); +vi.mock('./parts/trace-input-line', () => ({ + TraceRouteInputLine: vi.fn(() => ( +
Trace Input Line
+ )), +})); + +vi.mock('./parts/trace-route-markers', () => ({ + TraceRouteMarkers: vi.fn(() => ( +
Trace Route Markers
+ )), +})); + vi.mock('./parts/highlight-segment', () => ({ HighlightSegment: vi.fn(() => (
Highlight
@@ -345,6 +357,12 @@ describe('MapComponent', () => { expect(screen.getByTestId('route-lines')).toBeInTheDocument(); }); + it('should render trace-route input and marker layers', () => { + render(); + expect(screen.getByTestId('trace-input-line')).toBeInTheDocument(); + expect(screen.getByTestId('trace-route-markers')).toBeInTheDocument(); + }); + it('should render highlight segment component', () => { render(); expect(screen.getByTestId('highlight-segment')).toBeInTheDocument(); diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx index 8a35d9f1..61200020 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -68,6 +68,8 @@ import { useReverseGeocodeIsochrones, } from '@/hooks/use-isochrones-queries'; import { toast } from 'sonner'; +import { TraceRouteMarkers } from './parts/trace-route-markers'; +import { TraceRouteInputLine } from './parts/trace-input-line'; const { center, zoom: zoom_initial } = getInitialMapPosition(); @@ -826,7 +828,9 @@ export const MapComponent = () => { onStyleChange={handleStyleChange} onCustomStyleLoaded={handleCustomStyleLoaded} /> + + diff --git a/src/components/map/parts/route-lines.spec.tsx b/src/components/map/parts/route-lines.spec.tsx index 03e9db5d..b8b33114 100644 --- a/src/components/map/parts/route-lines.spec.tsx +++ b/src/components/map/parts/route-lines.spec.tsx @@ -17,13 +17,48 @@ vi.mock('react-map-gl/maplibre', () => ({ })); const mockUseDirectionsStore = vi.fn(); +const mockUseTraceRouteStore = vi.fn(); +const mockUseParams = vi.hoisted(() => + vi.fn(() => ({ activeTab: 'directions' })) +); + +vi.mock('@tanstack/react-router', () => ({ + useParams: mockUseParams, +})); vi.mock('@/stores/directions-store', () => ({ useDirectionsStore: (selector: (state: unknown) => unknown) => mockUseDirectionsStore(selector), })); -const createMockState = (overrides = {}) => ({ +vi.mock('@/stores/trace-route-store', () => ({ + useTraceRouteStore: (selector: (state: unknown) => unknown) => + mockUseTraceRouteStore(selector), +})); + +interface MockRouteGeometry { + decodedGeometry: number[][]; + trip: { + summary: { + length: number; + time: number; + }; + }; + alternates: MockRouteGeometry[]; +} + +interface MockRouteState { + results: { + data: MockRouteGeometry | null; + show: Record; + }; + successful: boolean; + activeRouteIndex: number; +} + +const createMockState = ( + overrides: Partial = {} +): MockRouteState => ({ results: { data: { decodedGeometry: [ @@ -40,17 +75,38 @@ const createMockState = (overrides = {}) => ({ ...overrides, }); +const setupStores = ({ + activeTab = 'directions', + directionState = createMockState(), + traceState = createMockState(), +}: { + activeTab?: string; + directionState?: MockRouteState; + traceState?: MockRouteState; +} = {}) => { + mockUseParams.mockReturnValue({ activeTab }); + mockUseDirectionsStore.mockImplementation((selector) => + selector(directionState) + ); + mockUseTraceRouteStore.mockImplementation((selector) => selector(traceState)); +}; + describe('RouteLines', () => { beforeEach(() => { mockSource.mockClear(); mockLayer.mockClear(); mockUseDirectionsStore.mockClear(); + mockUseTraceRouteStore.mockClear(); + mockUseParams.mockReturnValue({ activeTab: 'directions' }); }); it('should render nothing when results data is null', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = { results: { data: null, show: {} }, successful: false }; - return selector(state); + setupStores({ + directionState: { + results: { data: null, show: {} }, + successful: false, + activeRouteIndex: -1, + }, }); const { container } = render(); @@ -59,10 +115,7 @@ describe('RouteLines', () => { }); it('should render nothing when not successful', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState({ successful: false }); - return selector(state); - }); + setupStores({ directionState: createMockState({ successful: false }) }); const { container } = render(); @@ -70,10 +123,7 @@ describe('RouteLines', () => { }); it('should render Source when data is valid', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState(); - return selector(state); - }); + setupStores(); render(); @@ -83,10 +133,7 @@ describe('RouteLines', () => { }); it('should render two layers (outline and line)', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState(); - return selector(state); - }); + setupStores(); render(); @@ -94,10 +141,7 @@ describe('RouteLines', () => { }); it('should render outline layer with white color', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState(); - return selector(state); - }); + setupStores(); render(); @@ -111,10 +155,7 @@ describe('RouteLines', () => { }); it('should render line layer with dynamic color', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState(); - return selector(state); - }); + setupStores(); render(); @@ -132,10 +173,7 @@ describe('RouteLines', () => { }); it('should convert lat/lng to lng/lat format', () => { - mockUseDirectionsStore.mockImplementation((selector) => { - const state = createMockState(); - return selector(state); - }); + setupStores(); render(); @@ -144,4 +182,38 @@ describe('RouteLines', () => { expect(coords[0]).toEqual([10, 50]); expect(coords[1]).toEqual([11, 51]); }); + + it('should use trace-route results when active tab is trace-route', () => { + setupStores({ + activeTab: 'trace-route', + directionState: { + results: { data: null, show: {} }, + successful: false, + activeRouteIndex: -1, + }, + traceState: createMockState({ + activeRouteIndex: 0, + results: { + data: { + decodedGeometry: [ + [1, 2], + [3, 4], + ], + trip: { summary: { length: 2, time: 60 } }, + alternates: [], + }, + show: { 0: true }, + }, + }), + }); + + render(); + + const sourceCall = mockSource.mock.calls[0]?.[0]; + const coords = sourceCall?.data.features[0].geometry.coordinates; + expect(coords).toEqual([ + [2, 1], + [4, 3], + ]); + }); }); diff --git a/src/components/map/parts/route-lines.tsx b/src/components/map/parts/route-lines.tsx index 8a76cfaa..a47a3a42 100644 --- a/src/components/map/parts/route-lines.tsx +++ b/src/components/map/parts/route-lines.tsx @@ -4,22 +4,39 @@ import { useDirectionsStore } from '@/stores/directions-store'; import { routeObjects } from '../constants'; import type { Feature, FeatureCollection, LineString } from 'geojson'; import type { ParsedDirectionsGeometry } from '@/components/types'; +import { useParams } from '@tanstack/react-router'; +import { useTraceRouteStore } from '@/stores/trace-route-store'; export function RouteLines() { + const { activeTab } = useParams({ from: '/$activeTab' }); + const isTraceRoute = activeTab === 'trace-route'; + const directionResults = useDirectionsStore((state) => state.results); const directionsSuccessful = useDirectionsStore((state) => state.successful); - const activeRouteIndex = useDirectionsStore( + const activeDirectionRouteIndex = useDirectionsStore( (state) => state.activeRouteIndex ); + const traceRouteResults = useTraceRouteStore((state) => state.results); + const traceRouteSuccessful = useTraceRouteStore((state) => state.successful); + const traceRouteActiveIndex = useTraceRouteStore( + (state) => state.activeRouteIndex + ); + + const results = isTraceRoute ? traceRouteResults : directionResults; + const successful = isTraceRoute ? traceRouteSuccessful : directionsSuccessful; + const activeRouteIndex = isTraceRoute + ? traceRouteActiveIndex + : activeDirectionRouteIndex; + const data = useMemo(() => { - if (!directionResults.data || !directionsSuccessful) return null; + if (!results.data || !successful) return null; - const hasNoData = Object.keys(directionResults.data).length === 0; + const hasNoData = Object.keys(results.data).length === 0; if (hasNoData) return null; - const response = directionResults.data; - const showRoutes = directionResults.show || {}; + const response = results.data; + const showRoutes = results.show || {}; const features: Feature[] = []; if (response.alternates) { @@ -77,7 +94,7 @@ export function RouteLines() { type: 'FeatureCollection', features, } as FeatureCollection; - }, [directionResults, directionsSuccessful, activeRouteIndex]); + }, [results, successful, activeRouteIndex]); if (!data) return null; diff --git a/src/components/map/parts/trace-input-line.spec.tsx b/src/components/map/parts/trace-input-line.spec.tsx new file mode 100644 index 00000000..456667dc --- /dev/null +++ b/src/components/map/parts/trace-input-line.spec.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { TraceRouteInputLine } from './trace-input-line'; + +const mockSource = vi.fn(); +const mockLayer = vi.fn(); +const mockUseParams = vi.hoisted(() => + vi.fn(() => ({ activeTab: 'trace-route' })) +); +const mockUseTraceRouteStore = vi.fn(); + +vi.mock('@tanstack/react-router', () => ({ + useParams: mockUseParams, +})); + +vi.mock('react-map-gl/maplibre', () => ({ + Source: (props: Record) => { + mockSource(props); + return
{props.children as React.ReactNode}
; + }, + Layer: (props: Record) => { + mockLayer(props); + return
; + }, +})); + +vi.mock('@/stores/trace-route-store', () => ({ + useTraceRouteStore: (selector: (state: unknown) => unknown) => + mockUseTraceRouteStore(selector), +})); + +const setupStore = (inputGeometry: number[][] | null) => { + mockUseTraceRouteStore.mockImplementation((selector) => + selector({ inputGeometry }) + ); +}; + +describe('TraceRouteInputLine', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams.mockReturnValue({ activeTab: 'trace-route' }); + }); + + it('should render nothing when not on trace-route tab', () => { + mockUseParams.mockReturnValue({ activeTab: 'directions' }); + setupStore([ + [10, 20], + [11, 21], + ]); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when input geometry has less than 2 points', () => { + setupStore([[10, 20]]); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('should render source and layer for valid trace input geometry', () => { + setupStore([ + [10, 20], + [11, 21], + ]); + + render(); + + expect(mockSource).toHaveBeenCalledWith( + expect.objectContaining({ id: 'trace-input', type: 'geojson' }) + ); + expect(mockLayer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'trace-input-line', type: 'line' }) + ); + + const sourceCall = mockSource.mock.calls[0]?.[0]; + expect(sourceCall?.data.features[0].geometry.coordinates).toEqual([ + [20, 10], + [21, 11], + ]); + }); + + it('should filter invalid points and return null if fewer than 2 valid remain', () => { + setupStore([ + [10, 20], + [Number.NaN, 21], + ]); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/components/map/parts/trace-input-line.tsx b/src/components/map/parts/trace-input-line.tsx new file mode 100644 index 00000000..8254caec --- /dev/null +++ b/src/components/map/parts/trace-input-line.tsx @@ -0,0 +1,59 @@ +import { useMemo } from 'react'; +import { Source, Layer } from 'react-map-gl/maplibre'; +import { useParams } from '@tanstack/react-router'; +import { useTraceRouteStore } from '@/stores/trace-route-store'; +import type { FeatureCollection, LineString, Position } from 'geojson'; + +export function TraceRouteInputLine() { + const { activeTab } = useParams({ from: '/$activeTab' }); + const coords = useTraceRouteStore((s) => s.inputGeometry); + + const isTraceRoute = activeTab === 'trace-route'; + + const data = useMemo(() => { + if (!isTraceRoute || !coords || coords.length < 2) return null; + + const positions: Position[] = coords + .map((p) => { + const lat = p[0]; + const lng = p[1]; + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + return [lng, lat] as Position; + }) + .filter((p): p is Position => p !== null); + + if (positions.length < 2) return null; + + const geojson: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: positions, + }, + }, + ], + }; + + return geojson; + }, [isTraceRoute, coords]); + + if (!data) return null; + + return ( + + + + ); +} diff --git a/src/components/map/parts/trace-route-markers.spec.tsx b/src/components/map/parts/trace-route-markers.spec.tsx new file mode 100644 index 00000000..9bb4ab30 --- /dev/null +++ b/src/components/map/parts/trace-route-markers.spec.tsx @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TraceRouteMarkers } from './trace-route-markers'; + +const mockUseParams = vi.hoisted(() => + vi.fn(() => ({ activeTab: 'trace-route' })) +); +const mockUseTraceRouteStore = vi.fn(); +const mockMarker = vi.fn(); + +vi.mock('@tanstack/react-router', () => ({ + useParams: mockUseParams, +})); + +vi.mock('react-map-gl/maplibre', () => ({ + Marker: (props: Record) => { + mockMarker(props); + return
{props.children as React.ReactNode}
; + }, +})); + +vi.mock('@/stores/trace-route-store', () => ({ + useTraceRouteStore: (selector: (state: unknown) => unknown) => + mockUseTraceRouteStore(selector), +})); + +const setupStore = ({ + successful = true, + locations = [] as unknown[], +} = {}) => { + mockUseTraceRouteStore.mockImplementation((selector) => + selector({ + successful, + results: { + data: { + trip: { + locations, + }, + }, + }, + }) + ); +}; + +describe('TraceRouteMarkers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseParams.mockReturnValue({ activeTab: 'trace-route' }); + }); + + it('should render nothing outside trace-route tab', () => { + mockUseParams.mockReturnValue({ activeTab: 'directions' }); + setupStore({ + successful: true, + locations: [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.6, lon: 13.5 }, + ], + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when trace route is not successful', () => { + setupStore({ + successful: false, + locations: [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.6, lon: 13.5 }, + ], + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('should render start and end markers when successful data exists', () => { + setupStore({ + successful: true, + locations: [ + { lat: 52.5, lon: 13.4 }, + { lat: 52.6, lon: 13.5 }, + ], + }); + + render(); + + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + expect(mockMarker).toHaveBeenCalledTimes(2); + expect(mockMarker).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ longitude: 13.4, latitude: 52.5 }) + ); + expect(mockMarker).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ longitude: 13.5, latitude: 52.6 }) + ); + }); +}); diff --git a/src/components/map/parts/trace-route-markers.tsx b/src/components/map/parts/trace-route-markers.tsx new file mode 100644 index 00000000..27153189 --- /dev/null +++ b/src/components/map/parts/trace-route-markers.tsx @@ -0,0 +1,36 @@ +import { Marker } from 'react-map-gl/maplibre'; +import { useTraceRouteStore } from '@/stores/trace-route-store'; +import { useParams } from '@tanstack/react-router'; + +export function TraceRouteMarkers() { + const { activeTab } = useParams({ from: '/$activeTab' }); + + const results = useTraceRouteStore((s) => s.results); + const successful = useTraceRouteStore((s) => s.successful); + + if (activeTab !== 'trace-route') return null; + + const data = results.data; + if (!successful || !data?.trip?.locations?.length) return null; + + const start = data.trip.locations[0]; + const end = data.trip.locations[data.trip.locations.length - 1]; + + if (!start || !end) return null; + + return ( + <> + +
+ A +
+
+ + +
+ B +
+
+ + ); +} diff --git a/src/components/route-planner.spec.tsx b/src/components/route-planner.spec.tsx index 06a104a1..532065ba 100644 --- a/src/components/route-planner.spec.tsx +++ b/src/components/route-planner.spec.tsx @@ -7,6 +7,9 @@ const mockToggleDirections = vi.fn(); const mockRefetchDirections = vi.fn(); const mockRefetchIsochrones = vi.fn(); const mockNavigate = vi.fn(); +const mockClearRoutes = vi.fn(); +const mockClearWaypoints = vi.fn(); +const mockClearTraceRoute = vi.fn(); vi.mock('@tanstack/react-router', () => ({ useParams: vi.fn(() => ({ activeTab: 'directions' })), @@ -61,6 +64,29 @@ vi.mock('./tiles/tiles', () => ({ )), })); +vi.mock('./trace-route/trace-route', () => ({ + TraceRouteControl: vi.fn(() => ( +
Trace Route Control
+ )), +})); + +vi.mock('@/stores/directions-store', () => ({ + useDirectionsStore: { + getState: vi.fn(() => ({ + clearRoutes: mockClearRoutes, + clearWaypoints: mockClearWaypoints, + })), + }, +})); + +vi.mock('@/stores/trace-route-store', () => ({ + useTraceRouteStore: { + getState: vi.fn(() => ({ + clearTraceRoute: mockClearTraceRoute, + })), + }, +})); + vi.mock('./profile-picker', () => ({ ProfilePicker: vi.fn(({ onProfileChange }) => (
@@ -94,6 +120,7 @@ describe('RoutePlanner', () => { expect(screen.getByTestId('directions-tab-button')).toBeInTheDocument(); expect(screen.getByTestId('isochrones-tab-button')).toBeInTheDocument(); expect(screen.getByTestId('tiles-tab-button')).toBeInTheDocument(); + expect(screen.getByTestId('trace-route-tab-button')).toBeInTheDocument(); }); it('should render close button', () => { @@ -181,6 +208,40 @@ describe('RoutePlanner', () => { }); }); + it('should render TraceRouteControl when on trace-route tab', async () => { + const router = await import('@tanstack/react-router'); + vi.mocked(router.useParams).mockReturnValue({ activeTab: 'trace-route' }); + + render(); + + expect(screen.getByTestId('mock-trace-route-control')).toBeInTheDocument(); + }); + + it('should clear directions state when switching to trace-route tab', async () => { + const router = await import('@tanstack/react-router'); + vi.mocked(router.useParams).mockReturnValue({ activeTab: 'directions' }); + + const { rerender } = render(); + + vi.mocked(router.useParams).mockReturnValue({ activeTab: 'trace-route' }); + rerender(); + + expect(mockClearWaypoints).toHaveBeenCalled(); + expect(mockClearRoutes).toHaveBeenCalled(); + }); + + it('should clear trace-route state when switching back to directions tab', async () => { + const router = await import('@tanstack/react-router'); + vi.mocked(router.useParams).mockReturnValue({ activeTab: 'trace-route' }); + + const { rerender } = render(); + + vi.mocked(router.useParams).mockReturnValue({ activeTab: 'directions' }); + rerender(); + + expect(mockClearTraceRoute).toHaveBeenCalled(); + }); + describe('when on tiles tab', () => { beforeEach(async () => { const router = await import('@tanstack/react-router'); diff --git a/src/components/route-planner.tsx b/src/components/route-planner.tsx index a492fa44..ac863d2f 100644 --- a/src/components/route-planner.tsx +++ b/src/components/route-planner.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; import { DirectionsControl } from './directions/directions'; @@ -25,6 +25,9 @@ import { SettingsButton } from './settings-button'; import type { Profile } from '@/stores/common-store'; import { useDirectionsQuery } from '@/hooks/use-directions-queries'; import { useIsochronesQuery } from '@/hooks/use-isochrones-queries'; +import { TraceRouteControl } from './trace-route/trace-route'; +import { useDirectionsStore } from '@/stores/directions-store'; +import { useTraceRouteStore } from '@/stores/trace-route-store'; const TAB_CONFIG = { directions: { @@ -39,6 +42,10 @@ const TAB_CONFIG = { title: 'Tiles', description: 'View and manage map tiles', }, + 'trace-route': { + title: 'Trace Route', + description: 'Trace a route from a GPS trace', + }, } as const; export const RoutePlanner = () => { @@ -51,9 +58,26 @@ export const RoutePlanner = () => { const { refetch: refetchIsochrones } = useIsochronesQuery(); const loading = useCommonStore((state) => state.loading); const toggleDirections = useCommonStore((state) => state.toggleDirections); + const prevTab = useRef(null); const tabConfig = TAB_CONFIG[activeTab as keyof typeof TAB_CONFIG]; + useEffect(() => { + const prev = prevTab.current; + prevTab.current = activeTab; + + if (!prev || prev === activeTab) return; + if (activeTab === 'trace-route') { + const { clearRoutes, clearWaypoints } = useDirectionsStore.getState(); + clearWaypoints(); + clearRoutes(); + } + if (activeTab === 'directions' && prev === 'trace-route') { + const { clearTraceRoute } = useTraceRouteStore.getState(); + clearTraceRoute(); + } + }, [activeTab]); + const { data: lastUpdate, isLoading: isLoadingLastUpdate, @@ -120,6 +144,12 @@ export const RoutePlanner = () => { Tiles + + Trace Route +
}> + + + {activeTab !== 'tiles' && (
diff --git a/src/components/trace-route/trace-route.spec.tsx b/src/components/trace-route/trace-route.spec.tsx new file mode 100644 index 00000000..545d4a2a --- /dev/null +++ b/src/components/trace-route/trace-route.spec.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TraceRouteControl } from './trace-route'; + +const mockTraceRoute = vi.fn(); +const mockShowLoading = vi.fn(); +const mockZoomTo = vi.fn(); +const mockReceiveTraceRouteResults = vi.fn(); +const mockClearTraceRoute = vi.fn(); +const mockSetInputGeometry = vi.fn(); +const mockDecode = vi.fn(() => [ + [52.5, 13.4], + [52.6, 13.5], +]); + +vi.mock('@/hooks/use-trace-route-query', () => ({ + useTraceRouteQuery: vi.fn(() => ({ traceRoute: mockTraceRoute })), +})); + +vi.mock('@/stores/common-store', () => ({ + useCommonStore: vi.fn((selector) => + selector({ + showLoading: mockShowLoading, + zoomTo: mockZoomTo, + }) + ), +})); + +vi.mock('@/stores/trace-route-store', () => ({ + useTraceRouteStore: vi.fn((selector) => + selector({ + receiveTraceRouteResults: mockReceiveTraceRouteResults, + clearTraceRoute: mockClearTraceRoute, + setInputGeometry: mockSetInputGeometry, + }) + ), +})); + +vi.mock('@/utils/polyline', () => ({ + decode: () => mockDecode(), +})); + +vi.mock('@/utils/parse-gpx', () => ({ + parseGpxToLatLng: vi.fn(() => []), +})); + +describe('TraceRouteControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render controls and keep Trace Route button disabled initially', () => { + render(); + + expect( + screen.getByPlaceholderText('Enter encoded polyline') + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Trace Route' })).toBeDisabled(); + }); + + it('should decode polyline and set input geometry on textarea change', async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByPlaceholderText('Enter encoded polyline'), + 'abc' + ); + + expect(mockDecode).toHaveBeenCalled(); + expect(mockSetInputGeometry).toHaveBeenCalledWith([ + [52.5, 13.4], + [52.6, 13.5], + ]); + expect(screen.getByRole('button', { name: 'Trace Route' })).toBeEnabled(); + }); + + it('should trace route and store results on successful request', async () => { + const user = userEvent.setup(); + + const response = { + decodedGeometry: [ + [52.5, 13.4], + [52.6, 13.5], + ], + }; + mockTraceRoute.mockResolvedValue(response); + + render(); + + await user.type( + screen.getByPlaceholderText('Enter encoded polyline'), + 'abc' + ); + await user.click(screen.getByRole('button', { name: 'Trace Route' })); + + await waitFor(() => { + expect(mockTraceRoute).toHaveBeenCalled(); + expect(mockReceiveTraceRouteResults).toHaveBeenCalledWith({ + data: response, + }); + expect(mockZoomTo).toHaveBeenCalledWith(response.decodedGeometry); + expect(mockShowLoading).toHaveBeenCalledWith(true); + }); + + await waitFor(() => { + expect(mockShowLoading).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/src/components/trace-route/trace-route.tsx b/src/components/trace-route/trace-route.tsx new file mode 100644 index 00000000..888a7807 --- /dev/null +++ b/src/components/trace-route/trace-route.tsx @@ -0,0 +1,202 @@ +import { useTraceRouteQuery } from '@/hooks/use-trace-route-query'; +import { useCommonStore } from '@/stores/common-store'; +import { useTraceRouteStore } from '@/stores/trace-route-store'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { AccessibleIcon } from '@radix-ui/react-accessible-icon'; +import { ChevronDown, Settings } from 'lucide-react'; +import { decode } from '@/utils/polyline'; +import { parseGpxToLatLng } from '@/utils/parse-gpx'; + +export const TraceRouteControl = () => { + const [encodedPolyline, setEncodedPolyline] = useState(''); + const [fileText, setFileText] = useState(''); + const [file, setFile] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [shapeMatch, setShapeMatch] = useState< + 'walk_or_snap' | 'map_snap' | 'edge_walk' + >('map_snap'); + const [gpsAccuracy, setGpsAccuracy] = useState(5); + const [searchRadius, setSearchRadius] = useState(50); + + const showLoading = useCommonStore((state) => state.showLoading); + const zoomTo = useCommonStore((state) => state.zoomTo); + + const receiveTraceRouteResults = useTraceRouteStore( + (state) => state.receiveTraceRouteResults + ); + const clearTraceRoute = useTraceRouteStore((state) => state.clearTraceRoute); + + const { traceRoute } = useTraceRouteQuery({ + polyline: encodedPolyline || undefined, + fileText: fileText || undefined, + }); + const setInputGeometry = useTraceRouteStore( + (state) => state.setInputGeometry + ); + + useEffect(() => { + const t = window.setTimeout(() => { + const value = encodedPolyline.trim(); + if (!value) { + setInputGeometry(null); + return; + } + + const coords = decode(value, 6) as [number, number][]; + setInputGeometry(coords.length >= 2 ? coords : null); + }, 250); + + return () => window.clearTimeout(t); + }, [encodedPolyline, setInputGeometry]); + + const onPolylineChange = (value: string) => { + setEncodedPolyline(value); + + const decoded = decode(value); + setInputGeometry(decoded); + }; + + const onFileChange = async (e: React.ChangeEvent) => { + setFile(e.target.files?.[0] || null); + const file = e.target.files?.[0]; + if (!file) { + setInputGeometry(null); + return; + } + + const text = await file.text(); + setFileText(text); + const coords = parseGpxToLatLng(text); + + setInputGeometry(coords.length >= 2 ? coords : null); + }; + + const handleTraceRoute = async () => { + try { + setIsProcessing(true); + showLoading(true); + const data = await traceRoute(); + if (data) { + receiveTraceRouteResults({ data }); + zoomTo(data.decodedGeometry); + } + return data; + } catch (error) { + clearTraceRoute(); + if (axios.isAxiosError(error) && error.response) { + const response = error.response; + let error_msg = response.data.error; + if (response.data.error_code === 154) { + error_msg += ` for route.`; + } + + toast.warning(`${response.data.status}`, { + description: `${error_msg}`, + position: 'bottom-center', + duration: 5000, + closeButton: true, + }); + } else { + toast.warning('Trace route failed', { + description: error instanceof Error ? error.message : 'Unknown error', + position: 'bottom-center', + duration: 5000, + closeButton: true, + }); + } + throw error; + } finally { + setIsProcessing(false); + setTimeout(() => showLoading(false), 500); + } + }; + + return ( +
+