diff --git a/.changeset/junction-lane-geometry.md b/.changeset/junction-lane-geometry.md new file mode 100644 index 000000000..d0832594d --- /dev/null +++ b/.changeset/junction-lane-geometry.md @@ -0,0 +1,10 @@ +--- +"@openmapx/core": patch +"@openmapx/i18n": patch +"@openmapx/integration-routing": patch +"web": patch +--- + +Preserve junction lane geometry without per-lane destination tags, withhold unknown +or conflicting lane recommendations, and show connected outgoing roads with +separate destination boards and known lane counts, including motorway continuations. diff --git a/apps/web/src/components/navigation/junction/GantryStrip.test.tsx b/apps/web/src/components/navigation/junction/GantryStrip.test.tsx index 4b74486ea..c0c5bc1c0 100644 --- a/apps/web/src/components/navigation/junction/GantryStrip.test.tsx +++ b/apps/web/src/components/navigation/junction/GantryStrip.test.tsx @@ -4,6 +4,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { GantryStrip } from "./GantryStrip"; +vi.mock("next-intl", () => ({ + useTranslations: () => (_key: string, values: { total: number }) => `${values.total} lanes`, +})); + const threePanels: GantryModel = { laneCount: 5, panels: [ @@ -73,3 +77,36 @@ describe("GantryStrip", () => { } }); }); + +it("keeps same-ref branch boards distinct and displays known counts", () => { + const model: GantryModel = { + laneCount: 4, + activeLanes: [], + source: "osm", + panels: [1, 2].map((wayId) => ({ + branchWayId: wayId, + lanes: [], + refs: ["A 46"], + destinations: [wayId === 1 ? "Neuss" : "Heinsberg"], + symbols: [], + isExit: wayId === 1, + })), + branches: [1, 2].map((wayId) => ({ + wayId, + bearing: 350 + wayId, + laneCount: wayId === 1 ? 2 : undefined, + selected: wayId === 1, + })), + }; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { container } = render(); + expect(container.querySelectorAll("[data-panel]")).toHaveLength(2); + expect(container.querySelectorAll('[data-active="true"]')).toHaveLength(1); + expect(container.textContent).toContain("2 lanes"); + expect(container.textContent).not.toContain("undefined lanes"); + 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 index a005790da..6af1184f8 100644 --- a/apps/web/src/components/navigation/junction/GantryStrip.tsx +++ b/apps/web/src/components/navigation/junction/GantryStrip.tsx @@ -3,6 +3,7 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import type { GantryModel } from "@openmapx/core"; +import { useTranslations } from "next-intl"; interface Props { model: GantryModel; @@ -55,7 +56,11 @@ function GantryPanel({ panel: Panel; size: "banner" | "compact"; }) { - const active = panel.lanes.some((lane) => model.activeLanes.includes(lane)); + const t = useTranslations("navigation"); + const branch = model.branches?.find((entry) => entry.wayId === panel.branchWayId); + const active = branch + ? branch.selected + : 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 ( @@ -110,6 +115,11 @@ function GantryPanel({ {glyphs.join(" ")} )} + {branch?.laneCount !== undefined && ( + + {t("junctionLaneCount", { total: branch.laneCount })} + + )} ); } @@ -120,7 +130,11 @@ export function GantryStrip({ model, size = "banner" }: Props) { {model.panels.map((panel) => ( ))} - + {schematic.rampPath && } + {schematic.branches?.map((branch) => ( + + 0 ? "#3a3d42" : "#565b63"} + stroke="#2b2e33" + strokeWidth={1} + /> + {branch.lanePolygons.map((path, index) => ( + + ))} + + ))} ); } diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx index 81f216649..d86a35c8f 100644 --- a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx +++ b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx @@ -5,7 +5,11 @@ vi.mock("next-intl", () => ({ useTranslations: () => (key: string, values?: Record) => { if (key === "junctionLaneSummary") return `Use lane ${String(values?.lane)} of ${String(values?.total)}`; + if (key === "junctionLanesSummary") + return `Use lanes ${String(values?.lanes)} of ${String(values?.total)}`; + if (key === "junctionLaneCount") return `${String(values?.total)} lanes`; if (key === "junctionViewLabel") return "Junction view"; + if (key === "junctionBranchesSummary") return `Branches: ${String(values?.branches)}`; if (key === "toward") return `toward ${String(values?.places)}`; if (key === "junctionPhotoCaption") return `© ${String(values?.author)} · ${String(values?.license)} · ${String(values?.date)}`; @@ -14,8 +18,16 @@ vi.mock("next-intl", () => ({ useLocale: () => "en", })); -import type { GantryModel, Route } from "@openmapx/core"; -import { buildJunctionSchematic, findJunctionDecisionPoints } from "@openmapx/core"; +import type { GantryModel, JunctionLookupResult, Route } from "@openmapx/core"; +import { + buildJunctionSchematic, + findJunctionCandidates, + findJunctionDecisionPoints, + mergeExitPanel, + parseLaneTags, + selectApproachWay, +} from "@openmapx/core"; +import holz from "../../../../../../packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json"; import fixture from "../../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json"; import { GantryStrip } from "./GantryStrip"; import { JunctionSchematicView } from "./JunctionSchematic"; @@ -102,6 +114,47 @@ describe("JunctionViewPanel", () => { expect(html).toContain("20"); }); + it("keeps the exit sign without a fabricated schematic when the lane count is unknown", () => { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const html = renderToStaticMarkup(); + expect(html).toContain("Düsseldorf"); + expect(html).toContain("Neuss"); + expect(html).not.toContain('data-testid="junction-schematic"'); + expect(html).not.toContain("Use lane"); + }); + + it("draws Kreuz Holz's four lanes without highlighting an unresolved exit side", () => { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const result = holz.lookup as JunctionLookupResult; + const approach = selectApproachWay(result.approach, point)!; + const parsed = parseLaneTags(approach.tags, point.laneCount); + expect(parsed).not.toBeNull(); + const model = mergeExitPanel(parsed!, point, result.ramps[0].tags); + const html = renderToStaticMarkup(); + expect(html.match(/data-lane/g)).toHaveLength(4); + expect(html).toContain("Düsseldorf"); + expect(html).not.toContain('data-active="true"'); + expect(html).not.toContain("data-ramp"); + expect(html).not.toContain("Use lane"); + expect(html).toContain('aria-label="4 lanes, toward Düsseldorf, Neuss"'); + }); + + it("announces all confirmed lanes of a multi-lane exit", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Use lanes 4, 5 of 5, toward Neuss-Zentrum"'); + }); + + it("does not claim a lane recommendation just because the count is known", () => { + const html = renderToStaticMarkup( + , + ); + expect(html.match(/data-lane/g)).toHaveLength(5); + expect(html).not.toContain("Use lane"); + expect(html).not.toContain("data-ramp"); + }); + it("renders nothing when the point has no sign and no engine lanes", () => { const bare = { ...a57Point, sign: undefined, laneCount: undefined, activeLanes: [] }; expect(renderToStaticMarkup()).toBe(""); @@ -150,3 +203,35 @@ describe("JunctionViewPanel photo integration", () => { expect(html).not.toContain(" { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const parsed = parseLaneTags(holz.lookup.approach[0].tags)!; + const model = mergeExitPanel(parsed, point, holz.lookup.ramps[0].tags, { + ways: holz.lookup.outgoing, + }); + const host = document.createElement("div"); + host.innerHTML = renderToStaticMarkup(); + expect(host.querySelectorAll("[data-panel]")).toHaveLength(2); + expect(host.textContent).toContain("Heinsberg"); + expect(host.textContent).toContain("Venlo"); + expect(host.textContent).toContain("Mönchengladbach"); + expect(host.querySelectorAll("[data-branch-road]")).toHaveLength(2); + expect(host.querySelectorAll("[data-branch-lane]")).toHaveLength(4); + expect(host.querySelectorAll('[data-lane][data-active="true"]')).toHaveLength(0); + expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toContain("Heinsberg"); + expect(host.innerHTML).not.toContain("Use lane"); +}); + +it("announces outgoing destinations when per-lane boards remain in use", () => { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const model = mergeExitPanel( + parseLaneTags({ lanes: 4, destinationLanes: "X|X|Y|Y" })!, + point, + undefined, + { ways: holz.lookup.outgoing }, + ); + const html = renderToStaticMarkup(); + expect(html.match(/aria-label="([^"]+)"/)?.[1]).toContain("Heinsberg"); + expect(html.match(/aria-label="([^"]+)"/)?.[1]).toContain("2 lanes"); +}); diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx index d74b877c1..65f41b01b 100644 --- a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx +++ b/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx @@ -41,9 +41,7 @@ function engineModel(point: JunctionDecisionPoint): GantryModel | null { headline.length || exitNumber ? [ { - lanes: point.activeLanes.length - ? point.activeLanes - : [point.side === "left" ? 0 : Math.max(laneCount, 1) - 1], + lanes: point.activeLanes, destinations: headline, refs: visibleToward(point.sign?.exitBranches ?? []), symbols: [], @@ -53,7 +51,7 @@ function engineModel(point: JunctionDecisionPoint): GantryModel | null { ] : []; return { - laneCount: Math.max(laneCount, 1), + laneCount, panels, activeLanes: point.activeLanes, source: "engine", @@ -70,11 +68,35 @@ export function JunctionViewPanel({ point, gantry, photo, geometry }: Props) { const t = useTranslations("navigation"); const model = gantry ?? engineModel(point); if (!model) return null; - const lane = (model.activeLanes[0] ?? model.laneCount - 1) + 1; const toward = visibleToward(signHeadline(point.sign)); + const laneSummary = + model.activeLanes.length === 1 + ? t("junctionLaneSummary", { lane: model.activeLanes[0] + 1, total: model.laneCount }) + : model.activeLanes.length > 1 + ? t("junctionLanesSummary", { + lanes: model.activeLanes.map((lane) => lane + 1).join(", "), + total: model.laneCount, + }) + : model.laneCount > 0 + ? t("junctionLaneCount", { total: model.laneCount }) + : t("junctionViewLabel"); + const branchSummary = model.branches + ?.flatMap((branch) => { + const panel = model.panels.find((entry) => entry.branchWayId === branch.wayId); + const places = [ + ...(branch.refs ?? panel?.refs ?? []), + ...(branch.destinations ?? panel?.destinations ?? []), + ].join(", "); + const count = + branch.laneCount !== undefined ? t("junctionLaneCount", { total: branch.laneCount }) : ""; + const summary = [places, count].filter(Boolean).join(", "); + return summary ? [summary] : []; + }) + .join("; "); const label = [ - t("junctionLaneSummary", { lane, total: model.laneCount }), + laneSummary, toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null, + branchSummary ? t("junctionBranchesSummary", { branches: branchSummary }) : null, ] .filter(Boolean) .join(", "); @@ -102,9 +124,9 @@ export function JunctionViewPanel({ point, gantry, photo, geometry }: Props) { geometry={geometry} exitLanes={{ laneCount: model.laneCount, activeLanes: model.activeLanes }} /> - ) : ( + ) : model.laneCount > 0 ? ( - )} + ) : null} ); } diff --git a/apps/web/src/lib/navigation/useNavJunctions.test.tsx b/apps/web/src/lib/navigation/useNavJunctions.test.tsx index 35e8ebd9a..763af37a2 100644 --- a/apps/web/src/lib/navigation/useNavJunctions.test.tsx +++ b/apps/web/src/lib/navigation/useNavJunctions.test.tsx @@ -33,6 +33,9 @@ const fixture = await import( ); 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 = [ { @@ -146,6 +149,59 @@ describe("useNavJunctions gantry fetch", () => { useNavJunctionStore.getState().reset(); }); + it("uses the graph approach even when a different approach matches the engine count", async () => { + const graph = holz.lookup.approach[0]; + 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(() => useNavJunctions()); + await waitFor(() => expect(useNavJunctionStore.getState().gantryByStep.get(1)).toBeDefined()); + const model = useNavJunctionStore.getState().gantryByStep.get(1)!; + expect(model.laneCount).toBe(4); + expect(model.branches).toHaveLength(2); + }); + + it("stores lane geometry from the turn-only Kreuz Holz approach", async () => { + fetchJunctionLookups.mockResolvedValue([holz.lookup]); + start(); + useNavigationStore.setState({ route: holz.route as unknown as Route }); + renderHook(() => useNavJunctions()); + await waitFor(() => + expect(useNavJunctionStore.getState().gantryByStep.get(1)?.laneCount).toBe(4), + ); + const model = useNavJunctionStore.getState().gantryByStep.get(1)!; + expect(model.activeLanes).toEqual([]); + expect(model.panels.map((panel) => panel.destinations)).toEqual([ + ["Düsseldorf", "Neuss"], + ["Heinsberg", "Venlo", "Mönchengladbach"], + ]); + expect(model.branches?.map((branch) => branch.laneCount)).toEqual([2, 2]); + }); + it("promotes a candidate exit once OpenStreetMap puts the route on the motorway there", async () => { fetchJunctionLookups.mockResolvedValue(fixtureResponse); start(); diff --git a/apps/web/src/lib/navigation/useNavJunctions.ts b/apps/web/src/lib/navigation/useNavJunctions.ts index 13d276dd4..335020733 100644 --- a/apps/web/src/lib/navigation/useNavJunctions.ts +++ b/apps/web/src/lib/navigation/useNavJunctions.ts @@ -444,10 +444,15 @@ function buildGantry( result: JunctionLookupResult, point: JunctionDecisionPoint, ): GantryModel | null { - const approach = selectApproachWay(result.approach, point); + const approach = + result.outgoingApproachWayId !== undefined + ? result.approach.find((way) => way.wayId === result.outgoingApproachWayId) + : 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); + return mergeExitPanel(model, point, rampWay?.tags, { + ways: result.outgoing ?? [], + }); } diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 0600ce4e0..e13c7506e 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -322,16 +322,27 @@ Both revert automatically after the maneuver, and a pinch or drag during the approach is never interrupted. For motorway and expressway exits, forks, and splits while driving, a **junction view** card -appears under the banner. It shows the lanes of the overhead gantry with the -correct one highlighted and the ramp peeling off at the split angle. The schematic -is drawn on the client from open data: with network access it shows the real -per-lane destinations OpenStreetMap carries on the approach segment -(`destination:*:lanes`, fetched once per route through Overpass — set -`OVERPASS_URL` to a self-hosted instance for multi-user deployments), and offline -it falls back to the engine's exit sign. The lane to take comes from the routing -engine when it reports lanes (a self-hosted Valhalla does); otherwise it is read -from OpenStreetMap's per-lane turn arrows (`turn:lanes`), and only when neither -says does the outermost lane on the exit's side stand in. About half a minute +appears under the banner. Its schematic shows the lane layout at the upcoming +split, highlighting the recommended lanes when their assignment is known. +Connected outgoing roads are also shown with their own destination boards and +known lane counts, including the continuing motorway at a fork. These symbolic +roads meet a neutral junction area, with lane markings stopping on either side +so incoming lanes are not assigned to either branch. OpenStreetMap's approach +lane count and turn arrows remain useful even without per-lane destination names. With network +access, the card also shows the real per-lane destinations where OpenStreetMap +carries them (`destination:*:lanes`, fetched once per route through Overpass — +set `OVERPASS_URL` to a self-hosted instance for multi-user deployments). +Offline it falls back to the engine's exit sign and any lane data it supplies. + +Lane recommendations come from the routing engine when its lane count matches +the displayed layout; otherwise OpenStreetMap's per-lane turn arrows +(`turn:lanes`) must agree with the maneuver's exit side. If the lane assignment +is missing or conflicting, the known lane bands stay unhighlighted and the +schematic only draws branches confirmed by shared OpenStreetMap nodes. The +routed branch is highlighted only when its destination or reference distinguishes +it; missing tags preserve the engine sign. If the approach lane count is unknown, +only the sign is shown. +About half a minute before the exit, a photo preview shows a recent **street-level image** of the approach — with its author, licence, and capture date — when coverage from [Panoramax](./street-level-imagery.md) exists (Mapillary is never used here: its terms rule out use during navigation). Both the junction view and its photo preview are on by default and can be diff --git a/integrations/routing/__tests__/junctions.test.ts b/integrations/routing/__tests__/junctions.test.ts index 2e13c8905..53965c0c1 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); }); @@ -211,6 +212,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. @@ -408,3 +438,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/junctions.ts b/integrations/routing/junctions.ts index 2ce0d640e..446f301cf 100644 --- a/integrations/routing/junctions.ts +++ b/integrations/routing/junctions.ts @@ -85,7 +85,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:v3", [ round(point.lng, 4), round(point.lat, 4), round(point.bearing / 10, 0) * 10, @@ -107,21 +107,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"] @@ -227,7 +233,14 @@ export function mapJunctionWays( const tagged = Object.keys(element.tags ?? {}).some((key) => GANTRY_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, nodes, nodeIds, pointDistance: distanceToPolyline(centre, nodes) }]; }); const carriageways = matched.filter( ({ way, pointDistance }) => @@ -237,7 +250,12 @@ export function mapJunctionWays( ); const onMotorway = carriageways.length > 0; const fullLanesFromMeters = fullLanesFrom(matched, carriageways, point); - const drawable = matched.filter((entry) => entry.tagged).map((entry) => entry.way); + const drawable = matched + .filter( + ({ way, tagged }) => + tagged || (!way.highway.includes("_link") && way.tags.lanes !== undefined), + ) + .map((entry) => entry.way); const approach = drawable.filter( (way) => !way.highway.includes("_link") && @@ -246,10 +264,23 @@ export function mapJunctionWays( way.endDistanceMeters <= APPROACH_MAX_UPSTREAM_METERS, ); const ramps = drawable.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, + ...(outgoing.length > 0 ? { outgoing, outgoingApproachWayId: nearestApproach?.wayId } : {}), ...(fullLanesFromMeters !== undefined ? { fullLanesFromMeters } : {}), }; } 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..a0b4f95a5 --- /dev/null +++ b/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json @@ -0,0 +1,3301 @@ +{ + "manifest": { + "capturedAt": "2026-10-10", + "sources": { + "directions": "https://openmapx.com/api/integrations/routing/directions?originLng=6.47&originLat=51.06&destLng=6.69&destLat=51.20&mode=driving&provider=routing-osrm", + "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 original steps 12 and 13, retaining their distances, maneuvers, signs and geometry; step indices now start at 0.", + "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": 9097.3, + "duration": 293.4, + "mode": "driving", + "geometry": [ + [ + 6.477305, + 51.033644 + ], + [ + 6.478686, + 51.033896 + ], + [ + 6.479807, + 51.034116 + ], + [ + 6.480682, + 51.034289 + ], + [ + 6.481449, + 51.034446 + ], + [ + 6.482143, + 51.034597 + ], + [ + 6.483834, + 51.03501 + ], + [ + 6.48397, + 51.035044 + ], + [ + 6.484717, + 51.035257 + ], + [ + 6.485836, + 51.035581 + ], + [ + 6.486446, + 51.035791 + ], + [ + 6.487045, + 51.035998 + ], + [ + 6.487601, + 51.036214 + ], + [ + 6.48858, + 51.036611 + ], + [ + 6.489338, + 51.036944 + ], + [ + 6.490671, + 51.037593 + ], + [ + 6.491541, + 51.038065 + ], + [ + 6.492671, + 51.038728 + ], + [ + 6.493671, + 51.039399 + ], + [ + 6.494294, + 51.039848 + ], + [ + 6.494999, + 51.040395 + ], + [ + 6.496033, + 51.041293 + ], + [ + 6.496877, + 51.042141 + ], + [ + 6.49708, + 51.04236 + ], + [ + 6.497278, + 51.042581 + ], + [ + 6.497487, + 51.042822 + ], + [ + 6.497721, + 51.043103 + ], + [ + 6.497821, + 51.043225 + ], + [ + 6.498038, + 51.043503 + ], + [ + 6.498245, + 51.043784 + ], + [ + 6.498357, + 51.043938 + ], + [ + 6.498564, + 51.044237 + ], + [ + 6.498748, + 51.044518 + ], + [ + 6.498853, + 51.044684 + ], + [ + 6.499041, + 51.044995 + ], + [ + 6.499219, + 51.045304 + ], + [ + 6.4993, + 51.045456 + ], + [ + 6.499453, + 51.045748 + ], + [ + 6.499531, + 51.045905 + ], + [ + 6.499604, + 51.046057 + ], + [ + 6.49975, + 51.046372 + ], + [ + 6.499817, + 51.046528 + ], + [ + 6.499885, + 51.046689 + ], + [ + 6.499948, + 51.046843 + ], + [ + 6.500003, + 51.046988 + ], + [ + 6.500064, + 51.047151 + ], + [ + 6.500119, + 51.047309 + ], + [ + 6.500174, + 51.04747 + ], + [ + 6.500229, + 51.047637 + ], + [ + 6.500277, + 51.047793 + ], + [ + 6.500368, + 51.048111 + ], + [ + 6.50041, + 51.048279 + ], + [ + 6.500487, + 51.048596 + ], + [ + 6.500556, + 51.048918 + ], + [ + 6.500586, + 51.049076 + ], + [ + 6.500613, + 51.049236 + ], + [ + 6.500642, + 51.049411 + ], + [ + 6.500663, + 51.049563 + ], + [ + 6.500683, + 51.049727 + ], + [ + 6.500717, + 51.05005 + ], + [ + 6.500743, + 51.050372 + ], + [ + 6.500752, + 51.05053 + ], + [ + 6.500761, + 51.050852 + ], + [ + 6.500763, + 51.051031 + ], + [ + 6.500758, + 51.051344 + ], + [ + 6.500746, + 51.051671 + ], + [ + 6.500735, + 51.05184 + ], + [ + 6.500707, + 51.052154 + ], + [ + 6.500686, + 51.052344 + ], + [ + 6.500648, + 51.052636 + ], + [ + 6.500622, + 51.052801 + ], + [ + 6.500568, + 51.053118 + ], + [ + 6.500499, + 51.053446 + ], + [ + 6.500465, + 51.053605 + ], + [ + 6.500384, + 51.053918 + ], + [ + 6.500294, + 51.054236 + ], + [ + 6.500195, + 51.054559 + ], + [ + 6.500086, + 51.054877 + ], + [ + 6.499968, + 51.055191 + ], + [ + 6.499841, + 51.055508 + ], + [ + 6.499705, + 51.055825 + ], + [ + 6.499562, + 51.056133 + ], + [ + 6.499412, + 51.056445 + ], + [ + 6.499332, + 51.056602 + ], + [ + 6.499168, + 51.05691 + ], + [ + 6.499084, + 51.057063 + ], + [ + 6.498912, + 51.057365 + ], + [ + 6.498732, + 51.057673 + ], + [ + 6.498449, + 51.058126 + ], + [ + 6.498254, + 51.058424 + ], + [ + 6.498054, + 51.058725 + ], + [ + 6.497849, + 51.059021 + ], + [ + 6.497637, + 51.059316 + ], + [ + 6.497317, + 51.059751 + ], + [ + 6.497088, + 51.06005 + ], + [ + 6.496859, + 51.060344 + ], + [ + 6.496631, + 51.060635 + ], + [ + 6.496397, + 51.060924 + ], + [ + 6.496165, + 51.061207 + ], + [ + 6.495924, + 51.061495 + ], + [ + 6.495562, + 51.061924 + ], + [ + 6.495319, + 51.062207 + ], + [ + 6.495072, + 51.062495 + ], + [ + 6.494822, + 51.062777 + ], + [ + 6.49457, + 51.063063 + ], + [ + 6.494192, + 51.06349 + ], + [ + 6.493817, + 51.063909 + ], + [ + 6.493181, + 51.064618 + ], + [ + 6.492795, + 51.065047 + ], + [ + 6.492418, + 51.065469 + ], + [ + 6.492165, + 51.065751 + ], + [ + 6.491908, + 51.066037 + ], + [ + 6.491656, + 51.066318 + ], + [ + 6.491273, + 51.066744 + ], + [ + 6.491018, + 51.067028 + ], + [ + 6.49076, + 51.067316 + ], + [ + 6.49025, + 51.067882 + ], + [ + 6.490124, + 51.068023 + ], + [ + 6.489996, + 51.068168 + ], + [ + 6.489741, + 51.068452 + ], + [ + 6.489361, + 51.068875 + ], + [ + 6.488851, + 51.069444 + ], + [ + 6.488724, + 51.069584 + ], + [ + 6.488599, + 51.069726 + ], + [ + 6.488474, + 51.069864 + ], + [ + 6.488346, + 51.070008 + ], + [ + 6.488218, + 51.070151 + ], + [ + 6.488091, + 51.070291 + ], + [ + 6.487964, + 51.070432 + ], + [ + 6.487833, + 51.070578 + ], + [ + 6.487706, + 51.070719 + ], + [ + 6.487577, + 51.070862 + ], + [ + 6.487457, + 51.070997 + ], + [ + 6.487322, + 51.071147 + ], + [ + 6.487194, + 51.07129 + ], + [ + 6.486937, + 51.071575 + ], + [ + 6.485795, + 51.072849 + ], + [ + 6.485414, + 51.073274 + ], + [ + 6.485288, + 51.073412 + ], + [ + 6.485159, + 51.073562 + ], + [ + 6.485034, + 51.0737 + ], + [ + 6.482743, + 51.076247 + ], + [ + 6.480577, + 51.078662 + ], + [ + 6.480061, + 51.079237 + ], + [ + 6.479934, + 51.079379 + ], + [ + 6.479807, + 51.07952 + ], + [ + 6.479685, + 51.079657 + ], + [ + 6.479561, + 51.079796 + ], + [ + 6.479296, + 51.080089 + ], + [ + 6.479168, + 51.080232 + ], + [ + 6.479047, + 51.080368 + ], + [ + 6.478911, + 51.080517 + ], + [ + 6.478788, + 51.080651 + ], + [ + 6.478646, + 51.08081 + ], + [ + 6.478505, + 51.080968 + ], + [ + 6.478402, + 51.081084 + ], + [ + 6.478233, + 51.081273 + ], + [ + 6.47814, + 51.08138 + ], + [ + 6.478041, + 51.081492 + ], + [ + 6.477961, + 51.081584 + ], + [ + 6.477889, + 51.081667 + ], + [ + 6.477818, + 51.081749 + ], + [ + 6.477749, + 51.081829 + ], + [ + 6.477681, + 51.08191 + ], + [ + 6.477571, + 51.082046 + ], + [ + 6.477493, + 51.08214 + ], + [ + 6.477413, + 51.082239 + ], + [ + 6.477315, + 51.08236 + ], + [ + 6.477249, + 51.082443 + ], + [ + 6.47719, + 51.082522 + ], + [ + 6.477044, + 51.082711 + ], + [ + 6.476973, + 51.082806 + ], + [ + 6.476906, + 51.082898 + ], + [ + 6.47681, + 51.083035 + ], + [ + 6.4767, + 51.0832 + ], + [ + 6.476627, + 51.083308 + ], + [ + 6.476555, + 51.083422 + ], + [ + 6.476464, + 51.08357 + ], + [ + 6.476373, + 51.083727 + ], + [ + 6.476318, + 51.083825 + ], + [ + 6.476265, + 51.083922 + ], + [ + 6.47621, + 51.084033 + ], + [ + 6.476159, + 51.08414 + ], + [ + 6.476107, + 51.084248 + ], + [ + 6.476059, + 51.084355 + ], + [ + 6.476013, + 51.084461 + ], + [ + 6.475969, + 51.08457 + ], + [ + 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 + ], + [ + 6.475615, + 51.085936 + ], + [ + 6.475603, + 51.086048 + ], + [ + 6.475593, + 51.086168 + ], + [ + 6.475587, + 51.086273 + ], + [ + 6.475586, + 51.086429 + ], + [ + 6.475588, + 51.086571 + ], + [ + 6.475594, + 51.086743 + ], + [ + 6.475608, + 51.086922 + ], + [ + 6.475627, + 51.087095 + ], + [ + 6.475648, + 51.087244 + ], + [ + 6.475673, + 51.087384 + ], + [ + 6.475705, + 51.087543 + ], + [ + 6.47574, + 51.087699 + ], + [ + 6.475783, + 51.087862 + ], + [ + 6.475831, + 51.08802 + ], + [ + 6.475882, + 51.088179 + ], + [ + 6.475936, + 51.088329 + ], + [ + 6.476001, + 51.088497 + ], + [ + 6.47607, + 51.088657 + ], + [ + 6.476137, + 51.088808 + ], + [ + 6.476213, + 51.088963 + ], + [ + 6.476294, + 51.089121 + ], + [ + 6.476385, + 51.089285 + ], + [ + 6.476478, + 51.089439 + ], + [ + 6.476569, + 51.089578 + ], + [ + 6.476671, + 51.089731 + ], + [ + 6.476773, + 51.089877 + ], + [ + 6.476838, + 51.089964 + ], + [ + 6.476903, + 51.090048 + ], + [ + 6.47699, + 51.090159 + ], + [ + 6.477077, + 51.090264 + ], + [ + 6.477161, + 51.090362 + ], + [ + 6.477246, + 51.090455 + ], + [ + 6.47741, + 51.09063 + ], + [ + 6.477466, + 51.09069 + ], + [ + 6.477524, + 51.09075 + ], + [ + 6.477588, + 51.090814 + ], + [ + 6.477652, + 51.090875 + ], + [ + 6.47782, + 51.091029 + ], + [ + 6.477957, + 51.091152 + ], + [ + 6.478099, + 51.091272 + ], + [ + 6.478269, + 51.091411 + ], + [ + 6.478362, + 51.091485 + ], + [ + 6.478455, + 51.091556 + ], + [ + 6.478603, + 51.091667 + ], + [ + 6.478751, + 51.091775 + ], + [ + 6.47889, + 51.091869 + ], + [ + 6.478992, + 51.091939 + ], + [ + 6.479094, + 51.092008 + ], + [ + 6.479289, + 51.092133 + ], + [ + 6.479391, + 51.092197 + ], + [ + 6.479491, + 51.092257 + ], + [ + 6.479692, + 51.092375 + ], + [ + 6.47986, + 51.092469 + ], + [ + 6.480025, + 51.092559 + ], + [ + 6.480143, + 51.092622 + ], + [ + 6.48026, + 51.092683 + ], + [ + 6.48041, + 51.092758 + ], + [ + 6.480569, + 51.092836 + ], + [ + 6.480889, + 51.092982 + ], + [ + 6.481065, + 51.093061 + ], + [ + 6.481246, + 51.093138 + ], + [ + 6.481387, + 51.093196 + ], + [ + 6.481528, + 51.093252 + ], + [ + 6.481714, + 51.093326 + ], + [ + 6.4819, + 51.093395 + ], + [ + 6.482117, + 51.093474 + ], + [ + 6.482327, + 51.093548 + ], + [ + 6.482602, + 51.093641 + ], + [ + 6.482734, + 51.093685 + ], + [ + 6.482866, + 51.093727 + ], + [ + 6.48325, + 51.093845 + ], + [ + 6.483651, + 51.093963 + ], + [ + 6.483901, + 51.094033 + ], + [ + 6.484149, + 51.094101 + ], + [ + 6.484404, + 51.09417 + ], + [ + 6.484663, + 51.094238 + ], + [ + 6.484929, + 51.094306 + ], + [ + 6.485201, + 51.094376 + ], + [ + 6.485471, + 51.094444 + ], + [ + 6.485746, + 51.094511 + ], + [ + 6.486272, + 51.094637 + ], + [ + 6.486814, + 51.094763 + ], + [ + 6.487186, + 51.094848 + ], + [ + 6.487566, + 51.094932 + ], + [ + 6.487952, + 51.095015 + ], + [ + 6.488337, + 51.095097 + ], + [ + 6.488636, + 51.095159 + ], + [ + 6.488938, + 51.09522 + ], + [ + 6.489559, + 51.095343 + ], + [ + 6.490175, + 51.095459 + ], + [ + 6.490483, + 51.095516 + ], + [ + 6.490797, + 51.095573 + ], + [ + 6.491249, + 51.095653 + ], + [ + 6.491719, + 51.095733 + ], + [ + 6.49216, + 51.095805 + ], + [ + 6.492606, + 51.095877 + ], + [ + 6.492967, + 51.095932 + ], + [ + 6.493336, + 51.095986 + ], + [ + 6.493702, + 51.096039 + ], + [ + 6.494077, + 51.096092 + ], + [ + 6.494502, + 51.096151 + ], + [ + 6.494699, + 51.096177 + ], + [ + 6.494909, + 51.096204 + ], + [ + 6.495119, + 51.096231 + ], + [ + 6.495336, + 51.096257 + ], + [ + 6.496037, + 51.096342 + ] + ], + "steps": [ + { + "instruction": "Merge onto (A 44)", + "distance": 6977.8, + "duration": 225.7, + "coordinates": [ + [ + 6.477305, + 51.033644 + ], + [ + 6.478686, + 51.033896 + ], + [ + 6.479807, + 51.034116 + ], + [ + 6.480682, + 51.034289 + ], + [ + 6.481449, + 51.034446 + ], + [ + 6.482143, + 51.034597 + ], + [ + 6.483834, + 51.03501 + ], + [ + 6.48397, + 51.035044 + ], + [ + 6.484717, + 51.035257 + ], + [ + 6.485836, + 51.035581 + ], + [ + 6.486446, + 51.035791 + ], + [ + 6.487045, + 51.035998 + ], + [ + 6.487601, + 51.036214 + ], + [ + 6.48858, + 51.036611 + ], + [ + 6.489338, + 51.036944 + ], + [ + 6.490671, + 51.037593 + ], + [ + 6.491541, + 51.038065 + ], + [ + 6.492671, + 51.038728 + ], + [ + 6.493671, + 51.039399 + ], + [ + 6.494294, + 51.039848 + ], + [ + 6.494999, + 51.040395 + ], + [ + 6.496033, + 51.041293 + ], + [ + 6.496877, + 51.042141 + ], + [ + 6.49708, + 51.04236 + ], + [ + 6.497278, + 51.042581 + ], + [ + 6.497487, + 51.042822 + ], + [ + 6.497721, + 51.043103 + ], + [ + 6.497821, + 51.043225 + ], + [ + 6.498038, + 51.043503 + ], + [ + 6.498245, + 51.043784 + ], + [ + 6.498357, + 51.043938 + ], + [ + 6.498564, + 51.044237 + ], + [ + 6.498748, + 51.044518 + ], + [ + 6.498853, + 51.044684 + ], + [ + 6.499041, + 51.044995 + ], + [ + 6.499219, + 51.045304 + ], + [ + 6.4993, + 51.045456 + ], + [ + 6.499453, + 51.045748 + ], + [ + 6.499531, + 51.045905 + ], + [ + 6.499604, + 51.046057 + ], + [ + 6.49975, + 51.046372 + ], + [ + 6.499817, + 51.046528 + ], + [ + 6.499885, + 51.046689 + ], + [ + 6.499948, + 51.046843 + ], + [ + 6.500003, + 51.046988 + ], + [ + 6.500064, + 51.047151 + ], + [ + 6.500119, + 51.047309 + ], + [ + 6.500174, + 51.04747 + ], + [ + 6.500229, + 51.047637 + ], + [ + 6.500277, + 51.047793 + ], + [ + 6.500368, + 51.048111 + ], + [ + 6.50041, + 51.048279 + ], + [ + 6.500487, + 51.048596 + ], + [ + 6.500556, + 51.048918 + ], + [ + 6.500586, + 51.049076 + ], + [ + 6.500613, + 51.049236 + ], + [ + 6.500642, + 51.049411 + ], + [ + 6.500663, + 51.049563 + ], + [ + 6.500683, + 51.049727 + ], + [ + 6.500717, + 51.05005 + ], + [ + 6.500743, + 51.050372 + ], + [ + 6.500752, + 51.05053 + ], + [ + 6.500761, + 51.050852 + ], + [ + 6.500763, + 51.051031 + ], + [ + 6.500758, + 51.051344 + ], + [ + 6.500746, + 51.051671 + ], + [ + 6.500735, + 51.05184 + ], + [ + 6.500707, + 51.052154 + ], + [ + 6.500686, + 51.052344 + ], + [ + 6.500648, + 51.052636 + ], + [ + 6.500622, + 51.052801 + ], + [ + 6.500568, + 51.053118 + ], + [ + 6.500499, + 51.053446 + ], + [ + 6.500465, + 51.053605 + ], + [ + 6.500384, + 51.053918 + ], + [ + 6.500294, + 51.054236 + ], + [ + 6.500195, + 51.054559 + ], + [ + 6.500086, + 51.054877 + ], + [ + 6.499968, + 51.055191 + ], + [ + 6.499841, + 51.055508 + ], + [ + 6.499705, + 51.055825 + ], + [ + 6.499562, + 51.056133 + ], + [ + 6.499412, + 51.056445 + ], + [ + 6.499332, + 51.056602 + ], + [ + 6.499168, + 51.05691 + ], + [ + 6.499084, + 51.057063 + ], + [ + 6.498912, + 51.057365 + ], + [ + 6.498732, + 51.057673 + ], + [ + 6.498449, + 51.058126 + ], + [ + 6.498254, + 51.058424 + ], + [ + 6.498054, + 51.058725 + ], + [ + 6.497849, + 51.059021 + ], + [ + 6.497637, + 51.059316 + ], + [ + 6.497317, + 51.059751 + ], + [ + 6.497088, + 51.06005 + ], + [ + 6.496859, + 51.060344 + ], + [ + 6.496631, + 51.060635 + ], + [ + 6.496397, + 51.060924 + ], + [ + 6.496165, + 51.061207 + ], + [ + 6.495924, + 51.061495 + ], + [ + 6.495562, + 51.061924 + ], + [ + 6.495319, + 51.062207 + ], + [ + 6.495072, + 51.062495 + ], + [ + 6.494822, + 51.062777 + ], + [ + 6.49457, + 51.063063 + ], + [ + 6.494192, + 51.06349 + ], + [ + 6.493817, + 51.063909 + ], + [ + 6.493181, + 51.064618 + ], + [ + 6.492795, + 51.065047 + ], + [ + 6.492418, + 51.065469 + ], + [ + 6.492165, + 51.065751 + ], + [ + 6.491908, + 51.066037 + ], + [ + 6.491656, + 51.066318 + ], + [ + 6.491273, + 51.066744 + ], + [ + 6.491018, + 51.067028 + ], + [ + 6.49076, + 51.067316 + ], + [ + 6.49025, + 51.067882 + ], + [ + 6.490124, + 51.068023 + ], + [ + 6.489996, + 51.068168 + ], + [ + 6.489741, + 51.068452 + ], + [ + 6.489361, + 51.068875 + ], + [ + 6.488851, + 51.069444 + ], + [ + 6.488724, + 51.069584 + ], + [ + 6.488599, + 51.069726 + ], + [ + 6.488474, + 51.069864 + ], + [ + 6.488346, + 51.070008 + ], + [ + 6.488218, + 51.070151 + ], + [ + 6.488091, + 51.070291 + ], + [ + 6.487964, + 51.070432 + ], + [ + 6.487833, + 51.070578 + ], + [ + 6.487706, + 51.070719 + ], + [ + 6.487577, + 51.070862 + ], + [ + 6.487457, + 51.070997 + ], + [ + 6.487322, + 51.071147 + ], + [ + 6.487194, + 51.07129 + ], + [ + 6.486937, + 51.071575 + ], + [ + 6.485795, + 51.072849 + ], + [ + 6.485414, + 51.073274 + ], + [ + 6.485288, + 51.073412 + ], + [ + 6.485159, + 51.073562 + ], + [ + 6.485034, + 51.0737 + ], + [ + 6.482743, + 51.076247 + ], + [ + 6.480577, + 51.078662 + ], + [ + 6.480061, + 51.079237 + ], + [ + 6.479934, + 51.079379 + ], + [ + 6.479807, + 51.07952 + ], + [ + 6.479685, + 51.079657 + ], + [ + 6.479561, + 51.079796 + ], + [ + 6.479296, + 51.080089 + ], + [ + 6.479168, + 51.080232 + ], + [ + 6.479047, + 51.080368 + ], + [ + 6.478911, + 51.080517 + ], + [ + 6.478788, + 51.080651 + ], + [ + 6.478646, + 51.08081 + ], + [ + 6.478505, + 51.080968 + ], + [ + 6.478402, + 51.081084 + ], + [ + 6.478233, + 51.081273 + ], + [ + 6.47814, + 51.08138 + ], + [ + 6.478041, + 51.081492 + ], + [ + 6.477961, + 51.081584 + ], + [ + 6.477889, + 51.081667 + ], + [ + 6.477818, + 51.081749 + ], + [ + 6.477749, + 51.081829 + ], + [ + 6.477681, + 51.08191 + ], + [ + 6.477571, + 51.082046 + ], + [ + 6.477493, + 51.08214 + ], + [ + 6.477413, + 51.082239 + ], + [ + 6.477315, + 51.08236 + ], + [ + 6.477249, + 51.082443 + ], + [ + 6.47719, + 51.082522 + ], + [ + 6.477044, + 51.082711 + ], + [ + 6.476973, + 51.082806 + ], + [ + 6.476906, + 51.082898 + ], + [ + 6.47681, + 51.083035 + ], + [ + 6.4767, + 51.0832 + ], + [ + 6.476627, + 51.083308 + ], + [ + 6.476555, + 51.083422 + ], + [ + 6.476464, + 51.08357 + ], + [ + 6.476373, + 51.083727 + ], + [ + 6.476318, + 51.083825 + ], + [ + 6.476265, + 51.083922 + ], + [ + 6.47621, + 51.084033 + ], + [ + 6.476159, + 51.08414 + ], + [ + 6.476107, + 51.084248 + ], + [ + 6.476059, + 51.084355 + ], + [ + 6.476013, + 51.084461 + ], + [ + 6.475969, + 51.08457 + ], + [ + 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": 2119.5, + "duration": 67.7, + "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 + ], + [ + 6.475615, + 51.085936 + ], + [ + 6.475603, + 51.086048 + ], + [ + 6.475593, + 51.086168 + ], + [ + 6.475587, + 51.086273 + ], + [ + 6.475586, + 51.086429 + ], + [ + 6.475588, + 51.086571 + ], + [ + 6.475594, + 51.086743 + ], + [ + 6.475608, + 51.086922 + ], + [ + 6.475627, + 51.087095 + ], + [ + 6.475648, + 51.087244 + ], + [ + 6.475673, + 51.087384 + ], + [ + 6.475705, + 51.087543 + ], + [ + 6.47574, + 51.087699 + ], + [ + 6.475783, + 51.087862 + ], + [ + 6.475831, + 51.08802 + ], + [ + 6.475882, + 51.088179 + ], + [ + 6.475936, + 51.088329 + ], + [ + 6.476001, + 51.088497 + ], + [ + 6.47607, + 51.088657 + ], + [ + 6.476137, + 51.088808 + ], + [ + 6.476213, + 51.088963 + ], + [ + 6.476294, + 51.089121 + ], + [ + 6.476385, + 51.089285 + ], + [ + 6.476478, + 51.089439 + ], + [ + 6.476569, + 51.089578 + ], + [ + 6.476671, + 51.089731 + ], + [ + 6.476773, + 51.089877 + ], + [ + 6.476838, + 51.089964 + ], + [ + 6.476903, + 51.090048 + ], + [ + 6.47699, + 51.090159 + ], + [ + 6.477077, + 51.090264 + ], + [ + 6.477161, + 51.090362 + ], + [ + 6.477246, + 51.090455 + ], + [ + 6.47741, + 51.09063 + ], + [ + 6.477466, + 51.09069 + ], + [ + 6.477524, + 51.09075 + ], + [ + 6.477588, + 51.090814 + ], + [ + 6.477652, + 51.090875 + ], + [ + 6.47782, + 51.091029 + ], + [ + 6.477957, + 51.091152 + ], + [ + 6.478099, + 51.091272 + ], + [ + 6.478269, + 51.091411 + ], + [ + 6.478362, + 51.091485 + ], + [ + 6.478455, + 51.091556 + ], + [ + 6.478603, + 51.091667 + ], + [ + 6.478751, + 51.091775 + ], + [ + 6.47889, + 51.091869 + ], + [ + 6.478992, + 51.091939 + ], + [ + 6.479094, + 51.092008 + ], + [ + 6.479289, + 51.092133 + ], + [ + 6.479391, + 51.092197 + ], + [ + 6.479491, + 51.092257 + ], + [ + 6.479692, + 51.092375 + ], + [ + 6.47986, + 51.092469 + ], + [ + 6.480025, + 51.092559 + ], + [ + 6.480143, + 51.092622 + ], + [ + 6.48026, + 51.092683 + ], + [ + 6.48041, + 51.092758 + ], + [ + 6.480569, + 51.092836 + ], + [ + 6.480889, + 51.092982 + ], + [ + 6.481065, + 51.093061 + ], + [ + 6.481246, + 51.093138 + ], + [ + 6.481387, + 51.093196 + ], + [ + 6.481528, + 51.093252 + ], + [ + 6.481714, + 51.093326 + ], + [ + 6.4819, + 51.093395 + ], + [ + 6.482117, + 51.093474 + ], + [ + 6.482327, + 51.093548 + ], + [ + 6.482602, + 51.093641 + ], + [ + 6.482734, + 51.093685 + ], + [ + 6.482866, + 51.093727 + ], + [ + 6.48325, + 51.093845 + ], + [ + 6.483651, + 51.093963 + ], + [ + 6.483901, + 51.094033 + ], + [ + 6.484149, + 51.094101 + ], + [ + 6.484404, + 51.09417 + ], + [ + 6.484663, + 51.094238 + ], + [ + 6.484929, + 51.094306 + ], + [ + 6.485201, + 51.094376 + ], + [ + 6.485471, + 51.094444 + ], + [ + 6.485746, + 51.094511 + ], + [ + 6.486272, + 51.094637 + ], + [ + 6.486814, + 51.094763 + ], + [ + 6.487186, + 51.094848 + ], + [ + 6.487566, + 51.094932 + ], + [ + 6.487952, + 51.095015 + ], + [ + 6.488337, + 51.095097 + ], + [ + 6.488636, + 51.095159 + ], + [ + 6.488938, + 51.09522 + ], + [ + 6.489559, + 51.095343 + ], + [ + 6.490175, + 51.095459 + ], + [ + 6.490483, + 51.095516 + ], + [ + 6.490797, + 51.095573 + ], + [ + 6.491249, + 51.095653 + ], + [ + 6.491719, + 51.095733 + ], + [ + 6.49216, + 51.095805 + ], + [ + 6.492606, + 51.095877 + ], + [ + 6.492967, + 51.095932 + ], + [ + 6.493336, + 51.095986 + ], + [ + 6.493702, + 51.096039 + ], + [ + 6.494077, + 51.096092 + ], + [ + 6.494502, + 51.096151 + ], + [ + 6.494699, + 51.096177 + ], + [ + 6.494909, + 51.096204 + ], + [ + 6.495119, + 51.096231 + ], + [ + 6.495336, + 51.096257 + ], + [ + 6.496037, + 51.096342 + ] + ], + "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, + "fullLanesFromMeters": 450, + "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 + }, + "ways": [ + { + "id": 168452743, + "version": 23, + "timestamp": "2025-03-22T09:52:57Z", + "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" + } + }, + { + "id": 971245021, + "version": 8, + "timestamp": "2025-03-22T09:53:43Z", + "tags": { + "highway": "motorway", + "lanes": "4", + "lit": "no", + "maxspeed": "none", + "oneway": "yes", + "ref": "A 44", + "surface": "asphalt", + "turn:lanes": "none|none|slight_right|slight_right" + } + }, + { + "id": 971245022, + "version": 9, + "timestamp": "2024-10-29T08:51:31Z", + "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" + } + } + ], + "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/__tests__/junctionSchematic.test.ts b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts index 4c1a6dc31..6474333a8 100644 --- a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts +++ b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts @@ -63,7 +63,7 @@ describe("buildJunctionSchematic", () => { it("mirrors the ramp to the left for a left-side exit", () => { const mirrored: JunctionDecisionPoint = { ...a57Point, side: "left" }; - const schematic = buildJunctionSchematic(engineGantry, mirrored); + const schematic = buildJunctionSchematic({ ...engineGantry, activeLanes: [0] }, mirrored); const right = buildJunctionSchematic(engineGantry, a57Point); // Mirroring flips the ramp to the other half of the carriageway. expect(schematic.rampPath).not.toBe(right.rampPath); @@ -89,6 +89,35 @@ describe("buildJunctionSchematic", () => { ); }); + it("draws the full width of a confirmed two-lane branch above the lane bands", () => { + const schematic = buildJunctionSchematic( + { ...engineGantry, laneCount: 4, activeLanes: [2, 3] }, + a57Point, + ); + // The top of four bands spans x=100..220; lanes 2 and 3 occupy x=160..220. + expect(schematic.rampPath).toMatch(/^M 160\.0 58\.8 L 220\.0 58\.8 /); + }); + + it("mirrors a two-lane branch on the left", () => { + const schematic = buildJunctionSchematic( + { ...engineGantry, laneCount: 4, activeLanes: [0, 1] }, + { ...a57Point, side: "left" }, + ); + expect(schematic.rampPath).toMatch(/^M 100\.0 58\.8 L 160\.0 58\.8 /); + }); + + it.each([[], [0, 3], [1, 2]].map((activeLanes) => ({ activeLanes })))( + "withholds branch geometry for unconfirmed or disjoint exit lanes $activeLanes", + ({ activeLanes }) => { + const schematic = buildJunctionSchematic( + { ...engineGantry, laneCount: 4, activeLanes }, + a57Point, + ); + expect(schematic.lanePolygons).toHaveLength(4); + expect(schematic.rampPath).toBe(""); + }, + ); + it("still produces valid paths for a one-lane model", () => { const oneLane: GantryModel = { laneCount: 1, @@ -102,4 +131,101 @@ describe("buildJunctionSchematic", () => { expect(schematic.lanePolygons).toHaveLength(1); expect(schematic.rampPath.length).toBeGreaterThan(0); }); + + it("does not invent carriageway geometry for a sign-only model", () => { + const schematic = buildJunctionSchematic( + { ...engineGantry, laneCount: 0, activeLanes: [] }, + a57Point, + ); + expect(schematic.laneCount).toBe(0); + expect(schematic.lanePolygons).toEqual([]); + expect(schematic.throughPath).toBe(""); + expect(schematic.rampPath).toBe(""); + }); +}); + +describe("connected outgoing branch roads", () => { + it("draws two two-lane roads across a neutral split without lighting incoming lanes", () => { + const schematic = buildJunctionSchematic( + { + ...engineGantry, + laneCount: 4, + activeLanes: [], + branches: [ + { wayId: 1, bearing: 352, laneCount: 2, selected: true }, + { wayId: 2, bearing: 357, laneCount: 2, selected: false }, + ], + }, + a57Point, + ); + expect(schematic.lanePolygons).toHaveLength(4); + expect(schematic.activeLanes).toEqual([]); + expect(schematic.rampPath).toBe(""); + expect(schematic.branches?.map((branch) => branch.selected)).toEqual([true, false]); + const vertices = (path: string) => + [...path.matchAll(/(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g)].map((match) => ({ + x: Number(match[1]), + y: Number(match[2]), + })); + const incomingTop = Math.min( + ...schematic.lanePolygons.flatMap(vertices).map((vertex) => vertex.y), + ); + const roads = schematic.branches ?? []; + const outgoingBottom = Math.max( + ...roads.flatMap((road) => vertices(road.path)).map((vertex) => vertex.y), + ); + // Lane markings stop on either side of a broad neutral junction area. + expect(incomingTop - outgoingBottom).toBeGreaterThanOrEqual(20); + expect(schematic.branches?.map((branch) => branch.lanePolygons.length)).toEqual([2, 2]); + for (const road of roads) { + expect(road.path).toMatch(/Z$/); + const bottom = vertices(road.path).filter((vertex) => vertex.y === outgoingBottom); + const roadWidth = + Math.max(...bottom.map((vertex) => vertex.x)) - + Math.min(...bottom.map((vertex) => vertex.x)); + expect(roadWidth).toBeGreaterThanOrEqual(50); + const laneStarts = road.lanePolygons.map((polygon) => + vertices(polygon).filter((vertex) => vertex.y === outgoingBottom), + ); + expect(laneStarts[0]).not.toEqual(laneStarts[1]); + } + }); + it("keeps an unknown branch count unknown", () => { + const schematic = buildJunctionSchematic( + { + ...engineGantry, + branches: [ + { wayId: 1, bearing: 352, selected: true }, + { wayId: 2, bearing: 357, laneCount: 2, selected: false }, + ], + }, + a57Point, + ); + expect(schematic.branches?.[0].lanePolygons).toEqual([]); + }); +}); + +it("keeps three forward-facing branch roads within the viewBox", () => { + const schematic = buildJunctionSchematic( + { + ...engineGantry, + branches: [ + { wayId: 1, bearing: 340, laneCount: 1, selected: false }, + { wayId: 2, bearing: 350, laneCount: 2, selected: true }, + { wayId: 3, bearing: 10, selected: false }, + ], + }, + a57Point, + ); + for (const road of schematic.branches ?? []) { + expect(road.path).toMatch(/Z$/); + const numbers = [...road.path.matchAll(/-?\d+(?:\.\d+)?/g)].map((match) => Number(match[0])); + expect( + numbers.every( + (value, index) => + value >= 0 && value <= (index % 2 === 0 ? schematic.width : schematic.height), + ), + ).toBe(true); + } + expect(schematic.branches?.[2].lanePolygons).toEqual([]); }); diff --git a/packages/core/src/navigation/__tests__/laneTags.test.ts b/packages/core/src/navigation/__tests__/laneTags.test.ts index ed7f7c131..783b4cf6a 100644 --- a/packages/core/src/navigation/__tests__/laneTags.test.ts +++ b/packages/core/src/navigation/__tests__/laneTags.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from "vitest"; import type { GantryModel, JunctionWay } from "../../types/junction"; import type { Route } from "../../types/routing"; +import holz from "../__fixtures__/junction/a44-kreuz-holz-a46.json"; import fixture from "../__fixtures__/junction/a57-neuss-exit20.json"; -import { findJunctionDecisionPoints } from "../junctionDetect"; +import { findJunctionCandidates, findJunctionDecisionPoints } from "../junctionDetect"; import { mergeExitPanel, parseLaneTags, selectApproachWay, selectRampWay } from "../laneTags"; const a57Point = findJunctionDecisionPoints(fixture.route as unknown as Route)[0]; @@ -127,10 +128,11 @@ describe("parseLaneTags on fixture way 314653469", () => { expect(merged.activeLanes).toEqual([4]); }); - it("falls back to the outermost lane when neither the engine nor OSM says", () => { + it("withholds the lane recommendation 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]); + expect(merged.activeLanes).toEqual([]); + expect(merged.panels.at(-1)!.lanes).toEqual([]); }); it("appends the exit panel from the ramp tags and moves the active lanes", () => { @@ -186,21 +188,21 @@ describe("parseLaneTags lane-count mismatches", () => { source: "osm", }; - it("maps the exit panel to the outermost right lane when the model is narrower", () => { + it("withholds engine lane indices 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]); + expect(exitPanel.lanes).toEqual([]); + expect(merged.activeLanes).toEqual([]); }); - it("maps the exit panel to lane 0 on the left side", () => { + it("does not guess a left lane when the engine counted a different carriageway", () => { const merged = mergeExitPanel(fourLaneModel, { ...a57Point, activeLanes: [0], side: "left", }); - expect(merged.panels.at(-1)!.lanes).toEqual([0]); - expect(merged.activeLanes).toEqual([0]); + expect(merged.panels.at(-1)!.lanes).toEqual([]); + expect(merged.activeLanes).toEqual([]); }); }); @@ -211,22 +213,71 @@ describe("parseLaneTags edge cases", () => { expect(model.panels[0].refs).toEqual(["A 57"]); }); - it("returns null when the lanes tag disagrees", () => { - expect( - parseLaneTags({ lanes: 4, destinationRefLanes: "A 57|A 57|A 46|A 46|A 46" }, 5), - ).toBeNull(); + it("retains lane geometry when a destination list disagrees with it", () => { + const model = parseLaneTags({ lanes: 4, destinationRefLanes: "A 57|A 57|A 46|A 46|A 46" }, 5); + expect(model?.laneCount).toBe(4); + expect(model?.panels).toEqual([]); }); 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(); + expect( + parseLaneTags({ lanes: 5, destinationRefLanes: "A 57|A 57|A 46|A 46" }, 5)?.panels, + ).toEqual([]); + expect(parseLaneTags({ destinationRefLanes: "A 57|A 57|A 46|A 46" }, 5)?.panels).toEqual([]); }); - it("returns null with only turn:lanes and no destination tags", () => { - expect( - parseLaneTags({ lanes: 5, turnLanes: "none|none|none|none|slight_right" }, 5), - ).toBeNull(); + it("keeps the lane count and arrows without inventing destination panels", () => { + const model = parseLaneTags({ lanes: 4, turnLanes: "none|none|slight_right|slight_right" }); + expect(model).toMatchObject({ + laneCount: 4, + panels: [], + laneTurns: ["none", "none", "slight_right", "slight_right"], + }); + }); + + it("retains a lane count even without destination or turn tags", () => { + expect(parseLaneTags({ lanes: 3 })).toMatchObject({ + laneCount: 3, + panels: [], + activeLanes: [], + }); + }); + + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects unusable lane count %s", + (lanes) => { + expect(parseLaneTags({ lanes })).toBeNull(); + }, + ); + + it("does not choose the opposite side when OSM and the maneuver disagree", () => { + const model: GantryModel = { + laneCount: 4, + panels: [], + activeLanes: [], + laneTurns: ["none", "none", "slight_right", "slight_right"], + source: "osm", + }; + const merged = mergeExitPanel( + model, + { ...a57Point, side: "left", laneCount: undefined, activeLanes: [] }, + { lanes: 2, destination: "Düsseldorf;Neuss", destinationRef: "A 46" }, + ); + expect(merged.activeLanes).toEqual([]); + expect(merged.panels[0].lanes).toEqual([]); + expect(merged.panels[0].destinations).toEqual(["Düsseldorf", "Neuss"]); + }); + + it("reads composite OSM turn arrows when the exit direction agrees", () => { + const model: GantryModel = { + laneCount: 4, + panels: [], + activeLanes: [], + laneTurns: ["none", "none", "through;slight_right", "slight_right"], + source: "osm", + }; + const merged = mergeExitPanel(model, { ...a57Point, laneCount: undefined, activeLanes: [] }); + expect(merged.activeLanes).toEqual([2, 3]); }); it("attaches destination:symbol:lanes to its lane", () => { @@ -271,3 +322,129 @@ describe("way selection", () => { expect(selectRampWay(ramps, a57Point).map((r) => r.wayId)).toEqual([8, 7]); }); }); + +describe("outgoing branch destination boards", () => { + it("retains both Kreuz Holz destinations without assigning incoming lanes", () => { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const model = parseLaneTags(holz.lookup.approach[0].tags)!; + const gantry = mergeExitPanel(model, point, holz.lookup.ramps[0].tags, { + ways: holz.lookup.outgoing, + }); + expect(gantry.panels.map((panel) => panel.destinations)).toEqual([ + ["Düsseldorf", "Neuss"], + ["Heinsberg", "Venlo", "Mönchengladbach"], + ]); + expect(gantry.branches?.map((branch) => branch.laneCount)).toEqual([2, 2]); + expect(gantry.activeLanes).toEqual([]); + expect(gantry.panels.every((panel) => panel.lanes.length === 0)).toBe(true); + }); +}); + +describe("connected branch route evidence", () => { + const point = findJunctionCandidates(holz.route as unknown as Route)[0]; + const model = parseLaneTags(holz.lookup.approach[0].tags)!; + const roads: JunctionWay[] = holz.lookup.outgoing; + + it("selects the routed branch independent of response order", () => { + for (const ways of [roads, [...roads].reverse()]) { + const result = mergeExitPanel(model, point, undefined, { ways }); + expect( + result.branches?.filter((branch) => branch.selected).map((branch) => branch.wayId), + ).toEqual([168452743]); + } + }); + + it("can select the non-link motorway continuation by its destination", () => { + const result = mergeExitPanel( + model, + { + ...point, + sign: { + exitBranches: ["A 46", "A 61"], + exitToward: ["Heinsberg", "Venlo"], + }, + }, + undefined, + { ways: roads }, + ); + expect( + result.branches?.filter((branch) => branch.selected).map((branch) => branch.wayId), + ).toEqual([971245022]); + }); + + it("withholds branch selection when shared refs do not distinguish directions", () => { + const result = mergeExitPanel( + model, + { ...point, sign: { exitBranches: ["A 46"] } }, + undefined, + { ways: roads }, + ); + expect(result.branches?.some((branch) => branch.selected)).toBe(false); + expect(result.panels.some((panel) => panel.isExit && panel.branchWayId === undefined)).toBe( + true, + ); + }); + + it("preserves the engine board when the routed branch lacks OSM destinations", () => { + const ways = roads.map((road) => + road.wayId === 168452743 ? { ...road, tags: { lanes: 2 } } : road, + ); + const result = mergeExitPanel( + model, + { ...point, sign: { ...point.sign, exitNumbers: ["16"] } }, + undefined, + { ways }, + ); + expect( + result.panels.some( + (panel) => panel.destinations.includes("Neuss") && panel.exitNumber === "16", + ), + ).toBe(true); + expect(result.panels.some((panel) => panel.destinations.includes("Heinsberg"))).toBe(true); + expect(result.branches?.some((branch) => branch.selected)).toBe(false); + }); + + it("copies the engine exit number onto the selected branch board", () => { + const result = mergeExitPanel( + model, + { ...point, sign: { ...point.sign, exitNumbers: ["16"] } }, + undefined, + { ways: roads }, + ); + expect(result.panels.find((panel) => panel.branchWayId === 168452743)?.exitNumber).toBe("16"); + }); + + it("uses engine destinations on a branch identified by its unique ref", () => { + const ways = roads.map((road) => + road.wayId === 168452743 + ? { ...road, tags: { lanes: 2, destinationRef: "A 46" } } + : { ...road, tags: { ...road.tags, destinationRef: "A 61" } }, + ); + const result = mergeExitPanel(model, point, undefined, { ways }); + expect(result.panels.find((panel) => panel.branchWayId === 168452743)?.destinations).toEqual([ + "Düsseldorf", + "Neuss", + ]); + }); + + it.each([0, -1, 1.5, Number.NaN])( + "does not fabricate branch geometry for unusable lane count %s", + (lanes) => { + const ways = roads.map((road) => ({ ...road, tags: { ...road.tags, lanes } })); + expect( + mergeExitPanel(model, point, undefined, { ways }).branches?.every( + (branch) => branch.laneCount === undefined, + ), + ).toBe(true); + }, + ); + + it("retains per-lane boards and separate outgoing labels", () => { + const laneModel = parseLaneTags({ lanes: 4, destinationLanes: "X|X|Y|Y" })!; + const result = mergeExitPanel(laneModel, point, undefined, { ways: roads }); + expect(result.panels.some((panel) => panel.destinations.includes("X"))).toBe(true); + expect(result.branches?.find((branch) => branch.wayId === 971245022)).toMatchObject({ + destinations: ["Heinsberg", "Venlo", "Mönchengladbach"], + }); + }); +}); diff --git a/packages/core/src/navigation/junctionSchematic.ts b/packages/core/src/navigation/junctionSchematic.ts index 0ec6d9366..9006b0d5e 100644 --- a/packages/core/src/navigation/junctionSchematic.ts +++ b/packages/core/src/navigation/junctionSchematic.ts @@ -21,13 +21,28 @@ function rampAngle(divergenceDeg: number): number { /** * 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`. + * driving direction. A ramp is drawn only for a contiguous group of known + * exit lanes reaching the edge on `point.side`. */ export function buildJunctionSchematic( model: GantryModel, point: JunctionDecisionPoint, ): JunctionSchematic { - const laneCount = Math.max(1, model.laneCount); + const laneCount = model.laneCount; + if (laneCount < 1) { + return { + width: WIDTH, + height: HEIGHT, + laneCount: 0, + activeLanes: [], + side: point.side, + divergenceDeg: point.divergenceDeg, + throughPath: "", + rampPath: "", + lanePolygons: [], + panelAnchors: [], + }; + } const left = (WIDTH - BOTTOM_WIDTH) / 2; const right = left + BOTTOM_WIDTH; const topInset = (BOTTOM_WIDTH - TOP_WIDTH) / 2; @@ -35,34 +50,78 @@ export function buildJunctionSchematic( const throughPath = `M ${left} ${HEIGHT} L ${left + topInset} ${top} L ${right - topInset} ${top} L ${right} ${HEIGHT} Z`; + const hasBranches = (model.branches?.length ?? 0) > 0; + // Leave a neutral road area between incoming and outgoing markings rather + // than connecting lane indices whose assignment is unknown. + const laneTopY = hasBranches ? top + 30 : top; + const laneTopInset = (topInset * (HEIGHT - laneTopY)) / (HEIGHT - top); + const laneTopWidth = BOTTOM_WIDTH - 2 * laneTopInset; 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); + const x0t = left + laneTopInset + (laneTopWidth / laneCount) * i; + const x1t = left + laneTopInset + (laneTopWidth / 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`, + `M ${x0b.toFixed(1)} ${HEIGHT} L ${x0t.toFixed(1)} ${laneTopY} L ${x1t.toFixed(1)} ${laneTopY} 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. + // Start above the bands: painting the ramp over them erases the seams of a + // multi-lane exit. Do not bridge unconfirmed lanes to the carriageway edge. 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 exitLanes = [...model.activeLanes].sort((a, b) => a - b); + const first = exitLanes[0]; + const last = exitLanes.at(-1); + const contiguous = + first !== undefined && + last !== undefined && + exitLanes.every((lane, index) => lane === first + index) && + first >= 0 && + last < laneCount && + (point.side === "left" ? first === 0 : last === laneCount - 1); 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 rampPath = contiguous + ? [ + `M ${laneTop(first).toFixed(1)} ${top}`, + `L ${laneTop(last + 1).toFixed(1)} ${top}`, + `L ${(laneTop(last + 1) + peelX).toFixed(1)} ${(top - peelY).toFixed(1)}`, + `L ${(laneTop(first) + peelX).toFixed(1)} ${(top - peelY).toFixed(1)}`, + "Z", + ].join(" ") + : ""; + + const roads = hasBranches ? model.branches : undefined; + const weighted = roads?.every((branch) => branch.laneCount !== undefined && branch.laneCount > 0); + const weights = roads?.map((branch) => (weighted ? (branch.laneCount ?? 1) : 1)) ?? []; + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + let precedingWeight = 0; + const branches = roads?.map((branch, index) => { + const fraction = weights[index] / totalWeight; + const startLeft = left + topInset + (TOP_WIDTH * precedingWeight) / totalWeight; + const startWidth = TOP_WIDTH * fraction; + const endCentre = left + (BOTTOM_WIDTH * (precedingWeight + weights[index] / 2)) / totalWeight; + const endWidth = startWidth * 0.9; + const endLeft = endCentre - endWidth / 2; + precedingWeight += weights[index]; + const band = (from: number, to: number) => + [ + `M ${(startLeft + startWidth * from).toFixed(1)} ${top}`, + `L ${(endLeft + endWidth * from).toFixed(1)} 8`, + `L ${(endLeft + endWidth * to).toFixed(1)} 8`, + `L ${(startLeft + startWidth * to).toFixed(1)} ${top}`, + "Z", + ].join(" "); + const count = branch.laneCount; + const lanePolygons = + count === undefined + ? [] + : Array.from({ length: count }, (_, lane) => band(lane / count, (lane + 1) / count)); + return { wayId: branch.wayId, selected: branch.selected, path: band(0, 1), lanePolygons }; + }); const panelAnchors = model.panels.map((panel) => { const first = panel.lanes[0] ?? 0; @@ -83,8 +142,9 @@ export function buildJunctionSchematic( side: point.side, divergenceDeg: point.divergenceDeg, throughPath, - rampPath, + rampPath: hasBranches ? "" : rampPath, lanePolygons, panelAnchors, + ...(branches ? { branches } : {}), }; } diff --git a/packages/core/src/navigation/laneTags.ts b/packages/core/src/navigation/laneTags.ts index 1b00fcd87..f9b555a77 100644 --- a/packages/core/src/navigation/laneTags.ts +++ b/packages/core/src/navigation/laneTags.ts @@ -103,22 +103,21 @@ function joinKeys(values: string[]): string { } /** - * 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. + * Lane geometry does not require destination panels. Malformed destination + * lists are withheld while a known lane count and valid turn arrows survive. + * `null` means neither OSM nor the engine supplied a usable lane count. */ 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; - if ( + if (laneCount === undefined || !Number.isInteger(laneCount) || laneCount < 1) return null; + let destinationLanes = laneValues(splitLaneValues(tags.destinationLanes), laneCount); + let destinationRefLanes = laneValues(splitLaneValues(tags.destinationRefLanes), laneCount); + const malformedDestinations = (tags.destinationLanes !== undefined && destinationLanes === undefined) || - (tags.destinationRefLanes !== undefined && destinationRefLanes === undefined) - ) { - return null; + (tags.destinationRefLanes !== undefined && destinationRefLanes === undefined); + if (malformedDestinations) { + destinationLanes = undefined; + destinationRefLanes = undefined; } const turnLanes = laneValues(splitLaneValues(tags.turnLanes), laneCount); const symbolLanes = laneValues(splitLaneValues(tags.destinationSymbolLanes), laneCount); @@ -153,7 +152,6 @@ export function parseLaneTags(tags: OsmLaneTags, fallbackLaneCount?: number): Ga isExit: false, }); } - if (panels.length === 0) return null; return { laneCount, panels, @@ -166,16 +164,104 @@ export function parseLaneTags(tags: OsmLaneTags, fallbackLaneCount?: number): Ga /** * 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. + * engine sign, else the ramp's `junction:ref`. Lane assignments require engine + * lanes for this lane count, or OSM arrows agreeing with the maneuver side. + * Unknown assignments retain the sign without lighting an inferred lane. */ export function mergeExitPanel( model: GantryModel, point: JunctionDecisionPoint, rampTags?: OsmLaneTags, + outgoing?: { ways: JunctionWay[] }, +): GantryModel { + if (!outgoing || outgoing.ways.length < 2) return mergePrimaryExitPanel(model, point, rampTags); + const routeWay = selectOutgoingBySign(outgoing.ways, point); + // A nearest link candidate is not route identity at a shared-node fork. + // Fall back to the engine sign when no outgoing destination distinguishes it. + const merged = mergePrimaryExitPanel(model, point, routeWay?.tags); + const primary = merged.panels.find((panel) => panel.isExit); + const offset = (bearing: number) => ((bearing - point.approachBearing + 540) % 360) - 180; + const ways = [...outgoing.ways].sort( + (a, b) => offset(a.bearing) - offset(b.bearing) || a.wayId - b.wayId, + ); + const panels: GantryPanel[] = ways.flatMap((way) => { + const labels = branchLabels(way); + const selected = way.wayId === routeWay?.wayId; + const destinations = + labels.destinations.length > 0 + ? labels.destinations + : selected + ? (primary?.destinations ?? []) + : []; + const refs = labels.refs.length > 0 ? labels.refs : selected ? (primary?.refs ?? []) : []; + if (destinations.length === 0 && refs.length === 0 && !(selected && primary?.exitNumber)) + return []; + return [ + { + branchWayId: way.wayId, + lanes: [], + destinations, + refs, + symbols: splitParts(way.tags.destinationSymbol ?? ""), + isExit: selected, + ...(selected && primary?.exitNumber ? { exitNumber: primary.exitNumber } : {}), + }, + ]; + }); + const branches = ways.map((way) => { + const panel = panels.find((entry) => entry.branchWayId === way.wayId); + const count = way.tags.lanes; + return { + wayId: way.wayId, + bearing: way.bearing, + ...(count !== undefined && Number.isInteger(count) && count > 0 ? { laneCount: count } : {}), + selected: way.wayId === routeWay?.wayId, + refs: panel?.refs ?? [], + destinations: panel?.destinations ?? [], + }; + }); + // Actual per-lane boards retain their layout; outgoing labels also travel + // with the branch model for accessibility without implying lane assignments. + if (model.panels.length > 0) return { ...merged, branches }; + if (!routeWay && primary) panels.push(primary); + return { ...merged, branches, panels: panels.length > 0 ? panels : merged.panels }; +} + +function branchLabels(way: JunctionWay): { refs: string[]; destinations: string[] } { + const refs = splitRefParts(splitParts(way.tags.destinationRef ?? "")); + return { + refs: refs.refs, + destinations: union(splitParts(way.tags.destination ?? ""), refs.places), + }; +} + +/** Destination text can distinguish branches sharing the same road ref. */ +function selectOutgoingBySign( + ways: JunctionWay[], + point: JunctionDecisionPoint, +): JunctionWay | undefined { + const normalize = (value: string) => value.toLocaleLowerCase().replace(/\s+/g, ""); + const toward = (point.sign?.exitToward ?? []).map(normalize); + const refs = (point.sign?.exitBranches ?? []).map(normalize); + const labeled = ways.map((way) => ({ way, ...branchLabels(way) })); + const destinations = labeled.filter((entry) => + entry.destinations.some((place) => toward.includes(normalize(place))), + ); + 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; +} + +function mergePrimaryExitPanel( + model: GantryModel, + point: JunctionDecisionPoint, + rampTags?: OsmLaneTags, ): GantryModel { const exitNumber = point.sign?.exitNumbers?.[0] ?? rampTags?.junctionRef; const rampRefs = rampTags?.destinationRef @@ -186,17 +272,16 @@ export function mergeExitPanel( rampRefs?.places ?? [], ); 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. + // A different lane count is a different layout: the engine's indices cannot + // be shifted onto it. Opposite-side OSM arrows cannot resolve that mismatch. 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 panelLanes = engineLanes ?? taggedLanes; const carried = model.panels.filter((panel) => !panel.isExit); + if (destinations.length === 0 && refs.length === 0 && !exitNumber) { + return { ...model, panels: carried, activeLanes: [...panelLanes] }; + } // 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. @@ -244,7 +329,9 @@ function lanesTurningToward(laneTurns: string[] | undefined, side: "left" | "rig side === "left" ? ["left", "slight_left", "sharp_left"] : ["right", "slight_right", "sharp_right"]; - return laneTurns.flatMap((token, lane) => (leaving.includes(token.trim()) ? [lane] : [])); + return laneTurns.flatMap((token, lane) => + splitParts(token).some((part) => leaving.includes(part)) ? [lane] : [], + ); } /** Keep approach ways heading the same way as the route, upstream of the point. */ diff --git a/packages/core/src/types/junction.ts b/packages/core/src/types/junction.ts index 2285de9dd..d453a395c 100644 --- a/packages/core/src/types/junction.ts +++ b/packages/core/src/types/junction.ts @@ -72,10 +72,14 @@ 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 @@ -97,6 +101,8 @@ export interface JunctionLookupResult { } export interface GantryPanel { + /** Destination board for a connected outgoing road, without implied incoming lane indices. */ + branchWayId?: number; /** 0-based lane indices this panel spans (left to right). */ lanes: number[]; destinations: string[]; @@ -110,6 +116,7 @@ export interface GantryPanel { } export interface GantryModel { + /** Lane count at the split; 0 for an engine sign with no known lane geometry. */ laneCount: number; panels: GantryPanel[]; activeLanes: number[]; @@ -117,6 +124,15 @@ export interface GantryModel { laneTurns?: string[]; /** "osm" when built from way tags; "engine" when only the engine sign was available. */ source: "osm" | "engine"; + /** Connected outgoing roads, ordered left to right; independent of incoming lane recommendations. */ + branches?: Array<{ + wayId: number; + bearing: number; + laneCount?: number; + selected: boolean; + refs?: string[]; + destinations?: string[]; + }>; } export interface JunctionSchematic { @@ -126,11 +142,13 @@ export interface JunctionSchematic { activeLanes: number[]; side: "left" | "right"; divergenceDeg: number; - /** SVG path data for the through carriageway and the ramp, in a 320×140 viewBox. */ + /** SVG path data in a 320×140 viewBox; rampPath is empty without confirmed edge lanes. */ 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 }>; + /** Symbolic roads above a neutral junction area, with no incoming-to-outgoing lane mapping. */ + branches?: Array<{ wayId: number; selected: boolean; path: string; lanePolygons: string[] }>; } diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 99825a435..397ad6a85 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -235,6 +235,9 @@ "toward": "Richtung {places}", "junctionViewLabel": "Kreuzungsansicht", "junctionLaneSummary": "Spur {lane} von {total} nutzen", + "junctionLanesSummary": "Spuren {lanes} von {total} nutzen", + "junctionLaneCount": "{total, plural, one {# Spur} other {# Spuren}}", + "junctionBranchesSummary": "Abzweige: {branches}", "junctionPhotoCaption": "© {author} · {license} · {date}" }, "common": { diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index d59e074eb..708f28d89 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -235,6 +235,9 @@ "toward": "toward {places}", "junctionViewLabel": "Junction view", "junctionLaneSummary": "Use lane {lane} of {total}", + "junctionLanesSummary": "Use lanes {lanes} of {total}", + "junctionLaneCount": "{total, plural, one {# lane} other {# lanes}}", + "junctionBranchesSummary": "Branches: {branches}", "junctionPhotoCaption": "© {author} · {license} · {date}" }, "common": {