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');
+ }
+};