feat: add OSM turn lane arrows

This commit is contained in:
2026-08-05 10:17:34 +08:00
parent 9970e3eeef
commit 9a23f74f0f
34 changed files with 1540 additions and 33 deletions

View File

@@ -82,6 +82,9 @@ function normalizeAreaConfig(raw, options = {}) {
previewExtent: raw.qgis?.previewExtent ?? raw.previewExtent ?? null,
layerPrefix: raw.qgis?.layerPrefix ?? raw.layerPrefix ?? "osm2streets",
},
turnLaneArrows: {
enabled: booleanOption(raw.turnLaneArrows?.enabled, false, "turnLaneArrows.enabled"),
},
osm2streets: raw.osm2streets || {
debug_each_step: false,
dual_carriageway_experiment: false,

View File

@@ -0,0 +1,506 @@
"use strict";
const fs = require("fs");
const path = require("path");
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 laneCenterline(lane) {
const ring = lane?.geometry?.type === "Polygon" ? lane.geometry.coordinates?.[0] : null;
// A straight osm2streets Driving lane is commonly a closed quadrilateral:
// four distinct vertices plus the repeated closing vertex. Its opposing
// edges still provide the same two-point centerline as longer lane shapes.
if (!ring || ring.length < 5) return null;
// osm2streets Driving polygons are ordered along one boundary then back
// along the other. Midpoints of paired vertices form the rendered lane axis.
const vertices = ring.slice(0, -1);
const half = vertices.length / 2;
if (!Number.isInteger(half) || half < 2) return null;
return vertices.slice(0, half).map((point, index) => [
(point[0] + vertices[vertices.length - 1 - index][0]) / 2,
(point[1] + vertices[vertices.length - 1 - index][1]) / 2,
]);
}
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 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(/<line\b([^>]*)\/>|<path\b([^>]*)\/>/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 = { buildCustomTurnLaneArrows, loadManifest, normalizeManeuver, supportedAssets, templateFor };