Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/junction-lane-geometry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@openmapx/core": patch
"@openmapx/i18n": patch
"@openmapx/integration-routing": patch
"web": patch
---

Preserve junction lane geometry without per-lane destination tags, withhold unknown
or conflicting lane recommendations, and show connected outgoing roads with
separate destination boards and known lane counts, including motorway continuations.
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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(<GantryStrip model={model} />);
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();
}
});
18 changes: 16 additions & 2 deletions apps/web/src/components/navigation/junction/GantryStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -110,6 +115,11 @@ function GantryPanel({
{glyphs.join(" ")}
</Typography>
)}
{branch?.laneCount !== undefined && (
<Typography component="span" sx={{ fontSize: size === "banner" ? 10 : 9 }}>
{t("junctionLaneCount", { total: branch.laneCount })}
</Typography>
)}
</Box>
);
}
Expand All @@ -120,7 +130,11 @@ export function GantryStrip({ model, size = "banner" }: Props) {
<Box sx={{ display: "flex", gap: 0.5, alignItems: "stretch" }}>
{model.panels.map((panel) => (
<GantryPanel
key={`${panel.isExit ? "exit" : "lanes"}-${panel.lanes.join("-")}`}
key={
panel.branchWayId !== undefined
? `branch-${panel.branchWayId}`
: `${panel.isExit ? "exit" : "lanes"}-${panel.lanes.join("-")}`
}
model={model}
panel={panel}
size={size}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,28 @@ export function JunctionSchematicView({ schematic }: { schematic: JunctionSchema
strokeWidth={schematic.activeLanes.includes(i) ? 1.5 : 1}
/>
))}
<path data-ramp d={schematic.rampPath} fill="#9fd0ff" />
{schematic.rampPath && <path data-ramp d={schematic.rampPath} fill="#9fd0ff" />}
{schematic.branches?.map((branch) => (
<g key={branch.wayId} data-branch-road data-selected={String(branch.selected)}>
<path
d={branch.path}
fill={branch.lanePolygons.length > 0 ? "#3a3d42" : "#565b63"}
stroke="#2b2e33"
strokeWidth={1}
/>
{branch.lanePolygons.map((path, index) => (
<path
// biome-ignore lint/suspicious/noArrayIndexKey: branch lane bands have no stable id
key={index}
data-branch-lane
d={path}
fill={branch.selected ? "#9fd0ff" : "#565b63"}
stroke={branch.selected ? "#ffffff" : "#2b2e33"}
strokeWidth={1}
/>
))}
</g>
))}
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (key === "junctionLaneSummary")
return `Use lane ${String(values?.lane)} of ${String(values?.total)}`;
if (key === "junctionLanesSummary")
return `Use lanes ${String(values?.lanes)} of ${String(values?.total)}`;
if (key === "junctionLaneCount") return `${String(values?.total)} lanes`;
if (key === "junctionViewLabel") return "Junction view";
if (key === "junctionBranchesSummary") return `Branches: ${String(values?.branches)}`;
if (key === "toward") return `toward ${String(values?.places)}`;
if (key === "junctionPhotoCaption")
return `© ${String(values?.author)} · ${String(values?.license)} · ${String(values?.date)}`;
Expand All @@ -14,8 +18,16 @@ vi.mock("next-intl", () => ({
useLocale: () => "en",
}));

import type { GantryModel, Route } from "@openmapx/core";
import { buildJunctionSchematic, findJunctionDecisionPoints } from "@openmapx/core";
import type { GantryModel, JunctionLookupResult, Route } from "@openmapx/core";
import {
buildJunctionSchematic,
findJunctionCandidates,
findJunctionDecisionPoints,
mergeExitPanel,
parseLaneTags,
selectApproachWay,
} from "@openmapx/core";
import holz from "../../../../../../packages/core/src/navigation/__fixtures__/junction/a44-kreuz-holz-a46.json";
import fixture from "../../../../../../packages/core/src/navigation/__fixtures__/junction/a57-neuss-exit20.json";
import { GantryStrip } from "./GantryStrip";
import { JunctionSchematicView } from "./JunctionSchematic";
Expand Down Expand Up @@ -102,6 +114,47 @@ describe("JunctionViewPanel", () => {
expect(html).toContain("20");
});

it("keeps the exit sign without a fabricated schematic when the lane count is unknown", () => {
const point = findJunctionCandidates(holz.route as unknown as Route)[0];
const html = renderToStaticMarkup(<JunctionViewPanel point={point} />);
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(<JunctionViewPanel point={point} gantry={model} />);
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(
<JunctionViewPanel point={a57Point} gantry={{ ...threePanels, activeLanes: [3, 4] }} />,
);
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(
<JunctionViewPanel point={{ ...a57Point, activeLanes: [] }} />,
);
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(<JunctionViewPanel point={bare} />)).toBe("");
Expand Down Expand Up @@ -150,3 +203,35 @@ describe("JunctionViewPanel photo integration", () => {
expect(html).not.toContain("<a ");
});
});

it("shows both connected Kreuz Holz boards and branch roads while withholding incoming guidance", () => {
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(<JunctionViewPanel point={point} gantry={model} />);
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(<JunctionViewPanel point={point} gantry={model} />);
expect(html.match(/aria-label="([^"]+)"/)?.[1]).toContain("Heinsberg");
expect(html.match(/aria-label="([^"]+)"/)?.[1]).toContain("2 lanes");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -53,7 +51,7 @@ function engineModel(point: JunctionDecisionPoint): GantryModel | null {
]
: [];
return {
laneCount: Math.max(laneCount, 1),
laneCount,
panels,
activeLanes: point.activeLanes,
source: "engine",
Expand All @@ -70,11 +68,35 @@ export function JunctionViewPanel({ point, gantry, photo, geometry }: Props) {
const t = useTranslations("navigation");
const model = gantry ?? engineModel(point);
if (!model) return null;
const lane = (model.activeLanes[0] ?? model.laneCount - 1) + 1;
const toward = visibleToward(signHeadline(point.sign));
const laneSummary =
model.activeLanes.length === 1
? t("junctionLaneSummary", { lane: model.activeLanes[0] + 1, total: model.laneCount })
: model.activeLanes.length > 1
? t("junctionLanesSummary", {
lanes: model.activeLanes.map((lane) => lane + 1).join(", "),
total: model.laneCount,
})
: model.laneCount > 0
? t("junctionLaneCount", { total: model.laneCount })
: t("junctionViewLabel");
const branchSummary = model.branches
?.flatMap((branch) => {
const panel = model.panels.find((entry) => entry.branchWayId === branch.wayId);
const places = [
...(branch.refs ?? panel?.refs ?? []),
...(branch.destinations ?? panel?.destinations ?? []),
].join(", ");
const count =
branch.laneCount !== undefined ? t("junctionLaneCount", { total: branch.laneCount }) : "";
const summary = [places, count].filter(Boolean).join(", ");
return summary ? [summary] : [];
})
.join("; ");
const label = [
t("junctionLaneSummary", { lane, total: model.laneCount }),
laneSummary,
toward.length > 0 ? t("toward", { places: toward.join(", ") }) : null,
branchSummary ? t("junctionBranchesSummary", { branches: branchSummary }) : null,
]
.filter(Boolean)
.join(", ");
Expand Down Expand Up @@ -102,9 +124,9 @@ export function JunctionViewPanel({ point, gantry, photo, geometry }: Props) {
geometry={geometry}
exitLanes={{ laneCount: model.laneCount, activeLanes: model.activeLanes }}
/>
) : (
) : model.laneCount > 0 ? (
<SchematicBody model={model} point={point} />
)}
) : null}
</Box>
);
}
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/lib/navigation/useNavJunctions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -146,6 +149,59 @@ describe("useNavJunctions gantry fetch", () => {
useNavJunctionStore.getState().reset();
});

