"use strict"; const fs = require("fs"); const path = require("path"); const { laneCenterline } = require("./lane-geometry"); const ASSET_MANIFEST = path.resolve(__dirname, "..", "..", "assets", "lane-icons", "manifest.json"); const LANE_WIDTH_METERS = 3.2; const PLACEMENT_DISTANCE_METERS = 9; const SPATIAL_MATCH_MAX_DISTANCE_METERS = 18; const SPATIAL_MATCH_MIN_ALIGNMENT = Math.cos(Math.PI / 6); // Existing osm2streets lane arrows are approximately 1.4 m across. Keep the // 25-unit upstream icon at the same on-road scale rather than at screen scale. const SVG_METERS_PER_UNIT = 0.10; function loadManifest(file = ASSET_MANIFEST) { const manifest = JSON.parse(fs.readFileSync(file, "utf8")); if (!Array.isArray(manifest.assets)) throw new Error("turn-lane asset manifest has no assets array"); return manifest; } function supportedAssets(manifest = loadManifest()) { return new Map(manifest.assets .filter((asset) => asset.supported === true && asset.tested === true) .map((asset) => [asset.id, asset])); } function buildCustomTurnLaneArrows(osm, options = {}) { const enabled = options.enabled === true; const diagnostics = []; if (!enabled) return { features: [], diagnostics: [{ reason: "disabled" }] }; const assets = supportedAssets(options.manifest); const endpointRoadCounts = roadCountsByNode(osm); const networkIntersectionNodes = new Set((options.network?.intersections || []) .flatMap(([, intersection]) => intersection.osm_ids || []).map(Number)); const features = []; const ways = [...osm.ways.values()].sort((a, b) => a.id - b.id); for (const way of ways) { for (const direction of ["forward", "backward"]) { const tag = way.tags[`turn:lanes:${direction}`]; if (!tag) continue; const laneCount = directionalLaneCount(way, direction); if (!laneCount) { diagnostics.push(skip(way, direction, "missing_lane_count")); continue; } const endpoint = endpointGeometry(osm, way, direction, endpointRoadCounts, networkIntersectionNodes); if (!endpoint) { diagnostics.push(skip(way, direction, "indeterminate_intersection_endpoint")); continue; } const maneuvers = String(tag).split("|").map((value) => normalizeManeuver(value)); for (let laneIndex = 0; laneIndex < maneuvers.length; laneIndex += 1) { const maneuver = maneuvers[laneIndex]; const asset = assets.get(maneuver); if (!asset) { diagnostics.push(skip(way, direction, "unsupported_or_untested_maneuver", { lane_index: laneIndex, maneuver })); continue; } if (laneIndex >= laneCount) { diagnostics.push(skip(way, direction, "lane_index_exceeds_lane_count", { lane_index: laneIndex, maneuver })); continue; } const resolvedPlacement = lanePlacement(way, direction, laneIndex, endpoint, options.lanePolygons, options.crosswalkStripes, options.stopLines); if (resolvedPlacement?.blocked) { diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver })); continue; } const placement = resolvedPlacement || fallbackLanePlacement(endpoint, direction, laneIndex, options.crosswalkStripes, options.stopLines); if (!placement) { diagnostics.push(skip(way, direction, "no_safe_turn_arrow_position", { lane_index: laneIndex, maneuver })); continue; } const parts = templateFor(asset.id, options.manifest); for (let partIndex = 0; partIndex < parts.length; partIndex += 1) { features.push(makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, parts[partIndex], placement.center, placement)); } } } } return { features, diagnostics }; } function normalizeManeuver(value) { const parts = String(value || "").split(";").map((part) => part.trim()).filter(Boolean).sort(); const supported = new Map([ ["through", "through"], ["left", "left"], ["right", "right"], ["left;through", "through;left"], ["right;through", "through;right"], ["left;right;through", "through;left;right"], ]); return supported.get(parts.join(";")) || parts.join(";"); } function directionalLaneCount(way, direction) { const specific = Number(way.tags[`lanes:${direction}`]); if (Number.isInteger(specific) && specific > 0) return specific; const total = Number(way.tags.lanes); if (Number.isInteger(total) && total > 0 && total % 2 === 0 && !isOneway(way)) return total / 2; if (Number.isInteger(total) && total > 0 && isOneway(way)) return total; return null; } function roadCountsByNode(osm) { const out = new Map(); for (const way of osm.ways.values()) { if (!way.tags.highway || way.tags.highway === "service") continue; for (const ref of new Set(way.refs)) out.set(ref, (out.get(ref) || 0) + 1); } return out; } function endpointGeometry(osm, way, direction, roadCounts, networkIntersectionNodes) { const forward = direction === "forward"; const endpointIndex = forward ? way.refs.length - 1 : 0; const neighborIndex = forward ? endpointIndex - 1 : 1; const node = osm.nodes.get(way.refs[endpointIndex]); const neighbor = osm.nodes.get(way.refs[neighborIndex]); if (!node || !neighbor) return null; const networkSaysIntersection = networkIntersectionNodes && networkIntersectionNodes.size > 0 && networkIntersectionNodes.has(node.id); if (!networkSaysIntersection && (roadCounts.get(node.id) || 0) < 3) return null; const meters = metersForLat(node.lat); // For both directions, point from the adjacent road node to the endpoint. // At a forward endpoint this is the OSM-way direction; at a backward // endpoint it is the reverse OSM-way direction, i.e. the actual travel // direction used by turn:lanes:backward. const raw = [node.lon - neighbor.lon, node.lat - neighbor.lat]; const axis = normalizeMetersVector(raw, meters); if (!axis) return null; return { node, axis, right: [axis[1], -axis[0]], meters }; } function laneCenter(endpoint, direction, laneIndex, meters) { const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS; // The local axis always follows travel, so moving back from either endpoint // places the marking on its approach lane before the intersection. return addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -PLACEMENT_DISTANCE_METERS, endpoint.right, lateral, meters); } function fallbackLanePlacement(endpoint, direction, laneIndex, crosswalkStripes, stopLines) { const lateral = (laneIndex + 0.5) * LANE_WIDTH_METERS; for (const distance of [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]) { const center = addMeters([endpoint.node.lon, endpoint.node.lat], endpoint.axis, -distance, endpoint.right, lateral, endpoint.meters); if (!nearIntersectionMarking(center, endpoint.axis, crosswalkStripes, stopLines, endpoint.meters)) { return { center, axis: endpoint.axis, right: endpoint.right, meters: endpoint.meters, placementDistance: distance, placementSource: "osm_way_fallback" }; } } return null; } function lanePlacement(way, direction, laneIndex, endpoint, lanePolygons, crosswalkStripes, stopLines) { if (!Array.isArray(lanePolygons)) return null; const expectedDirection = direction === "forward" ? "Fwd" : "Back"; const directionalCandidates = lanePolygons.filter((feature) => feature.properties?.type === "Driving" && feature.properties.direction === expectedDirection ); let candidates = directionalCandidates.filter((feature) => (feature.properties.osm_way_ids || []).map(Number).includes(way.id) ); let placementSource = "driving_lane_centerline"; let spatialAnchors = null; if (!candidates.length) { const ranked = directionalCandidates .map((feature) => ({ feature, anchor: spatialLaneAnchor(feature, endpoint) })) .filter(({ anchor }) => anchor) .filter(({ anchor }) => anchor.alignment >= SPATIAL_MATCH_MIN_ALIGNMENT && anchor.distance <= SPATIAL_MATCH_MAX_DISTANCE_METERS) .sort((a, b) => a.anchor.distance - b.anchor.distance || a.anchor.lateral - b.anchor.lateral || Number(a.feature.properties.index) - Number(b.feature.properties.index)); if (ranked.length) { // JOSM may split a tagged OSM way into temporary negative IDs. Those IDs // are absent from osm2streets' rendered polygons, so associate the full // physical approach by endpoint proximity and road-axis alignment. candidates = ranked.map(({ feature }) => feature); placementSource = "spatial_driving_lane_centerline"; spatialAnchors = new Map(ranked.map(({ feature, anchor }) => [feature, anchor])); } } candidates.sort((a, b) => { const lateralA = spatialAnchors?.get(a)?.lateral; const lateralB = spatialAnchors?.get(b)?.lateral; if (Number.isFinite(lateralA) && Number.isFinite(lateralB) && lateralA !== lateralB) return lateralA - lateralB; return Number(a.properties.index) - Number(b.properties.index); }); const lane = candidates[laneIndex]; const spatialAnchor = spatialAnchors?.get(lane); if (spatialAnchor) { const sampled = placementDistances().map((distance) => ({ center: sampleCenterlineAwayFromEndpoint(spatialAnchor, distance, endpoint.meters), distance, })).find(({ center }) => center && !nearIntersectionMarking(center, spatialAnchor.axis, crosswalkStripes, stopLines, endpoint.meters)); if (!sampled) return { blocked: true }; return { center: sampled.center, axis: spatialAnchor.axis, right: [spatialAnchor.axis[1], -spatialAnchor.axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource }; } const centerline = laneCenterline(lane); if (!centerline) return null; const startsAtEndpoint = direction === "backward"; const ordered = startsAtEndpoint ? centerline : [...centerline].reverse(); const sampled = [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42] .map((distance) => ({ center: samplePolyline(ordered, distance, endpoint.meters), distance })) .find(({ center }) => center && !nearIntersectionMarking(center, axisForLane(ordered, endpoint.meters), crosswalkStripes, stopLines, endpoint.meters)); if (!sampled) return { blocked: true }; const axis = axisForLane(ordered, endpoint.meters); if (!axis) return null; return { center: sampled.center, axis, right: [axis[1], -axis[0]], meters: endpoint.meters, placementDistance: sampled.distance, placementSource }; } function spatialLaneAnchor(lane, endpoint) { const centerline = laneCenterline(lane); if (!centerline) return null; let best = null; for (let index = 0; index < centerline.length - 1; index += 1) { const start = centerline[index]; const end = centerline[index + 1]; const point = closestPointOnSegment([endpoint.node.lon, endpoint.node.lat], start, end, endpoint.meters); const distance = Math.hypot((point[0] - endpoint.node.lon) * endpoint.meters.lon, (point[1] - endpoint.node.lat) * endpoint.meters.lat); const tangent = normalizeMetersVector(subtractPoint(end, start), endpoint.meters); if (!tangent || (best && distance >= best.distance)) continue; const dot = tangent[0] * endpoint.axis[0] + tangent[1] * endpoint.axis[1]; const axis = dot >= 0 ? tangent : [-tangent[0], -tangent[1]]; const offset = subtractPoint(point, [endpoint.node.lon, endpoint.node.lat]); best = { point, distance, axis, alignment: Math.abs(dot), lateral: offset[0] * endpoint.right[0] * endpoint.meters.lon + offset[1] * endpoint.right[1] * endpoint.meters.lat, centerline, segmentIndex: index, }; } return best; } function placementDistances() { return [PLACEMENT_DISTANCE_METERS, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42]; } function sampleCenterlineAwayFromEndpoint(anchor, distanceMeters, meters) { const { centerline, segmentIndex } = anchor; const start = centerline[segmentIndex]; const end = centerline[segmentIndex + 1]; const tangent = normalizeMetersVector(subtractPoint(end, start), meters); if (!tangent) return null; // Walk away from the junction along the rendered centerline. This preserves // curved or split lane geometry instead of approximating it with a tangent. const towardEnd = tangent[0] * anchor.axis[0] + tangent[1] * anchor.axis[1] < 0; const points = [anchor.point]; if (towardEnd) { for (let index = segmentIndex + 1; index < centerline.length; index += 1) points.push(centerline[index]); } else { for (let index = segmentIndex; index >= 0; index -= 1) points.push(centerline[index]); } return samplePolyline(points, distanceMeters, meters); } function closestPointOnSegment(point, start, end, meters) { const dx = (end[0] - start[0]) * meters.lon; const dy = (end[1] - start[1]) * meters.lat; const px = (point[0] - start[0]) * meters.lon; const py = (point[1] - start[1]) * meters.lat; const lengthSquared = dx * dx + dy * dy; const ratio = lengthSquared ? Math.max(0, Math.min(1, (px * dx + py * dy) / lengthSquared)) : 0; return [start[0] + ratio * (end[0] - start[0]), start[1] + ratio * (end[1] - start[1])]; } function axisForLane(ordered, meters) { return normalizeMetersVector(subtractPoint(ordered[0], ordered[1]), meters); } function nearIntersectionMarking(center, axis, stripes, stopLines, meters) { if (!axis) return true; const right = [axis[1], -axis[0]]; const samples = []; for (const forward of [-0.2, 0.5, 1.2, 1.9, 2.2]) { for (const lateral of [-1.6, -0.8, 0, 0.8, 1.6]) { samples.push(addMeters(center, axis, forward, right, lateral, meters)); } } return [...(stripes || []), ...(stopLines || [])].some((feature) => samples.some((point) => nearFeature(point, feature, meters))); } function nearFeature(point, feature, meters) { const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null; if (!ring?.length) return false; const xs = ring.map((coordinate) => coordinate[0]); const ys = ring.map((coordinate) => coordinate[1]); const clearance = 0.7; const dx = Math.max((Math.min(...xs) - point[0]) * meters.lon, 0, (point[0] - Math.max(...xs)) * meters.lon); const dy = Math.max((Math.min(...ys) - point[1]) * meters.lat, 0, (point[1] - Math.max(...ys)) * meters.lat); return Math.hypot(dx, dy) < clearance; } function subtractPoint([lon, lat], [otherLon, otherLat]) { return [lon - otherLon, lat - otherLat]; } function samplePolyline(points, distanceMeters, meters) { let remaining = distanceMeters; for (let index = 0; index < points.length - 1; index += 1) { const start = points[index]; const end = points[index + 1]; const vector = normalizeMetersVector(subtractPoint(end, start), meters); const length = Math.hypot((end[0] - start[0]) * meters.lon, (end[1] - start[1]) * meters.lat); if (!vector || !length) continue; if (remaining <= length) return addMeters(start, vector, remaining, [0, 0], 0, meters); remaining -= length; } return null; } function makeFeature(way, direction, laneIndex, maneuver, asset, partIndex, template, center, endpoint) { const ring = template.map(([rightMeters, forwardMeters]) => addMeters(center, endpoint.axis, forwardMeters, endpoint.right, rightMeters, endpoint.meters)); return { type: "Feature", properties: { type: "lane arrow", source: "osm_turn_lanes", osm_way_id: way.id, direction, lane_index: laneIndex, maneuver, source_asset: asset.id, source_asset_path: asset.source, arrow_part: partIndex, // SVG strokes and fills are expanded separately for GeoJSON validity. // This stable key lets the QGIS normalizer restore one rendered arrow. custom_arrow_id: `${way.id}:${direction}:${laneIndex}:${maneuver}`, placement_distance_meters: endpoint.placementDistance ?? PLACEMENT_DISTANCE_METERS, placement_source: endpoint.placementSource ?? "osm_way_fallback", }, geometry: { type: "Polygon", coordinates: [ring] }, }; } function skip(way, direction, reason, extra = {}) { return { source: "osm_turn_lanes", osm_way_id: way.id, direction, reason, ...extra }; } function isOneway(way) { return ["yes", "true", "1"].includes(String(way.tags.oneway || "").toLowerCase()); } function metersForLat(lat) { return { lon: 111320 * Math.cos((lat * Math.PI) / 180), lat: 110540 }; } function normalizeMetersVector([dxLon, dyLat], meters) { const x = dxLon * meters.lon; const y = dyLat * meters.lat; const length = Math.hypot(x, y); return length ? [x / length, y / length] : null; } function addMeters(center, axis, axisDistance, right, rightDistance, meters) { return [ center[0] + (axis[0] * axisDistance + right[0] * rightDistance) / meters.lon, center[1] + (axis[1] * axisDistance + right[1] * rightDistance) / meters.lat, ]; } function arrowRingsAt(maneuver, center, axis, manifest = loadManifest()) { const normalized = normalizeManeuver(maneuver); if (!supportedAssets(manifest).has(normalized) || !Array.isArray(center) || !Array.isArray(axis)) return []; const meters = metersForLat(center[1]); const length = Math.hypot(axis[0], axis[1]); if (!Number.isFinite(length) || length < 0.001) return []; const forward = [axis[0] / length, axis[1] / length]; const right = [forward[1], -forward[0]]; return templateFor(normalized, manifest).map((template) => template.map(([rightMeters, forwardMeters]) => addMeters(center, forward, forwardMeters, right, rightMeters, meters))); } function templateFor(assetId, manifest = loadManifest()) { const asset = supportedAssets(manifest).get(assetId); if (!asset) throw new Error(`Unsupported or untested turn-lane asset: ${assetId}`); return angularTemplate(assetId); } function angularTemplate(assetId) { const shaftWidth = 0.30; const shaftHalf = shaftWidth / 2; const straightBase = 1.18; const straightTip = 1.92; const rectangle = (minX, minY, maxX, maxY) => [ [minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY], [minX, minY], ]; const throughHead = () => [[0, straightTip], [-0.42, straightBase], [-shaftHalf, straightBase], [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, straightBase], [0.42, straightBase], [0, straightTip]]; const diagonalShaft = (side) => { const start = [0, 0.56]; const end = [side * 0.72, 0.96]; const length = Math.hypot(end[0] - start[0], end[1] - start[1]); const normal = [-(end[1] - start[1]) / length * shaftHalf, (end[0] - start[0]) / length * shaftHalf]; return [[start[0] + normal[0], start[1] + normal[1]], [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]], [start[0] - normal[0], start[1] - normal[1]], [start[0] + normal[0], start[1] + normal[1]]]; }; const diagonalHead = (side) => { const base = [side * 0.60, 0.89]; const tip = [side * 1.22, 1.24]; const length = Math.hypot(tip[0] - base[0], tip[1] - base[1]); const normal = [-(tip[1] - base[1]) / length * 0.36, (tip[0] - base[0]) / length * 0.36]; return [tip, [base[0] + normal[0], base[1] + normal[1]], [base[0] - normal[0], base[1] - normal[1]], tip]; }; const turnStem = (side) => { const cutMidpoint = 0.73; const cutRise = side * 0.084; return [ [-shaftHalf, 0], [shaftHalf, 0], [shaftHalf, cutMidpoint + cutRise], [-shaftHalf, cutMidpoint - cutRise], [-shaftHalf, 0], ]; }; if (assetId === "through") return [throughHead()]; if (assetId === "right") return [turnStem(1), diagonalShaft(1), diagonalHead(1)]; if (assetId === "left") return [turnStem(-1), diagonalShaft(-1), diagonalHead(-1)]; if (assetId === "through;right") return [throughHead(), diagonalShaft(1), diagonalHead(1)]; if (assetId === "through;left") return [throughHead(), diagonalShaft(-1), diagonalHead(-1)]; if (assetId === "through;left;right") return [throughHead(), diagonalShaft(-1), diagonalHead(-1), diagonalShaft(1), diagonalHead(1)]; throw new Error(`No angular turn-lane template: ${assetId}`); } function sourceSvgTemplateFor(asset, assetId) { const source = fs.readFileSync(path.resolve(__dirname, "..", "..", "assets", "lane-icons", asset.source), "utf8"); const mirrorX = asset.mirror_x === true; const anchorX = Number(asset.anchor_x); if (!Number.isFinite(anchorX)) throw new Error(`turn-lane asset has invalid anchor_x: ${assetId}`); const shapes = []; for (const match of source.matchAll(/]*)\/>|]*)\/>/g)) { const attrs = parseSvgAttrs(match[1] || match[2]); const strokeWidth = Number(attrs["stroke-width"] || 0); if (match[1]) { shapes.push(strokePolygon([[Number(attrs.x1), Number(attrs.y1)], [Number(attrs.x2), Number(attrs.y2)]], strokeWidth)); } else { const points = parseSvgPath(attrs.d || ""); if (attrs.fill !== "none") shapes.push(points); if (strokeWidth > 0) shapes.push(strokePolygon(points, strokeWidth)); } } return shapes.filter((ring) => ring.length >= 4).map((ring) => ring.map(([x, y]) => [ (mirrorX ? anchorX - x : x - anchorX) * SVG_METERS_PER_UNIT, (23 - y) * SVG_METERS_PER_UNIT, ])); } function parseSvgAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([\w:-]+)=(['"])(.*?)\2/g)) attrs[match[1]] = match[3]; return attrs; } function parseSvgPath(value) { const tokens = value.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) || []; let index = 0; let command = ""; let point = [0, 0]; let start = null; const points = []; const number = () => Number(tokens[index++]); const lineTo = (x, y) => { point = [x, y]; points.push(point); }; while (index < tokens.length) { if (/^[a-zA-Z]$/.test(tokens[index])) command = tokens[index++]; const relative = command === command.toLowerCase(); const op = command.toUpperCase(); if (op === "Z") { if (start) points.push(start); command = ""; continue; } if (op === "M" || op === "L") { const x = number(); const y = number(); const next = relative ? [point[0] + x, point[1] + y] : [x, y]; if (op === "M" && !start) { start = next; point = next; points.push(point); command = relative ? "l" : "L"; } else lineTo(...next); continue; } if (op === "H") { lineTo(relative ? point[0] + number() : number(), point[1]); continue; } if (op === "V") { lineTo(point[0], relative ? point[1] + number() : number()); continue; } if (op === "C") { const values = [number(), number(), number(), number(), number(), number()]; const controls = relative ? values.map((n, i) => n + point[i % 2]) : values; const origin = point; for (let step = 1; step <= 8; step += 1) { const t = step / 8; const u = 1 - t; lineTo(u ** 3 * origin[0] + 3 * u ** 2 * t * controls[0] + 3 * u * t ** 2 * controls[2] + t ** 3 * controls[4], u ** 3 * origin[1] + 3 * u ** 2 * t * controls[1] + 3 * u * t ** 2 * controls[3] + t ** 3 * controls[5]); } continue; } if (op === "A") { number(); number(); number(); number(); number(); const x = number(); const y = number(); lineTo(relative ? point[0] + x : x, relative ? point[1] + y : y); continue; } throw new Error(`Unsupported SVG path command: ${command}`); } return points; } function strokePolygon(points, width) { if (points.length < 2) return []; const half = width / 2; const left = []; const right = []; for (let index = 0; index < points.length; index += 1) { const prev = points[Math.max(0, index - 1)]; const next = points[Math.min(points.length - 1, index + 1)]; const dx = next[0] - prev[0]; const dy = next[1] - prev[1]; const length = Math.hypot(dx, dy) || 1; const nx = -dy / length * half; const ny = dx / length * half; left.push([points[index][0] + nx, points[index][1] + ny]); right.unshift([points[index][0] - nx, points[index][1] - ny]); } return [...left, ...right, left[0]]; } module.exports = { arrowRingsAt, buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };