#!/usr/bin/env node "use strict"; const assert = require("assert"); const { turnLaneArrows: { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor } } = require("@osm-asset/road-compiler"); 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 nativePlacement = arrowRingsAt("left", [114, 30], [0, 1], manifest); assert.ok(nativePlacement.length > 0 && nativePlacement.every((ring) => ring.every((point) => Number.isFinite(point[0]) && Number.isFinite(point[1])))); assert.deepEqual(arrowRingsAt("slight_left", [114, 30], [0, 1], manifest), []); 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.");