From c03568c2d2274702ac1f65562bd8f00f268bf128 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 10 Oct 2026 08:58:10 +0200 Subject: [PATCH 1/3] fix(navigation): preserve junction lane geometry without destination tags --- .changeset/junction-lane-geometry.md | 9 + .../navigation/junction/JunctionSchematic.tsx | 2 +- .../junction/JunctionViewPanel.test.tsx | 56 +- .../navigation/junction/JunctionViewPanel.tsx | 27 +- .../lib/navigation/useNavJunctions.test.tsx | 16 + docs/docs/features/directions.md | 25 +- .../routing/__tests__/junctions.test.ts | 29 + integrations/routing/junctions.ts | 16 +- .../junction/a44-kreuz-holz-a46.json | 2622 +++++++++++++++++ .../__tests__/junctionSchematic.test.ts | 42 +- .../src/navigation/__tests__/laneTags.test.ts | 88 +- .../core/src/navigation/junctionSchematic.ts | 52 +- packages/core/src/navigation/laneTags.ts | 49 +- packages/core/src/types/junction.ts | 5 +- packages/i18n/locales/de.json | 2 + packages/i18n/locales/en.json | 2 + 16 files changed, 2953 insertions(+), 89 deletions(-) create mode 100644 .changeset/junction-lane-geometry.md create mode 100644 packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json diff --git a/.changeset/junction-lane-geometry.md b/.changeset/junction-lane-geometry.md new file mode 100644 index 000000000..d7b43d400 --- /dev/null +++ b/.changeset/junction-lane-geometry.md @@ -0,0 +1,9 @@ +--- +"@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 draw confirmed multi-lane branches. diff --git a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx b/apps/web/src/components/navigation/junction/JunctionSchematic.tsx index 24b16100d..7670c5eed 100644 --- a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx +++ b/apps/web/src/components/navigation/junction/JunctionSchematic.tsx @@ -32,7 +32,7 @@ export function JunctionSchematicView({ schematic }: { schematic: JunctionSchema strokeWidth={schematic.activeLanes.includes(i) ? 1.5 : 1} /> ))} - + {schematic.rampPath && } ); } diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx index 81f216649..b03d16078 100644 --- a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx +++ b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx @@ -5,6 +5,9 @@ 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 === "toward") return `toward ${String(values?.places)}`; if (key === "junctionPhotoCaption") @@ -14,8 +17,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 +113,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(""); diff --git a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx b/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx index d74b877c1..16cc25bee 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,12 +68,19 @@ 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 label = [ - t("junctionLaneSummary", { lane, total: model.laneCount }), - toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null, - ] + 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 label = [laneSummary, toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null] .filter(Boolean) .join(", "); return ( @@ -102,9 +107,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..946b963d1 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,19 @@ describe("useNavJunctions gantry fetch", () => { useNavJunctionStore.getState().reset(); }); + 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[0].destinations).toEqual(["Düsseldorf", "Neuss"]); + }); + it("promotes a candidate exit once OpenStreetMap puts the route on the motorway there", async () => { fetchJunctionLookups.mockResolvedValue(fixtureResponse); start(); diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index 0600ce4e0..e42897beb 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -322,16 +322,21 @@ 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 and drawing multi-lane branches when +their lane assignment is known. 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 omits the branch. If the 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..20057a251 100644 --- a/integrations/routing/__tests__/junctions.test.ts +++ b/integrations/routing/__tests__/junctions.test.ts @@ -211,6 +211,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. diff --git a/integrations/routing/junctions.ts b/integrations/routing/junctions.ts index 2ce0d640e..ca83f23da 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:v2", [ round(point.lng, 4), round(point.lat, 4), round(point.bearing / 10, 0) * 10, @@ -120,8 +120,13 @@ export interface OverpassWayElement { /** 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"] @@ -237,7 +242,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") && 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..e89052577 --- /dev/null +++ b/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json @@ -0,0 +1,2622 @@ +{ + "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" + }, + "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." + ], + "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 + }, + "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" + } + } + ] +} diff --git a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts index 4c1a6dc31..94f282f97 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,15 @@ 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(""); + }); }); diff --git a/packages/core/src/navigation/__tests__/laneTags.test.ts b/packages/core/src/navigation/__tests__/laneTags.test.ts index ed7f7c131..171ad6ab0 100644 --- a/packages/core/src/navigation/__tests__/laneTags.test.ts +++ b/packages/core/src/navigation/__tests__/laneTags.test.ts @@ -127,10 +127,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 +187,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 +212,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", () => { diff --git a/packages/core/src/navigation/junctionSchematic.ts b/packages/core/src/navigation/junctionSchematic.ts index 0ec6d9366..e0ee25d23 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; @@ -46,23 +61,32 @@ export function buildJunctionSchematic( ); } - // 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 panelAnchors = model.panels.map((panel) => { const first = panel.lanes[0] ?? 0; diff --git a/packages/core/src/navigation/laneTags.ts b/packages/core/src/navigation/laneTags.ts index 1b00fcd87..9d97aff63 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,11 +164,9 @@ 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, @@ -186,17 +182,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 +239,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..d230ebe61 100644 --- a/packages/core/src/types/junction.ts +++ b/packages/core/src/types/junction.ts @@ -72,7 +72,7 @@ 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[]; @@ -110,6 +110,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[]; @@ -126,7 +127,7 @@ 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. */ diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index 99825a435..a662a8bbd 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -235,6 +235,8 @@ "toward": "Richtung {places}", "junctionViewLabel": "Kreuzungsansicht", "junctionLaneSummary": "Spur {lane} von {total} nutzen", + "junctionLanesSummary": "Spuren {lanes} von {total} nutzen", + "junctionLaneCount": "{total} Spuren", "junctionPhotoCaption": "© {author} · {license} · {date}" }, "common": { diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index d59e074eb..1c0175100 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -235,6 +235,8 @@ "toward": "toward {places}", "junctionViewLabel": "Junction view", "junctionLaneSummary": "Use lane {lane} of {total}", + "junctionLanesSummary": "Use lanes {lanes} of {total}", + "junctionLaneCount": "{total} lanes", "junctionPhotoCaption": "© {author} · {license} · {date}" }, "common": { From 4be9b5dd5e53a879d071f0db7b3dc86528762c6b Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 10 Oct 2026 10:23:55 +0200 Subject: [PATCH 2/3] fix(navigation): show connected junction branches and signs --- .changeset/junction-lane-geometry.md | 3 +- .../navigation/junction/GantryStrip.test.tsx | 37 + .../navigation/junction/GantryStrip.tsx | 18 +- .../navigation/junction/JunctionSchematic.tsx | 21 + .../junction/JunctionViewPanel.test.tsx | 33 + .../navigation/junction/JunctionViewPanel.tsx | 19 +- .../lib/navigation/useNavJunctions.test.tsx | 42 +- .../web/src/lib/navigation/useNavJunctions.ts | 9 +- docs/docs/features/directions.md | 11 +- .../routing/__tests__/junctions.test.ts | 56 +- integrations/routing/junctions.ts | 27 +- .../junction/a44-kreuz-holz-a46.json | 685 +++++++++++++++++- .../__tests__/junctionSchematic.test.ts | 36 + .../src/navigation/__tests__/laneTags.test.ts | 129 +++- .../core/src/navigation/junctionSchematic.ts | 17 +- packages/core/src/navigation/laneTags.ts | 90 +++ packages/core/src/types/junction.ts | 17 + packages/i18n/locales/de.json | 3 +- packages/i18n/locales/en.json | 3 +- 19 files changed, 1235 insertions(+), 21 deletions(-) diff --git a/.changeset/junction-lane-geometry.md b/.changeset/junction-lane-geometry.md index d7b43d400..d0832594d 100644 --- a/.changeset/junction-lane-geometry.md +++ b/.changeset/junction-lane-geometry.md @@ -6,4 +6,5 @@ --- Preserve junction lane geometry without per-lane destination tags, withhold unknown -or conflicting lane recommendations, and draw confirmed multi-lane branches. +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 ? branch.lanePaths.length * 8 + 3 : 12} + /> + {branch.lanePaths.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 b03d16078..d86a35c8f 100644 --- a/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx +++ b/apps/web/src/components/navigation/junction/JunctionViewPanel.test.tsx @@ -9,6 +9,7 @@ vi.mock("next-intl", () => ({ 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)}`; @@ -202,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 16cc25bee..65f41b01b 100644 --- a/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx +++ b/apps/web/src/components/navigation/junction/JunctionViewPanel.tsx @@ -80,7 +80,24 @@ export function JunctionViewPanel({ point, gantry, photo, geometry }: Props) { : model.laneCount > 0 ? t("junctionLaneCount", { total: model.laneCount }) : t("junctionViewLabel"); - const label = [laneSummary, toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null] + 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 = [ + laneSummary, + toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null, + branchSummary ? t("junctionBranchesSummary", { branches: branchSummary }) : null, + ] .filter(Boolean) .join(", "); return ( diff --git a/apps/web/src/lib/navigation/useNavJunctions.test.tsx b/apps/web/src/lib/navigation/useNavJunctions.test.tsx index 946b963d1..763af37a2 100644 --- a/apps/web/src/lib/navigation/useNavJunctions.test.tsx +++ b/apps/web/src/lib/navigation/useNavJunctions.test.tsx @@ -149,6 +149,42 @@ 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(); @@ -159,7 +195,11 @@ describe("useNavJunctions gantry fetch", () => { ); const model = useNavJunctionStore.getState().gantryByStep.get(1)!; expect(model.activeLanes).toEqual([]); - expect(model.panels[0].destinations).toEqual(["Düsseldorf", "Neuss"]); + 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 () => { 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 e42897beb..fe6c61c07 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -323,8 +323,10 @@ approach is never interrupted. For motorway and expressway exits, forks, and splits while driving, a **junction view** card appears under the banner. Its schematic shows the lane layout at the upcoming -split, highlighting the recommended lanes and drawing multi-lane branches when -their lane assignment is known. OpenStreetMap's approach lane count and turn +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 share a junction point without assigning incoming lanes 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 — @@ -335,7 +337,10 @@ 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 omits the branch. If the lane count is unknown, only the sign is shown. +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 diff --git a/integrations/routing/__tests__/junctions.test.ts b/integrations/routing/__tests__/junctions.test.ts index 20057a251..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); }); @@ -437,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 ca83f23da..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:v2", [ + return hashKey("cache:nav:junction-ways:v3", [ round(point.lng, 4), round(point.lat, 4), round(point.bearing / 10, 0) * 10, @@ -107,13 +107,14 @@ 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 }[]; } @@ -232,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 }) => @@ -256,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 index e89052577..a0b4f95a5 100644 --- a/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json +++ b/packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json @@ -4,12 +4,15 @@ "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" + "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." + "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." }, @@ -2568,7 +2571,35 @@ } ], "onMotorway": true, - "fullLanesFromMeters": 450 + "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": [ { @@ -2618,5 +2649,653 @@ "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 94f282f97..4861d7bf4 100644 --- a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts +++ b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts @@ -143,3 +143,39 @@ describe("buildJunctionSchematic", () => { expect(schematic.rampPath).toBe(""); }); }); + +describe("connected outgoing branch roads", () => { + it("draws two two-lane roads from a common 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.lanePaths.length)).toEqual([2, 2]); + expect(schematic.branches?.map((branch) => branch.selected)).toEqual([true, false]); + expect(schematic.branches?.every((branch) => branch.path.startsWith("M 160 58.8"))).toBe(true); + }); + 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].lanePaths).toEqual([]); + }); +}); diff --git a/packages/core/src/navigation/__tests__/laneTags.test.ts b/packages/core/src/navigation/__tests__/laneTags.test.ts index 171ad6ab0..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]; @@ -321,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 e0ee25d23..c8a543d16 100644 --- a/packages/core/src/navigation/junctionSchematic.ts +++ b/packages/core/src/navigation/junctionSchematic.ts @@ -88,6 +88,20 @@ export function buildJunctionSchematic( ].join(" ") : ""; + const branches = model.branches?.map((branch, index, all) => { + const x = 70 + (180 * index) / Math.max(1, all.length - 1); + const path = `M 160 ${top} Q ${x} ${top - 10} ${x} 8`; + const count = branch.laneCount; + const lanePaths = + count === undefined + ? [] + : Array.from({ length: count }, (_, lane) => { + const end = x + (lane - (count - 1) / 2) * 8; + return `M 160 ${top} Q ${end} ${top - 10} ${end} 8`; + }); + return { wayId: branch.wayId, selected: branch.selected, path, lanePaths }; + }); + const panelAnchors = model.panels.map((panel) => { const first = panel.lanes[0] ?? 0; const last = panel.lanes.at(-1) ?? first; @@ -107,8 +121,9 @@ export function buildJunctionSchematic( side: point.side, divergenceDeg: point.divergenceDeg, throughPath, - rampPath, + rampPath: branches ? "" : rampPath, lanePolygons, panelAnchors, + ...(branches ? { branches } : {}), }; } diff --git a/packages/core/src/navigation/laneTags.ts b/packages/core/src/navigation/laneTags.ts index 9d97aff63..f9b555a77 100644 --- a/packages/core/src/navigation/laneTags.ts +++ b/packages/core/src/navigation/laneTags.ts @@ -172,6 +172,96 @@ 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 diff --git a/packages/core/src/types/junction.ts b/packages/core/src/types/junction.ts index d230ebe61..9b7026fec 100644 --- a/packages/core/src/types/junction.ts +++ b/packages/core/src/types/junction.ts @@ -76,6 +76,10 @@ export interface JunctionLookupResult { 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[]; @@ -118,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 { @@ -134,4 +149,6 @@ export interface JunctionSchematic { lanePolygons: string[]; /** Where each panel's label sits (x centre, y) in viewBox units. */ panelAnchors: Array<{ x: number; y: number }>; + /** Symbolic road paths sharing a junction point, with no incoming-to-outgoing lane mapping. */ + branches?: Array<{ wayId: number; selected: boolean; path: string; lanePaths: string[] }>; } diff --git a/packages/i18n/locales/de.json b/packages/i18n/locales/de.json index a662a8bbd..397ad6a85 100644 --- a/packages/i18n/locales/de.json +++ b/packages/i18n/locales/de.json @@ -236,7 +236,8 @@ "junctionViewLabel": "Kreuzungsansicht", "junctionLaneSummary": "Spur {lane} von {total} nutzen", "junctionLanesSummary": "Spuren {lanes} von {total} nutzen", - "junctionLaneCount": "{total} Spuren", + "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 1c0175100..708f28d89 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -236,7 +236,8 @@ "junctionViewLabel": "Junction view", "junctionLaneSummary": "Use lane {lane} of {total}", "junctionLanesSummary": "Use lanes {lanes} of {total}", - "junctionLaneCount": "{total} lanes", + "junctionLaneCount": "{total, plural, one {# lane} other {# lanes}}", + "junctionBranchesSummary": "Branches: {branches}", "junctionPhotoCaption": "© {author} · {license} · {date}" }, "common": { From 9e689121a1e69afcd9c77c0dfcdd99c46e88ec35 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 10 Oct 2026 10:47:04 +0200 Subject: [PATCH 3/3] fix(navigation): draw junction branches as forward road bands --- .../navigation/junction/JunctionSchematic.tsx | 14 ++--- docs/docs/features/directions.md | 5 +- .../__tests__/junctionSchematic.test.ts | 58 +++++++++++++++++-- .../core/src/navigation/junctionSchematic.ts | 47 ++++++++++----- packages/core/src/types/junction.ts | 4 +- 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx b/apps/web/src/components/navigation/junction/JunctionSchematic.tsx index 12a0b64bd..95b6e7db1 100644 --- a/apps/web/src/components/navigation/junction/JunctionSchematic.tsx +++ b/apps/web/src/components/navigation/junction/JunctionSchematic.tsx @@ -37,19 +37,19 @@ export function JunctionSchematicView({ schematic }: { schematic: JunctionSchema 0 ? branch.lanePaths.length * 8 + 3 : 12} + fill={branch.lanePolygons.length > 0 ? "#3a3d42" : "#565b63"} + stroke="#2b2e33" + strokeWidth={1} /> - {branch.lanePaths.map((path, index) => ( + {branch.lanePolygons.map((path, index) => ( ))} diff --git a/docs/docs/features/directions.md b/docs/docs/features/directions.md index fe6c61c07..e13c7506e 100644 --- a/docs/docs/features/directions.md +++ b/docs/docs/features/directions.md @@ -326,8 +326,9 @@ 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 share a junction point without assigning incoming lanes to either branch. OpenStreetMap's approach lane count and turn -arrows remain useful even without per-lane destination names. With network +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). diff --git a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts index 4861d7bf4..6474333a8 100644 --- a/packages/core/src/navigation/__tests__/junctionSchematic.test.ts +++ b/packages/core/src/navigation/__tests__/junctionSchematic.test.ts @@ -145,7 +145,7 @@ describe("buildJunctionSchematic", () => { }); describe("connected outgoing branch roads", () => { - it("draws two two-lane roads from a common split without lighting incoming lanes", () => { + it("draws two two-lane roads across a neutral split without lighting incoming lanes", () => { const schematic = buildJunctionSchematic( { ...engineGantry, @@ -161,9 +161,34 @@ describe("connected outgoing branch roads", () => { expect(schematic.lanePolygons).toHaveLength(4); expect(schematic.activeLanes).toEqual([]); expect(schematic.rampPath).toBe(""); - expect(schematic.branches?.map((branch) => branch.lanePaths.length)).toEqual([2, 2]); expect(schematic.branches?.map((branch) => branch.selected)).toEqual([true, false]); - expect(schematic.branches?.every((branch) => branch.path.startsWith("M 160 58.8"))).toBe(true); + 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( @@ -176,6 +201,31 @@ describe("connected outgoing branch roads", () => { }, a57Point, ); - expect(schematic.branches?.[0].lanePaths).toEqual([]); + 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/junctionSchematic.ts b/packages/core/src/navigation/junctionSchematic.ts index c8a543d16..9006b0d5e 100644 --- a/packages/core/src/navigation/junctionSchematic.ts +++ b/packages/core/src/navigation/junctionSchematic.ts @@ -50,14 +50,20 @@ 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`, ); } @@ -88,18 +94,33 @@ export function buildJunctionSchematic( ].join(" ") : ""; - const branches = model.branches?.map((branch, index, all) => { - const x = 70 + (180 * index) / Math.max(1, all.length - 1); - const path = `M 160 ${top} Q ${x} ${top - 10} ${x} 8`; + 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 lanePaths = + const lanePolygons = count === undefined ? [] - : Array.from({ length: count }, (_, lane) => { - const end = x + (lane - (count - 1) / 2) * 8; - return `M 160 ${top} Q ${end} ${top - 10} ${end} 8`; - }); - return { wayId: branch.wayId, selected: branch.selected, path, lanePaths }; + : 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) => { @@ -121,7 +142,7 @@ export function buildJunctionSchematic( side: point.side, divergenceDeg: point.divergenceDeg, throughPath, - rampPath: branches ? "" : rampPath, + rampPath: hasBranches ? "" : rampPath, lanePolygons, panelAnchors, ...(branches ? { branches } : {}), diff --git a/packages/core/src/types/junction.ts b/packages/core/src/types/junction.ts index 9b7026fec..d453a395c 100644 --- a/packages/core/src/types/junction.ts +++ b/packages/core/src/types/junction.ts @@ -149,6 +149,6 @@ export interface JunctionSchematic { lanePolygons: string[]; /** Where each panel's label sits (x centre, y) in viewBox units. */ panelAnchors: Array<{ x: number; y: number }>; - /** Symbolic road paths sharing a junction point, with no incoming-to-outgoing lane mapping. */ - branches?: Array<{ wayId: number; selected: boolean; path: string; lanePaths: string[] }>; + /** Symbolic roads above a neutral junction area, with no incoming-to-outgoing lane mapping. */ + branches?: Array<{ wayId: number; selected: boolean; path: string; lanePolygons: string[] }>; }