491 lines
23 KiB
JavaScript
491 lines
23 KiB
JavaScript
"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 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 };
|