) => {
+ mockMarker(props);
+ return {props.children as React.ReactNode}
;
+ },
+}));
+
+vi.mock('@/stores/trace-route-store', () => ({
+ useTraceRouteStore: (selector: (state: unknown) => unknown) =>
+ mockUseTraceRouteStore(selector),
+}));
+
+const setupStore = ({
+ inputShape = [] as Array<{ lat: number; lon: number; type?: string }>,
+} = {}) => {
+ mockUseTraceRouteStore.mockImplementation((selector) =>
+ selector({
+ inputShape,
+ })
+ );
+};
+
+describe('TraceRouteMarkers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseParams.mockReturnValue({ activeTab: 'trace-route' });
+ });
+
+ it('should render nothing outside trace-route tab', () => {
+ mockUseParams.mockReturnValue({ activeTab: 'directions' });
+ setupStore({
+ inputShape: [
+ { lat: 52.5, lon: 13.4, type: 'break' },
+ { lat: 52.6, lon: 13.5, type: 'break' },
+ ],
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render nothing when there are no break points', () => {
+ setupStore({
+ inputShape: [
+ { lat: 52.5, lon: 13.4, type: 'via' },
+ { lat: 52.6, lon: 13.5, type: 'through' },
+ ],
+ });
+
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render numbered markers for break points', () => {
+ setupStore({
+ inputShape: [
+ { lat: 52.5, lon: 13.4, type: 'break' },
+ { lat: 52.55, lon: 13.45, type: 'via' },
+ { lat: 52.6, lon: 13.5, type: 'break' },
+ ],
+ });
+
+ render();
+
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('2')).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 0000000..3bc9790
--- /dev/null
+++ b/src/components/map/parts/trace-route-markers.tsx
@@ -0,0 +1,31 @@
+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 inputShape = useTraceRouteStore((s) => s.inputShape);
+
+ if (activeTab !== 'trace-route') return null;
+
+ const breakPoints = inputShape?.filter((p) => p.type === 'break') ?? [];
+ if (breakPoints.length === 0) return null;
+
+ return (
+ <>
+ {breakPoints.map((p, idx) => (
+
+
+ {idx + 1}
+
+
+ ))}
+ >
+ );
+}
diff --git a/src/components/route-planner.spec.tsx b/src/components/route-planner.spec.tsx
index 06a104a..532065b 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 a492fa4..ac863d2 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/maneuvers.tsx b/src/components/trace-route/maneuvers.tsx
new file mode 100644
index 0000000..8ae43a2
--- /dev/null
+++ b/src/components/trace-route/maneuvers.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+
+import type { Leg } from '@/components/types';
+import { Clock, MoveHorizontal, DollarSign, Ship } from 'lucide-react';
+import { MetricItem } from '@/components/ui/metric-item';
+import { RouteAttributes } from '@/components/ui/route-attributes';
+import { formatDuration } from '@/utils/date-time';
+import { getManeuverIcon } from '@/utils/get-maneuver-icon';
+
+const getLength = (length: number) => {
+ const visibleLength = length * 1000;
+ if (visibleLength < 1000) {
+ return visibleLength + 'm';
+ }
+ return (visibleLength / 1000).toFixed(2) + 'km';
+};
+
+interface ManeuversProps {
+ legs: Leg[];
+}
+
+export const Maneuvers = ({ legs }: ManeuversProps) => {
+ return (
+
+ {legs?.map((leg) =>
+ leg.maneuvers.map((mnv, j) => (
+
+
+
+ {React.createElement(getManeuverIcon(mnv.type), {
+ size: 20,
+ className: 'mt-1 shrink-0 text-muted-foreground',
+ })}
+
+
{mnv.instruction}
+ {mnv.type !== 4 && mnv.type !== 5 && mnv.type !== 6 && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ ))
+ )}
+
+ );
+};
diff --git a/src/components/trace-route/route-card.tsx b/src/components/trace-route/route-card.tsx
new file mode 100644
index 0000000..42fc97a
--- /dev/null
+++ b/src/components/trace-route/route-card.tsx
@@ -0,0 +1,125 @@
+import { useCallback, useState } from 'react';
+
+import { downloadFile } from '@/utils/download-file';
+import { Summary } from './summary';
+import { Maneuvers } from './maneuvers';
+import { Button } from '@/components/ui/button';
+import type { ParsedDirectionsGeometry } from '@/components/types';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Download } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Separator } from '@/components/ui/separator';
+import { exportDataAsJson } from '@/utils/export';
+import { getDateTimeString } from '@/utils/date-time';
+
+interface RouteCardProps {
+ data: ParsedDirectionsGeometry;
+ index: number;
+ isActive: boolean;
+ onSelect: () => void;
+}
+
+export const RouteCard = ({
+ data,
+ index,
+ isActive,
+ onSelect,
+}: RouteCardProps) => {
+ const [showManeuvers, setShowManeuvers] = useState(false);
+
+ const exportToGeoJson = useCallback(() => {
+ const coordinates = data?.decodedGeometry;
+ if (!coordinates) return;
+
+ const geoJsonCoordinates = coordinates.map(([lat, lng]) => [lng, lat]);
+
+ const geoJson = {
+ type: 'Feature',
+ geometry: {
+ type: 'LineString',
+ coordinates: geoJsonCoordinates,
+ },
+ properties: {},
+ };
+
+ const formattedData = JSON.stringify(geoJson, null, 2);
+ downloadFile({
+ data: formattedData,
+ fileName: 'valhalla-directions_' + getDateTimeString() + '.geojson',
+ fileType: 'text/json',
+ });
+ }, [data]);
+
+ if (!data.trip) {
+ return null;
+ }
+
+ return (
+ <>
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onSelect();
+ }
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ exportDataAsJson(data, 'valhalla-directions')}
+ >
+ JSON
+
+
+ GeoJSON
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/trace-route/summary.spec.tsx b/src/components/trace-route/summary.spec.tsx
new file mode 100644
index 0000000..12cf56a
--- /dev/null
+++ b/src/components/trace-route/summary.spec.tsx
@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Summary } from './summary';
+import type { Summary as SummaryType } from '@/components/types';
+
+const mockFitBounds = vi.fn();
+const mockToggleShowOnMap = vi.fn();
+
+const mockTraceRouteStore = {
+ results: { show: { 0: true } },
+ toggleShowOnMap: mockToggleShowOnMap,
+ successful: true,
+};
+
+vi.mock('@/stores/trace-route-store', () => ({
+ useTraceRouteStore: vi.fn(
+ (selector: (state: typeof mockTraceRouteStore) => unknown) =>
+ selector(mockTraceRouteStore)
+ ),
+}));
+
+vi.mock('@/stores/common-store', () => ({
+ useCommonStore: vi.fn((selector) =>
+ selector({
+ settingsPanelOpen: false,
+ })
+ ),
+}));
+
+vi.mock('react-map-gl/maplibre', () => ({
+ useMap: vi.fn(() => ({
+ mainMap: {
+ fitBounds: mockFitBounds,
+ },
+ })),
+}));
+
+const createMockSummary = (
+ overrides: Partial
= {}
+): SummaryType => ({
+ has_time_restrictions: false,
+ has_toll: false,
+ has_highway: false,
+ has_ferry: false,
+ min_lat: 0,
+ min_lon: 0,
+ max_lat: 1,
+ max_lon: 2,
+ time: 120,
+ length: 1.2,
+ cost: 1,
+ ...overrides,
+});
+
+describe('TraceRoute Summary', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should recenter correctly when coordinates include zero values', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /zoom to route/i }));
+
+ expect(mockFitBounds).toHaveBeenCalledWith(
+ [
+ [0, 0],
+ [2, 1],
+ ],
+ expect.objectContaining({
+ padding: expect.objectContaining({
+ left: 420,
+ right: 50,
+ top: 50,
+ bottom: 50,
+ }),
+ })
+ );
+ });
+
+ it('should ignore invalid coordinates and still fit to valid route points', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole('button', { name: /zoom to route/i }));
+
+ expect(mockFitBounds).toHaveBeenCalledWith(
+ [
+ [3, 0],
+ [6, 5],
+ ],
+ expect.any(Object)
+ );
+ });
+});
diff --git a/src/components/trace-route/summary.tsx b/src/components/trace-route/summary.tsx
new file mode 100644
index 0000000..4d4026b
--- /dev/null
+++ b/src/components/trace-route/summary.tsx
@@ -0,0 +1,165 @@
+import React from 'react';
+
+import { formatDuration } from '@/utils/date-time';
+import type { Summary as SummaryType } from '@/components/types';
+import {
+ Clock,
+ Search,
+ DollarSign,
+ Milestone,
+ MoveHorizontal,
+ Ship,
+} from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { Button } from '@/components/ui/button';
+import { MetricItem } from '@/components/ui/metric-item';
+import { RouteAttributes } from '@/components/ui/route-attributes';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { useTraceRouteStore } from '@/stores/trace-route-store';
+import { useCommonStore } from '@/stores/common-store';
+import { useMap } from 'react-map-gl/maplibre';
+
+export const Summary = ({
+ summary,
+ title,
+ index,
+ routeCoordinates,
+}: {
+ summary: SummaryType;
+ title: string;
+ index: number;
+ routeCoordinates: number[][];
+}) => {
+ const results = useTraceRouteStore((state) => state.results);
+ const toggleShowOnMap = useTraceRouteStore((state) => state.toggleShowOnMap);
+ const successful = useTraceRouteStore((state) => state.successful);
+ const isSettingsPanelOpen = useCommonStore(
+ (state) => state.settingsPanelOpen
+ );
+
+ const { mainMap } = useMap();
+
+ const isFiniteLatLon = (
+ coord: number[] | undefined
+ ): coord is [number, number] =>
+ Array.isArray(coord) &&
+ coord.length >= 2 &&
+ Number.isFinite(coord[0]) &&
+ Number.isFinite(coord[1]);
+
+ const handleChange = (checked: boolean) => {
+ toggleShowOnMap({ show: checked, idx: index });
+ };
+
+ const handleRecenter = () => {
+ if (!mainMap || routeCoordinates.length === 0) return;
+
+ const validCoords = routeCoordinates.filter(isFiniteLatLon);
+
+ const firstCoord = validCoords[0];
+ if (!firstCoord) return;
+
+ const bounds: [[number, number], [number, number]] = validCoords.reduce<
+ [[number, number], [number, number]]
+ >(
+ (acc, coord) => {
+ return [
+ [Math.min(acc[0][0], coord[1]), Math.min(acc[0][1], coord[0])],
+ [Math.max(acc[1][0], coord[1]), Math.max(acc[1][1], coord[0])],
+ ];
+ },
+ [
+ [firstCoord[1], firstCoord[0]],
+ [firstCoord[1], firstCoord[0]],
+ ]
+ );
+
+ mainMap.fitBounds(bounds, {
+ padding: {
+ top: 50,
+ bottom: 50,
+ left: 420,
+ right: isSettingsPanelOpen ? 420 : 50,
+ },
+ maxZoom: routeCoordinates.length === 1 ? 11 : 18,
+ duration: 800,
+ });
+ };
+
+ if (!summary) {
+ return No route found
;
+ }
+
+ const routeAttributes = [
+ {
+ flag: summary.has_highway,
+ label: 'Route includes highway',
+ icon: Milestone,
+ },
+ {
+ flag: summary.has_ferry,
+ label: 'Route includes ferry',
+ icon: Ship,
+ },
+ {
+ flag: summary.has_toll,
+ label: 'Route includes toll',
+ icon: DollarSign,
+ },
+ ].filter(({ flag }) => Boolean(flag));
+
+ const metrics = [
+ {
+ icon: MoveHorizontal,
+ label: 'Route length',
+ value: `${summary.length.toFixed(summary.length > 1000 ? 0 : 1)} km`,
+ },
+ {
+ icon: Clock,
+ label: 'Route duration',
+ value: formatDuration(summary.time),
+ },
+ ];
+
+ return (
+
+
+ {title}
+
+
+
+
+ {metrics.map((metric) => (
+
+ ))}
+
+
+
+ {successful && (
+
+
+
+
+ Zoom to route
+
+ )}
+
+
+
+ );
+};
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 0000000..723aee9
--- /dev/null
+++ b/src/components/trace-route/trace-route.spec.tsx
@@ -0,0 +1,289 @@
+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';
+import { useTraceRouteQuery } from '@/hooks/use-trace-route-query';
+import type { ParsedDirectionsGeometry } from '@/components/types';
+import { parseGpxToLatLng } from '@/utils/parse-gpx';
+
+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 mockSetInputShape = vi.fn();
+const mockSetActiveRouteIndex = vi.fn();
+const mockToastWarning = vi.fn();
+const mockDecode = vi.fn(() => [
+ [52.5, 13.4],
+ [52.6, 13.5],
+]);
+
+interface MockTraceRouteStoreState {
+ receiveTraceRouteResults: typeof mockReceiveTraceRouteResults;
+ clearTraceRoute: typeof mockClearTraceRoute;
+ setInputGeometry: typeof mockSetInputGeometry;
+ setInputShape: typeof mockSetInputShape;
+ results: {
+ data: ParsedDirectionsGeometry | null;
+ show: Record;
+ };
+ successful: boolean;
+ activeRouteIndex: number;
+ setActiveRouteIndex: typeof mockSetActiveRouteIndex;
+}
+
+const mockTraceRouteStoreState: MockTraceRouteStoreState = {
+ receiveTraceRouteResults: mockReceiveTraceRouteResults,
+ clearTraceRoute: mockClearTraceRoute,
+ setInputGeometry: mockSetInputGeometry,
+ setInputShape: mockSetInputShape,
+ results: { data: null, show: { 0: true } },
+ successful: false,
+ activeRouteIndex: 0,
+ setActiveRouteIndex: mockSetActiveRouteIndex,
+};
+
+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(mockTraceRouteStoreState)),
+}));
+
+vi.mock('@/utils/polyline', () => ({
+ decode: () => mockDecode(),
+}));
+
+vi.mock('@/utils/parse-gpx', () => ({
+ parseGpxToLatLng: vi.fn(() => []),
+}));
+
+vi.mock('sonner', () => ({
+ toast: {
+ warning: (...args: unknown[]) => mockToastWarning(...args),
+ },
+}));
+
+vi.mock('@tanstack/react-router', () => ({
+ useSearch: vi.fn(() => ({ profile: 'auto' })),
+}));
+
+vi.mock('react-map-gl/maplibre', () => ({
+ useMap: vi.fn(() => ({ mainMap: null })),
+}));
+
+describe('TraceRouteControl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockTraceRouteStoreState.results = { data: null, show: { 0: true } };
+ mockTraceRouteStoreState.successful = false;
+ mockTraceRouteStoreState.activeRouteIndex = 0;
+ });
+
+ 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();
+ expect(screen.getByText(/Calculations by/i)).toBeInTheDocument();
+ });
+
+ 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);
+ });
+ });
+
+ it('should pass all advanced trace options to trace query hook', () => {
+ render();
+
+ expect(vi.mocked(useTraceRouteQuery)).toHaveBeenCalledWith(
+ expect.objectContaining({
+ trace_options: {
+ accuracy: 5,
+ radius: 50,
+ breakage_distance: 50,
+ interpolation_distance: 10,
+ },
+ })
+ );
+ });
+
+ it('should clear route when polyline input is cleared', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const input = screen.getByPlaceholderText('Enter encoded polyline');
+ await user.type(input, 'abc');
+ await user.clear(input);
+
+ expect(mockClearTraceRoute).toHaveBeenCalled();
+ });
+
+ it('should keep Trace Route disabled while GPX file is still being read', async () => {
+ const user = userEvent.setup();
+
+ vi.mocked(parseGpxToLatLng).mockReturnValue([
+ [52.5, 13.4],
+ [52.6, 13.5],
+ ]);
+
+ const deferredFileText: { resolve?: (value: string) => void } = {};
+ const file = new File([''], 'route.gpx', {
+ type: 'application/gpx+xml',
+ });
+ Object.defineProperty(file, 'text', {
+ value: () =>
+ new Promise((resolve) => {
+ deferredFileText.resolve = resolve;
+ }),
+ });
+
+ render();
+
+ const traceButton = screen.getByRole('button', { name: 'Trace Route' });
+ const fileInput = screen.getByLabelText('Upload GPX file');
+
+ await user.upload(fileInput, file);
+ expect(traceButton).toBeDisabled();
+
+ if (!deferredFileText.resolve) {
+ throw new Error('Expected file text resolver to be initialized');
+ }
+
+ deferredFileText.resolve(
+ ''
+ );
+
+ await waitFor(() => {
+ expect(traceButton).toBeEnabled();
+ });
+ });
+
+ it('should reject oversized GPX files and show warning toast', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const oversizedFile = new File(
+ ['x'.repeat(2 * 1024 * 1024 + 1)],
+ 'too-big.gpx',
+ {
+ type: 'application/gpx+xml',
+ }
+ );
+
+ await user.upload(screen.getByLabelText('Upload GPX file'), oversizedFile);
+
+ expect(mockToastWarning).toHaveBeenCalledWith(
+ 'File too large',
+ expect.objectContaining({
+ description: expect.stringContaining('Max GPX size is 2 MB'),
+ })
+ );
+ expect(mockClearTraceRoute).toHaveBeenCalled();
+ expect(screen.getByRole('button', { name: 'Trace Route' })).toBeDisabled();
+ });
+
+ it('should keep maneuvers hidden by default and show them on click', async () => {
+ const user = userEvent.setup();
+
+ mockTraceRouteStoreState.successful = true;
+ mockTraceRouteStoreState.results = {
+ data: {
+ decodedGeometry: [
+ [52.5, 13.4],
+ [52.6, 13.5],
+ ],
+ trip: {
+ summary: {
+ has_highway: false,
+ has_ferry: false,
+ has_toll: false,
+ length: 12.3,
+ time: 900,
+ },
+ legs: [
+ {
+ maneuvers: [
+ {
+ type: 1,
+ instruction: 'Head north on Main St',
+ length: 0.4,
+ time: 60,
+ },
+ ],
+ },
+ ],
+ },
+ alternates: [],
+ } as unknown as ParsedDirectionsGeometry,
+ show: { 0: true },
+ };
+
+ render();
+
+ expect(screen.queryByText('Head north on Main St')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Show Maneuvers' }));
+
+ expect(screen.getByText('Head north on Main St')).toBeInTheDocument();
+ });
+});
diff --git a/src/components/trace-route/trace-route.tsx b/src/components/trace-route/trace-route.tsx
new file mode 100644
index 0000000..da1512a
--- /dev/null
+++ b/src/components/trace-route/trace-route.tsx
@@ -0,0 +1,433 @@
+import { useTraceRouteQuery } from '@/hooks/use-trace-route-query';
+import { useCommonStore, type Profile } from '@/stores/common-store';
+import { useTraceRouteStore } from '@/stores/trace-route-store';
+import { useEffect, useRef, useState } from 'react';
+import { toast } from 'sonner';
+
+import { Button } from '@/components/ui/button';
+import { SettingsFooter } from '@/components/settings-footer';
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible';
+import { Separator } from '@/components/ui/separator';
+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';
+import { useSearch } from '@tanstack/react-router';
+import type { ParsedDirectionsGeometry } from '@/components/types';
+import { Summary } from './summary';
+import { Maneuvers } from './maneuvers';
+
+type TraceRouteErrorPayload = {
+ status?: string;
+ error?: string;
+ error_code?: number;
+};
+
+export const TraceRouteControl = () => {
+ const [encodedPolyline, setEncodedPolyline] = useState('');
+ const [fileText, setFileText] = useState('');
+ const [isProcessing, setIsProcessing] = useState(false);
+ const { profile } = useSearch({ from: '/$activeTab' }) as {
+ profile: Profile;
+ };
+ const [shapeMatch, setShapeMatch] = useState<
+ 'walk_or_snap' | 'map_snap' | 'edge_walk'
+ >('map_snap');
+ const [accuracy, setAccuracy] = useState(5);
+ const [radius, setRadius] = useState(50);
+ const [breakageDistance, setBreakageDistance] = useState(50);
+ const [interpolationDistance, setInterpolationDistance] =
+ useState(10);
+ const [showManeuvers, setShowManeuvers] = useState(false);
+ const isMountedRef = useRef(true);
+ const loadingTimeoutRef = useRef(null);
+ const fileReadSeqRef = useRef(0);
+ const showLoading = useCommonStore((state) => state.showLoading);
+ const zoomTo = useCommonStore((state) => state.zoomTo);
+ const [fileName, setFileName] = useState('');
+
+ const receiveTraceRouteResults = useTraceRouteStore(
+ (state) => state.receiveTraceRouteResults
+ );
+ const clearTraceRoute = useTraceRouteStore((state) => state.clearTraceRoute);
+ const traceRouteResults = useTraceRouteStore((state) => state.results);
+ const traceRouteSuccessful = useTraceRouteStore((state) => state.successful);
+ const activeRouteIndex = useTraceRouteStore(
+ (state) => state.activeRouteIndex
+ );
+ const setActiveRouteIndex = useTraceRouteStore(
+ (state) => state.setActiveRouteIndex
+ );
+
+ const decodePolylineSafely = (value: string) => {
+ try {
+ const decoded = decode(value, 6) as [number, number][];
+ return decoded.filter(
+ (coord) => Number.isFinite(coord[0]) && Number.isFinite(coord[1])
+ );
+ } catch {
+ return [] as [number, number][];
+ }
+ };
+
+ const shapeBuilder = (coords: [number, number][]) => {
+ const shape = coords.map(([lat, lon]) => ({ lat, lon })) as {
+ lat: number;
+ lon: number;
+ type?: 'break' | 'via' | 'through';
+ }[];
+
+ if (shape.length >= 2) {
+ shape[0]!.type = 'break';
+ shape[shape.length - 1]!.type = 'break';
+ }
+ return shape;
+ };
+
+ const MAX_GPX_BYTES = 2 * 1024 * 1024; // 2 MB
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: encodedPolyline || undefined,
+ fileText: fileText || undefined,
+ costing: profile,
+ shape_match: shapeMatch,
+ trace_options: {
+ accuracy,
+ radius,
+ breakage_distance: breakageDistance,
+ interpolation_distance: interpolationDistance,
+ },
+ });
+ const setInputGeometry = useTraceRouteStore(
+ (state) => state.setInputGeometry
+ );
+ const setInputShape = useTraceRouteStore((state) => state.setInputShape);
+
+ const routeOptions = traceRouteResults.data
+ ? [
+ {
+ index: 0,
+ title: 'Main Route',
+ data: traceRouteResults.data as ParsedDirectionsGeometry,
+ },
+ ...((traceRouteResults.data.alternates ?? []).map((alternate, i) => ({
+ index: i + 1,
+ title: `Alternate Route #${i + 1}`,
+ data: alternate as ParsedDirectionsGeometry,
+ })) ?? []),
+ ]
+ : [];
+
+ const selectedRoute =
+ routeOptions.find((route) => route.index === activeRouteIndex) ??
+ routeOptions[0];
+ const hasTraceInput =
+ encodedPolyline.trim().length > 0 || fileText.trim().length > 0;
+
+ useEffect(() => {
+ setShowManeuvers(false);
+ }, [activeRouteIndex, traceRouteResults.data]);
+
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ if (loadingTimeoutRef.current !== null) {
+ window.clearTimeout(loadingTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ const onPolylineChange = (value: string) => {
+ setEncodedPolyline(value);
+ const v = value.trim();
+ if (!v) {
+ setInputGeometry(null);
+ setInputShape(null);
+ clearTraceRoute();
+
+ return;
+ }
+ const coords = decodePolylineSafely(v);
+
+ if (coords.length === 0) {
+ setInputGeometry(null);
+ setInputShape(null);
+ clearTraceRoute();
+ return;
+ }
+
+ zoomTo(coords);
+ setInputGeometry(coords.length >= 2 ? coords : null);
+
+ if (coords.length >= 2) {
+ const shape = shapeBuilder(coords);
+ setInputShape(shape);
+ }
+ };
+
+ const onFileChange = async (e: React.ChangeEvent) => {
+ const readSeq = ++fileReadSeqRef.current;
+ const file = e.target.files?.[0];
+ if (!file) {
+ setFileName('');
+ setFileText('');
+ setInputGeometry(null);
+ setInputShape(null);
+ clearTraceRoute();
+ return;
+ }
+
+ if (file.size > MAX_GPX_BYTES) {
+ e.target.value = '';
+
+ setFileName('');
+ setFileText('');
+ setInputGeometry(null);
+ setInputShape(null);
+ clearTraceRoute();
+
+ toast.warning('File too large', {
+ description: `Max GPX size is ${(MAX_GPX_BYTES / (1024 * 1024)).toFixed(0)} MB.`,
+ position: 'bottom-center',
+ duration: 5000,
+ closeButton: true,
+ });
+ return;
+ }
+
+ setFileName(file.name);
+
+ const text = await file.text();
+ if (!isMountedRef.current || readSeq !== fileReadSeqRef.current) {
+ return;
+ }
+ setFileText(text);
+ const coords = parseGpxToLatLng(text);
+ zoomTo(coords);
+ setInputGeometry(coords.length >= 2 ? coords : null);
+ const shape = shapeBuilder(coords);
+ setInputShape(shape.length >= 2 ? shape : null);
+ };
+
+ const handleTraceRoute = async () => {
+ try {
+ setIsProcessing(true);
+ showLoading(true);
+ const data = await traceRoute();
+ if (isMountedRef.current && data) {
+ receiveTraceRouteResults({ data });
+ zoomTo(data.decodedGeometry);
+ }
+ return data;
+ } catch (error) {
+ if (!isMountedRef.current) {
+ return;
+ }
+ clearTraceRoute();
+ const payload =
+ typeof error === 'object' && error !== null && 'payload' in error
+ ? ((error as { payload?: TraceRouteErrorPayload }).payload ?? {})
+ : null;
+
+ if (payload) {
+ const statusText = payload.status ?? 'Trace route failed';
+ let errorMsg =
+ payload.error ??
+ (error instanceof Error ? error.message : 'Unknown error');
+ if (payload.error_code === 154 && !errorMsg.endsWith('for route.')) {
+ errorMsg += ' for route.';
+ }
+
+ toast.warning(statusText, {
+ description: errorMsg,
+ 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,
+ });
+ }
+ } finally {
+ if (!isMountedRef.current) {
+ return;
+ }
+ setIsProcessing(false);
+ if (loadingTimeoutRef.current !== null) {
+ window.clearTimeout(loadingTimeoutRef.current);
+ }
+ loadingTimeoutRef.current = window.setTimeout(() => {
+ if (isMountedRef.current) {
+ showLoading(false);
+ }
+ }, 500);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/src/hooks/use-trace-route-query.spec.ts b/src/hooks/use-trace-route-query.spec.ts
new file mode 100644
index 0000000..6bb632b
--- /dev/null
+++ b/src/hooks/use-trace-route-query.spec.ts
@@ -0,0 +1,290 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useTraceRouteQuery } from './use-trace-route-query';
+import { parseGpxToLatLng } from '@/utils/parse-gpx';
+import {
+ getValhallaUrl,
+ parseDirectionsGeometry,
+ showValhallaWarnings,
+} from '@/utils/valhalla';
+
+vi.mock('@/utils/parse-gpx', () => ({
+ parseGpxToLatLng: vi.fn(),
+}));
+
+vi.mock('@/utils/valhalla', () => ({
+ getValhallaUrl: vi.fn(() => 'http://mock-valhalla'),
+ parseDirectionsGeometry: vi.fn(() => [
+ [50, 10],
+ [51, 11],
+ ]),
+ showValhallaWarnings: vi.fn(),
+}));
+
+const createRouteResponse = () => ({
+ id: 'valhalla_directions' as const,
+ trip: {
+ locations: [
+ {
+ type: 'break',
+ lat: 52.5,
+ lon: 13.4,
+ side_of_street: 'none',
+ original_index: 0,
+ },
+ {
+ type: 'break',
+ lat: 52.6,
+ lon: 13.5,
+ side_of_street: 'none',
+ original_index: 1,
+ },
+ ],
+ legs: [{ shape: 'encoded-shape' }],
+ summary: {
+ has_time_restrictions: false,
+ has_toll: false,
+ has_highway: false,
+ has_ferry: false,
+ min_lat: 0,
+ min_lon: 0,
+ max_lat: 1,
+ max_lon: 1,
+ time: 1,
+ length: 1,
+ cost: 1,
+ },
+ status_message: 'ok',
+ status: 0,
+ units: 'kilometers',
+ language: 'en-US',
+ warnings: [{ code: 101, text: 'Heads up' }],
+ },
+ alternates: [
+ {
+ id: 'valhalla_directions' as const,
+ trip: {
+ locations: [],
+ legs: [{ shape: 'alternate-shape' }],
+ summary: {
+ has_time_restrictions: false,
+ has_toll: false,
+ has_highway: false,
+ has_ferry: false,
+ min_lat: 0,
+ min_lon: 0,
+ max_lat: 1,
+ max_lon: 1,
+ time: 1,
+ length: 1,
+ cost: 1,
+ },
+ status_message: 'ok',
+ status: 0,
+ units: 'kilometers',
+ language: 'en-US',
+ },
+ },
+ ],
+});
+
+const getLastFetchBody = () => {
+ const call = vi.mocked(fetch).mock.calls.at(-1);
+ const init = call?.[1] as RequestInit | undefined;
+ return JSON.parse((init?.body as string) ?? '{}');
+};
+
+describe('useTraceRouteQuery', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getValhallaUrl).mockReturnValue('http://mock-valhalla');
+ vi.stubGlobal('fetch', vi.fn());
+ });
+
+ it('should throw when both polyline and file are missing', async () => {
+ const { traceRoute } = useTraceRouteQuery({});
+
+ await expect(traceRoute()).rejects.toThrow(
+ 'Provide an encoded polyline or a GPX file.'
+ );
+ });
+
+ it('should post encoded polyline request and parse geometry', async () => {
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: ' abc\n123 ',
+ });
+
+ const result = await traceRoute();
+
+ expect(fetch).toHaveBeenCalledWith('http://mock-valhalla/trace_route', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ encoded_polyline: 'abc123',
+ shape_match: 'map_snap',
+ costing: 'auto',
+ trace_options: {
+ gps_accuracy: 5,
+ search_radius: 50,
+ interpolation_distance: 10,
+ breakage_distance: 50,
+ },
+ }),
+ });
+
+ expect(parseDirectionsGeometry).toHaveBeenCalledTimes(2);
+ expect(showValhallaWarnings).toHaveBeenCalledWith(response.trip.warnings);
+ expect(result.decodedGeometry).toEqual([
+ [50, 10],
+ [51, 11],
+ ]);
+ });
+
+ it('should build shape request from GPX file text', async () => {
+ vi.mocked(parseGpxToLatLng).mockReturnValue([
+ [52.5, 13.4],
+ [52.6, 13.5],
+ [52.7, 13.6],
+ ]);
+
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({ fileText: '...' });
+ await traceRoute();
+
+ expect(fetch).toHaveBeenCalledWith('http://mock-valhalla/trace_route', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ shape: [
+ { lat: 52.5, lon: 13.4, type: 'break' },
+ { lat: 52.6, lon: 13.5 },
+ { lat: 52.7, lon: 13.6, type: 'break' },
+ ],
+ shape_match: 'map_snap',
+ costing: 'auto',
+ trace_options: {
+ gps_accuracy: 5,
+ search_radius: 50,
+ interpolation_distance: 10,
+ breakage_distance: 50,
+ },
+ }),
+ });
+ });
+
+ it('should map accuracy and radius to valhalla trace options', async () => {
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: 'abc123',
+ trace_options: {
+ accuracy: 7,
+ radius: 60,
+ breakage_distance: 75,
+ interpolation_distance: 15,
+ },
+ });
+
+ await traceRoute();
+
+ expect(getLastFetchBody().trace_options).toEqual({
+ gps_accuracy: 7,
+ search_radius: 60,
+ interpolation_distance: 15,
+ breakage_distance: 75,
+ });
+ });
+
+ it('should prefer explicit gps_accuracy/search_radius over aliases', async () => {
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: 'abc123',
+ trace_options: {
+ accuracy: 9,
+ radius: 70,
+ gps_accuracy: 4,
+ search_radius: 25,
+ },
+ });
+
+ await traceRoute();
+
+ expect(getLastFetchBody().trace_options).toMatchObject({
+ gps_accuracy: 4,
+ search_radius: 25,
+ });
+ });
+
+ it('should fallback to defaults for invalid numeric trace options', async () => {
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: 'abc123',
+ trace_options: {
+ gps_accuracy: Number.NaN,
+ search_radius: Number.POSITIVE_INFINITY,
+ interpolation_distance: -1,
+ breakage_distance: Number.NaN,
+ },
+ });
+
+ await traceRoute();
+
+ expect(getLastFetchBody().trace_options).toEqual({
+ gps_accuracy: 5,
+ search_radius: 50,
+ interpolation_distance: 10,
+ breakage_distance: 50,
+ });
+ });
+
+ it("should map 'car' costing to valhalla 'auto'", async () => {
+ const response = createRouteResponse();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue(response),
+ } as unknown as Response);
+
+ const { traceRoute } = useTraceRouteQuery({
+ polyline: 'abc123',
+ costing: 'car',
+ });
+
+ await traceRoute();
+
+ expect(getLastFetchBody().costing).toBe('auto');
+ });
+
+ it('should throw when GPX file has fewer than 2 points', async () => {
+ vi.mocked(parseGpxToLatLng).mockReturnValue([[52.5, 13.4]]);
+
+ const { traceRoute } = useTraceRouteQuery({ fileText: '...' });
+
+ await expect(traceRoute()).rejects.toThrow(
+ 'GPX must contain at least 2 points.'
+ );
+ });
+});
diff --git a/src/hooks/use-trace-route-query.ts b/src/hooks/use-trace-route-query.ts
new file mode 100644
index 0000000..8480e96
--- /dev/null
+++ b/src/hooks/use-trace-route-query.ts
@@ -0,0 +1,151 @@
+import type {
+ ParsedDirectionsGeometry,
+ ValhallaRouteResponse,
+} from '@/components/types';
+import type { Profile } from '@/stores/common-store';
+import { parseGpxToLatLng } from '@/utils/parse-gpx';
+import {
+ getValhallaUrl,
+ parseDirectionsGeometry,
+ showValhallaWarnings,
+} from '@/utils/valhalla';
+
+type Shape = {
+ lat: number;
+ lon: number;
+ type?: 'break' | 'via' | 'through';
+ time?: number;
+};
+
+const normalizePolyline = (input: string) => input.trim().replace(/\r?\n/g, '');
+const withDefaultNonNegative = (value: number | undefined, fallback: number) =>
+ typeof value === 'number' && Number.isFinite(value) && value >= 0
+ ? value
+ : fallback;
+
+type ShapeMatch = 'map_snap' | 'edge_walk' | 'walk_or_snap';
+
+interface TraceOptions {
+ accuracy?: number;
+ radius?: number;
+ gps_accuracy?: number;
+ breakage_distance?: number;
+ search_radius?: number;
+ interpolation_distance?: number;
+}
+
+type TraceRouteErrorPayload = {
+ status?: string;
+ error?: string;
+ error_code?: number;
+};
+
+export const useTraceRouteQuery = ({
+ polyline,
+ fileText,
+ costing = 'auto',
+ shape_match = 'map_snap',
+ trace_options,
+}: {
+ polyline?: string;
+ fileText?: string;
+ costing?: Profile;
+ shape_match?: ShapeMatch;
+ trace_options?: TraceOptions;
+}) => {
+ const valhallaCosting = costing === 'car' ? 'auto' : costing;
+
+ const hasPolyline = !!polyline?.trim();
+ const hasFile = !!fileText?.trim();
+
+ let shape: Shape[] = [];
+ if (!hasPolyline && hasFile) {
+ const coords = parseGpxToLatLng(fileText!);
+ shape = coords.map(([lat, lon]) => ({ lat, lon }));
+
+ if (shape.length >= 2) {
+ shape[0]!.type = 'break';
+ shape[shape.length - 1]!.type = 'break';
+ }
+ }
+
+ const resolvedTraceOptions = {
+ gps_accuracy: withDefaultNonNegative(
+ trace_options?.gps_accuracy ?? trace_options?.accuracy,
+ 5
+ ),
+ search_radius: withDefaultNonNegative(
+ trace_options?.search_radius ?? trace_options?.radius,
+ 50
+ ),
+ interpolation_distance: withDefaultNonNegative(
+ trace_options?.interpolation_distance,
+ 10
+ ),
+ breakage_distance: withDefaultNonNegative(
+ trace_options?.breakage_distance,
+ 50
+ ),
+ };
+
+ const valhallaRequest = {
+ json: {
+ ...(hasPolyline
+ ? {
+ encoded_polyline: normalizePolyline(polyline!),
+ shape_match: shape_match ?? 'map_snap',
+ }
+ : {
+ shape,
+ shape_match: shape_match ?? 'map_snap',
+ }),
+ costing: valhallaCosting,
+ trace_options: resolvedTraceOptions,
+ },
+ };
+
+ const traceRoute = async () => {
+ if (!hasPolyline && !hasFile) {
+ throw new Error('Provide an encoded polyline or a GPX file.');
+ }
+ if (!hasPolyline && hasFile && shape.length < 2) {
+ throw new Error('GPX must contain at least 2 points.');
+ }
+
+ const response = await fetch(`${getValhallaUrl()}/trace_route`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(valhallaRequest.json),
+ });
+
+ if (!response.ok) {
+ const errorData =
+ ((await response.json().catch(() => ({}))) as TraceRouteErrorPayload) ??
+ {};
+ const error = new Error(
+ errorData.error || `Trace route failed (${response.status})`
+ ) as Error & { payload?: TraceRouteErrorPayload; status?: number };
+ error.payload = errorData;
+ error.status = response.status;
+ throw error;
+ }
+
+ const data: ValhallaRouteResponse = await response.json();
+
+ (data as ParsedDirectionsGeometry).decodedGeometry =
+ parseDirectionsGeometry(data);
+
+ data.alternates?.forEach((alternate, i) => {
+ if (alternate) {
+ (data.alternates![i] as ParsedDirectionsGeometry).decodedGeometry =
+ parseDirectionsGeometry(alternate);
+ }
+ });
+
+ showValhallaWarnings(data.trip.warnings);
+
+ return data as ParsedDirectionsGeometry;
+ };
+
+ return { traceRoute };
+};
diff --git a/src/stores/trace-route-store.ts b/src/stores/trace-route-store.ts
new file mode 100644
index 0000000..fc19e3b
--- /dev/null
+++ b/src/stores/trace-route-store.ts
@@ -0,0 +1,219 @@
+import type {
+ ActiveWaypoint,
+ ParsedDirectionsGeometry,
+} from '@/components/types';
+import { create } from 'zustand';
+import { devtools } from 'zustand/middleware';
+import { immer } from 'zustand/middleware/immer';
+
+export interface TraceRouteWaypoint {
+ id: 'start' | 'end';
+ geocodeResults: ActiveWaypoint[];
+ userInput: string;
+}
+
+interface TraceRouteResult {
+ data: ParsedDirectionsGeometry | null;
+ show: Record;
+}
+
+type Shape = {
+ lat: number;
+ lon: number;
+ type?: 'break' | 'via' | 'through';
+ time?: number;
+};
+
+const createEmptyWaypoint = (id: 'start' | 'end'): TraceRouteWaypoint => ({
+ id,
+ geocodeResults: [],
+ userInput: '',
+});
+
+const createDefaultWaypoints = (): [TraceRouteWaypoint, TraceRouteWaypoint] => [
+ createEmptyWaypoint('start'),
+ createEmptyWaypoint('end'),
+];
+
+const createWaypointFromLatLng = (
+ id: 'start' | 'end',
+ lng: number,
+ lat: number,
+ key: number
+): TraceRouteWaypoint => ({
+ id,
+ geocodeResults: [
+ {
+ title: '',
+ displaylnglat: [lng, lat],
+ sourcelnglat: [lng, lat],
+ key,
+ addressindex: key,
+ selected: true,
+ },
+ ],
+ userInput: `${lng.toFixed(6)}, ${lat.toFixed(6)}`,
+});
+
+const normalizeTwoWaypoints = (
+ waypoints: TraceRouteWaypoint[]
+): [TraceRouteWaypoint, TraceRouteWaypoint] => {
+ const start = waypoints[0]
+ ? { ...waypoints[0], id: 'start' as const }
+ : createEmptyWaypoint('start');
+ const end = waypoints[1]
+ ? { ...waypoints[1], id: 'end' as const }
+ : createEmptyWaypoint('end');
+
+ return [start, end];
+};
+
+export interface TraceRouteState {
+ successful: boolean;
+ results: TraceRouteResult;
+ activeRouteIndex: number;
+ waypoints: [TraceRouteWaypoint, TraceRouteWaypoint];
+ inputGeometry: number[][] | null;
+
+ inputShape: Shape[] | null;
+}
+
+interface TraceRouteActions {
+ clearTraceRoute: () => void;
+ receiveTraceRouteResults: (params: {
+ data: ParsedDirectionsGeometry;
+ }) => void;
+
+ setInputGeometry: (coords: number[][] | null) => void;
+
+ setInputShape: (shape: Shape[] | null) => void;
+
+ setWaypoints: (waypoints: TraceRouteWaypoint[]) => void;
+ clearWaypoints: () => void;
+ setActiveRouteIndex: (index: number) => void;
+ toggleShowOnMap: (params: { show: boolean; idx: number }) => void;
+}
+
+type TraceRouteStore = TraceRouteState & TraceRouteActions;
+
+export const useTraceRouteStore = create()(
+ devtools(
+ immer((set) => ({
+ successful: false,
+ results: { data: null, show: { '0': true } },
+ activeRouteIndex: 0,
+ waypoints: createDefaultWaypoints(),
+
+ inputGeometry: null,
+ inputShape: null,
+
+ clearTraceRoute: () =>
+ set(
+ (state) => {
+ state.successful = false;
+ state.results.data = null;
+ state.results.show = { '0': true };
+ state.activeRouteIndex = 0;
+ state.waypoints = createDefaultWaypoints();
+
+ state.inputGeometry = null;
+ state.inputShape = null;
+ },
+ undefined,
+ 'clearTraceRoute'
+ ),
+
+ receiveTraceRouteResults: ({ data }) =>
+ set(
+ (state) => {
+ const show: Record = { '0': true };
+ data.alternates?.forEach((_, i) => (show[i + 1] = true));
+
+ const locations = data.trip.locations ?? [];
+ const startLocation = locations[0];
+ const endLocation = locations[locations.length - 1];
+
+ state.successful = true;
+ state.results = { data, show };
+ state.activeRouteIndex = 0;
+
+ state.waypoints = [
+ startLocation
+ ? createWaypointFromLatLng(
+ 'start',
+ startLocation.lon,
+ startLocation.lat,
+ 0
+ )
+ : createEmptyWaypoint('start'),
+ endLocation
+ ? createWaypointFromLatLng(
+ 'end',
+ endLocation.lon,
+ endLocation.lat,
+ 1
+ )
+ : createEmptyWaypoint('end'),
+ ];
+ },
+ undefined,
+ 'receiveTraceRouteResults'
+ ),
+
+ setInputGeometry: (coords) =>
+ set(
+ (state) => {
+ state.inputGeometry = coords;
+ },
+ undefined,
+ 'setInputGeometry'
+ ),
+
+ setInputShape: (shape) =>
+ set(
+ (state) => {
+ state.inputShape = shape;
+ },
+ undefined,
+ 'setInputShape'
+ ),
+
+ setWaypoints: (waypoints) =>
+ set(
+ (state) => {
+ state.waypoints = normalizeTwoWaypoints(waypoints);
+ },
+ undefined,
+ 'setWaypoints'
+ ),
+
+ clearWaypoints: () =>
+ set(
+ (state) => {
+ state.waypoints = createDefaultWaypoints();
+ },
+ undefined,
+ 'clearWaypoints'
+ ),
+
+ setActiveRouteIndex: (index) =>
+ set(
+ (state) => {
+ state.activeRouteIndex = index;
+ },
+ undefined,
+ 'setActiveRouteIndex'
+ ),
+
+ toggleShowOnMap: ({ idx, show }) =>
+ set(
+ (state) => {
+ state.results.show[idx] = show;
+ },
+ undefined,
+ 'toggleShowOnMap'
+ ),
+ })),
+ { name: 'trace-route-store' }
+ )
+);
diff --git a/src/utils/parse-gpx.spec.ts b/src/utils/parse-gpx.spec.ts
new file mode 100644
index 0000000..b64ee36
--- /dev/null
+++ b/src/utils/parse-gpx.spec.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest';
+import { parseGpxToLatLng } from './parse-gpx';
+
+describe('parseGpxToLatLng', () => {
+ it('should parse track points from GPX', () => {
+ const gpx = `
+
+
+
+
+
+
+ `;
+
+ expect(parseGpxToLatLng(gpx)).toEqual([
+ [52.5, 13.4],
+ [52.51, 13.41],
+ ]);
+ });
+
+ it('should parse route points from GPX', () => {
+ const gpx = `
+
+
+
+
+
+
+ `;
+
+ expect(parseGpxToLatLng(gpx)).toEqual([
+ [40, -3],
+ [41, -4],
+ ]);
+ });
+
+ it('should return empty array for invalid xml', () => {
+ const invalid = ' {
+ const gpx = `
+
+
+
+
+
+
+
+ `;
+
+ expect(parseGpxToLatLng(gpx)).toEqual([
+ [52.5, 13.4],
+ [52.52, 13.42],
+ ]);
+ });
+});
diff --git a/src/utils/parse-gpx.ts b/src/utils/parse-gpx.ts
new file mode 100644
index 0000000..2f9f6b4
--- /dev/null
+++ b/src/utils/parse-gpx.ts
@@ -0,0 +1,21 @@
+export function parseGpxToLatLng(xmlText: string): [number, number][] {
+ const doc = new DOMParser().parseFromString(xmlText, 'application/xml');
+
+ // basic error handling
+ const parserError = doc.querySelector('parsererror');
+ if (parserError) return [];
+
+ const pts = Array.from(doc.querySelectorAll('trkpt, rtept'));
+ const coords: [number, number][] = [];
+
+ for (const pt of pts) {
+ const latStr = pt.getAttribute('lat');
+ const lonStr = pt.getAttribute('lon');
+ const lat = latStr ? Number(latStr) : NaN;
+ const lon = lonStr ? Number(lonStr) : NaN;
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
+ coords.push([lat, lon]);
+ }
+
+ return coords;
+}
diff --git a/src/utils/route-schemas.spec.ts b/src/utils/route-schemas.spec.ts
index c9d45bf..7b702b0 100644
--- a/src/utils/route-schemas.spec.ts
+++ b/src/utils/route-schemas.spec.ts
@@ -116,6 +116,10 @@ describe('route-schemas', () => {
expect(isValidTab('tiles')).toBe(true);
});
+ it('should return true for trace-route', () => {
+ expect(isValidTab('trace-route')).toBe(true);
+ });
+
it('should return false for invalid tab names', () => {
expect(isValidTab('invalid')).toBe(false);
expect(isValidTab('settings')).toBe(false);
diff --git a/src/utils/route-schemas.ts b/src/utils/route-schemas.ts
index dd26ded..05bb4af 100644
--- a/src/utils/route-schemas.ts
+++ b/src/utils/route-schemas.ts
@@ -15,7 +15,12 @@ export const searchParamsSchema = z.object({
export type SearchParamsSchema = z.infer;
-export const VALID_TABS = ['directions', 'isochrones', 'tiles'] as const;
+export const VALID_TABS = [
+ 'directions',
+ 'isochrones',
+ 'tiles',
+ 'trace-route',
+] as const;
export type ValidTab = (typeof VALID_TABS)[number];
export function isValidTab(tab: string): tab is ValidTab {