From b2ecf0e892efc30029485539f984db9da59d8f7e Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 8 Oct 2026 00:48:49 +0200 Subject: [PATCH 1/2] feat(navigation): evaluate network detours for route stops --- .changeset/route-stop-network-detours.md | 7 + .../navigation/RouteSearchControl.test.tsx | 153 +++++++++ .../navigation/RouteSearchControl.tsx | 94 +++++- .../RouteSearchResultsLayer.test.tsx | 35 ++ .../navigation/RouteSearchResultsLayer.tsx | 26 +- apps/web/src/lib/navigation/routeStopCopy.ts | 20 ++ .../lib/navigation/useRouteSearch.test.tsx | 294 ++++++++++++++++- apps/web/src/lib/navigation/useRouteSearch.ts | 312 ++++++++++++++++-- docs/docs/developer/navigation-performance.md | 39 +++ docs/docs/features/directions.md | 38 +++ .../routing/__tests__/provider-pin.test.ts | 95 ++++++ integrations/routing/directions-request.ts | 6 + integrations/routing/index.ts | 6 + packages/core/src/api/directions.test.ts | 21 ++ packages/core/src/api/directions.ts | 4 + packages/core/src/index.ts | 16 + .../__tests__/routeStopDetours.test.ts | 201 +++++++++++ packages/core/src/navigation/index.ts | 16 + .../core/src/navigation/routeStopDetours.ts | 215 ++++++++++++ .../core/src/navigation/searchAlongRoute.ts | 5 + packages/core/src/stores/navigationStore.ts | 5 +- packages/core/src/types/category.ts | 2 + packages/i18n/locales/de.json | 14 + packages/i18n/locales/en.json | 14 + 24 files changed, 1593 insertions(+), 45 deletions(-) create mode 100644 .changeset/route-stop-network-detours.md create mode 100644 apps/web/src/components/navigation/RouteSearchControl.test.tsx create mode 100644 apps/web/src/lib/navigation/routeStopCopy.ts create mode 100644 integrations/routing/__tests__/provider-pin.test.ts create mode 100644 packages/core/src/navigation/__tests__/routeStopDetours.test.ts create mode 100644 packages/core/src/navigation/routeStopDetours.ts diff --git a/.changeset/route-stop-network-detours.md b/.changeset/route-stop-network-detours.md new file mode 100644 index 000000000..bfdead7cc --- /dev/null +++ b/.changeset/route-stop-network-detours.md @@ -0,0 +1,7 @@ +--- +"@openmapx/core": minor +"@openmapx/i18n": patch +--- + +Add bounded remaining-itinerary network detour evaluation and ordered stop insertion, +explicit entrance fallback, confidence labels, and cancellable provider-pinned requests. diff --git a/apps/web/src/components/navigation/RouteSearchControl.test.tsx b/apps/web/src/components/navigation/RouteSearchControl.test.tsx new file mode 100644 index 000000000..bac19739f --- /dev/null +++ b/apps/web/src/components/navigation/RouteSearchControl.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import { type AlongRoutePoi, type CategoryPlace, useNavigationStore } from "@openmapx/core"; +import en from "@openmapx/i18n/locales/en.json"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ + results: [] as AlongRoutePoi[], + addStop: vi.fn(), + cancel: vi.fn(), +})); +vi.mock("@/lib/navigation/useRouteSearch", () => ({ + routeSearchQueryFor: (value: unknown) => value, + useRouteSearch: () => ({ + results: fixture.results, + isLoading: false, + addStop: fixture.addStop, + cancelAddStop: fixture.cancel, + }), +})); +vi.mock("@/integration-api/map/MapContext", () => ({ useMapOptional: () => null })); +vi.mock("@openmapx/core", async (original) => ({ + ...(await original()), + useBrandSuggest: () => ({ data: undefined }), +})); +vi.mock("./RouteSearchResultsLayer", () => ({ + RouteSearchResultsLayer: ({ + results, + onSelect, + }: { + results: AlongRoutePoi[]; + onSelect: (poi: AlongRoutePoi) => void; + }) => ( + + ), +})); + +import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; +import { RouteSearchControl } from "./RouteSearchControl"; + +function view() { + return ( + + + + ); +} +const poi = () => + ({ + place: { id: "stop", name: "River services", coordinates: [0.001, 0.001] }, + alongMeters: 100, + deviationMeters: 20, + detourSeconds: 3, + detourMeters: 40, + }) as AlongRoutePoi; +beforeEach(() => { + fixture.results = [poi()]; + fixture.cancel.mockReset(); + fixture.addStop.mockReset(); + useRouteSearchStore.getState().reset(); + useRouteSearchStore.getState().setCategoryKey("preset:amenity/fuel"); +}); +describe("route stop confidence", () => { + it("exits search after its successful add changes the active route", async () => { + let finish!: (value: boolean) => void; + fixture.addStop.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + act(() => + useNavigationStore.setState({ + route: { + geometry: [ + [0, 0], + [0.002, 0], + ], + } as never, + }), + ); + await act(async () => finish(true)); + expect(useRouteSearchStore.getState().categoryKey).toBeNull(); + }); + it("cancels a pending request when the selected entrance changes", () => { + fixture.addStop.mockReturnValue(new Promise(() => {})); + const { rerender } = render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fixture.cancel.mockClear(); + fixture.results = [{ ...poi(), place: { ...poi().place, routingEntrance: [0.003, 0] } }]; + rerender(view()); + expect(fixture.cancel).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(false); + }); + it("labels approximation and coordinate fallback", () => { + render(view()); + fireEvent.click(screen.getByText("Select stop")); + expect(screen.getByText(/straight-line estimate/)).toBeTruthy(); + expect(screen.getByText(/entrance unknown/)).toBeTruthy(); + }); + it("updates the selected card and disables unreachable stops", () => { + const { rerender } = render(view()); + fireEvent.click(screen.getByText("Select stop")); + fixture.results = [ + { + ...poi(), + detour: { + id: "stop", + kind: "network", + seconds: 600, + meters: 4000, + provider: "routing-fixture", + access: { kind: "entrance", coordinates: [0.002, 0] }, + waypoints: [], + }, + }, + ]; + rerender(view()); + expect(screen.getByText("+10 min by road")).toBeTruthy(); + expect(screen.getByText("+4.0 km")).toBeTruthy(); + expect(screen.getByText("Routing to known entrance")).toBeTruthy(); + fixture.results = [ + { + ...poi(), + detour: { + id: "stop", + kind: "unreachable", + access: { kind: "coordinate", coordinates: [0.001, 0.001] }, + waypoints: [], + }, + }, + ]; + rerender(view()); + expect(screen.getByText("No route to this stop")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(true); + }); + it("cancels a pending selection when the card is dismissed", () => { + fixture.addStop.mockReturnValue(new Promise(() => {})); + render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fixture.cancel.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(fixture.cancel).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/navigation/RouteSearchControl.tsx b/apps/web/src/components/navigation/RouteSearchControl.tsx index 7b14cb538..ecdec6ef6 100644 --- a/apps/web/src/components/navigation/RouteSearchControl.tsx +++ b/apps/web/src/components/navigation/RouteSearchControl.tsx @@ -33,6 +33,7 @@ import { BrandLogo } from "@/components/search/BrandLogo"; import { useMapOptional } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; +import { routeStopCopy } from "@/lib/navigation/routeStopCopy"; import { routeSearchQueryFor, useRouteSearch } from "@/lib/navigation/useRouteSearch"; import { RouteSearchResultsLayer } from "./RouteSearchResultsLayer"; @@ -101,8 +102,10 @@ export function RouteSearchControl() { const setBrand = useRouteSearchStore((s) => s.setBrand); const resetStore = useRouteSearchStore((s) => s.reset); const setCameraMode = useNavigationStore((s) => s.setCameraMode); - const [selected, setSelected] = useState | null>(null); + const [selectedId, setSelectedId] = useState(null); const [adding, setAdding] = useState(false); + const [addFailed, setAddFailed] = useState(false); + const selectionIntent = useRef(0); const [brandQuery, setBrandQuery] = useState(""); const debouncedBrandQuery = useDebounce(brandQuery, 200); const { data: brandSuggestData } = useBrandSuggest(debouncedBrandQuery); @@ -119,7 +122,33 @@ export function RouteSearchControl() { : brand ? routeSearchQueryFor({ brand }) : null; - const { results, isLoading, addStop } = useRouteSearch(query); + const { results, isLoading, addStop, cancelAddStop } = useRouteSearch(query); + const selected = results.find((poi) => poi.place.id === selectedId) ?? null; + const copy = selected ? routeStopCopy(selected) : null; + // A new search or route must retire any pending selection in this sheet. + const route = useNavigationStore((s) => s.route); + const session = useNavigationStore((s) => s.navigationStartedAtMs); + useEffect(() => { + void activeKey; + void route; + void session; + selectionIntent.current++; + setSelectedId(null); + setAdding(false); + setAddFailed(false); + cancelAddStop(); + }, [activeKey, route, session, cancelAddStop]); + const selectedTarget = selected?.place.routingEntrance ?? selected?.place.coordinates; + const targetLng = selectedTarget?.[0]; + const targetLat = selectedTarget?.[1]; + useEffect(() => { + void targetLng; + void targetLat; + selectionIntent.current++; + cancelAddStop(); + setAdding(false); + setAddFailed(false); + }, [targetLng, targetLat, cancelAddStop]); const handleSelectBrand = (b: BrandSummary) => { setBrand(b); @@ -174,13 +203,21 @@ export function RouteSearchControl() { }, [searching, results]); const reset = () => { + selectionIntent.current++; + cancelAddStop(); resetStore(); - setSelected(null); + setSelectedId(null); + setAdding(false); + setAddFailed(false); setBrandQuery(""); }; const handleSelect = (poi: AlongRoutePoi) => { - setSelected(poi); + selectionIntent.current++; + cancelAddStop(); + setSelectedId(poi.place.id); + setAdding(false); + setAddFailed(false); mapCtx?.flyTo(poi.place.coordinates, 15); }; @@ -190,10 +227,19 @@ export function RouteSearchControl() { const handleAdd = async () => { if (!selected) return; + const intent = ++selectionIntent.current; + setAddFailed(false); setAdding(true); - const ok = await addStop(selected.place.coordinates); + const ok = await addStop(selected.place); + // Success means this selection committed. Its route change also retires + // the card's intent, so complete search before guarding stale failures. + if (ok) { + reset(); + return; + } + if (intent !== selectionIntent.current) return; setAdding(false); - if (ok) reset(); + setAddFailed(true); }; return ( @@ -378,13 +424,41 @@ export function RouteSearchControl() { {selected.place.name} - - {t("rsDetour", { minutes: Math.max(1, Math.round(selected.detourSeconds / 60)) })} + + {copy && t(copy.key, copy.values)} + {selected.detour?.kind === "network" && ( + + {t("rsDetourDistance", { + kilometers: (selected.detour.meters / 1000).toFixed(1), + })} + + )} + + {t( + selected.detour?.access.kind === "entrance" || selected.place.routingEntrance + ? "rsDetourEntrance" + : "rsDetourCoordinate", + )} + + {addFailed && ( + + {t("rsAddFailed")} + + )} -