Skip to content

Commit c29dba3

Browse files
Add optimize route button (#292)
1 parent 4434acc commit c29dba3

File tree

7 files changed

+280
-2
lines changed

7 files changed

+280
-2
lines changed

src/components/directions/directions.spec.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ vi.mock('@/hooks/use-directions-queries', () => ({
6868
})),
6969
}));
7070

71+
vi.mock('@/hooks/use-optimized-route-query', () => ({
72+
useOptimizedRouteQuery: vi.fn(() => ({
73+
optimizeRoute: vi.fn(),
74+
isPending: false,
75+
})),
76+
}));
77+
7178
vi.mock('./waypoints/waypoint-list', () => ({
7279
Waypoints: () => <div data-testid="mock-waypoints">Waypoints</div>,
7380
}));

src/components/directions/directions.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import {
2222
useDirectionsQuery,
2323
useReverseGeocodeDirections,
2424
} from '@/hooks/use-directions-queries';
25+
import { useOptimizedRouteQuery } from '@/hooks/use-optimized-route-query';
26+
import { Sparkles } from 'lucide-react';
27+
import {
28+
Tooltip,
29+
TooltipContent,
30+
TooltipTrigger,
31+
} from '@/components/ui/tooltip';
2532

2633
export const DirectionsControl = () => {
2734
const waypoints = useDirectionsStore((state) => state.waypoints);
@@ -38,6 +45,8 @@ export const DirectionsControl = () => {
3845
const dateTime = useCommonStore((state) => state.dateTime);
3946
const { refetch: refetchDirections } = useDirectionsQuery();
4047
const { reverseGeocode } = useReverseGeocodeDirections();
48+
const { optimizeRoute, isPending: isOptimizing } = useOptimizedRouteQuery();
49+
const isOptimized = useDirectionsStore((state) => state.isOptimized);
4150

4251
useEffect(() => {
4352
if (urlParamsProcessed.current) return;
@@ -100,6 +109,10 @@ export const DirectionsControl = () => {
100109
clearRoutes();
101110
}, [clearWaypoints, clearRoutes]);
102111

112+
const activeWaypointsCount = waypoints.filter((wp) =>
113+
wp.geocodeResults.some((r) => r.selected)
114+
).length;
115+
103116
return (
104117
<>
105118
<div className="flex flex-col gap-3 border rounded-md p-2">
@@ -127,6 +140,26 @@ export const DirectionsControl = () => {
127140
Reset Waypoints
128141
</Button>
129142
</div>
143+
<Tooltip open={activeWaypointsCount >= 4 ? false : undefined}>
144+
<TooltipTrigger asChild>
145+
<span>
146+
<Button
147+
variant="outline"
148+
onClick={() => optimizeRoute()}
149+
disabled={
150+
activeWaypointsCount < 4 || isOptimizing || isOptimized
151+
}
152+
className="w-full"
153+
>
154+
<Sparkles className="size-4" />
155+
Optimize Route
156+
</Button>
157+
</span>
158+
</TooltipTrigger>
159+
<TooltipContent>
160+
<p>You should have at least 4 waypoints to optimize the route</p>
161+
</TooltipContent>
162+
</Tooltip>
130163
<DateTimePicker
131164
type={dateTime.type}
132165
value={dateTime.value}

src/components/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,15 @@ export interface FetchGeocodeObject {
240240
}
241241

242242
export type PossibleTabValues = 'directions' | 'isochrones';
243+
244+
export interface OptimizedLocation {
245+
type: string;
246+
lat: number;
247+
lon: number;
248+
original_index: number;
249+
}
250+
251+
export interface ValhallaOptimizedRouteResponse {
252+
trip: Trip;
253+
id?: string;
254+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import axios from 'axios';
3+
import { toast } from 'sonner';
4+
import { useDirectionsStore } from '@/stores/directions-store';
5+
import {
6+
VALHALLA_OSM_URL,
7+
buildOptimizedRouteRequest,
8+
parseDirectionsGeometry,
9+
} from '@/utils/valhalla';
10+
import { filterProfileSettings } from '@/utils/filter-profile-settings';
11+
import { useCommonStore } from '@/stores/common-store';
12+
import { router } from '@/routes';
13+
import type { ValhallaOptimizedRouteResponse } from '@/components/types';
14+
import type { Waypoint } from '@/stores/directions-store';
15+
import { getDirectionsLanguage } from '@/utils/directions-language';
16+
17+
export function useOptimizedRouteQuery() {
18+
const waypoints = useDirectionsStore((state) => state.waypoints);
19+
const setWaypoint = useDirectionsStore((state) => state.setWaypoint);
20+
const receiveRouteResults = useDirectionsStore(
21+
(state) => state.receiveRouteResults
22+
);
23+
const setIsOptimized = useDirectionsStore((state) => state.setIsOptimized);
24+
const zoomTo = useCommonStore((state) => state.zoomTo);
25+
const { settings: rawSettings } = useCommonStore.getState();
26+
27+
const mutation = useMutation({
28+
mutationFn: async () => {
29+
const relevantWaypoints: Waypoint[] = [];
30+
31+
const activeWaypoints = waypoints.flatMap((wp) => {
32+
const selected = wp.geocodeResults.filter((r) => r.selected);
33+
if (selected.length > 0) {
34+
relevantWaypoints.push(wp);
35+
}
36+
return selected;
37+
});
38+
39+
if (activeWaypoints.length < 4) {
40+
throw new Error('Not enough waypoints to optimize');
41+
}
42+
43+
const profile = router.state.location.search.profile || 'bicycle';
44+
const settings = filterProfileSettings(profile, rawSettings);
45+
const language = getDirectionsLanguage();
46+
const request = buildOptimizedRouteRequest({
47+
profile,
48+
activeWaypoints,
49+
// @ts-expect-error todo: initial settings and filtered settings types mismatch
50+
settings,
51+
language,
52+
});
53+
const { data } = await axios.get<ValhallaOptimizedRouteResponse>(
54+
`${VALHALLA_OSM_URL}/optimized_route`,
55+
{ params: { json: JSON.stringify(request.json) } }
56+
);
57+
58+
const processedData = {
59+
...data,
60+
id: data.id ?? 'valhalla_optimized_route',
61+
decodedGeometry: parseDirectionsGeometry(data),
62+
};
63+
64+
return { data: processedData, relevantWaypoints };
65+
},
66+
onSuccess: ({ data, relevantWaypoints }) => {
67+
const newWaypoints: Waypoint[] = [];
68+
const locations = data.trip.locations;
69+
locations.forEach((loc) => {
70+
if (typeof loc.original_index === 'number') {
71+
const original = relevantWaypoints[loc.original_index];
72+
if (original) {
73+
newWaypoints.push(original);
74+
}
75+
}
76+
});
77+
setWaypoint(newWaypoints);
78+
setIsOptimized(true);
79+
receiveRouteResults({ data });
80+
zoomTo(data.decodedGeometry);
81+
toast.success('Route optimized successfully');
82+
},
83+
onError: (error) => {
84+
console.error('Optimization error:', error);
85+
toast.error('Failed to optimize route');
86+
},
87+
});
88+
89+
return {
90+
optimizeRoute: mutation.mutate,
91+
isPending: mutation.isPending,
92+
};
93+
}

src/stores/directions-store.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export interface DirectionsState {
6767
selectedAddresses: string | (Waypoint | null)[];
6868
results: RouteResult;
6969
inclineDeclineTotal?: InclineDeclineTotal;
70+
isOptimized: boolean;
7071
}
7172

7273
interface DirectionsActions {
@@ -96,6 +97,7 @@ interface DirectionsActions {
9697
lng: number,
9798
lat: number
9899
) => void;
100+
setIsOptimized: (isOptimized: boolean) => void;
99101
}
100102

101103
type DirectionsStore = DirectionsState & DirectionsActions;
@@ -109,6 +111,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
109111
zoomObj: { index: -1, timeNow: -1 },
110112
selectedAddresses: '',
111113
results: { data: null, show: { '-1': true } },
114+
isOptimized: false,
112115

113116
updateInclineDecline: (inclineDeclineTotal) =>
114117
set(
@@ -158,6 +161,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
158161
(state) => {
159162
if (state.waypoints[index]) {
160163
state.waypoints[index].geocodeResults = addresses;
164+
state.isOptimized = false;
161165
}
162166
},
163167
undefined,
@@ -179,6 +183,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
179183
...result,
180184
selected: j === addressindex,
181185
}));
186+
state.isOptimized = false;
182187
}
183188
},
184189
undefined,
@@ -189,6 +194,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
189194
set(
190195
(state) => {
191196
state.waypoints = [...defaultWaypoints];
197+
state.isOptimized = false;
192198
},
193199
undefined,
194200
'clearWaypoints'
@@ -200,6 +206,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
200206
if (state.waypoints[index]) {
201207
state.waypoints[index].userInput = '';
202208
state.waypoints[index].geocodeResults = [];
209+
state.isOptimized = false;
203210
}
204211
},
205212
undefined,
@@ -237,6 +244,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
237244
: createEmptyWaypoint(id);
238245

239246
state.waypoints.splice(index, 0, newWaypoint);
247+
state.isOptimized = false;
240248
},
241249
undefined,
242250
'addWaypointAtIndex'
@@ -248,6 +256,7 @@ export const useDirectionsStore = create<DirectionsStore>()(
248256
state.waypoints.push(
249257
createEmptyWaypoint((state.waypoints.length + 1).toString())
250258
);
259+
state.isOptimized = false;
251260
},
252261
undefined,
253262
'addEmptyWaypointToEnd'
@@ -263,6 +272,8 @@ export const useDirectionsStore = create<DirectionsStore>()(
263272
state.waypoints[index].geocodeResults = [];
264273
}
265274

275+
state.isOptimized = false;
276+
266277
if (!hasActiveRoute(state.waypoints)) {
267278
state.successful = false;
268279
state.inclineDeclineTotal = undefined;
@@ -313,11 +324,21 @@ export const useDirectionsStore = create<DirectionsStore>()(
313324
];
314325
state.waypoints[index].userInput =
315326
`${lng.toFixed(6)}, ${lat.toFixed(6)}`;
327+
state.isOptimized = false;
316328
}
317329
},
318330
undefined,
319331
'updatePlaceholderAddressAtIndex'
320332
),
333+
334+
setIsOptimized: (isOptimized) =>
335+
set(
336+
(state) => {
337+
state.isOptimized = isOptimized;
338+
},
339+
undefined,
340+
'setIsOptimized'
341+
),
321342
})),
322343
{ name: 'directions-store' }
323344
)

src/utils/valhalla.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
buildIsochronesRequest,
1515
makeContours,
1616
makeLocations,
17+
buildOptimizedRouteRequest,
1718
} from './valhalla';
1819

1920
// Mock the polyline decode function
@@ -448,6 +449,86 @@ describe('valhalla.ts', () => {
448449
});
449450
});
450451

452+
describe('buildOptimizedRouteRequest', () => {
453+
const mockActiveWaypoints: ActiveWaypoints = [
454+
{
455+
title: 'Start',
456+
description: 'Starting point',
457+
selected: true,
458+
addresslnglat: [-74.006, 40.7128],
459+
sourcelnglat: [-74.006, 40.7128],
460+
displaylnglat: [-74.006, 40.7128],
461+
key: 1,
462+
addressindex: 0,
463+
},
464+
{
465+
title: 'Via 1',
466+
description: 'Via point 1',
467+
selected: true,
468+
addresslnglat: [-73.99, 40.75],
469+
sourcelnglat: [-73.99, 40.75],
470+
displaylnglat: [-73.99, 40.75],
471+
key: 2,
472+
addressindex: 1,
473+
},
474+
{
475+
title: 'Via 2',
476+
description: 'Via point 2',
477+
selected: true,
478+
addresslnglat: [-73.98, 40.755],
479+
sourcelnglat: [-73.98, 40.755],
480+
displaylnglat: [-73.98, 40.755],
481+
key: 3,
482+
addressindex: 2,
483+
},
484+
{
485+
title: 'End',
486+
description: 'Ending point',
487+
selected: true,
488+
addresslnglat: [-87.6298, 41.8781],
489+
sourcelnglat: [-87.6298, 41.8781],
490+
displaylnglat: [-87.6298, 41.8781],
491+
key: 4,
492+
addressindex: 3,
493+
},
494+
];
495+
const mockSettings: Settings = {
496+
// @ts-expect-error - Partial mock for testing
497+
costing: {
498+
maneuver_penalty: 5,
499+
use_highways: 1,
500+
},
501+
// @ts-expect-error - Partial mock for testing
502+
directions: {},
503+
};
504+
it('should create an optimized route request with proper structure', () => {
505+
const profile: Profile = 'car';
506+
const result = buildOptimizedRouteRequest({
507+
profile,
508+
activeWaypoints: mockActiveWaypoints,
509+
settings: mockSettings,
510+
language: 'en-US',
511+
});
512+
expect(result).toEqual({
513+
json: {
514+
costing: 'auto',
515+
costing_options: {
516+
auto: mockSettings.costing,
517+
},
518+
locations: [
519+
{ lon: -74.006, lat: 40.7128, type: 'break' },
520+
{ lon: -73.99, lat: 40.75, type: 'via' },
521+
{ lon: -73.98, lat: 40.755, type: 'via' },
522+
{ lon: -87.6298, lat: 41.8781, type: 'break' },
523+
],
524+
units: 'kilometers',
525+
id: 'valhalla_optimized_route',
526+
language: 'en-US',
527+
},
528+
});
529+
});
530+
});
531+
451532
describe('parseDirectionsGeometry', () => {
452533
it('should parse directions geometry with single leg', () => {
453534
const mockData: ValhallaRouteResponse = {

0 commit comments

Comments
 (0)