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": {