Files
osmWorkflow/scripts/test-turn-lane-arrows.js
2026-08-05 10:17:34 +08:00

122 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } = require("./lib/turn-lane-arrows");
function node(id, lon, lat) { return { id, lon, lat, tags: {} }; }
function way(id, refs, tags) { return { id, refs, tags }; }
const nodes = new Map([
[1, node(1, 0, 0)], [2, node(2, 0, 0.001)], [3, node(3, -0.001, 0.001)],
[4, node(4, 0.001, 0.001)], [5, node(5, -0.001, 0)], [6, node(6, 0.001, 0)],
]);
const primary = way(10, [1, 2], {
highway: "residential", lanes: "4", "lanes:forward": "2", "lanes:backward": "2",
"turn:lanes:forward": "through;right|left", "turn:lanes:backward": "right|through;left",
});
const osm = { nodes, ways: new Map([
[10, primary], [11, way(11, [2, 3], { highway: "residential" })], [12, way(12, [2, 4], { highway: "residential" })],
[13, way(13, [1, 5], { highway: "residential" })], [14, way(14, [1, 6], { highway: "residential" })],
]) };
const network = { intersections: [[1, { osm_ids: [1] }], [2, { osm_ids: [2] }]] };
assert.equal(normalizeManeuver("right;through"), "through;right");
assert.equal(normalizeManeuver("through;left"), "through;left");
assert.equal(normalizeManeuver("left;through;right"), "through;left;right");
assert.equal(normalizeManeuver("slight_right"), "slight_right");
const manifest = loadManifest();
assert.equal(manifest.assets.length, 14);
assert.deepEqual([...supportedAssets(manifest).keys()].sort(), ["left", "right", "through", "through;left", "through;left;right", "through;right"]);
const throughRight = templateFor("through;right", manifest);
const straightShaft = throughRight[0];
const shaftXs = straightShaft.map(([x]) => x);
assert.ok(Math.abs((Math.min(...shaftXs) + Math.max(...shaftXs)) / 2) < 1e-9, "the through shaft is anchored to the lane centerline");
const rightXs = templateFor("right", manifest).flat().map(([x]) => x);
assert.ok(Math.max(...rightXs) - Math.min(...rightXs) < 1.7, "source SVG scale stays comparable to existing lane arrows");
assert.equal(templateFor("through;left;right", manifest).length, 5, "triple maneuver has a shared straight shaft and two branches");
const disabled = buildCustomTurnLaneArrows(osm, { enabled: false, manifest });
assert.equal(disabled.features.length, 0);
assert.equal(disabled.diagnostics[0].reason, "disabled");
const result = buildCustomTurnLaneArrows(osm, { enabled: true, manifest, network });
assert.equal(result.features.length, 12, "each angular component remains a valid single Polygon feature");
assert.deepEqual([...new Set(result.features.map((feature) => feature.properties.custom_arrow_id))], [
"10:forward:0:through;right", "10:forward:1:left", "10:backward:0:right", "10:backward:1:through;left",
]);
assert.deepEqual(result.features.map((feature) => feature.properties.arrow_part), [
0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2,
]);
for (const feature of result.features) {
assert.equal(feature.geometry.type, "Polygon");
assert.equal(feature.properties.source, "osm_turn_lanes");
assert.ok(feature.properties.osm_way_id);
assert.ok(Number.isInteger(feature.properties.lane_index));
}
const forwardRight = result.features.find((feature) => feature.properties.custom_arrow_id === "10:forward:0:through;right");
const backwardRight = result.features.find((feature) => feature.properties.custom_arrow_id === "10:backward:0:right");
const centerY = (feature) => feature.geometry.coordinates[0].reduce((sum, point) => sum + point[1], 0) / feature.geometry.coordinates[0].length;
assert.ok(centerY(forwardRight) < nodes.get(2).lat, "forward arrow is placed before its endpoint");
assert.ok(centerY(backwardRight) > nodes.get(1).lat, "backward arrow is placed into its way from the endpoint");
const quadLanePolygons = [
{
type: "Feature",
properties: { type: "Driving", direction: "Fwd", index: 2, osm_way_ids: [10] },
geometry: { type: "Polygon", coordinates: [[[-0.00002, 0], [-0.00002, 0.001], [0.00002, 0.001], [0.00002, 0], [-0.00002, 0]]] },
},
{
type: "Feature",
properties: { type: "Driving", direction: "Back", index: 1, osm_way_ids: [10] },
geometry: { type: "Polygon", coordinates: [[[0.00002, 0], [0.00002, 0.001], [0.00006, 0.001], [0.00006, 0], [0.00002, 0]]] },
},
];
const centered = buildCustomTurnLaneArrows(osm, { enabled: true, manifest, network, lanePolygons: quadLanePolygons });
assert.ok(centered.features.length > 0);
assert.ok(centered.features
.filter((feature) => feature.properties.lane_index === 0)
.every((feature) => feature.properties.placement_source === "driving_lane_centerline"), "closed quadrilateral Driving lanes use their actual centerline");
const missingEndpoint = buildCustomTurnLaneArrows({ nodes, ways: new Map([[10, primary]]) }, { enabled: true, manifest });
assert.equal(missingEndpoint.features.length, 0);
assert.ok(missingEndpoint.diagnostics.every((entry) => entry.reason === "indeterminate_intersection_endpoint"));
const untested = way(20, [1, 2], { highway: "residential", "lanes:forward": "1", "turn:lanes:forward": "slight_left" });
const untestedResult = buildCustomTurnLaneArrows({ nodes, ways: new Map([[20, untested], [11, osm.ways.get(11)], [12, osm.ways.get(12)]]) }, { enabled: true, manifest });
assert.equal(untestedResult.features.length, 0);
assert.equal(untestedResult.diagnostics[0].reason, "unsupported_or_untested_maneuver");
function drivingLane(osmWayId, index, x) {
return {
type: "Feature",
properties: { type: "Driving", direction: "Fwd", index, osm_way_ids: [osmWayId] },
// The first edge ends at the junction and the opposite edge returns,
// matching osm2streets' Driving polygon winding.
geometry: { type: "Polygon", coordinates: [[
[x - 0.000006, 0.001], [x - 0.000006, -0.001],
[x + 0.000006, -0.001], [x + 0.000006, 0.001], [x - 0.000006, 0.001],
]] },
};
}
const splitWay = way(-500, [1, 2], {
highway: "residential", "lanes:forward": "2", "turn:lanes:forward": "through|through",
});
const splitOsm = { nodes, ways: new Map([[splitWay.id, splitWay]]) };
const splitResult = buildCustomTurnLaneArrows(splitOsm, {
enabled: true,
manifest,
network: { intersections: [[2, { osm_ids: [2] }]] },
// These are the pre-split rendered road IDs. Their index ordering is
// intentionally opposite to the physical left-to-right lane ordering.
lanePolygons: [drivingLane(9001, 99, -0.000015), drivingLane(9002, 1, 0.000015)],
});
assert.equal(splitResult.features.length, 2);
assert.ok(splitResult.features.every((feature) => feature.properties.placement_source === "spatial_driving_lane_centerline"));
const shaftCenter = (feature) => {
const ring = feature.geometry.coordinates[0];
return [(ring[3][0] + ring[4][0]) / 2, (ring[3][1] + ring[4][1]) / 2];
};
const splitLeft = splitResult.features.find((feature) => feature.properties.lane_index === 0);
const splitRight = splitResult.features.find((feature) => feature.properties.lane_index === 1);
assert.ok(Math.abs(shaftCenter(splitLeft)[0] + 0.000015) < 1e-9, "split-ID lane zero is centered on the physical left Driving lane");
assert.ok(Math.abs(shaftCenter(splitRight)[0] - 0.000015) < 1e-9, "split-ID lane one is centered on the physical right Driving lane");
assert.ok(shaftCenter(splitLeft)[1] < nodes.get(2).lat && shaftCenter(splitRight)[1] < nodes.get(2).lat, "spatial placement walks back along the rendered centerline from the intersection");
console.log("Turn-lane arrow tests passed.");