diff --git a/src/components/directions/route-card.spec.tsx b/src/components/directions/route-card.spec.tsx index 1ae8f9f2..47a468a2 100644 --- a/src/components/directions/route-card.spec.tsx +++ b/src/components/directions/route-card.spec.tsx @@ -6,6 +6,8 @@ import type { ParsedDirectionsGeometry } from '@/components/types'; const mockExportDataAsJson = vi.fn(); const mockDownloadFile = vi.fn(); +const mockFetchHeight = vi.fn(); +const mockToastError = vi.fn(); vi.mock('@/utils/export', () => ({ exportDataAsJson: (...args: unknown[]) => mockExportDataAsJson(...args), @@ -15,6 +17,16 @@ vi.mock('@/utils/download-file', () => ({ downloadFile: (...args: unknown[]) => mockDownloadFile(...args), })); +vi.mock('@/utils/height', () => ({ + fetchHeight: (...args: unknown[]) => mockFetchHeight(...args), +})); + +vi.mock('sonner', () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + }, +})); + vi.mock('@/utils/date-time', () => ({ getDateTimeString: () => '2024-01-01_12-00-00', formatDuration: (seconds: number) => `${Math.floor(seconds / 60)} min`, @@ -92,6 +104,7 @@ const createMockData = ( describe('RouteCard', () => { beforeEach(() => { vi.clearAllMocks(); + mockFetchHeight.mockResolvedValue({ height: [100, 101, 102] }); }); it('should render without crashing', () => { @@ -194,13 +207,20 @@ describe('RouteCard', () => { await user.click(screen.getByRole('button', { name: /export/i })); - expect(screen.getByRole('menuitem', { name: 'JSON' })).toBeInTheDocument(); + expect(screen.getByText('Format')).toBeInTheDocument(); + expect(screen.getByText('Options')).toBeInTheDocument(); + expect( + screen.getByRole('menuitemradio', { name: 'JSON' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitemradio', { name: 'GeoJSON' }) + ).toBeInTheDocument(); expect( - screen.getByRole('menuitem', { name: 'GeoJSON' }) + screen.getByRole('menuitemcheckbox', { name: 'Include elevation' }) ).toBeInTheDocument(); }); - it('should call exportDataAsJson when JSON is clicked', async () => { + it('should call exportDataAsJson when JSON format is selected and Export is clicked', async () => { const user = userEvent.setup(); const data = createMockData(); render( @@ -208,7 +228,8 @@ describe('RouteCard', () => { ); await user.click(screen.getByRole('button', { name: /export/i })); - await user.click(screen.getByRole('menuitem', { name: 'JSON' })); + await user.click(screen.getByRole('menuitemradio', { name: 'JSON' })); + await user.click(screen.getByTestId('export-action-button')); expect(mockExportDataAsJson).toHaveBeenCalledWith( data, @@ -216,7 +237,7 @@ describe('RouteCard', () => { ); }); - it('should call downloadFile with GeoJSON when GeoJSON is clicked', async () => { + it('should call downloadFile with GeoJSON when GeoJSON format is selected and Export is clicked', async () => { const user = userEvent.setup(); const data = createMockData(); render( @@ -224,7 +245,8 @@ describe('RouteCard', () => { ); await user.click(screen.getByRole('button', { name: /export/i })); - await user.click(screen.getByRole('menuitem', { name: 'GeoJSON' })); + await user.click(screen.getByRole('menuitemradio', { name: 'GeoJSON' })); + await user.click(screen.getByTestId('export-action-button')); expect(mockDownloadFile).toHaveBeenCalledWith({ data: expect.stringContaining('"type": "Feature"'), @@ -233,6 +255,77 @@ describe('RouteCard', () => { }); }); + it('should fetch elevation and export JSON with elevation when option is enabled', async () => { + const user = userEvent.setup(); + const baseData = createMockData(); + const firstLeg = baseData.trip.legs[0]!; + const data = createMockData({ + trip: { + ...baseData.trip, + legs: [ + ...baseData.trip.legs, + { + ...firstLeg, + shape: 'encoded-2', + }, + ], + }, + }); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /export/i })); + await user.click(screen.getByRole('menuitemradio', { name: 'JSON' })); + await user.click( + screen.getByRole('menuitemcheckbox', { name: 'Include elevation' }) + ); + await user.click(screen.getByTestId('export-action-button')); + + expect(mockFetchHeight).toHaveBeenCalledWith({ + coordinates: data.decodedGeometry, + }); + + const callArg = mockDownloadFile.mock.calls[0]?.[0] as { + data: string; + fileName: string; + fileType: string; + }; + const exportedJson = JSON.parse(callArg.data); + + expect(callArg.fileName).toBe( + 'valhalla-directions_2024-01-01_12-00-00_with_elevation.json' + ); + expect(exportedJson.trip.legs).toHaveLength(2); + expect(exportedJson.trip.legs[0].elevation_interval).toBe(30); + expect(exportedJson.trip.legs[0].elevation).toEqual([100, 101, 102]); + expect(exportedJson.trip.legs[1].elevation_interval).toBe(30); + expect(exportedJson.trip.legs[1].elevation).toEqual([100, 101, 102]); + }); + + it('should show error toast and skip download when elevation fetch fails', async () => { + const user = userEvent.setup(); + const data = createMockData(); + mockFetchHeight.mockRejectedValueOnce(new Error('network failed')); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /export/i })); + await user.click( + screen.getByRole('menuitemcheckbox', { name: 'Include elevation' }) + ); + await user.click(screen.getByTestId('export-action-button')); + + expect(mockToastError).toHaveBeenCalledWith( + 'Failed to fetch elevation data.', + expect.any(Object) + ); + expect(mockDownloadFile).not.toHaveBeenCalled(); + }); + it('should convert coordinates to GeoJSON format (lng, lat)', async () => { const user = userEvent.setup(); const data = createMockData({ @@ -243,7 +336,8 @@ describe('RouteCard', () => { ); await user.click(screen.getByRole('button', { name: /export/i })); - await user.click(screen.getByRole('menuitem', { name: 'GeoJSON' })); + await user.click(screen.getByRole('menuitemradio', { name: 'GeoJSON' })); + await user.click(screen.getByTestId('export-action-button')); const callArg = mockDownloadFile.mock.calls[0]?.[0] as { data: string; diff --git a/src/components/directions/route-card.tsx b/src/components/directions/route-card.tsx index 029242a2..d55da4d1 100644 --- a/src/components/directions/route-card.tsx +++ b/src/components/directions/route-card.tsx @@ -12,15 +12,21 @@ import { } from '@/components/ui/collapsible'; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Download } from 'lucide-react'; +import { ChevronDown, 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'; +import { fetchHeight } from '@/utils/height'; +import { toast } from 'sonner'; interface RouteCardProps { data: ParsedDirectionsGeometry; @@ -36,6 +42,11 @@ export const RouteCard = ({ onSelect, }: RouteCardProps) => { const [showManeuvers, setShowManeuvers] = useState(false); + const [includeElevation, setIncludeElevation] = useState(false); + const [exportFormat, setExportFormat] = useState<'geojson' | 'json'>( + 'geojson' + ); + const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); const exportToGeoJson = useCallback(() => { const coordinates = data?.decodedGeometry; @@ -60,6 +71,109 @@ export const RouteCard = ({ }); }, [data]); + const exportWithElevation = useCallback( + async (isGeoJson: boolean = false) => { + const coordinates = data?.decodedGeometry; + if (!coordinates) return; + + let elevationResults: Awaited>; + try { + elevationResults = await fetchHeight({ + coordinates: coordinates as [number, number][], + }); + } catch { + toast.error('Failed to fetch elevation data.', { + position: 'bottom-center', + duration: 5000, + closeButton: true, + }); + return; + } + + if (!elevationResults.height) { + toast.error('Failed to fetch elevation data.', { + position: 'bottom-center', + duration: 5000, + closeButton: true, + }); + return; + } + + if (!isGeoJson) { + const dataWithElevation = { + ...data, + trip: { + ...data.trip, + legs: (data.trip.legs ?? []).map((leg) => ({ + ...leg, + elevation_interval: 30, + elevation: elevationResults.height, + })), + }, + }; + const formattedData = JSON.stringify(dataWithElevation, null, 2); + downloadFile({ + data: formattedData, + fileName: + 'valhalla-directions_' + + getDateTimeString() + + '_with_elevation.json', + fileType: 'text/json', + }); + return; + } + + const geoJsonCoordinates = coordinates.map(([lat, lng]) => [lng, lat]); + + const geoJson = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: geoJsonCoordinates, + }, + properties: { + elevation_interval: 30, + elevation: elevationResults.height, + }, + }; + const formattedData = JSON.stringify(geoJson, null, 2); + downloadFile({ + data: formattedData, + fileName: + 'valhalla-directions_' + + getDateTimeString() + + '_with_elevation.geojson', + fileType: 'text/json', + }); + }, + [data] + ); + + const handleExport = useCallback(async () => { + if (exportFormat === 'json') { + if (includeElevation) { + await exportWithElevation(); + } else { + exportDataAsJson(data, 'valhalla-directions'); + } + setIsExportMenuOpen(false); + return; + } + + if (includeElevation) { + await exportWithElevation(true); + } else { + exportToGeoJson(); + } + setIsExportMenuOpen(false); + }, [ + data, + exportFormat, + includeElevation, + exportToGeoJson, + exportWithElevation, + ]); + if (!data.trip) { return null; } @@ -95,22 +209,63 @@ export const RouteCard = ({ {showManeuvers ? 'Hide Maneuvers' : 'Show Maneuvers'} - + - - exportDataAsJson(data, 'valhalla-directions')} + + + Format + + + setExportFormat(value as 'geojson' | 'json') + } + > + e.preventDefault()} + > + GeoJSON + + e.preventDefault()} + > + JSON + + + + + Options + + setIncludeElevation(!!checked)} + onSelect={(e) => e.preventDefault()} > - JSON - - - GeoJSON - + Include elevation + + +
+ +
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index a5d926fd..a2e39ed9 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; -import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -90,13 +90,13 @@ function DropdownMenuCheckboxItem({ - + @@ -126,15 +126,13 @@ function DropdownMenuRadioItem({ - - - - + + {children} diff --git a/src/utils/height.ts b/src/utils/height.ts new file mode 100644 index 00000000..a22e450c --- /dev/null +++ b/src/utils/height.ts @@ -0,0 +1,47 @@ +import { getValhallaUrl } from './valhalla'; + +type LatLng = [lat: number, lng: number]; + +interface HeightResponse { + height?: number[]; +} + +export const fetchHeight = async ({ + coordinates, +}: { + coordinates: LatLng[]; +}): Promise => { + const resample_distance = 30; // meters + const height_precision = 2; + + const heightPayload = { + shape: coordinates.map(([lat, lng]) => ({ lat, lon: lng })), + resample_distance, + height_precision, + id: 'valhalla_height', + }; + + try { + const res = await fetch(`${getValhallaUrl()}/height`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(heightPayload), + }); + + if (!res.ok) { + throw new Error( + `Failed to fetch height (${res.status} ${res.statusText})` + ); + } + + const data = await res.json(); + return data; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to fetch height data: ${error.message}`); + } + throw new Error('Failed to fetch height data: Unknown error'); + } +};