it("uses the graph approach even when a different approach matches the engine count", async () => {
const graph = holz.lookup.approach[0];
const farther = {
...graph,
wayId: 999,
endDistanceMeters: graph.endDistanceMeters + 100,
tags: { lanes: 3 },
};
fetchJunctionLookups.mockResolvedValue([
{ ...holz.lookup, outgoingApproachWayId: graph.wayId, approach: [farther, graph] },
]);
start();
const route = holz.route as unknown as Route;
useNavigationStore.setState({
route: {
...route,
steps: route.steps.map((step, index) =>
index === 1
? {
...step,
lanes: Array.from({ length: 3 }, () => ({
indications: ["straight"],
valid: false,
})),
}
: step,
),
},
});
renderHook(() => useNavJunctions());
await waitFor(() => expect(useNavJunctionStore.getState().gantryByStep.get(1)).toBeDefined());
const model = useNavJunctionStore.getState().gantryByStep.get(1)!;
expect(model.laneCount).toBe(4);
expect(model.branches).toHaveLength(2);
});

it("stores lane geometry from the turn-only Kreuz Holz approach", async () => {
fetchJunctionLookups.mockResolvedValue([holz.lookup]);
start();
useNavigationStore.setState({ route: holz.route as unknown as Route });
renderHook(() => useNavJunctions());
await waitFor(() =>
expect(useNavJunctionStore.getState().gantryByStep.get(1)?.laneCount).toBe(4),
);
const model = useNavJunctionStore.getState().gantryByStep.get(1)!;
expect(model.activeLanes).toEqual([]);
expect(model.panels.map((panel) => panel.destinations)).toEqual([
["Düsseldorf", "Neuss"],
["Heinsberg", "Venlo", "Mönchengladbach"],
]);
expect(model.branches?.map((branch) => branch.laneCount)).toEqual([2, 2]);
});

it("promotes a candidate exit once OpenStreetMap puts the route on the motorway there", async () => {
fetchJunctionLookups.mockResolvedValue(fixtureResponse);
start();
Expand Down
Loading
Loading