diff --git a/.changeset/extension-cli-bundled-navigation-cleanup.md b/.changeset/extension-cli-bundled-navigation-cleanup.md
new file mode 100644
index 000000000..b494261e4
--- /dev/null
+++ b/.changeset/extension-cli-bundled-navigation-cleanup.md
@@ -0,0 +1,5 @@
+---
+"@openmapx/extension-cli": patch
+---
+
+Refresh bundled workspace code after retiring experimental navigation previews and preserving conservative lane-guidance semantics. Extension scaffolding, validation and packaging commands and their shipped templates are unchanged.
diff --git a/apps/web/src/components/map/layers/NavManeuverArrowLayer.test.tsx b/apps/web/src/components/map/layers/NavManeuverArrowLayer.test.tsx
index f4a919917..c75f60dc4 100644
--- a/apps/web/src/components/map/layers/NavManeuverArrowLayer.test.tsx
+++ b/apps/web/src/components/map/layers/NavManeuverArrowLayer.test.tsx
@@ -66,6 +66,7 @@ function features(): GeoJSON.Feature[] {
}
afterEach(() => {
+ navState.route = route;
navState.progress = {
currentStepIndex: 0,
distanceToNextManeuver: 300,
@@ -100,6 +101,25 @@ describe("NavManeuverArrowLayer", () => {
expect(typeof point?.properties?.bearing).toBe("number");
});
+ it("centres the map arrow on the same physical maneuver as the countdown", () => {
+ navState.route = {
+ ...route,
+ steps: route.steps.map((step, i) => ({
+ ...step,
+ coordinates:
+ i === 0
+ ? [geometry[0], geometry[1]]
+ : i === 1
+ ? [geometry[1], geometry[2]]
+ : [geometry[2]],
+ })),
+ };
+ render();
+ const line = features().find(
+ (feature) => feature.geometry.type === "LineString",
+ ) as GeoJSON.Feature;
+ expect(line.geometry.coordinates.some(([lng, lat]) => lng === 8 && lat === 50.01)).toBe(true);
+ });
it("empties the source outside the approach window", () => {
navState.progress = {
currentStepIndex: 0,
diff --git a/apps/web/src/components/map/layers/NavManeuverArrowLayer.tsx b/apps/web/src/components/map/layers/NavManeuverArrowLayer.tsx
index ce5feee51..0e3a0bf8e 100644
--- a/apps/web/src/components/map/layers/NavManeuverArrowLayer.tsx
+++ b/apps/web/src/components/map/layers/NavManeuverArrowLayer.tsx
@@ -7,7 +7,7 @@ import {
maneuverArrowLine,
maneuverArrowSpans,
maneuverArrowTipBearing,
- stepStartMeters,
+ maneuverStartMeters,
upcomingManeuverIndex,
useNavigationStore,
} from "@openmapx/core";
@@ -62,7 +62,7 @@ export function NavManeuverArrowLayer() {
if (!step?.maneuver || NON_JUNCTION_TYPES.has(step.maneuver.type))
return EMPTY_FEATURE_COLLECTION;
const cum = cumulativeDistances(route.geometry);
- const alongMeters = stepStartMeters(route.steps, upcomingIndex);
+ const alongMeters = maneuverStartMeters(route, upcomingIndex);
const spans: ManeuverArrowSpans = maneuverArrowSpans(mode);
const line = maneuverArrowLine(route.geometry, cum, alongMeters, spans);
if (!line || line.length < 2) return EMPTY_FEATURE_COLLECTION;
diff --git a/apps/web/src/components/navigation/GroundNavigationChrome.layout.test.tsx b/apps/web/src/components/navigation/GroundNavigationChrome.layout.test.tsx
deleted file mode 100644
index e51755f7b..000000000
--- a/apps/web/src/components/navigation/GroundNavigationChrome.layout.test.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-import type { Route } from "@integrations/routing/types";
-import { findJunctionDecisionPoints, useNavigationStore, useSettingsStore } from "@openmapx/core";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { act, cleanup, render, screen } from "@testing-library/react";
-import { expect, it, vi } from "vitest";
-import { useNavJunctionStore } from "@/lib/navigation/junctionStore";
-import { installMeasuredLayout } from "@/test/measuredLayout";
-import routeFixture from "../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json";
-
-vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
-
-const mapCtx = { fitBounds: vi.fn() };
-vi.mock("@/integration-api/map/MapContext", async () => ({
- ...(await vi.importActual(
- "@/integration-api/map/MapContext",
- )),
- useMapOptional: () => mapCtx,
-}));
-
-// Keep the actual chrome, junction, speed and bottom-bar consumers; external
-// controls/dialogs are irrelevant to the measured photo boundary.
-vi.mock("./NavMenu", () => ({ NavMenu: () => null }));
-vi.mock("./NavManeuverSlot", () => ({ NavManeuverSlot: () => null }));
-
-vi.mock("./NavOfflineBannerSlot", () => ({ NavOfflineBannerSlot: () => null }));
-vi.mock("./NavAlertSlot", () => ({ NavAlertSlot: () => null }));
-vi.mock("./NavStatusSlot", () => ({ NavStatusSlot: () => null }));
-
-vi.mock("./FasterRouteBanner", () => ({ FasterRouteBanner: () => null }));
-vi.mock("./ArrivalCard", () => ({ ArrivalCard: () => null }));
-vi.mock("./NavDirectionsDialog", () => ({ NavDirectionsDialog: () => null }));
-vi.mock("./NavPerfControl", () => ({ NavPerfControl: () => null }));
-vi.mock("./NavSimControl", () => ({ NavSimControl: () => null }));
-vi.mock("./RouteSearchControl", () => ({ RouteSearchControl: () => null }));
-vi.mock("@/components/settings/NavigationSettingsDialog", () => ({
- NavigationSettingsDialog: () => null,
-}));
-
-const { GroundNavigationChrome } = await import("./GroundNavigationChrome");
-const { MobileRuntimeProvider } = await import("@/lib/mobile/MobileRuntimeProvider");
-
-const route = routeFixture.route as unknown as Route;
-const points = findJunctionDecisionPoints(route);
-
-it("uses the real measured shell budget to replace an overflowing junction photo with a schematic and restore it", () => {
- let shellHeight = 900;
- const layout = installMeasuredLayout((el) => {
- const marker = el.dataset.testid;
- if (marker === "ground-nav-chrome") return new DOMRect(0, 0, 390, shellHeight);
- if (marker === "ground-nav-bottom") return new DOMRect(0, 10000, 390, 10);
- if (marker === "nav-speed-limit") {
- return getComputedStyle(el).display === "none" ? new DOMRect() : new DOMRect(0, 0, 64, 64);
- }
- if (marker === "ground-nav-bottom-header") return new DOMRect(0, 0, 390, 96);
- if (marker === "junction-photo-measurement") return new DOMRect(0, 0, 358, 500);
- const panel = document.querySelector('[data-testid="junction-view-panel"]');
- const hasPhoto = panel?.querySelector(':scope > [role="img"] [data-testid="junction-photo"]');
- const visibleHeight = hasPhoto ? 500 : 240;
- if (marker === "junction-view-panel") return new DOMRect(0, 150, 358, visibleHeight);
- if (marker === "ground-nav-banner") return new DOMRect(0, 0, 390, 150 + visibleHeight + 24);
- return new DOMRect();
- });
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- useSettingsStore.setState({ junctionView: true, junctionPhotos: true });
- useNavigationStore.setState({
- status: "navigating",
- kind: "ground",
- mode: "driving",
- route,
- currentSpeedLimit: 80,
- progress: { currentStepIndex: 0, distanceToNextManeuver: 400, speedMps: 30 } as never,
- });
- useNavJunctionStore.getState().setDecisionPoints("layout-route", points);
- const point = points[0];
- if (!point) throw new Error("Fixture must contain a junction");
- useNavJunctionStore.getState().setPhoto(point.stepIndex, {
- status: "ready",
- objectUrl: "blob:layout",
- image: {
- id: "layout-image",
- providerId: "panoramax",
- lngLat: [6.679, 51.1786],
- heading: 283,
- isPano: false,
- fovDeg: 70,
- assets: {},
- contributors: [
- {
- sourceId: "captured",
- name: "Photographer",
- attributionText: "Full required image notice",
- },
- ],
- },
- });
- try {
- const view = render(
-
-
-
-
- ,
- );
- expect(screen.getByRole("contentinfo").textContent).toContain("Full required image notice");
- act(() => {
- shellHeight = 844;
- layout.resize();
- });
- expect(screen.queryByRole("contentinfo")).toBeNull();
- expect(view.container.querySelector('[data-testid="junction-schematic"]')).not.toBeNull();
- // At this shell height, reclaiming the badge's 64 px lets the photo fit.
- // Showing the badge again must reserve that space and hide the photo.
- act(() => useNavigationStore.setState({ currentSpeedLimit: null }));
- act(() => layout.resize());
- expect(screen.getByRole("contentinfo").textContent).toContain("Full required image notice");
- act(() => useNavigationStore.setState({ currentSpeedLimit: 80 }));
- act(() => layout.resize());
- expect(screen.queryByRole("contentinfo")).toBeNull();
- expect(view.container.querySelector('[data-testid="junction-schematic"]')).not.toBeNull();
- act(() => {
- shellHeight = 300;
- layout.resize();
- layout.resize();
- });
- expect(screen.queryByRole("contentinfo")).toBeNull();
- act(() => {
- shellHeight = 900;
- layout.resize();
- });
- expect(screen.getByRole("contentinfo").textContent).toContain("Full required image notice");
- act(() => useNavigationStore.setState({ route: null }));
- expect(view.container.querySelector('[data-testid="junction-view-panel"]')).toBeNull();
- expect(screen.queryByRole("contentinfo")).toBeNull();
- } finally {
- cleanup();
- layout.restore();
- useNavigationStore.getState().stopNavigation();
- useNavJunctionStore.getState().reset();
- }
-});
diff --git a/apps/web/src/components/navigation/GroundNavigationChrome.test.tsx b/apps/web/src/components/navigation/GroundNavigationChrome.test.tsx
index 2d18889b8..125844779 100644
--- a/apps/web/src/components/navigation/GroundNavigationChrome.test.tsx
+++ b/apps/web/src/components/navigation/GroundNavigationChrome.test.tsx
@@ -29,7 +29,6 @@ vi.mock("./NavBottomBarSlot", () => ({
NavBottomBarSlot: ({ menuToggle }: { menuToggle?: ReactNode }) => {menuToggle}
,
}));
vi.mock("./NavManeuverSlot", () => ({ NavManeuverSlot: () => null }));
-vi.mock("./JunctionViewSlot", () => ({ JunctionViewSlot: () => null }));
vi.mock("./NavOfflineBannerSlot", () => ({ NavOfflineBannerSlot: () => null }));
vi.mock("./NavAlertSlot", () => ({ NavAlertSlot: () => null }));
vi.mock("./NavStatusSlot", () => ({ NavStatusSlot: () => null }));
diff --git a/apps/web/src/components/navigation/GroundNavigationChrome.tsx b/apps/web/src/components/navigation/GroundNavigationChrome.tsx
index 5864865e9..11b4f41a0 100644
--- a/apps/web/src/components/navigation/GroundNavigationChrome.tsx
+++ b/apps/web/src/components/navigation/GroundNavigationChrome.tsx
@@ -22,19 +22,13 @@ import { NavigationSettingsDialog } from "@/components/settings/NavigationSettin
import { useMapOptional } from "@/integration-api/map/MapContext";
import { NAV_CHROME_TOP, NAV_LANDSCAPE_PANEL_WIDTH } from "@/lib/layout";
import { useNavigationMutations } from "@/lib/mobile/useNavigationMutations";
-import {
- useMobilePanelClearance,
- useMobilePanelOccupiedHeight,
- useWindowHeight,
-} from "@/lib/mobilePanelHeight";
+import { useMobilePanelClearance, useWindowHeight } from "@/lib/mobilePanelHeight";
import type { OfflineRouteCoverage } from "@/lib/navigation/offlineRouteCoverage";
-import { useJunctionPreviewBudget } from "@/lib/navigation/useJunctionPreviewBudget";
import { useNavChromeObstructions } from "@/lib/navigation/useNavChromeObstructions";
import { useSheetFollowTrack } from "@/lib/sheetFollow";
import { ArrivalCard } from "./ArrivalCard";
import { ARRIVAL_WAYPOINT_MATCH_TOLERANCE_METERS } from "./arrivalHandoffState";
import { FasterRouteBanner } from "./FasterRouteBanner";
-import { JunctionViewSlot } from "./JunctionViewSlot";
import { NavAlertSlot } from "./NavAlertSlot";
import { NavBottomBarSlot } from "./NavBottomBarSlot";
import { NavDirectionsDialog } from "./NavDirectionsDialog";
@@ -83,11 +77,6 @@ export function GroundNavigationChrome({ coverage }: Props) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
- const [shellEl, setShellEl] = useState(null);
- const [bottomEl, setBottomEl] = useState(null);
- const [junctionEl, setJunctionEl] = useState(null);
- const [speedLimitEl, setSpeedLimitEl] = useState(null);
- const [bottomHeaderEl, setBottomHeaderEl] = useState(null);
const [bannerEl, setBannerEl] = useState(null);
useNavChromeObstructions("ground", { isMobile, arrived: status === "arrived", bannerEl });
@@ -106,21 +95,6 @@ export function GroundNavigationChrome({ coverage }: Props) {
const vh = useWindowHeight();
const sheetClearance = useMobilePanelClearance(vh);
const followTrack = useSheetFollowTrack();
- const sheetOccupiedHeight = useMobilePanelOccupiedHeight();
- const junctionAvailableHeight = useJunctionPreviewBudget({
- shell: shellEl,
- banner: bannerEl,
- junction: junctionEl,
- bottom: bottomEl,
- speedLimit: speedLimitEl,
- bottomHeader: bottomHeaderEl,
- isMobile,
- sheetClearance,
- // Matches NavSpeedLimitSlot: the badge rides a driving sheet by translation.
- speedLimitRidesSheet: isMobile && followTrack !== null,
- sheetOccupiedHeight,
- mobilePanelPending: isMobile && !!route && sheetOccupiedHeight === 0,
- });
const rerouting = status === "rerouting";
@@ -173,7 +147,6 @@ export function GroundNavigationChrome({ coverage }: Props) {
return (
-
{rerouting && (
@@ -263,7 +235,6 @@ export function GroundNavigationChrome({ coverage }: Props) {
{/* Bottom region: speed limit sits just above the panel, on the left. */}
-
+
{route &&
(isMobile ? (
+
}
@@ -313,11 +280,7 @@ export function GroundNavigationChrome({ coverage }: Props) {
overflow: "hidden",
}}
>
-
+
{/* Header stays pinned; the expanded menu scrolls. */}
diff --git a/apps/web/src/components/navigation/GroundNavigationRuntime.test.tsx b/apps/web/src/components/navigation/GroundNavigationRuntime.test.tsx
index 83521d2ea..be9bc9790 100644
--- a/apps/web/src/components/navigation/GroundNavigationRuntime.test.tsx
+++ b/apps/web/src/components/navigation/GroundNavigationRuntime.test.tsx
@@ -15,8 +15,8 @@ vi.mock("@/lib/navigation/useNavCamera", () => ({
calls.camera += 1;
},
}));
-vi.mock("@/lib/navigation/useNavJunctions", () => ({
- useNavJunctions: () => {},
+vi.mock("@/lib/navigation/useNavLaneGuidance", () => ({
+ useNavLaneGuidance: () => {},
}));
vi.mock("@/lib/useWakeLock", () => ({
useWakeLock: (enabled: boolean) => {
diff --git a/apps/web/src/components/navigation/GroundNavigationRuntime.tsx b/apps/web/src/components/navigation/GroundNavigationRuntime.tsx
index ef610978f..6c6a5dc8a 100644
--- a/apps/web/src/components/navigation/GroundNavigationRuntime.tsx
+++ b/apps/web/src/components/navigation/GroundNavigationRuntime.tsx
@@ -5,7 +5,7 @@ import { useNavIncidentResource } from "@openmapx/integration-framework/react";
import { useMobileRuntime } from "@/lib/mobile/useMobileRuntime";
import { useNavCamera } from "@/lib/navigation/useNavCamera";
import { useNavigationEngine } from "@/lib/navigation/useNavigationEngine";
-import { useNavJunctions } from "@/lib/navigation/useNavJunctions";
+import { useNavLaneGuidance } from "@/lib/navigation/useNavLaneGuidance";
import { useWakeLock } from "@/lib/useWakeLock";
/**
@@ -33,7 +33,7 @@ export function GroundNavigationRuntime() {
// store says what the shell says. Rendering is the one thing both authorities
// share, so it stays here rather than inside the browser-only child.
useNavCamera();
- useNavJunctions();
+ useNavLaneGuidance();
return browserAuthority ? : null;
}
diff --git a/apps/web/src/components/navigation/JunctionViewSlot.test.tsx b/apps/web/src/components/navigation/JunctionViewSlot.test.tsx
deleted file mode 100644
index 7dc55f670..000000000
--- a/apps/web/src/components/navigation/JunctionViewSlot.test.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import type { Route } from "@openmapx/core";
-import { useNavigationStore, useSettingsStore } from "@openmapx/core";
-import { licensingFromDeclaration } from "@openmapx/mobility-core/licensing";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { act, cleanup, render } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
-
-const { JunctionViewSlot } = await import("./JunctionViewSlot");
-const { useNavJunctionStore } = await import("@/lib/navigation/junctionStore");
-const fixture = await import(
- "../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json"
-);
-const { findJunctionDecisionPoints } = await import("@openmapx/core");
-
-const a57Route = fixture.route as unknown as Route;
-const decisionPoints = findJunctionDecisionPoints(a57Route);
-
-function populateStore(): void {
- useNavJunctionStore.getState().setDecisionPoints("route-1", decisionPoints);
-}
-
-const baseProgress = {
- currentStepIndex: 0,
- distanceToNextManeuver: 400,
- distanceRemaining: 1400,
- durationRemaining: 80,
- snapped: [6.676, 51.179] as [number, number],
- alongMeters: 1000,
- deviationMeters: 0,
- segmentIndex: 0,
- etaEpochMs: 0,
- bearing: 283,
- speedMps: 30,
-};
-
-function setState(overrides: Record = {}): void {
- useNavigationStore.setState({
- kind: "ground",
- status: "navigating",
- mode: "driving",
- route: a57Route,
- progress: baseProgress,
- ...overrides,
- });
-}
-
-function mount() {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render(
-
-
- ,
- );
-}
-
-describe("JunctionViewSlot", () => {
- beforeEach(() => {
- useSettingsStore.setState({ junctionView: true, junctionPhotos: true });
- useNavJunctionStore.getState().reset();
- populateStore();
- });
-
- afterEach(() => {
- cleanup();
- useNavigationStore.getState().stopNavigation();
- useNavJunctionStore.getState().reset();
- });
-
- it("renders the panel inside the approach window before the exit", () => {
- setState();
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeTruthy();
- });
-
- it("holds the photo back until the exit is close, showing the gantry meanwhile", () => {
- const photo = {
- status: "ready" as const,
- image: {
- id: "photo-1",
- providerId: "panoramax",
- lngLat: [6.678013787, 51.1787552] as [number, number],
- heading: 282,
- capturedAt: "2019-09-10T06:24:34Z",
- isPano: false,
- fovDeg: 70,
- assets: {},
- author: "motocultrice",
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined),
- },
- objectUrl: "blob:photo-1",
- };
- useNavJunctionStore.getState().setPhoto(decisionPoints[0].stepIndex, photo);
- // 2 km out at motorway speed: inside the guidance window, well outside the
- // photo's own, so the card shows the schematic.
- setState({
- progress: { ...baseProgress, distanceToNextManeuver: 2000 },
- });
- const far = mount();
- expect(far.queryByTestId("junction-view-panel")).toBeTruthy();
- expect(far.queryByTestId("junction-photo")).toBeNull();
- cleanup();
-
- setState({ progress: { ...baseProgress, distanceToNextManeuver: 700 } });
- const near = mount();
- expect(near.queryByTestId("junction-photo")).toBeTruthy();
- });
-
- it("renders nothing far from the maneuver", () => {
- setState({
- progress: {
- currentStepIndex: 0,
- distanceToNextManeuver: 5000,
- distanceRemaining: 1400,
- durationRemaining: 80,
- snapped: [6.676, 51.179],
- alongMeters: 0,
- deviationMeters: 0,
- segmentIndex: 0,
- etaEpochMs: 0,
- bearing: 283,
- speedMps: 30,
- },
- });
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-
- it("renders nothing when junctionView is off", () => {
- setState();
- useSettingsStore.setState({ junctionView: false });
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-
- it("hides the moment the step advances past the exit", () => {
- setState({ progress: { currentStepIndex: 1, distanceToNextManeuver: 200 } });
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-
- it("renders nothing for cycling", () => {
- setState({ mode: "cycling" });
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-
- it("renders nothing after arrival", () => {
- setState({ status: "arrived" });
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-
- it("hands the prefetched photo to the panel once its bytes are in", () => {
- setState();
- useNavJunctionStore.getState().setPhoto(1, {
- status: "ready",
- image: {
- id: "photo-1",
- providerId: "panoramax",
- lngLat: [6.679, 51.1786],
- heading: 283,
- capturedAt: "2019-09-10T06:24:40+00:00",
- isPano: false,
- fovDeg: 70,
- assets: {},
- author: "motocultrice",
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined),
- },
- objectUrl: "blob:photo-1",
- });
- const view = mount();
- expect(view.queryByTestId("junction-photo")).toBeTruthy();
- expect(view.queryByTestId("junction-schematic")).toBeNull();
-
- // Switched off mid-drive: a photo already fetched goes too.
- act(() => {
- useSettingsStore.setState({ junctionPhotos: false });
- });
- expect(view.queryByTestId("junction-photo")).toBeNull();
- expect(view.queryByTestId("junction-schematic")).toBeTruthy();
- });
-
- it("renders nothing for a decision point with no sign and no lanes", () => {
- const barePoint = {
- ...decisionPoints[0],
- sign: undefined,
- laneCount: undefined,
- activeLanes: [],
- };
- useNavJunctionStore.getState().setDecisionPoints("route-1", [barePoint]);
- setState();
- const view = mount();
- expect(view.queryByTestId("junction-view-panel")).toBeNull();
- });
-});
diff --git a/apps/web/src/components/navigation/JunctionViewSlot.tsx b/apps/web/src/components/navigation/JunctionViewSlot.tsx
deleted file mode 100644
index b319c299e..000000000
--- a/apps/web/src/components/navigation/JunctionViewSlot.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-"use client";
-
-import {
- guidanceApproachMeters,
- junctionPhotoApproachMeters,
- upcomingManeuverIndex,
- useNavigationStore,
- useSettingsStore,
-} from "@openmapx/core";
-import type { Ref } from "react";
-import { useMapAttributions } from "@/integration-api/overlay/useMapAttributions";
-import { OSM_ATTRIBUTION } from "@/lib/map";
-import { useNavJunctionStore } from "@/lib/navigation/junctionStore";
-import { JunctionViewPanel } from "./junction/JunctionViewPanel";
-
-const OSM_CREDIT = [OSM_ATTRIBUTION];
-const NO_CREDIT: typeof OSM_CREDIT = [];
-
-/**
- * The junction card slot under the maneuver banner. O(1) work per fix: look
- * up the decision point for the upcoming step, compare the distance to the
- * approach window, and read the gantry and photo from the store. Mounts the
- * panel only when there is something to draw. While a gantry built from OSM
- * way tags is on screen, the map attribution carries the OSM credit
- * explicitly rather than relying on the basemap's string.
- */
-export function JunctionViewSlot({
- availableHeight,
- panelRef,
-}: {
- availableHeight?: number | null;
- panelRef?: Ref;
-} = {}) {
- const status = useNavigationStore((s) => s.status);
- const mode = useNavigationStore((s) => s.mode);
- const stepCount = useNavigationStore((s) => s.route?.steps.length ?? 0);
- const geometry = useNavigationStore((s) => s.route?.geometry);
- const currentStepIndex = useNavigationStore((s) => s.progress?.currentStepIndex);
- const distanceToNextManeuver = useNavigationStore((s) => s.progress?.distanceToNextManeuver);
- const speedMps = useNavigationStore((s) => s.progress?.speedMps);
- const junctionView = useSettingsStore((s) => s.junctionView);
- const junctionPhotos = useSettingsStore((s) => s.junctionPhotos);
-
- const routeKey = useNavJunctionStore((s) => s.routeKey);
- const byStep = useNavJunctionStore((s) => s.byStep);
- const gantryByStep = useNavJunctionStore((s) => s.gantryByStep);
- const photoByStep = useNavJunctionStore((s) => s.photoByStep);
-
- const upcomingIndex = upcomingManeuverIndex(currentStepIndex ?? 0, stepCount);
- const point = routeKey !== null ? byStep.get(upcomingIndex) : undefined;
- const gantry = point && gantryByStep.get(upcomingIndex);
- const photo = point && photoByStep.get(upcomingIndex);
-
- const visible =
- junctionView &&
- !!point &&
- (status === "navigating" || status === "rerouting") &&
- (mode === "driving" || mode === "motorcycle") &&
- distanceToNextManeuver !== undefined &&
- distanceToNextManeuver <= guidanceApproachMeters(mode, speedMps ?? 0) &&
- // Something to draw: a sign, engine lanes, or a fetched gantry. A bare
- // `keep` with none of those stays hidden.
- (!!point.sign || (point.laneCount ?? 0) > 0 || !!gantry);
-
- // The gantry goes up with the rest of the guidance; the photo waits until
- // what it shows is nearly in front of the windscreen. Photos switched off
- // mid-drive leave at once, including ones already fetched.
- const photoVisible =
- junctionPhotos &&
- distanceToNextManeuver !== undefined &&
- distanceToNextManeuver <= junctionPhotoApproachMeters(mode, speedMps ?? 0);
-
- useMapAttributions("nav-junction", visible && gantry?.source === "osm" ? OSM_CREDIT : NO_CREDIT);
-
- if (!visible) return null;
- return (
-
- );
-}
diff --git a/apps/web/src/components/navigation/LaneGuidance.test.tsx b/apps/web/src/components/navigation/LaneGuidance.test.tsx
index 6d724a76e..337f60cc6 100644
--- a/apps/web/src/components/navigation/LaneGuidance.test.tsx
+++ b/apps/web/src/components/navigation/LaneGuidance.test.tsx
@@ -3,6 +3,24 @@ import { describe, expect, it } from "vitest";
import { LaneGuidance } from "./LaneGuidance";
describe("LaneGuidance", () => {
+ it.each(["banner", "standalone"] as const)(
+ "uses the same %s highlighting for preferred and usable lanes",
+ (variant) => {
+ const preferred = renderToStaticMarkup(
+ ,
+ );
+ const usable = renderToStaticMarkup(
+ ,
+ );
+ expect(preferred).toBe(usable);
+ },
+ );
it("renders nothing when there are no lanes", () => {
expect(renderToStaticMarkup()).toBe("");
});
@@ -20,7 +38,7 @@ describe("LaneGuidance", () => {
});
it("lights the lanes the OSM destinations send toward the sign at an all-through split", () => {
- const through = { indications: ["through"], valid: false };
+ const through = { indications: ["through"], valid: undefined };
const lanes = [through, through, through, through];
const keepLeft = { type: "keep", modifier: "left" };
const unlit = renderToStaticMarkup();
@@ -79,19 +97,19 @@ describe("LaneGuidance", () => {
/>,
);
expect(notChosen).toContain('data-empty="true"');
- // Without any engine choice the lanes are our own recommendation: no guessed arrow.
- const guessed = renderToStaticMarkup(
+ // Missing route evidence must not turn a maneuver direction into a lane choice.
+ const unknown = renderToStaticMarkup(
,
);
- expect(guessed).toContain('data-valid="true"');
- expect(guessed).not.toContain('data-inferred="true"');
- expect(guessed).toContain('data-empty="true"');
+ expect(unknown).not.toContain('data-valid="true"');
+ expect(unknown).not.toContain('data-inferred="true"');
+ expect(unknown).toContain('data-empty="true"');
});
it("keeps a chosen unmarked lane blank for a maneuver without a lane direction", () => {
@@ -120,7 +138,7 @@ describe("LaneGuidance", () => {
expect(html).toContain('data-active="true"');
});
- it("highlights through lanes for a keep-left instruction", () => {
+ it("preserves explicit rejection for a keep-left instruction", () => {
const html = renderToStaticMarkup(
{
]}
/>,
);
- expect(html.match(/data-valid="true"/g)?.length).toBe(2);
- expect(html.match(/data-valid="false"/g)?.length).toBe(1);
+ expect(html.match(/data-valid="true"/g)?.length ?? 0).toBe(0);
+ expect(html.match(/data-valid="false"/g)?.length).toBe(3);
});
});
diff --git a/apps/web/src/components/navigation/LaneGuidance.tsx b/apps/web/src/components/navigation/LaneGuidance.tsx
index c339dfc50..5a4aace8e 100644
--- a/apps/web/src/components/navigation/LaneGuidance.tsx
+++ b/apps/web/src/components/navigation/LaneGuidance.tsx
@@ -19,6 +19,7 @@ export function LaneGuidance({
maneuver,
sign,
laneDestinations,
+ source = "engine",
variant = "standalone",
size = "regular",
}: {
@@ -28,6 +29,8 @@ export function LaneGuidance({
sign?: ManeuverSign;
/** OSM destinations per lane on the approach, when the junction lookup found them. */
laneDestinations?: LaneDestinations[];
+ /** Only engine-selected unmarked lanes may inherit the maneuver arrow. */
+ source?: "engine" | "osm";
/**
* "standalone" = its own light card. "banner" = embedded in the maneuver
* banner's darkened sub-row: transparent, white arrows keyed by opacity.
@@ -38,7 +41,7 @@ export function LaneGuidance({
}) {
// Shared with the Android Auto routing card: which lanes light up and which arrow
// in each is recommended (see `presentGuidanceLanes`).
- const cells = presentGuidanceLanes(lanes, maneuver, { sign, laneDestinations });
+ const cells = presentGuidanceLanes(lanes, maneuver, { sign, laneDestinations }, source);
if (cells.length === 0) return null;
const banner = variant === "banner";
const cell = size === "compact" ? 28 : banner ? 38 : 36;
diff --git a/apps/web/src/components/navigation/ManeuverBanner.test.tsx b/apps/web/src/components/navigation/ManeuverBanner.test.tsx
index 78690fe9d..22401b102 100644
--- a/apps/web/src/components/navigation/ManeuverBanner.test.tsx
+++ b/apps/web/src/components/navigation/ManeuverBanner.test.tsx
@@ -12,7 +12,8 @@ vi.mock("next-intl", () => ({
return key;
},
}));
-vi.mock("@openmapx/core", () => ({
+vi.mock("@openmapx/core", async () => ({
+ ...(await import("../../../../../packages/core/src/navigation/signFormat")),
formatDistance: (m: number) => `${m} m`,
formatMeasurementDistance: (m: number, sys: string) =>
sys === "imperial" ? `${m} ft` : `${m} m`,
diff --git a/apps/web/src/components/navigation/ManeuverBanner.tsx b/apps/web/src/components/navigation/ManeuverBanner.tsx
index a10aa3ff7..575233fb8 100644
--- a/apps/web/src/components/navigation/ManeuverBanner.tsx
+++ b/apps/web/src/components/navigation/ManeuverBanner.tsx
@@ -67,6 +67,7 @@ export interface ManeuverBannerProps {
lanes?: ManeuverLane[];
/** OSM destinations per lane on the approach, to pick the lanes toward the sign. */
laneDestinations?: LaneDestinations[];
+ laneSource?: "engine" | "osm";
/** Interchange signage for the upcoming maneuver, when the engine supplies it. */
sign?: ManeuverSign;
/** Country of the route origin, for the sign palette. */
@@ -82,6 +83,7 @@ export function ManeuverBanner({
nextManeuver,
lanes,
laneDestinations,
+ laneSource,
sign,
country,
units,
@@ -102,6 +104,7 @@ export function ManeuverBanner({
maneuver={maneuver}
sign={sign}
laneDestinations={laneDestinations}
+ source={laneSource}
/>
) : hasNext ? (
<>
diff --git a/apps/web/src/components/navigation/NavManeuverSlot.test.tsx b/apps/web/src/components/navigation/NavManeuverSlot.test.tsx
index d399164d3..2da5ff033 100644
--- a/apps/web/src/components/navigation/NavManeuverSlot.test.tsx
+++ b/apps/web/src/components/navigation/NavManeuverSlot.test.tsx
@@ -3,6 +3,7 @@ import { useNavigationStore } from "@openmapx/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useMapAttributionStore } from "@/integration-api/overlay/mapAttributionStore";
vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
@@ -12,17 +13,22 @@ vi.mock("@openmapx/core", async (importOriginal) => ({
}));
const { NavManeuverSlot } = await import("./NavManeuverSlot");
+const { useNavLaneGuidanceStore } = await import("@/lib/navigation/laneGuidanceStore");
const route = {
+ distance: 1500,
+ duration: 110,
+ legs: [],
mode: "driving",
geometry: [
[6.67, 51.17],
[6.68, 51.18],
],
steps: [
- { instruction: "Drive on", distance: 1000, duration: 60 },
+ { instruction: "Drive on", distance: 1000, duration: 60, coordinates: [] },
{
instruction: "Take exit 20 toward Neuss-Zentrum",
+ coordinates: [],
distance: 400,
duration: 30,
maneuver: { type: "turn", modifier: "right" },
@@ -32,7 +38,7 @@ const route = {
exitToward: ["Neuss-Zentrum"],
},
},
- { instruction: "Arrive", distance: 100, duration: 20 },
+ { instruction: "Arrive", distance: 100, duration: 20, coordinates: [] },
],
} as unknown as Route;
@@ -70,6 +76,7 @@ describe("NavManeuverSlot", () => {
afterEach(() => {
cleanup();
useNavigationStore.getState().stopNavigation();
+ useNavLaneGuidanceStore.getState().reset();
});
it("passes the upcoming step's sign to the banner", () => {
@@ -77,6 +84,93 @@ describe("NavManeuverSlot", () => {
expect(view.getByTestId("exit-sign-strip")).toBeTruthy();
expect(view.container.textContent).toContain("Neuss-Zentrum");
});
+ it("keeps OSM-only unmarked lanes blank instead of inferring an engine arrow", () => {
+ useNavLaneGuidanceStore.setState({
+ routeIdentity: route,
+ guidanceByStep: new Map([
+ [
+ 1,
+ {
+ lanes: [
+ { indications: ["none"], valid: true, preferred: true },
+ { indications: ["right"], valid: false },
+ ],
+ usableLanes: [0],
+ preferredLanes: [0],
+ state: "known",
+ source: "osm",
+ },
+ ],
+ ]),
+ });
+ const view = mount();
+ expect(view.container.querySelector('[data-inferred="true"]')).toBeNull();
+ expect(view.container.querySelector('[data-empty="true"]')).not.toBeNull();
+ });
+ it("credits displayed OSM lane evidence and releases that credit on unmount", () => {
+ useMapAttributionStore.setState({ byLayer: {} });
+ useNavLaneGuidanceStore.setState({
+ routeIdentity: route,
+ guidanceByStep: new Map([
+ [
+ 1,
+ {
+ lanes: [{ indications: ["through"], valid: true, preferred: true }],
+ usableLanes: [0],
+ preferredLanes: [0],
+ state: "known",
+ source: "osm",
+ },
+ ],
+ ]),
+ });
+ const view = mount();
+ expect(JSON.stringify(useMapAttributionStore.getState().byLayer)).toContain("OpenStreetMap");
+ view.unmount();
+ expect(useMapAttributionStore.getState().byLayer).toEqual({});
+ });
+ it("does not paint the previous route's lane choice during the reroute render", () => {
+ useNavLaneGuidanceStore.setState({
+ routeIdentity: route,
+ guidanceByStep: new Map([
+ [
+ 1,
+ {
+ source: "osm",
+ lanes: [
+ { indications: ["through"], valid: true, preferred: true },
+ { indications: ["right"], valid: false },
+ ],
+ usableLanes: [0],
+ preferredLanes: [0],
+ state: "known",
+ },
+ ],
+ ]),
+ });
+ useNavigationStore.setState({
+ route: {
+ ...route,
+ steps: route.steps.map((step, i) =>
+ i === 1
+ ? {
+ ...step,
+ lanes: [
+ { indications: ["through"], valid: false },
+ { indications: ["right"], valid: true, preferred: true },
+ ],
+ }
+ : step,
+ ),
+ },
+ });
+ const view = mount();
+ expect(
+ [...view.container.querySelectorAll("[data-valid]")].map((cell) =>
+ cell.getAttribute("data-valid"),
+ ),
+ ).toEqual(["false", "true"]);
+ });
it("colours the sign by the country the exit stands in, not the origin's", () => {
// Origin in Germany (blue), the border 900 m in, the exit at 1000 m in
diff --git a/apps/web/src/components/navigation/NavManeuverSlot.tsx b/apps/web/src/components/navigation/NavManeuverSlot.tsx
index b61551118..f7913defc 100644
--- a/apps/web/src/components/navigation/NavManeuverSlot.tsx
+++ b/apps/web/src/components/navigation/NavManeuverSlot.tsx
@@ -3,17 +3,22 @@
import {
countryAtMeters,
guidanceApproachMeters,
+ maneuverStartMeters,
shouldPreviewNextStep,
- stepStartMeters,
upcomingManeuverIndex,
useCountryFromCoordinates,
useNavigationStore,
useSettingsStore,
} from "@openmapx/core";
import { useMemo } from "react";
-import { useNavJunctionStore } from "@/lib/navigation/junctionStore";
+import { useMapAttributions } from "@/integration-api/overlay/useMapAttributions";
+import { OSM_ATTRIBUTION } from "@/lib/map";
+import { useNavLaneGuidanceStore } from "@/lib/navigation/laneGuidanceStore";
import { ManeuverBanner } from "./ManeuverBanner";
+const OSM_CREDIT = [OSM_ATTRIBUTION];
+const NO_CREDIT: typeof OSM_CREDIT = [];
+
/**
* The only piece of nav chrome that needs the upcoming-maneuver arithmetic on
* every accepted fix. Subscribes to `progress` directly so re-computing it
@@ -25,6 +30,8 @@ export function NavManeuverSlot() {
const mode = useNavigationStore((s) => s.mode);
const units = useSettingsStore((s) => s.units);
const routeCountries = useNavigationStore((s) => s.routeCountries);
+ const guidanceByStep = useNavLaneGuidanceStore((s) => s.guidanceByStep);
+ const guidanceRoute = useNavLaneGuidanceStore((s) => s.routeIdentity);
// Show the nav chrome from the static route immediately on Start; live
// position (progress) refines it once GPS fixes arrive. Without this, the
@@ -43,10 +50,9 @@ export function NavManeuverSlot() {
// time before the maneuver that stretches on the motorway) — so on a long
// stretch neither the lanes nor the "Then …" preview clutter the banner.
const approaching = distanceToManeuver <= guidanceApproachMeters(mode, speedMps);
- const showLanes = !!step?.lanes && approaching;
- // Where each lane leads per OSM, from the junction lookup: at a split whose
- // lanes all point straight on, the only way to tell the branches apart.
- const laneDestinations = useNavJunctionStore((s) => s.laneDestinationsByStep.get(upcomingIndex));
+ const resolvedLanes =
+ (guidanceRoute === route ? guidanceByStep.get(upcomingIndex)?.lanes : undefined) ?? step?.lanes;
+ const showLanes = !!resolvedLanes?.length && approaching;
// Preview the maneuver after this one only when it's both relevant (approaching)
// and follows closely (a short gap), so back-to-back turns chain but far-apart
// ones don't.
@@ -61,13 +67,17 @@ export function NavManeuverSlot() {
const signCountry = useMemo(
() =>
route && routeCountries && hasSign
- ? countryAtMeters(routeCountries, stepStartMeters(route.steps, upcomingIndex))
+ ? countryAtMeters(routeCountries, maneuverStartMeters(route, upcomingIndex))
: undefined,
[route, routeCountries, hasSign, upcomingIndex],
);
const { data: originCountry } = useCountryFromCoordinates(route?.geometry[0] ?? null, hasSign);
const country = signCountry ?? originCountry;
+ const usesOsmLanes =
+ guidanceRoute === route && showLanes && guidanceByStep.get(upcomingIndex)?.source === "osm";
+ useMapAttributions("nav-lane-guidance", usesOsmLanes ? OSM_CREDIT : NO_CREDIT);
+
if (!step) return null;
return (
@@ -77,8 +87,8 @@ export function NavManeuverSlot() {
maneuver={step.maneuver}
nextInstruction={showNextStep ? nextStep?.instruction : undefined}
nextManeuver={showNextStep ? nextStep?.maneuver : undefined}
- lanes={showLanes ? step.lanes : undefined}
- laneDestinations={laneDestinations}
+ lanes={showLanes ? resolvedLanes : undefined}
+ laneSource={usesOsmLanes ? "osm" : "engine"}
sign={step.sign}
country={country}
units={units}
diff --git a/apps/web/src/components/navigation/NavSpeedLimitSlot.tsx b/apps/web/src/components/navigation/NavSpeedLimitSlot.tsx
index 894d04a6c..0e8391188 100644
--- a/apps/web/src/components/navigation/NavSpeedLimitSlot.tsx
+++ b/apps/web/src/components/navigation/NavSpeedLimitSlot.tsx
@@ -2,7 +2,6 @@
import Box from "@mui/material/Box";
import { isOverSpeed, useNavigationStore, useSettingsStore } from "@openmapx/core";
-import type { Ref } from "react";
import { planSheetFollow, sheetFollowSx, useSheetFollowTrack } from "@/lib/sheetFollow";
import { SpeedLimitBadge } from "./SpeedLimitBadge";
@@ -10,7 +9,6 @@ interface Props {
isMobile: boolean;
/** Live height of the fixed-position mobile sheet, to lift the badge above it. */
sheetClearance: number;
- elementRef?: Ref;
}
/**
@@ -19,7 +17,7 @@ interface Props {
* otherwise a fix that only nudges the speed (with the limit unchanged) would
* still have to re-render whatever owns the layout around it.
*/
-export function NavSpeedLimitSlot({ isMobile, sheetClearance, elementRef }: Props) {
+export function NavSpeedLimitSlot({ isMobile, sheetClearance }: Props) {
const currentSpeedLimit = useNavigationStore((s) => s.currentSpeedLimit);
const speedMps = useNavigationStore((s) => s.progress?.speedMps ?? 0);
const units = useSettingsStore((s) => s.units);
@@ -29,11 +27,8 @@ export function NavSpeedLimitSlot({ isMobile, sheetClearance, elementRef }: Prop
const follow =
isMobile && followTrack ? planSheetFollow(followTrack, { offset: 0, min: 0 }) : null;
- // Keep the measured element stable: publishing its first ref from a GPS
- // fix would re-render the cold chrome that owns the junction budget.
return (
({
vi.mock("@/lib/navigation/useNavigationEngine", () => ({ useNavigationEngine: () => {} }));
vi.mock("@/lib/navigation/useNavCamera", () => ({ useNavCamera: () => {} }));
vi.mock("@/lib/navigation/useNavAlerts", () => ({ useNavAlerts: () => null }));
-vi.mock("@/lib/navigation/useNavJunctions", () => ({ useNavJunctions: () => {} }));
+vi.mock("@/lib/navigation/useNavLaneGuidance", () => ({ useNavLaneGuidance: () => {} }));
vi.mock("@/lib/navigation/useNavigationSessionPersistence", () => ({
useNavigationSessionPersistence: () => ({
pending: null,
diff --git a/apps/web/src/components/navigation/junction/GantryStrip.test.tsx b/apps/web/src/components/navigation/junction/GantryStrip.test.tsx
deleted file mode 100644
index 4b74486ea..000000000
--- a/apps/web/src/components/navigation/junction/GantryStrip.test.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { GantryModel } from "@openmapx/core";
-import { render } from "@testing-library/react";
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vitest";
-import { GantryStrip } from "./GantryStrip";
-
-const threePanels: GantryModel = {
- laneCount: 5,
- panels: [
- {
- lanes: [0, 1, 2],
- destinations: ["Krefeld", "Düsseldorf Nord", "Büttgen"],
- refs: ["A 57"],
- symbols: [],
- isExit: false,
- },
- {
- lanes: [3],
- destinations: ["Heinsberg", "Aachen", "Neuss-Holzheim"],
- refs: ["A 46"],
- symbols: [],
- isExit: false,
- },
- {
- lanes: [4],
- destinations: ["Neuss-Zentrum"],
- refs: [],
- symbols: [],
- isExit: true,
- exitNumber: "20",
- },
- ],
- activeLanes: [4],
- source: "osm",
-};
-
-describe("GantryStrip", () => {
- it("renders one panel per destination group with the exit panel active and badged", () => {
- const html = renderToStaticMarkup();
- expect(html.match(/data-panel/g)).toHaveLength(3);
- expect(html).toContain("Krefeld");
- expect(html).toContain("Heinsberg");
- expect(html).toContain("Neuss-Zentrum");
- expect(html).toContain("A 57");
- expect(html).toContain("20");
- expect(html.match(/data-active="true"/g)).toHaveLength(1);
- });
-
- it("keeps panels distinct when the exit panel spans a lane that also has its own board", () => {
- // Live A57 exit 20: lane 4 carries its own destination group and the exit panel.
- const model: GantryModel = {
- ...threePanels,
- panels: [
- ...threePanels.panels.slice(0, 2),
- {
- lanes: [4],
- destinations: ["Heinsberg", "Aachen", "Neuss-Zentrum"],
- refs: ["A 46"],
- symbols: [],
- isExit: false,
- },
- threePanels.panels[2],
- ],
- };
- const error = vi.spyOn(console, "error").mockImplementation(() => {});
- try {
- // The duplicate-key check lives in the client reconciler, not the static renderer.
- const { container } = render();
- expect(container.querySelectorAll("[data-panel]")).toHaveLength(4);
- expect(error.mock.calls.flat().join(" ")).not.toContain("same key");
- } finally {
- error.mockRestore();
- }
- });
-});
diff --git a/apps/web/src/components/navigation/junction/GantryStrip.tsx b/apps/web/src/components/navigation/junction/GantryStrip.tsx
deleted file mode 100644
index a005790da..000000000
--- a/apps/web/src/components/navigation/junction/GantryStrip.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-"use client";
-
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import type { GantryModel } from "@openmapx/core";
-
-interface Props {
- model: GantryModel;
- size?: "banner" | "compact";
-}
-
-type Panel = GantryModel["panels"][number];
-
-/** The overhead-board blue, with the active panel a shade brighter. */
-const BOARD_BLUE = { bg: "#0e3464", fg: "#ffffff" };
-const ACTIVE_BOARD_BLUE = { bg: "#154889", fg: "#ffffff" };
-
-/**
- * `destination:colour` values that appear on real boards; anything else keeps
- * the motorway blue. Yellow and white boards carry dark text.
- */
-const BOARD_COLOURS: Record = {
- yellow: { bg: "#f4c542", fg: "#000000" },
- white: { bg: "#f5f5f5", fg: "#000000" },
- green: { bg: "#0b7a3b", fg: "#ffffff" },
- brown: { bg: "#6b4a2b", fg: "#ffffff" },
- orange: { bg: "#e8792b", fg: "#000000" },
-};
-
-/** `destination:symbol` values with a glyph a driver recognises at a glance. */
-const SYMBOL_GLYPHS: Record = {
- airport: "✈",
- industrial: "⚙",
- hospital: "✚",
- centre: "◉",
- center: "◉",
-};
-
-function panelColours(panel: Panel, active: boolean) {
- if (panel.colour && BOARD_COLOURS[panel.colour]) return BOARD_COLOURS[panel.colour];
- return active ? ACTIVE_BOARD_BLUE : BOARD_BLUE;
-}
-
-/**
- * One panel of the gantry: the exit number, the refs, then the destination
- * texts stacked as on an overhead board. The active panel is emphasised with
- * a brighter board and a light outline.
- */
-function GantryPanel({
- model,
- panel,
- size,
-}: {
- model: GantryModel;
- panel: Panel;
- size: "banner" | "compact";
-}) {
- const active = panel.lanes.some((lane) => model.activeLanes.includes(lane));
- const colours = panelColours(panel, active);
- const glyphs = panel.symbols.map((symbol) => SYMBOL_GLYPHS[symbol]).filter(Boolean);
- return (
-
- {panel.exitNumber && (
-
- {panel.exitNumber}
-
- )}
- {panel.refs.map((roadRef) => (
-
- {roadRef}
-
- ))}
- {panel.destinations.slice(0, 3).map((destination) => (
-
- {destination}
-
- ))}
- {glyphs.length > 0 && (
-
- {glyphs.join(" ")}
-
- )}
-
- );
-}
-
-/** The overhead gantry: one board per destination group, left to right. */
-export function GantryStrip({ model, size = "banner" }: Props) {
- return (
-
- {model.panels.map((panel) => (
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/navigation/junction/JunctionPhoto.test.tsx b/apps/web/src/components/navigation/junction/JunctionPhoto.test.tsx
deleted file mode 100644
index 8ea4e25c5..000000000
--- a/apps/web/src/components/navigation/junction/JunctionPhoto.test.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-import { licensingFromDeclaration } from "@openmapx/mobility-core/licensing";
-import { fireEvent, render } from "@testing-library/react";
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("next-intl", () => ({
- useTranslations: () => (key: string, values?: Record) => {
- if (key === "junctionPhotoDate") return `Captured ${String(values?.date)}`;
- return key;
- },
- useLocale: () => "en",
-}));
-
-import type { JunctionDecisionPoint, LngLat, StreetLevelImage } from "@openmapx/core";
-import { JunctionPhoto } from "./JunctionPhoto";
-
-/** A straight road running due east, ~314 m long, at the fixture's latitude. */
-const geometry: LngLat[] = [
- [0, 51.18],
- [0.0045, 51.18],
-];
-
-/** The split 200 m along; the photo is taken 100 m along, 100 m before it. */
-const point: JunctionDecisionPoint = {
- stepIndex: 1,
- kind: "exit",
- side: "right",
- point: [0.0028653, 51.18],
- alongMeters: 200,
- approachBearing: 90,
- divergenceDeg: 20,
- activeLanes: [],
-};
-
-const flatImage: StreetLevelImage = {
- id: "photo-1",
- providerId: "panoramax",
- lngLat: [0.0014327, 51.18],
- heading: 90,
- capturedAt: "2019-09-10T06:24:40+00:00",
- isPano: false,
- fovDeg: 70,
- assets: {},
- author: "motocultrice",
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined),
-};
-
-/** The centre-line samples the overlay drew, as `[x, y]` pairs. */
-function pathPoints(html: string): [number, number][] {
- const attribute = html.match(/data-photo-path="([^"]+)"/)?.[1];
- return (attribute ?? "")
- .split(" ")
- .filter(Boolean)
- .map((pair) => pair.split(",").map(Number) as [number, number]);
-}
-
-describe("JunctionPhoto", () => {
- it("renders a hidden image, the projected route path, and a separate capture date", () => {
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).toContain("aria-hidden");
- expect(html).toContain('alt=""');
- expect(html).toContain("blob:photo-1");
- expect(html).toContain("data-photo-path");
- expect(html).toContain("Captured Sep 2019");
- expect(html).not.toContain("motocultrice");
- expect(html).not.toContain(" {
- const html = renderToStaticMarkup(
- ,
- );
- // The image is displayed with `cover`; the overlay uses the matching SVG
- // rule over a viewBox of the source's own shape, so both crop alike.
- expect(html).toContain('preserveAspectRatio="xMidYMid slice"');
- expect(html).toContain('viewBox="0 0 100 75"');
- });
-
- it("shapes the overlay to the photo's own frame when the provider reports it", () => {
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).toContain('viewBox="0 0 100 56.25"');
- });
-
- it("re-shapes the overlay once the loaded photo reveals its own frame", () => {
- const { container } = render(
- ,
- );
- expect(container.querySelector("[data-photo-path]")?.getAttribute("viewBox")).toBe(
- "0 0 100 75",
- );
- const img = container.querySelector("img") as HTMLImageElement;
- Object.defineProperty(img, "naturalWidth", { value: 2048, configurable: true });
- Object.defineProperty(img, "naturalHeight", { value: 1024, configurable: true });
- fireEvent.load(img);
- expect(container.querySelector("[data-photo-path]")?.getAttribute("viewBox")).toBe(
- "0 0 100 50",
- );
- });
-
- it("moves the drawn path into the exit lane the gantry names", () => {
- // Shot on the carriageway's centre line of three lanes; the exit leaves the right one.
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).toContain('data-lane-shift="3.5"');
- });
-
- it("leaves the photo bare when the route cannot be projected onto it", () => {
- const offRoute: StreetLevelImage = { ...flatImage, lngLat: [0.0014327, 51.1808] };
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).toContain("blob:photo-1");
- expect(html).not.toContain("data-photo-path");
- });
-
- it("renders a cropped window for a panorama, offset from the image's own heading", () => {
- const pano: StreetLevelImage = { ...flatImage, isPano: true, fovDeg: 360 };
- const html = renderToStaticMarkup(
- ,
- );
- // The window is centred on the road ahead, which runs due east here.
- expect(Number(html.match(/data-crop-start="([^"]+)"/)?.[1])).toBeCloseTo(45, 1);
- expect(html).toContain('data-crop-span="90"');
- // The image centre faces 90°, so its left edge is 270°; a window starting
- // at 45° begins 135° in — one and a half windows of a four-window strip.
- expect(Number(html.match(/data-left-percent="([^"]+)"/)?.[1])).toBeCloseTo(-150, 1);
- expect(html.match(/
{
- // A frame facing 300° cannot show the road ahead, so no path is drawn, but
- // the 90° window onto the approach still has to be assembled.
- const pano: StreetLevelImage = { ...flatImage, isPano: true, fovDeg: 360, heading: 300 };
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).not.toContain("data-photo-path");
- expect(html.match(/
Number(m[1]));
- expect(offsets[0]).toBeCloseTo(-(285 / 90) * 100, 1);
- expect(offsets[1]).toBeCloseTo(-(285 / 90) * 100 + 400, 1);
- });
-
- it("does not manufacture credit from legacy fields when captured receipts are empty", () => {
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).toContain("Captured Sep 2019");
- expect(html).not.toContain("©");
- expect(html).not.toContain("CC-BY-SA");
- });
-});
diff --git a/apps/web/src/components/navigation/junction/JunctionPhoto.tsx b/apps/web/src/components/navigation/junction/JunctionPhoto.tsx
deleted file mode 100644
index f7b38e991..000000000
--- a/apps/web/src/components/navigation/junction/JunctionPhoto.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-"use client";
-
-import Box from "@mui/material/Box";
-import type { SxProps, Theme } from "@mui/material/styles";
-import Typography from "@mui/material/Typography";
-import type {
- JunctionDecisionPoint,
- LngLat,
- PhotoRoutePath,
- StreetLevelImage,
-} from "@openmapx/core";
-import { projectRoutePath } from "@openmapx/core";
-import { useLocale, useTranslations } from "next-intl";
-import { useId, useMemo, useState } from "react";
-import { AttributionStrip } from "@/components/ui/AttributionStrip";
-
-const PHOTO_HEIGHT = 180;
-/** Frame shape used until the photo reports or reveals its own. */
-const DEFAULT_ASPECT_RATIO = 4 / 3;
-/** Equirectangular frames cover the whole sphere, always 2:1. */
-const PANO_VIEWBOX = { width: 360, height: 180 };
-
-/**
- * The photo preview: the image with the route ahead projected onto it, and a
- * separate capture month. Everything decorative is `aria-hidden`; the panel
- * owns the accessible graphic and renders captured credits beside it.
- */
-export function JunctionPhoto({
- image,
- objectUrl,
- point,
- geometry,
- exitLanes,
-}: {
- image: StreetLevelImage;
- objectUrl: string;
- point: JunctionDecisionPoint;
- /** The active route, for projecting the path ahead onto the photo. */
- geometry: LngLat[];
- /** The gantry's lane layout, so the path can move into the exit lane. */
- exitLanes?: { laneCount: number; activeLanes: number[] };
-}) {
- const t = useTranslations("navigation");
- const locale = useLocale();
- // The frame's shape sets the vertical scale of the projection. The provider's
- // sensor dimensions are the first guess; the loaded image is the truth, and
- // is all a provider that reports no sensor gives us.
- const [measuredAspect, setMeasuredAspect] = useState(null);
- const aspectRatio = measuredAspect ?? image.aspectRatio ?? DEFAULT_ASPECT_RATIO;
- // The panel re-renders on every fix; the projection depends only on the
- // route, the decision point, the photo and the lane layout, so it is computed
- // once per photo. The layout arrives as a fresh object each render, so it is
- // keyed by value.
- const laneCount = exitLanes?.laneCount;
- const activeLanesKey = exitLanes?.activeLanes.join(",");
- const path = useMemo(
- () =>
- projectRoutePath(geometry, point, image, {
- aspectRatio,
- ...(laneCount !== undefined && activeLanesKey !== undefined
- ? {
- exitLanes: {
- laneCount,
- activeLanes: activeLanesKey ? activeLanesKey.split(",").map(Number) : [],
- },
- }
- : {}),
- }),
- [geometry, point, image, aspectRatio, laneCount, activeLanesKey],
- );
- const capturedAt = image.capturedAt ? new Date(image.capturedAt) : null;
- const date =
- capturedAt && Number.isFinite(capturedAt.getTime())
- ? new Intl.DateTimeFormat(locale, { month: "short", year: "numeric" }).format(capturedAt)
- : "";
-
- return (
-
- {image.isPano && path.crop ? (
-
- ) : (
-
- ) => {
- const { naturalWidth, naturalHeight } = event.currentTarget;
- if (naturalWidth > 0 && naturalHeight > 0) {
- setMeasuredAspect(naturalWidth / naturalHeight);
- }
- }}
- sx={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
- />
- {path.visible && (
-
- )}
-
- )}
- {date && (
-
- {t("junctionPhotoDate", { date })}
-
- )}
-
- );
-}
-
-/** Captured image receipts only; empty receipts never borrow legacy provider fields. */
-export function JunctionPhotoAttribution({ image }: { image: StreetLevelImage }) {
- return (
-
- );
-}
-
-/**
- * A 90° slice of an equirectangular panorama. The image's horizontal centre
- * faces `image.heading`, so its left edge is `heading − 180`; the crop window
- * starts `startDeg` clockwise from north. The image is laid out four windows
- * wide (360° / 90°) and slid left by the window's offset from that edge. A
- * window that wraps past the right edge is completed by a second copy. The
- * path overlay is laid out over the same strip, so it slides with the image.
- */
-function PanoramaWindow({
- image,
- objectUrl,
- path,
-}: {
- image: StreetLevelImage;
- objectUrl: string;
- path: PhotoRoutePath;
-}) {
- const crop = path.crop ?? { startDeg: 0, spanDeg: 90 };
- const leftEdgeDeg = (image.heading ?? 0) - 180;
- const offsetDeg = (((crop.startDeg - leftEdgeDeg) % 360) + 360) % 360;
- const windowsPerTurn = 360 / crop.spanDeg;
- const leftPercent = -(offsetDeg / crop.spanDeg) * 100;
- const wraps = offsetDeg + crop.spanDeg > 360;
- const slice = {
- position: "absolute" as const,
- top: 0,
- width: `${windowsPerTurn * 100}%`,
- height: "100%",
- objectFit: "cover" as const,
- };
- return (
-
-
- {wraps && (
-
- )}
- {path.visible && (
-
- )}
-
- );
-}
-
-/**
- * The route ahead, drawn as a ribbon on the road: it narrows with distance,
- * bends where the route leaves the carriageway, and fades out toward the
- * horizon, where the projection is least certain and the road is a few pixels
- * wide. Coordinates are percentages of the source image, so the overlay is
- * laid out over the same box as the photo and cropped the same way (`slice`
- * mirrors `object-fit: cover`) — the path stays on the road when the frame is
- * cropped.
- */
-function RoutePathOverlay({
- path,
- viewBox,
- sx,
-}: {
- path: PhotoRoutePath;
- viewBox: { width: number; height: number };
- sx: SxProps;
-}) {
- const gradientId = useId();
- const toViewBox = (xPercent: number, yPercent: number): [number, number] => [
- (xPercent / 100) * viewBox.width,
- (yPercent / 100) * viewBox.height,
- ];
- const left = path.points.map((p) => toViewBox(p.xPercent - p.widthPercent / 2, p.yPercent));
- const right = path.points.map((p) => toViewBox(p.xPercent + p.widthPercent / 2, p.yPercent));
- const ribbon = [...left, ...right.reverse()]
- .map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`)
- .join(" ");
- const centre = path.points
- .map((p) => `${p.xPercent.toFixed(2)},${p.yPercent.toFixed(2)}`)
- .join(" ");
- return (
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx b/apps/web/src/components/navigation/junction/JunctionSchematic.tsx
deleted file mode 100644
index 24b16100d..000000000
--- a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-"use client";
-
-import Box from "@mui/material/Box";
-import type { JunctionSchematic } from "@openmapx/core";
-
-/**
- * The SVG drawing of a junction schematic: lane bands under perspective, the
- * active lane emphasised, the ramp peeling off. `aria-hidden` — the wrapping
- * panel is the accessible surface.
- */
-export function JunctionSchematicView({ schematic }: { schematic: JunctionSchematic }) {
- return (
-
-
- {schematic.lanePolygons.map((d, i) => (
-
- ))}
-
-
- );
-}
diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx
deleted file mode 100644
index 549200096..000000000
--- a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx
+++ /dev/null
@@ -1,425 +0,0 @@
-import type { StreetLevelImage } from "@openmapx/core";
-import { licensingFromDeclaration } from "@openmapx/mobility-core/licensing";
-import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vitest";
-import { installMeasuredLayout } from "@/test/measuredLayout";
-
-vi.mock("next-intl", () => ({
- useTranslations: () => (key: string, values?: Record) => {
- if (key === "junctionLaneSummary")
- return `Use lane ${String(values?.lane)} of ${String(values?.total)}`;
- if (key === "junctionViewLabel") return "Junction view";
- if (key === "toward") return `toward ${String(values?.places)}`;
- if (key === "junctionPhotoDate") return `Captured ${String(values?.date)}`;
- return key;
- },
- useLocale: () => "en",
-}));
-
-import type { GantryModel, Route } from "@openmapx/core";
-import { buildJunctionSchematic, findJunctionDecisionPoints } from "@openmapx/core";
-import fixture from "../../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json";
-import { GantryStrip } from "./GantryStrip";
-import { JunctionSchematicView } from "./JunctionSchematic";
-import { JunctionViewPanel } from "./JunctionViewPanel";
-
-const a57Route = (fixture as { route: unknown }).route as Route;
-const a57Point = findJunctionDecisionPoints(a57Route)[0];
-
-const threePanels: GantryModel = {
- laneCount: 5,
- panels: [
- {
- lanes: [0, 1, 2],
- destinations: ["Krefeld", "Düsseldorf Nord", "Büttgen"],
- refs: ["A 57"],
- symbols: [],
- isExit: false,
- },
- {
- lanes: [3],
- destinations: ["Heinsberg", "Aachen", "Neuss-Holzheim"],
- refs: ["A 46"],
- symbols: [],
- isExit: false,
- },
- {
- lanes: [4],
- destinations: ["Neuss-Zentrum"],
- refs: [],
- symbols: [],
- isExit: true,
- exitNumber: "20",
- },
- ],
- activeLanes: [4],
- source: "osm",
-};
-
-describe("GantryStrip", () => {
- it("renders one panel per destination group with the exit panel active and badged", () => {
- const html = renderToStaticMarkup();
- expect(html.match(/data-panel/g)).toHaveLength(3);
- expect(html).toContain("Krefeld");
- expect(html).toContain("Heinsberg");
- expect(html).toContain("Neuss-Zentrum");
- expect(html).toContain("A 57");
- expect(html).toContain("20");
- expect(html).toContain('data-active="true"');
- expect(html.match(/data-active="true"/g)).toHaveLength(1);
- });
-});
-
-describe("JunctionSchematicView", () => {
- it("draws the schematic SVG with lane bands, one active, and the ramp", () => {
- const schematic = buildJunctionSchematic(threePanels, a57Point);
- const html = renderToStaticMarkup();
- expect(html).toContain('viewBox="0 0 320 140"');
- expect(html.match(/data-lane/g)).toHaveLength(5);
- expect(html.match(/data-active="true"/g)).toHaveLength(1);
- expect(html).toContain("data-ramp");
- });
-});
-
-describe("JunctionViewPanel", () => {
- it("is an accessible image with the lane summary and toward places", () => {
- const html = renderToStaticMarkup();
- expect(html).toContain('role="img"');
- expect(html).toContain('aria-label="Use lane 5 of 5, toward Neuss-Zentrum"');
- expect(html).toContain('data-testid="junction-schematic"');
- });
-
- it("draws bare lane bands when the engine sent lanes but no sign", () => {
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).not.toContain("data-panel");
- expect(html.match(/data-lane/g)).toHaveLength(5);
- expect(html).toContain('aria-label="Use lane 5 of 5"');
- });
-
- it("builds an engine-only gantry from the sign when no OSM gantry exists", () => {
- const html = renderToStaticMarkup();
- expect(html).toContain("Neuss-Zentrum");
- expect(html).toContain("20");
- });
-
- it("renders nothing when the point has no sign and no engine lanes", () => {
- const bare = { ...a57Point, sign: undefined, laneCount: undefined, activeLanes: [] };
- expect(renderToStaticMarkup()).toBe("");
- });
-});
-
-describe("JunctionViewPanel photo integration", () => {
- const photoImage = {
- id: "photo-1",
- providerId: "panoramax",
- lngLat: [6.679, 51.1786],
- heading: 283,
- capturedAt: "2019-09-10T06:24:40+00:00",
- isPano: false,
- fovDeg: 70,
- assets: {},
- author: "motocultrice",
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined),
- } as never;
-
- it("shows the photo when the store marks it ready, the schematic otherwise", () => {
- const withPhoto = renderToStaticMarkup(
- ,
- );
- expect(withPhoto).toContain('data-testid="junction-photo"');
- const schematic = renderToStaticMarkup(
- ,
- );
- expect(schematic).toContain('data-testid="junction-schematic"');
- });
-
- it("keeps the caption free of links", () => {
- const html = renderToStaticMarkup(
- ,
- );
- expect(html).not.toContain("
- );
-}
-
-it("keeps required captured image credits outside the graphic with full one-action details", async () => {
- const { container } = render(photoPanel());
- const graphic = screen.getByRole("img", { name: "Use lane 5 of 5, toward Neuss-Zentrum" });
- expect(graphic.querySelector("button,a")).toBeNull();
- const credits = screen.getByRole("contentinfo");
- expect(graphic.contains(credits)).toBe(false);
- expect(credits.textContent).toContain("Complete required image notice A");
- expect(credits.querySelector('img[alt="Mapillary"]')).not.toBeNull();
- expect(credits.querySelector("[data-source-id]")).toHaveStyle({
- whiteSpace: "normal",
- overflowWrap: "anywhere",
- });
- expect(container.textContent).toContain("Captured Sep 2019");
- const trigger = within(credits).getByRole("button", { name: "attributions" });
- trigger.focus();
- fireEvent.click(trigger);
- const dialog = screen.getByRole("dialog");
- expect(dialog.contains(document.activeElement)).toBe(true);
- expect(dialog.textContent).toContain("Photographer A");
- expect(dialog.textContent).toContain("CC-BY-SA-4.0");
- expect(dialog.textContent).toContain("Preserve the embedded notice");
- expect(dialog.querySelector('a[href="https://image.test/a"]')).not.toBeNull();
- expect(dialog.querySelector('img[alt="Mapillary"]')).not.toBeNull();
- const shortcuts = vi.fn();
- window.addEventListener("keydown", shortcuts);
- try {
- fireEvent.keyDown(dialog, { key: "ArrowRight" });
- expect(shortcuts).not.toHaveBeenCalled();
- fireEvent.keyDown(dialog, { key: "Escape" });
- await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
- await waitFor(() => expect(trigger).toHaveFocus());
- } finally {
- window.removeEventListener("keydown", shortcuts);
- }
-});
-
-it.each(["image", "provider"])(
- "resets measured aspect and attribution dialog when the %s identity changes",
- (identity) => {
- const { container, rerender } = render(photoPanel());
- const img = container.querySelector('[data-testid="junction-photo"] img') as HTMLImageElement;
- Object.defineProperties(img, { naturalWidth: { value: 2000 }, naturalHeight: { value: 1000 } });
- fireEvent.load(img);
- expect(container.querySelector("[data-photo-path]")?.getAttribute("viewBox")).toBe(
- "0 0 100 50",
- );
- fireEvent.click(screen.getByRole("button", { name: "attributions" }));
- const next =
- identity === "image"
- ? replacementImage
- : { ...replacementImage, id: capturedImage.id, providerId: "another" };
- rerender(photoPanel(next));
- expect(screen.queryByRole("dialog")).toBeNull();
- expect(container.textContent).not.toContain("Complete required image notice A");
- expect(container.textContent).toContain("Required image notice B");
- expect(container.querySelector("[data-photo-path]")?.getAttribute("viewBox")).toBe(
- "0 0 100 56.25",
- );
- fireEvent.click(screen.getByRole("button", { name: "attributions" }));
- expect(screen.getByRole("dialog").textContent).toContain("Photo B");
- expect(screen.getByRole("dialog").textContent).not.toContain("Photographer A");
- },
-);
-
-it.each(["loading", "missing-url", "missing-geometry"])(
- "removes image credits and open details when the displayed photo becomes %s",
- (state) => {
- const { container, rerender } = render(photoPanel());
- fireEvent.click(screen.getByRole("button", { name: "attributions" }));
- rerender(
- ,
- );
- expect(screen.queryByRole("dialog")).toBeNull();
- expect(screen.queryByRole("contentinfo")).toBeNull();
- expect(container.querySelector('[data-testid="junction-schematic"]')).not.toBeNull();
- expect(container.textContent).not.toContain("Complete required image notice A");
- },
-);
-
-it("treats an empty captured receipt list as authoritative despite legacy image fields", () => {
- const { container } = render(
- photoPanel({
- ...capturedImage,
- contributors: [],
- author: "Invented legacy author",
- licensing: licensingFromDeclaration("CC-BY-SA-4.0"),
- }),
- );
- expect(screen.queryByRole("contentinfo")).toBeNull();
- expect(container.textContent).not.toContain("Invented legacy author");
- expect(container.textContent).toContain("Captured Sep 2019");
-});
-
-it("shows the schematic until the full photo fits its measured budget and restores it without a stale dialog", () => {
- let candidateHeight = 500;
- const layout = installMeasuredLayout(
- (el) =>
- new DOMRect(
- 0,
- 0,
- 358,
- el.dataset.testid === "junction-photo-measurement" ? candidateHeight : 250,
- ),
- );
- try {
- const panel = (height: number | null, image = capturedImage) => (
-
- );
- const { container, rerender } = render(panel(null));
- const measurement = container.querySelector('[data-testid="junction-photo-measurement"]');
- expect(measurement).toHaveAttribute("inert");
- expect(measurement).toHaveAttribute("aria-hidden", "true");
- expect(measurement).toHaveStyle({
- visibility: "hidden",
- position: "absolute",
- pointerEvents: "none",
- });
- expect(screen.queryByRole("contentinfo")).toBeNull();
- rerender(panel(500));
- expect(screen.getByRole("contentinfo").textContent).toContain(
- "Complete required image notice A",
- );
- fireEvent.click(screen.getByRole("button", { name: "attributions" }));
- expect(screen.getByRole("dialog")).not.toBeNull();
- rerender(panel(499));
- expect(screen.queryByRole("dialog")).toBeNull();
- expect(screen.queryByRole("contentinfo")).toBeNull();
- expect(container.querySelector('[data-testid="junction-schematic"]')).not.toBeNull();
- act(() => {
- layout.resize();
- layout.resize();
- });
- expect(screen.queryByRole("contentinfo")).toBeNull();
- rerender(panel(501));
- expect(screen.getByRole("contentinfo")).not.toBeNull();
- expect(screen.queryByRole("dialog")).toBeNull();
- act(() => {
- candidateHeight = 600;
- layout.resize();
- });
- expect(screen.queryByRole("contentinfo")).toBeNull();
- act(() => {
- candidateHeight = 350;
- layout.resize();
- });
- expect(screen.getByRole("contentinfo")).not.toBeNull();
- rerender(panel(400, { ...replacementImage, contributors: [] }));
- expect(screen.queryByRole("contentinfo")).toBeNull();
- const visibleGraphic = screen.getByRole("img", {
- name: "Use lane 5 of 5, toward Neuss-Zentrum",
- });
- expect(visibleGraphic.querySelector('[data-testid="junction-photo"]')).not.toBeNull();
- expect(visibleGraphic.querySelector('[data-testid="junction-schematic"]')).toBeNull();
- } finally {
- layout.restore();
- }
-});
-
-it("keeps an initially schematic-only card safe after the host budget becomes available", () => {
- const { container } = render(
- ,
- );
- expect(container.querySelector('[data-testid="junction-schematic"]')).not.toBeNull();
- expect(container.querySelector('[data-testid="junction-photo-measurement"]')).toBeNull();
- expect(screen.queryByRole("contentinfo")).toBeNull();
-});
-
-it("keeps each measured and visible route ribbon bound to its own gradient", () => {
- const layout = installMeasuredLayout(() => new DOMRect(0, 0, 358, 400));
- try {
- const { container } = render(
- ,
- );
- const gradients = [...container.querySelectorAll("linearGradient")];
- expect(gradients).toHaveLength(2);
- expect(new Set(gradients.map((gradient) => gradient.id)).size).toBe(2);
- for (const gradient of gradients) {
- expect(gradient.closest("svg")?.querySelector("polygon")).toHaveAttribute(
- "fill",
- `url(#${gradient.id})`,
- );
- }
- } finally {
- layout.restore();
- }
-});
diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx
deleted file mode 100644
index c3a506acd..000000000
--- a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-"use client";
-
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import {
- buildJunctionSchematic,
- type GantryModel,
- type JunctionDecisionPoint,
- type LngLat,
- type StreetLevelImage,
- signHeadline,
- visibleToward,
-} from "@openmapx/core";
-import { useTranslations } from "next-intl";
-import { type Ref, useLayoutEffect, useState } from "react";
-import type { JunctionPhotoState } from "@/lib/navigation/junctionStore";
-import { GantryStrip } from "./GantryStrip";
-import { JunctionPhoto, JunctionPhotoAttribution } from "./JunctionPhoto";
-import { JunctionSchematicView } from "./JunctionSchematic";
-
-interface Props {
- point: JunctionDecisionPoint;
- /** The gantry fetched from OSM, when available; otherwise an engine-only model is built. */
- gantry?: GantryModel;
- /** The prefetched photo for this decision point, when one qualified. */
- photo?: JunctionPhotoState;
- /** The route being driven, for projecting the path ahead onto the photo. */
- geometry?: LngLat[];
- /** Whole-card budget measured by the containing navigation chrome; null while pending. */
- availableHeight?: number | null;
- panelRef?: Ref;
-}
-
-/**
- * The engine-only gantry, shown until real OSM lane tags arrive: the exit
- * panel drawn from the engine sign over the lanes the engine marked active,
- * or bare lane bands when the engine sent lanes but no sign. `null` when
- * there is nothing to draw at all.
- */
-function engineModel(point: JunctionDecisionPoint): GantryModel | null {
- const headline = visibleToward(signHeadline(point.sign));
- const exitNumber = point.sign?.exitNumbers?.[0];
- const laneCount = point.laneCount ?? 0;
- if (!headline.length && !exitNumber && laneCount === 0) return null;
- const panels: GantryModel["panels"] =
- headline.length || exitNumber
- ? [
- {
- lanes: point.activeLanes.length
- ? point.activeLanes
- : [point.side === "left" ? 0 : Math.max(laneCount, 1) - 1],
- destinations: headline,
- refs: visibleToward(point.sign?.exitBranches ?? []),
- symbols: [],
- isExit: true,
- ...(exitNumber ? { exitNumber } : {}),
- },
- ]
- : [];
- return {
- laneCount: Math.max(laneCount, 1),
- panels,
- activeLanes: point.activeLanes,
- source: "engine",
- };
-}
-
-const CARD_SX = {
- bgcolor: "background.paper",
- borderRadius: 3,
- overflow: "hidden",
- p: 1,
- display: "flex",
- flexDirection: "column",
- gap: 1,
- boxSizing: "border-box",
-} as const;
-
-type DisplayedPhoto = { image: StreetLevelImage; objectUrl: string; geometry: LngLat[] };
-
-/**
- * The junction card: gantry strip on top, photo preview or schematic below.
- * `role="img"` with a lane summary and the toward places — the SVG and photo
- * inside are hidden from assistive tech, and the graphic is not live, so an
- * approach never chatters. Captured image credits sit beside the graphic.
- */
-export function JunctionViewPanel({
- point,
- gantry,
- photo,
- geometry,
- availableHeight,
- panelRef,
-}: Props) {
- const t = useTranslations("navigation");
- const model = gantry ?? engineModel(point);
- const candidatePhoto =
- photo?.status === "ready" && photo.image && photo.objectUrl && geometry
- ? { image: photo.image, objectUrl: photo.objectUrl, geometry }
- : null;
- const [measurementEl, setMeasurementEl] = useState(null);
- const [measurement, setMeasurement] = useState<{
- image: StreetLevelImage;
- element: HTMLElement;
- height: number;
- } | null>(null);
- const candidateImage = candidatePhoto?.image;
- useLayoutEffect(() => {
- if (!measurementEl || !candidateImage) return;
- const update = () => {
- const height = measurementEl.getBoundingClientRect().height;
- setMeasurement((previous) =>
- previous?.image === candidateImage &&
- previous.element === measurementEl &&
- previous.height === height
- ? previous
- : { image: candidateImage, element: measurementEl, height },
- );
- };
- update();
- const observer = new ResizeObserver(update);
- observer.observe(measurementEl);
- return () => observer.disconnect();
- }, [measurementEl, candidateImage]);
- if (!model) return null;
- const lane = (model.activeLanes[0] ?? model.laneCount - 1) + 1;
- const toward = visibleToward(signHeadline(point.sign));
- const label = [
- t("junctionLaneSummary", { lane, total: model.laneCount }),
- toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null,
- ]
- .filter(Boolean)
- .join(", ");
- const photoKey = candidatePhoto
- ? JSON.stringify([candidatePhoto.image.providerId, candidatePhoto.image.id])
- : undefined;
- const fits =
- availableHeight === undefined ||
- (availableHeight !== null &&
- measurement !== null &&
- measurement.image === candidateImage &&
- measurement.element === measurementEl &&
- measurement.height > 0 &&
- measurement.height <= availableHeight);
- return (
-
-
- {availableHeight !== undefined && candidatePhoto && (
-
-
-
- )}
-
- );
-}
-
-/** Same intrinsic layout for displayed guidance and the always-hidden full-photo candidate. */
-function JunctionBody({
- model,
- point,
- label,
- photo,
- photoKey,
-}: {
- model: GantryModel;
- point: JunctionDecisionPoint;
- label: string;
- photo: DisplayedPhoto | null;
- photoKey?: string;
-}) {
- return (
- <>
-
- {model.panels.length > 0 && }
- {photo ? (
-
- ) : (
-
- )}
-
- {photo && }
- >
- );
-}
-
-function SchematicBody({ model, point }: { model: GantryModel; point: JunctionDecisionPoint }) {
- const t = useTranslations("navigation");
- return (
-
-
- {t("junctionViewLabel")}
-
-
-
- );
-}
diff --git a/apps/web/src/components/navigation/navigationRenderBoundaries.test.tsx b/apps/web/src/components/navigation/navigationRenderBoundaries.test.tsx
index af41152c9..39b713e25 100644
--- a/apps/web/src/components/navigation/navigationRenderBoundaries.test.tsx
+++ b/apps/web/src/components/navigation/navigationRenderBoundaries.test.tsx
@@ -33,7 +33,7 @@ vi.mock("@/lib/useWakeLock", () => ({ useWakeLock: () => {} }));
vi.mock("@/lib/navigation/useNavigationEngine", () => ({ useNavigationEngine: () => {} }));
vi.mock("@/lib/navigation/useNavCamera", () => ({ useNavCamera: () => {} }));
vi.mock("@/lib/navigation/useNavAlerts", () => ({ useNavAlerts: () => null }));
-vi.mock("@/lib/navigation/useNavJunctions", () => ({ useNavJunctions: () => {} }));
+vi.mock("@/lib/navigation/useNavLaneGuidance", () => ({ useNavLaneGuidance: () => {} }));
// Mutable so individual tests can flip the pending-session-resume case without
// re-declaring the whole module mock. Referenced only from inside the factory's
diff --git a/apps/web/src/components/navigation/signs/ExitSignStrip.tsx b/apps/web/src/components/navigation/signs/ExitSignStrip.tsx
index c57906572..954fc1ad5 100644
--- a/apps/web/src/components/navigation/signs/ExitSignStrip.tsx
+++ b/apps/web/src/components/navigation/signs/ExitSignStrip.tsx
@@ -2,7 +2,13 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
-import { type ManeuverSign, signHeadline, visibleToward } from "@openmapx/core";
+import {
+ cleanSignTexts,
+ exitNumberLabel,
+ type ManeuverSign,
+ signHeadline,
+ visibleToward,
+} from "@openmapx/core";
import { useTranslations } from "next-intl";
import { RouteShield } from "./RouteShield";
import { signPalette } from "./signPalette";
@@ -24,9 +30,10 @@ interface Props {
*/
export function ExitSignStrip({ sign, country, size = "banner", hideHeadline = false }: Props) {
const t = useTranslations("navigation");
- const exitNumber = sign.exitNumbers?.[0];
- const shields = visibleToward(sign.exitBranches ?? [], 3);
+ const exitNumber = exitNumberLabel(sign.exitNumbers?.[0]);
+ const shields = cleanSignTexts(sign.exitBranches ?? [], true).slice(0, 3);
const headline = hideHeadline ? [] : visibleToward(signHeadline(sign));
+
const palette = signPalette(country ?? null);
if (!exitNumber && shields.length === 0 && headline.length === 0) return null;
return (
diff --git a/apps/web/src/components/settings/NavigationSettingsDialog.test.tsx b/apps/web/src/components/settings/NavigationSettingsDialog.test.tsx
new file mode 100644
index 000000000..1e45ab73d
--- /dev/null
+++ b/apps/web/src/components/settings/NavigationSettingsDialog.test.tsx
@@ -0,0 +1,21 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import { MobileRuntimeProvider } from "@/lib/mobile/MobileRuntimeProvider";
+import { NavigationSettingsDialog } from "./NavigationSettingsDialog";
+
+vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl());
+afterEach(cleanup);
+
+it("keeps voice guidance and speed-camera preferences available", () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const view = render(
+
+
+ {}} />
+
+ ,
+ );
+ expect(view.getByText("navigationSettings.voiceGuidance")).toBeTruthy();
+ expect(view.getByText("settings.speedCameraAlerts")).toBeTruthy();
+});
diff --git a/apps/web/src/components/settings/NavigationSettingsDialog.tsx b/apps/web/src/components/settings/NavigationSettingsDialog.tsx
index 24f5fc60f..6b08a42af 100644
--- a/apps/web/src/components/settings/NavigationSettingsDialog.tsx
+++ b/apps/web/src/components/settings/NavigationSettingsDialog.tsx
@@ -65,10 +65,6 @@ export function NavigationSettingsDialog({
const setAutoSwitchFasterRoutes = useSettingsStore((s) => s.setAutoSwitchFasterRoutes);
const mapNorthUp = useSettingsStore((s) => s.mapNorthUp);
const setMapNorthUp = useSettingsStore((s) => s.setMapNorthUp);
- const junctionView = useSettingsStore((s) => s.junctionView);
- const setJunctionView = useSettingsStore((s) => s.setJunctionView);
- const junctionPhotos = useSettingsStore((s) => s.junctionPhotos);
- const setJunctionPhotos = useSettingsStore((s) => s.setJunctionPhotos);
const avoidHighways = useDirectionsStore((s) => s.avoidHighways);
const setAvoidHighways = useDirectionsStore((s) => s.setAvoidHighways);
@@ -151,18 +147,6 @@ export function NavigationSettingsDialog({
{hint(ts("speedCameraAlertsHint"))}
-
-
-
- {hint(ts("junctionViewHint"))}
-
-
-
- {hint(ts("junctionPhotosHint"))}
diff --git a/apps/web/src/lib/navigation/junctionStore.ts b/apps/web/src/lib/navigation/junctionStore.ts
deleted file mode 100644
index 424c42659..000000000
--- a/apps/web/src/lib/navigation/junctionStore.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import type {
- GantryModel,
- JunctionDecisionPoint,
- LaneDestinations,
- StreetLevelImage,
-} from "@openmapx/core";
-import { create } from "zustand";
-
-/**
- * Photo state for one decision point. `idle` until the rolling photo window
- * reaches it; `ready` carries the selected image, and `objectUrl` its bytes
- * once they have been prefetched (`bytesRequested` marks the fetch in flight).
- */
-export interface JunctionPhotoState {
- status: "idle" | "loading" | "ready" | "none";
- image?: StreetLevelImage;
- objectUrl?: string;
- bytesRequested?: boolean;
-}
-
-/** Junction state kept across a route change, keyed by the new route's step indices. */
-export interface CarriedJunctionState {
- gantryByStep: Map;
- laneDestinationsByStep: Map;
- fullLanesByStep: Map;
- photoByStep: Map;
-}
-
-/**
- * Web-local junction state, keyed by decision-point step index. Populated once
- * per route by `useNavJunctions`; the per-fix chrome reads it with a single
- * `Map.get` (see `JunctionViewSlot`).
- */
-interface NavJunctionState {
- routeKey: string | null;
- decisionPoints: JunctionDecisionPoint[];
- byStep: Map;
- gantryByStep: Map;
- /** Where each of the route's lanes leads per OSM, for the lane guidance. */
- laneDestinationsByStep: Map;
- /** Metres before the split from which OSM has the exit lanes in place. */
- fullLanesByStep: Map;
- photoByStep: Map;
- reset: () => void;
- /** Start a route's points; `carried` holds what the previous route already had for the same junctions. */
- setDecisionPoints: (
- routeKey: string,
- points: JunctionDecisionPoint[],
- carried?: CarriedJunctionState,
- ) => void;
- /** Add points confirmed after route start (candidates OSM promoted), keeping gantries and photos. */
- addDecisionPoints: (points: JunctionDecisionPoint[]) => void;
- setGantry: (stepIndex: number, model: GantryModel) => void;
- setLaneDestinations: (stepIndex: number, lanes: LaneDestinations[]) => void;
- setFullLanesFrom: (stepIndex: number, meters: number) => void;
- setPhoto: (stepIndex: number, photo: JunctionPhotoState) => void;
-}
-
-export const useNavJunctionStore = create((set) => ({
- routeKey: null,
- decisionPoints: [],
- byStep: new Map(),
- gantryByStep: new Map(),
- laneDestinationsByStep: new Map(),
- fullLanesByStep: new Map(),
- photoByStep: new Map(),
- reset: () =>
- set({
- routeKey: null,
- decisionPoints: [],
- byStep: new Map(),
- gantryByStep: new Map(),
- laneDestinationsByStep: new Map(),
- fullLanesByStep: new Map(),
- photoByStep: new Map(),
- }),
- setDecisionPoints: (routeKey, points, carried) =>
- set({
- routeKey,
- decisionPoints: points,
- byStep: new Map(points.map((p) => [p.stepIndex, p])),
- gantryByStep: carried?.gantryByStep ?? new Map(),
- laneDestinationsByStep: carried?.laneDestinationsByStep ?? new Map(),
- fullLanesByStep: carried?.fullLanesByStep ?? new Map(),
- photoByStep: carried?.photoByStep ?? new Map(),
- }),
- addDecisionPoints: (points) =>
- set((state) => {
- const merged = [
- ...state.decisionPoints,
- ...points.filter((point) => !state.byStep.has(point.stepIndex)),
- ].sort((a, b) => a.stepIndex - b.stepIndex);
- return { decisionPoints: merged, byStep: new Map(merged.map((p) => [p.stepIndex, p])) };
- }),
- setGantry: (stepIndex, model) =>
- set((state) => ({ gantryByStep: new Map(state.gantryByStep).set(stepIndex, model) })),
- setLaneDestinations: (stepIndex, lanes) =>
- set((state) => ({
- laneDestinationsByStep: new Map(state.laneDestinationsByStep).set(stepIndex, lanes),
- })),
- setFullLanesFrom: (stepIndex, meters) =>
- set((state) => ({ fullLanesByStep: new Map(state.fullLanesByStep).set(stepIndex, meters) })),
- setPhoto: (stepIndex, photo) =>
- set((state) => ({ photoByStep: new Map(state.photoByStep).set(stepIndex, photo) })),
-}));
diff --git a/apps/web/src/lib/navigation/laneGuidanceStore.ts b/apps/web/src/lib/navigation/laneGuidanceStore.ts
new file mode 100644
index 000000000..347ecfb57
--- /dev/null
+++ b/apps/web/src/lib/navigation/laneGuidanceStore.ts
@@ -0,0 +1,41 @@
+import type { ApproachLaneGuidance, JunctionDecisionPoint, Route } from "@openmapx/core";
+import { create } from "zustand";
+
+/** Lane evidence belongs to a route object, not merely to a reusable step index. */
+interface NavLaneGuidanceState {
+ routeIdentity: Route | null;
+ byStep: Map;
+ guidanceByStep: Map;
+ reset: () => void;
+ setRoute: (
+ route: Route,
+ points: JunctionDecisionPoint[],
+ guidance: Map,
+ ) => void;
+ setGuidance: (stepIndex: number, guidance: ApproachLaneGuidance) => void;
+ addDecisionPoints: (points: JunctionDecisionPoint[]) => void;
+}
+
+export const useNavLaneGuidanceStore = create((set) => ({
+ routeIdentity: null,
+ byStep: new Map(),
+ guidanceByStep: new Map(),
+ reset: () => set({ routeIdentity: null, byStep: new Map(), guidanceByStep: new Map() }),
+ setRoute: (routeIdentity, points, guidanceByStep) =>
+ set({
+ routeIdentity,
+ byStep: new Map(points.map((point) => [point.stepIndex, point])),
+ guidanceByStep,
+ }),
+ setGuidance: (stepIndex, guidance) =>
+ set((state) => ({
+ guidanceByStep: new Map(state.guidanceByStep).set(stepIndex, guidance),
+ })),
+ addDecisionPoints: (points) =>
+ set((state) => ({
+ byStep: new Map([
+ ...state.byStep,
+ ...points.map((point) => [point.stepIndex, point] as const),
+ ]),
+ })),
+}));
diff --git a/apps/web/src/lib/navigation/useJunctionPreviewBudget.test.tsx b/apps/web/src/lib/navigation/useJunctionPreviewBudget.test.tsx
deleted file mode 100644
index b1c1ae7ef..000000000
--- a/apps/web/src/lib/navigation/useJunctionPreviewBudget.test.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { act, renderHook } from "@testing-library/react";
-import { afterEach, expect, it } from "vitest";
-import { installMeasuredLayout } from "@/test/measuredLayout";
-import { useJunctionPreviewBudget } from "./useJunctionPreviewBudget";
-
-let restore: (() => void) | undefined;
-afterEach(() => restore?.());
-
-it("reserves measured controls and following banner content even when the photo pushes the bottom below the shell", () => {
- const shell = document.createElement("div");
- const banner = document.createElement("div");
- const junction = document.createElement("div");
- const bottom = document.createElement("div");
- const speedLimit = document.createElement("div");
- const panel = document.createElement("div");
- const bottomHeader = document.createElement("div");
- panel.style.marginBottom = "16px";
- panel.append(bottomHeader);
- let shellHeight = 844;
- let bottomTop = 1000;
- let tail = 24;
- const layout = installMeasuredLayout((el) => {
- if (el === shell) return new DOMRect(0, 0, 390, shellHeight);
- if (el === junction) return new DOMRect(0, 150, 358, 500);
- if (el === banner) return new DOMRect(0, 0, 390, 650 + tail);
- if (el === bottom) return new DOMRect(0, bottomTop, 390, 10);
- if (el === speedLimit) return new DOMRect(0, 0, 64, 64);
- if (el === bottomHeader) return new DOMRect(0, 0, 390, 96);
- return new DOMRect();
- });
- restore = layout.restore;
- const { result, rerender } = renderHook(
- (isMobile: boolean) =>
- useJunctionPreviewBudget({
- shell,
- banner,
- junction,
- bottom,
- speedLimit,
- bottomHeader,
- isMobile,
- sheetClearance: 96,
- speedLimitRidesSheet: false,
- sheetOccupiedHeight: 96,
- mobilePanelPending: false,
- }),
- { initialProps: false },
- );
- // 844 - (64 + 96 + 16) - 150 - 24; affected bottomTop=1000 cannot certify a fit.
- expect(result.current).toBe(494);
- act(() => {
- layout.resize();
- layout.resize();
- });
- expect(result.current).toBe(494);
- act(() => {
- shellHeight = 600;
- layout.resize();
- });
- expect(result.current).toBe(250);
- act(() => {
- shellHeight = 1000;
- bottomTop = 800;
- tail = 60;
- layout.resize();
- });
- expect(result.current).toBe(590);
- speedLimit.style.marginBottom = "96px";
- rerender(true);
- // Actual sheet clearance is included once in the speed-limit wrapper margin.
- expect(result.current).toBe(590);
-});
-
-it("reserves the measured mobile sheet even when no speed badge is rendered", () => {
- const shell = document.createElement("div");
- const banner = document.createElement("div");
- const junction = document.createElement("div");
- const bottom = document.createElement("div");
- const layout = installMeasuredLayout((el) =>
- el === shell
- ? new DOMRect(0, 0, 390, 844)
- : el === junction
- ? new DOMRect(0, 150, 358, 250)
- : el === banner
- ? new DOMRect(0, 0, 390, 416)
- : new DOMRect(0, 844, 390, 0),
- );
- restore = layout.restore;
- const { result, rerender } = renderHook(
- ({ pending, occupied }: { pending: boolean; occupied: number }) =>
- useJunctionPreviewBudget({
- shell,
- banner,
- junction,
- bottom,
- speedLimit: null,
- bottomHeader: null,
- isMobile: true,
- sheetClearance: 120,
- speedLimitRidesSheet: false,
- sheetOccupiedHeight: occupied,
- mobilePanelPending: pending,
- }),
- { initialProps: { pending: true, occupied: 120 } },
- );
- expect(result.current).toBeNull();
- rerender({ pending: false, occupied: 120 });
- expect(result.current).toBe(558);
- // Expanded sheet exceeds the intentionally capped badge-follow distance.
- rerender({ pending: false, occupied: 700 });
- expect(result.current).toBe(0);
- rerender({ pending: false, occupied: 120 });
- expect(result.current).toBe(558);
-});
-
-it("reserves the sheet clearance above a speed badge that rides the sheet by translation", () => {
- const shell = document.createElement("div");
- const banner = document.createElement("div");
- const junction = document.createElement("div");
- const bottom = document.createElement("div");
- const speedLimit = document.createElement("div");
- const layout = installMeasuredLayout((el) => {
- if (el === shell) return new DOMRect(0, 0, 390, 844);
- if (el === junction) return new DOMRect(0, 150, 358, 500);
- if (el === banner) return new DOMRect(0, 0, 390, 650);
- if (el === bottom) return new DOMRect(0, 1000, 390, 10);
- if (el === speedLimit) return new DOMRect(0, 0, 64, 64);
- return new DOMRect();
- });
- restore = layout.restore;
- const { result, rerender } = renderHook(
- (speedLimitRidesSheet: boolean) =>
- useJunctionPreviewBudget({
- shell,
- banner,
- junction,
- bottom,
- speedLimit,
- bottomHeader: null,
- isMobile: true,
- sheetClearance: 120,
- speedLimitRidesSheet,
- sheetOccupiedHeight: 120,
- mobilePanelPending: false,
- }),
- { initialProps: false },
- );
- // A margin badge has no 120px margin in this layout, so the clearance alone counts.
- expect(result.current).toBe(844 - 120 - 150);
- // Translated, it stands on the sheet: its own 64px sit above the clearance.
- rerender(true);
- expect(result.current).toBe(844 - (64 + 120) - 150);
-});
diff --git a/apps/web/src/lib/navigation/useJunctionPreviewBudget.ts b/apps/web/src/lib/navigation/useJunctionPreviewBudget.ts
deleted file mode 100644
index 10ccc9dfc..000000000
--- a/apps/web/src/lib/navigation/useJunctionPreviewBudget.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client";
-
-import { useLayoutEffect, useState } from "react";
-
-interface LayoutElements {
- shell: HTMLElement | null;
- banner: HTMLElement | null;
- junction: HTMLElement | null;
- bottom: HTMLElement | null;
- speedLimit: HTMLElement | null;
- bottomHeader: HTMLElement | null;
- isMobile: boolean;
- sheetClearance: number;
- /** The speed badge rides the sheet by translation, so its margins exclude the clearance. */
- speedLimitRidesSheet: boolean;
- sheetOccupiedHeight: number;
- mobilePanelPending: boolean;
-}
-
-function verticalMargins(element: HTMLElement | null): number {
- if (!element) return 0;
- const style = getComputedStyle(element);
- return (Number.parseFloat(style.marginTop) || 0) + (Number.parseFloat(style.marginBottom) || 0);
-}
-
-/** Real layout budget, independent of an overflowing photo pushing/shrinking bottom controls. */
-export function useJunctionPreviewBudget({
- shell,
- banner,
- junction,
- bottom,
- speedLimit,
- bottomHeader,
- isMobile,
- sheetClearance,
- speedLimitRidesSheet,
- sheetOccupiedHeight,
- mobilePanelPending,
-}: LayoutElements): number | null {
- const [height, setHeight] = useState(null);
- useLayoutEffect(() => {
- if (!shell || !banner || !junction || !bottom || mobilePanelPending) {
- setHeight(null);
- return;
- }
- const update = () => {
- const shellRect = shell.getBoundingClientRect();
- const junctionRect = junction.getBoundingClientRect();
- if (shellRect.height <= 0 || junctionRect.width <= 0) {
- setHeight(null);
- return;
- }
- const speedHeight =
- (speedLimit?.getBoundingClientRect().height ?? 0) +
- verticalMargins(speedLimit) +
- (speedLimitRidesSheet ? sheetClearance : 0);
- const headerHeight = bottomHeader?.getBoundingClientRect().height ?? 0;
- // The speed badge's height now includes sheet clearance, from its mobile
- // margin or, while it rides the sheet, added above.
- // Desktop headers never shrink, even if their containing panel does.
- const minimumControls = isMobile
- ? Math.max(speedHeight, sheetClearance, sheetOccupiedHeight, headerHeight)
- : speedHeight + headerHeight + verticalMargins(bottomHeader?.parentElement ?? null);
- const controlsTop = Math.min(
- bottom.getBoundingClientRect().top,
- shellRect.bottom - minimumControls,
- );
- const followingContent = Math.max(
- 0,
- banner.getBoundingClientRect().bottom - junctionRect.bottom,
- );
- setHeight(Math.max(0, controlsTop - junctionRect.top - followingContent));
- };
- update();
- const observer = new ResizeObserver(update);
- for (const element of [
- shell,
- banner,
- junction,
- bottom,
- speedLimit,
- bottomHeader,
- bottomHeader?.parentElement,
- ]) {
- if (element) observer.observe(element);
- }
- window.addEventListener("resize", update);
- return () => {
- observer.disconnect();
- window.removeEventListener("resize", update);
- };
- }, [
- shell,
- banner,
- junction,
- bottom,
- speedLimit,
- bottomHeader,
- isMobile,
- sheetClearance,
- speedLimitRidesSheet,
- sheetOccupiedHeight,
- mobilePanelPending,
- ]);
- return height;
-}
diff --git a/apps/web/src/lib/navigation/useNavJunctions.test.tsx b/apps/web/src/lib/navigation/useNavJunctions.test.tsx
deleted file mode 100644
index f40f54af4..000000000
--- a/apps/web/src/lib/navigation/useNavJunctions.test.tsx
+++ /dev/null
@@ -1,796 +0,0 @@
-import type { Route } from "@openmapx/core";
-import { useNavigationStore, useSettingsStore } from "@openmapx/core";
-import { licensingFromDeclaration } from "@openmapx/mobility-core/licensing";
-import { act, renderHook, waitFor } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-const fetchJunctionLookups = vi.fn();
-const searchStreetLevelImages = vi.fn();
-
-vi.mock("@openmapx/core", async (importOriginal) => {
- const actual = await importOriginal>();
- return {
- ...actual,
- fetchJunctionLookups: (...args: unknown[]) => fetchJunctionLookups(...args),
- searchStreetLevelImages: (...args: unknown[]) => searchStreetLevelImages(...args),
- };
-});
-
-// The configured street-level providers, as the app's query hook reports them:
-// two whose terms allow navigation use, and Mapillary, whose terms do not.
-const providers = [
- { id: "panoramax", allowsNavigationUse: true },
- { id: "panoramax-ign", allowsNavigationUse: true },
- { id: "mapillary", allowsNavigationUse: false },
-];
-vi.mock("@/integration-api/components/useStreetLevelProviders", () => ({
- useStreetLevelProviders: () => ({ providers, isLoading: false }),
-}));
-
-const { useNavJunctions } = await import("./useNavJunctions");
-const { useNavJunctionStore } = await import("./junctionStore");
-const fixture = await import(
- "../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json"
-);
-
-const a57Route = fixture.route as unknown as Route;
-
-const fixtureResponse = [
- {
- index: 0,
- approach: [
- {
- wayId: 314653469,
- highway: "motorway",
- bearing: 283,
- endDistanceMeters: 0,
- startDistanceMeters: 190,
- tags: {
- lanes: 5,
- destinationLanes:
- "Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Heinsberg;Aachen;Neuss-Holzheim|Heinsberg;Aachen;Neuss-Holzheim",
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
- turnLanes: "none|none|none|none|slight_right",
- },
- },
- ],
- ramps: [
- {
- wayId: 163585518,
- highway: "motorway_link",
- bearing: 300,
- endDistanceMeters: -170,
- startDistanceMeters: 0,
- tags: { destination: "Neuss-Zentrum" },
- },
- ],
- onMotorway: true,
- },
-];
-
-/** The fixture route as a backend that never flags motorways would send it. */
-const unflaggedRoute: Route = {
- ...a57Route,
- steps: a57Route.steps.map((step) => ({ ...step, motorway: undefined })),
-};
-
-function progress(currentStepIndex: number) {
- return {
- currentStepIndex,
- distanceToNextManeuver: 400,
- distanceRemaining: 1400,
- durationRemaining: 80,
- snapped: [6.676, 51.179] as [number, number],
- alongMeters: 1000,
- deviationMeters: 0,
- segmentIndex: 0,
- etaEpochMs: 0,
- bearing: 283,
- speedMps: 30,
- };
-}
-
-function start(): void {
- useNavigationStore.setState({
- kind: "ground",
- status: "navigating",
- mode: "driving",
- connectivity: "online",
- route: a57Route,
- progress: progress(0),
- });
-}
-
-const flush = () =>
- act(async () => {
- await Promise.resolve();
- });
-
-/** A northbound motorway with an exit every 500 m: each follows the last, so each counts. */
-function motorwayWithExits(exits: number): { route: Route; geometry: [number, number][] } {
- const STEP_M = 500;
- const geometry = Array.from(
- { length: exits + 2 },
- (_, i) => [6.7, 51 + (i * STEP_M) / 111_320] as [number, number],
- );
- const route = {
- ...a57Route,
- distance: (exits + 1) * STEP_M,
- geometry,
- steps: [
- { instruction: "Drive", distance: STEP_M, duration: 20, coordinates: [], motorway: true },
- ...Array.from({ length: exits }, () => ({
- instruction: "Keep right",
- distance: STEP_M,
- duration: 20,
- coordinates: [],
- maneuver: { type: "fork", modifier: "right" },
- })),
- ],
- } as unknown as Route;
- return { route, geometry };
-}
-
-describe("useNavJunctions gantry fetch", () => {
- beforeEach(() => {
- useSettingsStore.setState({ junctionView: true, junctionPhotos: true });
- useNavJunctionStore.getState().reset();
- fetchJunctionLookups.mockReset();
- // The photo flow runs for every navigating route; tests that do not care
- // about photos must not see their providers hit.
- searchStreetLevelImages.mockReset();
- searchStreetLevelImages.mockResolvedValue([]);
- });
-
- afterEach(() => {
- useNavigationStore.getState().stopNavigation();
- useNavJunctionStore.getState().reset();
- });
-
- it("promotes a candidate exit once OpenStreetMap puts the route on the motorway there", async () => {
- fetchJunctionLookups.mockResolvedValue(fixtureResponse);
- start();
- act(() => {
- useNavigationStore.setState({ route: unflaggedRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().byStep.get(1)).toBeDefined();
- });
- expect(useNavJunctionStore.getState().gantryByStep.get(1)?.source).toBe("osm");
- });
-
- it("keeps a candidate out when the route is not on a motorway at the split", async () => {
- fetchJunctionLookups.mockResolvedValue([{ ...fixtureResponse[0], onMotorway: false }]);
- start();
- act(() => {
- useNavigationStore.setState({ route: unflaggedRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- await flush();
- expect(useNavJunctionStore.getState().decisionPoints).toEqual([]);
- expect(useNavJunctionStore.getState().gantryByStep.size).toBe(0);
- });
-
- it("issues exactly one request per route with ≤ 40 points and stores the gantry", async () => {
- fetchJunctionLookups.mockResolvedValue(fixtureResponse);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- const sent = fetchJunctionLookups.mock.calls[0][0] as {
- lng: number;
- lat: number;
- bearing: number;
- trace: [number, number][];
- }[];
- expect(sent).toHaveLength(1);
- expect(sent[0].trace).toHaveLength(6);
- // Oldest first: the trace runs 400 m upstream → the point → 30 m past it.
- expect(sent[0].trace[0][0]).toBeGreaterThan(sent[0].trace[4][0]);
- expect(sent[0].trace[4][0]).toBeCloseTo(6.6768, 3);
- expect(sent[0].trace[5][0]).toBeLessThan(sent[0].trace[4][0]);
-
- await waitFor(() => {
- const gantry = useNavJunctionStore.getState().gantryByStep.get(1);
- expect(gantry?.panels).toHaveLength(3);
- expect(gantry?.source).toBe("osm");
- });
- });
-
- it("looks up a long route's junctions in batches as the drive reaches them", async () => {
- const { route: longRoute, geometry } = motorwayWithExits(60);
- fetchJunctionLookups.mockImplementation((async (points: unknown[]) =>
- points.map((_, index) => ({ index, approach: [], ramps: [], onMotorway: true }))) as never);
- start();
- act(() => {
- useNavigationStore.setState({ route: longRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- expect(fetchJunctionLookups.mock.calls[0][0]).toHaveLength(40);
- await flush();
-
- // Ten looked-up exits still ahead: nothing more yet.
- act(() => {
- useNavigationStore.setState({ progress: progress(30) });
- });
- await flush();
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
-
- act(() => {
- useNavigationStore.setState({ progress: progress(31) });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
- const second = fetchJunctionLookups.mock.calls[1][0] as { lat: number }[];
- expect(second).toHaveLength(20);
- // Nearest first: the batch continues where the first one stopped.
- // (Within 55 m: the route's distances are great-circle, the fixture's spacing a flat guess.)
- expect(second[0].lat).toBeCloseTo(geometry[41][1], 3);
- });
-
- it("does not refetch or reset when only the step index advances", async () => {
- fetchJunctionLookups.mockResolvedValue(fixtureResponse);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(useNavJunctionStore.getState().gantryByStep.size).toBe(1));
- const routeKey = useNavJunctionStore.getState().routeKey;
- act(() => {
- useNavigationStore.setState({ progress: progress(1) });
- });
- await flush();
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
- expect(useNavJunctionStore.getState().routeKey).toBe(routeKey);
- expect(useNavJunctionStore.getState().gantryByStep.size).toBe(1);
- });
-
- it("fetches nothing when offline", async () => {
- start();
- act(() => {
- useNavigationStore.setState({ connectivity: "offline" });
- });
- renderHook(() => useNavJunctions());
- await flush();
- expect(fetchJunctionLookups).not.toHaveBeenCalled();
- });
-
- it("fetches nothing when junctionView is off", async () => {
- useSettingsStore.setState({ junctionView: false });
- start();
- renderHook(() => useNavJunctions());
- await flush();
- expect(fetchJunctionLookups).not.toHaveBeenCalled();
- });
-
- it("looks up and searches the exit ahead as soon as the connection returns, within the same step", async () => {
- fetchJunctionLookups.mockResolvedValue(fixtureResponse);
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- start();
- act(() => {
- useNavigationStore.setState({ connectivity: "offline" });
- });
- renderHook(() => useNavJunctions());
- await flush();
- expect(fetchJunctionLookups).not.toHaveBeenCalled();
-
- act(() => {
- useNavigationStore.setState({ connectivity: "online" });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(searchStreetLevelImages).toHaveBeenCalled());
- expect(useNavigationStore.getState().progress?.currentStepIndex).toBe(0);
- });
-
- it("looks up the exit ahead as soon as junctionView is switched on, within the same step", async () => {
- fetchJunctionLookups.mockResolvedValue(fixtureResponse);
- useSettingsStore.setState({ junctionView: false });
- start();
- renderHook(() => useNavJunctions());
- await flush();
- expect(fetchJunctionLookups).not.toHaveBeenCalled();
-
- act(() => {
- useSettingsStore.setState({ junctionView: true });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- await waitFor(() => expect(useNavJunctionStore.getState().gantryByStep.size).toBe(1));
- });
-
- it("discards a stale response from a replaced route", async () => {
- let resolveFirst: ((value: unknown) => void) | undefined;
- fetchJunctionLookups.mockImplementation(() =>
- resolveFirst
- ? Promise.resolve([{ index: 0, approach: [], ramps: [] }])
- : new Promise((resolve) => {
- resolveFirst = resolve;
- }),
- );
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- act(() => {
- useNavigationStore.setState({ route: { ...a57Route, distance: 999 } });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
- await act(async () => {
- resolveFirst?.(fixtureResponse);
- await Promise.resolve();
- });
- // The stale response must not write into the new route's store state.
- expect(useNavJunctionStore.getState().gantryByStep.size).toBe(0);
- });
-
- it("asks again at the next step for a batch whose request failed", async () => {
- const { route: longRoute } = motorwayWithExits(20);
- let calls = 0;
- fetchJunctionLookups.mockImplementation((async (points: unknown[]) => {
- calls += 1;
- return calls === 1
- ? null
- : points.map((_, index) => ({ index, approach: [], ramps: [], onMotorway: true }));
- }) as never);
- start();
- act(() => {
- useNavigationStore.setState({ route: longRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- await flush();
-
- act(() => {
- useNavigationStore.setState({ progress: progress(1) });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
- // Every exit still ahead rides the retry.
- expect(fetchJunctionLookups.mock.calls[1][0]).toHaveLength(19);
-
- // Answered now: the next step asks for nothing.
- await flush();
- act(() => {
- useNavigationStore.setState({ progress: progress(2) });
- });
- await flush();
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
- });
-
- it("asks again for the junctions OpenStreetMap could not answer for, and only those", async () => {
- const { route: longRoute } = motorwayWithExits(20);
- let calls = 0;
- fetchJunctionLookups.mockImplementation((async (points: unknown[]) => {
- calls += 1;
- return points.map((_, index) =>
- calls === 1 && index === 3
- ? { index, approach: [], ramps: [], onMotorway: false, unavailable: true }
- : { index, approach: [], ramps: [], onMotorway: true },
- );
- }) as never);
- start();
- act(() => {
- useNavigationStore.setState({ route: longRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- const first = fetchJunctionLookups.mock.calls[0][0] as { lat: number }[];
- await flush();
-
- act(() => {
- useNavigationStore.setState({ progress: progress(1) });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
- const retry = fetchJunctionLookups.mock.calls[1][0] as { lat: number }[];
- expect(retry).toEqual([first[3]]);
- });
-
- it("asks again after a delay for a batch left unanswered, without waiting for the step to change", async () => {
- vi.useFakeTimers();
- try {
- let calls = 0;
- fetchJunctionLookups.mockImplementation(async () => {
- calls += 1;
- return calls === 1 ? null : fixtureResponse;
- });
- start();
- renderHook(() => useNavJunctions());
- await flush();
- await flush();
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await vi.advanceTimersByTimeAsync(29_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
- await flush();
- expect(useNavJunctionStore.getState().gantryByStep.get(1)?.source).toBe("osm");
- expect(useNavigationStore.getState().progress?.currentStepIndex).toBe(0);
- } finally {
- vi.useRealTimers();
- }
- });
-
- it("stretches the retry delay while OpenStreetMap stays unreachable, and drops it with the route", async () => {
- vi.useFakeTimers();
- try {
- fetchJunctionLookups.mockResolvedValue(null);
- start();
- renderHook(() => useNavJunctions());
- await flush();
- await flush();
- await act(async () => {
- await vi.advanceTimersByTimeAsync(30_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
- // The second failure waits twice as long.
- await act(async () => {
- await vi.advanceTimersByTimeAsync(59_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
- await act(async () => {
- await vi.advanceTimersByTimeAsync(1_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(3);
-
- act(() => {
- useNavigationStore.getState().stopNavigation();
- });
- await act(async () => {
- await vi.advanceTimersByTimeAsync(600_000);
- });
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(3);
- } finally {
- vi.useRealTimers();
- }
- });
-
- it("looks up a junction the replacement route accepts where the previous one turned it down", async () => {
- let calls = 0;
- fetchJunctionLookups.mockImplementation(async () => {
- calls += 1;
- return calls === 1 ? [{ ...fixtureResponse[0], onMotorway: false }] : fixtureResponse;
- });
- start();
- act(() => {
- useNavigationStore.setState({ route: unflaggedRoute });
- });
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
- await flush();
- expect(useNavJunctionStore.getState().decisionPoints).toEqual([]);
-
- // The same exit, now accepted outright by the motorway flag.
- act(() => {
- useNavigationStore.setState({ route: a57Route });
- });
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
- await waitFor(() =>
- expect(useNavJunctionStore.getState().gantryByStep.get(1)?.source).toBe("osm"),
- );
- });
-
- it("leaves gantryByStep empty when the response has no qualifying way", async () => {
- fetchJunctionLookups.mockResolvedValue([{ index: 0, approach: [], ramps: [] }]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalled());
- await flush();
- expect(useNavJunctionStore.getState().gantryByStep.size).toBe(0);
- });
-
- it("clears the store when navigation ends", async () => {
- fetchJunctionLookups.mockResolvedValue([]);
- start();
- renderHook(() => useNavJunctions());
- expect(useNavJunctionStore.getState().decisionPoints).toHaveLength(1);
- act(() => {
- useNavigationStore.getState().stopNavigation();
- });
- expect(useNavJunctionStore.getState().routeKey).toBeNull();
- expect(useNavJunctionStore.getState().decisionPoints).toEqual([]);
- });
-});
-
-const PHOTO_IMAGE = {
- id: "photo-1",
- providerId: "panoramax",
- lngLat: [6.679, 51.1786] as [number, number],
- heading: 283,
- capturedAt: "2019-09-10T06:24:40+00:00",
- isPano: false,
- fovDeg: 70,
- assets: { sd: "https://panoramax.example/sd.jpg" },
- author: "motocultrice",
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined),
-};
-
-describe("useNavJunctions photo window", () => {
- const originalCreateObjectURL = URL.createObjectURL;
- const originalRevokeObjectURL = URL.revokeObjectURL;
-
- beforeEach(() => {
- useSettingsStore.setState({ junctionView: true, junctionPhotos: true });
- useNavJunctionStore.getState().reset();
- fetchJunctionLookups.mockReset();
- fetchJunctionLookups.mockResolvedValue([]);
- searchStreetLevelImages.mockReset();
- URL.createObjectURL = vi.fn(() => "blob:photo-1") as typeof URL.createObjectURL;
- URL.revokeObjectURL = vi.fn() as typeof URL.revokeObjectURL;
- vi.stubGlobal(
- "fetch",
- vi.fn(async () => new Response(new Blob(["photo"], { type: "image/jpeg" }))),
- );
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- URL.createObjectURL = originalCreateObjectURL;
- URL.revokeObjectURL = originalRevokeObjectURL;
- useNavigationStore.getState().stopNavigation();
- useNavJunctionStore.getState().reset();
- });
-
- it("searches the decision points ahead at route start, looking at the split, and marks ready", async () => {
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- const photo = useNavJunctionStore.getState().photoByStep.get(1);
- expect(photo?.status).toBe("ready");
- expect(photo?.image?.id).toBe("photo-1");
- expect(photo?.objectUrl).toBe("blob:photo-1");
- });
- expect(searchStreetLevelImages).toHaveBeenCalledTimes(1);
- const [providerId, query] = searchStreetLevelImages.mock.calls[0] as [
- string,
- { lookingAt?: [number, number]; lngLat: [number, number]; heading: number },
- ];
- expect(providerId).toBe("panoramax");
- expect(query.lookingAt).toEqual(query.lngLat);
- expect(query.heading).toBeCloseTo(283, 0);
- });
-
- it("waits for OpenStreetMap to say where the exit lanes begin before choosing a frame", async () => {
- let answer: (value: unknown) => void = () => {};
- fetchJunctionLookups.mockImplementation(
- () =>
- new Promise((resolve) => {
- answer = resolve;
- }),
- );
- // 84 m and 35 m before the split; the fifth lane opens 41 m before it.
- const farther = { ...PHOTO_IMAGE, id: "at-84", lngLat: [6.678013787, 51.1787552] };
- const inLanes = { ...PHOTO_IMAGE, id: "at-35", lngLat: [6.677331862, 51.178847745] };
- searchStreetLevelImages.mockResolvedValue([farther, inLanes]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(searchStreetLevelImages).toHaveBeenCalledTimes(1));
- await flush();
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.status).toBe("loading");
-
- await act(async () => {
- answer([{ ...fixtureResponse[0], fullLanesFromMeters: 41 }]);
- });
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.image?.id).toBe("at-35");
- });
- });
-
- it("never searches a provider whose terms forbid use during navigation", async () => {
- searchStreetLevelImages.mockResolvedValue([]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.status).toBe("none");
- });
- expect(searchStreetLevelImages.mock.calls.map((call) => call[0])).not.toContain("mapillary");
- });
-
- it("tries the second provider only when the first returns nothing, then marks none", async () => {
- searchStreetLevelImages.mockImplementation((async (providerId: string) =>
- providerId === "panoramax" ? [] : [PHOTO_IMAGE]) as never);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.status).toBe("ready");
- });
- expect(searchStreetLevelImages.mock.calls.map((call) => call[0])).toEqual([
- "panoramax",
- "panoramax-ign",
- ]);
-
- searchStreetLevelImages.mockReset();
- searchStreetLevelImages.mockResolvedValue([]);
- useNavJunctionStore.getState().reset();
- act(() => {
- useNavigationStore.setState({ route: { ...a57Route, distance: 1 } });
- });
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.status).toBe("none");
- });
- });
-
- it("runs nothing when offline or junctionPhotos is off", async () => {
- start();
- act(() => {
- useNavigationStore.setState({ connectivity: "offline" });
- });
- renderHook(() => useNavJunctions());
- await flush();
- expect(searchStreetLevelImages).not.toHaveBeenCalled();
-
- useSettingsStore.setState({ junctionPhotos: false });
- act(() => {
- useNavigationStore.setState({ connectivity: "online" });
- });
- await flush();
- expect(searchStreetLevelImages).not.toHaveBeenCalled();
- });
-
- it("fetches the thumbnail rather than the full frame when the provider has one", async () => {
- searchStreetLevelImages.mockResolvedValue([
- {
- ...PHOTO_IMAGE,
- assets: { ...PHOTO_IMAGE.assets, thumb: "https://panoramax.example/thumb.jpg" },
- },
- ]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBe("blob:photo-1");
- });
- const fetchMock = global.fetch as ReturnType;
- const proxied = String(fetchMock.mock.calls[0][0]);
- expect(proxied).toContain(encodeURIComponent("https://panoramax.example/thumb.jpg"));
- expect(proxied).not.toContain(encodeURIComponent("https://panoramax.example/sd.jpg"));
- });
-
- it("fetches a panorama's full sd frame, never its thumbnail crop", async () => {
- searchStreetLevelImages.mockResolvedValue([
- {
- ...PHOTO_IMAGE,
- isPano: true,
- fovDeg: 360,
- assets: { ...PHOTO_IMAGE.assets, thumb: "https://panoramax.example/thumb.jpg" },
- },
- ]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBe("blob:photo-1");
- });
- const fetchMock = global.fetch as ReturnType;
- const proxied = String(fetchMock.mock.calls[0][0]);
- expect(proxied).toContain(encodeURIComponent("https://panoramax.example/sd.jpg"));
- expect(proxied).not.toContain(encodeURIComponent("https://panoramax.example/thumb.jpg"));
- });
-
- it("falls back to the sd asset through the image proxy once, and not again per fix", async () => {
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBe("blob:photo-1");
- });
- const fetchMock = global.fetch as ReturnType;
- const proxied = String(fetchMock.mock.calls[0][0]);
- expect(proxied).toContain("/api/image-proxy?url=");
- expect(proxied).toContain(encodeURIComponent("https://panoramax.example/sd.jpg"));
- for (let i = 0; i < 5; i += 1) {
- act(() => {
- useNavigationStore.setState({
- progress: { ...progress(0), distanceToNextManeuver: 380 - i * 10 },
- });
- });
- }
- await flush();
- expect(fetchMock).toHaveBeenCalledTimes(1);
- expect(searchStreetLevelImages).toHaveBeenCalledTimes(1);
- });
-
- it("revokes the bytes once the decision point is passed and when a new route drops the junction", async () => {
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBe("blob:photo-1");
- });
- act(() => {
- useNavigationStore.setState({ progress: progress(1) });
- });
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:photo-1");
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBeUndefined();
-
- (URL.revokeObjectURL as ReturnType).mockClear();
- useNavJunctionStore.getState().setPhoto(1, {
- status: "ready",
- image: PHOTO_IMAGE,
- objectUrl: "blob:photo-2",
- bytesRequested: true,
- });
- act(() => {
- useNavigationStore.setState({ route: { ...a57Route, mode: "walking" }, progress: null });
- });
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:photo-2");
- });
-
- it("makes no object URL for bytes that land after the junction was passed", async () => {
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- let deliver: (() => void) | undefined;
- vi.stubGlobal(
- "fetch",
- vi.fn(
- () =>
- new Promise((resolve) => {
- deliver = () => resolve(new Response(new Blob(["photo"], { type: "image/jpeg" })));
- }),
- ),
- );
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => expect(deliver).toBeDefined());
- act(() => {
- useNavigationStore.setState({ progress: progress(1) });
- });
- await act(async () => {
- deliver?.();
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
- expect(URL.createObjectURL).not.toHaveBeenCalled();
- expect(useNavJunctionStore.getState().photoByStep.get(1)?.objectUrl).toBeUndefined();
- });
-
- it("writes nothing into the cleared store once the hook has unmounted", async () => {
- let deliver: (() => void) | undefined;
- searchStreetLevelImages.mockImplementation(
- () =>
- new Promise((resolve) => {
- deliver = () => resolve([PHOTO_IMAGE]);
- }),
- );
- start();
- const { unmount } = renderHook(() => useNavJunctions());
- await waitFor(() => expect(deliver).toBeDefined());
- unmount();
- await act(async () => {
- deliver?.();
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
- expect(useNavJunctionStore.getState().photoByStep.size).toBe(0);
- expect(URL.createObjectURL).not.toHaveBeenCalled();
- });
-
- it("keeps the gantry and photo of a junction the replacement route shares", async () => {
- fetchJunctionLookups.mockResolvedValue([{ ...fixtureResponse[0], fullLanesFromMeters: 60 }]);
- searchStreetLevelImages.mockResolvedValue([PHOTO_IMAGE]);
- start();
- renderHook(() => useNavJunctions());
- await waitFor(() => {
- const state = useNavJunctionStore.getState();
- expect(state.gantryByStep.get(1)).toBeDefined();
- expect(state.photoByStep.get(1)?.objectUrl).toBe("blob:photo-1");
- });
-
- // An accepted faster route: the same exit, one step later in the list.
- const faster: Route = {
- ...a57Route,
- steps: [
- { instruction: "Continue", distance: 0, duration: 0, coordinates: [], motorway: true },
- ...a57Route.steps,
- ],
- };
- act(() => {
- useNavigationStore.setState({ route: faster, progress: null });
- });
- const state = useNavJunctionStore.getState();
- expect(state.decisionPoints.map((point) => point.stepIndex)).toEqual([2]);
- expect(state.gantryByStep.get(2)?.source).toBe("osm");
- expect(state.fullLanesByStep.get(2)).toBe(60);
- expect(state.photoByStep.get(2)?.objectUrl).toBe("blob:photo-1");
- await flush();
- expect(URL.revokeObjectURL).not.toHaveBeenCalled();
- expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
- expect(searchStreetLevelImages).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/apps/web/src/lib/navigation/useNavJunctions.ts b/apps/web/src/lib/navigation/useNavJunctions.ts
deleted file mode 100644
index 96c958563..000000000
--- a/apps/web/src/lib/navigation/useNavJunctions.ts
+++ /dev/null
@@ -1,459 +0,0 @@
-import {
- fetchJunctionLookups,
- findJunctionCandidates,
- findJunctionDecisionPoints,
- type GantryModel,
- type JunctionDecisionPoint,
- type JunctionLookupPoint,
- type JunctionLookupResult,
- junctionLookupPoints,
- laneDestinationsAt,
- mergeExitPanel,
- parseLaneTags,
- proxyImageUrl,
- type StreetLevelImage,
- sameJunction,
- searchStreetLevelImages,
- selectApproachWay,
- selectJunctionPhoto,
- selectRampWay,
- useNavigationStore,
- useSettingsStore,
-} from "@openmapx/core";
-import { useEffect, useMemo } from "react";
-import { useStreetLevelProviders } from "@/integration-api/components/useStreetLevelProviders";
-import {
- type CarriedJunctionState,
- type JunctionPhotoState,
- useNavJunctionStore,
-} from "./junctionStore";
-
-/**
- * Monotonic route generation; bumped on every route identity change so a
- * response that arrives after a reroute is recognised as stale and dropped.
- */
-let routeGeneration = 0;
-
-/** One junction (accepted or candidate) awaiting, riding, or done with its OSM lookup. */
-interface LookupEntry {
- point: JunctionDecisionPoint;
- lookup: JunctionLookupPoint;
- /** Offered by the engine without motorway evidence; OSM decides. */
- candidate: boolean;
- status: "pending" | "sent" | "done";
- /** Settles when the batch carrying it lands. Photo selection waits for it. */
- settled: Promise;
-}
-
-/** The current route's lookups, in route order. */
-let lookupEntries: LookupEntry[] = [];
-
-/** The pending retry of lookups a batch left unanswered, if one is scheduled. */
-let retryTimer: ReturnType | null = null;
-/** Consecutive batches that came back with unanswered lookups; stretches the retry delay. */
-let failedBatches = 0;
-
-/** Gantry lookups cost one POST for up to 40 junctions. */
-const MAX_GANTRY_POINTS = 40;
-/** The next batch is sent once one of this many junctions ahead still waits for its lookup. */
-const LOOKUP_LOOKAHEAD = 10;
-/**
- * Delay before lookups a batch left unanswered are asked for again, doubling
- * per consecutive failure up to the cap. A motorway step can run for many
- * minutes, so waiting for the next step could reach the exit first; a
- * rate-limited OSM server needs the gap.
- */
-const LOOKUP_RETRY_MS = 30_000;
-const LOOKUP_RETRY_MAX_MS = 240_000;
-/** Decision points ahead of the current step the photo window searches. */
-const PHOTO_WINDOW_AHEAD = 4;
-/** Decision points ahead whose photo bytes are held in memory. */
-const PHOTO_BYTES_AHEAD = 2;
-/** Search radius around the decision point, metres; the selector keeps 30–200 m. */
-const PHOTO_SEARCH_RADIUS_M = 200;
-const PHOTO_HEADING_TOLERANCE_DEG = 30;
-/** Motorway coverage is often years old; the selector applies the 8-year cap itself. */
-const PHOTO_CAPTURED_AFTER = "2018-01-01T00:00:00Z";
-/** Search results per provider request. */
-const PHOTO_SEARCH_LIMIT = 20;
-
-/**
- * Route junction work: detect the decision points synchronously, look up the
- * per-lane gantry tags in batches of 40 as the drive reaches them, and run the
- * rolling photo window. A replacement route keeps what the previous one
- * already had for the junctions both share. Lookups and the photo window
- * advance with the step index, and resume as soon as the connection or the
- * view returns; lookups left unanswered are asked for again after a delay.
- * Nothing here runs per GPS fix.
- */
-export function useNavJunctions(): void {
- const route = useNavigationStore((s) => s.route);
- const currentStepIndex = useNavigationStore((s) => s.progress?.currentStepIndex);
- const junctionPhotos = useSettingsStore((s) => s.junctionPhotos);
- // Subscribed, not read on demand: coming back online or switching the view
- // on mid-step must start the lookups at once. A motorway step can run tens
- // of kilometres and ends at the exit, so waiting for it to advance would
- // miss that exit entirely.
- const online = useNavigationStore((s) => s.connectivity === "online");
- const junctionView = useSettingsStore((s) => s.junctionView);
- // Candidates promoted after route start join the photo window when they land.
- const decisionPointCount = useNavJunctionStore((s) => s.decisionPoints.length);
- const { providers } = useStreetLevelProviders();
- const providerIds = useMemo(
- () =>
- providers.filter((provider) => provider.allowsNavigationUse).map((provider) => provider.id),
- [providers],
- );
-
- useEffect(() => {
- const store = useNavJunctionStore.getState();
- routeGeneration += 1;
- cancelLookupRetry();
- const previousEntries = lookupEntries;
- lookupEntries = [];
- if (!route) {
- revokePhotoUrls(store.photoByStep);
- store.reset();
- return;
- }
- const generation = routeGeneration;
- const decisionPoints = findJunctionDecisionPoints(route);
- const candidates = findJunctionCandidates(route);
- const lookups = junctionLookupPoints(route, [...decisionPoints, ...candidates]);
- const candidateSteps = new Set(candidates.map((candidate) => candidate.stepIndex));
- const points = [...decisionPoints, ...candidates];
- lookupEntries = lookups
- .map(({ stepIndex, lookup }, i) => ({
- point: points[i],
- lookup,
- candidate: candidateSteps.has(stepIndex),
- status: "pending" as LookupEntry["status"],
- settled: Promise.resolve(),
- }))
- .sort((a, b) => a.point.stepIndex - b.point.stepIndex);
- const { accepted, carried } = carryOver(store, previousEntries, lookupEntries, decisionPoints);
- store.setDecisionPoints(`route-${generation}`, accepted, carried);
- }, [route]);
-
- useEffect(() => {
- if (!route || !online || !junctionView) return;
- requestLookups(routeGeneration, currentStepIndex ?? 0);
- }, [route, currentStepIndex, online, junctionView]);
-
- useEffect(() => {
- if (!route || !online || !junctionView || !junctionPhotos) return;
- if (providerIds.length === 0 || decisionPointCount === 0) return;
- advancePhotoWindow(routeGeneration, currentStepIndex ?? 0, providerIds);
- }, [
- route,
- currentStepIndex,
- online,
- junctionView,
- junctionPhotos,
- providerIds,
- decisionPointCount,
- ]);
-
- useEffect(
- () => () => {
- // Anything still in flight belongs to a view that is gone; it must not
- // write into the cleared store (or leave an object URL there).
- routeGeneration += 1;
- cancelLookupRetry();
- const store = useNavJunctionStore.getState();
- revokePhotoUrls(store.photoByStep);
- store.reset();
- lookupEntries = [];
- },
- [],
- );
-}
-
-/**
- * Hand what the previous route knew about a junction to the same junction on
- * the new one — its lookup outcome, gantry, lane start and settled photo — so
- * accepting a faster route just before an exit does not blank the panel and
- * fetch it all again. Everything not carried is released.
- */
-function carryOver(
- store: ReturnType,
- previousEntries: LookupEntry[],
- entries: LookupEntry[],
- decisionPoints: JunctionDecisionPoint[],
-): { accepted: JunctionDecisionPoint[]; carried: CarriedJunctionState } {
- const accepted = [...decisionPoints];
- const carried: CarriedJunctionState = {
- gantryByStep: new Map(),
- laneDestinationsByStep: new Map(),
- fullLanesByStep: new Map(),
- photoByStep: new Map(),
- };
- const keptUrls = new Set();
- const previousDone = previousEntries.filter((entry) => entry.status === "done");
- for (const entry of entries) {
- const previous = previousDone.find((old) => sameJunction(old.point, entry.point));
- if (!previous) continue;
- const oldStep = previous.point.stepIndex;
- const newStep = entry.point.stepIndex;
- const wasAccepted = store.byStep.has(oldStep);
- // The previous route turned it down as a candidate, so no gantry was ever
- // built for it; this route accepts it outright and looks it up afresh.
- if (!entry.candidate && !wasAccepted) continue;
- entry.status = "done";
- if (entry.candidate) {
- // A candidate the previous lookup promoted is a junction on this route
- // too; one it turned down stays out, with nothing to carry.
- if (!wasAccepted) continue;
- accepted.push(entry.point);
- }
- const gantry = store.gantryByStep.get(oldStep);
- if (gantry) carried.gantryByStep.set(newStep, gantry);
- const laneDestinations = store.laneDestinationsByStep.get(oldStep);
- if (laneDestinations) carried.laneDestinationsByStep.set(newStep, laneDestinations);
- const fullLanes = store.fullLanesByStep.get(oldStep);
- if (fullLanes !== undefined) carried.fullLanesByStep.set(newStep, fullLanes);
- const photo = store.photoByStep.get(oldStep);
- // A search or byte fetch still in flight belongs to the old route and is
- // dropped on arrival, so only settled photos move over.
- if (photo?.status === "ready" || photo?.status === "none") {
- carried.photoByStep.set(newStep, {
- ...photo,
- bytesRequested: photo.objectUrl !== undefined,
- });
- if (photo.objectUrl) keptUrls.add(photo.objectUrl);
- }
- }
- for (const photo of store.photoByStep.values()) {
- if (photo.objectUrl && !keptUrls.has(photo.objectUrl)) URL.revokeObjectURL(photo.objectUrl);
- }
- accepted.sort((a, b) => a.stepIndex - b.stepIndex);
- return { accepted, carried };
-}
-
-/**
- * Send the next batch of up to 40 lookups, nearest first, once one of the next
- * ten junctions ahead still waits for its lookup — not yet sent, or sent and
- * left unanswered. Runs at route start, when the step advances, when the
- * connection or the view comes back, and on the retry after an unanswered
- * batch — never per fix.
- */
-function requestLookups(generation: number, currentStepIndex: number): void {
- const ahead = lookupEntries.filter((entry) => entry.point.stepIndex > currentStepIndex);
- if (!ahead.slice(0, LOOKUP_LOOKAHEAD).some((entry) => entry.status === "pending")) return;
- const batch = ahead.filter((entry) => entry.status === "pending").slice(0, MAX_GANTRY_POINTS);
- if (batch.length === 0) return;
- for (const entry of batch) entry.status = "sent";
- const settled = fetchGantries(generation, batch);
- for (const entry of batch) entry.settled = settled;
-}
-
-/**
- * Ask again for what a batch left unanswered, after a delay that grows with
- * each consecutive failure. Only one retry is ever scheduled; it does nothing
- * for a superseded route, offline (the reconnect asks instead) or with the
- * view switched off.
- */
-function scheduleLookupRetry(generation: number): void {
- failedBatches += 1;
- if (retryTimer !== null) return;
- const delay = Math.min(LOOKUP_RETRY_MS * 2 ** (failedBatches - 1), LOOKUP_RETRY_MAX_MS);
- retryTimer = setTimeout(() => {
- retryTimer = null;
- if (generation !== routeGeneration) return;
- const nav = useNavigationStore.getState();
- if (nav.connectivity !== "online" || !useSettingsStore.getState().junctionView) return;
- requestLookups(generation, nav.progress?.currentStepIndex ?? 0);
- }, delay);
-}
-
-function cancelLookupRetry(): void {
- if (retryTimer !== null) clearTimeout(retryTimer);
- retryTimer = null;
- failedBatches = 0;
-}
-
-/** Release every object URL the store still holds; the bytes are transient. */
-function revokePhotoUrls(photoByStep: Map): void {
- for (const photo of photoByStep.values()) {
- if (photo.objectUrl) URL.revokeObjectURL(photo.objectUrl);
- }
-}
-
-/** The decision points still ahead of the step being driven, nearest first. */
-function pointsAhead(currentStepIndex: number, limit: number): JunctionDecisionPoint[] {
- return useNavJunctionStore
- .getState()
- .decisionPoints.filter((point) => point.stepIndex > currentStepIndex)
- .slice(0, limit);
-}
-
-/**
- * The rolling photo window: release the bytes of passed decision points,
- * search the next four ahead (once each), and hold bytes for the next two.
- */
-function advancePhotoWindow(
- generation: number,
- currentStepIndex: number,
- providerIds: string[],
-): void {
- const store = useNavJunctionStore.getState();
- for (const [stepIndex, photo] of store.photoByStep) {
- if (stepIndex <= currentStepIndex && photo.objectUrl) {
- URL.revokeObjectURL(photo.objectUrl);
- store.setPhoto(stepIndex, { ...photo, objectUrl: undefined });
- }
- }
- for (const point of pointsAhead(currentStepIndex, PHOTO_WINDOW_AHEAD)) {
- const existing = store.photoByStep.get(point.stepIndex);
- if (existing && existing.status !== "idle") continue;
- void searchPhoto(generation, point, providerIds);
- }
- prefetchWindowBytes(generation, currentStepIndex);
-}
-
-/** Search the providers in order for one qualifying image. */
-async function searchPhoto(
- generation: number,
- point: JunctionDecisionPoint,
- providerIds: string[],
-): Promise {
- const store = useNavJunctionStore.getState();
- store.setPhoto(point.stepIndex, { status: "loading" });
- const query = {
- lngLat: point.point,
- radiusM: PHOTO_SEARCH_RADIUS_M,
- heading: point.approachBearing,
- headingToleranceDeg: PHOTO_HEADING_TOLERANCE_DEG,
- capturedAfter: PHOTO_CAPTURED_AFTER,
- lookingAt: point.point,
- limit: PHOTO_SEARCH_LIMIT,
- };
- const lookup =
- lookupEntries.find((entry) => entry.point.stepIndex === point.stepIndex)?.settled ??
- Promise.resolve();
- try {
- for (const providerId of providerIds) {
- const images = await searchStreetLevelImages(providerId, query);
- await lookup;
- if (generation !== routeGeneration) return;
- // Read after the generation check, so a superseded route can never be
- // the one a photo is judged against.
- const geometry = useNavigationStore.getState().route?.geometry;
- const fullLanesFromMeters = useNavJunctionStore
- .getState()
- .fullLanesByStep.get(point.stepIndex);
- const selected = selectJunctionPhoto(images, point, new Date(), {
- ...(geometry ? { geometry } : {}),
- ...(fullLanesFromMeters !== undefined ? { fullLanesFromMeters } : {}),
- });
- if (selected) {
- store.setPhoto(point.stepIndex, { status: "ready", image: selected });
- const currentStepIndex = useNavigationStore.getState().progress?.currentStepIndex ?? 0;
- prefetchWindowBytes(generation, currentStepIndex);
- return;
- }
- }
- } catch {
- // A photo is decoration: any failure below shows the schematic instead.
- }
- if (generation === routeGeneration) store.setPhoto(point.stepIndex, { status: "none" });
-}
-
-/** Start fetching bytes for the ready photos of the next decision points. */
-function prefetchWindowBytes(generation: number, currentStepIndex: number): void {
- const store = useNavJunctionStore.getState();
- for (const point of pointsAhead(currentStepIndex, PHOTO_BYTES_AHEAD)) {
- const photo = store.photoByStep.get(point.stepIndex);
- if (photo?.status !== "ready" || !photo.image || photo.bytesRequested) continue;
- store.setPhoto(point.stepIndex, { ...photo, bytesRequested: true });
- void fetchPhotoBytes(generation, point.stepIndex, photo.image);
- }
-}
-
-/**
- * Fetch the photo's bytes through the image proxy into an object URL: the
- * thumbnail for a flat frame, the `sd` asset for a panorama.
- */
-async function fetchPhotoBytes(
- generation: number,
- stepIndex: number,
- image: StreetLevelImage,
-): Promise {
- // The panel shows the scene, not legible sign text (the gantry strip above
- // draws that), so a flat frame's thumbnail is enough: Panoramax's 500 px
- // frame is ~30 KB against ~500 KB for its 2048 px one, on mobile data, per
- // junction. A panorama's thumbnail is only a crop from its middle, while the
- // panel lays out the full 360°, so a panorama needs the whole frame.
- const source = image.isPano ? image.assets.sd : (image.assets.thumb ?? image.assets.sd);
- if (!source) return;
- try {
- const res = await fetch(proxyImageUrl(source));
- if (!res.ok) return;
- const blob = await res.blob();
- if (generation !== routeGeneration) return;
- // Bytes landing after the junction was passed would never be released:
- // the window already revoked that step and does not look back.
- const currentStepIndex = useNavigationStore.getState().progress?.currentStepIndex ?? 0;
- if (stepIndex <= currentStepIndex) return;
- const store = useNavJunctionStore.getState();
- const photo = store.photoByStep.get(stepIndex);
- if (photo?.image?.id !== image.id) return;
- store.setPhoto(stepIndex, { ...photo, objectUrl: URL.createObjectURL(blob) });
- } catch {
- // No bytes: the panel falls back to the schematic.
- }
-}
-
-/**
- * Fetch and map the gantry ways for one batch of junctions; a response from a
- * superseded route is discarded. A junction the server could not answer for —
- * the request failed, or OSM was unreachable — goes back to pending and rides
- * the next batch or the retry, so one dropped request does not cost it for
- * the whole drive.
- */
-async function fetchGantries(generation: number, batch: LookupEntry[]): Promise {
- const results = await fetchJunctionLookups(batch.map((entry) => entry.lookup));
- if (generation !== routeGeneration) return;
- const answered = batch.flatMap((entry, i) => {
- const result = results?.[i];
- if (!result || result.unavailable) {
- entry.status = "pending";
- return [];
- }
- entry.status = "done";
- return [{ entry, result }];
- });
- if (answered.length < batch.length) scheduleLookupRetry(generation);
- else failedBatches = 0;
- // A candidate becomes a junction only where OSM puts the route on a motorway
- // or trunk carriageway at the split; an on-ramp's town street never is.
- const confirmed = answered.flatMap(({ entry, result }) =>
- entry.candidate && result.onMotorway ? [entry.point] : [],
- );
- if (confirmed.length > 0) useNavJunctionStore.getState().addDecisionPoints(confirmed);
- for (const { entry, result } of answered) {
- const store = useNavJunctionStore.getState();
- const point = store.byStep.get(entry.point.stepIndex);
- if (!point) continue;
- if (result.fullLanesFromMeters !== undefined) {
- store.setFullLanesFrom(point.stepIndex, result.fullLanesFromMeters);
- }
- const model = buildGantry(result, point);
- if (model) store.setGantry(point.stepIndex, model);
- const laneDestinations = laneDestinationsAt(result, point);
- if (laneDestinations) store.setLaneDestinations(point.stepIndex, laneDestinations);
- }
-}
-
-/** Approach way → parsed lane tags → exit panel from the ramp or the engine sign. */
-function buildGantry(
- result: JunctionLookupResult,
- point: JunctionDecisionPoint,
-): GantryModel | null {
- const approach = selectApproachWay(result.approach, point);
- if (!approach) return null;
- const model = parseLaneTags(approach.tags, point.laneCount);
- if (!model) return null;
- const rampWay = selectRampWay(result.ramps, point)[0];
- return mergeExitPanel(model, point, rampWay?.tags);
-}
diff --git a/apps/web/src/lib/navigation/useNavLaneGuidance.test.tsx b/apps/web/src/lib/navigation/useNavLaneGuidance.test.tsx
new file mode 100644
index 000000000..f907d7a23
--- /dev/null
+++ b/apps/web/src/lib/navigation/useNavLaneGuidance.test.tsx
@@ -0,0 +1,524 @@
+import type { Route } from "@openmapx/core";
+import { useNavigationStore } from "@openmapx/core";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useNavLaneGuidanceStore } from "./laneGuidanceStore";
+import { useNavLaneGuidance } from "./useNavLaneGuidance";
+
+const fetchJunctionLookups = vi.hoisted(() => vi.fn());
+vi.mock("@openmapx/core", async (original) => ({
+ ...(await original>()),
+ fetchJunctionLookups: (...args: unknown[]) => fetchJunctionLookups(...args),
+}));
+const fixture = await import(
+ "../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json"
+);
+
+const a57Route = fixture.route as unknown as Route;
+const holz = await import(
+ "../../../../../packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json"
+);
+
+const fixtureResponse = [
+ {
+ index: 0,
+ approach: [
+ {
+ wayId: 314653469,
+ highway: "motorway",
+ bearing: 283,
+ endDistanceMeters: 0,
+ startDistanceMeters: 190,
+ geometry: a57Route.geometry,
+ tags: {
+ lanes: 5,
+ destinationLanes:
+ "Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Heinsberg;Aachen;Neuss-Holzheim|Heinsberg;Aachen;Neuss-Holzheim",
+ destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
+ turnLanes: "none|none|none|none|slight_right",
+ },
+ },
+ ],
+ ramps: [
+ {
+ wayId: 163585518,
+ highway: "motorway_link",
+ bearing: 300,
+ endDistanceMeters: -170,
+ startDistanceMeters: 0,
+ tags: { destination: "Neuss-Zentrum" },
+ },
+ ],
+ onMotorway: true,
+ },
+];
+
+/** The fixture route as a backend that never flags motorways would send it. */
+const unflaggedRoute: Route = {
+ ...a57Route,
+ steps: a57Route.steps.map((step) => ({ ...step, motorway: undefined })),
+};
+
+function progress(currentStepIndex: number) {
+ return {
+ currentStepIndex,
+ distanceToNextManeuver: 400,
+ distanceRemaining: 1400,
+ durationRemaining: 80,
+ snapped: [6.676, 51.179] as [number, number],
+ alongMeters: 1000,
+ deviationMeters: 0,
+ segmentIndex: 0,
+ etaEpochMs: 0,
+ bearing: 283,
+ speedMps: 30,
+ };
+}
+
+function start(): void {
+ useNavigationStore.setState({
+ kind: "ground",
+ status: "navigating",
+ mode: "driving",
+ connectivity: "online",
+ route: a57Route,
+ progress: progress(0),
+ });
+}
+
+const flush = () =>
+ act(async () => {
+ await Promise.resolve();
+ });
+
+/** A northbound motorway with an exit every 500 m: each follows the last, so each counts. */
+function motorwayWithExits(exits: number): { route: Route; geometry: [number, number][] } {
+ const STEP_M = 500;
+ const geometry = Array.from(
+ { length: exits + 2 },
+ (_, i) => [6.7, 51 + (i * STEP_M) / 111_320] as [number, number],
+ );
+ const route = {
+ ...a57Route,
+ distance: (exits + 1) * STEP_M,
+ geometry,
+ steps: [
+ { instruction: "Drive", distance: STEP_M, duration: 20, coordinates: [], motorway: true },
+ ...Array.from({ length: exits }, () => ({
+ instruction: "Keep right",
+ distance: STEP_M,
+ duration: 20,
+ coordinates: [],
+ maneuver: { type: "fork", modifier: "right" },
+ })),
+ ],
+ } as unknown as Route;
+ return { route, geometry };
+}
+
+describe("useNavLaneGuidance", () => {
+ beforeEach(() => {
+ useNavLaneGuidanceStore.getState().reset();
+ fetchJunctionLookups.mockReset();
+ });
+
+ afterEach(() => {
+ useNavigationStore.getState().stopNavigation();
+ useNavLaneGuidanceStore.getState().reset();
+ });
+
+ it("uses the graph approach even when a different approach matches the engine count", async () => {
+ const graph = {
+ ...holz.lookup.approach[0],
+ geometry: holz.route.geometry as [number, number][],
+ };
+ const farther = {
+ ...graph,
+ wayId: 999,
+ endDistanceMeters: graph.endDistanceMeters + 100,
+ tags: { lanes: 3 },
+ };
+ fetchJunctionLookups.mockResolvedValue([
+ { ...holz.lookup, outgoingApproachWayId: graph.wayId, approach: [farther, graph] },
+ ]);
+ start();
+ const route = holz.route as unknown as Route;
+ useNavigationStore.setState({
+ route: {
+ ...route,
+ steps: route.steps.map((step, index) =>
+ index === 1
+ ? {
+ ...step,
+ lanes: Array.from({ length: 3 }, () => ({
+ indications: ["straight"],
+ valid: false,
+ })),
+ }
+ : step,
+ ),
+ },
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() =>
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)).toBeDefined(),
+ );
+ const model = useNavLaneGuidanceStore.getState().guidanceByStep.get(1)!;
+ expect(model.lanes).toHaveLength(4);
+ expect(model.preferredLanes).toEqual([]);
+ });
+
+ it("promotes a candidate exit once OpenStreetMap puts the route on the motorway there", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: unflaggedRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => {
+ expect(useNavLaneGuidanceStore.getState().byStep.get(1)).toBeDefined();
+ });
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.source).toBe("engine");
+ });
+
+ it("keeps a candidate out when the route is not on a motorway at the split", async () => {
+ fetchJunctionLookups.mockResolvedValue([{ ...fixtureResponse[0], onMotorway: false }]);
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: unflaggedRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ await flush();
+ expect(useNavLaneGuidanceStore.getState().byStep.size).toBe(0);
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.size).toBe(0);
+ });
+
+ it("issues exactly one request per route with ≤ 40 points and stores the guidance", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ const sent = fetchJunctionLookups.mock.calls[0][0] as {
+ lng: number;
+ lat: number;
+ bearing: number;
+ trace: [number, number][];
+ }[];
+ expect(sent).toHaveLength(1);
+ expect(sent[0].trace).toHaveLength(6);
+ // Oldest first: the trace runs 400 m upstream → the point → 30 m past it.
+ expect(sent[0].trace[0][0]).toBeGreaterThan(sent[0].trace[4][0]);
+ expect(sent[0].trace[4][0]).toBeCloseTo(6.6768, 3);
+ expect(sent[0].trace[5][0]).toBeLessThan(sent[0].trace[4][0]);
+
+ await waitFor(() => {
+ const guidance = useNavLaneGuidanceStore.getState().guidanceByStep.get(1);
+ expect(guidance?.lanes).toHaveLength(5);
+ expect(guidance?.source).toBe("engine");
+ });
+ });
+
+ it("looks up a long route's junctions in batches as the drive reaches them", async () => {
+ const { route: longRoute, geometry } = motorwayWithExits(60);
+ fetchJunctionLookups.mockImplementation((async (points: unknown[]) =>
+ points.map((_, index) => ({ index, approach: [], ramps: [], onMotorway: true }))) as never);
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: longRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ expect(fetchJunctionLookups.mock.calls[0][0]).toHaveLength(40);
+ await flush();
+
+ // Ten looked-up exits still ahead: nothing more yet.
+ act(() => {
+ useNavigationStore.setState({ progress: progress(30) });
+ });
+ await flush();
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ useNavigationStore.setState({ progress: progress(31) });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
+ const second = fetchJunctionLookups.mock.calls[1][0] as { lat: number }[];
+ expect(second).toHaveLength(20);
+ // Nearest first: the batch continues where the first one stopped.
+ // (Within 55 m: the route's distances are great-circle, the fixture's spacing a flat guess.)
+ expect(second[0].lat).toBeCloseTo(geometry[41][1], 3);
+ });
+
+ it("does not refetch or reset when only the step index advances", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(useNavLaneGuidanceStore.getState().guidanceByStep.size).toBe(1));
+ const routeIdentity = useNavLaneGuidanceStore.getState().routeIdentity;
+ act(() => {
+ useNavigationStore.setState({ progress: progress(1) });
+ });
+ await flush();
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
+ expect(useNavLaneGuidanceStore.getState().routeIdentity).toBe(routeIdentity);
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.size).toBe(1);
+ });
+
+ it("fetches nothing when offline", async () => {
+ start();
+ act(() => {
+ useNavigationStore.setState({ connectivity: "offline" });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await flush();
+ expect(fetchJunctionLookups).not.toHaveBeenCalled();
+ });
+
+ it("looks up the exit ahead as soon as the connection returns, within the same step", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ act(() => {
+ useNavigationStore.setState({ connectivity: "offline" });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await flush();
+ expect(fetchJunctionLookups).not.toHaveBeenCalled();
+
+ act(() => {
+ useNavigationStore.setState({ connectivity: "online" });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ expect(useNavigationStore.getState().progress?.currentStepIndex).toBe(0);
+ });
+
+ it("discards a stale response from a replaced route", async () => {
+ let resolveFirst: ((value: unknown) => void) | undefined;
+ fetchJunctionLookups.mockImplementation(() =>
+ resolveFirst
+ ? Promise.resolve([{ index: 0, approach: [], ramps: [] }])
+ : new Promise((resolve) => {
+ resolveFirst = resolve;
+ }),
+ );
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ act(() => {
+ useNavigationStore.setState({
+ route: {
+ ...a57Route,
+ distance: 999,
+ steps: a57Route.steps.map((step) => ({
+ ...step,
+ lanes: step.lanes?.map((lane) => ({ ...lane, valid: undefined, preferred: undefined })),
+ })),
+ },
+ });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
+ await act(async () => {
+ resolveFirst?.(fixtureResponse);
+ await Promise.resolve();
+ });
+ // The stale response must not write into the new route's store state.
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.source).toBe("engine");
+ });
+
+ it("asks again at the next step for a batch whose request failed", async () => {
+ const { route: longRoute } = motorwayWithExits(20);
+ let calls = 0;
+ fetchJunctionLookups.mockImplementation((async (points: unknown[]) => {
+ calls += 1;
+ return calls === 1
+ ? null
+ : points.map((_, index) => ({ index, approach: [], ramps: [], onMotorway: true }));
+ }) as never);
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: longRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ await flush();
+
+ act(() => {
+ useNavigationStore.setState({ progress: progress(1) });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
+ // Every exit still ahead rides the retry.
+ expect(fetchJunctionLookups.mock.calls[1][0]).toHaveLength(19);
+
+ // Answered now: the next step asks for nothing.
+ await flush();
+ act(() => {
+ useNavigationStore.setState({ progress: progress(2) });
+ });
+ await flush();
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
+ });
+
+ it("asks again for the junctions OpenStreetMap could not answer for, and only those", async () => {
+ const { route: longRoute } = motorwayWithExits(20);
+ let calls = 0;
+ fetchJunctionLookups.mockImplementation((async (points: unknown[]) => {
+ calls += 1;
+ return points.map((_, index) =>
+ calls === 1 && index === 3
+ ? { index, approach: [], ramps: [], onMotorway: false, unavailable: true }
+ : { index, approach: [], ramps: [], onMotorway: true },
+ );
+ }) as never);
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: longRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ const first = fetchJunctionLookups.mock.calls[0][0] as { lat: number }[];
+ await flush();
+
+ act(() => {
+ useNavigationStore.setState({ progress: progress(1) });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
+ const retry = fetchJunctionLookups.mock.calls[1][0] as { lat: number }[];
+ expect(retry).toEqual([first[3]]);
+ });
+
+ it("asks again after a delay for a batch left unanswered, without waiting for the step to change", async () => {
+ vi.useFakeTimers();
+ try {
+ let calls = 0;
+ fetchJunctionLookups.mockImplementation(async () => {
+ calls += 1;
+ return calls === 1 ? null : fixtureResponse;
+ });
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await flush();
+ await flush();
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(29_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
+ await flush();
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.source).toBe("engine");
+ expect(useNavigationStore.getState().progress?.currentStepIndex).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stretches the retry delay while OpenStreetMap stays unreachable, and drops it with the route", async () => {
+ vi.useFakeTimers();
+ try {
+ fetchJunctionLookups.mockResolvedValue(null);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await flush();
+ await flush();
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(30_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
+ // The second failure waits twice as long.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(59_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(2);
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(3);
+
+ act(() => {
+ useNavigationStore.getState().stopNavigation();
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(600_000);
+ });
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(3);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("looks up a junction the replacement route accepts where the previous one turned it down", async () => {
+ let calls = 0;
+ fetchJunctionLookups.mockImplementation(async () => {
+ calls += 1;
+ return calls === 1 ? [{ ...fixtureResponse[0], onMotorway: false }] : fixtureResponse;
+ });
+ start();
+ act(() => {
+ useNavigationStore.setState({ route: unflaggedRoute });
+ });
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(1));
+ await flush();
+ expect(useNavLaneGuidanceStore.getState().byStep.size).toBe(0);
+
+ // The same exit, now accepted outright by the motorway flag.
+ act(() => {
+ useNavigationStore.setState({ route: a57Route });
+ });
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.source).toBe("engine"),
+ );
+ });
+
+ it("keeps the route engine's lane evidence when OSM has no qualifying way", async () => {
+ fetchJunctionLookups.mockResolvedValue([{ index: 0, approach: [], ramps: [] }]);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(fetchJunctionLookups).toHaveBeenCalled());
+ await flush();
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.source).toBe("engine");
+ });
+
+ it("clears the store when navigation ends", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ expect(useNavLaneGuidanceStore.getState().byStep.size).toBe(1);
+ act(() => {
+ useNavigationStore.getState().stopNavigation();
+ });
+ expect(useNavLaneGuidanceStore.getState().routeIdentity).toBeNull();
+ expect(useNavLaneGuidanceStore.getState().byStep.size).toBe(0);
+ });
+
+ it("recomputes lane advice when a replacement route shares the approach", async () => {
+ fetchJunctionLookups.mockResolvedValue(fixtureResponse);
+ start();
+ renderHook(() => useNavLaneGuidance());
+ await waitFor(() => expect(useNavLaneGuidanceStore.getState().guidanceByStep.size).toBe(1));
+ const replacement = {
+ ...a57Route,
+ steps: a57Route.steps.map((step, i) =>
+ i === 1
+ ? {
+ ...step,
+ lanes: step.lanes?.map((lane, index) => ({
+ ...lane,
+ valid: index === 0,
+ preferred: index === 0,
+ })),
+ }
+ : step,
+ ),
+ };
+ act(() => useNavigationStore.setState({ route: replacement }));
+ await waitFor(() => expect(useNavLaneGuidanceStore.getState().routeIdentity).toBe(replacement));
+ expect(fetchJunctionLookups).toHaveBeenCalledTimes(1);
+ expect(useNavLaneGuidanceStore.getState().guidanceByStep.get(1)?.usableLanes).toEqual([0]);
+ });
+});
diff --git a/apps/web/src/lib/navigation/useNavLaneGuidance.ts b/apps/web/src/lib/navigation/useNavLaneGuidance.ts
new file mode 100644
index 000000000..8fb951a29
--- /dev/null
+++ b/apps/web/src/lib/navigation/useNavLaneGuidance.ts
@@ -0,0 +1,155 @@
+import {
+ type ApproachLaneGuidance,
+ fetchJunctionLookups,
+ findJunctionCandidates,
+ findJunctionDecisionPoints,
+ type JunctionDecisionPoint,
+ type JunctionLookupPoint,
+ type JunctionLookupResult,
+ junctionLookupPoints,
+ resolveApproachLaneGuidance,
+ sameJunction,
+ useNavigationStore,
+} from "@openmapx/core";
+import { useEffect } from "react";
+import { useNavLaneGuidanceStore } from "./laneGuidanceStore";
+
+let routeGeneration = 0;
+interface LookupEntry {
+ point: JunctionDecisionPoint;
+ lookup: JunctionLookupPoint;
+ candidate: boolean;
+ status: "pending" | "sent" | "done";
+ result?: JunctionLookupResult;
+}
+let lookupEntries: LookupEntry[] = [];
+let retryTimer: ReturnType | null = null;
+let failedBatches = 0;
+const MAX_LOOKUP_POINTS = 40;
+const LOOKUP_LOOKAHEAD = 10;
+const LOOKUP_RETRY_MS = 30_000;
+const LOOKUP_RETRY_MAX_MS = 240_000;
+
+/** Batched OSM enrichment for the green lane arrows; never acquires imagery. */
+export function useNavLaneGuidance(): void {
+ const route = useNavigationStore((s) => s.route);
+ const currentStepIndex = useNavigationStore((s) => s.progress?.currentStepIndex);
+ const online = useNavigationStore((s) => s.connectivity === "online");
+ useEffect(() => {
+ const store = useNavLaneGuidanceStore.getState();
+ routeGeneration += 1;
+ cancelLookupRetry();
+ const previousEntries = lookupEntries;
+ lookupEntries = [];
+ if (!route) {
+ store.reset();
+ return;
+ }
+ const accepted = findJunctionDecisionPoints(route);
+ const candidates = findJunctionCandidates(route);
+ const points = [...accepted, ...candidates];
+ const candidateSteps = new Set(candidates.map((point) => point.stepIndex));
+ lookupEntries = junctionLookupPoints(route, points)
+ .map(({ stepIndex, lookup }, i) => ({
+ point: points[i],
+ lookup,
+ candidate: candidateSteps.has(stepIndex),
+ status: "pending" as const,
+ }))
+ .sort((a, b) => a.point.stepIndex - b.point.stepIndex);
+ const carried = new Map();
+ for (const entry of lookupEntries) {
+ const previous = previousEntries.find(
+ (old) => old.status === "done" && sameJunction(old.point, entry.point),
+ );
+ if (!previous?.result) continue;
+ const wasAccepted = store.byStep.has(previous.point.stepIndex);
+ if (!entry.candidate && !wasAccepted) continue;
+ entry.status = "done";
+ entry.result = previous.result;
+ if (entry.candidate) {
+ if (!wasAccepted) continue;
+ accepted.push(entry.point);
+ }
+ // Reuse raw lookup data, never the previous route's selected lanes.
+ const guidance = resolveApproachLaneGuidance(previous.result, entry.point);
+ if (guidance) carried.set(entry.point.stepIndex, guidance);
+ }
+ store.setRoute(route, accepted, carried);
+ }, [route]);
+ useEffect(() => {
+ if (route && online) requestLookups(routeGeneration, currentStepIndex ?? 0);
+ }, [route, currentStepIndex, online]);
+ useEffect(
+ () => () => {
+ routeGeneration += 1;
+ cancelLookupRetry();
+ lookupEntries = [];
+ useNavLaneGuidanceStore.getState().reset();
+ },
+ [],
+ );
+}
+
+function requestLookups(generation: number, currentStepIndex: number): void {
+ const ahead = lookupEntries.filter((entry) => entry.point.stepIndex > currentStepIndex);
+ if (!ahead.slice(0, LOOKUP_LOOKAHEAD).some((entry) => entry.status === "pending")) return;
+ const batch = ahead.filter((entry) => entry.status === "pending").slice(0, MAX_LOOKUP_POINTS);
+ if (batch.length === 0) return;
+ for (const entry of batch) entry.status = "sent";
+ void fetchLaneGuidance(generation, batch);
+}
+
+/**
+ * Ask again for what a batch left unanswered, after a delay that grows with
+ * each consecutive failure. Only one retry is ever scheduled; it does nothing
+ * for a superseded route, offline (the reconnect asks instead).
+ */
+function scheduleLookupRetry(generation: number): void {
+ failedBatches += 1;
+ if (retryTimer !== null) return;
+ const delay = Math.min(LOOKUP_RETRY_MS * 2 ** (failedBatches - 1), LOOKUP_RETRY_MAX_MS);
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ if (generation !== routeGeneration) return;
+ const nav = useNavigationStore.getState();
+ if (nav.connectivity !== "online") return;
+ requestLookups(generation, nav.progress?.currentStepIndex ?? 0);
+ }, delay);
+}
+
+function cancelLookupRetry(): void {
+ if (retryTimer !== null) clearTimeout(retryTimer);
+ retryTimer = null;
+ failedBatches = 0;
+}
+
+async function fetchLaneGuidance(generation: number, batch: LookupEntry[]): Promise {
+ const results = await fetchJunctionLookups(batch.map((entry) => entry.lookup));
+ if (generation !== routeGeneration) return;
+ const answered = batch.flatMap((entry, i) => {
+ const result = results?.[i];
+ if (!result || result.unavailable) {
+ entry.status = "pending";
+ return [];
+ }
+ entry.status = "done";
+ entry.result = result;
+ return [{ entry, result }];
+ });
+ if (answered.length < batch.length) scheduleLookupRetry(generation);
+ else failedBatches = 0;
+ // A candidate becomes a junction only where OSM puts the route on a motorway
+ // or trunk carriageway at the split; an on-ramp's town street never is.
+ const confirmed = answered.flatMap(({ entry, result }) =>
+ entry.candidate && result.onMotorway ? [entry.point] : [],
+ );
+ if (confirmed.length > 0) useNavLaneGuidanceStore.getState().addDecisionPoints(confirmed);
+ for (const { entry, result } of answered) {
+ const store = useNavLaneGuidanceStore.getState();
+ const point = store.byStep.get(entry.point.stepIndex);
+ if (!point) continue;
+ const guidance = resolveApproachLaneGuidance(result, point);
+ if (guidance) store.setGuidance(point.stepIndex, guidance);
+ }
+}
diff --git a/apps/web/src/test/measuredLayout.ts b/apps/web/src/test/measuredLayout.ts
deleted file mode 100644
index 45b9d33df..000000000
--- a/apps/web/src/test/measuredLayout.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { vi } from "vitest";
-
-/** Deterministic DOM geometry and resize delivery for measured-layout regressions. */
-export function installMeasuredLayout(rectFor: (element: HTMLElement) => DOMRect) {
- const observers = new Set();
- class Observer {
- constructor(readonly callback: () => void) {
- observers.add(this);
- }
- observe() {}
- unobserve() {}
- disconnect() {
- observers.delete(this);
- }
- }
- vi.stubGlobal("ResizeObserver", Observer);
- const rectSpy = vi
- .spyOn(HTMLElement.prototype, "getBoundingClientRect")
- .mockImplementation(function (this: HTMLElement) {
- return rectFor(this);
- });
- return {
- resize: () => {
- for (const observer of [...observers]) observer.callback();
- },
- restore: () => {
- rectSpy.mockRestore();
- vi.unstubAllGlobals();
- },
- };
-}
diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md
index 69790665d..3720ef4b9 100644
--- a/docs/docs/features/directions.md
+++ b/docs/docs/features/directions.md
@@ -287,7 +287,8 @@ time:
take and brightening the one to follow. Where every lane points straight on
before a split, OpenStreetMap's per-lane destinations pick the lanes toward
the signed destination; when nothing tells the lanes apart, none is
- highlighted;
+ highlighted. Engine-reported lane rejection is never overridden by a guessed
+ turn direction;
- a **speed-limit badge** when the road's limit is known;
- an **arrival card** with the remaining distance and estimated time of arrival.
@@ -298,13 +299,6 @@ approach is never interrupted. Small speed changes keep the same maneuver in
focus; the emphasis resets when the maneuver passes, the route changes, or you
move back far enough to leave its approach.
-A junction view can appear for driving exits, forks, and splits. It uses engine
-and available OpenStreetMap lane/sign information, with fallback estimates when
-complete lane data is absent. With coverage, a Panoramax approach photo can also
-appear with author, license, and capture date. Mapillary is excluded from
-navigation imagery. Both junction view and photos can be disabled in navigation
-settings. Treat the road's actual signs and markings as authoritative.
-
Navigation also speaks instructions aloud (voice guidance can be toggled off) and
offers a **keep-screen-on** option that holds a wake lock so the display does not
sleep while you drive. If you leave the route, OpenMapX **reroutes
diff --git a/docs/docs/features/street-level-imagery.md b/docs/docs/features/street-level-imagery.md
index a0cb139fc..8d93c0765 100644
--- a/docs/docs/features/street-level-imagery.md
+++ b/docs/docs/features/street-level-imagery.md
@@ -55,8 +55,7 @@ Mapillary's imagery is openly licensed (CC BY-SA 4.0), but its **platform Terms*
prohibit use "in connection with real-time navigation or route guidance" and
require applications to materially supplement Mapillary rather than replicate it.
OpenMapX is a navigation product, so Mapillary ships **disabled by default**,
-and even when enabled its imagery is never used for the junction photo shown
-while navigating. It remains available in the viewer and on place panels.
+and remains available only in the imagery viewer and on place panels when enabled.
Review [Mapillary's terms](https://www.mapillary.com/terms) for your deployment before enabling it. Mapillary is
API-only and has no self-hosted fallback.
:::
@@ -106,17 +105,6 @@ The viewer uses provider-qualified image references, including links such as
`?sli=panoramax:`. A link can stop working if its source removes the image
or the instance disables that provider.
-### During navigation
-
-The [junction view](./directions.md#turn-by-turn-navigation) can show an available
-approach photo while driving. Only providers permitted for navigation are
-searched, currently Panoramax. The caption retains author, license, and capture
-month. An old photo is not evidence of current lane layout or road conditions.
-
-Junction photos can be disabled in navigation settings. Where no suitable image
-exists, navigation keeps its schematic junction view. This is separate from
-opening the imagery viewer to browse photos.
-
### Navigation arrows
The viewer shows up to six directional arrows. Sequence continuations take
diff --git a/docs/docs/install/configuration.md b/docs/docs/install/configuration.md
index 35b1a726d..f00cef9d8 100644
--- a/docs/docs/install/configuration.md
+++ b/docs/docs/install/configuration.md
@@ -329,7 +329,7 @@ environment values take precedence over admin values. Direct helpers using
| `VALHALLA_URL` | Valhalla endpoint for isochrones, elevation, and road-snap (bus). The default targets Stadia Maps' hosted Valhalla, which requires `VALHALLA_API_KEY`; a self-hosted Valhalla needs no key. The routing/directions **provider** resolves separately; via the co-deployed `valhalla` service or `INTEGRATION_ROUTING_VALHALLA_ENDPOINT` / `INTEGRATION_ROUTING_VALHALLA_APIKEY`. | Optional. `https://api.stadiamaps.com` |
| `VALHALLA_API_KEY` | API key for the hosted Valhalla above (account at [stadiamaps.com](https://stadiamaps.com/)). Leave blank for a self-hosted Valhalla. | Optional. Default unset |
| `MOTIS_URL` | Self-hosted MOTIS endpoint for transit, live vehicles, stop search, and rentals. Set automatically when the `motis` service is selected; over the internal network MOTIS listens on `8080` (`8081` is only the host-loopback mapping). Leave unset without a self-hosted MOTIS: transit then runs on the public Transitous API directly. | Optional. Commented `http://motis:8080` |
-| `OVERPASS_URL` | Overpass API base URL (no path) for the core Overpass client (POI fallbacks, navigation alerts, junction-view gantry lookups). The OpenConditions ingest service has its own setting, `SERVICE_OPENCONDITIONS_INGEST_OVERPASS_URL`. Multi-user deployments should use the self-hosted `overpass`. | Optional. Commented `http://overpass:80` |
+| `OVERPASS_URL` | Overpass API base URL (no path) for the core Overpass client (POI fallbacks, navigation alerts, navigation lane lookups). The OpenConditions ingest service has its own setting, `SERVICE_OPENCONDITIONS_INGEST_OVERPASS_URL`. Multi-user deployments should use the self-hosted `overpass`. | Optional. Commented `http://overpass:80` |
| `NOMINATIM_URL` | Nominatim endpoint for station reverse-geocoding (bike / scooter / car sharing). | Optional. Commented `http://nominatim:8080` |
| `TRANSITOUS_URL` | Transitous endpoint for rental POIs used by shared-mobility integrations. | Optional. `https://api.transitous.org` |
diff --git a/integrations/routing-osrm/provider.ts b/integrations/routing-osrm/provider.ts
index c97e76fc9..5a432d2fb 100644
--- a/integrations/routing-osrm/provider.ts
+++ b/integrations/routing-osrm/provider.ts
@@ -148,7 +148,7 @@ function osrmLanes(step: OsrmStep): ManeuverLane[] | undefined {
return lanes.map((l) => {
const lane: ManeuverLane = {
indications: l.indications ?? [],
- valid: Boolean(l.valid),
+ ...(typeof l.valid === "boolean" ? { valid: l.valid } : {}),
};
if (l.valid_indication) lane.active = l.valid_indication;
return lane;
diff --git a/integrations/routing-valhalla/provider.test.ts b/integrations/routing-valhalla/provider.test.ts
index 8f3226d8b..d77d65507 100644
--- a/integrations/routing-valhalla/provider.test.ts
+++ b/integrations/routing-valhalla/provider.test.ts
@@ -114,6 +114,59 @@ describe("transformTraceEdge", () => {
});
describe("valhallaLanes", () => {
+ it("distinguishes initially usable lanes from preferred lanes requiring no later change", () => {
+ const result = valhallaLanes({
+ type: 24,
+ instruction: "",
+ length: 0,
+ time: 0,
+ begin_shape_index: 0,
+ end_shape_index: 0,
+ lanes: [
+ { directions: 10, valid: 8, active: 0 },
+ { directions: 10, valid: 8, active: 8 },
+ ],
+ });
+ expect(result).toEqual([
+ { indications: ["through", "left"], valid: true, preferred: false, active: "left" },
+ { indications: ["through", "left"], valid: true, preferred: true, active: "left" },
+ ]);
+ });
+ it("preserves omitted zero flags in a maneuver with reported active lanes", () => {
+ const lanes = valhallaLanes({
+ type: 24,
+ instruction: "",
+ length: 0,
+ time: 0,
+ begin_shape_index: 0,
+ end_shape_index: 0,
+ lanes: [{ directions: 32, active: 32 }, { directions: 96, valid: 32 }, { directions: 64 }],
+ });
+ expect(lanes).toEqual([
+ { indications: ["slight_right"], valid: true, preferred: true, active: "slight_right" },
+ {
+ indications: ["slight_right", "right"],
+ valid: true,
+ preferred: false,
+ active: "slight_right",
+ },
+ { indications: ["right"], valid: false, preferred: false },
+ ]);
+ });
+ it("leaves a direction-only maneuver without reported flags unknown", () => {
+ const lanes = valhallaLanes({
+ type: 24,
+ instruction: "",
+ length: 0,
+ time: 0,
+ begin_shape_index: 0,
+ end_shape_index: 0,
+ lanes: [{ directions: 2 }, { directions: 64 }],
+ });
+ expect(lanes?.every((lane) => lane.valid === undefined && lane.preferred === undefined)).toBe(
+ true,
+ );
+ });
it("returns undefined when the maneuver carries no lanes", () => {
expect(
valhallaLanes({
@@ -141,8 +194,8 @@ describe("valhallaLanes", () => {
],
});
expect(lanes).toEqual([
- { indications: ["through", "left"], valid: true, active: "left" },
- { indications: ["right"], valid: false },
+ { indications: ["through", "left"], valid: true, preferred: true, active: "left" },
+ { indications: ["right"], valid: false, preferred: false },
]);
});
});
diff --git a/integrations/routing-valhalla/provider.ts b/integrations/routing-valhalla/provider.ts
index 3863c2907..9a23bad7c 100644
--- a/integrations/routing-valhalla/provider.ts
+++ b/integrations/routing-valhalla/provider.ts
@@ -360,11 +360,18 @@ function laneFlagIndication(flag: number | undefined, directions: string[]): str
/** Lane guidance from a Valhalla maneuver, when present. Exported for testing. */
export function valhallaLanes(maneuver: ValhallaManeuver): ManeuverLane[] | undefined {
if (!maneuver.lanes || maneuver.lanes.length === 0) return undefined;
+ // Valhalla serializes nonzero lane states only. Once this maneuver reports
+ // guidance, omitted flags on its other lanes represent zero, not unknown.
+ const reported = maneuver.lanes.some(
+ (lane) => lane.valid !== undefined || lane.active !== undefined,
+ );
+ const preferredReported = maneuver.lanes.some((lane) => lane.active !== undefined);
return maneuver.lanes.map((l) => {
const indications = laneDirections(l.directions);
const lane: ManeuverLane = {
indications,
- valid: laneFlagSet(l.valid) || laneFlagSet(l.active),
+ ...(reported ? { valid: laneFlagSet(l.valid) || laneFlagSet(l.active) } : {}),
+ ...(preferredReported ? { preferred: laneFlagSet(l.active) } : {}),
};
const active =
laneFlagIndication(l.active, indications) ?? laneFlagIndication(l.valid, indications);
diff --git a/integrations/routing/__tests__/junctions.test.ts b/integrations/routing/__tests__/junctions.test.ts
index 2e13c8905..342b598ce 100644
--- a/integrations/routing/__tests__/junctions.test.ts
+++ b/integrations/routing/__tests__/junctions.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import holz from "../../../packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json";
const overpassQuerySafe = vi.fn();
@@ -123,7 +124,7 @@ describe("buildJunctionsQuery", () => {
expect(query).toBe(
`[out:json][timeout:25];(way(around:20,${TRACE.map(([lng, lat]) => `${lat},${lng}`).join(
",",
- )})["highway"~"^(motorway|trunk|motorway_link|trunk_link)$"];);out tags geom;`,
+ )})["highway"~"^(motorway|trunk|motorway_link|trunk_link)$"];);out body geom;`,
);
expect((query.match(/around:/g) ?? []).length).toBe(1);
});
@@ -146,6 +147,13 @@ describe("junctionCacheKey", () => {
});
describe("mapJunctionWays", () => {
+ it("preserves travel-direction geometry for matching the incoming lane layout", () => {
+ const result = mapJunctionWays([APPROACH_WAY], point());
+ expect(result.approach[0].geometry).toEqual([
+ [6.6785, 51.1786],
+ [6.6768301, 51.1788713],
+ ]);
+ });
const mapped = () => parseJunctionPoints({ points: [point()] })?.[0] as ReturnType;
it("reads the bearing at the node nearest the point, splits ramps off, and signs the end distance", () => {
@@ -195,7 +203,7 @@ describe("mapJunctionWays", () => {
expect(mapJunctionWays([DOWNSTREAM_WAY], mapped()).onMotorway).toBe(true);
});
- it("counts an untagged trunk carriageway as the road, but never draws it as a gantry", () => {
+ it("recognizes an untagged trunk carriageway without inventing lane tags", () => {
const untagged: OverpassWayElement = {
type: "way",
id: 55,
@@ -211,6 +219,35 @@ describe("mapJunctionWays", () => {
expect(result.approach).toEqual([]);
});
+ it("returns approach lane counts even without destination or turn tags", () => {
+ const result = mapJunctionWays(
+ [{ ...APPROACH_WAY, tags: { highway: "motorway", oneway: "yes", lanes: "4" } }],
+ mapped(),
+ );
+ expect(result.approach[0]?.tags).toEqual({ lanes: 4 });
+ expect(result.onMotorway).toBe(true);
+ });
+
+ it.each(["4.5", "0", "-2", "unknown"])(
+ "does not admit lane-only geometry for invalid count %s",
+ (lanes) => {
+ const result = mapJunctionWays(
+ [{ ...APPROACH_WAY, tags: { highway: "motorway", oneway: "yes", lanes } }],
+ mapped(),
+ );
+ expect(result.approach).toEqual([]);
+ },
+ );
+
+ it("does not use both directions' lane total on a two-way trunk approach", () => {
+ const result = mapJunctionWays(
+ [{ ...APPROACH_WAY, tags: { highway: "trunk", lanes: "4" } }],
+ mapped(),
+ );
+ expect(result.approach).toEqual([]);
+ expect(result.onMotorway).toBe(true);
+ });
+
it("does not put an on-ramp's surface street on the motorway", () => {
// Only the link leaving the split: the road the route arrives on is not
// a motorway or trunk, so nothing in the result runs through the point.
@@ -233,55 +270,6 @@ describe("mapJunctionWays", () => {
expect(mapJunctionWays([oncoming], mapped()).onMotorway).toBe(false);
});
- describe("where the exit lanes already exist", () => {
- // The real OSM chain into exit 20: four lanes, then a fifth added 41 m
- // before the split.
- const way = (id: number, lanes: string, nodes: [number, number][]): OverpassWayElement => ({
- type: "way",
- id,
- tags: { highway: "motorway", oneway: "yes", lanes },
- geometry: nodes.map(([lon, lat]) => ({ lat, lon })),
- });
- const FOUR_LANES_FAR = way(314653470, "4", [
- [6.6819535, 51.1781486],
- [6.6795424, 51.1784787],
- ]);
- const FOUR_LANES = way(314653467, "4", [
- [6.6795424, 51.1784787],
- [6.6774012, 51.1787881],
- ]);
- const FIVE_LANES = way(314653466, "5", [
- [6.6774012, 51.1787881],
- [6.6768301, 51.1788713],
- ]);
- const atSplit = () =>
- point({ lng: 6.6768301, lat: 51.1788713, bearing: 283 }) as ReturnType;
-
- it("measures from where the lane count at the split begins", () => {
- const result = mapJunctionWays(
- [FOUR_LANES_FAR, FOUR_LANES, FIVE_LANES, DOWNSTREAM_WAY, RAMP_WAY],
- atSplit(),
- );
- expect(result.fullLanesFromMeters).toBe(41);
- });
-
- it("follows the carriageway across ways cut for other reasons", () => {
- const bridge = way(9, "5", [
- [6.6795424, 51.1784787],
- [6.6774012, 51.1787881],
- ]);
- const result = mapJunctionWays([FOUR_LANES_FAR, bridge, FIVE_LANES], atSplit());
- expect(result.fullLanesFromMeters).toBe(194);
- });
-
- it("is absent when the way at the split has no lane count", () => {
- const untagged: OverpassWayElement = { ...FIVE_LANES, tags: { highway: "motorway" } };
- expect(mapJunctionWays([FOUR_LANES, untagged], atSplit()).fullLanesFromMeters).toBe(
- undefined,
- );
- });
- });
-
it("keeps the downstream continuation out of the approach set", () => {
const result = mapJunctionWays([DOWNSTREAM_WAY], mapped());
expect(result.approach).toEqual([]);
@@ -408,3 +396,56 @@ describe("POST /navigation/junctions", () => {
expect(overpassQuerySafe).not.toHaveBeenCalled();
});
});
+
+describe("connected outgoing roads at Kreuz Holz", () => {
+ it("includes the continuing motorway and the routed ramp at their shared OSM node", () => {
+ const result = mapJunctionWays(
+ holz.osmTopology as unknown as OverpassWayElement[],
+ holz.lookupPoint,
+ );
+ expect(result.outgoing?.map((way) => way.wayId).sort()).toEqual([168452743, 971245022]);
+ expect(result.outgoing?.find((way) => way.wayId === 971245022)?.tags.destination).toBe(
+ "Heinsberg;Venlo;Mönchengladbach",
+ );
+ });
+
+ it("rejects a nearby road with a different start node, even at the same coordinates", () => {
+ const adjacent = {
+ ...holz.osmTopology[2],
+ id: 987,
+ nodes: [999, ...holz.osmTopology[2].nodes.slice(1)],
+ };
+ const result = mapJunctionWays(
+ [...holz.osmTopology, adjacent] as unknown as OverpassWayElement[],
+ holz.lookupPoint,
+ );
+ expect(result.outgoing?.some((way) => way.wayId === 987)).toBe(false);
+ });
+
+ it("uses travel node order for a reversed one-way outgoing road", () => {
+ const forward = holz.osmTopology[2];
+ const reverse = {
+ ...forward,
+ tags: { ...forward.tags, oneway: "-1" },
+ nodes: [...forward.nodes].reverse(),
+ geometry: [...forward.geometry].reverse(),
+ };
+ const result = mapJunctionWays(
+ [holz.osmTopology[0], holz.osmTopology[1], reverse] as unknown as OverpassWayElement[],
+ holz.lookupPoint,
+ );
+ expect(result.outgoing?.map((way) => way.wayId).sort()).toEqual([168452743, 971245022]);
+ });
+});
+
+it("binds outgoing topology to its approach way and rejects incomplete node arrays", () => {
+ const elements = holz.osmTopology as unknown as OverpassWayElement[];
+ const result = mapJunctionWays(elements, holz.lookupPoint);
+ expect(result.outgoingApproachWayId).toBe(971245021);
+ const malformed = elements.map((entry) =>
+ entry.id === 971245022 ? { ...entry, nodes: entry.nodes?.slice(0, 1) } : entry,
+ );
+ expect(
+ mapJunctionWays(malformed, holz.lookupPoint).outgoing?.some((road) => road.wayId === 971245022),
+ ).toBe(false);
+});
diff --git a/integrations/routing/index.ts b/integrations/routing/index.ts
index 0bb673712..eb6df39d3 100644
--- a/integrations/routing/index.ts
+++ b/integrations/routing/index.ts
@@ -825,14 +825,14 @@ export function setup(ctx: IntegrationContext): void {
});
/**
- * POST /navigation/junctions — per-lane gantry tags for motorway decision
+ * POST /navigation/junctions — per-lane navigation tags for motorway decision
* points, from OSM `destination:*:lanes` / `turn:lanes` on the approach way.
* All uncached points of a request share ONE Overpass query (one polyline
* `around` statement each over its route trace): the public instance allows
* two concurrent slots per IP, so a per-point fan-out would rate-limit itself.
* Results are cached per point for a week; a point Overpass could not answer
* for comes back empty with `unavailable: true` and nothing cached, so the
- * client asks again later while the panel falls back to the engine sign. A
+ * client asks again later while the banner keeps its engine-provided guidance. A
* POST response is not HTTP-cacheable; the server cache is the cache.
*/
ctx.registerRoute("POST", "/navigation/junctions", async (req, reply) => {
diff --git a/integrations/routing/junctions.ts b/integrations/routing/junctions.ts
index 2ce0d640e..42297de14 100644
--- a/integrations/routing/junctions.ts
+++ b/integrations/routing/junctions.ts
@@ -12,8 +12,7 @@ import { parseWgs84Point, parseWgs84PointList } from "@openmapx/integration-fram
import { hashKey, round } from "./closure-exclusions.js";
/**
- * `POST /navigation/junctions` — the per-lane gantry data behind the junction
- * view. All uncached decision points of a request share one Overpass query
+ * `POST /navigation/junctions` — the OSM lane data behind the maneuver arrows. All uncached decision points of a request share one Overpass query
* (one polyline `around` statement per point over its route trace, never a fan
* of extrapolated circles); the merged result is handed back out per point by
* proximity to that point's trace. Cached per point for a week.
@@ -44,8 +43,8 @@ const END_ON_POINT_METERS = 5;
* further out and runs the other way.
*/
const ON_CARRIAGEWAY_METERS = 10;
-/** Keys that make a way worth drawing as a gantry or exit panel. */
-const GANTRY_TAG = /^(destination|turn)(:|$)/;
+/** Keys carrying lane directions or branch destinations. */
+const LANE_TAG = /^(destination|turn)(:|$)/;
export const JUNCTION_CACHE_TTL_SECONDS = 7 * 86_400;
@@ -85,7 +84,7 @@ export function parseJunctionPoints(body: unknown): JunctionLookupPoint[] | null
/** The per-point cache key: position to ~10 m, bearing to 10°. */
export function junctionCacheKey(point: JunctionLookupPoint): string {
- return hashKey("cache:nav:junction-ways", [
+ return hashKey("cache:nav:junction-ways:v4", [
round(point.lng, 4),
round(point.lat, 4),
round(point.bearing / 10, 0) * 10,
@@ -107,21 +106,27 @@ export function buildJunctionsQuery(points: JunctionLookupPoint[]): string {
.join(",")})["highway"~"^(motorway|trunk|motorway_link|trunk_link)$"];`,
)
.join("");
- return `[out:json][timeout:25];(${statements});out tags geom;`;
+ return `[out:json][timeout:25];(${statements});out body geom;`;
}
export interface OverpassWayElement {
type: string;
id: number;
tags?: Record;
+ nodes?: number[];
geometry?: { lat: number; lon: number }[];
}
/** Tags the route reads off an approach or ramp way. */
function laneTags(tags: Record): OsmLaneTags {
const lanes = Number(tags.lanes);
+ // On two-way trunk roads `lanes` counts both directions. A travel-direction
+ // count needs separate tags, which this lookup does not currently resolve.
+ const oneWay =
+ ["yes", "1", "true", "-1"].includes(tags.oneway ?? "") ||
+ (tags.oneway === undefined && ["motorway", "motorway_link"].includes(tags.highway ?? ""));
return {
- ...(Number.isFinite(lanes) && lanes > 0 ? { lanes: Math.round(lanes) } : {}),
+ ...(oneWay && Number.isInteger(lanes) && lanes > 0 ? { lanes } : {}),
...(tags["turn:lanes"] ? { turnLanes: tags["turn:lanes"] } : {}),
...(tags["destination:lanes"] ? { destinationLanes: tags["destination:lanes"] } : {}),
...(tags["destination:ref:lanes"]
@@ -193,6 +198,18 @@ function mapWay(element: OverpassWayElement, point: JunctionLookupPoint): Juncti
endDistance < END_ON_POINT_METERS ||
angularDifference(bearingBetween(end, centre), endBearing) <= 90;
+ const localSegments = nodes.flatMap((node, i) =>
+ i < nodes.length - 1 && distanceToPolyline(centre, [node, nodes[i + 1]]) <= 500
+ ? [i, i + 1]
+ : [],
+ );
+ const first = localSegments.length ? localSegments[0] : 0;
+ const last = localSegments.length ? localSegments[localSegments.length - 1] : nodes.length - 1;
+ const local = nodes.slice(first, last + 1);
+ const geometry =
+ local.length <= 128
+ ? local
+ : Array.from({ length: 128 }, (_, i) => local[Math.round((i * (local.length - 1)) / 127)]);
return {
wayId: element.id,
highway: tags.highway ?? "",
@@ -202,6 +219,7 @@ function mapWay(element: OverpassWayElement, point: JunctionLookupPoint): Juncti
endDistanceMeters: leadsIn ? endDistance : -endDistance,
startDistanceMeters: haversineDistance(nodes[0], centre),
tags: laneTags(tags),
+ geometry,
};
}
@@ -224,10 +242,17 @@ export function mapJunctionWays(
if (!nearTrace) return [];
const way = mapWay(element, point);
if (!way) return [];
- const tagged = Object.keys(element.tags ?? {}).some((key) => GANTRY_TAG.test(key));
+ const tagged = Object.keys(element.tags ?? {}).some((key) => LANE_TAG.test(key));
const nodes = element.geometry.map((node) => [node.lon, node.lat] as LngLat);
if (element.tags?.oneway === "-1") nodes.reverse();
- return [{ way, tagged, nodes, pointDistance: distanceToPolyline(centre, nodes) }];
+ const nodeIds =
+ Array.isArray(element.nodes) &&
+ element.nodes.length === nodes.length &&
+ element.nodes.every((id) => Number.isSafeInteger(id) && id > 0)
+ ? [...element.nodes]
+ : [];
+ if (element.tags?.oneway === "-1") nodeIds.reverse();
+ return [{ way, tagged, nodeIds, pointDistance: distanceToPolyline(centre, nodes) }];
});
const carriageways = matched.filter(
({ way, pointDistance }) =>
@@ -236,65 +261,36 @@ export function mapJunctionWays(
angularDifference(way.bearing, point.bearing) <= APPROACH_BEARING_TOLERANCE_DEG,
);
const onMotorway = carriageways.length > 0;
- const fullLanesFromMeters = fullLanesFrom(matched, carriageways, point);
- const drawable = matched.filter((entry) => entry.tagged).map((entry) => entry.way);
- const approach = drawable.filter(
+ const laneWays = matched
+ .filter(
+ ({ way, tagged }) =>
+ tagged || (!way.highway.includes("_link") && way.tags.lanes !== undefined),
+ )
+ .map((entry) => entry.way);
+ const approach = laneWays.filter(
(way) =>
!way.highway.includes("_link") &&
angularDifference(way.bearing, point.bearing) <= APPROACH_BEARING_TOLERANCE_DEG &&
way.endDistanceMeters >= 0 &&
way.endDistanceMeters <= APPROACH_MAX_UPSTREAM_METERS,
);
- const ramps = drawable.filter((way) => way.highway.includes("_link"));
+ const ramps = laneWays.filter((way) => way.highway.includes("_link"));
+ const nearestApproach = [...approach].sort(
+ (a, b) => a.endDistanceMeters - b.endDistanceMeters,
+ )[0];
+ const splitNode = matched.find(({ way }) => way.wayId === nearestApproach?.wayId)?.nodeIds.at(-1);
+ const outgoing =
+ splitNode === undefined
+ ? []
+ : matched
+ .filter(
+ ({ way, nodeIds }) => nodeIds[0] === splitNode && way.wayId !== nearestApproach?.wayId,
+ )
+ .map(({ way }) => way);
return {
approach,
ramps,
onMotorway,
- ...(fullLanesFromMeters !== undefined ? { fullLanesFromMeters } : {}),
+ ...(outgoing.length > 0 ? { outgoing, outgoingApproachWayId: nearestApproach?.wayId } : {}),
};
}
-
-interface MatchedWay {
- way: JunctionWay;
- nodes: LngLat[];
-}
-
-/**
- * Metres before the decision point from which the carriageway already has the
- * lane count it has at the split. OSM splits a way wherever `lanes` changes,
- * so the chain of upstream ways keeping at least that many lanes ends where
- * the exit lanes were added. Walked node to node rather than read off the one
- * way at the split, which is often cut short by a bridge or a sign change.
- */
-function fullLanesFrom(
- matched: MatchedWay[],
- carriageways: MatchedWay[],
- point: JunctionLookupPoint,
-): number | undefined {
- const centre: LngLat = [point.lng, point.lat];
- // The way carrying the route into the split, not one starting at it.
- const atSplit = carriageways
- .filter(({ way }) => way.startDistanceMeters > END_ON_POINT_METERS)
- .sort((a, b) => Math.abs(a.way.endDistanceMeters) - Math.abs(b.way.endDistanceMeters))[0];
- const lanes = atSplit?.way.tags.lanes;
- if (!atSplit || lanes === undefined) return undefined;
- let current = atSplit;
- const visited = new Set([current.way.wayId]);
- for (;;) {
- const start = current.nodes[0];
- const upstream = matched.find(
- (entry) =>
- !visited.has(entry.way.wayId) &&
- !entry.way.highway.includes("_link") &&
- (entry.way.tags.lanes ?? 0) >= lanes &&
- // A motorway merging in at that node is not this carriageway.
- angularDifference(entry.way.bearing, current.way.bearing) <=
- APPROACH_BEARING_TOLERANCE_DEG &&
- haversineDistance(entry.nodes[entry.nodes.length - 1], start) < 1,
- );
- if (!upstream) break;
- visited.add(upstream.way.wayId);
- current = upstream;
- }
- return Math.round(haversineDistance(current.nodes[0], centre));
-}
diff --git a/integrations/street-level-imagery-mapillary/provider.ts b/integrations/street-level-imagery-mapillary/provider.ts
index f273ace67..619806e41 100644
--- a/integrations/street-level-imagery-mapillary/provider.ts
+++ b/integrations/street-level-imagery-mapillary/provider.ts
@@ -108,7 +108,7 @@ export function createMapillaryProvider(options: {
// browser never contacts the provider directly.
endUserExposure: "server-only",
// Mapillary's Terms forbid use "in connection with real-time navigation
- // or route guidance", so the junction photo never draws on it.
+ // or route guidance", so this capability must remain disabled.
allowsNavigationUse: false,
coverage: {
kind: "mvt",
diff --git a/packages/core/src/api/junctions.ts b/packages/core/src/api/junctions.ts
index a495210ce..6ae2a9a51 100644
--- a/packages/core/src/api/junctions.ts
+++ b/packages/core/src/api/junctions.ts
@@ -4,12 +4,12 @@ import { API_ENDPOINTS } from "./endpoints";
/**
* Client for `POST /api/integrations/routing/navigation/junctions` — the
- * per-lane gantry tags behind the junction view. One request per batch (≤ 40
+ * per-lane OSM tags used to enrich maneuver arrows. One request per batch (≤ 40
* points).
*/
/**
- * Fetch the gantry ways for decision points; `null` when the request failed,
+ * Fetch the lane-tagged ways for decision points; `null` when the request failed,
* so the caller can ask again instead of settling on an empty answer.
*/
export async function fetchJunctionLookups(
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 215d13526..c118d40be 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -53,19 +53,18 @@ export {
type AlongRange,
type AlongRouteOptions,
type AlongRoutePoi,
- type ApproachCamera,
+ type ApproachLaneGuidance,
angularDifference,
appendCountrySpans,
- approachCamera,
type BuildGroundPackageInput,
type BuildGroundPackageResult,
bearingBetween,
brakingDistanceMeters,
buildGroundNavigationPackage,
- buildJunctionSchematic,
CAMERA_RESTRICTED_COUNTRIES,
type CoastOptions,
type CoastResult,
+ cleanSignTexts,
coastState,
computeProgress,
countryAtMeters,
@@ -76,6 +75,7 @@ export {
type DeadReckonTarget,
eta,
evaluateFasterRoute,
+ exitNumberLabel,
extractTimeline,
extractTrafficSignals,
FASTER_ROUTE_DEFAULTS,
@@ -104,18 +104,18 @@ export {
isOverSpeed,
isReroutingTooOften,
junctionLookupPoints,
- junctionPhotoApproachMeters,
laneArrowStemShift,
laneDestinationsAt,
laneIndicationIconName,
type ManeuverArrowSpans,
+ type ManeuverPositions,
maneuverArrowLine,
maneuverArrowSpans,
maneuverArrowTipBearing,
maneuverIconName,
+ maneuverStartMeters,
matchCountriesByPoint,
matchSpeedLimitsByPoint,
- mergeExitPanel,
NAV_RECORDING_VERSION,
NAVIGATION_SESSION_MAX_AGE_MS,
NAVIGATION_SESSION_SCHEMA_VERSION,
@@ -126,10 +126,6 @@ export {
nextVoiceCue,
normalizeLaneToken,
OVER_SPEED_TOLERANCE_KMH,
- type PhotoCropWindow,
- type PhotoPathPoint,
- type PhotoProjectionOptions,
- type PhotoRoutePath,
PROGRESS_BUCKET_METERS,
type PreparedRouteMatcher,
type PreparedTransitProgress,
@@ -137,11 +133,11 @@ export {
type ProjectedRoutePoi,
type ProjectFlowOptions,
paddedRouteAheadBounds,
- parseLaneTags,
parseNavigationSessionSnapshot,
pickSpeedLimit,
poiAlongRoute,
positionAt,
+ prepareManeuverPositions,
prepareRouteMatcher,
prepareTransitProgress,
presentGuidanceLanes,
@@ -151,11 +147,11 @@ export {
projectEventsToRoute,
projectFlowToRoute,
projectRoadAlertsOntoRoute,
- projectRoutePath,
projectRoutePois,
pruneRerouteTimes,
type RecordedReroute,
type ReplayStep,
+ type ResolvedLaneGuidance,
ROUTE_CORRIDOR_METERS,
ROUTE_SEARCH_CORRIDOR_METERS,
type RoadAlert,
@@ -168,7 +164,9 @@ export {
remainingWaypoints,
replayRecording,
resetRouteMatcherCounters,
+ resolveApproachLaneGuidance,
resolveGuidanceLanes,
+ resolveLaneGuidance,
resolveRecommendedLanes,
routeAheadBounds,
routeFingerprint,
@@ -177,7 +175,6 @@ export {
sameJunction,
selectActiveAlert,
selectApproachWay,
- selectJunctionPhoto,
selectRampWay,
setRouteMatcherCounting,
shouldPreviewNextStep,
diff --git a/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json b/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json
new file mode 100644
index 000000000..df8e3ffcf
--- /dev/null
+++ b/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json
@@ -0,0 +1,898 @@
+{
+ "manifest": {
+ "capturedAt": "2026-10-10",
+ "sources": {
+ "junctions": "POST https://openmapx.com/api/integrations/routing/navigation/junctions using junctionLookupPoints for the live route",
+ "osm": "https://api.openstreetmap.org/api/0.6/map.json?bbox=6.4748,51.0844,6.477,51.0860",
+ "topology": "OSM API way/full.json for 971245021, 168452743, 971245022, verified 2026-10-10"
+ },
+ "licence": "OpenStreetMap data © OpenStreetMap contributors, ODbL",
+ "transformations": [
+ "Route cropped to the immediate approach and departure of the decision point; synthetic distances describe only this local fixture.",
+ "OSM ways retain only IDs, versions, timestamps and tags. No user metadata or imagery included.",
+ "Topology retains way node IDs, coordinates and tags; user metadata omitted.",
+ "Outgoing ways mapped from captured shared-node topology by mapJunctionWays; lane assignments are not inferred."
+ ],
+ "note": "OSRM reports slight left while OSM approach arrows say slight_right. Neither identifies the A46 lane assignment consistently."
+ },
+ "route": {
+ "distance": 1000,
+ "duration": 60,
+ "mode": "driving",
+ "geometry": [
+ [
+ 6.475928,
+ 51.08468
+ ],
+ [
+ 6.475891,
+ 51.084788
+ ],
+ [
+ 6.475856,
+ 51.084893
+ ],
+ [
+ 6.475825,
+ 51.084993
+ ],
+ [
+ 6.475795,
+ 51.085103
+ ],
+ [
+ 6.475768,
+ 51.085207
+ ],
+ [
+ 6.475733,
+ 51.085321
+ ],
+ [
+ 6.475704,
+ 51.085432
+ ],
+ [
+ 6.47566,
+ 51.085636
+ ],
+ [
+ 6.475637,
+ 51.085753
+ ],
+ [
+ 6.475628,
+ 51.08582
+ ]
+ ],
+ "steps": [
+ {
+ "instruction": "Merge onto (A 44)",
+ "distance": 500,
+ "duration": 30,
+ "coordinates": [
+ [
+ 6.475928,
+ 51.08468
+ ],
+ [
+ 6.475891,
+ 51.084788
+ ],
+ [
+ 6.475856,
+ 51.084893
+ ],
+ [
+ 6.475825,
+ 51.084993
+ ],
+ [
+ 6.475795,
+ 51.085103
+ ],
+ [
+ 6.475768,
+ 51.085207
+ ]
+ ],
+ "roadNames": [
+ "A 44"
+ ],
+ "maneuver": {
+ "type": "merge",
+ "modifier": "slight left"
+ },
+ "drivingSide": "right",
+ "bearingBefore": 67,
+ "bearingAfter": 73
+ },
+ {
+ "instruction": "Take exit onto the road",
+ "distance": 500,
+ "duration": 30,
+ "coordinates": [
+ [
+ 6.475768,
+ 51.085207
+ ],
+ [
+ 6.475733,
+ 51.085321
+ ],
+ [
+ 6.475704,
+ 51.085432
+ ],
+ [
+ 6.47566,
+ 51.085636
+ ],
+ [
+ 6.475637,
+ 51.085753
+ ],
+ [
+ 6.475628,
+ 51.08582
+ ]
+ ],
+ "maneuver": {
+ "type": "off ramp",
+ "modifier": "slight left"
+ },
+ "drivingSide": "right",
+ "sign": {
+ "exitBranches": [
+ "A 46"
+ ],
+ "exitToward": [
+ "Düsseldorf",
+ "Neuss"
+ ]
+ },
+ "bearingBefore": 347,
+ "bearingAfter": 351
+ }
+ ]
+ },
+ "lookupPoint": {
+ "lng": 6.4757172296857615,
+ "lat": 51.08538136223726,
+ "bearing": 345.77117626681115,
+ "trace": [
+ [
+ 6.477604448384132,
+ 51.082004645634164
+ ],
+ [
+ 6.47668445064213,
+ 51.08322300452945
+ ],
+ [
+ 6.476071961328724,
+ 51.08432610703805
+ ],
+ [
+ 6.475791707996266,
+ 51.08511568031067
+ ],
+ [
+ 6.4757172296857615,
+ 51.08538136223726
+ ],
+ [
+ 6.475657538334548,
+ 51.08564852238513
+ ]
+ ]
+ },
+ "lookup": {
+ "index": 0,
+ "approach": [
+ {
+ "wayId": 971245021,
+ "highway": "motorway",
+ "ref": "A 44",
+ "bearing": 350.63658102278754,
+ "endDistanceMeters": 19.765790007993083,
+ "startDistanceMeters": 450.36789717389655,
+ "tags": {
+ "lanes": 4,
+ "turnLanes": "none|none|slight_right|slight_right"
+ }
+ }
+ ],
+ "ramps": [
+ {
+ "wayId": 168452743,
+ "highway": "motorway_link",
+ "bearing": 352.287993113203,
+ "endDistanceMeters": -825.7273749772886,
+ "startDistanceMeters": 19.765790007993083,
+ "tags": {
+ "lanes": 2,
+ "destination": "Düsseldorf;Neuss",
+ "destinationRef": "A 46"
+ }
+ }
+ ],
+ "onMotorway": true,
+ "outgoing": [
+ {
+ "wayId": 168452743,
+ "highway": "motorway_link",
+ "bearing": 352.287993113203,
+ "endDistanceMeters": -825.7273749772886,
+ "startDistanceMeters": 19.765790007993083,
+ "tags": {
+ "lanes": 2,
+ "destination": "Düsseldorf;Neuss",
+ "destinationRef": "A 46"
+ }
+ },
+ {
+ "wayId": 971245022,
+ "highway": "motorway",
+ "ref": "A 44",
+ "bearing": 358.486178743452,
+ "endDistanceMeters": -168.55925290809697,
+ "startDistanceMeters": 19.765790007993083,
+ "tags": {
+ "lanes": 2,
+ "destination": "Heinsberg;Venlo;Mönchengladbach",
+ "destinationRef": "A 46;A 61"
+ }
+ }
+ ],
+ "outgoingApproachWayId": 971245021
+ },
+ "osmTopology": [
+ {
+ "type": "way",
+ "id": 971245021,
+ "nodes": [
+ 7904688645,
+ 9429017371,
+ 9429017369,
+ 9429017370,
+ 9429017368,
+ 9429017367,
+ 9429017366,
+ 9429017365,
+ 9429017363,
+ 9429017364,
+ 1796939946,
+ 9429017361,
+ 9429017362,
+ 9429017360,
+ 9429017359,
+ 7904688644,
+ 9429017358,
+ 9429017357,
+ 9429017356,
+ 9429017354,
+ 9429017355,
+ 5732548067,
+ 9429017353,
+ 9429017351,
+ 9429017352,
+ 7904688643,
+ 9429017350,
+ 9429017348,
+ 9429017349,
+ 5732548065,
+ 9429017347,
+ 9429017345,
+ 9429017346,
+ 5742353440
+ ],
+ "tags": {
+ "highway": "motorway",
+ "lanes": "4",
+ "lit": "no",
+ "maxspeed": "none",
+ "oneway": "yes",
+ "ref": "A 44",
+ "surface": "asphalt",
+ "turn:lanes": "none|none|slight_right|slight_right"
+ },
+ "geometry": [
+ {
+ "lon": 6.4779611,
+ "lat": 51.0815843
+ },
+ {
+ "lon": 6.4778887,
+ "lat": 51.0816668
+ },
+ {
+ "lon": 6.4778178,
+ "lat": 51.0817488
+ },
+ {
+ "lon": 6.4777492,
+ "lat": 51.0818286
+ },
+ {
+ "lon": 6.4776812,
+ "lat": 51.08191
+ },
+ {
+ "lon": 6.4775707,
+ "lat": 51.082046
+ },
+ {
+ "lon": 6.4774927,
+ "lat": 51.0821402
+ },
+ {
+ "lon": 6.4774127,
+ "lat": 51.0822386
+ },
+ {
+ "lon": 6.4773153,
+ "lat": 51.0823595
+ },
+ {
+ "lon": 6.4772493,
+ "lat": 51.0824425
+ },
+ {
+ "lon": 6.4771895,
+ "lat": 51.0825216
+ },
+ {
+ "lon": 6.4770441,
+ "lat": 51.0827108
+ },
+ {
+ "lon": 6.476973,
+ "lat": 51.0828055
+ },
+ {
+ "lon": 6.4769061,
+ "lat": 51.0828975
+ },
+ {
+ "lon": 6.4768102,
+ "lat": 51.0830352
+ },
+ {
+ "lon": 6.4766997,
+ "lat": 51.0831998
+ },
+ {
+ "lon": 6.4766273,
+ "lat": 51.083308
+ },
+ {
+ "lon": 6.4765549,
+ "lat": 51.0834221
+ },
+ {
+ "lon": 6.4764644,
+ "lat": 51.0835703
+ },
+ {
+ "lon": 6.4763729,
+ "lat": 51.0837266
+ },
+ {
+ "lon": 6.4763179,
+ "lat": 51.0838251
+ },
+ {
+ "lon": 6.476265,
+ "lat": 51.0839223
+ },
+ {
+ "lon": 6.4762099,
+ "lat": 51.0840333
+ },
+ {
+ "lon": 6.476159,
+ "lat": 51.0841404
+ },
+ {
+ "lon": 6.4761071,
+ "lat": 51.0842482
+ },
+ {
+ "lon": 6.4760593,
+ "lat": 51.0843546
+ },
+ {
+ "lon": 6.476013,
+ "lat": 51.0844611
+ },
+ {
+ "lon": 6.4759688,
+ "lat": 51.0845701
+ },
+ {
+ "lon": 6.4759278,
+ "lat": 51.0846801
+ },
+ {
+ "lon": 6.4758909,
+ "lat": 51.0847875
+ },
+ {
+ "lon": 6.4758558,
+ "lat": 51.0848931
+ },
+ {
+ "lon": 6.4758248,
+ "lat": 51.0849934
+ },
+ {
+ "lon": 6.4757954,
+ "lat": 51.0851025
+ },
+ {
+ "lon": 6.4757681,
+ "lat": 51.0852065
+ }
+ ]
+ },
+ {
+ "type": "way",
+ "id": 168452743,
+ "nodes": [
+ 5742353440,
+ 9429017344,
+ 9429017343,
+ 9429017341,
+ 9429017342,
+ 9428561852,
+ 9428561855,
+ 9428561853,
+ 9428561854,
+ 7904688631,
+ 9429017340,
+ 9429017339,
+ 5739864357,
+ 9429017338,
+ 7904688636,
+ 9429017337,
+ 9429017336,
+ 5739864356,
+ 9429017335,
+ 9429017334,
+ 9429017333,
+ 5739864358,
+ 9429017332,
+ 9429017331,
+ 5742353445,
+ 9429017330,
+ 9429017329,
+ 1796939957,
+ 9429017328,
+ 9429017327,
+ 7904688648,
+ 9429017326,
+ 1796939958,
+ 9429017325,
+ 9429017323,
+ 9429017324,
+ 5742353443,
+ 9429017322,
+ 9429017321,
+ 1796939960,
+ 9429017319,
+ 9429017318,
+ 9429017320,
+ 9429017317,
+ 7904688649,
+ 9428989616,
+ 1796939961,
+ 9428989614,
+ 9428989615,
+ 5860089083,
+ 9428989613,
+ 9428989612,
+ 1796939963,
+ 9428989611,
+ 9428989610,
+ 7904688651,
+ 9428989609,
+ 9428989608,
+ 1511482126
+ ],
+ "tags": {
+ "destination": "Düsseldorf;Neuss",
+ "destination:ref": "A 46",
+ "highway": "motorway_link",
+ "lanes": "2",
+ "lit": "no",
+ "maxheight:signed": "no",
+ "maxspeed": "none",
+ "oneway": "yes",
+ "surface": "asphalt",
+ "zone:traffic": "DE:motorway"
+ },
+ "geometry": [
+ {
+ "lon": 6.4757681,
+ "lat": 51.0852065
+ },
+ {
+ "lon": 6.4757328,
+ "lat": 51.0853213
+ },
+ {
+ "lon": 6.4757038,
+ "lat": 51.0854323
+ },
+ {
+ "lon": 6.4756598,
+ "lat": 51.0856364
+ },
+ {
+ "lon": 6.4756373,
+ "lat": 51.0857525
+ },
+ {
+ "lon": 6.4756278,
+ "lat": 51.08582
+ },
+ {
+ "lon": 6.4756152,
+ "lat": 51.085936
+ },
+ {
+ "lon": 6.4756031,
+ "lat": 51.0860475
+ },
+ {
+ "lon": 6.4755929,
+ "lat": 51.0861678
+ },
+ {
+ "lon": 6.4755874,
+ "lat": 51.0862725
+ },
+ {
+ "lon": 6.4755857,
+ "lat": 51.0864288
+ },
+ {
+ "lon": 6.4755875,
+ "lat": 51.0865709
+ },
+ {
+ "lon": 6.4755935,
+ "lat": 51.0867427
+ },
+ {
+ "lon": 6.4756079,
+ "lat": 51.086922
+ },
+ {
+ "lon": 6.4756271,
+ "lat": 51.0870945
+ },
+ {
+ "lon": 6.4756481,
+ "lat": 51.0872436
+ },
+ {
+ "lon": 6.4756727,
+ "lat": 51.0873837
+ },
+ {
+ "lon": 6.4757052,
+ "lat": 51.0875425
+ },
+ {
+ "lon": 6.4757398,
+ "lat": 51.0876991
+ },
+ {
+ "lon": 6.4757827,
+ "lat": 51.0878623
+ },
+ {
+ "lon": 6.4758312,
+ "lat": 51.08802
+ },
+ {
+ "lon": 6.4758822,
+ "lat": 51.0881789
+ },
+ {
+ "lon": 6.4759358,
+ "lat": 51.0883288
+ },
+ {
+ "lon": 6.4760013,
+ "lat": 51.0884968
+ },
+ {
+ "lon": 6.4760704,
+ "lat": 51.0886574
+ },
+ {
+ "lon": 6.4761374,
+ "lat": 51.0888082
+ },
+ {
+ "lon": 6.4762128,
+ "lat": 51.0889634
+ },
+ {
+ "lon": 6.4762944,
+ "lat": 51.0891212
+ },
+ {
+ "lon": 6.4763853,
+ "lat": 51.0892847
+ },
+ {
+ "lon": 6.4764775,
+ "lat": 51.0894385
+ },
+ {
+ "lon": 6.4765688,
+ "lat": 51.0895783
+ },
+ {
+ "lon": 6.4766713,
+ "lat": 51.0897312
+ },
+ {
+ "lon": 6.4767726,
+ "lat": 51.0898766
+ },
+ {
+ "lon": 6.4768377,
+ "lat": 51.0899638
+ },
+ {
+ "lon": 6.4769027,
+ "lat": 51.090048
+ },
+ {
+ "lon": 6.4769899,
+ "lat": 51.0901585
+ },
+ {
+ "lon": 6.477077,
+ "lat": 51.0902644
+ },
+ {
+ "lon": 6.4771614,
+ "lat": 51.0903618
+ },
+ {
+ "lon": 6.4772458,
+ "lat": 51.0904546
+ },
+ {
+ "lon": 6.4774099,
+ "lat": 51.0906299
+ },
+ {
+ "lon": 6.4774656,
+ "lat": 51.0906901
+ },
+ {
+ "lon": 6.4775237,
+ "lat": 51.0907503
+ },
+ {
+ "lon": 6.4775878,
+ "lat": 51.0908142
+ },
+ {
+ "lon": 6.4776519,
+ "lat": 51.0908752
+ },
+ {
+ "lon": 6.47782,
+ "lat": 51.0910291
+ },
+ {
+ "lon": 6.4779572,
+ "lat": 51.0911515
+ },
+ {
+ "lon": 6.4780992,
+ "lat": 51.0912717
+ },
+ {
+ "lon": 6.4782686,
+ "lat": 51.0914109
+ },
+ {
+ "lon": 6.4783617,
+ "lat": 51.091485
+ },
+ {
+ "lon": 6.4784548,
+ "lat": 51.0915561
+ },
+ {
+ "lon": 6.478603,
+ "lat": 51.091667
+ },
+ {
+ "lon": 6.4787512,
+ "lat": 51.0917749
+ },
+ {
+ "lon": 6.4788902,
+ "lat": 51.0918693
+ },
+ {
+ "lon": 6.4789921,
+ "lat": 51.0919387
+ },
+ {
+ "lon": 6.4790939,
+ "lat": 51.0920081
+ },
+ {
+ "lon": 6.4792893,
+ "lat": 51.0921326
+ },
+ {
+ "lon": 6.4793912,
+ "lat": 51.0921972
+ },
+ {
+ "lon": 6.4794908,
+ "lat": 51.0922572
+ },
+ {
+ "lon": 6.4796922,
+ "lat": 51.092375
+ }
+ ]
+ },
+ {
+ "type": "way",
+ "id": 971245022,
+ "nodes": [
+ 5742353440,
+ 9428561865,
+ 9428561864,
+ 9428561861,
+ 9428989597,
+ 9428561862,
+ 9428989596,
+ 9428561856,
+ 9428561860,
+ 9428561857,
+ 9428561859,
+ 7904688637,
+ 9428561858,
+ 9428561850,
+ 9428561851,
+ 7904688638,
+ 9428989594,
+ 9428561849,
+ 9428989595,
+ 5226569757,
+ 9428561847,
+ 9428989593,
+ 9428561846,
+ 9428561848,
+ 5742353441
+ ],
+ "tags": {
+ "destination": "Heinsberg;Venlo;Mönchengladbach",
+ "destination:ref": "A 46;A 61",
+ "highway": "motorway",
+ "lanes": "2",
+ "maxspeed": "none",
+ "oneway": "yes",
+ "ref": "A 44",
+ "surface": "asphalt"
+ },
+ "geometry": [
+ {
+ "lon": 6.4757681,
+ "lat": 51.0852065
+ },
+ {
+ "lon": 6.4757547,
+ "lat": 51.085384
+ },
+ {
+ "lon": 6.4757404,
+ "lat": 51.0857239
+ },
+ {
+ "lon": 6.4757341,
+ "lat": 51.0858792
+ },
+ {
+ "lon": 6.4757338,
+ "lat": 51.0859271
+ },
+ {
+ "lon": 6.4757336,
+ "lat": 51.0859715
+ },
+ {
+ "lon": 6.4757359,
+ "lat": 51.0860163
+ },
+ {
+ "lon": 6.4757396,
+ "lat": 51.0860593
+ },
+ {
+ "lon": 6.4757443,
+ "lat": 51.0861126
+ },
+ {
+ "lon": 6.4757504,
+ "lat": 51.0861594
+ },
+ {
+ "lon": 6.4757593,
+ "lat": 51.0862074
+ },
+ {
+ "lon": 6.4757682,
+ "lat": 51.0862497
+ },
+ {
+ "lon": 6.4757842,
+ "lat": 51.0863127
+ },
+ {
+ "lon": 6.4758017,
+ "lat": 51.0863734
+ },
+ {
+ "lon": 6.4758188,
+ "lat": 51.0864292
+ },
+ {
+ "lon": 6.4758381,
+ "lat": 51.0864829
+ },
+ {
+ "lon": 6.4758539,
+ "lat": 51.0865236
+ },
+ {
+ "lon": 6.4758712,
+ "lat": 51.0865635
+ },
+ {
+ "lon": 6.4758928,
+ "lat": 51.0866051
+ },
+ {
+ "lon": 6.4759129,
+ "lat": 51.086644
+ },
+ {
+ "lon": 6.4759393,
+ "lat": 51.0866948
+ },
+ {
+ "lon": 6.4759702,
+ "lat": 51.0867475
+ },
+ {
+ "lon": 6.4759899,
+ "lat": 51.0867787
+ },
+ {
+ "lon": 6.476023,
+ "lat": 51.086831
+ },
+ {
+ "lon": 6.4760587,
+ "lat": 51.086882
+ }
+ ]
+ }
+ ]
+}
diff --git a/packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json b/packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json
index 1ac2be4ab..73c0a4b5c 100644
--- a/packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json
+++ b/packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json
@@ -3,16 +3,13 @@
"capturedAt": "2026-09-15",
"sources": {
"valhalla": "public instance valhalla1.openstreetmap.de (re-run 2026-09-15)",
- "osm": "Overpass API 0.7.62, ways 314653469 (v10, 2023-12-21T08:33:16Z), 314653466 (v11, 2023-12-21T20:07:04Z), 163585518 (v13, 2025-10-30T01:16:29Z). 314653466 ends at the split and is the exit-20 gantry way; 314653469 begins at the split and carries the A 57 / A 46 lane destinations of the following fork; 163585518 is the exit ramp.",
- "panoramax": "api.panoramax.xyz STAC search, re-run 2026-09-15"
+ "osm": "Overpass API 0.7.62, ways 314653469 (v10, 2023-12-21T08:33:16Z), 314653466 (v11, 2023-12-21T20:07:04Z), 163585518 (v13, 2025-10-30T01:16:29Z). 314653466 ends at the split and is the exit-20 lane-tagged way; 314653469 begins at the split and carries the A 57 / A 46 lane destinations of the following fork; 163585518 is the exit ramp."
},
"licence": {
- "osm": "OpenStreetMap data © OpenStreetMap contributors, ODbL",
- "panoramax": "CC BY-SA 4.0 per item; authors motocultrice and thetornado76"
+ "osm": "OpenStreetMap data © OpenStreetMap contributors, ODbL"
},
"synthetic": [
- "route geometry vertices are evenly spaced at bearing 283/300 (exact node spacing not captured)",
- "image id 00000000-0000-4000-8000-000000000001 (no-heading case) is synthetic; its shape mirrors the live items"
+ "route geometry vertices are evenly spaced at bearing 283/300 (exact node spacing not captured)"
]
},
"route": {
@@ -334,396 +331,5 @@
]
]
}
- ],
- "images": [
- {
- "id": "00000000-0000-4000-8000-000000000001",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.6785,
- 51.1787
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:45.000000+00:00",
- "license": "CC-BY-SA-4.0",
- "pers:interior_orientation": {},
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/00/00/00/00/01/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "5f5dcbc8-1aee-4b36-b51a-d565f856b841",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.679050225,
- 51.178607844
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:31.771000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 283,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/5f/5d/cb/c8/1aee-4b36-b51a-d565f856b841/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "caea047f-f3c0-4819-8354-11805e6597c3",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.678013787,
- 51.1787552
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:34.836000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 282,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/ca/ea/04/7f/f3c0-4819-8354-11805e6597c3/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "b77f03dc-b947-4802-bda7-6ac54bc29c0a",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.677636758,
- 51.178806692
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:35.903000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 282,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/b7/7f/03/dc/b947-4802-bda7-6ac54bc29c0a/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "ceba5384-8940-425b-a4b2-8aafcc5eb123",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.679683024,
- 51.178520969
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:29.845000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 283,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/ce/ba/53/84/8940-425b-a4b2-8aafcc5eb123/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "90f4aefe-1310-4f08-b6b4-57e442a3e7ec",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.6792165,
- 51.1783987
- ]
- },
- "properties": {
- "datetime": "2025-04-26T13:50:30.111931+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 97,
- "pers:interior_orientation": {
- "camera_model": "moto g(9) plus",
- "focal_length": 5.53,
- "camera_manufacturer": "motorola",
- "sensor_array_dimensions": [
- 4640,
- 3472
- ]
- },
- "geovisio:producer": "thetornado76"
- },
- "providers": [
- {
- "id": "fed63bc3-6fdc-4ef4-927d-cc574ed92ab4",
- "name": "thetornado76",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/90/f4/ae/fe/1310-4f08-b6b4-57e442a3e7ec/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "219c2552-e47a-4754-b9cd-e8584174b1f8",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.6796192,
- 51.1783435
- ]
- },
- "properties": {
- "datetime": "2025-04-26T13:50:31.161564+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 98,
- "pers:interior_orientation": {
- "camera_model": "moto g(9) plus",
- "focal_length": 5.53,
- "camera_manufacturer": "motorola",
- "sensor_array_dimensions": [
- 4640,
- 3472
- ]
- },
- "geovisio:producer": "thetornado76"
- },
- "providers": [
- {
- "id": "fed63bc3-6fdc-4ef4-927d-cc574ed92ab4",
- "name": "thetornado76",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/21/9c/25/52/e47a-4754-b9cd-e8584174b1f8/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "8acfda9a-eafc-4b38-bb26-e92f8cfc6c05",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.684467038,
- 51.177855286
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:16.923000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 282,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/8a/cf/da/9a/eafc-4b38-bb26-e92f8cfc6c05/sd.jpg",
- "type": "image/jpeg"
- }
- }
- },
- {
- "id": "b6653d58-8dfe-4468-977b-6ec379d0d96d",
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 6.672512452,
- 51.179545685
- ]
- },
- "properties": {
- "datetime": "2019-09-10T06:24:49.811000+00:00",
- "license": "CC-BY-SA-4.0",
- "view:azimuth": 287,
- "pers:interior_orientation": {
- "camera_model": "ONEPLUS A5000",
- "camera_manufacturer": "OnePlus",
- "sensor_array_dimensions": [
- 4608,
- 3456
- ]
- },
- "geovisio:producer": "motocultrice"
- },
- "providers": [
- {
- "id": "59efd8b7-aeb9-401e-8a28-fd4e6d58aa62",
- "name": "motocultrice",
- "roles": [
- "producer"
- ]
- },
- {
- "name": "raymond",
- "roles": [
- "producer"
- ]
- }
- ],
- "assets": {
- "sd": {
- "href": "https://panoramax.openstreetmap.fr/derivates/b6/65/3d/58/8dfe-4468-977b-6ec379d0d96d/sd.jpg",
- "type": "image/jpeg"
- }
- }
- }
]
}
diff --git a/packages/core/src/navigation/__tests__/junctionDetect.test.ts b/packages/core/src/navigation/__tests__/junctionDetect.test.ts
index 64e1e9819..54fb5628a 100644
--- a/packages/core/src/navigation/__tests__/junctionDetect.test.ts
+++ b/packages/core/src/navigation/__tests__/junctionDetect.test.ts
@@ -2,14 +2,396 @@ import { describe, expect, it } from "vitest";
import type { LngLat } from "../../types/geometry";
import type { Route, RouteStep } from "../../types/routing";
import fixture from "../__fixtures__/junction/a57-neuss-exit20.json";
+import { cumulativeDistances } from "../deadReckon";
import {
findJunctionCandidates,
findJunctionDecisionPoints,
sameJunction,
} from "../junctionDetect";
+import { junctionLookupPoints } from "../junctionLookup";
const a57Route = fixture.route as unknown as Route;
+const driftRoute: Route = {
+ mode: "driving",
+ distance: 1920,
+ duration: 120,
+ legs: [],
+ geometry: [
+ [0, 0],
+ [0.01, 0],
+ [0.01, 0.002],
+ [0.012, 0.002],
+ ],
+ steps: [
+ {
+ instruction: "Drive east",
+ distance: 1400,
+ duration: 80,
+ coordinates: [
+ [0, 0],
+ [0.01, 0],
+ ],
+ },
+ {
+ instruction: "Drive north",
+ distance: 300,
+ duration: 20,
+ coordinates: [
+ [0.01, 0],
+ [0.01, 0.002],
+ ],
+ },
+ {
+ instruction: "Exit right",
+ distance: 220,
+ duration: 20,
+ coordinates: [
+ [0.01, 0.002],
+ [0.012, 0.002],
+ ],
+ maneuver: { type: "fork", modifier: "right" },
+ },
+ ],
+};
+
+describe("junction geometry positions", () => {
+ it.each([true, false])(
+ "anchors junctions to the maneuver despite distance drift (motorway flag %s)",
+ (motorway) => {
+ const route = {
+ ...driftRoute,
+ steps: driftRoute.steps.map((step) => ({ ...step, motorway })),
+ };
+ const points = motorway ? findJunctionDecisionPoints(route) : findJunctionCandidates(route);
+ expect(points).toHaveLength(1);
+ expect(points[0].point).toEqual([0.01, 0.002]);
+ expect(points[0].alongMeters).toBeCloseTo(1334.339, 2);
+ expect(points[0].approachBearing).toBeCloseTo(0, 4);
+ expect(points[0].divergenceDeg).toBeCloseTo(90, 2);
+ const lookup = junctionLookupPoints(route, points)[0].lookup;
+ expect([lookup.lng, lookup.lat]).toEqual([0.01, 0.002]);
+ expect(lookup.trace[4]).toEqual([0.01, 0.002]);
+ expect(lookup.trace[3][0]).toBeCloseTo(0.01, 6);
+ expect(lookup.trace[3][1]).toBeLessThan(0.002);
+ },
+ );
+
+ it("uses the later occurrence when a step returns to an earlier coordinate", () => {
+ const route: Route = {
+ ...driftRoute,
+ geometry: [
+ [0, 0],
+ [0.01, 0],
+ [0.01, 0.01],
+ [0, 0.01],
+ [0, 0],
+ [0.002, 0],
+ ],
+ steps: [
+ {
+ instruction: "Drive around the block",
+ distance: 4000,
+ duration: 100,
+ coordinates: [
+ [0, 0],
+ [0.01, 0],
+ [0.01, 0.01],
+ [0, 0.01],
+ [0, 0],
+ ],
+ motorway: true,
+ },
+ {
+ instruction: "Exit right",
+ distance: 220,
+ duration: 20,
+ coordinates: [
+ [0, 0],
+ [0.002, 0],
+ ],
+ maneuver: { type: "fork", modifier: "right" },
+ },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point).toEqual([0, 0]);
+ expect(point.alongMeters).toBeCloseTo(4447.797, 2);
+ expect(point.approachBearing).toBeCloseTo(180, 4);
+ });
+
+ it("retains the distance fallback when a maneuver has no coordinates", () => {
+ const route = {
+ ...driftRoute,
+ steps: driftRoute.steps.map((step) => ({ ...step, coordinates: [], motorway: true })),
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.alongMeters).toBe(1700);
+ expect(point.point).toEqual([0.012, 0.002]);
+ });
+
+ it("projects a maneuver omitted from the route's geometry vertices", () => {
+ const route: Route = {
+ ...driftRoute,
+ steps: [
+ { ...driftRoute.steps[0], motorway: true },
+ {
+ ...driftRoute.steps[2],
+ coordinates: [
+ [0.01, 0.001],
+ [0.01, 0.002],
+ ],
+ },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point[0]).toBeCloseTo(0.01, 6);
+ expect(point.point[1]).toBeCloseTo(0.001, 6);
+ expect(point.alongMeters).toBeCloseTo(1223.144, 2);
+ expect(point.approachBearing).toBeCloseTo(0, 4);
+ });
+
+ it.each([0, 0.00002])(
+ "keeps an omitted first visit before the same coordinate's later vertex (deviation %s)",
+ (deviation) => {
+ const route: Route = {
+ ...driftRoute,
+ geometry: [
+ [0, 0],
+ [0.01, 0],
+ [0.01, 0.01],
+ [0.005, 0.01],
+ [0.005, deviation],
+ [0.006, -0.001],
+ ],
+ steps: [
+ {
+ instruction: "Drive east",
+ distance: 600,
+ duration: 20,
+ motorway: true,
+ coordinates: [
+ [0, 0],
+ [0.005, deviation],
+ ],
+ },
+ {
+ instruction: "Fork around the block",
+ distance: 3400,
+ duration: 100,
+ motorway: true,
+ coordinates: [
+ [0.005, deviation],
+ [0.01, 0],
+ [0.01, 0.01],
+ [0.005, 0.01],
+ [0.005, deviation],
+ ],
+ maneuver: { type: "fork", modifier: "right" },
+ },
+ {
+ instruction: "Exit right",
+ distance: 150,
+ duration: 20,
+ coordinates: [
+ [0.005, deviation],
+ [0.006, -0.001],
+ ],
+ maneuver: { type: "fork", modifier: "right" },
+ },
+ ],
+ };
+ const points = findJunctionDecisionPoints(route);
+ expect(points).toHaveLength(2);
+ expect(points[0].point).toEqual([0.005, deviation]);
+ expect(points[1].point).toEqual([0.005, deviation]);
+ expect(points[0].alongMeters).toBeCloseTo(555.975, 2);
+ expect(points[0].approachBearing).toBeCloseTo(90, 4);
+ expect(points[1].alongMeters).toBeCloseTo(cumulativeDistances(route.geometry)[4], 2);
+ expect(points[1].approachBearing).toBeCloseTo(180, 4);
+ },
+ );
+
+ it("skips lookahead vertices whose remaining visit is omitted from the geometry", () => {
+ const t: LngLat = [0.005, -0.01];
+ const b: LngLat = [0.005, 0];
+ const start: LngLat = [0, 0];
+ const a: LngLat = [0.0025, 0];
+ const c: LngLat = [0.01, 0];
+ const d: LngLat = [0.01, 0.01];
+ const e: LngLat = [0.0025, 0.01];
+ const end: LngLat = [0.003, -0.001];
+ const route: Route = {
+ ...driftRoute,
+ geometry: [t, b, start, c, d, e, a, end],
+ steps: [
+ { ...driftRoute.steps[0], motorway: true, coordinates: [t, b, start, a] },
+ { ...driftRoute.steps[2], motorway: true, coordinates: [a, b, c, d, e, a] },
+ { ...driftRoute.steps[2], coordinates: [a, end] },
+ ],
+ };
+ const points = findJunctionDecisionPoints(route);
+ expect(points).toHaveLength(2);
+ expect(points[0].alongMeters).toBeCloseTo(1945.911, 2);
+ expect(points[0].approachBearing).toBeCloseTo(90, 4);
+ expect(points[1].alongMeters).toBeCloseTo(cumulativeDistances(route.geometry)[6], 2);
+ expect(points[1].approachBearing).toBeCloseTo(180, 4);
+ });
+
+ it("anchors a fork on the return along a retraced segment", () => {
+ const a: LngLat = [0, 0];
+ const middle: LngLat = [0.001, 0];
+ const b: LngLat = [0.002, 0];
+ const c: LngLat = [0.003, 0];
+ const route: Route = {
+ ...driftRoute,
+ geometry: [a, b, c, a],
+ steps: [
+ { ...driftRoute.steps[0], motorway: true, coordinates: [a, middle, b, c] },
+ { ...driftRoute.steps[1], motorway: true, coordinates: [c, b], maneuver: { type: "turn" } },
+ { ...driftRoute.steps[2], coordinates: [b, middle, a] },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point).toEqual(b);
+ expect(point.alongMeters).toBeCloseTo(444.78, 2);
+ expect(point.approachBearing).toBeCloseTo(270, 4);
+ });
+
+ it.each(Array.from({ length: 64 }, (_, mask) => mask))(
+ "retains travel order through simplified retraced geometry (mask %s)",
+ (mask) => {
+ const raw: LngLat[] = [0, 1, 2, 3, 2, 1, 0, 1, 2, 3].map((x) => [x * 0.001, 0]);
+ const optional = [1, 2, 4, 5, 7, 8];
+ const retained = new Set([0, 3, 6, 9, ...optional.filter((_, bit) => mask & (1 << bit))]);
+ const route: Route = {
+ ...driftRoute,
+ geometry: raw.filter((_, index) => retained.has(index)),
+ steps: raw.slice(0, -1).map((coordinate, index) => ({
+ ...driftRoute.steps[0],
+ motorway: true,
+ coordinates: [coordinate, raw[index + 1]],
+ maneuver: { type: index === 0 ? "depart" : index === 3 || index === 6 ? "turn" : "fork" },
+ })),
+ };
+ const points = findJunctionDecisionPoints(route);
+ expect(points.map((point) => point.stepIndex)).toEqual([1, 2, 4, 5, 7, 8]);
+ for (const point of points) {
+ expect(point.point).toEqual(raw[point.stepIndex]);
+ expect(point.alongMeters).toBeCloseTo(111.19492664455875 * point.stepIndex, 4);
+ }
+ },
+ );
+
+ it("reprojects anchors consumed by an earlier loop when its step shape is missing", () => {
+ const a: LngLat = [0, 0];
+ const b: LngLat = [0.01, 0];
+ const c: LngLat = [0.01, 0.01];
+ const d: LngLat = [0, 0.01];
+ const end: LngLat = [0.012, 0];
+ const route: Route = {
+ ...driftRoute,
+ geometry: [a, b, c, d, a, b, end],
+ steps: [
+ { ...driftRoute.steps[0], distance: 4447.797048846394, coordinates: [], motorway: true },
+ { ...driftRoute.steps[1], coordinates: [a, b], motorway: true },
+ { ...driftRoute.steps[2], coordinates: [b, end] },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point).toEqual(b);
+ expect(point.alongMeters).toBeCloseTo(cumulativeDistances(route.geometry)[5], 4);
+ expect(point.approachBearing).toBeCloseTo(90, 4);
+ });
+
+ it.each([5000, 6000])(
+ "recovers a maneuver after missing-shape distance drift (%s m)",
+ (distance) => {
+ const a: LngLat = [0, 0];
+ const b: LngLat = [0.01, 0];
+ const c: LngLat = [0.01, 0.01];
+ const d: LngLat = [0, 0.01];
+ const route: Route = {
+ ...driftRoute,
+ geometry: [a, b, c, d, a, b, a],
+ steps: [
+ { ...driftRoute.steps[0], distance, coordinates: [], motorway: true },
+ { ...driftRoute.steps[2], coordinates: [b, a] },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point).toEqual(b);
+ expect(point.alongMeters).toBeCloseTo(cumulativeDistances(route.geometry)[5], 4);
+ expect(point.divergenceDeg).toBeCloseTo(180, 4);
+ },
+ );
+
+ it.each([
+ { distance: 5000, earlierVertex: false },
+ { distance: 6000, earlierVertex: false },
+ { distance: 5000, earlierVertex: true },
+ { distance: 6000, earlierVertex: true },
+ ])(
+ "recovers an omitted maneuver after a missing shape ($distance m, earlier vertex $earlierVertex)",
+ ({ distance, earlierVertex }) => {
+ const a: LngLat = [0, 0];
+ const middle: LngLat = [0.005, 0];
+ const b: LngLat = [0.01, 0];
+ const c: LngLat = [0.01, 0.01];
+ const d: LngLat = [0, 0.01];
+ const end: LngLat = [0.012, 0];
+ const route: Route = {
+ ...driftRoute,
+ geometry: earlierVertex ? [a, middle, b, c, d, a, b, end] : [a, b, c, d, a, b, end],
+ steps: [
+ { ...driftRoute.steps[0], distance, coordinates: [], motorway: true },
+ { ...driftRoute.steps[2], coordinates: [middle, b, end] },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.point).toEqual(middle);
+ expect(point.alongMeters).toBeCloseTo(5003.77168196654, 4);
+ expect(junctionLookupPoints(route, [point])[0].lookup.trace[3][0]).toBeLessThan(0.005);
+ },
+ );
+
+ it.each([1, 8])("uses distance evidence to pass a loop with %s missing step shapes", (count) => {
+ const route: Route = {
+ ...driftRoute,
+ geometry: [
+ [0, 0],
+ [0.01, 0],
+ [0.01, 0.01],
+ [0, 0.01],
+ [0, 0],
+ [0.002, 0],
+ ],
+ steps: [
+ ...Array.from({ length: count }, () => ({
+ instruction: "Drive around the block",
+ distance: 4447.797048846394 / count,
+ duration: 100 / count,
+ coordinates: [],
+ motorway: true,
+ })),
+ {
+ instruction: "Exit right",
+ distance: 220,
+ duration: 20,
+ coordinates: [
+ [0, 0],
+ [0.002, 0],
+ ],
+ maneuver: { type: "fork", modifier: "right" },
+ },
+ ],
+ };
+ const point = findJunctionDecisionPoints(route)[0];
+ expect(point.alongMeters).toBeCloseTo(4447.797, 2);
+ expect(point.approachBearing).toBeCloseTo(180, 4);
+ });
+});
+
const METERS_PER_DEG_LAT = 111320;
interface SyntheticStep {
diff --git a/packages/core/src/navigation/__tests__/junctionFixture.test.ts b/packages/core/src/navigation/__tests__/junctionFixture.test.ts
index 8dd78e60f..54e632434 100644
--- a/packages/core/src/navigation/__tests__/junctionFixture.test.ts
+++ b/packages/core/src/navigation/__tests__/junctionFixture.test.ts
@@ -7,7 +7,6 @@ interface Fixture {
manifest: { capturedAt: string; synthetic: string[] };
route: Route;
ways: { wayId: number; tags: Record }[];
- images: { id: string; properties: Record; providers?: unknown }[];
}
const fixture = JSON.parse(
@@ -33,21 +32,4 @@ describe("A57 Neuss exit 20 golden fixture", () => {
expect(exitStep.bearingBefore).toBe(283);
expect(exitStep.bearingAfter).toBe(300);
});
-
- it("holds imagery from 2019 and 2025 in the live item shape", () => {
- expect(fixture.images.length).toBeGreaterThanOrEqual(6);
- const in2019 = fixture.images.filter((i) => String(i.properties.datetime).startsWith("2019"));
- const in2025 = fixture.images.filter((i) => String(i.properties.datetime).startsWith("2025"));
- expect(in2019.length).toBeGreaterThanOrEqual(3);
- expect(in2025.length).toBeGreaterThanOrEqual(2);
- // Live Panoramax items carry providers at the Feature top level and the
- // producer as a plain string, never under properties.
- for (const image of fixture.images) {
- expect(Array.isArray(image.providers)).toBe(true);
- expect(image.properties.providers).toBeUndefined();
- }
- expect(fixture.images.every((i) => typeof i.properties["geovisio:producer"] === "string")).toBe(
- true,
- );
- });
});
diff --git a/packages/core/src/navigation/__tests__/junctionPhoto.test.ts b/packages/core/src/navigation/__tests__/junctionPhoto.test.ts
deleted file mode 100644
index ce0343a99..000000000
--- a/packages/core/src/navigation/__tests__/junctionPhoto.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { licensingFromDeclaration } from "@openmapx/mobility-core/licensing";
-import { describe, expect, it } from "vitest";
-import type { LngLat } from "../../types/geometry";
-import type { JunctionDecisionPoint } from "../../types/junction";
-import type { Route } from "../../types/routing";
-import type { StreetLevelImage } from "../../types/streetLevel";
-import fixture from "../__fixtures__/junction/a57-neuss-exit20.json";
-import { findJunctionDecisionPoints } from "../junctionDetect";
-import { selectJunctionPhoto } from "../junctionPhoto";
-
-const a57Route = fixture.route as unknown as Route;
-const a57Point = findJunctionDecisionPoints(a57Route)[0];
-
-interface FixtureImage {
- id: string;
- geometry?: { coordinates: [number, number] };
- properties?: Record;
- providers?: { name: string; roles?: string[] }[];
-}
-
-/** Map one fixture Panoramax item to the normalised StreetLevelImage shape. */
-function toImage(raw: FixtureImage): StreetLevelImage {
- return {
- id: raw.id,
- providerId: "panoramax",
- lngLat: raw.geometry?.coordinates ?? [0, 0],
- heading: raw.properties?.["view:azimuth"] as number | undefined,
- capturedAt: raw.properties?.datetime as string | undefined,
- isPano: false,
- fovDeg: 70,
- assets: {},
- author: raw.properties?.["geovisio:producer"] as string | undefined,
- licensing: licensingFromDeclaration("CC BY-SA 4.0", undefined, "Data"),
- };
-}
-
-const images = (fixture.images as unknown as FixtureImage[]).map(toImage);
-
-// 2026-09-15: the 2019-09 items are 7 years old, inside the 8-year window.
-const NOW = new Date("2026-09-15T12:00:00Z");
-
-const LAT = 51.18;
-const M_PER_DEG_LAT = 111320;
-const M_PER_DEG_LNG = 111320 * Math.cos((LAT * Math.PI) / 180);
-
-/** A straight road of `lengthM` at `bearingDeg`, sampled every 10 m. */
-function straightRoad(bearingDeg: number, lengthM: number): LngLat[] {
- const rad = (bearingDeg * Math.PI) / 180;
- const points: LngLat[] = [];
- for (let walked = 0; walked <= lengthM; walked += 10) {
- points.push([
- ((Math.sin(rad) * walked) / M_PER_DEG_LNG) as number,
- LAT + (Math.cos(rad) * walked) / M_PER_DEG_LAT,
- ]);
- }
- return points;
-}
-
-/** The point `meters` along a road sampled every 10 m. */
-function pointAlong(road: LngLat[], meters: number): LngLat {
- return road[Math.round(meters / 10)];
-}
-
-describe("selectJunctionPhoto on the A57 fixture", () => {
- it("selects the upstream frame that shows the exit lane and the gantry", () => {
- const selected = selectJunctionPhoto(images, a57Point, NOW);
- // All 2019-09 frames come from one drive, so the one closest to ~80 m
- // before the split wins: far enough to see the boards, close enough that
- // the exit lane has opened. The 2025 frames face the other carriageway, the
- // 600 m frame is out of the band and the downstream frame fails the
- // upstream rule.
- expect(selected?.id).toBe("caea047f-f3c0-4819-8354-11805e6597c3");
- });
-
- it("prefers a newer capture month over a better distance", () => {
- const close = images.find((image) => image.id.startsWith("caea047f"))!;
- const farther = images.find((image) => image.id.startsWith("5f5dcbc8"))!;
- const newerFarther = { ...farther, id: "newer", capturedAt: "2024-05-01T08:00:00Z" };
- expect(selectJunctionPhoto([close, newerFarther], a57Point, NOW)?.id).toBe("newer");
- });
-
- it("treats frames from the same drive as equally recent", () => {
- const close = images.find((image) => image.id.startsWith("caea047f"))!;
- // A second later in the same drive, but further from the ~80 m sweet spot.
- const later = images.find((image) => image.id.startsWith("b77f03dc"))!;
- expect(selectJunctionPhoto([later, close], a57Point, NOW)?.id).toBe(close.id);
- });
-
- describe("with the point where the exit lanes begin", () => {
- const at84 = images.find((image) => image.id.startsWith("caea047f"))!;
- const at57 = images.find((image) => image.id.startsWith("b77f03dc"))!;
- // The next frame of the same drive, 35 m before the split: all five lanes
- // are painted under the gantry. OSM adds the fifth lane 41 m before it.
- const at35: StreetLevelImage = {
- ...at57,
- id: "c8462718-1f2a-4480-933c-b6914aa142a3",
- lngLat: [6.677331862, 51.178847745],
- heading: 283,
- capturedAt: "2019-09-10T06:24:36.795000+00:00",
- };
-
- it("takes the frame where the exit lanes already exist", () => {
- const frames = [at84, at57, at35];
- expect(selectJunctionPhoto(frames, a57Point, NOW)?.id).toBe(at84.id);
- expect(selectJunctionPhoto(frames, a57Point, NOW, { fullLanesFromMeters: 41 })?.id).toBe(
- at35.id,
- );
- });
-
- it("takes the frame nearest the lanes' start when none is past it", () => {
- expect(
- selectJunctionPhoto([at84, at57], a57Point, NOW, { fullLanesFromMeters: 41 })?.id,
- ).toBe(at57.id);
- });
-
- it("keeps the usual distance when the lanes exist long before the split", () => {
- expect(
- selectJunctionPhoto([at84, at57, at35], a57Point, NOW, { fullLanesFromMeters: 400 })?.id,
- ).toBe(at84.id);
- });
- });
-
- it("rejects the downstream frame even though it faces along the road", () => {
- const downstream = images.find((image) => image.id.startsWith("b6653d58"))!;
- expect(selectJunctionPhoto([downstream], a57Point, NOW)).toBeNull();
- });
-
- it("rejects old imagery when the age limit shrinks", () => {
- expect(selectJunctionPhoto(images, a57Point, NOW, { maxAgeYears: 5 })).toBeNull();
- });
-
- it("judges the heading against the road at the photo's own position", () => {
- // A curving approach: the road at the photo runs 40° off the bearing read
- // 100 m before the split, and the photo faces the road it was shot from.
- const road = straightRoad(243, 300);
- const point: JunctionDecisionPoint = {
- stepIndex: 1,
- kind: "exit",
- side: "right",
- point: road[road.length - 1],
- alongMeters: 300,
- approachBearing: 283,
- divergenceDeg: 17,
- activeLanes: [],
- };
- const photo: StreetLevelImage = {
- id: "curve",
- providerId: "panoramax",
- lngLat: pointAlong(road, 220),
- heading: 243,
- capturedAt: "2024-05-01T08:00:00Z",
- isPano: false,
- fovDeg: 70,
- assets: {},
- };
- expect(selectJunctionPhoto([photo], point, NOW)).toBeNull();
- expect(selectJunctionPhoto([photo], point, NOW, { geometry: road })?.id).toBe("curve");
- });
-
- it("rejects a photo taken off the route when the route is known", () => {
- // ~40 m off the carriageway: still plausibly "before the exit, facing it",
- // so only the route itself rules it out.
- const aside = images
- .filter((image) => image.id.startsWith("caea047f"))
- .map((image) => ({
- ...image,
- lngLat: [image.lngLat[0], image.lngLat[1] + 0.00036] as LngLat,
- }));
- expect(selectJunctionPhoto(aside, a57Point, NOW)).not.toBeNull();
- expect(selectJunctionPhoto(aside, a57Point, NOW, { geometry: a57Route.geometry })).toBeNull();
- });
-
- it("drops images without a heading", () => {
- const noHeading = images.map((image) => ({ ...image, heading: undefined }));
- expect(selectJunctionPhoto(noHeading, a57Point, NOW)).toBeNull();
- });
-});
diff --git a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts
deleted file mode 100644
index 4c1a6dc31..000000000
--- a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { GantryModel, JunctionDecisionPoint } from "../../types/junction";
-import type { Route } from "../../types/routing";
-import fixture from "../__fixtures__/junction/a57-neuss-exit20.json";
-import { findJunctionDecisionPoints } from "../junctionDetect";
-import { buildJunctionSchematic } from "../junctionSchematic";
-
-const a57Route = fixture.route as unknown as Route;
-const a57Point = findJunctionDecisionPoints(a57Route)[0];
-
-const engineGantry: GantryModel = {
- laneCount: 5,
- panels: [
- {
- lanes: [4],
- destinations: ["Neuss-Zentrum"],
- refs: [],
- symbols: [],
- isExit: true,
- exitNumber: "20",
- },
- ],
- activeLanes: [4],
- source: "engine",
-};
-
-describe("buildJunctionSchematic", () => {
- it("draws five lane bands, the exit panel anchor and a right-side ramp for the fixture", () => {
- const schematic = buildJunctionSchematic(engineGantry, a57Point);
- expect(schematic.width).toBe(320);
- expect(schematic.height).toBe(140);
- expect(schematic.lanePolygons).toHaveLength(5);
- expect(schematic.panelAnchors).toHaveLength(1);
- const anchor = schematic.panelAnchors[0];
- // The rightmost lane's top span: the lanes run across the top edge.
- expect(anchor.x).toBeGreaterThan(320 * 0.6);
- expect(schematic.rampPath.length).toBeGreaterThan(0);
- // The ramp's far end pulls to the right half of the viewBox.
- const rampXs = [...schematic.rampPath.matchAll(/[\d.]+/g)].map(Number);
- expect(Math.max(...rampXs)).toBeGreaterThan(160);
- });
-
- it("lights the lanes of the gantry it draws, not the engine's", () => {
- // OSM tags four lanes at the gantry and names the rightmost as the exit;
- // the engine counted five and marked its fifth.
- const osmGantry: GantryModel = {
- ...engineGantry,
- laneCount: 4,
- panels: [{ ...engineGantry.panels[0], lanes: [3] }],
- activeLanes: [3],
- source: "osm",
- };
- const schematic = buildJunctionSchematic(osmGantry, { ...a57Point, activeLanes: [4] });
- expect(schematic.lanePolygons).toHaveLength(4);
- expect(schematic.activeLanes).toEqual([3]);
- });
-
- it("lights OSM's exit lanes where the engine sent no lanes", () => {
- const osmGantry: GantryModel = { ...engineGantry, activeLanes: [3, 4], source: "osm" };
- const schematic = buildJunctionSchematic(osmGantry, { ...a57Point, activeLanes: [] });
- expect(schematic.activeLanes).toEqual([3, 4]);
- });
-
- it("mirrors the ramp to the left for a left-side exit", () => {
- const mirrored: JunctionDecisionPoint = { ...a57Point, side: "left" };
- const schematic = buildJunctionSchematic(engineGantry, mirrored);
- const right = buildJunctionSchematic(engineGantry, a57Point);
- // Mirroring flips the ramp to the other half of the carriageway.
- expect(schematic.rampPath).not.toBe(right.rampPath);
- const xs = [...schematic.rampPath.matchAll(/[\d.]+/g)].map(Number);
- expect(Math.max(...xs)).toBeLessThanOrEqual(160 + 1e-9);
- });
-
- it("clamps the ramp angle to 8..45 degrees", () => {
- const shallow = buildJunctionSchematic(engineGantry, { ...a57Point, divergenceDeg: 3 });
- const steep = buildJunctionSchematic(engineGantry, { ...a57Point, divergenceDeg: 90 });
- const normal = buildJunctionSchematic(engineGantry, a57Point);
- // A 3° ramp and a 90° ramp clamp to the same two geometries.
- expect(shallow.rampPath).not.toBe(normal.rampPath);
- expect(steep.rampPath).not.toBe(normal.rampPath);
- expect(shallow.rampPath).toBe(
- buildJunctionSchematic(engineGantry, { ...a57Point, divergenceDeg: 0 }).rampPath,
- );
- });
-
- it("is deterministic across calls", () => {
- expect(buildJunctionSchematic(engineGantry, a57Point)).toEqual(
- buildJunctionSchematic(engineGantry, a57Point),
- );
- });
-
- it("still produces valid paths for a one-lane model", () => {
- const oneLane: GantryModel = {
- laneCount: 1,
- panels: [
- { lanes: [0], destinations: ["Neuss-Zentrum"], refs: [], symbols: [], isExit: true },
- ],
- activeLanes: [0],
- source: "engine",
- };
- const schematic = buildJunctionSchematic(oneLane, a57Point);
- expect(schematic.lanePolygons).toHaveLength(1);
- expect(schematic.rampPath.length).toBeGreaterThan(0);
- });
-});
diff --git a/packages/core/src/navigation/__tests__/laneTags.test.ts b/packages/core/src/navigation/__tests__/laneTags.test.ts
index edbf1a3ec..5b4be585a 100644
--- a/packages/core/src/navigation/__tests__/laneTags.test.ts
+++ b/packages/core/src/navigation/__tests__/laneTags.test.ts
@@ -1,260 +1,250 @@
import { describe, expect, it } from "vitest";
-import type { GantryModel, JunctionWay } from "../../types/junction";
-import type { Route } from "../../types/routing";
-import fixture from "../__fixtures__/junction/a57-neuss-exit20.json";
-import { findJunctionDecisionPoints } from "../junctionDetect";
-import {
- laneDestinationsAt,
- mergeExitPanel,
- parseLaneTags,
- selectApproachWay,
- selectRampWay,
-} from "../laneTags";
+import type {
+ JunctionDecisionPoint,
+ JunctionLookupResult,
+ JunctionWay,
+} from "../../types/junction";
+import { resolveLaneGuidance } from "../lanes";
+import * as laneTags from "../laneTags";
-const a57Point = findJunctionDecisionPoints(fixture.route as unknown as Route)[0];
-
-function way(partial: Partial): JunctionWay {
+const point: JunctionDecisionPoint = {
+ stepIndex: 1,
+ kind: "fork",
+ side: "left",
+ point: [0, 0.001],
+ alongMeters: 100,
+ approachBearing: 0,
+ divergenceDeg: -20,
+ laneCount: 2,
+ activeLanes: [],
+ approachTrace: [
+ [0, 0],
+ [0, 0.001],
+ ],
+ sign: { exitBranches: ["A 1"], exitToward: ["Target"] },
+};
+function way(patch: Partial = {}): JunctionWay {
return {
wayId: 1,
highway: "motorway",
- bearing: 283,
- endDistanceMeters: 50,
- startDistanceMeters: 0,
- tags: {},
- ...partial,
+ bearing: 0,
+ endDistanceMeters: 0,
+ startDistanceMeters: 100,
+ geometry: [
+ [0, 0],
+ [0, 0.001],
+ ],
+ tags: {
+ lanes: 2,
+ turnLanes: "through|slight_right",
+ destinationLanes: "Target|Other",
+ destinationRefLanes: "A1|A2",
+ },
+ ...patch,
};
}
+function lookup(approach = way()): JunctionLookupResult {
+ return { index: 0, onMotorway: true, approach: [approach], ramps: [] };
+}
-describe("parseLaneTags on fixture way 314653469", () => {
- it("groups adjacent lanes into A 57 and A 46 panels", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationLanes:
- "Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Krefeld;Düsseldorf Nord;Büttgen|Heinsberg;Aachen;Neuss-Holzheim|Heinsberg;Aachen;Neuss-Holzheim",
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
- turnLanes: "none|none|none|none|slight_right",
- },
- 5,
- )!;
- expect(model.laneCount).toBe(5);
- expect(model.panels).toHaveLength(2);
- expect(model.panels[0].lanes).toEqual([0, 1, 2]);
- expect(model.panels[0].refs).toEqual(["A 57"]);
- expect(model.panels[0].destinations).toEqual(["Krefeld", "Düsseldorf Nord", "Büttgen"]);
- expect(model.panels[0].turn).toBeUndefined();
- expect(model.panels[1].lanes).toEqual([3, 4]);
- expect(model.panels[1].refs).toEqual(["A 46"]);
- expect(model.panels[1].destinations).toEqual(["Heinsberg", "Aachen", "Neuss-Holzheim"]);
- // The slight_right is not uniform across the panel, so no turn token.
- expect(model.panels[1].turn).toBeUndefined();
- });
-
- it("takes the exit lane from the OSM turn tags when the engine sent no lanes", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
- turnLanes: "none|none|none|none|slight_right",
- },
- 5,
- )!;
- // Stadia's hosted Valhalla sends no lanes at all; OSM names the exit lane.
- const noEngineLanes = { ...a57Point, laneCount: undefined, activeLanes: [] };
- const merged = mergeExitPanel(model, noEngineLanes, { destination: "Neuss-Zentrum" });
- expect(merged.panels.at(-1)!.lanes).toEqual([4]);
- expect(merged.activeLanes).toEqual([4]);
+describe("approach lane evidence", () => {
+ it("uses a matched carriageway's per-lane destinations", () => {
+ expect(laneTags.resolveApproachLaneGuidance(lookup(), point)).toMatchObject({
+ preferredLanes: [0],
+ usableLanes: [0],
+ state: "known",
+ source: "osm",
+ });
});
-
- it("folds the exit into the board that already covers the exit lane", () => {
- // Way 314653466: lane 4 has its own board. Appending a second exit board
- // over the same lane lights two boards for one decision.
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationLanes:
- "|||Heinsberg;Aachen;Neuss-Holzheim|Heinsberg;Aachen;Neuss-Holzheim;Neuss-Zentrum",
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46;Neuss-Zentrum",
- turnLanes: "none|none|none|none|slight_right",
- },
- 5,
- )!;
- const merged = mergeExitPanel(
- model,
- { ...a57Point, laneCount: undefined, activeLanes: [] },
- {
- destination: "Neuss-Zentrum",
- },
+ it("keeps an unknown gap between equal destinations", () => {
+ const result = laneTags.resolveApproachLaneGuidance(
+ lookup(
+ way({
+ tags: { lanes: 3, destinationLanes: "Target||Target", destinationRefLanes: "A1||A1" },
+ }),
+ ),
+ { ...point, laneCount: 3 },
);
- expect(merged.panels.filter((panel) => panel.isExit)).toHaveLength(1);
- expect(merged.panels).toHaveLength(model.panels.length);
- const exitPanel = merged.panels.at(-1)!;
- expect(exitPanel.lanes).toEqual([4]);
- expect(exitPanel.exitNumber).toBe("20");
- expect(exitPanel.destinations).toContain("Neuss-Zentrum");
- expect(exitPanel.destinations).toContain("Heinsberg");
- // Each text once across the whole board, however the ways were tagged.
- const texts = [...exitPanel.refs, ...exitPanel.destinations];
- expect(texts.filter((text) => text === "Neuss-Zentrum")).toHaveLength(1);
+ expect(result?.preferredLanes).toEqual([0, 2]);
});
-
- it("reads a place name tagged into destination:ref as a destination", () => {
- // Way 314653466 carries "Neuss-Zentrum" in `destination:ref:lanes`; a road
- // ref always has a number, a town never does.
- const model = parseLaneTags({ lanes: 2, destinationRefLanes: "A 46|A 46;Neuss-Zentrum" }, 2)!;
- expect(model.panels.at(-1)!.refs).toEqual(["A 46"]);
- expect(model.panels.at(-1)!.destinations).toEqual(["Neuss-Zentrum"]);
- });
-
- it("spans every lane the OSM tags send toward the exit", () => {
- const model = parseLaneTags(
- {
- lanes: 4,
- destinationRefLanes: "A 1|A 1|A 2|A 2",
- turnLanes: "none|none|slight_right|right",
- },
- 4,
- )!;
- const merged = mergeExitPanel(model, { ...a57Point, laneCount: undefined, activeLanes: [] });
- expect(merged.activeLanes).toEqual([2, 3]);
+ it.each(["Target", "Target|Other|Extra"])(
+ "ignores malformed per-lane lists: %s",
+ (destinationLanes) => {
+ expect(
+ laneTags.resolveApproachLaneGuidance(
+ lookup(way({ tags: { lanes: 2, destinationLanes } })),
+ point,
+ )?.state,
+ ).toBe("unknown");
+ },
+ );
+ it("does not borrow destinations from a parallel carriageway", () => {
+ const result = laneTags.resolveApproachLaneGuidance(
+ lookup(
+ way({
+ geometry: [
+ [0.0002, 0],
+ [0.0002, 0.001],
+ ],
+ }),
+ ),
+ point,
+ );
+ expect(result).toBeUndefined();
});
-
- it("keeps the engine's lanes when it sent them", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
- // Mis-tagged: OSM says lane 0 exits, the engine says lane 4.
- turnLanes: "slight_right|none|none|none|none",
- },
- 5,
- )!;
- const merged = mergeExitPanel(model, { ...a57Point, laneCount: 5, activeLanes: [4] });
- expect(merged.activeLanes).toEqual([4]);
+ it("does not borrow lanes from the opposite direction", () => {
+ expect(
+ laneTags.resolveApproachLaneGuidance(
+ lookup(
+ way({
+ bearing: 180,
+ geometry: [
+ [0, 0.001],
+ [0, 0],
+ ],
+ }),
+ ),
+ point,
+ ),
+ ).toBeUndefined();
});
-
- it("falls back to the outermost lane when neither the engine nor OSM says", () => {
- const model = parseLaneTags({ lanes: 5, destinationRefLanes: "A 57|A 57|A 57|A 46|A 46" }, 5)!;
- const merged = mergeExitPanel(model, { ...a57Point, laneCount: undefined, activeLanes: [] });
- expect(merged.activeLanes).toEqual([4]);
+ it("does not transfer engine indices onto a different lane count", () => {
+ const laneGuidance = resolveLaneGuidance([
+ { indications: ["left"], valid: true, preferred: true },
+ { indications: ["right"], valid: false },
+ ]);
+ const result = laneTags.resolveApproachLaneGuidance(
+ lookup(
+ way({
+ tags: {
+ lanes: 3,
+ destinationLanes: "Other|Other|Target",
+ destinationRefLanes: "A2|A2|A1",
+ },
+ }),
+ ),
+ { ...point, laneGuidance, activeLanes: [0] },
+ );
+ expect(result?.usableLanes).toEqual([2]);
+ expect(result?.source).toBe("osm");
});
-
- it("appends the exit panel from the ramp tags and moves the active lanes", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46",
- },
- 5,
- )!;
- const merged = mergeExitPanel(model, a57Point, { destination: "Neuss-Zentrum" });
- expect(merged.panels.at(-1)).toEqual({
- lanes: [4],
- destinations: ["Neuss-Zentrum"],
- refs: [],
- symbols: [],
- isExit: true,
- exitNumber: "20",
- });
- expect(merged.activeLanes).toEqual([4]);
+ it("preserves explicit engine rejection instead of guessing acceptance", () => {
+ const laneGuidance = resolveLaneGuidance([
+ { indications: ["through"], valid: false },
+ { indications: ["right"], valid: false },
+ ]);
+ expect(
+ laneTags.resolveApproachLaneGuidance(lookup(), { ...point, laneGuidance }),
+ ).toMatchObject({ usableLanes: [], preferredLanes: [], source: "engine" });
});
-
- it("yields three panels for way 314653466 without the ramp", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationLanes:
- "|||Heinsberg;Aachen;Neuss-Holzheim|Heinsberg;Aachen;Neuss-Holzheim;Neuss-Zentrum",
- destinationRefLanes: "A 57|A 57|A 57|A 46|A 46;Neuss-Zentrum",
- },
- 5,
- )!;
- expect(model.panels).toHaveLength(3);
- expect(model.panels[2].destinations).toEqual([
- "Heinsberg",
- "Aachen",
- "Neuss-Holzheim",
- "Neuss-Zentrum",
+ it("withholds conflicting engine and destination evidence", () => {
+ const laneGuidance = resolveLaneGuidance([
+ { indications: ["through"], valid: false },
+ { indications: ["right"], valid: true, preferred: true },
]);
- // The town tagged into `destination:ref:lanes` is a destination, not a ref.
- expect(model.panels[2].refs).toEqual(["A 46"]);
- expect(model.panels[2].isExit).toBe(false);
+ expect(
+ laneTags.resolveApproachLaneGuidance(lookup(), { ...point, laneGuidance, activeLanes: [1] }),
+ ).toMatchObject({ state: "conflict", usableLanes: [], preferredLanes: [] });
});
-});
-
-describe("parseLaneTags lane-count mismatches", () => {
- const fourLaneModel: GantryModel = {
- laneCount: 4,
- panels: [
- { lanes: [0, 1, 2, 3], destinations: ["Köln"], refs: ["A 57"], symbols: [], isExit: false },
- ],
- activeLanes: [],
- source: "osm",
- };
-
- it("maps the exit panel to the outermost right lane when the model is narrower", () => {
- const merged = mergeExitPanel(fourLaneModel, { ...a57Point, activeLanes: [4] }, undefined);
- const exitPanel = merged.panels.at(-1)!;
- expect(exitPanel.lanes).toEqual([3]);
- expect(merged.activeLanes).toEqual([3]);
+ it("keeps initially usable lanes distinct from preferred lanes", () => {
+ const laneGuidance = resolveLaneGuidance([
+ { indications: ["through"], valid: true },
+ { indications: ["right"], valid: true },
+ ]);
+ expect(
+ laneTags.resolveApproachLaneGuidance(lookup(), {
+ ...point,
+ laneGuidance,
+ activeLanes: [0, 1],
+ }),
+ ).toMatchObject({ usableLanes: [0, 1], preferredLanes: [], source: "engine" });
});
-
- it("maps the exit panel to lane 0 on the left side", () => {
- const merged = mergeExitPanel(fourLaneModel, {
- ...a57Point,
- activeLanes: [0],
- side: "left",
- });
- expect(merged.panels.at(-1)!.lanes).toEqual([0]);
- expect(merged.activeLanes).toEqual([0]);
+ it("withholds every lane when engine preference contradicts engine rejection", () => {
+ const laneGuidance = resolveLaneGuidance([
+ { indications: ["through"], valid: false, preferred: true },
+ { indications: ["right"], valid: true },
+ ]);
+ const result = laneTags.resolveApproachLaneGuidance(lookup(), { ...point, laneGuidance });
+ expect(result?.state).toBe("conflict");
+ expect(result?.lanes.some((lane) => lane.valid === true)).toBe(false);
});
-});
-
-describe("parseLaneTags edge cases", () => {
- it("tolerates a trailing empty lane value", () => {
- const model = parseLaneTags({ lanes: 3, destinationRefLanes: "A 57|A 46|" }, 3)!;
- expect(model.panels).toHaveLength(2);
- expect(model.panels[0].refs).toEqual(["A 57"]);
+ it("ignores destination evidence when one of its lane lists has a different length", () => {
+ const result = laneTags.resolveApproachLaneGuidance(
+ lookup(
+ way({
+ tags: {
+ lanes: 2,
+ destinationLanes: "Target",
+ destinationRefLanes: "A1|A2",
+ },
+ }),
+ ),
+ point,
+ );
+ expect(result?.preferredLanes).toEqual([]);
});
-
- it("returns null when the lanes tag disagrees", () => {
+ it("does not infer lane selection from arrow direction alone", () => {
expect(
- parseLaneTags({ lanes: 4, destinationRefLanes: "A 57|A 57|A 46|A 46|A 46" }, 5),
- ).toBeNull();
+ laneTags.resolveApproachLaneGuidance(
+ lookup(way({ tags: { lanes: 2, turnLanes: "left|right" } })),
+ point,
+ ),
+ ).toMatchObject({ state: "unknown", preferredLanes: [] });
});
-
- it("does not invent destinations for lanes the tag leaves out", () => {
- expect(parseLaneTags({ lanes: 5, destinationRefLanes: "A 57|A 57|A 46|A 46" }, 5)).toBeNull();
- // The same short list is rejected when only the engine knows the lane count.
- expect(parseLaneTags({ destinationRefLanes: "A 57|A 57|A 46|A 46" }, 5)).toBeNull();
+ it("does not assign all incoming lanes to an unresolved same-ref branch", () => {
+ const result = lookup(way({ tags: { lanes: 2, destinationRefLanes: "A1|A1" } }));
+ result.outgoing = [
+ way({ wayId: 11, tags: { destinationRef: "A1", destination: "Target" } }),
+ way({ wayId: 12, tags: { destinationRef: "A1", destination: "Other" } }),
+ ];
+ expect(laneTags.resolveApproachLaneGuidance(result, point)?.preferredLanes).toEqual([]);
});
+ it("does not use a nearer unconnected ramp to select lanes", () => {
+ const result = lookup(
+ way({ tags: { lanes: 2, destinationLanes: "Target|Other", destinationRefLanes: "A1|A2" } }),
+ );
+ result.outgoing = [
+ way({ wayId: 11, tags: { destinationRef: "A1", destination: "Elsewhere" } }),
+ way({ wayId: 12, tags: { destinationRef: "A2", destination: "Other" } }),
+ ];
+ expect(laneTags.resolveApproachLaneGuidance(result, point)?.preferredLanes).toEqual([]);
+ });
+});
- it("returns null with only turn:lanes and no destination tags", () => {
+describe("laneDestinationsAt", () => {
+ it("can read a same-count link carrying the route into a split", () => {
expect(
- parseLaneTags({ lanes: 5, turnLanes: "none|none|none|none|slight_right" }, 5),
- ).toBeNull();
+ laneTags.laneDestinationsAt(
+ { approach: [], ramps: [way({ highway: "motorway_link" })] },
+ point,
+ ),
+ ).toEqual([
+ { destinations: ["Target"], refs: ["A1"] },
+ { destinations: ["Other"], refs: ["A2"] },
+ ]);
});
-
- it("attaches destination:symbol:lanes to its lane", () => {
- const model = parseLaneTags(
- {
- lanes: 5,
- destinationRefLanes: "A 57|A 57|A 46|A 46|A 46",
- destinationSymbolLanes: "||airport||",
- },
- 5,
- )!;
- expect(model.panels[1].symbols).toEqual(["airport"]);
+ it("requires the engine lane count and legal approach direction", () => {
+ expect(laneTags.laneDestinationsAt(lookup(), { ...point, laneCount: 3 })).toBeUndefined();
+ expect(
+ laneTags.laneDestinationsAt(lookup(way({ endDistanceMeters: -10 })), point),
+ ).toBeUndefined();
});
});
describe("way selection", () => {
+ const westPoint = {
+ ...point,
+ approachBearing: 283,
+ divergenceDeg: 17,
+ laneCount: 5,
+ approachTrace: undefined,
+ };
it("prefers a way whose bearing and lane count match over a reverse carriageway", () => {
const candidates: JunctionWay[] = [
way({ wayId: 2, bearing: 355, endDistanceMeters: 10, tags: { lanes: 5 } }),
way({ wayId: 1, bearing: 285, endDistanceMeters: 100, tags: { lanes: 5 } }),
];
- expect(selectApproachWay(candidates, a57Point)?.wayId).toBe(1);
+ expect(laneTags.selectApproachWay(candidates, westPoint)?.wayId).toBe(1);
});
it("keeps only _link ramps that start at the split within 60 degrees of the after bearing", () => {
@@ -265,7 +255,7 @@ describe("way selection", () => {
way({ wayId: 5, highway: "motorway_link", bearing: 268, startDistanceMeters: 372 }),
way({ wayId: 6, highway: "motorway", bearing: 300, startDistanceMeters: 0 }),
];
- const selected = selectRampWay(ramps, a57Point);
+ const selected = laneTags.selectRampWay(ramps, westPoint);
expect(selected.map((r) => r.wayId)).toEqual([3]);
});
@@ -274,43 +264,6 @@ describe("way selection", () => {
way({ wayId: 7, highway: "motorway_link", bearing: 295, startDistanceMeters: 40 }),
way({ wayId: 8, highway: "motorway_link", bearing: 305, startDistanceMeters: 3 }),
];
- expect(selectRampWay(ramps, a57Point).map((r) => r.wayId)).toEqual([8, 7]);
- });
-});
-
-describe("laneDestinationsAt", () => {
- const point = { ...a57Point, laneCount: 4 };
- const split = way({
- wayId: 152275743,
- highway: "motorway_link",
- bearing: 285,
- endDistanceMeters: 2,
- tags: {
- lanes: 4,
- destinationLanes: "Düsseldorf|Düsseldorf|Liège|Liège",
- destinationRefLanes: "A 44|A 44|A 44|A 44",
- },
- });
-
- it("reads the split's lanes off a link carrying the route into it", () => {
- const mainline = way({ wayId: 1, bearing: 285, endDistanceMeters: 30, tags: { lanes: 3 } });
- expect(laneDestinationsAt({ approach: [mainline], ramps: [split] }, point)).toEqual([
- { destinations: ["Düsseldorf"], refs: ["A 44"] },
- { destinations: ["Düsseldorf"], refs: ["A 44"] },
- { destinations: ["Liège"], refs: ["A 44"] },
- { destinations: ["Liège"], refs: ["A 44"] },
- ]);
- });
-
- it("needs the engine's lane count, so the lanes line up one to one", () => {
- expect(
- laneDestinationsAt({ approach: [], ramps: [split] }, { ...point, laneCount: 3 }),
- ).toBeUndefined();
- });
-
- it("skips a way leaving the split or heading elsewhere", () => {
- const leaving = { ...split, endDistanceMeters: -60 };
- const crossing = { ...split, bearing: 20 };
- expect(laneDestinationsAt({ approach: [], ramps: [leaving, crossing] }, point)).toBeUndefined();
+ expect(laneTags.selectRampWay(ramps, westPoint).map((r) => r.wayId)).toEqual([8, 7]);
});
});
diff --git a/packages/core/src/navigation/__tests__/lanes.test.ts b/packages/core/src/navigation/__tests__/lanes.test.ts
index 57c474e57..261596fa4 100644
--- a/packages/core/src/navigation/__tests__/lanes.test.ts
+++ b/packages/core/src/navigation/__tests__/lanes.test.ts
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import type { ManeuverLane } from "../../types/routing";
import {
guidanceApproachMeters,
- junctionPhotoApproachMeters,
resolveGuidanceLanes,
+ resolveLaneGuidance,
resolveRecommendedLanes,
shouldPreviewNextStep,
} from "../lanes";
@@ -11,45 +11,77 @@ import {
const lane = (indications: string[], valid = false): ManeuverLane => ({ indications, valid });
describe("resolveRecommendedLanes", () => {
+ it("does not turn unknown arrows into an invented recommendation", () => {
+ const result = resolveLaneGuidance([{ indications: ["left"] }, { indications: ["through"] }]);
+ expect(result.state).toBe("unknown");
+ expect(result.usableLanes).toEqual([]);
+ expect(result.preferredLanes).toEqual([]);
+ });
+ it("keeps initially usable and preferred sets distinct", () => {
+ const result = resolveLaneGuidance([
+ { indications: ["left"], valid: true, preferred: false },
+ { indications: ["left"], valid: true, preferred: true },
+ { indications: ["right"], valid: false, preferred: false },
+ ]);
+ expect(result.usableLanes).toEqual([0, 1]);
+ expect(result.preferredLanes).toEqual([1]);
+ expect(result.state).toBe("known");
+ });
+ it("preserves explicit engine rejection instead of guessing from turn arrows", () => {
+ const lanes = [lane(["left"], false), lane(["through"], false), lane(["right"], false)];
+ expect(
+ resolveRecommendedLanes(lanes, { type: "turn", modifier: "left" }).map((l) => l.valid),
+ ).toEqual([false, false, false]);
+ });
it("trusts the engine when a lane is already valid", () => {
const lanes = [lane(["left"], true), lane(["through"])];
expect(resolveRecommendedLanes(lanes, { type: "turn", modifier: "right" })).toBe(lanes);
});
- it("recommends an exact-match lane when the engine didn't decide", () => {
+ it("does not override rejection even for an exact turn match", () => {
const lanes = [lane(["left"]), lane(["through"]), lane(["right"])];
const out = resolveRecommendedLanes(lanes, { type: "turn", modifier: "left" });
- expect(out.map((l) => l.valid)).toEqual([true, false, false]);
- expect(out[0].active).toBe("left");
+ expect(out.map((l) => l.valid)).toEqual([false, false, false]);
+ expect(out[0].active).toBeUndefined();
});
- it("falls back to the same side when there's no exact match", () => {
+ it("does not infer branch identity from the same side", () => {
const lanes = [lane(["slight left"]), lane(["through"])];
const out = resolveRecommendedLanes(lanes, { type: "turn", modifier: "left" });
- expect(out[0].valid).toBe(true); // slight left serves a left turn
+ expect(out[0].valid).toBe(false);
});
- it("marks through/straight lanes for a straight maneuver", () => {
+ it("retains rejected through lanes for a straight maneuver", () => {
const lanes = [lane(["left"]), lane(["through"])];
const out = resolveRecommendedLanes(lanes, { type: "turn", modifier: "straight" });
- expect(out.map((l) => l.valid)).toEqual([false, true]);
+ expect(out.map((l) => l.valid)).toEqual([false, false]);
});
it("returns [] for no lanes", () => {
expect(resolveRecommendedLanes(undefined, { type: "turn", modifier: "left" })).toEqual([]);
});
- it("highlights through lanes for keep-left when a right exit peels away", () => {
+ it("does not invent through recommendations for a keep maneuver", () => {
const lanes = [lane(["through"]), lane(["through"]), lane(["slight_right"])];
const out = resolveRecommendedLanes(lanes, { type: "keep", modifier: "left" });
- expect(out.map((l) => l.valid)).toEqual([true, true, false]);
+ expect(out.map((l) => l.valid)).toEqual([false, false, false]);
});
});
+const unknownLane = (indications: string[], valid?: boolean): ManeuverLane => ({
+ indications,
+ valid,
+});
+
describe("resolveGuidanceLanes", () => {
// Kreuz Aachen, the 4-lane link toward A 44: every lane `through`, the left
// two signed Düsseldorf and the right two Liège, all four "A 44".
- const kreuzAachen = [lane(["through"]), lane(["through"]), lane(["through"]), lane(["through"])];
+ const kreuzAachen = [
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ ];
const keepLeft = { type: "keep", modifier: "left" };
const toDuesseldorf = { exitBranches: ["A 44"], exitToward: ["Düsseldorf"] };
const splitDestinations = [
@@ -61,7 +93,7 @@ describe("resolveGuidanceLanes", () => {
it("highlights no lane when every through lane would light up for a keep left", () => {
const out = resolveGuidanceLanes(kreuzAachen, keepLeft);
- expect(out.map((l) => l.valid)).toEqual([false, false, false, false]);
+ expect(out.map((l) => l.valid)).toEqual([undefined, undefined, undefined, undefined]);
});
it("highlights the lanes whose OSM destinations name the sign", () => {
@@ -74,7 +106,7 @@ describe("resolveGuidanceLanes", () => {
it("falls back to refs when the places do not tell the lanes apart", () => {
const out = resolveGuidanceLanes(
- [lane(["through"]), lane(["through"]), lane(["through"])],
+ [unknownLane(["through"]), unknownLane(["through"]), unknownLane(["through"])],
{ type: "keep", modifier: "right" },
{
sign: { exitBranches: ["A44"], exitToward: ["Köln"] },
@@ -93,15 +125,15 @@ describe("resolveGuidanceLanes", () => {
sign: toDuesseldorf,
laneDestinations: splitDestinations.slice(0, 3),
});
- expect(out.map((l) => l.valid)).toEqual([false, false, false, false]);
+ expect(out.map((l) => l.valid)).toEqual([undefined, undefined, undefined, undefined]);
});
it("still trusts the engine over the lane destinations", () => {
const lanes = [
- lane(["through"]),
- lane(["through"]),
- lane(["through"]),
- lane(["through"], true),
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ unknownLane(["through"], true),
];
const out = resolveGuidanceLanes(lanes, keepLeft, {
sign: toDuesseldorf,
@@ -110,46 +142,28 @@ describe("resolveGuidanceLanes", () => {
expect(out).toBe(lanes);
});
- it("keeps the through lanes for a keep left when an exit peels away on the right", () => {
- const lanes = [lane(["through"]), lane(["through"]), lane(["slight_right"])];
+ it("does not infer routed lanes from through arrows before a keep-left", () => {
+ const lanes = [
+ unknownLane(["through"]),
+ unknownLane(["through"]),
+ unknownLane(["slight_right"]),
+ ];
const out = resolveGuidanceLanes(lanes, keepLeft);
- expect(out.map((l) => l.valid)).toEqual([true, true, false]);
+ expect(out.map((l) => l.valid)).toEqual([undefined, undefined, undefined]);
});
- it("keeps every lane lit when each one turns the maneuver's way", () => {
- const lanes = [lane(["left"]), lane(["left", "through"])];
+ it("keeps unknown lane selection even when both arrows turn the maneuver's way", () => {
+ const lanes = [unknownLane(["left"]), unknownLane(["left", "through"])];
const out = resolveGuidanceLanes(lanes, { type: "turn", modifier: "left" });
- expect(out.map((l) => l.valid)).toEqual([true, true]);
+ expect(out.map((l) => l.valid)).toEqual([undefined, undefined]);
});
- it("keeps every through lane lit for a straight maneuver", () => {
- const out = resolveGuidanceLanes([lane(["through"]), lane(["through"])], {
+ it("does not infer lane selection for a straight maneuver", () => {
+ const out = resolveGuidanceLanes([unknownLane(["through"]), unknownLane(["through"])], {
type: "continue",
modifier: "straight",
});
- expect(out.map((l) => l.valid)).toEqual([true, true]);
- });
-});
-
-describe("junctionPhotoApproachMeters", () => {
- it("shows the photo about half a minute out, not the whole guidance window", () => {
- // 30 s at ~120 km/h, where the gantry has already been up for 2970 m.
- expect(junctionPhotoApproachMeters("driving", 33)).toBe(990);
- expect(junctionPhotoApproachMeters("driving", 33)).toBeLessThan(
- guidanceApproachMeters("driving", 33),
- );
- });
-
- it("keeps a floor so a slow approach still shows the photo in time", () => {
- expect(junctionPhotoApproachMeters("driving", 0)).toBe(300);
- });
-
- it("never outlasts the guidance window it sits inside", () => {
- for (const speed of [0, 5, 14, 25, 33, 45]) {
- expect(junctionPhotoApproachMeters("driving", speed)).toBeLessThanOrEqual(
- guidanceApproachMeters("driving", speed),
- );
- }
+ expect(out.map((l) => l.valid)).toEqual([undefined, undefined]);
});
});
@@ -199,3 +213,32 @@ describe("shouldPreviewNextStep", () => {
expect(shouldPreviewNextStep("driving", 14, 300, 0, 800)).toBe(false);
});
});
+
+it("keeps contradictory preferred/rejected lane evidence in conflict", () => {
+ const result = resolveLaneGuidance([{ indications: ["through"], valid: false, preferred: true }]);
+ expect(result.state).toBe("conflict");
+ expect(result.preferredLanes).toEqual([]);
+ expect(result.usableLanes).toEqual([]);
+});
+
+describe("signed destinations and explicit engine evidence", () => {
+ it("does not replace explicitly rejected lanes with destination-derived advice", () => {
+ const lanes = [
+ { indications: ["through"], valid: false },
+ { indications: ["through"], valid: false },
+ ];
+ expect(
+ resolveGuidanceLanes(
+ lanes,
+ { type: "keep", modifier: "left" },
+ {
+ sign: { exitToward: ["Target"] },
+ laneDestinations: [
+ { destinations: ["Target"], refs: [] },
+ { destinations: ["Other"], refs: [] },
+ ],
+ },
+ ),
+ ).toEqual(lanes);
+ });
+});
diff --git a/packages/core/src/navigation/__tests__/maneuverPosition.test.ts b/packages/core/src/navigation/__tests__/maneuverPosition.test.ts
new file mode 100644
index 000000000..614898847
--- /dev/null
+++ b/packages/core/src/navigation/__tests__/maneuverPosition.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "vitest";
+import type { Route } from "../../types/routing";
+import { prepareManeuverPositions } from "../maneuverPosition";
+import { computeProgress } from "../progress";
+import { advanceStepGate } from "../stepGate";
+
+describe("prepareManeuverPositions transport fallback", () => {
+ it("retains engine boundaries for a mobile route with omitted step shapes", () => {
+ const route = {
+ distance: 1000,
+ geometry: [
+ [0, 0],
+ [0, 0.009],
+ ],
+ steps: [{ distance: 400 }, { distance: 600 }],
+ } as unknown as Route;
+ expect(prepareManeuverPositions(route)).toEqual({
+ starts: [0, 400],
+ ends: [400, 1000],
+ total: 1000,
+ metric: "engine-fallback",
+ });
+ });
+
+ it("recovers a physical boundary after a step with no transported shape", () => {
+ const route = {
+ distance: 1200,
+ geometry: [
+ [0, 0],
+ [0, 0.004],
+ [0, 0.009],
+ ],
+ steps: [
+ { distance: 600 },
+ {
+ distance: 600,
+ coordinates: [
+ [0, 0.004],
+ [0, 0.009],
+ ],
+ },
+ ],
+ } as unknown as Route;
+ const positions = prepareManeuverPositions(route);
+ expect(positions.metric).toBe("geometry");
+ expect(positions.starts[0]).toBe(0);
+ expect(positions.starts[1]).toBeCloseTo(444.78, 1);
+ });
+
+ it("keeps both known boundaries around an omitted middle shape despite provider-distance drift", () => {
+ const route = {
+ distance: 1800,
+ duration: 180,
+ geometry: [
+ [0, 0],
+ [0, 0.001],
+ [0, 0.002],
+ [0, 0.009],
+ ],
+ steps: [
+ {
+ distance: 600,
+ duration: 60,
+ coordinates: [
+ [0, 0],
+ [0, 0.001],
+ ],
+ },
+ { distance: 600, duration: 60, coordinates: [] },
+ {
+ distance: 600,
+ duration: 60,
+ coordinates: [
+ [0, 0.002],
+ [0, 0.009],
+ ],
+ },
+ ],
+ } as unknown as Route;
+ const positions = prepareManeuverPositions(route);
+ expect(positions.starts[0]).toBe(0);
+ expect(positions.starts[1]).toBeCloseTo(111.195, 2);
+ expect(positions.starts[2]).toBeCloseTo(222.39, 2);
+ const progress = computeProgress(route, 250);
+ expect(progress.currentStepIndex).toBe(2);
+ expect(progress.distanceToNextManeuver).toBeCloseTo(750.754, 2);
+ expect(progress.distanceRemaining).toBeCloseTo(578.717, 2);
+ expect(progress.durationRemaining).toBeCloseTo(57.872, 2);
+ expect(
+ advanceStepGate(route, 250, { committedStepIndex: 0, reachedStepEnd: false }, 15, 15),
+ ).toEqual({ committedStepIndex: 2, reachedStepEnd: false });
+ });
+
+ it("distributes multiple missing boundaries between confirmed endpoints using relative provider distances", () => {
+ const route = {
+ distance: 2800,
+ geometry: [
+ [0, 0],
+ [0, 0.001],
+ [0, 0.004],
+ [0, 0.009],
+ ],
+ steps: [
+ {
+ distance: 600,
+ coordinates: [
+ [0, 0],
+ [0, 0.001],
+ ],
+ },
+ { distance: 400, coordinates: [] },
+ { distance: 1200, coordinates: [] },
+ {
+ distance: 600,
+ coordinates: [
+ [0, 0.004],
+ [0, 0.009],
+ ],
+ },
+ ],
+ } as unknown as Route;
+ const positions = prepareManeuverPositions(route);
+ expect(positions.starts[1]).toBeCloseTo(111.195, 2);
+ expect(positions.starts[2]).toBeCloseTo(194.591, 2);
+ expect(positions.starts[3]).toBeCloseTo(444.78, 2);
+ });
+
+ it("bounds trailing missing shapes by the remaining physical route length", () => {
+ const route = {
+ distance: 2600,
+ geometry: [
+ [0, 0],
+ [0, 0.001],
+ [0, 0.009],
+ ],
+ steps: [
+ {
+ distance: 600,
+ coordinates: [
+ [0, 0],
+ [0, 0.001],
+ ],
+ },
+ { distance: 500, coordinates: [] },
+ { distance: 1500, coordinates: [] },
+ ],
+ } as unknown as Route;
+ const positions = prepareManeuverPositions(route);
+ expect(positions.starts[1]).toBeCloseTo(111.195, 2);
+ expect(positions.starts[2]).toBeCloseTo(333.585, 2);
+ expect(positions.ends[2]).toBeCloseTo(1000.754, 2);
+ });
+});
diff --git a/packages/core/src/navigation/__tests__/photoProjection.test.ts b/packages/core/src/navigation/__tests__/photoProjection.test.ts
deleted file mode 100644
index 6ee763e68..000000000
--- a/packages/core/src/navigation/__tests__/photoProjection.test.ts
+++ /dev/null
@@ -1,363 +0,0 @@
-import { describe, expect, it } from "vitest";
-import type { LngLat } from "../../types/geometry";
-import type { JunctionDecisionPoint } from "../../types/junction";
-import type { Route } from "../../types/routing";
-import type { StreetLevelImage } from "../../types/streetLevel";
-import fixture from "../__fixtures__/junction/a57-neuss-exit20.json";
-import { cumulativeDistances, positionAt } from "../deadReckon";
-import { findJunctionDecisionPoints } from "../junctionDetect";
-import { projectRoutePath } from "../photoProjection";
-
-const LAT = 51.18;
-const M_PER_DEG_LAT = 111320;
-const M_PER_DEG_LNG = 111320 * Math.cos((LAT * Math.PI) / 180);
-
-/** `straightM` due east, then `bendM` at `bendDeg` off east — an exit ramp. */
-function road(straightM: number, bendDeg = 0, bendM = 0, stepM = 10): LngLat[] {
- let cursor: LngLat = [0, LAT];
- const points: LngLat[] = [cursor];
- const walk = (bearingDeg: number, distance: number) => {
- const rad = (bearingDeg * Math.PI) / 180;
- cursor = [
- cursor[0] + (Math.sin(rad) * distance) / M_PER_DEG_LNG,
- cursor[1] + (Math.cos(rad) * distance) / M_PER_DEG_LAT,
- ];
- points.push(cursor);
- };
- for (let walked = 0; walked < straightM; walked += stepM) walk(90, stepM);
- for (let walked = 0; walked < bendM; walked += stepM) walk(90 + bendDeg, stepM);
- return points;
-}
-
-function decisionPoint(
- geometry: LngLat[],
- alongMeters: number,
- divergenceDeg: number,
-): JunctionDecisionPoint {
- const cum = cumulativeDistances(geometry);
- return {
- stepIndex: 1,
- kind: "exit",
- side: divergenceDeg < 0 ? "left" : "right",
- point: positionAt(geometry, cum, alongMeters).point,
- alongMeters,
- approachBearing: 90,
- divergenceDeg,
- activeLanes: [],
- };
-}
-
-function camera(
- geometry: LngLat[],
- alongMeters: number,
- overrides: Partial = {},
-): StreetLevelImage {
- const cum = cumulativeDistances(geometry);
- return {
- id: "photo",
- providerId: "panoramax",
- lngLat: positionAt(geometry, cum, alongMeters).point,
- heading: 90,
- isPano: false,
- fovDeg: 70,
- assets: {},
- ...overrides,
- };
-}
-
-describe("projectRoutePath", () => {
- it("draws a straight road as a centred path running up to the horizon", () => {
- const geometry = road(400);
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), camera(geometry, 200));
- expect(path.visible).toBe(true);
- expect(path.points.length).toBeGreaterThan(5);
- for (const projected of path.points) {
- expect(projected.xPercent).toBeCloseTo(50, 1);
- expect(projected.yPercent).toBeGreaterThan(50);
- expect(projected.yPercent).toBeLessThanOrEqual(100);
- }
- // Near points sit low in the frame, far ones climb toward the horizon.
- const [nearest] = path.points;
- const farthest = path.points[path.points.length - 1];
- expect(nearest.distanceMeters).toBeLessThan(farthest.distanceMeters);
- expect(nearest.yPercent).toBeGreaterThan(farthest.yPercent);
- expect(farthest.yPercent).toBeLessThan(52);
- });
-
- it("tapers the path with distance, like a road seen in perspective", () => {
- const geometry = road(400);
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), camera(geometry, 200));
- const [nearest] = path.points;
- const farthest = path.points[path.points.length - 1];
- expect(nearest.widthPercent).toBeGreaterThan(3 * farthest.widthPercent);
- expect(farthest.widthPercent).toBeGreaterThan(0);
- // Wide enough to read a few metres ahead, never a slab across the frame.
- expect(nearest.widthPercent).toBeGreaterThan(5);
- expect(nearest.widthPercent).toBeLessThan(40);
- });
-
- it("scales the vertical drop to the photo's own aspect ratio", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- // A wider frame packs the same lens into less height, so the road sits
- // lower in it than in a 4:3 frame of the same lens.
- const fourThree = projectRoutePath(
- geometry,
- point,
- camera(geometry, 200, { aspectRatio: 4 / 3 }),
- );
- const wide = projectRoutePath(geometry, point, camera(geometry, 200, { aspectRatio: 16 / 9 }));
- expect(wide.points[0].yPercent).toBeGreaterThan(fourThree.points[0].yPercent);
- // Without metadata the projection falls back to the 4:3 default.
- const unknown = projectRoutePath(geometry, point, camera(geometry, 200));
- expect(unknown.points[0].yPercent).toBeCloseTo(fourThree.points[0].yPercent, 6);
- });
-
- it("starts the path at the bottom edge of a frame that does not see the road close up", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- // A 16:9 frame through a 60° lens, and a 4:3 one through 40°, first see
- // the road several metres further out than the near clip.
- for (const overrides of [
- { fovDeg: 60, aspectRatio: 16 / 9 },
- { fovDeg: 40, aspectRatio: 4 / 3 },
- ]) {
- const path = projectRoutePath(geometry, point, camera(geometry, 200, overrides));
- expect(path.visible).toBe(true);
- expect(path.points.length).toBeGreaterThan(5);
- expect(path.points[0].yPercent).toBeGreaterThan(99);
- for (const projected of path.points) expect(projected.yPercent).toBeLessThanOrEqual(100);
- }
- });
-
- it("bends toward the exit side of the frame on a right-hand ramp", () => {
- const geometry = road(300, 25, 100);
- const path = projectRoutePath(
- geometry,
- decisionPoint(geometry, 300, 25),
- camera(geometry, 220),
- );
- const [nearest] = path.points;
- const farthest = path.points[path.points.length - 1];
- expect(nearest.xPercent).toBeCloseTo(50, 1);
- expect(farthest.xPercent).toBeGreaterThan(55);
- expect(path.headRotateDeg).toBeGreaterThan(0);
- });
-
- it("mirrors the bend for a left-hand exit", () => {
- const geometry = road(300, -25, 100);
- const path = projectRoutePath(
- geometry,
- decisionPoint(geometry, 300, -25),
- camera(geometry, 220),
- );
- expect(path.points[path.points.length - 1].xPercent).toBeLessThan(45);
- expect(path.headRotateDeg).toBeLessThan(0);
- });
-
- it("stays inside the frame by ending the path where the route leaves it", () => {
- const geometry = road(300, 60, 120);
- const narrow = projectRoutePath(
- geometry,
- decisionPoint(geometry, 300, 60),
- camera(geometry, 250, { fovDeg: 40 }),
- );
- const wide = projectRoutePath(
- geometry,
- decisionPoint(geometry, 300, 60),
- camera(geometry, 250, { fovDeg: 100 }),
- );
- expect(narrow.points.length).toBeLessThan(wide.points.length);
- for (const projected of narrow.points) {
- expect(projected.xPercent).toBeGreaterThanOrEqual(0);
- expect(projected.xPercent).toBeLessThanOrEqual(100);
- }
- });
-
- it("draws nothing when the photo faces away from the road", () => {
- const geometry = road(400);
- const path = projectRoutePath(
- geometry,
- decisionPoint(geometry, 300, 0),
- camera(geometry, 200, { heading: 150 }),
- );
- expect(path.visible).toBe(false);
- expect(path.points).toEqual([]);
- });
-
- it("draws nothing when the photo was taken well off the route", () => {
- const geometry = road(400);
- const onRoute = camera(geometry, 200);
- const asideLngLat: LngLat = [onRoute.lngLat[0], onRoute.lngLat[1] + 60 / M_PER_DEG_LAT];
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), {
- ...onRoute,
- lngLat: asideLngLat,
- });
- expect(path.visible).toBe(false);
- });
-
- it("draws nothing over a flat photo whose field of view is unknown", () => {
- const geometry = road(400);
- const { fovDeg: _unknown, ...unknownLens } = camera(geometry, 200);
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), unknownLens);
- expect(path.visible).toBe(false);
- expect(path.points).toEqual([]);
- });
-
- it("draws nothing when the photo was taken past the decision point", () => {
- const geometry = road(400);
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), camera(geometry, 340));
- expect(path.visible).toBe(false);
- });
-
- it("maps a panorama across the full 360° frame and reports its crop window", () => {
- const geometry = road(300, 60, 120);
- const point = decisionPoint(geometry, 300, 60);
- // Close to the split, where the ramp sweeps past the edge of a 70° lens.
- const pano = camera(geometry, 280, { isPano: true, fovDeg: 360 });
- const path = projectRoutePath(geometry, point, pano);
- const flat = projectRoutePath(geometry, point, camera(geometry, 280));
- // 360° of frame: the whole bend stays in the image where a 70° lens clips it.
- expect(path.points.length).toBeGreaterThan(flat.points.length);
- expect(path.crop?.spanDeg).toBe(90);
- expect(path.crop?.startDeg).toBeCloseTo(90 - 45, 0);
- // Equirectangular is linear in angle, so the bend stays near the centre of
- // the full-width image: a 60° turn is a sixth of the frame away.
- for (const projected of path.points) {
- expect(projected.xPercent).toBeGreaterThan(45);
- expect(projected.xPercent).toBeLessThan(75);
- }
- });
-
- it("places a panorama's path against the photo's heading, where its frame is centred", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- // Road runs east (90°); the panorama's centre column faces 110°.
- const pano = camera(geometry, 200, { isPano: true, fovDeg: 360, heading: 110 });
- const path = projectRoutePath(geometry, point, pano);
- expect(path.visible).toBe(true);
- for (const projected of path.points) {
- expect(projected.xPercent).toBeCloseTo(50 + (-20 / 360) * 100, 0);
- }
- });
-
- it("draws nothing over a panorama whose heading is unknown", () => {
- const geometry = road(400);
- const { heading: _unknown, ...noHeading } = camera(geometry, 200, {
- isPano: true,
- fovDeg: 360,
- });
- const path = projectRoutePath(geometry, decisionPoint(geometry, 300, 0), noHeading);
- expect(path.visible).toBe(false);
- });
-
- it("keeps a wider lens' path closer to the centre of the frame", () => {
- const geometry = road(300, 25, 100);
- const point = decisionPoint(geometry, 300, 25);
- const narrow = projectRoutePath(geometry, point, camera(geometry, 240, { fovDeg: 60 }));
- const wide = projectRoutePath(geometry, point, camera(geometry, 240, { fovDeg: 90 }));
- const lastX = (path: typeof narrow) => path.points[path.points.length - 1].xPercent;
- expect(lastX(wide)).toBeLessThan(lastX(narrow));
- });
-});
-
-describe("projectRoutePath into the exit lane", () => {
- // Eastbound, so "right of travel" is south.
- const LANE = 3.5;
- const aside = (image: StreetLevelImage, rightMeters: number): StreetLevelImage => ({
- ...image,
- lngLat: [image.lngLat[0], image.lngLat[1] - rightMeters / M_PER_DEG_LAT],
- });
- const threeLanesExitRight = { laneCount: 3, activeLanes: [2] };
- /** The projected sample closest to `meters` ahead of the camera. */
- const sampleAt = (path: ReturnType, meters: number) =>
- path.points.reduce((best, p) =>
- Math.abs(p.distanceMeters - meters) < Math.abs(best.distanceMeters - meters) ? p : best,
- );
-
- it("moves the path over into the exit lane when the photo was shot a lane beside it", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- // Shot in the middle lane (centre of the carriageway); the exit leaves the right lane.
- const photo = camera(geometry, 220);
- const plain = projectRoutePath(geometry, point, photo);
- const laned = projectRoutePath(geometry, point, photo, { exitLanes: threeLanesExitRight });
- // By the split, 80 m ahead, the path has crossed one lane to the right.
- expect(sampleAt(laned, 80).xPercent).toBeGreaterThan(sampleAt(plain, 80).xPercent + 1);
- expect(laned.laneShiftMeters).toBeCloseTo(LANE, 0);
- });
-
- it("keeps the path in its lane when the photo was already shot in the exit lane", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- const photo = aside(camera(geometry, 220), LANE);
- const path = projectRoutePath(geometry, point, photo, { exitLanes: threeLanesExitRight });
- expect(path.laneShiftMeters).toBeCloseTo(0, 0);
- expect(sampleAt(path, 60).xPercent).toBeCloseTo(50, 0);
- });
-
- it("always starts under the camera, whichever lane the exit leaves from", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- for (const offset of [-LANE, 0, LANE]) {
- const path = projectRoutePath(geometry, point, aside(camera(geometry, 220), offset), {
- exitLanes: threeLanesExitRight,
- });
- expect(path.points[0].xPercent).toBeCloseTo(50, 0);
- }
- });
-
- it("leaves the path alone when the lane layout says nothing about the exit", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- const photo = aside(camera(geometry, 220), -LANE);
- const plain = projectRoutePath(geometry, point, photo);
- const empty = projectRoutePath(geometry, point, photo, {
- exitLanes: { laneCount: 3, activeLanes: [] },
- });
- expect(empty.points).toEqual(plain.points);
- expect(empty.laneShiftMeters).toBe(0);
- });
-
- it("does not trust a GPS position beyond the carriageway edge", () => {
- const geometry = road(400);
- const point = decisionPoint(geometry, 300, 0);
- // 12 m right of the centre of a three-lane carriageway is off the road.
- const path = projectRoutePath(geometry, point, aside(camera(geometry, 220), 12), {
- exitLanes: threeLanesExitRight,
- });
- expect(path.visible).toBe(true);
- expect(path.laneShiftMeters).toBeGreaterThanOrEqual(-1.75 - 1e-6);
- });
-});
-
-describe("projectRoutePath on the A57 fixture", () => {
- const route = fixture.route as unknown as Route;
- const point = findJunctionDecisionPoints(route)[0];
- const image: StreetLevelImage = {
- id: "caea047f-f3c0-4819-8354-11805e6597c3",
- providerId: "panoramax",
- lngLat: [6.678013787, 51.1787552],
- heading: 282,
- capturedAt: "2019-09-10T06:24:34.836000+00:00",
- isPano: false,
- fovDeg: 70,
- assets: {},
- };
-
- it("projects the approach onto the real photo, leaning toward the ramp", () => {
- const path = projectRoutePath(route.geometry, point, image);
- expect(path.visible).toBe(true);
- expect(path.points.length).toBeGreaterThan(5);
- const [nearest] = path.points;
- const farthest = path.points[path.points.length - 1];
- expect(nearest.xPercent).toBeCloseTo(50, 0);
- expect(farthest.xPercent).toBeGreaterThan(nearest.xPercent);
- for (const projected of path.points) {
- expect(projected.xPercent).toBeGreaterThanOrEqual(0);
- expect(projected.xPercent).toBeLessThanOrEqual(100);
- expect(projected.yPercent).toBeGreaterThanOrEqual(0);
- expect(projected.yPercent).toBeLessThanOrEqual(100);
- }
- });
-});
diff --git a/packages/core/src/navigation/__tests__/processFix.test.ts b/packages/core/src/navigation/__tests__/processFix.test.ts
index 1c816cafa..ad770f970 100644
--- a/packages/core/src/navigation/__tests__/processFix.test.ts
+++ b/packages/core/src/navigation/__tests__/processFix.test.ts
@@ -151,6 +151,58 @@ describe("processFix speed fallback", () => {
});
describe("processFix", () => {
+ it("keeps maneuver distance and gate timing on the same geometry despite provider distance drift", () => {
+ const physical: Route = {
+ ...route,
+ distance: 2640,
+ duration: 120,
+ geometry: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ steps: [
+ {
+ instruction: "Continue",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ ],
+ },
+ {
+ instruction: "Keep left",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ maneuver: { type: "fork", modifier: "left" },
+ },
+ ],
+ };
+ let state = { ...emptyState };
+ for (const [index, meters] of [300, 500, 700, 900, 970, 1000, 1050].entries()) {
+ const r = processFix(
+ physical,
+ {
+ coords: [(0.00899320363724538 * meters) / 1000, 0],
+ accuracy: 3,
+ speed: 25,
+ heading: 90,
+ timestampMs: index * 1000,
+ },
+ state,
+ opts,
+ );
+ state = r.nextState;
+ expect(r.progress?.currentStepIndex).toBe(meters <= 1000 ? 0 : 1);
+ if (meters < 1000) expect(r.progress?.distanceToNextManeuver).toBeCloseTo(1000 - meters, 1);
+ expect(r.needsReroute).toBe(false);
+ }
+ });
it("rejects fixes worse than the accuracy cap", () => {
const r = processFix(
route,
diff --git a/packages/core/src/navigation/__tests__/progress.test.ts b/packages/core/src/navigation/__tests__/progress.test.ts
index 62516b31a..a8c127e06 100644
--- a/packages/core/src/navigation/__tests__/progress.test.ts
+++ b/packages/core/src/navigation/__tests__/progress.test.ts
@@ -32,6 +32,43 @@ const route = {
} as unknown as Route;
describe("computeProgress", () => {
+ it("uses the physical maneuver boundary despite longer engine step distances", () => {
+ const physical: Route = {
+ ...route,
+ distance: 2640,
+ duration: 120,
+ geometry: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ steps: [
+ {
+ instruction: "a",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ ],
+ },
+ {
+ instruction: "b",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ },
+ ],
+ };
+ const p = computeProgress(physical, 700);
+ expect(p.distanceToNextManeuver).toBeCloseTo(300, 2);
+ expect(p.distanceRemaining).toBeCloseTo(1716, 2);
+ expect(p.durationRemaining).toBeCloseTo(78, 3);
+ expect(computeProgress(physical, 1010).currentStepIndex).toBe(1);
+ });
it("locates the current step from along-distance", () => {
const p = computeProgress(route, 150);
expect(p.currentStepIndex).toBe(1);
diff --git a/packages/core/src/navigation/__tests__/stepGate.test.ts b/packages/core/src/navigation/__tests__/stepGate.test.ts
index d3e60b171..b6c41d278 100644
--- a/packages/core/src/navigation/__tests__/stepGate.test.ts
+++ b/packages/core/src/navigation/__tests__/stepGate.test.ts
@@ -24,6 +24,39 @@ const gate = (along: number, prev: StepGateState) =>
advanceStepGate(route, along, prev, ENTRY, EXIT);
describe("advanceStepGate", () => {
+ it("advances at the geometry boundary, not the engine distance boundary", () => {
+ const physical: Route = {
+ ...route,
+ distance: 2640,
+ geometry: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ steps: [
+ {
+ instruction: "a",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0, 0],
+ [0.00899320363724538, 0],
+ ],
+ },
+ {
+ instruction: "b",
+ distance: 1320,
+ duration: 60,
+ coordinates: [
+ [0.00899320363724538, 0],
+ [0.01798640727449076, 0],
+ ],
+ },
+ ],
+ };
+ expect(advanceStepGate(physical, 990, fresh, ENTRY, EXIT).reachedStepEnd).toBe(true);
+ expect(advanceStepGate(physical, 1006, fresh, ENTRY, EXIT).committedStepIndex).toBe(1);
+ });
it("does not advance before the step's end is approached", () => {
expect(gate(50, fresh)).toEqual({ committedStepIndex: 0, reachedStepEnd: false });
});
diff --git a/packages/core/src/navigation/guidanceIcons.ts b/packages/core/src/navigation/guidanceIcons.ts
index 9ad565ce5..0170f8651 100644
--- a/packages/core/src/navigation/guidanceIcons.ts
+++ b/packages/core/src/navigation/guidanceIcons.ts
@@ -174,11 +174,12 @@ export function presentGuidanceLanes(
lanes: ManeuverLane[] | undefined,
maneuver: LaneManeuver | undefined,
evidence: LaneGuidanceEvidence = {},
+ source: "engine" | "osm" = "engine",
): GuidanceLaneCell[] {
const resolved = resolveGuidanceLanes(lanes, maneuver, evidence);
// Only the engine knows an unmarked lane leads into the maneuver; lanes we
// recommended ourselves stay blank rather than gain a guessed arrow.
- const engineChose = lanes?.some((l) => l.valid) ?? false;
+ const engineChose = source === "engine" && (lanes?.some((l) => l.valid) ?? false);
const unmarkedIcon = engineChose ? unmarkedLaneIconName(maneuver) : null;
return resolved.map((lane) => {
const marked = lane.indications.flatMap((indication) => {
@@ -190,13 +191,13 @@ export function presentGuidanceLanes(
const arrows = fallback ? [{ indication: "none", icon: fallback }] : marked;
const activeNorm = lane.active && !inferred ? normalizeLaneToken(lane.active) : null;
return {
- valid: lane.valid,
+ valid: lane.valid === true,
inferred,
arrows: arrows.map(({ indication, icon }) => ({
indication,
icon,
active:
- lane.valid &&
+ lane.valid === true &&
(activeNorm ? normalizeLaneToken(indication) === activeNorm : arrows.length === 1),
stemShift: arrows.length > 1 ? laneArrowStemShift(icon) : 0,
})),
diff --git a/packages/core/src/navigation/index.ts b/packages/core/src/navigation/index.ts
index a7c381e8b..38a0f7ae3 100644
--- a/packages/core/src/navigation/index.ts
+++ b/packages/core/src/navigation/index.ts
@@ -91,19 +91,18 @@ export {
sameJunction,
} from "./junctionDetect";
export { junctionLookupPoints } from "./junctionLookup";
-export { type SelectJunctionPhotoOptions, selectJunctionPhoto } from "./junctionPhoto";
-export { buildJunctionSchematic } from "./junctionSchematic";
export {
guidanceApproachMeters,
- junctionPhotoApproachMeters,
+ type ResolvedLaneGuidance,
resolveGuidanceLanes,
+ resolveLaneGuidance,
resolveRecommendedLanes,
shouldPreviewNextStep,
} from "./lanes";
export {
+ type ApproachLaneGuidance,
laneDestinationsAt,
- mergeExitPanel,
- parseLaneTags,
+ resolveApproachLaneGuidance,
selectApproachWay,
selectRampWay,
} from "./laneTags";
@@ -114,6 +113,11 @@ export {
maneuverArrowSpans,
maneuverArrowTipBearing,
} from "./maneuverArrow";
+export {
+ type ManeuverPositions,
+ maneuverStartMeters,
+ prepareManeuverPositions,
+} from "./maneuverPosition";
export {
type CarDestination,
type CarDestinations,
@@ -177,15 +181,6 @@ export {
parseNavigationSessionSnapshot,
} from "./offlineSession";
export { navOptionsForMode } from "./options";
-export {
- type ApproachCamera,
- approachCamera,
- type PhotoCropWindow,
- type PhotoPathPoint,
- type PhotoProjectionOptions,
- type PhotoRoutePath,
- projectRoutePath,
-} from "./photoProjection";
export { processFix } from "./processFix";
export {
DEFAULT_TRANSIT_TICK_OPTIONS,
@@ -277,7 +272,13 @@ export {
routeAheadBounds,
routeSearchChunks,
} from "./searchAlongRoute";
-export { refKind, signHeadline, visibleToward } from "./signFormat";
+export {
+ cleanSignTexts,
+ exitNumberLabel,
+ refKind,
+ signHeadline,
+ visibleToward,
+} from "./signFormat";
export { simulatePositions } from "./simulatePositions";
export { snapToRoute } from "./snap";
export { isOverSpeed, OVER_SPEED_TOLERANCE_KMH } from "./speedAlert";
diff --git a/packages/core/src/navigation/junctionDetect.ts b/packages/core/src/navigation/junctionDetect.ts
index 073b5a6f1..7dd2554cf 100644
--- a/packages/core/src/navigation/junctionDetect.ts
+++ b/packages/core/src/navigation/junctionDetect.ts
@@ -3,8 +3,8 @@ import type { Route } from "../types/routing";
import { haversineDistance } from "../utils/coordinates";
import { angularDifference } from "./bearing";
import { cumulativeDistances, positionAt } from "./deadReckon";
-import { resolveRecommendedLanes } from "./lanes";
-import { stepStartMeters } from "./progress";
+import { resolveLaneGuidance } from "./lanes";
+import { prepareManeuverPositions } from "./maneuverPosition";
/**
* Route-derived motorway decision points. Everything here reads `RouteStep`
@@ -25,13 +25,19 @@ const DIVERGENCE_OFFSET_METERS = 50;
* Find every motorway decision point (exit, fork, split) on a ground route.
* Roundabouts, merges, plain turns and arrivals are never decision points; a
* `keep` on a motorway counts as a fork even where the split is only a lane
- * drop — the panel's sign/gantry gating hides those without anything to draw.
+ * drop. Per-lane recommendations still require independent route evidence.
*/
export function findJunctionDecisionPoints(route: Route): JunctionDecisionPoint[] {
if (route.mode !== "driving" && route.mode !== "motorcycle") return [];
const geometry = route.geometry;
if (geometry.length < 2) return [];
+ if (!route.steps.some((step) => DECISION_TYPES.has(step.maneuver?.type ?? ""))) return [];
const cum = cumulativeDistances(geometry);
+ const starts = prepareManeuverPositions(route).starts;
+ return decisionPoints(route, cum, starts);
+}
+
+function decisionPoints(route: Route, cum: number[], starts: number[]): JunctionDecisionPoint[] {
const points: JunctionDecisionPoint[] = [];
for (let i = 1; i < route.steps.length; i += 1) {
const maneuverType = route.steps[i].maneuver?.type ?? "";
@@ -45,7 +51,7 @@ export function findJunctionDecisionPoints(route: Route): JunctionDecisionPoint[
const fromMotorway =
route.steps[i - 1]?.motorway === true || points.at(-1)?.stepIndex === i - 1;
if (!fromMotorway) continue;
- points.push(buildDecisionPoint(route, cum, i));
+ points.push(buildDecisionPoint(route, cum, i, starts[i]));
}
return points;
}
@@ -63,11 +69,13 @@ const CANDIDATE_TYPES = new Set(["fork", "off ramp"]);
export function findJunctionCandidates(route: Route): JunctionDecisionPoint[] {
if (route.mode !== "driving" && route.mode !== "motorcycle") return [];
if (route.geometry.length < 2) return [];
- const accepted = new Set(findJunctionDecisionPoints(route).map((point) => point.stepIndex));
+ if (!route.steps.some((step) => CANDIDATE_TYPES.has(step.maneuver?.type ?? ""))) return [];
const cum = cumulativeDistances(route.geometry);
+ const starts = prepareManeuverPositions(route).starts;
+ const accepted = new Set(decisionPoints(route, cum, starts).map((point) => point.stepIndex));
return route.steps.flatMap((step, i) =>
i >= 1 && CANDIDATE_TYPES.has(step.maneuver?.type ?? "") && !accepted.has(i)
- ? [buildDecisionPoint(route, cum, i)]
+ ? [buildDecisionPoint(route, cum, i, starts[i])]
: [],
);
}
@@ -91,7 +99,12 @@ export function sameJunction(a: JunctionDecisionPoint, b: JunctionDecisionPoint)
}
/** The decision point at step `i`: position, approach bearing, split angle and lanes. */
-function buildDecisionPoint(route: Route, cum: number[], i: number): JunctionDecisionPoint {
+function buildDecisionPoint(
+ route: Route,
+ cum: number[],
+ i: number,
+ alongMeters: number,
+): JunctionDecisionPoint {
const geometry = route.geometry;
const step = route.steps[i];
const maneuverType = step.maneuver?.type ?? "";
@@ -100,8 +113,7 @@ function buildDecisionPoint(route: Route, cum: number[], i: number): JunctionDec
maneuverType === "fork" || maneuverType === "off ramp" || hasExitNumber ? "exit" : "fork";
const modifier = step.maneuver?.modifier ?? "";
const side: "left" | "right" = modifier.includes("left") ? "left" : "right";
- const alongMeters = stepStartMeters(route.steps, i);
- const point = positionAt(geometry, cum, alongMeters).point;
+ const point = step.coordinates[0] ?? positionAt(geometry, cum, alongMeters).point;
const approachBearing = positionAt(
geometry,
cum,
@@ -116,6 +128,8 @@ function buildDecisionPoint(route: Route, cum: number[], i: number): JunctionDec
positionAt(geometry, cum, alongMeters + DIVERGENCE_OFFSET_METERS).bearing -
positionAt(geometry, cum, Math.max(alongMeters - DIVERGENCE_OFFSET_METERS, 0)).bearing,
);
+ const laneGuidance = resolveLaneGuidance(step.lanes);
+ const after = Math.max(0, (prepareManeuverPositions(route).ends[i] ?? alongMeters) - alongMeters);
return {
stepIndex: i,
kind,
@@ -126,6 +140,13 @@ function buildDecisionPoint(route: Route, cum: number[], i: number): JunctionDec
divergenceDeg,
...(step.lanes?.length ? { laneCount: step.lanes.length } : {}),
activeLanes: activeLaneIndices(step),
+ laneGuidance,
+ approachTrace: [80, 30, 5].map(
+ (offset) => positionAt(geometry, cum, Math.max(0, alongMeters - offset)).point,
+ ),
+ departureTrace: [15, 40, 80]
+ .filter((offset) => offset <= after)
+ .map((offset) => positionAt(geometry, cum, alongMeters + offset).point),
...(step.sign ? { sign: step.sign } : {}),
};
}
@@ -136,8 +157,8 @@ function normalizeSigned(delta: number): number {
return wrapped === -180 ? 180 : wrapped;
}
-/** Indices of the lanes the engine (or the maneuver fallback) marks valid. */
+/** Preferred lanes when reported, otherwise the engine's initially usable lanes. */
function activeLaneIndices(step: Route["steps"][number]): number[] {
- const resolved = resolveRecommendedLanes(step.lanes, step.maneuver);
- return resolved.flatMap((lane, i) => (lane.valid ? [i] : []));
+ const resolved = resolveLaneGuidance(step.lanes);
+ return resolved.preferredLanes.length > 0 ? resolved.preferredLanes : resolved.usableLanes;
}
diff --git a/packages/core/src/navigation/junctionPhoto.ts b/packages/core/src/navigation/junctionPhoto.ts
deleted file mode 100644
index 0e1bac2c6..000000000
--- a/packages/core/src/navigation/junctionPhoto.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import type { LngLat } from "../types/geometry";
-import type { JunctionDecisionPoint } from "../types/junction";
-import type { StreetLevelImage } from "../types/streetLevel";
-import { haversineDistance } from "../utils/coordinates";
-import { angularDifference, bearingBetween } from "./bearing";
-import { cumulativeDistances } from "./deadReckon";
-import { approachCamera } from "./photoProjection";
-
-/**
- * Photo selection for the junction photo preview. Pure arithmetic over
- * already-fetched imagery — nothing here runs per fix. The route path drawn
- * on the chosen photo is projected in `photoProjection.ts`.
- */
-
-const DEFAULT_MAX_AGE_YEARS = 8;
-/** Heading window around the approach bearing, degrees. */
-const HEADING_TOLERANCE_DEG = 30;
-/**
- * Distance window between the camera and the decision point, metres. Frames
- * further out show plain motorway before the exit lane opens; around 80 m the
- * exit lane and the overhead boards are both in view.
- */
-const MIN_DISTANCE_METERS = 30;
-const MAX_DISTANCE_METERS = 200;
-const TARGET_DISTANCE_METERS = 80;
-/** Upstream-position tolerance: the bearing from the image to the point. */
-const UPSTREAM_TOLERANCE_DEG = 45;
-
-/** Photo-selection options; the age limit guards against stale imagery. */
-export interface SelectJunctionPhotoOptions {
- maxAgeYears?: number;
- /**
- * The route being driven. With it, each photo's heading is judged against
- * the road where the photo was taken rather than against the approach
- * bearing read 100 m before the split — the two differ on a curve — and a
- * photo off the route is rejected outright.
- */
- geometry?: LngLat[];
- /**
- * Metres before the split from which the exit lanes already exist (from
- * OSM). A frame inside that stretch shows every lane the driver picks from;
- * one further back catches the lane still opening, or not there yet.
- */
- fullLanesFromMeters?: number;
-}
-
-/**
- * Pick the photo for a decision point: recent enough, heading within ±30° of
- * the approach, 30–200 m upstream of it. The newest capture month wins; within
- * a month (one drive captures a frame every few metres) a frame where the exit
- * lanes already exist beats one before, and then the frame closest to 80 m
- * wins — or closest to where those lanes begin, when that is nearer the split.
- * `null` when nothing qualifies — the schematic shows instead.
- */
-export function selectJunctionPhoto(
- images: StreetLevelImage[],
- point: JunctionDecisionPoint,
- now: Date,
- opts: SelectJunctionPhotoOptions = {},
-): StreetLevelImage | null {
- const maxAgeMs = (opts.maxAgeYears ?? DEFAULT_MAX_AGE_YEARS) * 365.25 * 24 * 3600 * 1000;
- const geometry = opts.geometry && opts.geometry.length >= 2 ? opts.geometry : undefined;
- const cum = geometry ? cumulativeDistances(geometry) : undefined;
- const fullLanesFrom = opts.fullLanesFromMeters;
- const target =
- fullLanesFrom === undefined
- ? TARGET_DISTANCE_METERS
- : Math.min(TARGET_DISTANCE_METERS, fullLanesFrom);
- const candidates = images.flatMap((image) => {
- if (image.heading === undefined) return [];
- const camera = geometry && cum ? approachCamera(geometry, cum, point, image.lngLat) : undefined;
- // Off the route (another carriageway, a service road, or past the split):
- // nothing it shows describes this approach.
- if (camera && !camera.onApproach) return [];
- const roadBearing = camera?.bearing ?? point.approachBearing;
- if (angularDifference(image.heading, roadBearing) > HEADING_TOLERANCE_DEG) {
- return [];
- }
- const distance = haversineDistance(image.lngLat, point.point);
- if (distance < MIN_DISTANCE_METERS || distance > MAX_DISTANCE_METERS) return [];
- // Without the route, a frame past the exit that happens to face along the
- // road has to be caught by the bearing from the image to the point.
- const towardPoint = bearingBetween(image.lngLat, point.point);
- if (!camera && angularDifference(towardPoint, point.approachBearing) > UPSTREAM_TOLERANCE_DEG) {
- return [];
- }
- if (!image.capturedAt) return [];
- const capturedAt = new Date(image.capturedAt).getTime();
- if (!Number.isFinite(capturedAt) || now.getTime() - capturedAt > maxAgeMs) return [];
- const captured = new Date(capturedAt);
- return [
- {
- image,
- month: captured.getUTCFullYear() * 12 + captured.getUTCMonth(),
- beforeLanes: fullLanesFrom !== undefined && distance > fullLanesFrom ? 1 : 0,
- distanceOffset: Math.abs(distance - target),
- },
- ];
- });
- if (candidates.length === 0) return null;
- candidates.sort(
- (a, b) =>
- b.month - a.month || a.beforeLanes - b.beforeLanes || a.distanceOffset - b.distanceOffset,
- );
- return candidates[0].image;
-}
diff --git a/packages/core/src/navigation/junctionSchematic.ts b/packages/core/src/navigation/junctionSchematic.ts
deleted file mode 100644
index 0ec6d9366..000000000
--- a/packages/core/src/navigation/junctionSchematic.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { GantryModel, JunctionDecisionPoint, JunctionSchematic } from "../types/junction";
-
-/**
- * Deterministic schematic geometry for one decision point, in a 320×140
- * viewBox. The carriageway is a perspective trapezoid (wide at the bottom,
- * narrow at the top), the lanes are bands across it, and the ramp peels off
- * the exit side with an angle proportional to the divergence. Pure numbers and
- * path strings — React only draws them.
- */
-
-const WIDTH = 320;
-const HEIGHT = 140;
-const BOTTOM_WIDTH = 280;
-const TOP_WIDTH = 120;
-const RAMP_LEN = 46;
-
-/** The ramp angle, clamped so a 3° drift and a 90° split both read sensibly. */
-function rampAngle(divergenceDeg: number): number {
- return Math.min(Math.max(Math.abs(divergenceDeg), 8), 45);
-}
-
-/**
- * Build the schematic for one decision point. The lanes run left to right in
- * driving direction; the ramp leaves from the outermost lane on `point.side`.
- */
-export function buildJunctionSchematic(
- model: GantryModel,
- point: JunctionDecisionPoint,
-): JunctionSchematic {
- const laneCount = Math.max(1, model.laneCount);
- const left = (WIDTH - BOTTOM_WIDTH) / 2;
- const right = left + BOTTOM_WIDTH;
- const topInset = (BOTTOM_WIDTH - TOP_WIDTH) / 2;
- const top = HEIGHT * 0.42;
-
- const throughPath = `M ${left} ${HEIGHT} L ${left + topInset} ${top} L ${right - topInset} ${top} L ${right} ${HEIGHT} Z`;
-
- const lanePolygons: string[] = [];
- for (let i = 0; i < laneCount; i += 1) {
- const x0b = left + (BOTTOM_WIDTH / laneCount) * i;
- const x1b = left + (BOTTOM_WIDTH / laneCount) * (i + 1);
- const x0t = left + topInset + (TOP_WIDTH / laneCount) * i;
- const x1t = left + topInset + (TOP_WIDTH / laneCount) * (i + 1);
- lanePolygons.push(
- `M ${x0b.toFixed(1)} ${HEIGHT} L ${x0t.toFixed(1)} ${top} L ${x1t.toFixed(1)} ${top} L ${x1b.toFixed(1)} ${HEIGHT} Z`,
- );
- }
-
- // The ramp is the outermost lane on the exit side continuing past the top
- // edge of the carriageway and bending away by the divergence angle, so the
- // highlighted lane and the ramp read as one band leaving the road.
- const mirror = point.side === "left" ? -1 : 1;
- const exitLane = point.side === "left" ? 0 : laneCount - 1;
- const laneBottom = (i: number) => left + (BOTTOM_WIDTH / laneCount) * i;
- const laneTop = (i: number) => left + topInset + (TOP_WIDTH / laneCount) * i;
- const angle = (rampAngle(point.divergenceDeg) * Math.PI) / 180;
- const peelX = mirror * Math.sin(angle) * RAMP_LEN;
- const peelY = Math.cos(angle) * RAMP_LEN * 0.6;
- const rampPath = [
- `M ${laneBottom(exitLane).toFixed(1)} ${HEIGHT}`,
- `L ${laneBottom(exitLane + 1).toFixed(1)} ${HEIGHT}`,
- `L ${(laneTop(exitLane + 1) + peelX).toFixed(1)} ${(top - peelY).toFixed(1)}`,
- `L ${(laneTop(exitLane) + peelX).toFixed(1)} ${(top - peelY).toFixed(1)}`,
- "Z",
- ].join(" ");
-
- const panelAnchors = model.panels.map((panel) => {
- const first = panel.lanes[0] ?? 0;
- const last = panel.lanes.at(-1) ?? first;
- const topStart = left + topInset + (TOP_WIDTH / laneCount) * first;
- const topEnd = left + topInset + (TOP_WIDTH / laneCount) * (last + 1);
- return { x: Number(((topStart + topEnd) / 2).toFixed(2)), y: top - 6 };
- });
-
- return {
- width: WIDTH,
- height: HEIGHT,
- laneCount,
- // The model's lanes, not the engine's: the bands drawn are the model's, and
- // where its lane count differs from the engine's, or the engine sent no
- // lanes, the model has already worked out which of them the exit leaves from.
- activeLanes: model.activeLanes,
- side: point.side,
- divergenceDeg: point.divergenceDeg,
- throughPath,
- rampPath,
- lanePolygons,
- panelAnchors,
- };
-}
diff --git a/packages/core/src/navigation/laneTags.ts b/packages/core/src/navigation/laneTags.ts
index b4186f79f..0b519d35f 100644
--- a/packages/core/src/navigation/laneTags.ts
+++ b/packages/core/src/navigation/laneTags.ts
@@ -1,6 +1,4 @@
import type {
- GantryModel,
- GantryPanel,
JunctionDecisionPoint,
JunctionLookupResult,
JunctionWay,
@@ -8,245 +6,218 @@ import type {
OsmLaneTags,
} from "../types/junction";
import { angularDifference } from "./bearing";
+import { type ResolvedLaneGuidance, resolveLaneGuidance } from "./lanes";
+import { prepareRouteMatcher, snapPreparedRoute } from "./routeMatcher";
+import { cleanSignTexts, refKind, signHeadline } from "./signFormat";
-/**
- * OSM per-lane destination tags → the gantry drawn in the junction panel.
- * OSM `:lanes` values run left to right in way direction — the same order as
- * the engines' lane arrays, so lane index 0 is the leftmost lane everywhere.
- */
+export interface ApproachLaneGuidance extends ResolvedLaneGuidance {
+ source: "engine" | "osm";
+}
-/** Split a `|`-delimited `:lanes` value into per-lane strings, trimmed. */
function splitLaneValues(raw: string | undefined): string[] | undefined {
- if (raw === undefined) return undefined;
- return raw.split("|").map((v) => v.trim());
+ return raw?.split("|").map((value) => value.trim());
}
-
-/**
- * A per-lane value list with exactly `laneCount` entries, or `undefined` when
- * the tag disagrees with the lane count. A trailing `|` (an untagged last
- * lane) splits into an empty entry and still counts, so `a|b|` fits three
- * lanes; a genuinely shorter or longer list is not guessed at.
- */
-function laneValues(values: string[] | undefined, laneCount: number): string[] | undefined {
- if (values === undefined) return undefined;
- return values.length === laneCount ? values : undefined;
+function laneValues(values: string[] | undefined, count: number): string[] | undefined {
+ return values?.length === count ? values : undefined;
}
-
-/** Split one lane's value into its semicolon-separated parts, trimmed, empties dropped. */
function splitParts(value: string): string[] {
- return value
- .split(";")
- .map((part) => part.trim())
- .filter(Boolean);
+ return cleanSignTexts(value.split(";"));
}
-
-/**
- * Separate road refs from place names found in a `destination:ref` value. A
- * road ref always carries a number ("A 46", "L 381", "M25"); a town never
- * does, so a digit-free value is a destination tagged into the wrong key.
- */
-function splitRefParts(parts: string[]): { refs: string[]; places: string[] } {
- const hasNumber = (part: string) => /\d/.test(part);
- return { refs: parts.filter(hasNumber), places: parts.filter((part) => !hasNumber(part)) };
+function splitRefParts(values: string[]): { refs: string[]; places: string[] } {
+ return {
+ refs: values.filter((value) => refKind(value) !== "other"),
+ places: values.filter((value) => refKind(value) === "other"),
+ };
}
-
-/** A uniform turn token across the panel's lanes, when every tagged lane agrees. */
-function uniformTurn(turns: string[]): string | undefined {
- if (turns.length === 0) return undefined;
- const first = turns[0];
- if (!first || first === "none") return undefined;
- return turns.every((t) => t === first) ? first : undefined;
+function union(first: string[], rest: string[]): string[] {
+ return cleanSignTexts([...first, ...rest]);
}
-/** Attach one lane's tags to the panel it belongs to, merging equal neighbours. */
-function groupAdjacent(
- panels: LaneGroup[],
- lane: number,
- refs: string[],
- destinations: string[],
- symbol: string,
- turn: string,
- colour: string,
-): void {
- const previous = panels.at(-1);
+/** Lane evidence for the green maneuver arrows, scoped to the incoming route occurrence. */
+export function resolveApproachLaneGuidance(
+ result: JunctionLookupResult,
+ point: JunctionDecisionPoint,
+): ApproachLaneGuidance | undefined {
+ const approach =
+ result.outgoingApproachWayId !== undefined
+ ? result.approach.find((way) => way.wayId === result.outgoingApproachWayId)
+ : selectApproachWay([...result.approach, ...result.ramps], point);
+ const count = approach?.tags.lanes ?? point.laneCount;
+ const engine = point.laneGuidance;
+ const engineResult = engine
+ ? {
+ ...engine,
+ ...(engine.state === "conflict"
+ ? {
+ lanes: engine.lanes.map((lane) => ({
+ ...lane,
+ valid: undefined,
+ preferred: undefined,
+ active: undefined,
+ })),
+ usableLanes: [],
+ preferredLanes: [],
+ }
+ : {}),
+ source: "engine" as const,
+ }
+ : undefined;
if (
- previous &&
- joinKeys(previous.refs) === joinKeys(refs) &&
- joinKeys(previous.destinations) === joinKeys(destinations) &&
- previous.symbol === symbol &&
- previous.colour === colour
- ) {
- previous.lanes.push(lane);
- previous.turns.push(turn);
- return;
- }
- panels.push({
- lanes: [lane],
- destinations,
- refs,
- symbol,
- turns: [turn],
- colour,
- });
-}
-
-/** Per-lane values collected before the adjacent-equal grouping pass. */
-interface LaneGroup {
- lanes: number[];
- refs: string[];
- destinations: string[];
- symbol: string;
- turns: string[];
- colour: string;
-}
-
-function joinKeys(values: string[]): string {
- return values.join(";");
-}
-
-/**
- * Build a gantry model from one way's OSM tags. `null` when the tags carry no
- * per-lane destinations at all, or when a `:lanes` value disagrees with the
- * lane count and the fallback — a half-parsed gantry would lie about where
- * the lanes point.
- */
-export function parseLaneTags(tags: OsmLaneTags, fallbackLaneCount?: number): GantryModel | null {
- const laneCount = tags.lanes ?? fallbackLaneCount;
- if (!laneCount || laneCount < 1) return null;
- const destinationLanes = laneValues(splitLaneValues(tags.destinationLanes), laneCount);
- const destinationRefLanes = laneValues(splitLaneValues(tags.destinationRefLanes), laneCount);
- if (destinationLanes === undefined && destinationRefLanes === undefined) return null;
+ !approach ||
+ !count ||
+ !Number.isInteger(count) ||
+ count < 1 ||
+ (point.approachTrace && !incomingLayoutMatches(point, approach, count))
+ )
+ return engineResult;
+ const destinations = laneDestinationsAt(
+ { approach: [approach], ramps: [] },
+ { ...point, laneCount: count },
+ );
+ const turnValues = laneValues(splitLaneValues(approach.tags.turnLanes), count);
+ const unknownLanes = Array.from({ length: count }, (_, i) => ({
+ indications: (turnValues?.[i] ?? "").split(";").filter(Boolean),
+ }));
+ const ways = result.outgoing ?? [];
+ const routeWay = ways.length >= 2 ? selectOutgoingBySign(ways, point) : undefined;
+ const ramp = ways.length >= 2 ? routeWay : selectRampWay(result.ramps, point)[0];
+ const labels =
+ ramp && labelsAgree(ramp.tags, point)
+ ? branchLabels(ramp)
+ : { refs: point.sign?.exitBranches ?? [], destinations: signHeadline(point.sign) };
+ const key = (value: string) => value.normalize("NFC").toLowerCase().replace(/\s/g, "");
+ const wantedRefs = labels.refs.map(key);
+ const wantedPlaces = labels.destinations.map(key);
+ let selected =
+ ways.length >= 2 && !routeWay
+ ? []
+ : (destinations ?? []).flatMap((lane, i) => {
+ const refsMatch =
+ !wantedRefs.length || lane.refs.some((ref) => wantedRefs.includes(key(ref)));
+ const placesMatch =
+ !wantedPlaces.length ||
+ (wantedRefs.length > 0 && lane.destinations.length === 0) ||
+ lane.destinations.some((place) => wantedPlaces.includes(key(place)));
+ return refsMatch && placesMatch && (wantedRefs.length > 0 || wantedPlaces.length > 0)
+ ? [i]
+ : [];
+ });
+ // An outgoing subset cannot be inferred from a shared reference on every incoming lane.
if (
- (tags.destinationLanes !== undefined && destinationLanes === undefined) ||
- (tags.destinationRefLanes !== undefined && destinationRefLanes === undefined)
- ) {
- return null;
- }
- const turnLanes = laneValues(splitLaneValues(tags.turnLanes), laneCount);
- const symbolLanes = laneValues(splitLaneValues(tags.destinationSymbolLanes), laneCount);
- const colourLanes = laneValues(splitLaneValues(tags.destinationColourLanes), laneCount);
-
- const panels: GantryPanel[] = [];
- const groups: LaneGroup[] = [];
- for (let lane = 0; lane < laneCount; lane += 1) {
- const tagged = splitRefParts(splitParts(destinationRefLanes?.[lane] ?? ""));
- const destinations = union(splitParts(destinationLanes?.[lane] ?? ""), tagged.places);
- const refs = tagged.refs;
- if (destinations.length === 0 && refs.length === 0) continue;
- groupAdjacent(
- groups,
- lane,
- refs,
- destinations,
- symbolLanes?.[lane] ?? "",
- turnLanes?.[lane] ?? "",
- colourLanes?.[lane] ?? "",
- );
+ selected.length === count &&
+ (ways.length >= 2 || (ramp?.tags.lanes !== undefined && ramp.tags.lanes < count))
+ )
+ selected = [];
+ if (engine?.state === "conflict") return engineResult;
+ if (point.laneCount === count && engine && engine.state !== "unknown") {
+ const conflict =
+ engine.state === "known" &&
+ selected.length > 0 &&
+ (engine.preferredLanes.some((i) => !selected.includes(i)) ||
+ selected.some((i) => engine.lanes[i]?.valid === false)) &&
+ engine.usableLanes.length > 0;
+ return conflict
+ ? { ...resolveLaneGuidance(unknownLanes), state: "conflict", source: "osm" }
+ : engineResult;
}
- for (const group of groups) {
- const turn = uniformTurn(group.turns);
- panels.push({
- lanes: group.lanes,
- destinations: group.destinations,
- refs: group.refs,
- symbols: group.symbol ? [group.symbol] : [],
- ...(group.colour ? { colour: group.colour } : {}),
- ...(turn ? { turn } : {}),
- isExit: false,
- });
- }
- if (panels.length === 0) return null;
+ if (!selected.length) return { ...resolveLaneGuidance(unknownLanes), source: "osm" };
return {
- laneCount,
- panels,
- activeLanes: [],
- ...(turnLanes ? { laneTurns: turnLanes } : {}),
+ ...resolveLaneGuidance(
+ unknownLanes.map((lane, i) => ({
+ ...lane,
+ valid: selected.includes(i),
+ preferred: selected.includes(i),
+ })),
+ ),
source: "osm",
};
}
-/**
- * Append (or mark) the exit panel: destination text from the ramp's
- * `destination`/`destination:ref`, else the engine sign; exit number from the
- * engine sign, else the ramp's `junction:ref`. When the gantry way's lane
- * count disagrees with the decision point's (a lane gain before or after the
- * way was tagged), the exit panel spans the single outermost lane on the
- * point's side, so the highlight never lands on a lane the drawn gantry
- * does not have.
- */
-export function mergeExitPanel(
- model: GantryModel,
- point: JunctionDecisionPoint,
- rampTags?: OsmLaneTags,
-): GantryModel {
- const exitNumber = point.sign?.exitNumbers?.[0] ?? rampTags?.junctionRef;
- const rampRefs = rampTags?.destinationRef
- ? splitRefParts(splitParts(rampTags.destinationRef))
- : undefined;
- const destinations = union(
- rampTags?.destination ? splitParts(rampTags.destination) : (point.sign?.exitToward ?? []),
- rampRefs?.places ?? [],
+function labelsAgree(tags: OsmLaneTags, point: JunctionDecisionPoint): boolean {
+ const key = (value: string) => value.toLowerCase().replace(/\s/g, "");
+ const refs = point.sign?.exitBranches?.map(key) ?? [];
+ const places = point.sign?.exitToward?.map(key) ?? [];
+ const labels = branchLabels({ tags });
+ return (
+ (!refs.length || !labels.refs.length || labels.refs.some((ref) => refs.includes(key(ref)))) &&
+ (!places.length ||
+ !labels.destinations.length ||
+ labels.destinations.some((place) => places.includes(key(place))))
);
- const refs = rampRefs ? rampRefs.refs : (point.sign?.exitBranches ?? []);
- // The engine's own lanes win whenever it sent them for this gantry. Without
- // them (a routing backend that omits `turn_lanes`), OSM's per-lane turn
- // arrows name the exit lanes; only if neither speaks does the outermost lane
- // on the exit's side stand in.
- const engineLanes =
- model.laneCount === point.laneCount && point.activeLanes.length > 0 ? point.activeLanes : null;
- const taggedLanes = lanesTurningToward(model.laneTurns, point.side);
- const panelLanes =
- engineLanes ??
- (taggedLanes.length > 0 ? taggedLanes : [point.side === "left" ? 0 : model.laneCount - 1]);
- const carried = model.panels.filter((panel) => !panel.isExit);
- // A gantry often already boards the exit lane on its own ("A 46 /
- // Neuss-Zentrum"). Marking that board as the exit keeps one board lit per
- // decision instead of two over the same lane.
- const existing = carried.findIndex((panel) => sameLanes(panel.lanes, panelLanes));
- const exitPanel: GantryPanel =
- existing >= 0
- ? {
- ...carried[existing],
- destinations: union(destinations, carried[existing].destinations),
- refs: union(refs, carried[existing].refs),
- isExit: true,
- ...(exitNumber ? { exitNumber } : {}),
- }
- : {
- lanes: panelLanes,
- destinations,
- refs,
- symbols: [],
- isExit: true,
- ...(exitNumber ? { exitNumber } : {}),
- };
- const panels =
- existing >= 0
- ? carried.map((panel, index) => (index === existing ? exitPanel : panel))
- : [...carried, exitPanel];
- return { ...model, panels, activeLanes: [...panelLanes] };
}
-function sameLanes(a: number[], b: number[]): boolean {
- return a.length === b.length && a.every((lane, index) => lane === b[index]);
+function incomingLayoutMatches(
+ point: JunctionDecisionPoint,
+ approach: JunctionWay | undefined,
+ count: number,
+): boolean {
+ if (
+ !approach?.geometry ||
+ approach.geometry.length < 2 ||
+ approach.tags.lanes !== count ||
+ !point.approachTrace ||
+ point.approachTrace.length < 2
+ )
+ return false;
+ const matcher = prepareRouteMatcher(approach.geometry);
+ const snaps = point.approachTrace
+ .slice(-2)
+ .map((coordinate) => snapPreparedRoute(matcher, coordinate));
+ return snaps.every(
+ (snap, i) =>
+ snap.deviationMeters <= 8 && (i === 0 || snap.alongMeters > snaps[i - 1].alongMeters + 1),
+ );
}
-/** `first` then whatever `rest` adds, each value once. */
-function union(first: string[], rest: string[]): string[] {
- return [...new Set([...first, ...rest])];
+function branchLabels(way: Pick): { refs: string[]; destinations: string[] } {
+ const refs = splitRefParts(splitParts(way.tags.destinationRef ?? ""));
+ return {
+ refs: refs.refs,
+ destinations: union(splitParts(way.tags.destination ?? ""), refs.places),
+ };
}
-/**
- * Lanes whose OSM turn arrow leaves the carriageway on the exit's side.
- * `merge_to_*` is left out: that marks a lane drop, not a turn-off.
- */
-function lanesTurningToward(laneTurns: string[] | undefined, side: "left" | "right"): number[] {
- if (!laneTurns) return [];
- const leaving =
- side === "left"
- ? ["left", "slight_left", "sharp_left"]
- : ["right", "slight_right", "sharp_right"];
- return laneTurns.flatMap((token, lane) => (leaving.includes(token.trim()) ? [lane] : []));
+function selectOutgoingBySign(
+ ways: JunctionWay[],
+ point: JunctionDecisionPoint,
+): JunctionWay | undefined {
+ const normalize = (value: string) => value.toLocaleLowerCase().replace(/\s+/g, "");
+ const toward = signHeadline(point.sign).map(normalize);
+ const refs = (point.sign?.exitBranches ?? []).map(normalize);
+ const labeled = ways.map((way) => ({ way, ...branchLabels(way) }));
+ if ((point.departureTrace?.length ?? 0) >= 2 && ways.some((way) => way.geometry)) {
+ const matched = labeled.filter(({ way, refs: labels }) => {
+ if (!way.geometry || way.geometry.length < 2) return false;
+ if (refs.length && labels.length && !labels.some((ref) => refs.includes(normalize(ref))))
+ return false;
+ const matcher = prepareRouteMatcher(way.geometry);
+ const snaps = point.departureTrace!.map((coordinate) =>
+ snapPreparedRoute(matcher, coordinate),
+ );
+ return snaps.every(
+ (snap, i) =>
+ snap.deviationMeters <= 10 &&
+ (i === 0 || snap.alongMeters > snaps[i - 1].alongMeters + 1),
+ );
+ });
+ return matched.length === 1 ? matched[0].way : undefined;
+ }
+ const destinations = labeled.filter(
+ (entry) =>
+ entry.destinations.some((place) => toward.includes(normalize(place))) &&
+ (refs.length === 0 ||
+ entry.refs.length === 0 ||
+ entry.refs.some((ref) => refs.includes(normalize(ref)))),
+ );
+ if (destinations.length > 0) return destinations.length === 1 ? destinations[0].way : undefined;
+ // A reference alone may identify a destination-less road, but cannot override
+ // an explicit, different destination or resolve two same-ref roads.
+ const references = labeled.filter(
+ (entry) =>
+ (toward.length === 0 || entry.destinations.length === 0) &&
+ entry.refs.some((ref) => refs.includes(normalize(ref))),
+ );
+ return references.length === 1 ? references[0].way : undefined;
}
/** Keep approach ways heading the same way as the route, upstream of the point. */
@@ -258,10 +229,11 @@ export function selectApproachWay(
(candidate) =>
angularDifference(candidate.bearing, point.approachBearing) <= 35 &&
candidate.endDistanceMeters >= 0 &&
- candidate.endDistanceMeters <= 450,
+ candidate.endDistanceMeters <= 450 &&
+ (!point.approachTrace || incomingLayoutMatches(point, candidate, candidate.tags.lanes ?? 0)),
);
if (candidates.length === 0) return null;
- // A way whose lane count matches the decision point is the gantry in use;
+ // Prefer a compatible incoming layout;
// prefer it over a parallel carriageway or a differently-tagged neighbour.
const laneMatch = candidates.filter(
(candidate) =>
@@ -277,7 +249,7 @@ export function selectApproachWay(
/**
* Where each of the route's lanes leads per OSM, read off the way carrying
- * them into the split. Unlike the gantry's approach way this may be a ramp:
+ * them into the split. The approach may be a ramp:
* a two-way split on a slip road happens on a `_link`. The way must head the
* route's way, end at or before the point, and have exactly the engine's lane
* count, so its `:lanes` values line up with the engine's lanes one to one.
@@ -295,13 +267,20 @@ export function laneDestinationsAt(
candidate.tags.lanes === laneCount &&
angularDifference(candidate.bearing, point.approachBearing) <= 35 &&
candidate.endDistanceMeters >= 0 &&
- candidate.endDistanceMeters <= 450,
+ candidate.endDistanceMeters <= 450 &&
+ (!point.approachTrace ||
+ incomingLayoutMatches(point, candidate, candidate.tags.lanes ?? 0)),
)
.sort((a, b) => a.endDistanceMeters - b.endDistanceMeters)[0];
if (!way) return undefined;
const destinationLanes = laneValues(splitLaneValues(way.tags.destinationLanes), laneCount);
const destinationRefLanes = laneValues(splitLaneValues(way.tags.destinationRefLanes), laneCount);
- if (!destinationLanes && !destinationRefLanes) return undefined;
+ if (
+ (way.tags.destinationLanes !== undefined && !destinationLanes) ||
+ (way.tags.destinationRefLanes !== undefined && !destinationRefLanes) ||
+ (!destinationLanes && !destinationRefLanes)
+ )
+ return undefined;
return Array.from({ length: laneCount }, (_, lane) => {
const tagged = splitRefParts(splitParts(destinationRefLanes?.[lane] ?? ""));
return {
diff --git a/packages/core/src/navigation/lanes.ts b/packages/core/src/navigation/lanes.ts
index 682cef23c..c70f97dba 100644
--- a/packages/core/src/navigation/lanes.ts
+++ b/packages/core/src/navigation/lanes.ts
@@ -25,23 +25,6 @@ export function guidanceApproachMeters(mode: TravelMode, currentSpeedMps: number
return Math.max(g.minMeters, Math.max(currentSpeedMps, 0) * leadSeconds);
}
-/** Lead time for the junction photo, seconds, and the floor it never drops below. */
-const PHOTO_LEAD_SECONDS = 30;
-const PHOTO_MIN_METERS = 300;
-
-/**
- * Distance at which the junction photo is worth showing. The gantry goes up
- * with the rest of the guidance, minutes ahead on the motorway, but a picture
- * of a junction that far off shows a road you cannot place yet — and it holds
- * a large part of the screen. Half a minute of travel puts it up when what it
- * shows is about to appear through the windscreen, never beyond the guidance
- * window that carries it.
- */
-export function junctionPhotoApproachMeters(mode: TravelMode, currentSpeedMps: number): number {
- const lead = Math.max(Math.max(currentSpeedMps, 0) * PHOTO_LEAD_SECONDS, PHOTO_MIN_METERS);
- return Math.min(lead, guidanceApproachMeters(mode, currentSpeedMps));
-}
-
/**
* Whether to preview the maneuver *after* the upcoming one ("Then …"). Two
* conditions: we're inside the approach window for the current maneuver (so it's
@@ -65,62 +48,40 @@ export function shouldPreviewNextStep(
return (nextStepDistanceMeters ?? Number.POSITIVE_INFINITY) <= g.chainSeconds * voice.refSpeedMps;
}
-/** Normalize a lane/turn token: trim, lowercase, underscores → spaces. */
-function norm(token: string): string {
- return token.trim().toLowerCase().replace(/_/g, " ");
+export interface ResolvedLaneGuidance {
+ lanes: ManeuverLane[];
+ usableLanes: number[];
+ preferredLanes: number[];
+ state: "known" | "partial" | "unknown" | "conflict";
}
-/** Mark a lane valid (and set its active indication) when one of its indications matches. */
-function markIfMatch(lane: ManeuverLane, matches: (indication: string) => boolean): ManeuverLane {
- const hit = lane.indications.find((i) => matches(norm(i)));
- if (!hit) return { ...lane, valid: false };
- return { ...lane, valid: true, active: hit };
+/** Preserve reported meaning; a turn arrow alone does not identify a routed branch. */
+export function resolveLaneGuidance(lanes: ManeuverLane[] | undefined): ResolvedLaneGuidance {
+ const values = lanes ?? [];
+ const conflict = values.some((lane) => lane.preferred === true && lane.valid === false);
+ const reported = values.filter((lane) => lane.valid !== undefined).length;
+ return {
+ lanes: values,
+ usableLanes: values.flatMap((lane, i) => (lane.valid === true ? [i] : [])),
+ preferredLanes: values.flatMap((lane, i) =>
+ lane.valid === true && lane.preferred === true ? [i] : [],
+ ),
+ state: conflict
+ ? "conflict"
+ : reported === 0
+ ? "unknown"
+ : reported === values.length
+ ? "known"
+ : "partial",
+ };
}
-/**
- * Decide which lanes to highlight for an upcoming maneuver. When the routing
- * engine already marked a lane valid we trust it; otherwise we recommend lanes
- * ourselves from the maneuver. A `keep` instruction first prefers through
- * lanes because the named side describes the branch to stay on, not a turn to
- * make. Other maneuvers use exact direction → same side → unrestricted. Pure.
- */
+/** Retained API: unknown or rejected lanes never become guessed recommendations. */
export function resolveRecommendedLanes(
lanes: ManeuverLane[] | undefined,
- maneuver: LaneManeuver | undefined,
+ _maneuver: LaneManeuver | undefined,
): ManeuverLane[] {
- if (!lanes || lanes.length === 0) return lanes ?? [];
- if (lanes.some((l) => l.valid)) return lanes; // engine already decided
-
- const mod = maneuver?.modifier ? norm(maneuver.modifier) : "";
-
- // "Keep left/right to stay on …" commonly means remaining in the through
- // carriageway while an exit peels away on the other side. Highlight the
- // through lanes instead of looking for a literal left/right turn arrow.
- if (maneuver?.type === "keep") {
- const through = lanes.map((l) =>
- markIfMatch(l, (i) => i === "through" || i === "straight" || i === "none" || i === ""),
- );
- if (through.some((l) => l.valid)) return through;
- }
-
- // Going straight (or no modifier): a through/straight/unmarked lane keeps you on-route.
- if (mod === "" || mod === "straight") {
- return lanes.map((l) =>
- markIfMatch(l, (i) => i === "through" || i === "straight" || i === "none" || i === ""),
- );
- }
-
- const exact = lanes.map((l) => markIfMatch(l, (i) => i === mod));
- if (exact.some((l) => l.valid)) return exact;
-
- const side = mod.includes("left") ? "left" : mod.includes("right") ? "right" : null;
- if (side) {
- const approx = lanes.map((l) => markIfMatch(l, (i) => i.includes(side)));
- if (approx.some((l) => l.valid)) return approx;
- }
-
- // Last resort: an unrestricted lane is usable for any maneuver.
- return lanes.map((l) => markIfMatch(l, (i) => i === "none" || i === ""));
+ return resolveLaneGuidance(lanes).lanes;
}
/** What else is known about where the lanes lead, beyond the engine's arrows. */
@@ -135,10 +96,7 @@ export interface LaneGuidanceEvidence {
* The lanes to highlight in turn-lane guidance. The engine's own choice wins.
* Without it, lanes whose OSM destinations name the maneuver's sign come next:
* at a split tagged `through` on every lane, they are the only thing telling
- * the branches apart. Then `resolveRecommendedLanes`, except where it lights
- * every lane for a maneuver to one side through arrows that do not point that
- * way: all four `through` lanes before a split say nothing about which two
- * keep left, so none is highlighted rather than all. Pure.
+ * the branches apart. Arrow direction alone never identifies a routed branch.
*/
export function resolveGuidanceLanes(
lanes: ManeuverLane[] | undefined,
@@ -146,18 +104,13 @@ export function resolveGuidanceLanes(
evidence: LaneGuidanceEvidence = {},
): ManeuverLane[] {
if (!lanes || lanes.length === 0) return lanes ?? [];
- if (lanes.some((l) => l.valid)) return lanes;
+ if (lanes.some((l) => l.valid !== undefined)) return lanes;
const side = sideOf(maneuver);
const signed = lanesTowardSign(lanes, side, evidence);
if (signed) return signed;
- const resolved = resolveRecommendedLanes(lanes, maneuver);
- const uninformative =
- side !== null &&
- resolved.every((l) => l.valid) &&
- !resolved.some((l) => l.active !== undefined && norm(l.active).includes(side));
- return uninformative ? lanes : resolved;
+ return lanes;
}
function sideOf(maneuver: LaneManeuver | undefined): "left" | "right" | null {
@@ -186,9 +139,9 @@ function lanesTowardSign(
) ?? distinguishing(laneDestinations.map((l) => l.refs.some((r) => refs.has(refKey(r)))));
if (!hits) return null;
return lanes.map((lane, i) => {
- if (!hits[i]) return { ...lane, valid: false };
+ if (!hits[i]) return { ...lane, valid: false, preferred: false };
const toward = side ? lane.indications.find((ind) => norm(ind).includes(side)) : undefined;
- return { ...lane, valid: true, ...(toward ? { active: toward } : {}) };
+ return { ...lane, valid: true, preferred: true, ...(toward ? { active: toward } : {}) };
});
}
@@ -206,3 +159,7 @@ function placeKey(place: string): string {
function refKey(ref: string): string {
return ref.normalize("NFC").toLowerCase().replace(/\s+/g, "");
}
+
+function norm(value: string): string {
+ return value.toLowerCase().replace(/[-\s]/g, "_");
+}
diff --git a/packages/core/src/navigation/maneuverPosition.ts b/packages/core/src/navigation/maneuverPosition.ts
new file mode 100644
index 000000000..0342de5cc
--- /dev/null
+++ b/packages/core/src/navigation/maneuverPosition.ts
@@ -0,0 +1,261 @@
+import type { LngLat } from "../types/geometry";
+import type { Route } from "../types/routing";
+import { haversineDistance } from "../utils/coordinates";
+import { cumulativeDistances } from "./deadReckon";
+import { type PreparedRouteMatcher, prepareRouteMatcher, snapPreparedRoute } from "./routeMatcher";
+
+export interface ManeuverPositions {
+ starts: number[];
+ ends: number[];
+ total: number;
+ metric: "geometry" | "engine-fallback";
+}
+const prepared = new WeakMap();
+
+/** Ordered physical maneuver occurrences; immutable route objects share the preparation. */
+export function prepareManeuverPositions(route: Route): ManeuverPositions {
+ const cached = prepared.get(route);
+ if (cached) return cached;
+ const usableGeometry =
+ route.geometry.length >= 2 && route.steps.some((s) => (s.coordinates?.length ?? 0) > 0);
+ const cum = usableGeometry ? cumulativeDistances(route.geometry) : [];
+ const total = usableGeometry ? cum[cum.length - 1] : Math.max(0, route.distance);
+ let engine = 0;
+ const starts = usableGeometry
+ ? stepGeometryDistances(route, cum)
+ : route.steps.map((step) => {
+ const start = engine;
+ engine += step.distance;
+ return start;
+ });
+ for (let i = 0; i < starts.length; i++)
+ starts[i] = Math.max(i ? starts[i - 1] : 0, Math.min(total, starts[i]));
+ const ends = starts.map((_, i) => starts[i + 1] ?? total);
+ const result: ManeuverPositions = {
+ starts,
+ ends,
+ total,
+ metric: usableGeometry ? "geometry" : "engine-fallback",
+ };
+ prepared.set(route, result);
+ return result;
+}
+
+export function maneuverStartMeters(route: Route, stepIndex: number): number {
+ return prepareManeuverPositions(route).starts[stepIndex] ?? prepareManeuverPositions(route).total;
+}
+
+/** Match step shapes in route order, so repeated coordinates retain their occurrence. */
+function stepGeometryDistances(route: Route, cum: number[]): number[] {
+ const geometry = route.geometry;
+ const coordinateKey = ([lng, lat]: LngLat): string => `${lng},${lat}`;
+ const keys: string[] = [];
+ const groupCoordinates: LngLat[] = [];
+ const stepGroups = route.steps.map((step) =>
+ (step.coordinates ?? []).map((coordinate) => {
+ const key = coordinateKey(coordinate);
+ if (key !== keys.at(-1)) {
+ keys.push(key);
+ groupCoordinates.push(coordinate);
+ }
+ return keys.length - 1;
+ }),
+ );
+ const occurrences = new Map();
+ keys.forEach((key, i) => {
+ const indices = occurrences.get(key) ?? [];
+ indices.push(i);
+ occurrences.set(key, indices);
+ });
+ const anchors = new Array(keys.length);
+ const geometryVertices = new Map();
+ let previousGroup = -1;
+ let previousDistance = -1;
+ for (let i = 0; i < geometry.length; i += 1) {
+ const key = coordinateKey(geometry[i]);
+ const vertices = geometryVertices.get(key) ?? [];
+ vertices.push(i);
+ geometryVertices.set(key, vertices);
+ const indices = occurrences.get(key);
+ if (!indices) continue;
+ const minimum = cum[i] === previousDistance ? previousGroup : previousGroup + 1;
+ let lo = 0;
+ let hi = indices.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ if (indices[mid] < minimum) lo = mid + 1;
+ else hi = mid;
+ }
+ const group = indices[lo];
+ if (group === undefined) continue;
+ anchors[group] = i;
+ previousGroup = group;
+ previousDistance = cum[i];
+ }
+ // Overview vertices are ordered anchors in the full step shape. Coordinates
+ // omitted by simplification belong before their next anchor, even on a loop.
+ const nextAnchor = new Array(keys.length);
+ let next = geometry.length - 1;
+ for (let i = keys.length - 1; i >= 0; i -= 1) {
+ nextAnchor[i] = next;
+ const anchor = anchors[i];
+ if (anchor !== undefined) next = anchor;
+ }
+ const projections = new Map();
+ let geometryIndex = 0;
+ let matchedAlongMeters = 0;
+ let confirmedAlongMeters = 0;
+ let confirmedIndex = 0;
+ let missingShape = false;
+ let hasShapeEnd = false;
+ let shapeDistances: number[] | undefined;
+ const nearestVertex = (group: number, hint: number): number | undefined => {
+ const indices = geometryVertices.get(keys[group]) ?? [];
+ let lo = 0;
+ let hi = indices.length;
+ while (lo < hi) {
+ const mid = (lo + hi) >>> 1;
+ if (cum[indices[mid]] < hint) lo = mid + 1;
+ else hi = mid;
+ }
+ let vertex: number | undefined;
+ let nearest = Number.POSITIVE_INFINITY;
+ for (const candidate of [indices[lo - 1], indices[lo]]) {
+ if (candidate === undefined || cum[candidate] < confirmedAlongMeters - 1e-6) continue;
+ const difference = Math.abs(cum[candidate] - hint);
+ if (difference < nearest) {
+ nearest = difference;
+ vertex = candidate;
+ }
+ }
+ return vertex;
+ };
+ const advance = (alongMeters: number): void => {
+ matchedAlongMeters = Math.max(matchedAlongMeters, Math.min(alongMeters, cum[cum.length - 1]));
+ while (geometryIndex < geometry.length - 1 && cum[geometryIndex + 1] <= matchedAlongMeters) {
+ geometryIndex += 1;
+ }
+ };
+
+ const starts = route.steps.map((step, stepIndex) => {
+ const coordinates = step.coordinates ?? [];
+ let alongMeters = confirmedAlongMeters;
+ if (coordinates.length === 0) {
+ const start =
+ stepIndex === 0 ? 0 : !missingShape && hasShapeEnd ? confirmedAlongMeters : undefined;
+ // Retain a distance hint for repeated visits; it cannot replace known boundaries.
+ missingShape = true;
+ advance(matchedAlongMeters + step.distance);
+ return start;
+ }
+ for (let i = 0; i < coordinates.length; i += 1) {
+ const coordinate = coordinates[i];
+ const group = stepGroups[stepIndex][i];
+ let vertex = anchors[group];
+ let recoveryHint: number | undefined;
+ let recoveryEnd: number | undefined;
+ if (missingShape) {
+ // Inferred progress is a hint, not a lower bound when coordinates resume.
+ const hint = matchedAlongMeters;
+ vertex = undefined;
+ recoveryHint = hint;
+ shapeDistances ??= cumulativeDistances(groupCoordinates);
+ for (let following = group + 1; following < keys.length; following += 1) {
+ const prefix = shapeDistances[following] - shapeDistances[group];
+ const anchor = nearestVertex(following, hint + prefix);
+ if (anchor === undefined) continue;
+ anchors[following] = anchor;
+ recoveryEnd = anchor;
+ recoveryHint = Math.max(confirmedAlongMeters, cum[anchor] - prefix);
+ break;
+ }
+ matchedAlongMeters = confirmedAlongMeters;
+ geometryIndex = confirmedIndex;
+ missingShape = false;
+ }
+ const upcoming = recoveryEnd ?? nextAnchor[group];
+ const end =
+ (recoveryHint !== undefined && recoveryEnd === undefined) ||
+ cum[upcoming] < matchedAlongMeters - 1e-6
+ ? geometry.length - 1
+ : Math.max(geometryIndex, upcoming);
+ let offset = matchedAlongMeters;
+ if (vertex !== undefined && cum[vertex] >= matchedAlongMeters - 1e-6) {
+ offset = cum[vertex];
+ } else if (end > geometryIndex) {
+ let projection = projections.get(end);
+ if (!projection) {
+ projection = {
+ start: geometryIndex,
+ prepared: prepareRouteMatcher(geometry.slice(geometryIndex, end + 1)),
+ };
+ projections.set(end, projection);
+ }
+ let snapped = snapPreparedRoute(
+ projection.prepared,
+ coordinate,
+ geometryIndex - projection.start,
+ );
+ if (projection.start + snapped.segmentIndex < geometryIndex) {
+ // A previous visit can be nearer; remove it once and reuse the remaining index.
+ projection = {
+ start: geometryIndex,
+ prepared: prepareRouteMatcher(geometry.slice(geometryIndex, end + 1)),
+ };
+ projections.set(end, projection);
+ snapped = snapPreparedRoute(projection.prepared, coordinate);
+ }
+ let segment = projection.start + snapped.segmentIndex;
+ if (recoveryHint !== undefined) {
+ let lo = geometryIndex;
+ let hi = end - 1;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >>> 1;
+ if (cum[mid] <= recoveryHint) lo = mid;
+ else hi = mid - 1;
+ }
+ const preferred = snapPreparedRoute(
+ prepareRouteMatcher(geometry.slice(lo, lo + 2)),
+ coordinate,
+ );
+ // Geographically equal projections on repeated stretches use the recovered shape's offset.
+ if (preferred.deviationMeters <= snapped.deviationMeters + 1e-6) {
+ snapped = preferred;
+ segment = lo + preferred.segmentIndex;
+ }
+ }
+ if (segment === end) offset = cum[end];
+ else {
+ const length = cum[segment + 1] - cum[segment];
+ const fraction =
+ length > 0 ? haversineDistance(geometry[segment], snapped.snapped) / length : 0;
+ // Use the geometry's metric, not the matcher's independent prefix sum.
+ offset = cum[segment] + Math.min(fraction, 1) * length;
+ }
+ }
+ advance(offset);
+ confirmedAlongMeters = matchedAlongMeters;
+ confirmedIndex = geometryIndex;
+ if (i === 0) alongMeters = matchedAlongMeters;
+ }
+ hasShapeEnd = coordinates.length > 1 || step.distance === 0;
+ return alongMeters;
+ });
+ // Missing boundaries are relative to the surrounding physical anchors. An
+ // absolute engine prefix would move later confirmed coordinates during clamping.
+ let left = 0;
+ for (let right = 1; right <= starts.length; right += 1) {
+ if (right < starts.length && starts[right] === undefined) continue;
+ const from = starts[left]!;
+ const to = starts[right] ?? cum[cum.length - 1];
+ let distance = 0;
+ for (let i = left; i < right; i += 1) distance += route.steps[i].distance;
+ let traveled = 0;
+ for (let i = left + 1; i < right; i += 1) {
+ traveled += route.steps[i - 1].distance;
+ starts[i] = from + (to - from) * (distance > 0 ? traveled / distance : 0);
+ }
+ left = right;
+ }
+ return starts as number[];
+}
diff --git a/packages/core/src/navigation/offlineSession.test.ts b/packages/core/src/navigation/offlineSession.test.ts
index 4b47c2aca..5347c0713 100644
--- a/packages/core/src/navigation/offlineSession.test.ts
+++ b/packages/core/src/navigation/offlineSession.test.ts
@@ -214,6 +214,10 @@ const corruptions: [string, Corrupt][] = [
["lanes holding a string", onStep("lanes", ["right"])],
["lane indications as an object", onStep("lanes", [{ indications: {}, valid: true }])],
["lane indications holding numbers", onStep("lanes", [{ indications: [1], valid: true }])],
+ [
+ "lane preference as a string",
+ onStep("lanes", [{ indications: [], valid: true, preferred: "yes" }]),
+ ],
["lane validity as a string", onStep("lanes", [{ indications: [], valid: "yes" }])],
[
"a lane active indication as a number",
@@ -505,3 +509,30 @@ it("retains validated cycling preferences and rejects corrupt saved values", ()
Object.assign(corrupt.routeOptions, { bikeProfile: "car" });
expect(parseNavigationSessionSnapshot(corrupt)).toBeNull();
});
+
+describe("lane evidence in saved navigation", () => {
+ it("round trips unknown, initially usable, preferred and rejected lanes", () => {
+ const lanes = [
+ { indications: ["through"] },
+ { indications: ["through"], valid: true, preferred: false },
+ { indications: ["through"], valid: true, preferred: true, active: "through" },
+ { indications: ["right"], valid: false, preferred: false },
+ ];
+ const base = route();
+ const storedRoute = {
+ ...base,
+ steps: base.steps.map((step, i) => (i === 0 ? { ...step, lanes } : step)),
+ legs: base.legs.map((leg) => ({
+ ...leg,
+ steps: leg.steps.map((step) => ({ ...step, lanes })),
+ })),
+ };
+ const snapshot = createNavigationSessionSnapshot(
+ input({ route: storedRoute, routes: [storedRoute] }),
+ );
+ const restored = parseNavigationSessionSnapshot(JSON.parse(JSON.stringify(snapshot)));
+ expect(restored?.route.steps[0].lanes).toEqual(lanes);
+ expect(restored?.route.legs[0].steps[0].lanes).toEqual(lanes);
+ expect(restored?.routes[0].steps[0].lanes).toEqual(lanes);
+ });
+});
diff --git a/packages/core/src/navigation/offlineSession.ts b/packages/core/src/navigation/offlineSession.ts
index dba2382e9..d65f487c8 100644
--- a/packages/core/src/navigation/offlineSession.ts
+++ b/packages/core/src/navigation/offlineSession.ts
@@ -99,7 +99,8 @@ function copyStep(step: RouteStep): RouteStep {
...(step.lanes && {
lanes: step.lanes.map((lane) => ({
indications: [...lane.indications],
- valid: lane.valid,
+ ...(lane.valid !== undefined && { valid: lane.valid }),
+ ...(lane.preferred !== undefined && { preferred: lane.preferred }),
...(lane.active !== undefined && { active: lane.active }),
})),
}),
@@ -189,7 +190,8 @@ function validateLanes(value: unknown): value is ManeuverLane[] {
(lane) =>
isPlainObject(lane) &&
isStringArray(lane.indications) &&
- typeof lane.valid === "boolean" &&
+ (lane.valid === undefined || typeof lane.valid === "boolean") &&
+ (lane.preferred === undefined || typeof lane.preferred === "boolean") &&
isOptionalString(lane.active),
)
);
diff --git a/packages/core/src/navigation/photoProjection.ts b/packages/core/src/navigation/photoProjection.ts
deleted file mode 100644
index 0d4bc788b..000000000
--- a/packages/core/src/navigation/photoProjection.ts
+++ /dev/null
@@ -1,351 +0,0 @@
-import type { LngLat } from "../types/geometry";
-import type { JunctionDecisionPoint } from "../types/junction";
-import type { StreetLevelImage } from "../types/streetLevel";
-import { haversineDistance } from "../utils/coordinates";
-import { angularDifference, bearingBetween } from "./bearing";
-import { cumulativeDistances, positionAt } from "./deadReckon";
-
-/**
- * Projection of the route ahead onto a street-level photo taken on the
- * approach. Pure geometry: the photo is snapped onto the route (so its own
- * GPS scatter never moves the path sideways), the camera is assumed to face
- * along the road, and the route is projected through a pinhole camera.
- *
- * Coordinates come back as percentages of the *source image*, not of the box
- * it is displayed in, so the overlay can be cropped exactly like the image.
- */
-
-/** Eye height of a dash-mounted camera above the road, metres. */
-const CAMERA_HEIGHT_M = 1.3;
-/**
- * Half the drawn path, metres — about a car's track width. Narrower than the
- * 3.5 m lane it runs in, so the lane markings either side stay visible.
- */
-const PATH_HALF_WIDTH_M = 0.8;
-/** Frame shape assumed when the provider reports none; phone cameras shoot 4:3. */
-const DEFAULT_ASPECT_RATIO = 4 / 3;
-/** Height of the horizon in the frame, percent — a level camera looks straight ahead. */
-const HORIZON_PERCENT = 50;
-/** The panorama crop window showing the approach, degrees. */
-const PANO_CROP_SPAN_DEG = 90;
-/** Equirectangular images span the full sphere. */
-const PANO_WIDTH_DEG = 360;
-const PANO_HEIGHT_DEG = 180;
-/** Spacing of the projected samples along the route, metres. */
-const SAMPLE_STEP_METERS = 5;
-/**
- * Where the path starts at the earliest, metres ahead. Close enough that it
- * runs off the bottom of a typical frame, so it reads as the road under the
- * car rather than a shape floating in the middle of the picture; a lens that
- * sees less of the road starts it at its own bottom edge instead.
- */
-const NEAR_CLIP_METERS = 4;
-/** How far past the decision point the path follows the ramp, metres. */
-const PAST_POINT_METERS = 60;
-/** Beyond this the path is a pixel-wide smudge on the horizon, metres. */
-const MAX_PATH_METERS = 200;
-/** A photo further than this from the route is on another road or carriageway. */
-const MAX_CAMERA_OFFSET_METERS = 25;
-/**
- * How far upstream of the decision point the photo is looked for. The selector
- * only keeps frames within 200 m, and searching a window rather than the whole
- * route keeps the cost off the route length — and stops a route that passes
- * the same motorway twice from matching the wrong stretch.
- */
-const UPSTREAM_SEARCH_METERS = 260;
-/** Resolution of that search, metres. */
-const SEARCH_STEP_METERS = 1;
-/** The camera must look along the road for the forward-facing assumption to hold. */
-const MAX_HEADING_MISMATCH_DEG = 40;
-/** Lane width used to place the exit lane across the carriageway, metres. */
-const LANE_WIDTH_M = 3.5;
-/** The path runs straight ahead for this long before it starts moving over, metres. */
-const LANE_CHANGE_LEAD_METERS = 10;
-/** The move into the exit lane is complete this far before the split, metres. */
-const LANE_CHANGE_SETTLE_METERS = 20;
-/**
- * How far past the split the path hands over from the exit lane to the ramp's
- * own line. OSM joins a ramp to the carriageway's centre node, so the ramp's
- * first metres run through the through-lanes before it separates.
- */
-const RAMP_HANDOVER_METERS = 40;
-const METERS_PER_DEGREE = 111_320;
-/** Keep the outermost degree clear so a sample never lands exactly on the frame edge. */
-const FRAME_EDGE_MARGIN_DEG = 0.5;
-/** The path's first sample sits this far inside the bottom edge, so rounding never drops it. */
-const BOTTOM_EDGE_MARGIN_DEG = 0.01;
-
-/** One projected route sample, in percent of the source image. */
-export interface PhotoPathPoint {
- xPercent: number;
- yPercent: number;
- /** Width of the drawn path at this sample, percent of the image width. */
- widthPercent: number;
- /** Distance from the camera to this sample, metres. */
- distanceMeters: number;
-}
-
-/** The slice of a panorama that shows the approach. */
-export interface PhotoCropWindow {
- startDeg: number;
- spanDeg: number;
-}
-
-export interface PhotoRoutePath {
- /** Camera-first samples of the route ahead; empty when nothing can be drawn. */
- points: PhotoPathPoint[];
- /** How far the route has turned by the end of the path, signed degrees. */
- headRotateDeg: number;
- /** Present for panoramas: the window the image is cropped to. */
- crop?: PhotoCropWindow;
- /**
- * How far the path moves across the carriageway between the camera and the
- * split, metres, positive to the right. 0 when the lane layout is unknown.
- */
- laneShiftMeters: number;
- visible: boolean;
-}
-
-export interface PhotoProjectionOptions {
- /** Camera height above the road, metres. */
- cameraHeightM?: number;
- /**
- * Width / height of the source image, measured from the rendered frame.
- * Wins over `image.aspectRatio`, which is only what the provider claims.
- */
- aspectRatio?: number;
- /**
- * The lanes at the split and the ones the exit leaves from (0 = leftmost),
- * from the gantry. With them the path moves from the camera's own lane into
- * the exit lane; without them it follows the route's centreline.
- */
- exitLanes?: { laneCount: number; activeLanes: number[] };
-}
-
-/**
- * Project the route from the photo's position through the decision point onto
- * the image. Returns an empty, invisible path when the photo cannot be trusted
- * to show the approach: taken off the route, facing away from it, already
- * past the junction, or through a lens whose field of view is unknown.
- */
-export function projectRoutePath(
- geometry: LngLat[],
- point: JunctionDecisionPoint,
- image: StreetLevelImage,
- options: PhotoProjectionOptions = {},
-): PhotoRoutePath {
- const crop = image.isPano ? cropWindow(point.approachBearing) : undefined;
- if (geometry.length < 2) return hidden(crop);
-
- const cum = cumulativeDistances(geometry);
- const camera = approachCamera(geometry, cum, point, image.lngLat);
- // A dash camera points where the car drives, so the route's own bearing is a
- // steadier optical axis than the photo's compass reading. The heading is
- // still checked below, to catch a frame that does not look along the road.
- const axisBearing = camera.bearing;
- const panoCrop = image.isPano ? cropWindow(axisBearing) : undefined;
- if (!camera.onApproach) return hidden(panoCrop);
- if (
- image.heading !== undefined &&
- angularDifference(image.heading, axisBearing) > MAX_HEADING_MISMATCH_DEG
- ) {
- return hidden(panoCrop);
- }
-
- // Without the lens's field of view the path could land anywhere across a
- // wide-angle frame; showing the photo alone beats a line off the road.
- if (!image.isPano && image.fovDeg === undefined) return hidden();
- // An equirectangular frame is centred on the photo's heading, not on the
- // road: a panorama's horizontal position is read against that, and without
- // it there is nothing to place the path against.
- if (image.isPano && image.heading === undefined) return hidden(panoCrop);
- const panoCentreBearing = image.heading ?? axisBearing;
- const halfWidthDeg = image.isPano ? PANO_WIDTH_DEG / 2 : Math.max((image.fovDeg ?? 0) / 2, 1);
- const aspectRatio = options.aspectRatio ?? image.aspectRatio ?? DEFAULT_ASPECT_RATIO;
- const halfHeightDeg = image.isPano
- ? PANO_HEIGHT_DEG / 2
- : toDegrees(Math.atan(Math.tan(toRadians(halfWidthDeg)) / aspectRatio));
- const cameraHeight = options.cameraHeightM ?? CAMERA_HEIGHT_M;
-
- // The path starts where the road enters the bottom of the frame. A narrow
- // or wide-format lens sees the road only from further out than the near
- // clip, and a path started below the frame would be cut off at its first
- // sample.
- const bottomEdgeMeters = image.isPano
- ? 0
- : cameraHeight / Math.tan(toRadians(halfHeightDeg - BOTTOM_EDGE_MARGIN_DEG));
- const startAlong = camera.alongMeters + Math.max(NEAR_CLIP_METERS, bottomEdgeMeters);
- if (point.alongMeters <= startAlong) return hidden(panoCrop);
- const endAlong = Math.min(
- point.alongMeters + PAST_POINT_METERS,
- camera.alongMeters + MAX_PATH_METERS,
- cum[cum.length - 1],
- );
-
- // Lateral offsets are metres right of the route line, which follows the
- // carriageway's centre. The exit lane is placed from the gantry; the camera
- // from its own GPS — only its lateral offset, clamped to the carriageway's
- // outer lanes, since that is all the lane change needs from it.
- const exitOffset = exitLaneOffset(options.exitLanes);
- const outerLaneOffset = options.exitLanes
- ? ((options.exitLanes.laneCount - 1) * LANE_WIDTH_M) / 2
- : 0;
- const cameraOffset =
- exitOffset === undefined
- ? 0
- : Math.min(Math.max(camera.lateralOffsetMeters, -outerLaneOffset), outerLaneOffset);
- const shiftStart = camera.alongMeters + LANE_CHANGE_LEAD_METERS;
- const shiftEnd = Math.max(point.alongMeters - LANE_CHANGE_SETTLE_METERS, shiftStart + 1);
- const offsetAt = (along: number): number => {
- if (exitOffset === undefined) return 0;
- if (along > point.alongMeters) {
- const handover = Math.min((along - point.alongMeters) / RAMP_HANDOVER_METERS, 1);
- return exitOffset * (1 - handover);
- }
- const t = Math.min(Math.max((along - shiftStart) / (shiftEnd - shiftStart), 0), 1);
- return cameraOffset + (exitOffset - cameraOffset) * t * t * (3 - 2 * t);
- };
- const eye = offsetRight(camera.point, axisBearing, cameraOffset);
-
- const points: PhotoPathPoint[] = [];
- for (let along = startAlong; along <= endAlong; along += SAMPLE_STEP_METERS) {
- const onRoute = positionAt(geometry, cum, along);
- const sample = offsetRight(onRoute.point, onRoute.bearing, offsetAt(along));
- const sampleBearing = bearingBetween(eye, sample);
- const relativeDeg = normalizeSigned(sampleBearing - axisBearing);
- if (Math.abs(relativeDeg) > halfWidthDeg - FRAME_EDGE_MARGIN_DEG) break;
- const distanceMeters = along - camera.alongMeters;
- const belowHorizonDeg = toDegrees(Math.atan(cameraHeight / distanceMeters));
- const xPercent = image.isPano
- ? HORIZON_PERCENT +
- (normalizeSigned(sampleBearing - panoCentreBearing) / PANO_WIDTH_DEG) * 100
- : 50 + (Math.tan(toRadians(relativeDeg)) / Math.tan(toRadians(halfWidthDeg))) * 50;
- const yPercent = image.isPano
- ? HORIZON_PERCENT + (belowHorizonDeg / PANO_HEIGHT_DEG) * 100
- : HORIZON_PERCENT +
- (Math.tan(toRadians(belowHorizonDeg)) / Math.tan(toRadians(halfHeightDeg))) * 50;
- if (yPercent > 100) break;
- const laneHalfDeg = toDegrees(Math.atan(PATH_HALF_WIDTH_M / distanceMeters));
- const widthPercent = image.isPano
- ? ((2 * laneHalfDeg) / PANO_WIDTH_DEG) * 100
- : (Math.tan(toRadians(laneHalfDeg)) / Math.tan(toRadians(halfWidthDeg))) * 100;
- points.push({ xPercent, yPercent, widthPercent, distanceMeters });
- }
-
- if (points.length < 2) return hidden(panoCrop);
- const endBearing = positionAt(geometry, cum, endAlong).bearing;
- return {
- points,
- headRotateDeg: normalizeSigned(endBearing - axisBearing),
- ...(panoCrop ? { crop: panoCrop } : {}),
- laneShiftMeters: exitOffset === undefined ? 0 : exitOffset - cameraOffset,
- visible: true,
- };
-}
-
-/** Metres right of the carriageway centre of the exit lanes' middle, when the layout is known. */
-function exitLaneOffset(exitLanes: PhotoProjectionOptions["exitLanes"]): number | undefined {
- if (!exitLanes || exitLanes.laneCount < 1 || exitLanes.activeLanes.length === 0) return undefined;
- const middle =
- exitLanes.activeLanes.reduce((sum, lane) => sum + lane, 0) / exitLanes.activeLanes.length;
- return (middle - (exitLanes.laneCount - 1) / 2) * LANE_WIDTH_M;
-}
-
-/** `point` moved `meters` to the right of travel at `bearingDeg`. */
-function offsetRight(point: LngLat, bearingDeg: number, meters: number): LngLat {
- if (meters === 0) return point;
- const rad = toRadians(bearingDeg);
- const east = Math.cos(rad) * meters;
- const north = -Math.sin(rad) * meters;
- return [
- point[0] + east / (METERS_PER_DEGREE * Math.cos(toRadians(point[1]))),
- point[1] + north / METERS_PER_DEGREE,
- ];
-}
-
-/** Where a photo sits on the approach to a decision point. */
-export interface ApproachCamera {
- alongMeters: number;
- point: LngLat;
- /** Travel bearing of the road there — the way a forward-facing camera looks. */
- bearing: number;
- deviationMeters: number;
- /**
- * The photo's GPS position across the road from the route line, metres,
- * positive to the right of travel — which lane it was shot from.
- */
- lateralOffsetMeters: number;
- /** Whether the photo is close enough to the route to describe this approach. */
- onApproach: boolean;
-}
-
-/**
- * Where on the approach the photo was taken: the closest point of the route in
- * the stretch leading up to the decision point. Working in route coordinates
- * rather than raw image coordinates cancels the photo's own GPS scatter, which
- * would otherwise slide the whole path sideways.
- */
-export function approachCamera(
- geometry: LngLat[],
- cum: number[],
- point: JunctionDecisionPoint,
- imageLngLat: LngLat,
-): ApproachCamera {
- const searchStart = Math.max(point.alongMeters - UPSTREAM_SEARCH_METERS, 0);
- let best = {
- alongMeters: searchStart,
- ...positionAt(geometry, cum, searchStart),
- deviationMeters: Number.POSITIVE_INFINITY,
- };
- for (let along = searchStart; along <= point.alongMeters; along += SEARCH_STEP_METERS) {
- const at = positionAt(geometry, cum, along);
- const deviationMeters = haversineDistance(at.point, imageLngLat);
- if (deviationMeters < best.deviationMeters)
- best = { alongMeters: along, ...at, deviationMeters };
- }
- const rad = toRadians(best.bearing);
- const east =
- (imageLngLat[0] - best.point[0]) * METERS_PER_DEGREE * Math.cos(toRadians(best.point[1]));
- const north = (imageLngLat[1] - best.point[1]) * METERS_PER_DEGREE;
- return {
- ...best,
- lateralOffsetMeters: east * Math.cos(rad) - north * Math.sin(rad),
- onApproach: best.deviationMeters <= MAX_CAMERA_OFFSET_METERS,
- };
-}
-
-/** The 90° window centred on the approach, for cropping a panorama. */
-function cropWindow(centreBearing: number): PhotoCropWindow {
- return {
- startDeg: normalizeDeg(centreBearing - PANO_CROP_SPAN_DEG / 2),
- spanDeg: PANO_CROP_SPAN_DEG,
- };
-}
-
-function hidden(crop?: PhotoCropWindow): PhotoRoutePath {
- return {
- points: [],
- headRotateDeg: 0,
- ...(crop ? { crop } : {}),
- laneShiftMeters: 0,
- visible: false,
- };
-}
-
-function toRadians(degrees: number): number {
- return (degrees * Math.PI) / 180;
-}
-
-function toDegrees(radians: number): number {
- return (radians * 180) / Math.PI;
-}
-
-function normalizeDeg(deg: number): number {
- return ((deg % 360) + 360) % 360;
-}
-
-/** Normalise a signed delta to (−180, 180]. */
-function normalizeSigned(delta: number): number {
- const wrapped = ((delta + 540) % 360) - 180;
- return wrapped === -180 ? 180 : wrapped;
-}
diff --git a/packages/core/src/navigation/progress.ts b/packages/core/src/navigation/progress.ts
index cd57d2b17..cbac26440 100644
--- a/packages/core/src/navigation/progress.ts
+++ b/packages/core/src/navigation/progress.ts
@@ -1,10 +1,11 @@
import type { Route } from "../types/routing";
+import { prepareManeuverPositions } from "./maneuverPosition";
import type { ProgressResult } from "./types";
/**
* Map a distance traveled along the route to the current step, the distance to
* the next maneuver, and the remaining distance/duration to the destination.
- * Step boundaries are derived from per-step `distance` (cumulative).
+ * Step boundaries use the same ordered geometry occurrences as route snapping.
*
* `forcedStepIndex` overrides which step is treated as current — passed by the
* step-advance gate so the displayed/announced step only advances once the
@@ -40,43 +41,29 @@ export function computeProgress(
durationRemaining,
};
}
- const total = route.distance;
+ const positions = prepareManeuverPositions(route);
+ const total = positions.total;
const along = Math.max(0, Math.min(alongMeters, total));
-
- // The geometric step the snapped position is actually in — drives the
- // distance/duration remaining (ETA), which must stay independent of the gate.
let rawIdx = steps.length - 1;
- let cumEnd = 0;
for (let i = 0; i < steps.length; i++) {
- cumEnd += steps[i].distance;
- if (along <= cumEnd) {
+ if (along <= positions.ends[i]) {
rawIdx = i;
break;
}
}
-
- // The displayed/announced step: the gate may hold an earlier one until the
- // maneuver is completed.
const idx =
forcedStepIndex != null ? Math.min(Math.max(forcedStepIndex, 0), steps.length - 1) : rawIdx;
-
- // Distance to the next maneuver is to the end of the DISPLAY step.
- let endOfDisplay = 0;
- for (let i = 0; i <= idx; i++) endOfDisplay += steps[i].distance;
- const distanceToNextManeuver = Math.max(0, endOfDisplay - along);
-
- const distanceRemaining = Math.max(0, total - along);
-
- // Duration remaining is geometric: pro-rate the RAW current step by how much of
- // it is left, then add every later step — so a lagging display gate can't add
- // the already-driven part of the current step back into the ETA.
- let endOfRaw = 0;
- for (let i = 0; i <= rawIdx; i++) endOfRaw += steps[i].distance;
- const rawCurrent = steps[rawIdx];
- const rawDistanceToEnd = Math.max(0, endOfRaw - along);
- const fractionLeft = rawCurrent.distance > 0 ? rawDistanceToEnd / rawCurrent.distance : 0;
- let durationRemaining = rawCurrent.duration * fractionLeft;
- for (let i = rawIdx + 1; i < steps.length; i++) durationRemaining += steps[i].duration;
+ const distanceToNextManeuver = Math.max(0, positions.ends[idx] - along);
+ const rawLength = positions.ends[rawIdx] - positions.starts[rawIdx];
+ const fractionLeft =
+ rawLength > 0 ? Math.min(1, Math.max(0, (positions.ends[rawIdx] - along) / rawLength)) : 0;
+ // Convert the physical fraction back into provider travel-distance and ETA units.
+ let distanceRemaining = steps[rawIdx].distance * fractionLeft;
+ let durationRemaining = steps[rawIdx].duration * fractionLeft;
+ for (let i = rawIdx + 1; i < steps.length; i++) {
+ distanceRemaining += steps[i].distance;
+ durationRemaining += steps[i].duration;
+ }
return { currentStepIndex: idx, distanceToNextManeuver, distanceRemaining, durationRemaining };
}
@@ -97,11 +84,7 @@ export function upcomingManeuverIndex(currentStepIndex: number, stepCount: numbe
return Math.min(currentStepIndex + 1, stepCount - 1);
}
-/**
- * Along-route distance at which `steps[stepIndex]` begins — the point where its
- * maneuver happens, in the same metric `computeProgress` uses for
- * `distanceToNextManeuver` (summed step distances, not geometry).
- */
+/** Legacy engine-distance metric; use maneuverStartMeters(route, index) for geometry positions. */
export function stepStartMeters(steps: readonly { distance: number }[], stepIndex: number): number {
let along = 0;
for (let i = 0; i < stepIndex && i < steps.length; i += 1) along += steps[i].distance;
diff --git a/packages/core/src/navigation/signFormat.ts b/packages/core/src/navigation/signFormat.ts
index dcc8c5def..d7a8f521c 100644
--- a/packages/core/src/navigation/signFormat.ts
+++ b/packages/core/src/navigation/signFormat.ts
@@ -23,7 +23,26 @@ export function refKind(ref: string): RefKind {
/** The toward destinations that fit in one strip (the spec's three-town cap). */
export function visibleToward(list: string[], max = 3): string[] {
- return list.slice(0, max);
+ return cleanSignTexts(list).slice(0, max);
+}
+
+export function cleanSignTexts(list: string[], refs = false): string[] {
+ const seen = new Set();
+ return list
+ .map((value) => value.trim())
+ .filter((value) => {
+ if (!value || /^(none|null|undefined)$/i.test(value)) return false;
+ const key = value.toLocaleLowerCase().replace(refs ? /\s/g : /\s+/g, refs ? "" : " ");
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+/** Numbered interchange identifiers; opaque provider tokens are not exit badges. */
+export function exitNumberLabel(value: string | undefined): string | undefined {
+ const label = value?.trim();
+ return label && /^\d{1,4}[a-z]?$/i.test(label) ? label : undefined;
}
/**
@@ -32,7 +51,7 @@ export function visibleToward(list: string[], max = 3): string[] {
* this exit's title, so it must never be shown alongside a toward list.
*/
export function signHeadline(sign: ManeuverSign | undefined): string[] {
- if (sign?.exitToward?.length) return sign.exitToward;
- if (sign?.exitNames?.length) return sign.exitNames;
+ if (sign?.exitToward?.length) return cleanSignTexts(sign.exitToward);
+ if (sign?.exitNames?.length) return cleanSignTexts(sign.exitNames);
return [];
}
diff --git a/packages/core/src/navigation/stepGate.ts b/packages/core/src/navigation/stepGate.ts
index 9f7add0ea..c18c03ab3 100644
--- a/packages/core/src/navigation/stepGate.ts
+++ b/packages/core/src/navigation/stepGate.ts
@@ -1,3 +1,6 @@
+import type { Route } from "../types/routing";
+import { prepareManeuverPositions } from "./maneuverPosition";
+
export interface StepGateState {
/** The step committed for display/announcement. Monotonic — never decreases. */
committedStepIndex: number;
@@ -30,13 +33,13 @@ export function advanceStepGate(
const lastIndex = steps.length - 1;
if (lastIndex <= 0) return { committedStepIndex: 0, reachedStepEnd: false };
- // Cumulative end arc-length of each step.
- const ends: number[] = [];
- let acc = 0;
- for (const s of steps) {
- acc += s.distance;
- ends.push(acc);
- }
+ const ends =
+ "geometry" in route
+ ? prepareManeuverPositions(route as Route).ends
+ : steps.reduce((out, step) => {
+ out.push((out.at(-1) ?? 0) + step.distance);
+ return out;
+ }, []);
let committed = Math.min(Math.max(prev.committedStepIndex, 0), lastIndex);
let entered = prev.reachedStepEnd;
diff --git a/packages/core/src/stores/settingsStore.test.ts b/packages/core/src/stores/settingsStore.test.ts
index e78eeb7cc..7a2a015e6 100644
--- a/packages/core/src/stores/settingsStore.test.ts
+++ b/packages/core/src/stores/settingsStore.test.ts
@@ -114,35 +114,6 @@ describe("useSettingsStore", () => {
expect(storage.getString("openmapx:avoidIncidents")).toBe("true");
});
- it("junction view and junction photos default on", () => {
- configureStorage(makeMemoryStorage());
- useSettingsStore.getState().hydrate();
- expect(useSettingsStore.getState().junctionView).toBe(true);
- expect(useSettingsStore.getState().junctionPhotos).toBe(true);
- });
-
- it("setJunctionView(false) persists and hydrates back as disabled", () => {
- const storage = makeMemoryStorage();
- configureStorage(storage);
- useSettingsStore.getState().setJunctionView(false);
- expect(useSettingsStore.getState().junctionView).toBe(false);
- expect(storage.getString("openmapx:junctionView")).toBe("false");
- useSettingsStore.setState({ junctionView: true });
- useSettingsStore.getState().hydrate();
- expect(useSettingsStore.getState().junctionView).toBe(false);
- });
-
- it("setJunctionPhotos(false) persists and hydrates back as disabled", () => {
- const storage = makeMemoryStorage();
- configureStorage(storage);
- useSettingsStore.getState().setJunctionPhotos(false);
- expect(useSettingsStore.getState().junctionPhotos).toBe(false);
- expect(storage.getString("openmapx:junctionPhotos")).toBe("false");
- useSettingsStore.setState({ junctionPhotos: true });
- useSettingsStore.getState().hydrate();
- expect(useSettingsStore.getState().junctionPhotos).toBe(false);
- });
-
it("touch zoom buttons default off", () => {
configureStorage(makeMemoryStorage());
useSettingsStore.getState().hydrate();
diff --git a/packages/core/src/stores/settingsStore.ts b/packages/core/src/stores/settingsStore.ts
index af5ca5b4f..3563f4d2b 100644
--- a/packages/core/src/stores/settingsStore.ts
+++ b/packages/core/src/stores/settingsStore.ts
@@ -14,8 +14,6 @@ const TIME_FORMAT_STORAGE_KEY = "openmapx:timeFormat";
const DATE_FORMAT_STORAGE_KEY = "openmapx:dateFormat";
const VOICE_TIMING_STORAGE_KEY = "openmapx:voiceGuidanceTiming";
const SPEED_CAMERA_ALERTS_STORAGE_KEY = "openmapx:speedCameraAlerts";
-const JUNCTION_VIEW_STORAGE_KEY = "openmapx:junctionView";
-const JUNCTION_PHOTOS_STORAGE_KEY = "openmapx:junctionPhotos";
const AI_SEARCH_STORAGE_KEY = "openmapx:aiSearch";
const SEARCH_HISTORY_ENABLED_STORAGE_KEY = "openmapx:searchHistoryEnabled";
const RECENT_SEARCHES_STORAGE_KEY = "openmapx:recentSearches";
@@ -77,16 +75,6 @@ function readSpeedCameraAlerts(): boolean {
return getStorage().getString(SPEED_CAMERA_ALERTS_STORAGE_KEY) === "true";
}
-// The junction view is a guidance feature, not an opt-in extra: on by default,
-// switchable off by users who find the column too tall. Photos ride on it.
-function readJunctionView(): boolean {
- return getStorage().getString(JUNCTION_VIEW_STORAGE_KEY) !== "false";
-}
-
-function readJunctionPhotos(): boolean {
- return getStorage().getString(JUNCTION_PHOTOS_STORAGE_KEY) !== "false";
-}
-
// Natural-language ("AI") search is on by default; users can opt out for privacy
// or to avoid the inference latency. Stored only when toggled, so an absent value
// means enabled.
@@ -199,12 +187,6 @@ interface SettingsState {
/** Opt-in speed-camera approach alerts (off by default; region-gated downstream). */
speedCameraAlerts: boolean;
setSpeedCameraAlerts: (v: boolean) => void;
- /** Schematic junction view for motorway exits during navigation (on by default). */
- junctionView: boolean;
- setJunctionView: (v: boolean) => void;
- /** Street-level photo preview of the exit approach (on by default; needs junctionView). */
- junctionPhotos: boolean;
- setJunctionPhotos: (v: boolean) => void;
/** Natural-language ("AI") search understanding (on by default; opt-out). */
aiSearchEnabled: boolean;
setAiSearchEnabled: (v: boolean) => void;
@@ -294,16 +276,6 @@ export const useSettingsStore = create((set) => ({
getStorage().setString(SPEED_CAMERA_ALERTS_STORAGE_KEY, String(speedCameraAlerts));
set({ speedCameraAlerts });
},
- junctionView: readJunctionView(),
- setJunctionView: (junctionView) => {
- getStorage().setString(JUNCTION_VIEW_STORAGE_KEY, String(junctionView));
- set({ junctionView });
- },
- junctionPhotos: readJunctionPhotos(),
- setJunctionPhotos: (junctionPhotos) => {
- getStorage().setString(JUNCTION_PHOTOS_STORAGE_KEY, String(junctionPhotos));
- set({ junctionPhotos });
- },
aiSearchEnabled: readAiSearch(),
setAiSearchEnabled: (aiSearchEnabled) => {
getStorage().setString(AI_SEARCH_STORAGE_KEY, String(aiSearchEnabled));
@@ -412,8 +384,6 @@ export const useSettingsStore = create((set) => ({
dateFormat: readDateFormat(),
voiceGuidanceTiming: readVoiceTiming(),
speedCameraAlerts: readSpeedCameraAlerts(),
- junctionView: readJunctionView(),
- junctionPhotos: readJunctionPhotos(),
aiSearchEnabled: readAiSearch(),
searchHistoryEnabled: readSearchHistoryEnabled(),
incidentAlerts: readIncidentAlerts(),
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 755a169a9..4c86a4447 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -94,13 +94,10 @@ export type { I18nToken, MoneyValue, Translatable, WallClockValue } from "./i18n
export type { Identified, Ids } from "./identified";
export { makeId, parseId, withId } from "./identified";
export type {
- GantryModel,
- GantryPanel,
JunctionDecisionPoint,
JunctionKind,
JunctionLookupPoint,
JunctionLookupResult,
- JunctionSchematic,
JunctionWay,
LaneDestinations,
OsmLaneTags,
diff --git a/packages/core/src/types/junction.ts b/packages/core/src/types/junction.ts
index a1a782d3e..b766a1695 100644
--- a/packages/core/src/types/junction.ts
+++ b/packages/core/src/types/junction.ts
@@ -1,3 +1,4 @@
+import type { ResolvedLaneGuidance } from "../navigation/lanes";
import type { LngLat } from "./geometry";
import type { ManeuverSign } from "./routing";
@@ -10,14 +11,20 @@ export interface JunctionDecisionPoint {
kind: JunctionKind;
side: "left" | "right";
point: LngLat;
+ /** Arc-length on route geometry in metres; engine-distance fallback when step coordinates are missing. */
alongMeters: number;
/** Route bearing ~100 m before the point, from geometry (not from the engine). */
approachBearing: number;
/** Signed ramp divergence: bearingAfter − bearingBefore normalised to (−180, 180]; geometry fallback. */
divergenceDeg: number;
laneCount?: number;
- /** 0-based indices of lanes the engine marks valid/active (after resolveRecommendedLanes). */
+ /** Preferred lane indices when supplied, otherwise initially usable indices. */
activeLanes: number[];
+ laneGuidance?: ResolvedLaneGuidance;
+ /** Ordered route samples after this occurrence, used to identify the connected branch. */
+ departureTrace?: LngLat[];
+ /** Ordered route samples on the incoming layout. */
+ approachTrace?: LngLat[];
sign?: ManeuverSign;
}
@@ -53,6 +60,8 @@ export interface JunctionWay {
/** Metres from the way's travel-direction start node to the decision point; a ramp leaving the split starts near 0. */
startDistanceMeters: number;
tags: OsmLaneTags;
+ /** Bounded local shape in legal travel direction. */
+ geometry?: LngLat[];
}
/** One decision point as sent to the junctions endpoint. */
@@ -72,71 +81,30 @@ export interface JunctionLookupPoint {
export interface JunctionLookupResult {
index: number;
- /** Tagged approach ways that can be drawn as a gantry. */
+ /** Approach ways carrying lane geometry or destination/turn tags. */
approach: JunctionWay[];
/** Tagged ramps leaving the split. */
ramps: JunctionWay[];
+ /** Roads whose travel start shares the selected approach's final OSM node. */
+ outgoing?: JunctionWay[];
+ /** Approach whose terminal node defines outgoing topology. */
+ outgoingApproachWayId?: number;
/**
* Whether a motorway or trunk carriageway (not a link) runs through the
* decision point in the route's direction, tagged or not. This is what
* separates an exit from an on-ramp's surface street.
*/
onMotorway: boolean;
- /**
- * Metres before the decision point from which the carriageway carries the
- * lane count it has at the split, i.e. where the exit lanes already exist.
- * Absent when OSM has no `lanes` on the way at the split.
- */
- fullLanesFromMeters?: number;
/**
* OSM could not be reached for this point, so the empty arrays are no
* answer: the caller asks again later rather than treating the junction as
- * having no gantry and no motorway through it.
+ * having no lane evidence and no motorway through it.
*/
unavailable?: true;
}
-export interface GantryPanel {
- /** 0-based lane indices this panel spans (left to right). */
- lanes: number[];
- destinations: string[];
- refs: string[];
- symbols: string[];
- colour?: string;
- /** turn:lanes token when uniform across the panel's lanes. */
- turn?: string;
- exitNumber?: string;
- isExit: boolean;
-}
-
/** Where one lane leads, as its OSM `destination:lanes`/`destination:ref:lanes` name it. */
export interface LaneDestinations {
destinations: string[];
refs: string[];
}
-
-export interface GantryModel {
- laneCount: number;
- panels: GantryPanel[];
- activeLanes: number[];
- /** Raw OSM `turn:lanes` token per lane, left to right, when the way carries them. */
- laneTurns?: string[];
- /** "osm" when built from way tags; "engine" when only the engine sign was available. */
- source: "osm" | "engine";
-}
-
-export interface JunctionSchematic {
- width: number;
- height: number;
- laneCount: number;
- activeLanes: number[];
- side: "left" | "right";
- divergenceDeg: number;
- /** SVG path data for the through carriageway and the ramp, in a 320×140 viewBox. */
- throughPath: string;
- rampPath: string;
- /** One polygon per lane (trapezoids under perspective), left to right. */
- lanePolygons: string[];
- /** Where each panel's label sits (x centre, y) in viewBox units. */
- panelAnchors: Array<{ x: number; y: number }>;
-}
diff --git a/packages/core/src/types/routing.ts b/packages/core/src/types/routing.ts
index 24cc37853..20a3c4fe0 100644
--- a/packages/core/src/types/routing.ts
+++ b/packages/core/src/types/routing.ts
@@ -54,12 +54,14 @@ export interface Waypoint {
export interface ManeuverLane {
/** Allowed turn indications for this lane, e.g. ["straight","slight right"]. */
indications: string[];
- /** Whether this lane is valid for the recommended maneuver. */
- valid: boolean;
+ /** Initially usable for the maneuver. False is explicit rejection; absent is unknown. */
+ valid?: boolean;
+ /** Best lane for following the route without a later lane change, when the engine reports it. */
+ preferred?: boolean;
/**
* The single recommended indication to follow within a valid lane, when the
* engine reports it (OSRM `valid_indication`, Valhalla active/valid lane
- * direction). Lets the UI brighten the exact arrow to take in a multi-arrow
+ * direction). Lets the UI identify the exact arrow to take in a multi-arrow
* lane. Undefined when unknown.
*/
active?: string;
diff --git a/packages/core/src/types/streetLevel.ts b/packages/core/src/types/streetLevel.ts
index 35f99d8fd..90a67a538 100644
--- a/packages/core/src/types/streetLevel.ts
+++ b/packages/core/src/types/streetLevel.ts
@@ -82,7 +82,7 @@ export type StreetLevelCoverage =
maxzoom: number;
};
-/** Query for the provider `/search` surface (navigation photo prefetch). */
+/** Query for the provider `/search` surface. */
export interface StreetLevelSearchQuery {
/** Search centre. */
lngLat: LngLat;
@@ -110,8 +110,7 @@ export interface StreetLevelCapabilities {
coverage: StreetLevelCoverage;
/**
* Whether the provider's terms allow showing its imagery during real-time
- * navigation. The junction photo only searches providers that do; the
- * street-level viewer is unaffected.
+ * navigation. This capability does not affect the street-level viewer.
*/
allowsNavigationUse: boolean;
/** What the provider's `/search` route can filter server-side. */
diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json
index b4f8de9e7..e395c0d54 100644
--- a/packages/i18n/locales/de.json
+++ b/packages/i18n/locales/de.json
@@ -247,9 +247,6 @@
"parkingWalkToDestination": "{minutes, plural, =1 {1 Min.} other {# Min.}} zu Fuß · {dist}",
"exitNumber": "Ausfahrt {number}",
"toward": "Richtung {places}",
- "junctionViewLabel": "Kreuzungsansicht",
- "junctionLaneSummary": "Spur {lane} von {total} nutzen",
- "junctionPhotoDate": "Aufgenommen {date}",
"confirmBoard": "Ich bin eingestiegen",
"confirmAlight": "Ich bin ausgestiegen",
"undoTransitAction": "Rückgängig",
@@ -2621,11 +2618,7 @@
"persistentStorageOn": "Geschützt – der Browser löscht deine heruntergeladenen Kartenpakete und die Offline-App nicht zwischen Sitzungen.",
"persistentStorageOff": "Nicht geschützt – bei wenig Speicher kann der Browser zwischengespeicherte Kacheln, heruntergeladene Kartenpakete und die Offline-App löschen. Dann lässt sich die App offline nicht mehr öffnen.",
"enablePersistentStorage": "Offline-Speicher schützen",
- "back": "Zurück zur Karte",
- "junctionView": "Kreuzungsansicht",
- "junctionViewHint": "Zeigt während der Navigation eine schematische Ansicht von Autobahnabfahrten und Gabelungen.",
- "junctionPhotos": "Abfahrtsfotos",
- "junctionPhotosHint": "Lädt vorab ein Street-Level-Foto der Ausfahrtszufahrt, mit Urheber und Lizenz."
+ "back": "Zurück zur Karte"
},
"pwa": {
"updateAvailable": "Eine neue Version ist verfügbar",
diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json
index b435db334..ca49b3af9 100644
--- a/packages/i18n/locales/en.json
+++ b/packages/i18n/locales/en.json
@@ -247,9 +247,6 @@
"parkingWalkToDestination": "{minutes, plural, =1 {1 min} other {# min}} walk · {dist}",
"exitNumber": "Exit {number}",
"toward": "toward {places}",
- "junctionViewLabel": "Junction view",
- "junctionLaneSummary": "Use lane {lane} of {total}",
- "junctionPhotoDate": "Captured {date}",
"confirmBoard": "I'm on board",
"confirmAlight": "I've got off",
"undoTransitAction": "Undo",
@@ -2621,11 +2618,7 @@
"persistentStorageOn": "Protected — the browser won't evict your downloaded map packages or the offline app between sessions.",
"persistentStorageOff": "Not protected — the browser may delete cached tiles, downloaded map packages, and the offline app when storage runs low. This can stop the app from opening offline.",
"enablePersistentStorage": "Protect offline storage",
- "back": "Back to map",
- "junctionView": "Junction view",
- "junctionViewHint": "Show a schematic of motorway exits and forks while navigating.",
- "junctionPhotos": "Junction photos",
- "junctionPhotosHint": "Prefetch a street-level photo of the exit approach, with its author and licence."
+ "back": "Back to map"
},
"pwa": {
"updateAvailable": "A new version is available",
diff --git a/release-notes/retire-junction-previews.md b/release-notes/retire-junction-previews.md
new file mode 100644
index 000000000..c6ac455bc
--- /dev/null
+++ b/release-notes/retire-junction-previews.md
@@ -0,0 +1,17 @@
+---
+impacts: [breaking]
+---
+
+## What changes
+
+Remove the experimental junction destination boards, road schematics and automatic street-level photo guidance, including their acquisition pipeline and settings. Maneuver instructions, route shields, exit labels and lane arrows remain in the green navigation panel. The ordinary street-level imagery viewer remains available.
+
+Keep geometry-based maneuver timing, initially usable versus preferred lane evidence, conservative OSM lane enrichment, reroute handling and attribution.
+
+## Who is affected
+
+Users navigating with the additional junction previews enabled will see the maneuver panel and map without the extra junction card.
+
+## Required action
+
+No manual action or data migration is required. The removed preview preferences cease to have an effect. Follow the remaining maneuver instructions and lane arrows; ordinary imagery browsing is unaffected.
diff --git a/scripts/check-routing-canaries.ts b/scripts/check-routing-canaries.ts
index 8325bf83b..8cde85615 100644
--- a/scripts/check-routing-canaries.ts
+++ b/scripts/check-routing-canaries.ts
@@ -13,6 +13,7 @@
* pnpm check-routing-canaries
*
* Override ROUTING_BASE_URL to probe another deployment.
+ * Endpoints are public railway stations selected as synthetic controls.
*/
export interface RoutingCanaryRoute {
@@ -34,14 +35,14 @@ export interface RoutingCanary {
export const ROUTING_CANARIES: readonly RoutingCanary[] = [
{
- name: "Aachen → Orsoy (short alternate control)",
- waypoints: "6.0839,50.7753;6.6833,51.5167",
+ name: "Regional alternate control",
+ waypoints: "13.3694,52.5251;13.0666,52.3914",
minimumRoutes: 2,
requireBaseline: true,
},
{
- name: "Aachen → Berlin (long-route regression control)",
- waypoints: "6.0839,50.7753;13.4050,52.5200",
+ name: "Intercity long-route regression control",
+ waypoints: "13.3694,52.5251;10.0067,53.5528",
minimumRoutes: 1,
requireBaseline: true,
},