diff --git a/src/components/map/index.spec.tsx b/src/components/map/index.spec.tsx index 0cfe82d..50b425a 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 2816eb4..f46bf2a 100644 --- a/src/components/map/index.tsx +++ b/src/components/map/index.tsx @@ -67,6 +67,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(); @@ -835,7 +837,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 03e9db5..b8b3311 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 8a76cfa..a47a3a4 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 0000000..456667d --- /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 0000000..8254cae --- /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 0000000..84e852a --- /dev/null +++ b/src/components/map/parts/trace-route-markers.spec.tsx @@ -0,0 +1,91 @@ +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 = ({ + 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 ( +
+