diff --git a/CLAUDE.md b/CLAUDE.md
index 7384836..d8333ca 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -66,7 +66,7 @@ The Vite `base` is derived from `package.json` `homepage` (see `vite.config.ts
Three Zustand stores, each with `immer` + `devtools`:
- `src/stores/common-store.ts` — settings panel/directions panel open state, costing settings, dateTime, map-ready flag. `Profile` enum and `profileEnum` zod schema live here.
-- `src/stores/directions-store.ts` — waypoints (with geocode results), route results, highlighted maneuver, optimized-route flag, active-route index.
+- `src/stores/directions-store.ts` — waypoints (each with a selected address + geocode candidates), route results, highlighted maneuver, optimized-route flag, active-route index.
- `src/stores/isochrones-store.ts` — input/result, range/interval/denoise/generalize, color palette, opacity.
Server-state lives in TanStack Query. The global `QueryClient` (`src/lib/tanstack-query/root-provider.tsx`) sets `refetchOnWindowFocus: false`, `retry: 1`, `staleTime: 5min`, `gcTime: 10min`. Query hooks are in `src/hooks/use-*-queries.ts`. They read inputs directly from Zustand stores via `useStore.getState()` and from the router via `router.state.location.search` rather than parameters — keep that pattern when adding new queries.
diff --git a/src/components/directions/directions.spec.tsx b/src/components/directions/directions.spec.tsx
index f7326ce..c4fe0fc 100644
--- a/src/components/directions/directions.spec.tsx
+++ b/src/components/directions/directions.spec.tsx
@@ -19,8 +19,8 @@ vi.mock('@/utils/parse-url-params', () => ({
}));
const mockWaypoints = [
- { id: '0', geocodeResults: [], userInput: '' },
- { id: '1', geocodeResults: [], userInput: '' },
+ { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' },
+ { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' },
];
const mockResults = {
@@ -54,8 +54,8 @@ vi.mock('@/stores/directions-store', () => {
);
return {
defaultWaypoints: [
- { id: '0', geocodeResults: [], userInput: '' },
- { id: '1', geocodeResults: [], userInput: '' },
+ { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' },
+ { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' },
],
useDirectionsStore,
};
@@ -107,8 +107,8 @@ describe('DirectionsControl', () => {
mockResults.data = null;
mockWaypoints.length = 0;
mockWaypoints.push(
- { id: '0', geocodeResults: [], userInput: '' },
- { id: '1', geocodeResults: [], userInput: '' }
+ { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' },
+ { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' }
);
});
@@ -160,12 +160,11 @@ describe('DirectionsControl', () => {
mockWaypoints.push(
{
id: '0',
- geocodeResults: [
- { selected: true, sourcelnglat: [13.4, 52.5] },
- ] as never[],
+ geocodeResults: [],
+ selectedAddress: { sourcelnglat: [13.4, 52.5] } as never,
userInput: 'Berlin',
},
- { id: '1', geocodeResults: [], userInput: '' }
+ { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' }
);
render();
@@ -222,16 +221,14 @@ describe('DirectionsControl', () => {
mockWaypoints.push(
{
id: '0',
- geocodeResults: [
- { selected: true, sourcelnglat: [13.4, 52.5] },
- ] as never[],
+ geocodeResults: [],
+ selectedAddress: { sourcelnglat: [13.4, 52.5] } as never,
userInput: 'Berlin',
},
{
id: '1',
- geocodeResults: [
- { selected: true, sourcelnglat: [10.0, 48.0] },
- ] as never[],
+ geocodeResults: [],
+ selectedAddress: { sourcelnglat: [10.0, 48.0] } as never,
userInput: 'Munich',
}
);
@@ -259,8 +256,8 @@ describe('DirectionsControl URL parsing', () => {
mockResults.data = null;
mockWaypoints.length = 0;
mockWaypoints.push(
- { id: '0', geocodeResults: [], userInput: '' },
- { id: '1', geocodeResults: [], userInput: '' }
+ { id: '0', geocodeResults: [], selectedAddress: null, userInput: '' },
+ { id: '1', geocodeResults: [], selectedAddress: null, userInput: '' }
);
});
diff --git a/src/components/directions/directions.tsx b/src/components/directions/directions.tsx
index eeca19f..b8fcde2 100644
--- a/src/components/directions/directions.tsx
+++ b/src/components/directions/directions.tsx
@@ -52,7 +52,7 @@ export const DirectionsControl = () => {
const alreadyHydrated = useDirectionsStore
.getState()
- .waypoints.some((wp) => wp.geocodeResults.some((r) => r.selected));
+ .waypoints.some((wp) => wp.selectedAddress);
if (alreadyHydrated) {
urlParamsProcessed.current = true;
return;
@@ -83,10 +83,9 @@ export const DirectionsControl = () => {
const wps: number[] = [];
for (const wp of waypoints) {
- for (const result of wp.geocodeResults) {
- if (result.selected && result.sourcelnglat) {
- wps.push(result.sourcelnglat[0], result.sourcelnglat[1]);
- }
+ const lngLat = wp.selectedAddress?.sourcelnglat;
+ if (lngLat) {
+ wps.push(lngLat[0], lngLat[1]);
}
}
@@ -108,8 +107,8 @@ export const DirectionsControl = () => {
clearRoutes();
}, [clearWaypoints, clearRoutes]);
- const activeWaypointsCount = waypoints.filter((wp) =>
- wp.geocodeResults.some((r) => r.selected)
+ const activeWaypointsCount = waypoints.filter(
+ (wp) => wp.selectedAddress
).length;
return (
diff --git a/src/components/directions/waypoints/waypoint-item.spec.tsx b/src/components/directions/waypoints/waypoint-item.spec.tsx
index 0708dbd..1bbcf46 100644
--- a/src/components/directions/waypoints/waypoint-item.spec.tsx
+++ b/src/components/directions/waypoints/waypoint-item.spec.tsx
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import { Waypoint } from './waypoint-item';
const mockReceiveGeocodeResults = vi.fn();
-const mockUpdateTextInput = vi.fn();
+const mockSelectAddress = vi.fn();
const mockDoRemoveWaypoint = vi.fn();
const mockRefetchDirections = vi.fn();
const mockSetWaypointFromCoords = vi.fn().mockResolvedValue([]);
@@ -45,29 +45,38 @@ vi.mock('@/stores/directions-store', () => ({
{
id: 'wp-1',
userInput: 'Berlin',
- geocodeResults: [
- {
- title: 'Berlin, Germany',
- addressindex: 0,
- displaylnglat: [13.4, 52.5],
- selected: true,
- },
- ],
+ geocodeResults: [],
+ selectedAddress: {
+ title: 'Berlin, Germany',
+ addressindex: 0,
+ displaylnglat: [13.4, 52.5],
+ },
},
{
id: 'wp-2',
userInput: 'Munich',
geocodeResults: [],
+ selectedAddress: null,
},
],
receiveGeocodeResults: mockReceiveGeocodeResults,
- updateTextInput: mockUpdateTextInput,
+ selectAddress: mockSelectAddress,
doRemoveWaypoint: mockDoRemoveWaypoint,
})
),
defaultWaypoints: [
- { id: 'default-1', userInput: '', geocodeResults: [] },
- { id: 'default-2', userInput: '', geocodeResults: [] },
+ {
+ id: 'default-1',
+ userInput: '',
+ geocodeResults: [],
+ selectedAddress: null,
+ },
+ {
+ id: 'default-2',
+ userInput: '',
+ geocodeResults: [],
+ selectedAddress: null,
+ },
],
}));
@@ -171,16 +180,15 @@ describe('Waypoint', () => {
});
});
- it('should call updateTextInput and refetchDirections when result is selected', async () => {
+ it('should call selectAddress and refetchDirections when result is selected', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByTestId('select-result'));
- expect(mockUpdateTextInput).toHaveBeenCalledWith({
- inputValue: 'Selected',
+ expect(mockSelectAddress).toHaveBeenCalledWith({
index: 0,
- addressindex: 0,
+ address: { title: 'Selected', addressindex: 0, lngLat: [0, 0] },
});
expect(mockRefetchDirections).toHaveBeenCalled();
});
diff --git a/src/components/directions/waypoints/waypoint-item.tsx b/src/components/directions/waypoints/waypoint-item.tsx
index 42beb54..4554c37 100644
--- a/src/components/directions/waypoints/waypoint-item.tsx
+++ b/src/components/directions/waypoints/waypoint-item.tsx
@@ -37,7 +37,7 @@ export const Waypoint = ({ id, index }: WaypointProps) => {
const receiveGeocodeResults = useDirectionsStore(
(state) => state.receiveGeocodeResults
);
- const updateTextInput = useDirectionsStore((state) => state.updateTextInput);
+ const selectAddress = useDirectionsStore((state) => state.selectAddress);
const { refetch: refetchDirections } = useDirectionsQuery();
const { setWaypointFromCoords } = useSetWaypointFromCoords();
const doRemoveWaypoint = useDirectionsStore(
@@ -45,8 +45,8 @@ export const Waypoint = ({ id, index }: WaypointProps) => {
);
const { mainMap } = useMap();
const waypoint = waypoints[index];
- const { userInput, geocodeResults } = waypoint!;
- const selectedCoords = geocodeResults?.find((r) => r.selected)?.displaylnglat;
+ const { userInput, geocodeResults, selectedAddress } = waypoint!;
+ const selectedCoords = selectedAddress?.displaylnglat;
const handleGeocodeResults = useCallback(
(addresses: ActiveWaypoint[]) => {
@@ -87,15 +87,10 @@ export const Waypoint = ({ id, index }: WaypointProps) => {
const handleResultSelect = useCallback(
(result: ActiveWaypoint) => {
- updateTextInput({
- inputValue: result.title,
- index: index,
- addressindex: result.addressindex,
- });
-
+ selectAddress({ index, address: result });
refetchDirections();
},
- [updateTextInput, index, refetchDirections]
+ [selectAddress, index, refetchDirections]
);
const style = {
@@ -185,10 +180,7 @@ export const Waypoint = ({ id, index }: WaypointProps) => {
refetchDirections();
}}
data-testid="remove-waypoint-button"
- disabled={
- waypoints.length < 3 &&
- !geocodeResults?.some((r) => r.selected)
- }
+ disabled={waypoints.length < 3 && !selectedAddress}
>
diff --git a/src/components/isochrones/isochrones.spec.tsx b/src/components/isochrones/isochrones.spec.tsx
index b45f86f..3411293 100644
--- a/src/components/isochrones/isochrones.spec.tsx
+++ b/src/components/isochrones/isochrones.spec.tsx
@@ -30,25 +30,16 @@ const mockResults = {
show: true,
};
-const mockGeocodeResults: {
- selected: boolean;
- sourcelnglat: [number, number];
-}[] = [];
+let mockSelectedAddress: { sourcelnglat: [number, number] } | null = null;
vi.mock('@/stores/isochrones-store', () => {
+ const getState = () => ({
+ results: mockResults,
+ selectedAddress: mockSelectedAddress,
+ });
const useIsochronesStore = Object.assign(
- vi.fn((selector) =>
- selector({
- results: mockResults,
- geocodeResults: mockGeocodeResults,
- })
- ),
- {
- getState: () => ({
- results: mockResults,
- geocodeResults: mockGeocodeResults,
- }),
- }
+ vi.fn((selector) => selector(getState())),
+ { getState }
);
return { useIsochronesStore };
});
@@ -96,7 +87,7 @@ describe('IsochronesControl', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResults.data = null;
- mockGeocodeResults.length = 0;
+ mockSelectedAddress = null;
});
it('should render without crashing', () => {
@@ -143,10 +134,7 @@ describe('IsochronesControl', () => {
});
it('should sync geocode results to URL', () => {
- mockGeocodeResults.push({
- selected: true,
- sourcelnglat: [13.4, 52.5],
- });
+ mockSelectedAddress = { sourcelnglat: [13.4, 52.5] };
render();
@@ -157,10 +145,7 @@ describe('IsochronesControl', () => {
});
it('should call navigate with wps parameter when center exists', () => {
- mockGeocodeResults.push({
- selected: true,
- sourcelnglat: [13.4, 52.5],
- });
+ mockSelectedAddress = { sourcelnglat: [13.4, 52.5] };
render();
@@ -197,7 +182,7 @@ describe('IsochronesControl URL parsing', () => {
beforeEach(() => {
vi.clearAllMocks();
mockResults.data = null;
- mockGeocodeResults.length = 0;
+ mockSelectedAddress = null;
});
it('should process URL params with valid coordinates', async () => {
diff --git a/src/components/isochrones/isochrones.tsx b/src/components/isochrones/isochrones.tsx
index 654098e..a33982c 100644
--- a/src/components/isochrones/isochrones.tsx
+++ b/src/components/isochrones/isochrones.tsx
@@ -16,7 +16,7 @@ import {
export const IsochronesControl = () => {
const { mainMap } = useMap();
const results = useIsochronesStore((state) => state.results);
- const geocodeResults = useIsochronesStore((state) => state.geocodeResults);
+ const selectedAddress = useIsochronesStore((state) => state.selectedAddress);
const initialUrlParams = useRef(parseUrlParams());
const urlParamsProcessed = useRef(false);
const navigate = useNavigate({ from: '/$activeTab' });
@@ -26,9 +26,8 @@ export const IsochronesControl = () => {
useEffect(() => {
if (urlParamsProcessed.current || !mainMap) return;
- const alreadyHydrated = useIsochronesStore
- .getState()
- .geocodeResults.some((r) => r.selected);
+ const alreadyHydrated =
+ useIsochronesStore.getState().selectedAddress !== null;
if (alreadyHydrated) {
urlParamsProcessed.current = true;
return;
@@ -61,19 +60,13 @@ export const IsochronesControl = () => {
// Sync isochrone center to URL
useEffect(() => {
- let center: string | undefined;
-
- for (const result of geocodeResults) {
- if (result.selected && result.sourcelnglat) {
- center = result.sourcelnglat.join(',');
- }
- }
+ const center = selectedAddress?.sourcelnglat?.join(',');
navigate({
search: (prev) => ({ ...prev, wps: center || undefined }),
replace: true,
});
- }, [geocodeResults, navigate]);
+ }, [selectedAddress, navigate]);
return (
<>
diff --git a/src/components/map/index.spec.tsx b/src/components/map/index.spec.tsx
index 5b0af83..c816dfc 100644
--- a/src/components/map/index.spec.tsx
+++ b/src/components/map/index.spec.tsx
@@ -160,7 +160,7 @@ vi.mock('@/stores/directions-store', () => ({
vi.mock('@/stores/isochrones-store', () => ({
useIsochronesStore: vi.fn((selector) => {
const state = {
- geocodeResults: [],
+ selectedAddress: null,
};
return selector(state);
}),
diff --git a/src/components/map/index.tsx b/src/components/map/index.tsx
index 32dabe7..371351c 100644
--- a/src/components/map/index.tsx
+++ b/src/components/map/index.tsx
@@ -383,7 +383,7 @@ export const MapComponent = () => {
}, [directionResults, heightPayload, updateInclineDecline]);
// Update markers when waypoints or isochrone centers change
- const geocodeResults = useIsochronesStore((state) => state.geocodeResults);
+ const isochroneCenter = useIsochronesStore((state) => state.selectedAddress);
const markers = useMemo(() => {
const newMarkers: MarkerData[] = [];
@@ -397,40 +397,37 @@ export const MapComponent = () => {
: isDestination
? 'red'
: 'grey';
- waypoint.geocodeResults.forEach((address) => {
- if (address.selected) {
- newMarkers.push({
- id: `waypoint-${index}`,
- lng: address.displaylnglat[0],
- lat: address.displaylnglat[1],
- type: 'waypoint',
- index: index,
- title: address.title,
- color,
- number: (index + 1).toString(),
- });
- }
- });
- });
-
- // Add isochrone center marker
- geocodeResults.forEach((address) => {
- if (address.selected) {
+ const address = waypoint.selectedAddress;
+ if (address) {
newMarkers.push({
- id: 'iso-center',
+ id: `waypoint-${index}`,
lng: address.displaylnglat[0],
lat: address.displaylnglat[1],
- type: 'isocenter',
+ type: 'waypoint',
+ index: index,
title: address.title,
- color: 'purple',
- shape: 'star',
- number: '1',
+ color,
+ number: (index + 1).toString(),
});
}
});
+ // Add isochrone center marker
+ if (isochroneCenter) {
+ newMarkers.push({
+ id: 'iso-center',
+ lng: isochroneCenter.displaylnglat[0],
+ lat: isochroneCenter.displaylnglat[1],
+ type: 'isocenter',
+ title: isochroneCenter.title,
+ color: 'purple',
+ shape: 'star',
+ number: '1',
+ });
+ }
+
return newMarkers;
- }, [waypoints, geocodeResults]);
+ }, [waypoints, isochroneCenter]);
//Stores the route content
const lastZoomedCoordKeyRef = useRef(null);
diff --git a/src/components/types.ts b/src/components/types.ts
index 272d153..ce709b5 100644
--- a/src/components/types.ts
+++ b/src/components/types.ts
@@ -1,7 +1,6 @@
export interface ActiveWaypoint {
title: string;
description?: string;
- selected?: boolean;
addresslnglat?: [number, number];
sourcelnglat?: [number, number];
displaylnglat: [number, number];
@@ -177,7 +176,6 @@ export interface IsochronesRequestParams {
export interface Center {
title: string;
description: string;
- selected: boolean;
addresslnglat: number[];
sourcelnglat: number[];
displaylnglat: number[];
diff --git a/src/components/ui/waypoint-search.tsx b/src/components/ui/waypoint-search.tsx
index 4b66523..47613dd 100644
--- a/src/components/ui/waypoint-search.tsx
+++ b/src/components/ui/waypoint-search.tsx
@@ -86,7 +86,6 @@ export const WaypointSearch = ({
{
title: internalValue.trim(),
description: '',
- selected: false,
addresslnglat: result.lngLat,
sourcelnglat: result.lngLat,
displaylnglat: result.lngLat,
diff --git a/src/hooks/use-directions-queries.ts b/src/hooks/use-directions-queries.ts
index 6856ea9..58198c3 100644
--- a/src/hooks/use-directions-queries.ts
+++ b/src/hooks/use-directions-queries.ts
@@ -17,11 +17,17 @@ import { forward_geocode, parseGeocodeResponse } from '@/utils/nominatim';
import { filterProfileSettings } from '@/utils/filter-profile-settings';
import { getDirectionsLanguage } from '@/utils/directions-language';
import { useCommonStore } from '@/stores/common-store';
-import { useDirectionsStore, type Waypoint } from '@/stores/directions-store';
+import {
+ createCoordinateAddress,
+ useDirectionsStore,
+ type Waypoint,
+} from '@/stores/directions-store';
import { router } from '@/routes';
const getActiveWaypoints = (waypoints: Waypoint[]): ActiveWaypoint[] =>
- waypoints.flatMap((wp) => wp.geocodeResults.filter((r) => r.selected));
+ waypoints
+ .map((wp) => wp.selectedAddress)
+ .filter((address) => address !== null);
async function fetchDirections() {
const waypoints = useDirectionsStore.getState().waypoints;
@@ -126,16 +132,10 @@ export function useDirectionsQuery() {
}
export function useSetWaypointFromCoords() {
- const receiveGeocodeResults = useDirectionsStore(
- (state) => state.receiveGeocodeResults
- );
- const updateTextInput = useDirectionsStore((state) => state.updateTextInput);
+ const selectAddress = useDirectionsStore((state) => state.selectAddress);
const addEmptyWaypointToEnd = useDirectionsStore(
(state) => state.addEmptyWaypointToEnd
);
- const updatePlaceholderAddressAtIndex = useDirectionsStore(
- (state) => state.updatePlaceholderAddressAtIndex
- );
const setWaypointFromCoords = async (
lng: number,
@@ -153,27 +153,9 @@ export function useSetWaypointFromCoords() {
}
}
- // Set placeholder immediately
- updatePlaceholderAddressAtIndex(index, lng, lat);
-
- const lngLat: [number, number] = [lng, lat];
- const address: ActiveWaypoint = {
- title: `${lng.toFixed(6)}, ${lat.toFixed(6)}`,
- key: 0,
- selected: true,
- addresslnglat: lngLat,
- sourcelnglat: lngLat,
- displaylnglat: lngLat,
- addressindex: 0,
- };
- const addresses = [address];
- receiveGeocodeResults({ addresses, index });
- updateTextInput({
- inputValue: address.title,
- index,
- addressindex: 0,
- });
- return addresses;
+ const address = createCoordinateAddress(lng, lat);
+ selectAddress({ index, address });
+ return [address];
};
return { setWaypointFromCoords };
@@ -188,7 +170,6 @@ async function fetchForwardGeocode(
{
title: lngLat.toString(),
key: 0,
- selected: false,
addresslnglat: lngLat,
sourcelnglat: lngLat,
displaylnglat: lngLat,
diff --git a/src/hooks/use-isochrones-queries.ts b/src/hooks/use-isochrones-queries.ts
index 4a90a0f..8f49430 100644
--- a/src/hooks/use-isochrones-queries.ts
+++ b/src/hooks/use-isochrones-queries.ts
@@ -24,13 +24,13 @@ import { useIsochronesStore } from '@/stores/isochrones-store';
import { router } from '@/routes';
async function fetchIsochrones() {
- const { geocodeResults, maxRange, interval, denoise, generalize } =
+ const { selectedAddress, maxRange, interval, denoise, generalize } =
useIsochronesStore.getState();
const profile = router.state.location.search.profile;
const { settings: rawSettings } = useCommonStore.getState();
const settings = filterProfileSettings(profile || 'bicycle', rawSettings);
- const center = geocodeResults.find((result) => result.selected);
+ const center = selectedAddress;
if (!center) {
return null;
@@ -142,7 +142,6 @@ export function useReverseGeocodeIsochrones() {
// Set placeholder immediately
const placeholderAddresses: ActiveWaypoint[] = [
{
- selected: true,
title: '',
displaylnglat: [lng, lat],
sourcelnglat: [lng, lat],
@@ -184,7 +183,6 @@ async function fetchForwardGeocode(
{
title: lngLat.toString(),
key: 0,
- selected: false,
addresslnglat: lngLat,
sourcelnglat: lngLat,
displaylnglat: lngLat,
diff --git a/src/hooks/use-optimized-route-query.ts b/src/hooks/use-optimized-route-query.ts
index 78b1d3f..b12c848 100644
--- a/src/hooks/use-optimized-route-query.ts
+++ b/src/hooks/use-optimized-route-query.ts
@@ -27,15 +27,12 @@ export function useOptimizedRouteQuery() {
const mutation = useMutation({
mutationFn: async () => {
- const relevantWaypoints: Waypoint[] = [];
-
- const activeWaypoints = waypoints.flatMap((wp) => {
- const selected = wp.geocodeResults.filter((r) => r.selected);
- if (selected.length > 0) {
- relevantWaypoints.push(wp);
- }
- return selected;
- });
+ const relevantWaypoints: Waypoint[] = waypoints.filter(
+ (wp) => wp.selectedAddress
+ );
+ const activeWaypoints = relevantWaypoints.map(
+ (wp) => wp.selectedAddress!
+ );
if (activeWaypoints.length < 4) {
throw new Error('Not enough waypoints to optimize');
diff --git a/src/stores/directions-store.spec.ts b/src/stores/directions-store.spec.ts
new file mode 100644
index 0000000..fa9a91c
--- /dev/null
+++ b/src/stores/directions-store.spec.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import type { ActiveWaypoint } from '@/components/types';
+import {
+ createCoordinateAddress,
+ defaultWaypoints,
+ useDirectionsStore,
+} from './directions-store';
+
+const berlin: ActiveWaypoint = {
+ title: 'Berlin, Germany',
+ displaylnglat: [13.4, 52.5],
+ sourcelnglat: [13.4, 52.5],
+ key: 0,
+ addressindex: 0,
+};
+
+const munich: ActiveWaypoint = {
+ title: 'Munich, Germany',
+ displaylnglat: [11.58, 48.14],
+ sourcelnglat: [11.58, 48.14],
+ key: 1,
+ addressindex: 1,
+};
+
+const waypointIds = () =>
+ useDirectionsStore.getState().waypoints.map((wp) => wp.id);
+
+describe('directions-store', () => {
+ beforeEach(() => {
+ useDirectionsStore.setState({ waypoints: [...defaultWaypoints] });
+ });
+
+ describe('selectAddress', () => {
+ it('records the address and shows its title as the input value', () => {
+ useDirectionsStore
+ .getState()
+ .selectAddress({ index: 0, address: berlin });
+
+ const waypoint = useDirectionsStore.getState().waypoints[0]!;
+ expect(waypoint.selectedAddress).toEqual(berlin);
+ expect(waypoint.userInput).toBe('Berlin, Germany');
+ });
+
+ it('ignores indices that have no waypoint', () => {
+ useDirectionsStore
+ .getState()
+ .selectAddress({ index: 7, address: berlin });
+
+ expect(useDirectionsStore.getState().waypoints).toHaveLength(2);
+ });
+ });
+
+ describe('receiveGeocodeResults', () => {
+ // Regression: a new (or empty) search used to wipe the selection, which
+ // dropped the waypoint from the route and from the permalink.
+ it('keeps the selected address when new candidates arrive', () => {
+ const { selectAddress, receiveGeocodeResults } =
+ useDirectionsStore.getState();
+ selectAddress({ index: 0, address: berlin });
+
+ receiveGeocodeResults({ index: 0, addresses: [munich] });
+
+ const waypoint = useDirectionsStore.getState().waypoints[0]!;
+ expect(waypoint.geocodeResults).toEqual([munich]);
+ expect(waypoint.selectedAddress).toEqual(berlin);
+ expect(waypoint.userInput).toBe('Berlin, Germany');
+ });
+
+ it('keeps the selected address when a search comes back empty', () => {
+ const { selectAddress, receiveGeocodeResults } =
+ useDirectionsStore.getState();
+ selectAddress({ index: 0, address: berlin });
+
+ receiveGeocodeResults({ index: 0, addresses: [] });
+
+ expect(
+ useDirectionsStore.getState().waypoints[0]!.selectedAddress
+ ).toEqual(berlin);
+ });
+ });
+
+ describe('waypoint ids', () => {
+ // Regression: ids derived from the waypoint count collided after a
+ // removal, which gave two rows the same React key.
+ it('stays unique when a waypoint is removed and another is added', () => {
+ const { addEmptyWaypointToEnd, doRemoveWaypoint } =
+ useDirectionsStore.getState();
+
+ addEmptyWaypointToEnd();
+ addEmptyWaypointToEnd();
+ doRemoveWaypoint({ index: 1 });
+ addEmptyWaypointToEnd();
+
+ const ids = waypointIds();
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+ });
+
+ describe('doRemoveWaypoint', () => {
+ it('clears the last two waypoints in place instead of removing them', () => {
+ const { selectAddress, doRemoveWaypoint } = useDirectionsStore.getState();
+ selectAddress({ index: 0, address: berlin });
+
+ doRemoveWaypoint({ index: 0 });
+
+ const waypoint = useDirectionsStore.getState().waypoints[0]!;
+ expect(useDirectionsStore.getState().waypoints).toHaveLength(2);
+ expect(waypoint.selectedAddress).toBeNull();
+ expect(waypoint.userInput).toBe('');
+ });
+ });
+
+ describe('addWaypointAtIndex', () => {
+ it('inserts a placeholder that is already routable', () => {
+ useDirectionsStore.getState().addWaypointAtIndex({
+ index: 1,
+ placeholder: { lng: 13.4, lat: 52.5 },
+ });
+
+ const waypoint = useDirectionsStore.getState().waypoints[1]!;
+ expect(waypoint.selectedAddress).toEqual(
+ createCoordinateAddress(13.4, 52.5)
+ );
+ expect(waypoint.userInput).toBe('13.400000, 52.500000');
+ });
+ });
+});
diff --git a/src/stores/directions-store.ts b/src/stores/directions-store.ts
index 1be212b..435c183 100644
--- a/src/stores/directions-store.ts
+++ b/src/stores/directions-store.ts
@@ -9,6 +9,7 @@ import { immer } from 'zustand/middleware/immer';
export interface Waypoint {
id: string;
geocodeResults: ActiveWaypoint[];
+ selectedAddress: ActiveWaypoint | null;
userInput: string;
}
@@ -40,6 +41,7 @@ interface LatLng {
const createEmptyWaypoint = (id: string): Waypoint => ({
id,
geocodeResults: [],
+ selectedAddress: null,
userInput: '',
});
@@ -48,23 +50,35 @@ export const defaultWaypoints: Waypoint[] = [
createEmptyWaypoint('1'),
];
+/** An address for a raw coordinate pair, i.e. one that was never geocoded. */
+export const createCoordinateAddress = (
+ lng: number,
+ lat: number
+): ActiveWaypoint => {
+ const lngLat: [number, number] = [lng, lat];
+ return {
+ title: `${lng.toFixed(6)}, ${lat.toFixed(6)}`,
+ addresslnglat: lngLat,
+ sourcelnglat: lngLat,
+ displaylnglat: lngLat,
+ key: 0,
+ addressindex: 0,
+ };
+};
+
const getNextWaypointId = (waypoints: Waypoint[]): string => {
const maxIndex = Math.max(...waypoints.map((wp) => parseInt(wp.id, 10)));
return (isFinite(maxIndex) ? maxIndex + 1 : 0).toString();
};
const hasActiveRoute = (waypoints: Waypoint[]): boolean =>
- waypoints.filter(
- (wp) =>
- wp.geocodeResults.length > 0 && wp.geocodeResults.some((r) => r.selected)
- ).length >= 2;
+ waypoints.filter((wp) => wp.selectedAddress).length >= 2;
export interface DirectionsState {
successful: boolean;
highlightSegment: HighlightSegment;
waypoints: Waypoint[];
zoomObj: ZoomObj;
- selectedAddresses: string | (Waypoint | null)[];
results: RouteResult;
inclineDeclineTotal?: InclineDeclineTotal;
isOptimized: boolean;
@@ -80,11 +94,7 @@ interface DirectionsActions {
index: number;
addresses: ActiveWaypoint[];
}) => void;
- updateTextInput: (params: {
- inputValue: string;
- index: number;
- addressindex?: number;
- }) => void;
+ selectAddress: (params: { index: number; address: ActiveWaypoint }) => void;
clearWaypoints: () => void;
emptyWaypoint: (params: { index: number }) => void;
setWaypoint: (waypoints: Waypoint[]) => void;
@@ -93,11 +103,6 @@ interface DirectionsActions {
doRemoveWaypoint: (params: { index: number }) => void;
highlightManeuver: (fromTo: HighlightSegment) => void;
zoomToManeuver: (zoomObj: ZoomObj) => void;
- updatePlaceholderAddressAtIndex: (
- index: number,
- lng: number,
- lat: number
- ) => void;
setIsOptimized: (isOptimized: boolean) => void;
setActiveRouteIndex: (index: number) => void;
}
@@ -111,7 +116,6 @@ export const useDirectionsStore = create()(
highlightSegment: { startIndex: -1, endIndex: -1, alternate: -1 },
waypoints: defaultWaypoints,
zoomObj: { index: -1, timeNow: -1 },
- selectedAddresses: '',
results: { data: null, show: { '0': true } },
isOptimized: false,
activeRouteIndex: 0,
@@ -173,26 +177,17 @@ export const useDirectionsStore = create()(
'receiveGeocodeResults'
),
- updateTextInput: ({ inputValue, index, addressindex }) =>
+ selectAddress: ({ index, address }) =>
set(
(state) => {
- state.selectedAddresses = state.waypoints.flatMap((wp) =>
- wp.geocodeResults.map((_, i) => (i === addressindex ? wp : null))
- );
-
if (state.waypoints[index]) {
- state.waypoints[index].userInput = inputValue;
- state.waypoints[index].geocodeResults = state.waypoints[
- index
- ].geocodeResults.map((result, j) => ({
- ...result,
- selected: j === addressindex,
- }));
+ state.waypoints[index].selectedAddress = address;
+ state.waypoints[index].userInput = address.title;
state.isOptimized = false;
}
},
undefined,
- 'updateTextInput'
+ 'selectAddress'
),
clearWaypoints: () =>
@@ -211,6 +206,7 @@ export const useDirectionsStore = create()(
if (state.waypoints[index]) {
state.waypoints[index].userInput = '';
state.waypoints[index].geocodeResults = [];
+ state.waypoints[index].selectedAddress = null;
state.isOptimized = false;
}
},
@@ -230,23 +226,18 @@ export const useDirectionsStore = create()(
addWaypointAtIndex: ({ index, placeholder }) =>
set(
(state) => {
- const id = getNextWaypointId(state.waypoints);
-
- const newWaypoint: Waypoint = placeholder
- ? {
- id,
- geocodeResults: [
- {
- title: '',
- displaylnglat: [placeholder.lng, placeholder.lat],
- sourcelnglat: [placeholder.lng, placeholder.lat],
- key: index,
- addressindex: index,
- },
- ],
- userInput: `${placeholder.lng.toFixed(6)}, ${placeholder.lat.toFixed(6)}`,
- }
- : createEmptyWaypoint(id);
+ const newWaypoint = createEmptyWaypoint(
+ getNextWaypointId(state.waypoints)
+ );
+
+ if (placeholder) {
+ const address = createCoordinateAddress(
+ placeholder.lng,
+ placeholder.lat
+ );
+ newWaypoint.selectedAddress = address;
+ newWaypoint.userInput = address.title;
+ }
state.waypoints.splice(index, 0, newWaypoint);
state.isOptimized = false;
@@ -259,7 +250,7 @@ export const useDirectionsStore = create()(
set(
(state) => {
state.waypoints.push(
- createEmptyWaypoint((state.waypoints.length + 1).toString())
+ createEmptyWaypoint(getNextWaypointId(state.waypoints))
);
state.isOptimized = false;
},
@@ -275,6 +266,7 @@ export const useDirectionsStore = create()(
} else if (state.waypoints[index]) {
state.waypoints[index].userInput = '';
state.waypoints[index].geocodeResults = [];
+ state.waypoints[index].selectedAddress = null;
}
state.isOptimized = false;
@@ -313,29 +305,6 @@ export const useDirectionsStore = create()(
'zoomToManeuver'
),
- updatePlaceholderAddressAtIndex: (index, lng, lat) =>
- set(
- (state) => {
- if (state.waypoints[index]) {
- state.waypoints[index].geocodeResults = [
- {
- title: '',
- displaylnglat: [lng, lat],
- sourcelnglat: [lng, lat],
- key: index,
- addressindex: index,
- selected: true,
- },
- ];
- state.waypoints[index].userInput =
- `${lng.toFixed(6)}, ${lat.toFixed(6)}`;
- state.isOptimized = false;
- }
- },
- undefined,
- 'updatePlaceholderAddressAtIndex'
- ),
-
setIsOptimized: (isOptimized) =>
set(
(state) => {
diff --git a/src/stores/isochrones-store.ts b/src/stores/isochrones-store.ts
index 417931d..febbb1a 100644
--- a/src/stores/isochrones-store.ts
+++ b/src/stores/isochrones-store.ts
@@ -94,10 +94,6 @@ export const useIsochronesStore = create()(
state.geocodeResults[addressIndex]
) {
state.selectedAddress = state.geocodeResults[addressIndex];
- state.geocodeResults = state.geocodeResults.map((result, i) => ({
- ...result,
- selected: i === addressIndex,
- }));
}
},
undefined,
diff --git a/src/utils/nominatim.ts b/src/utils/nominatim.ts
index 846a34d..d5e9d5e 100644
--- a/src/utils/nominatim.ts
+++ b/src/utils/nominatim.ts
@@ -51,7 +51,6 @@ export const parseGeocodeResponse = (
processedResults.push({
title: lngLat?.toString() || '',
description: '',
- selected: true,
addresslnglat: '',
sourcelnglat: lngLat,
displaylnglat: lngLat,
@@ -65,7 +64,6 @@ export const parseGeocodeResponse = (
? result.display_name
: lngLat?.toString() || '',
description: `https://www.openstreetmap.org/${result.osm_type}/${result.osm_id}`,
- selected: false,
addresslnglat: [parseFloat(result.lon), parseFloat(result.lat)],
sourcelnglat:
lngLat === undefined
diff --git a/src/utils/valhalla.spec.ts b/src/utils/valhalla.spec.ts
index ca88150..4fd00a9 100644
--- a/src/utils/valhalla.spec.ts
+++ b/src/utils/valhalla.spec.ts
@@ -168,7 +168,6 @@ describe('valhalla.ts', () => {
{
title: 'Start',
description: 'Starting point',
- selected: true,
addresslnglat: [-74.006, 40.7128],
sourcelnglat: [-74.006, 40.7128],
displaylnglat: [-74.006, 40.7128],
@@ -178,7 +177,6 @@ describe('valhalla.ts', () => {
{
title: 'Via',
description: 'Via point',
- selected: true,
addresslnglat: [-118.2437, 34.0522],
sourcelnglat: [-118.2437, 34.0522],
displaylnglat: [-118.2437, 34.0522],
@@ -188,7 +186,6 @@ describe('valhalla.ts', () => {
{
title: 'End',
description: 'Ending point',
- selected: true,
addresslnglat: [-87.6298, 41.8781],
sourcelnglat: [-87.6298, 41.8781],
displaylnglat: [-87.6298, 41.8781],
@@ -462,7 +459,6 @@ describe('valhalla.ts', () => {
{
title: 'Start',
description: 'Starting point',
- selected: true,
addresslnglat: [-74.006, 40.7128],
sourcelnglat: [-74.006, 40.7128],
displaylnglat: [-74.006, 40.7128],
@@ -472,7 +468,6 @@ describe('valhalla.ts', () => {
{
title: 'Via 1',
description: 'Via point 1',
- selected: true,
addresslnglat: [-73.99, 40.75],
sourcelnglat: [-73.99, 40.75],
displaylnglat: [-73.99, 40.75],
@@ -482,7 +477,6 @@ describe('valhalla.ts', () => {
{
title: 'Via 2',
description: 'Via point 2',
- selected: true,
addresslnglat: [-73.98, 40.755],
sourcelnglat: [-73.98, 40.755],
displaylnglat: [-73.98, 40.755],
@@ -492,7 +486,6 @@ describe('valhalla.ts', () => {
{
title: 'End',
description: 'Ending point',
- selected: true,
addresslnglat: [-87.6298, 41.8781],
sourcelnglat: [-87.6298, 41.8781],
displaylnglat: [-87.6298, 41.8781],
@@ -715,7 +708,6 @@ describe('valhalla.ts', () => {
const mockCenter: import('@/components/types').Center = {
title: 'Center',
description: 'Center point',
- selected: true,
addresslnglat: [-118.2437, 34.0522],
sourcelnglat: [-118.2437, 34.0522],
displaylnglat: [-118.2437, 34.0522],
@@ -973,7 +965,6 @@ describe('valhalla.ts', () => {
{
title: 'Start',
description: 'Starting point',
- selected: true,
addresslnglat: [-74.006, 40.7128],
sourcelnglat: [-74.006, 40.7128],
displaylnglat: [-74.006, 40.7128],
@@ -983,7 +974,6 @@ describe('valhalla.ts', () => {
{
title: 'Via 1',
description: 'Via point 1',
- selected: true,
addresslnglat: [-118.2437, 34.0522],
sourcelnglat: [-118.2437, 34.0522],
displaylnglat: [-118.2437, 34.0522],
@@ -993,7 +983,6 @@ describe('valhalla.ts', () => {
{
title: 'Via 2',
description: 'Via point 2',
- selected: true,
addresslnglat: [-87.6298, 41.8781],
sourcelnglat: [-87.6298, 41.8781],
displaylnglat: [-87.6298, 41.8781],
@@ -1003,7 +992,6 @@ describe('valhalla.ts', () => {
{
title: 'End',
description: 'Ending point',
- selected: true,
addresslnglat: [-122.4194, 37.7749],
sourcelnglat: [-122.4194, 37.7749],
displaylnglat: [-122.4194, 37.7749],