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, },