#!/usr/bin/env node const fs = require("fs"); const path = require("path"); const os = require("os"); const { execFileSync } = require("child_process"); const { JsStreetNetwork } = require("osm2streets-js-node"); const { qgisPaths } = require("./lib/tool-paths"); const { buildCustomTurnLaneArrows } = require("./lib/turn-lane-arrows"); const { readTrafficSignalFeatures } = require("./lib/traffic-signals"); const { SCENE_LAYERS, AUXILIARY_EDIT_LAYERS, SCENE_FILE, SCENE_STYLE_FILE, layerFile, mergeScene, sceneStyle, qgisRgba, } = require("./lib/scene-layers"); const repoRoot = path.resolve(__dirname, ".."); const args = parseArgs(process.argv.slice(2)); const configPath = path.resolve(args.config || path.join(repoRoot, "config", "default.json")); const config = loadConfig(configPath, args); const qgisApp = config.qgisApp; const qgis = qgisPaths(qgisApp); const qgisPython = qgis.python; const ogr2ogr = qgis.ogr2ogr; const normalizeLaneArrowsScript = path.join(repoRoot, "scripts", "normalize-lane-arrows.py"); const inputPath = path.resolve(config.input); const outDir = path.resolve(config.outDir); const gpkgPath = path.resolve(config.gpkg); const projectPath = path.resolve(config.project); const previewPath = path.resolve(config.preview); const arrowScale = Number(config.arrowScale); const arrowMergeTriangles = config.arrowMergeTriangles !== false; const arrowOutlineSimplifyMeters = Number(config.arrowOutlineSimplifyMeters ?? 0.05); const intersectionCornerSourceMaxDimensionMeters = Number(config.intersectionCornerSourceMaxDimensionMeters ?? 2.6); const clipPad = Number(config.clipPad); const canvasPad = Number(config.canvasPad); const previewPad = Number(config.previewPad); const layerPrefix = config.layerPrefix || "osm2streets"; const trafficSignalLayer = AUXILIARY_EDIT_LAYERS.find((layer) => layer.id === "traffic_signal_assemblies"); if (!trafficSignalLayer) throw new Error("Missing traffic_signal_assemblies auxiliary layer definition"); const trafficSignalAssembliesPath = path.resolve( config.trafficSignalAssemblies || path.join(outDir, trafficSignalLayer.file), ); if (!Number.isFinite(arrowScale) || arrowScale <= 0) { throw new Error(`Invalid arrowScale: ${config.arrowScale}`); } if (!Number.isFinite(arrowOutlineSimplifyMeters) || arrowOutlineSimplifyMeters < 0) { throw new Error(`Invalid arrowOutlineSimplifyMeters: ${config.arrowOutlineSimplifyMeters}`); } if (!Number.isFinite(intersectionCornerSourceMaxDimensionMeters) || intersectionCornerSourceMaxDimensionMeters <= 0) { throw new Error(`Invalid intersectionCornerSourceMaxDimensionMeters: ${config.intersectionCornerSourceMaxDimensionMeters}`); } for (const [key, value] of [["clipPad", clipPad], ["canvasPad", canvasPad], ["previewPad", previewPad]]) { if (!Number.isFinite(value) || value < 0) { throw new Error(`Invalid ${key}: ${config[key]}`); } } if (!fs.existsSync(inputPath)) { throw new Error(`Input OSM XML not found: ${inputPath}`); } for (const exe of [ogr2ogr, qgisPython]) { if (!fs.existsSync(exe)) { throw new Error(`QGIS executable not found: ${exe}`); } } if (!fs.existsSync(normalizeLaneArrowsScript)) { throw new Error(`Lane-arrow normalizer not found: ${normalizeLaneArrowsScript}`); } fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(path.dirname(gpkgPath), { recursive: true }); fs.mkdirSync(path.dirname(projectPath), { recursive: true }); fs.mkdirSync(path.dirname(previewPath), { recursive: true }); const xml = fs.readFileSync(inputPath, "utf8"); const bbox = getOsmBounds(xml); const osm = parseOsm(xml); const clip = makeClipPolygon(bbox, clipPad); const network = new JsStreetNetwork(xml, JSON.stringify(clip), config.osm2streets); writeGeoJson(outDir, "plain.geojson", network.toGeojsonPlain()); writeGeoJson(outDir, "lane_polygons.geojson", network.toLanePolygonsGeojson()); writeGeoJson(outDir, "lane_markings.geojson", network.toLaneMarkingsGeojson()); writeGeoJson(outDir, "intersection_markings.geojson", network.toIntersectionMarkingsGeojson()); fs.writeFileSync(path.join(outDir, "network.json"), network.toJson()); const split = splitLayers( outDir, arrowScale, intersectionCornerSourceMaxDimensionMeters, osm, ); const customTurnLaneArrows = buildCustomTurnLaneArrows(osm, { ...config.turnLaneArrows, network: JSON.parse(network.toJson()), lanePolygons: JSON.parse(fs.readFileSync(path.join(outDir, "lane_polygons.geojson"), "utf8")).features, crosswalkStripes: split.crosswalks.features, stopLines: split.vehicleStopLines.features, }); const suppressedStandardLaneArrows = suppressNearestStandardLaneArrows( split.laneArrows.features, customTurnLaneArrows.features, ); split.laneArrows.features = split.laneArrows.features.filter((feature) => !suppressedStandardLaneArrows.has(feature)); split.laneArrows.features.push(...customTurnLaneArrows.features); const turnLaneArrowDiagnostics = { enabled: config.turnLaneArrows?.enabled === true, generated: customTurnLaneArrows.features.length, suppressed_standard_lane_arrows: suppressedStandardLaneArrows.size, diagnostics: customTurnLaneArrows.diagnostics, }; fs.writeFileSync( path.join(outDir, "turn_lane_arrow_diagnostics.json"), `${JSON.stringify(turnLaneArrowDiagnostics, null, 2)}\n`, ); for (const layer of SCENE_LAYERS) { writeJson(path.join(outDir, layerFile(layer)), split[layer.splitKey]); } writeJson(trafficSignalAssembliesPath, readTrafficSignalFeatures( path.join(outDir, "vehicle_stop_lines.geojson"), path.join(outDir, "intersection_surface.geojson"), inputPath, )); if (arrowMergeTriangles) { normalizeLaneArrows(path.join(outDir, "lane_arrows_webscale.geojson"), arrowOutlineSimplifyMeters); split.laneArrows = JSON.parse(fs.readFileSync(path.join(outDir, "lane_arrows_webscale.geojson"), "utf8")); } writeJson(path.join(outDir, SCENE_FILE), mergeScene((layer) => split[layer.splitKey])); fs.writeFileSync( path.join(outDir, SCENE_STYLE_FILE), JSON.stringify(sceneStyle(), null, 2), ); if (fs.existsSync(gpkgPath)) { fs.unlinkSync(gpkgPath); } const ogrEnv = qgis.env; // First layer creates the GeoPackage; the rest append into it. SCENE_LAYERS.forEach((layer, index) => { importLayer(gpkgPath, path.join(outDir, layerFile(layer)), layer.id, index > 0, ogrEnv); }); importLayer(gpkgPath, trafficSignalAssembliesPath, trafficSignalLayer.id, true, ogrEnv); const qgisScript = path.join(outDir, "_create_qgis_project.py"); const previewFeature = split.crosswalks.features[0] || split.laneArrows.features[0] || split.roadSurface.features[0]; const defaultPreviewExtent = extentString(expandBounds( featureBounds(previewFeature), previewPad, )); fs.writeFileSync(qgisScript, makeQgisScript({ qgisPrefix: qgis.prefix, gpkgPath, projectPath, previewPath, layerPrefix, canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)), previewExtent: config.previewExtent || defaultPreviewExtent, trafficSignalSymbolPath: path.join(repoRoot, "assets", "qgis", "traffic-signal-direction.svg"), })); execFileSync(qgisPython, [qgisScript], { stdio: "inherit", env: { ...process.env, ...qgis.env, QT_QPA_PLATFORM: "offscreen", ...qgis.pythonEnv, }, }); fixCanvas(projectPath, config.canvasExtent || extentString(expandBounds(bbox, canvasPad))); console.log(`Config: ${configPath}`); console.log(`GeoPackage: ${gpkgPath}`); console.log(`QGIS project: ${projectPath}`); console.log(`Preview PNG: ${previewPath}`); function parseArgs(argv) { const out = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); const next = argv[i + 1]; if (!next || next.startsWith("--")) { out[key] = "true"; } else { out[key] = next; i += 1; } } return out; } function loadConfig(file, cliArgs) { if (!fs.existsSync(file)) { throw new Error(`Config file not found: ${file}`); } const base = JSON.parse(fs.readFileSync(file, "utf8")); const overrides = {}; const mapping = { qgisApp: "qgisApp", input: "input", outDir: "outDir", gpkg: "gpkg", project: "project", preview: "preview", arrowScale: "arrowScale", arrowMergeTriangles: "arrowMergeTriangles", arrowOutlineSimplifyMeters: "arrowOutlineSimplifyMeters", intersectionCornerSourceMaxDimensionMeters: "intersectionCornerSourceMaxDimensionMeters", clipPad: "clipPad", pad: "clipPad", canvasPad: "canvasPad", previewPad: "previewPad", canvasExtent: "canvasExtent", previewExtent: "previewExtent", }; for (const [argKey, configKey] of Object.entries(mapping)) { if (cliArgs[argKey] !== undefined) { overrides[configKey] = cliArgs[argKey]; } } if (process.env.QGIS_APP && overrides.qgisApp === undefined) { overrides.qgisApp = process.env.QGIS_APP; } const merged = deepMerge(base, overrides); const required = ["qgisApp", "input", "outDir", "gpkg", "project", "preview", "osm2streets"]; for (const key of required) { if (merged[key] === undefined || merged[key] === null || merged[key] === "") { throw new Error(`Missing config key: ${key}`); } } return merged; } function deepMerge(base, overrides) { const out = { ...base }; for (const [key, value] of Object.entries(overrides)) { if ( value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object" && !Array.isArray(base[key]) ) { out[key] = deepMerge(base[key], value); } else { out[key] = value; } } return out; } function getOsmBounds(xmlText) { // OSM exports may contain distant relation members (for example subway // nodes) that are not part of the requested map extent. Prefer the explicit // bounds element when present and only fall back to node extents for files // that do not provide one. const boundsMatch = xmlText.match(/]*)\/?\s*>/); if (boundsMatch) { const attrs = parseAttrs(boundsMatch[1]); const values = { minLon: Number(attrs.minlon), minLat: Number(attrs.minlat), maxLon: Number(attrs.maxlon), maxLat: Number(attrs.maxlat), }; if (Object.values(values).every(Number.isFinite)) { return values; } } let minLon = Infinity; let minLat = Infinity; let maxLon = -Infinity; let maxLat = -Infinity; for (const match of xmlText.matchAll(/]*)>/g)) { const attrs = match[1]; const latMatch = attrs.match(/\blat=(["'])(.*?)\1/); const lonMatch = attrs.match(/\blon=(["'])(.*?)\1/); if (!latMatch || !lonMatch) continue; const lat = Number(latMatch[2]); const lon = Number(lonMatch[2]); if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; minLon = Math.min(minLon, lon); maxLon = Math.max(maxLon, lon); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); } if (!Number.isFinite(minLon)) { throw new Error("No OSM node coordinates found"); } return { minLon, minLat, maxLon, maxLat }; } function parseOsm(xmlText) { const nodes = new Map(); const ways = new Map(); for (const match of xmlText.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { const attrs = parseAttrs(match[1]); const id = Number(attrs.id); const lat = Number(attrs.lat); const lon = Number(attrs.lon); if (!Number.isFinite(id) || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; nodes.set(id, { id, lat, lon, tags: parseTags(match[2] || "") }); } for (const match of xmlText.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = parseAttrs(match[1]); const id = Number(attrs.id); if (!Number.isFinite(id)) continue; const body = match[2] || ""; const refs = [...body.matchAll(/]*)\/>/g)] .map((refMatch) => Number(parseAttrs(refMatch[1]).ref)) .filter(Number.isFinite); ways.set(id, { id, refs, tags: parseTags(body) }); } return { nodes, ways }; } function parseAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([\w:.-]+)=(["'])(.*?)\2/g)) { attrs[match[1]] = decodeXml(match[3]); } return attrs; } function parseTags(text) { const tags = {}; for (const match of text.matchAll(/]*)\/>/g)) { const attrs = parseAttrs(match[1]); if (attrs.k !== undefined) tags[attrs.k] = attrs.v ?? ""; } return tags; } function decodeXml(value) { return value .replaceAll(""", "\"") .replaceAll("'", "'") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("&", "&"); } function makeClipPolygon(bbox, pad) { const b = expandBounds(bbox, pad); return { type: "FeatureCollection", features: [{ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[ [b.minLon, b.minLat], [b.maxLon, b.minLat], [b.maxLon, b.maxLat], [b.minLon, b.maxLat], [b.minLon, b.minLat], ]], }, }], }; } function expandBounds(bbox, pad) { return { minLon: bbox.minLon - pad, minLat: bbox.minLat - pad, maxLon: bbox.maxLon + pad, maxLat: bbox.maxLat + pad, }; } function centeredExtent(bbox, width, height) { const cx = (bbox.minLon + bbox.maxLon) / 2; const cy = (bbox.minLat + bbox.maxLat) / 2; return { minLon: cx - width / 2, minLat: cy - height / 2, maxLon: cx + width / 2, maxLat: cy + height / 2, }; } function extentString(bbox) { return `${bbox.minLon},${bbox.minLat},${bbox.maxLon},${bbox.maxLat}`; } function featureBounds(feature) { if (!feature?.geometry?.coordinates) { throw new Error("No feature available for preview extent"); } const coords = []; collectCoords(feature.geometry.coordinates, coords); if (!coords.length) { throw new Error("Preview feature has no coordinates"); } return { minLon: Math.min(...coords.map((p) => p[0])), minLat: Math.min(...coords.map((p) => p[1])), maxLon: Math.max(...coords.map((p) => p[0])), maxLat: Math.max(...coords.map((p) => p[1])), }; } function writeGeoJson(dir, name, content) { const file = path.join(dir, name); fs.writeFileSync(file, content); const count = JSON.parse(content).features?.length ?? 0; console.log(`${file}\tfeatures=${count}`); } function writeJson(file, value) { fs.writeFileSync(file, JSON.stringify(value)); console.log(`${file}\tfeatures=${value.features.length}`); } function emptyCollection() { return { type: "FeatureCollection", features: [] }; } function splitLayers(dir, arrowScaleValue, maxCornerDimensionMeters, osm) { const plain = JSON.parse(fs.readFileSync(path.join(dir, "plain.geojson"), "utf8")); const lanePolygons = JSON.parse(fs.readFileSync(path.join(dir, "lane_polygons.geojson"), "utf8")); const markings = JSON.parse(fs.readFileSync(path.join(dir, "lane_markings.geojson"), "utf8")); const intersections = JSON.parse(fs.readFileSync(path.join(dir, "intersection_markings.geojson"), "utf8")); const network = JSON.parse(fs.readFileSync(path.join(dir, "network.json"), "utf8")); const crosswalkData = buildCrosswalks(osm, lanePolygons.features); const serviceWayIds = new Set([...osm.ways.values()] .filter((way) => way.tags.highway === "service") .map((way) => way.id)); const out = { roadSurface: emptyCollection(), intersectionSurface: emptyCollection(), sidewalks: emptyCollection(), laneSeparators: emptyCollection(), centerLines: emptyCollection(), vehicleStopLines: crosswalkData.stopLines, laneArrows: emptyCollection(), sidewalkCorners: buildSidewalkCorners(intersections, plain, network, maxCornerDimensionMeters), crosswalks: crosswalkData.stripes, }; const serviceDrivingPolygons = []; for (const feature of plain.features || []) { if (feature.properties?.type === "intersection") { out.intersectionSurface.features.push(feature); } } for (const feature of lanePolygons.features || []) { const type = feature.properties?.type; if (type === "Sidewalk" || type === "Footway") { out.sidewalks.features.push(feature); } else { out.roadSurface.features.push(feature); if (type === "Driving" && hasAnyWayId(feature.properties?.osm_way_ids, serviceWayIds)) { serviceDrivingPolygons.push({ bbox: featureBounds(feature), rings: polygonRings(feature.geometry), }); } } } for (const feature of markings.features || []) { const type = feature.properties?.type; const conflictsWithCrosswalk = isInAnyPolygon(feature, crosswalkData.zones, "intersects"); if (type === "lane separator" && !conflictsWithCrosswalk) out.laneSeparators.features.push(feature); if (type === "center line" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) out.centerLines.features.push(feature); if (type === "lane arrow" && !conflictsWithCrosswalk && !isInAnyPolygon(feature, serviceDrivingPolygons)) { out.laneArrows.features.push(scaleFeature(feature, arrowScaleValue)); } } return out; } function hasAnyWayId(value, ids) { const values = Array.isArray(value) ? value : [value]; return values.some((id) => ids.has(Number(id))); } function filteredSidewalkCorners(intersections, maxDimensionMeters) { const out = emptyCollection(); for (const feature of intersections.features || []) { if (feature.properties?.type !== "sidewalk corner") continue; const dimension = maxFeatureDimensionMeters(feature); if (dimension === null || dimension > maxDimensionMeters) continue; out.features.push(feature); } return out; } function buildSidewalkCorners(intersections, plain, network, maxDimensionMeters) { const out = filteredSidewalkCorners(intersections, maxDimensionMeters); const missing = synthesizeMissingSidewalkCorners(out, plain, network, maxDimensionMeters); out.features.push(...missing); const caps = synthesizeTJunctionSidewalkCaps(out, plain, network, maxDimensionMeters); out.features.push(...caps); return out; } function synthesizeMissingSidewalkCorners(existing, plain, network, maxDimensionMeters) { const roadFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "road") .map((feature) => [Number(feature.properties.id), feature])); const intersectionFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "intersection") .map((feature) => [Number(feature.properties.id), feature])); const roads = new Map((network.roads || []).map(([id, road]) => [Number(id), road])); const intersections = new Map((network.intersections || []).map(([id, intersection]) => [Number(id), intersection])); const existingByIntersection = assignCornersToIntersections(existing.features || [], intersectionFeatures); const synthesized = []; for (const [intersectionId, intersection] of intersections.entries()) { const intersectionFeature = intersectionFeatures.get(intersectionId); if (!intersectionFeature || intersection.roads.length < 2) continue; const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); if (!edges.length) continue; const qualifyingPairs = qualifyingCornerPairs(edges); if (!qualifyingPairs.length) continue; const current = existingByIntersection.get(intersectionId) || []; const currentCenters = current.map((entry) => entry.center); const missing = []; for (const [one, two] of qualifyingPairs) { const candidate = synthesizeCornerFeature(one, two, intersectionFeature, maxDimensionMeters); if (!candidate) continue; const candidateCenter = featureCenter(candidate); if (!candidateCenter || !pointInPolygon(candidateCenter, intersectionFeature.geometry.coordinates)) continue; const dimension = maxFeatureDimensionMeters(candidate); if (dimension === null || dimension > maxDimensionMeters) continue; if (polygonAreaMeters2(candidate) < 0.4) continue; if (currentCenters.some((point) => pointDistance(point, candidateCenter) <= 0.6)) continue; missing.push(candidate); } if (missing.length !== 1) continue; synthesized.push(missing[0]); } return synthesized; } function synthesizeTJunctionSidewalkCaps(existing, plain, network, maxDimensionMeters) { const roadFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "road") .map((feature) => [Number(feature.properties.id), feature])); const intersectionFeatures = new Map((plain.features || []) .filter((feature) => feature.properties?.type === "intersection") .map((feature) => [Number(feature.properties.id), feature])); const roads = new Map((network.roads || []).map(([id, road]) => [Number(id), road])); const intersections = new Map((network.intersections || []).map(([id, intersection]) => [Number(id), intersection])); const existingByIntersection = assignCornersToIntersections(existing.features || [], intersectionFeatures); const synthesized = []; for (const [intersectionId, intersection] of intersections.entries()) { const intersectionFeature = intersectionFeatures.get(intersectionId); if (!intersectionFeature) continue; if (intersectionFeature.properties?.intersection_kind !== "Intersection") continue; if (new Set(intersection.roads || []).size !== 3) continue; const edges = buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature); if (!edges.length) continue; const current = existingByIntersection.get(intersectionId) || []; const smallCurrent = current.filter((entry) => { const dimension = maxFeatureDimensionMeters(entry.feature); return Number.isFinite(dimension) && dimension <= maxDimensionMeters; }); if (smallCurrent.length !== 2) continue; const candidates = []; for (const [one, two] of qualifyingCornerPairs(edges)) { const candidate = synthesizeCornerFeature(one, two, intersectionFeature, 100); if (!candidate) continue; const center = featureCenter(candidate); const dimension = maxFeatureDimensionMeters(candidate); const area = polygonAreaMeters2(candidate); if (!center || !Number.isFinite(dimension) || !Number.isFinite(area)) continue; candidates.push({ feature: candidate, center, dimension, area }); } // For a true T-junction, the remaining large candidate is the sidewalk "cap" // opposite the side street, not another curb-return corner. const caps = candidates.filter(({ dimension, area, center }) => ( dimension > maxDimensionMeters && dimension <= 12 && area >= 6 && area <= 20 && pointInPolygon(center, intersectionFeature.geometry.coordinates) && !smallCurrent.some((entry) => pointDistance(entry.center, center) <= 1) )); if (caps.length !== 1) continue; caps[0].feature.properties.source = "fallback_t_cap"; synthesized.push(caps[0].feature); } return synthesized; } function assignCornersToIntersections(features, intersectionFeatures) { const out = new Map(); for (const feature of features) { const point = featureCenter(feature); if (!point) continue; for (const [intersectionId, intersectionFeature] of intersectionFeatures.entries()) { if (!pointInPolygon(point, intersectionFeature.geometry.coordinates)) continue; const bucket = out.get(intersectionId) || []; bucket.push({ feature, center: point, }); out.set(intersectionId, bucket); break; } } return out; } function buildIntersectionEdges(intersection, roads, roadFeatures, intersectionFeature) { const edges = []; for (const roadId of intersection.roads || []) { const road = roads.get(Number(roadId)); const roadFeature = roadFeatures.get(Number(roadId)); if (!road || !roadFeature) return []; const geometry = roadEndpointGeometry(road, roadFeature, intersectionFeature, intersection.id); if (!geometry) return []; const first = road.dst_i === intersection.id ? makeRoadEdge(road, geometry, "right") : makeRoadEdge(road, geometry, "left"); const second = road.dst_i === intersection.id ? makeRoadEdge(road, geometry, "left") : makeRoadEdge(road, geometry, "right"); if (!first || !second) return []; edges.push(first, second); } return edges; } function qualifyingCornerPairs(edges) { if (!edges.length) return []; const loop = [...edges, edges[0]]; const pairs = []; for (let i = 0; i < loop.length - 1; i += 1) { const one = loop[i]; const two = loop[i + 1]; if (one.roadId === two.roadId) continue; if (!isWalkableOuterLane(one.laneType) || !isWalkableOuterLane(two.laneType)) continue; if (one.laneCount === 1 || two.laneCount === 1) continue; pairs.push([one, two]); } return pairs; } function isWalkableOuterLane(type) { return type === "Sidewalk" || type === "Shoulder"; } function roadEndpointGeometry(road, roadFeature, intersectionFeature, intersectionId) { const ring = normalizedRing(roadFeature.geometry.coordinates?.[0]); if (ring.length !== 4) return null; const shortEdges = shortEdgePairs(ring); if (!shortEdges) return null; const intersectionCenter = ringCenter(intersectionFeature.geometry.coordinates[0]); const candidates = shortEdges.map(([a, b]) => { const near = [ring[a], ring[b]]; return { pair: [a, b], center: midpoint(near[0], near[1]), distance: pointDistanceMeters(midpoint(near[0], near[1]), intersectionCenter, metersForLat(intersectionCenter[1])), }; }); candidates.sort((a, b) => a.distance - b.distance); const nearPair = candidates[0].pair; const farPair = candidates[1].pair; const nearPoints = nearPair.map((idx) => ring[idx]); const farPoints = farPair.map((idx) => ring[idx]); const nearCenter = midpoint(nearPoints[0], nearPoints[1]); const farCenter = midpoint(farPoints[0], farPoints[1]); const roadDirection = road.src_i === intersectionId ? normalizeLonLatVector([farCenter[0] - nearCenter[0], farCenter[1] - nearCenter[1]], nearCenter[1]) : normalizeLonLatVector([nearCenter[0] - farCenter[0], nearCenter[1] - farCenter[1]], nearCenter[1]); if (!roadDirection) return null; const correspondences = nearPair.map((idx) => { const farIdx = farPair.find((candidate) => circularIndexDistance(idx, candidate, ring.length) === 1); return farIdx === undefined ? null : [ring[idx], ring[farIdx]]; }); if (correspondences.some((pair) => !pair)) return null; const classified = correspondences.map(([nearPoint, farPoint]) => ({ near: nearPoint, far: farPoint, cross: signedSide(roadDirection, nearCenter, nearPoint, nearCenter[1]), })).sort((a, b) => a.cross - b.cross); return { nearCenter, nearLeft: classified[1].near, farLeft: classified[1].far, nearRight: classified[0].near, farRight: classified[0].far, }; } function shortEdgePairs(ring) { const lengths = ring.map((point, index) => lineLengthMeters(point, ring[(index + 1) % ring.length])); const optionA = lengths[0] + lengths[2]; const optionB = lengths[1] + lengths[3]; if (!Number.isFinite(optionA) || !Number.isFinite(optionB)) return null; return optionA <= optionB ? [[0, 1], [2, 3]] : [[1, 2], [3, 0]]; } function circularIndexDistance(a, b, size) { const distance = Math.abs(a - b); return Math.min(distance, size - distance); } function makeRoadEdge(road, geometry, side) { const lane = side === "left" ? road.lane_specs_ltr?.[0] : road.lane_specs_ltr?.[road.lane_specs_ltr.length - 1]; if (!lane) return null; const outerNear = side === "left" ? geometry.nearLeft : geometry.nearRight; const outerFar = side === "left" ? geometry.farLeft : geometry.farRight; const oppositeNear = side === "left" ? geometry.nearRight : geometry.nearLeft; const oppositeFar = side === "left" ? geometry.farRight : geometry.farLeft; const widthMeters = Number(lane.width) / 10000; const innerNear = moveTowards(outerNear, oppositeNear, widthMeters); const innerFar = moveTowards(outerFar, oppositeFar, widthMeters); return { roadId: road.id, laneType: lane.lt, laneCount: road.lane_specs_ltr?.length || 0, outerNear, innerNear, innerFar, }; } function synthesizeCornerFeature(one, two, intersectionFeature, maxDimensionMeters) { const ring = normalizedRing(intersectionFeature.geometry.coordinates?.[0]); const slice = shorterRingSliceBetween(ring, one.outerNear, two.outerNear); if (!slice || slice.length < 2) return null; const meetPoint = lineIntersection(one.innerFar, one.innerNear, two.innerFar, two.innerNear); const points = dedupeSequentialPoints([ ...slice, two.innerNear, ...(meetPoint && pointInPolygon(meetPoint, intersectionFeature.geometry.coordinates) ? [meetPoint] : []), one.innerNear, slice[0], ]); if (points.length < 4) return null; const feature = { type: "Feature", properties: { type: "sidewalk corner", source: "fallback", }, geometry: { type: "Polygon", coordinates: [points], }, }; const dimension = maxFeatureDimensionMeters(feature); if (dimension === null || dimension > maxDimensionMeters) return null; return feature; } function shorterRingSliceBetween(ring, start, end) { if (!ring.length) return null; const startIndex = nearestRingPointIndex(ring, start, 0.8); const endIndex = nearestRingPointIndex(ring, end, 0.8); if (startIndex === null || endIndex === null) return null; if (startIndex === endIndex) return [ring[startIndex]]; const forward = walkRing(ring, startIndex, endIndex, 1); const backward = walkRing(ring, startIndex, endIndex, -1); return pathLengthMeters(forward) <= pathLengthMeters(backward) ? forward : backward; } function walkRing(ring, startIndex, endIndex, direction) { const out = [ring[startIndex]]; let index = startIndex; while (index !== endIndex) { index = (index + direction + ring.length) % ring.length; out.push(ring[index]); } return out; } function pathLengthMeters(points) { let total = 0; for (let i = 1; i < points.length; i += 1) total += lineLengthMeters(points[i - 1], points[i]); return total; } function lineIntersection(a1, a2, b1, b2) { const originLat = (a1[1] + a2[1] + b1[1] + b2[1]) / 4; const meters = metersForLat(originLat); const ax1 = 0; const ay1 = 0; const ax2 = (a2[0] - a1[0]) * meters.lon; const ay2 = (a2[1] - a1[1]) * meters.lat; const bx1 = (b1[0] - a1[0]) * meters.lon; const by1 = (b1[1] - a1[1]) * meters.lat; const bx2 = (b2[0] - a1[0]) * meters.lon; const by2 = (b2[1] - a1[1]) * meters.lat; const denominator = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1); if (Math.abs(denominator) < 1e-9) return null; const ua = ((bx2 - bx1) * (ay1 - by1) - (by2 - by1) * (ax1 - bx1)) / denominator; return [ a1[0] + ((ax1 + ua * (ax2 - ax1)) / meters.lon), a1[1] + ((ay1 + ua * (ay2 - ay1)) / meters.lat), ]; } function normalizedRing(ring) { if (!Array.isArray(ring) || ring.length < 4) return []; const out = ring.map((point) => [point[0], point[1]]); if (pointDistance(out[0], out[out.length - 1]) <= 0.02) out.pop(); return out; } function nearestRingPointIndex(ring, point, maxDistanceMeters) { let bestIndex = null; let bestDistance = Infinity; for (let i = 0; i < ring.length; i += 1) { const distance = pointDistance(ring[i], point); if (distance < bestDistance) { bestDistance = distance; bestIndex = i; } } return bestDistance <= maxDistanceMeters ? bestIndex : null; } function nearestRingPoint(ring, point, maxDistanceMeters) { const normalized = normalizedRing(ring); const index = nearestRingPointIndex(normalized, point, maxDistanceMeters); return index === null ? null : normalized[index]; } function dedupeSequentialPoints(points, toleranceMeters = 0.02) { const out = []; for (const point of points) { if (!out.length || pointDistance(out[out.length - 1], point) > toleranceMeters) out.push(point); } if (out.length >= 2 && pointDistance(out[0], out[out.length - 1]) > toleranceMeters) out.push(out[0]); return out; } function dedupePointList(points, toleranceMeters) { const out = []; for (const point of points) { if (out.some((other) => pointDistance(point, other) <= toleranceMeters)) continue; out.push(point); } return out; } function midpoint(a, b) { return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; } function moveTowards(from, to, distanceMeters) { const meters = metersForLat((from[1] + to[1]) / 2); const unit = normalizeMetersVector([to[0] - from[0], to[1] - from[1]], meters); if (!unit) return from; return addMeters(from, unit, distanceMeters, meters); } function normalizeLonLatVector([dxLon, dyLat], lat) { return normalizeMetersVector([dxLon, dyLat], metersForLat(lat)); } function signedSide(direction, origin, point, lat) { const meters = metersForLat(lat); const dx = (point[0] - origin[0]) * meters.lon; const dy = (point[1] - origin[1]) * meters.lat; return direction[0] * dy - direction[1] * dx; } function pointDistance(a, b) { return lineLengthMeters(a, b); } function lineLengthMeters(a, b) { const meters = metersForLat((a[1] + b[1]) / 2); return Math.hypot((a[0] - b[0]) * meters.lon, (a[1] - b[1]) * meters.lat); } function ringCenter(ring) { const points = normalizedRing(ring); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } function polygonAreaMeters2(feature) { const ring = normalizedRing(feature.geometry?.coordinates?.[0]); if (ring.length < 3) return 0; const meters = metersForLat(ring.reduce((sum, point) => sum + point[1], 0) / ring.length); let area = 0; for (let i = 0; i < ring.length; i += 1) { const a = ring[i]; const b = ring[(i + 1) % ring.length]; area += (a[0] * meters.lon) * (b[1] * meters.lat) - (b[0] * meters.lon) * (a[1] * meters.lat); } return Math.abs(area) / 2; } function maxFeatureDimensionMeters(feature) { const points = []; collectCoords(feature.geometry?.coordinates, points); if (points.length === 0) return null; const lat = points.reduce((sum, point) => sum + point[1], 0) / points.length; const meters = metersForLat(lat); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const width = (Math.max(...xs) - Math.min(...xs)) * meters.lon; const height = (Math.max(...ys) - Math.min(...ys)) * meters.lat; return Math.max(width, height); } function polygonRings(geometry) { if (!geometry?.coordinates) return []; if (geometry.type === "Polygon") return [geometry.coordinates]; if (geometry.type === "MultiPolygon") return geometry.coordinates; return []; } function isInAnyPolygon(feature, polygons, mode = "point") { if (!polygons.length) return false; const featureBbox = featureBounds(feature); const points = mode === "intersects" ? featurePoints(feature) : [representativePoint(feature)].filter(Boolean); if (!points.length) return false; return polygons.some(({ bbox, rings }) => ( bboxesOverlap(featureBbox, bbox, 0.00001) && points.some((point) => ( point[0] >= bbox.minLon - 0.00001 && point[0] <= bbox.maxLon + 0.00001 && point[1] >= bbox.minLat - 0.00001 && point[1] <= bbox.maxLat + 0.00001 && rings.some((polygon) => pointInPolygon(point, polygon)) )) )); } function featurePoints(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return []; return [ coords[0], coords[Math.floor(coords.length / 2)], coords[coords.length - 1], ...bboxCorners(featureBounds(feature)), ]; } function bboxCorners(bbox) { return [ [bbox.minLon, bbox.minLat], [bbox.maxLon, bbox.minLat], [bbox.maxLon, bbox.maxLat], [bbox.minLon, bbox.maxLat], ]; } function bboxesOverlap(a, b, pad = 0) { return a.minLon <= b.maxLon + pad && a.maxLon >= b.minLon - pad && a.minLat <= b.maxLat + pad && a.maxLat >= b.minLat - pad; } function representativePoint(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return null; return coords[Math.floor(coords.length / 2)]; } function featureCenter(feature) { const coords = []; collectCoords(feature.geometry?.coordinates, coords); if (!coords.length) return null; const xs = coords.map((point) => point[0]); const ys = coords.map((point) => point[1]); return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } function suppressNearestStandardLaneArrows(standardFeatures, customFeatures) { const groups = new Map(); for (const feature of customFeatures) { const id = feature.properties?.custom_arrow_id; if (!id) continue; const group = groups.get(id) || []; group.push(feature); groups.set(id, group); } const suppressed = new Set(); for (const parts of groups.values()) { const center = featureGroupCenter(parts); if (!center) continue; let closest = null; for (const feature of standardFeatures) { if (suppressed.has(feature)) continue; const candidateCenter = featureCenter(feature); if (!candidateCenter) continue; const distance = lineLengthMeters(center, candidateCenter); if (distance > 5.5 || (closest && distance >= closest.distance)) continue; closest = { feature, distance }; } if (closest) suppressed.add(closest.feature); } return suppressed; } function featureGroupCenter(features) { const points = []; for (const feature of features) collectCoords(feature.geometry?.coordinates, points); if (!points.length) return null; return [ (Math.min(...points.map((point) => point[0])) + Math.max(...points.map((point) => point[0]))) / 2, (Math.min(...points.map((point) => point[1])) + Math.max(...points.map((point) => point[1]))) / 2, ]; } function pointInPolygon(point, rings) { if (!rings?.length || !pointInRing(point, rings[0])) return false; return !rings.slice(1).some((ring) => pointInRing(point, ring)); } function pointInRing([x, y], ring) { let inside = false; for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { const xi = ring[i][0]; const yi = ring[i][1]; const xj = ring[j][0]; const yj = ring[j][1]; const intersect = ((yi > y) !== (yj > y)) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi; if (intersect) inside = !inside; } return inside; } function buildCrosswalks(osm, lanePolygons = []) { const crossingNodes = markedCrossingNodes(osm); const clusterCenters = crossingClusters(crossingNodes); const stripes = emptyCollection(); const stopLines = emptyCollection(); const zones = []; const fixedCenters = []; for (const node of crossingNodes) { const way = findCrossingWay(osm, node.id); const vector = crossingVector(osm, way, node.id); if (!vector) continue; const crosswalk = crosswalkGeometry(node, way, vector, clusterCenters.get(node.id), lanePolygons); if (!crosswalk) continue; if (fixedCenters.some((center) => pointDistanceMeters(center, crosswalk.center, crosswalk.meters) < 1)) continue; fixedCenters.push(crosswalk.center); stripes.features.push(...crosswalk.stripes); if (crosswalk.stopLine) stopLines.features.push(crosswalk.stopLine); zones.push({ bbox: featureBounds(crosswalk.zone), rings: polygonRings(crosswalk.zone.geometry), }); } return { stripes, stopLines, zones }; } function markedCrossingNodes(osm) { return [...osm.nodes.values()].filter((node) => { if (node.tags.highway !== "crossing") return false; const markings = node.tags["crossing:markings"]; return !(markings && ["no", "none", "unmarked"].includes(markings)); }); } function crossingNodeClusters(nodes) { const clusters = []; for (const node of nodes) { let cluster = clusters.find((candidate) => { const center = clusterCenter(candidate); return distanceMeters(node, center) <= 45; }); if (!cluster) { cluster = []; clusters.push(cluster); } cluster.push(node); } return clusters; } function crossingClusters(nodes) { const clusters = crossingNodeClusters(nodes); const centers = new Map(); for (const cluster of clusters) { if (cluster.length < 2) continue; const center = clusterCenter(cluster); for (const node of cluster) centers.set(node.id, center); } return centers; } function clusterCenter(nodes) { return { lon: nodes.reduce((sum, node) => sum + node.lon, 0) / nodes.length, lat: nodes.reduce((sum, node) => sum + node.lat, 0) / nodes.length, }; } function distanceMeters(a, b) { const meters = metersForLat((a.lat + b.lat) / 2); return Math.hypot((a.lon - b.lon) * meters.lon, (a.lat - b.lat) * meters.lat); } function findCrossingWay(osm, nodeId) { const candidates = [...osm.ways.values()] .filter((way) => way.tags.highway && way.refs.includes(nodeId)); return candidates.find((way) => way.tags.highway !== "service") || candidates[0] || null; } function crossingVector(osm, way, nodeId) { if (!way) return null; const index = way.refs.indexOf(nodeId); const prev = osm.nodes.get(way.refs[index - 1]); const next = osm.nodes.get(way.refs[index + 1]); const current = osm.nodes.get(nodeId); if (prev && next) return [next.lon - prev.lon, next.lat - prev.lat]; if (prev && current) return [current.lon - prev.lon, current.lat - prev.lat]; if (next && current) return [next.lon - current.lon, next.lat - current.lat]; return null; } function crosswalkGeometry(node, way, vector, intersectionCenter, lanePolygons) { const meters = metersForLat(node.lat); const stripeLength = crosswalkLengthMeters(way); const stripeWidth = 0.45; const gap = 0.45; const count = 6; const total = count * stripeWidth + (count - 1) * gap; const rawRoadUnit = normalizeMetersVector(vector, meters); if (!rawRoadUnit) return null; const provisionalCenter = fixedCrosswalkCenter(node, rawRoadUnit, intersectionCenter, meters); const laneFrame = crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters); const roadUnit = laneFrame?.roadUnit || rawRoadUnit; const acrossUnit = [-roadUnit[1], roadUnit[0]]; const center = laneFrame?.center || provisionalCenter; const zoneCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 0.8, total + 0.8, meters); const zone = { type: "Feature", properties: { type: "crosswalk zone", crossing_node_id: node.id, }, geometry: { type: "Polygon", coordinates: [zoneCoords] }, }; const stripes = []; for (let i = 0; i < count; i += 1) { const offset = -total / 2 + stripeWidth / 2 + i * (stripeWidth + gap); const stripeCenter = addMeters(center, acrossUnit, offset, meters); const coords = rectangleMeters(stripeCenter, roadUnit, acrossUnit, stripeLength, stripeWidth, meters); stripes.push({ type: "Feature", properties: { type: "crosswalk stripe", crossing_node_id: node.id, highway: way?.tags.highway || null, }, geometry: { type: "Polygon", coordinates: [coords] }, }); } return { stripes, center, meters, zone, stopLine: syntheticStopLine(center, node.id, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters), }; } function crosswalkLaneFrame(way, lanePolygons, provisionalCenter, rawRoadUnit, meters) { if (!way || !Array.isArray(lanePolygons)) return null; const candidates = lanePolygons .filter((feature) => feature.properties?.type === "Driving" && hasAnyWayId(feature.properties?.osm_way_ids, new Set([way.id]))) .map((feature) => nearestLaneAnchor(feature, provisionalCenter, meters)) .filter(Boolean) .sort((a, b) => a.distance - b.distance); if (!candidates.length) return null; // A way may be represented by adjacent normalized road pieces. Keep the // nearby cross-section, including every directional lane, not a distant // piece that happens to retain the same OSM way ID. const maxDistance = candidates[0].distance + 8; const anchors = candidates.filter((anchor) => anchor.distance <= maxDistance); if (!anchors.length) return null; const center = anchors.reduce((sum, anchor) => [sum[0] + anchor.point[0], sum[1] + anchor.point[1]], [0, 0]) .map((value) => value / anchors.length); const axis = anchors.reduce((sum, anchor) => { const sign = anchor.tangent[0] * rawRoadUnit[0] + anchor.tangent[1] * rawRoadUnit[1] >= 0 ? 1 : -1; return [sum[0] + anchor.tangent[0] * sign, sum[1] + anchor.tangent[1] * sign]; }, [0, 0]); const roadUnit = normalizeMetersVector(axis, { lon: 1, lat: 1 }); return roadUnit ? { center, roadUnit } : null; } function nearestLaneAnchor(feature, point, meters) { const centerline = drivingLaneCenterline(feature); 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 closest = closestPointOnSegment(point, start, end, meters); const distance = pointDistanceMeters(closest, point, meters); const tangent = normalizeMetersVector([end[0] - start[0], end[1] - start[1]], meters); if (tangent && (!best || distance < best.distance)) best = { point: closest, distance, tangent }; } return best; } function drivingLaneCenterline(feature) { const ring = feature.geometry?.type === "Polygon" ? feature.geometry.coordinates?.[0] : null; if (!ring || ring.length < 5) return null; 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 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 fixedCrosswalkCenter(node, roadUnit, intersectionCenter, meters) { if (!intersectionCenter) return [node.lon, node.lat]; const center = [intersectionCenter.lon, intersectionCenter.lat]; const candidates = [ addMeters(center, roadUnit, 7, meters), addMeters(center, roadUnit, -7, meters), ]; const source = [node.lon, node.lat]; return pointDistanceMeters(candidates[0], source, meters) <= pointDistanceMeters(candidates[1], source, meters) ? candidates[0] : candidates[1]; } function syntheticStopLine(crosswalkCenter, crossingNodeId, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) { if (!intersectionCenter) return null; const offset = stripeLength / 2 + 1.2; const candidateA = addMeters(crosswalkCenter, roadUnit, offset, meters); const candidateB = addMeters(crosswalkCenter, roadUnit, -offset, meters); const stopSide = pointDistanceMeters(candidateA, intersectionCenter, meters) >= pointDistanceMeters(candidateB, intersectionCenter, meters) ? 1 : -1; const stopCenterOnRoad = stopSide === 1 ? candidateA : candidateB; const placement = stopLineLanePlacement(way, stripeLength, stopSide); const stopCenter = addMeters(stopCenterOnRoad, acrossUnit, placement.acrossOffset, meters); const coords = rectangleMeters(stopCenter, acrossUnit, roadUnit, placement.length, 0.45, meters); return { type: "Feature", properties: { type: "vehicle stop line", source: "crosswalk", crossing_node_id: crossingNodeId, highway: way?.tags.highway || null, stop_side: stopSide === 1 ? "with_way_outside" : "against_way_outside", }, geometry: { type: "Polygon", coordinates: [coords] }, }; } function pointDistanceMeters(pointA, point, meters) { const [lon, lat] = Array.isArray(pointA) ? pointA : [pointA.lon, pointA.lat]; const px = Array.isArray(point) ? point[0] : point.lon; const py = Array.isArray(point) ? point[1] : point.lat; return Math.hypot((lon - px) * meters.lon, (lat - py) * meters.lat); } function stopLineLanePlacement(way, roadWidth, stopSide) { if (isOneway(way)) { return { length: Math.max(2.8, roadWidth - 1.4), acrossOffset: 0 }; } const halfWidth = roadWidth / 2; const innerGap = 0.15; const outerGap = 0.8; const laneWidth = Math.max(2.4, halfWidth - innerGap - outerGap); // China uses right-hand traffic. If the stop line is on the +roadUnit side, // the approaching traffic direction is -roadUnit, whose right side is +acrossUnit. const laneSide = stopSide === 1 ? 1 : -1; return { length: laneWidth, acrossOffset: laneSide * (innerGap + laneWidth / 2), }; } function isOneway(way) { const value = String(way?.tags.oneway || "").toLowerCase(); return ["yes", "true", "1"].includes(value); } function crosswalkLengthMeters(way) { const lanes = Number(way?.tags.lanes); if (Number.isFinite(lanes) && lanes > 0) return Math.max(5, lanes * 3.2 + 1.2); if (way?.tags.highway === "secondary") return 8.5; if (way?.tags.highway === "service") return 4.5; return 7.5; } 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); if (!Number.isFinite(length) || length === 0) return null; return [x / length, y / length]; } function addMeters([lon, lat], [ux, uy], distance, meters) { return [lon + (ux * distance) / meters.lon, lat + (uy * distance) / meters.lat]; } function rectangleMeters(center, axisUnit, acrossUnit, axisWidth, acrossLength, meters) { const halfAxis = axisWidth / 2; const halfAcross = acrossLength / 2; const corners = [ [-halfAxis, -halfAcross], [halfAxis, -halfAcross], [halfAxis, halfAcross], [-halfAxis, halfAcross], [-halfAxis, -halfAcross], ]; return corners.map(([axis, across]) => { const x = axisUnit[0] * axis + acrossUnit[0] * across; const y = axisUnit[1] * axis + acrossUnit[1] * across; return [center[0] + x / meters.lon, center[1] + y / meters.lat]; }); } function scaleFeature(feature, scale) { const coords = []; collectCoords(feature.geometry.coordinates, coords); if (!coords.length) return feature; const xs = coords.map((p) => p[0]); const ys = coords.map((p) => p[1]); const cx = (Math.min(...xs) + Math.max(...xs)) / 2; const cy = (Math.min(...ys) + Math.max(...ys)) / 2; return { type: "Feature", properties: { ...feature.properties, render_scale: scale }, geometry: { ...feature.geometry, coordinates: scaleCoords(feature.geometry.coordinates, cx, cy, scale), }, }; } function collectCoords(obj, acc) { if (Array.isArray(obj) && obj.length >= 2 && typeof obj[0] === "number" && typeof obj[1] === "number") { acc.push([obj[0], obj[1]]); } else if (Array.isArray(obj)) { for (const item of obj) collectCoords(item, acc); } } function scaleCoords(obj, cx, cy, scale) { if (Array.isArray(obj) && obj.length >= 2 && typeof obj[0] === "number" && typeof obj[1] === "number") { return [cx + (obj[0] - cx) * scale, cy + (obj[1] - cy) * scale, ...obj.slice(2)]; } if (Array.isArray(obj)) { return obj.map((item) => scaleCoords(item, cx, cy, scale)); } return obj; } function normalizeLaneArrows(geojsonPath, outlineSimplifyMeters) { execFileSync(qgisPython, [ normalizeLaneArrowsScript, "--input", geojsonPath, "--outline-simplify-meters", String(outlineSimplifyMeters), ], { stdio: "inherit", env: { ...process.env, ...qgis.env, QT_QPA_PLATFORM: "offscreen", ...qgis.pythonEnv, }, }); } function importLayer(gpkg, source, layerName, update, env) { const args = ["-f", "GPKG"]; if (update) args.push("-update", "-overwrite"); args.push(gpkg, source, "-nln", layerName); execFileSync(ogr2ogr, args, { stdio: "inherit", env: { ...process.env, ...env } }); } function qgisLayerSpecs() { return SCENE_LAYERS.map((layer) => ({ id: layer.id, title: layer.title, fill: qgisRgba(layer.fill), outline: qgisRgba(layer.outline, layer.outlineAlpha ?? 255), outlineWidth: String(layer.outlineWidth), })); } function makeQgisScript(options) { return ` from pathlib import Path from qgis.PyQt.QtCore import QSize from qgis.PyQt.QtGui import QColor, QImage, QPainter from qgis.core import ( QgsApplication, QgsCoordinateReferenceSystem, QgsEditorWidgetSetup, QgsFieldConstraints, QgsFillSymbol, QgsMarkerSymbol, QgsMapRendererCustomPainterJob, QgsMapSettings, QgsProject, QgsPalLayerSettings, QgsProperty, QgsRectangle, QgsSingleSymbolRenderer, QgsSymbolLayer, QgsSvgMarkerSymbolLayer, QgsVectorLayerSimpleLabeling, QgsVectorLayer, ) QGIS_PREFIX = ${JSON.stringify(options.qgisPrefix)} TRAFFIC_SIGNAL_SYMBOL = ${JSON.stringify(options.trafficSignalSymbolPath)} GPKG = ${JSON.stringify(options.gpkgPath)} PROJECT_PATH = ${JSON.stringify(options.projectPath)} PREVIEW_PATH = ${JSON.stringify(options.previewPath)} PREVIEW_EXTENT = [${options.previewExtent.split(",").map(Number).join(", ")}] LAYER_PREFIX = ${JSON.stringify(options.layerPrefix || "osm2streets")} LAYER_SPECS = ${JSON.stringify(qgisLayerSpecs(), null, 4)} try: IMAGE_FORMAT = QImage.Format.Format_ARGB32_Premultiplied except AttributeError: IMAGE_FORMAT = QImage.Format_ARGB32_Premultiplied try: NOT_NULL_CONSTRAINT = QgsFieldConstraints.Constraint.ConstraintNotNull except AttributeError: NOT_NULL_CONSTRAINT = QgsFieldConstraints.ConstraintNotNull def fill_symbol(color, outline="0,0,0,0", outline_width="0"): return QgsFillSymbol.createSimple({ "color": color, "outline_color": outline, "outline_width": outline_width, "outline_width_unit": "MM", "joinstyle": "round", }) def make_layer(layer_name, title, color, outline="0,0,0,0", outline_width="0"): layer = QgsVectorLayer(f"{GPKG}|layername={layer_name}", title, "ogr") if not layer.isValid(): raise RuntimeError(f"Invalid layer: {title}") layer.setRenderer(QgsSingleSymbolRenderer(fill_symbol(color, outline, outline_width))) return layer def make_signal_layer(): layer = QgsVectorLayer(f"{GPKG}|layername=traffic_signal_assemblies", f"{LAYER_PREFIX} traffic signal assemblies", "ogr") if not layer.isValid(): raise RuntimeError("Invalid traffic signal assemblies layer") symbol = QgsMarkerSymbol() svg_layer = QgsSvgMarkerSymbolLayer(TRAFFIC_SIGNAL_SYMBOL, 9) svg_layer.setDataDefinedProperty( QgsSymbolLayer.Property.Angle, QgsProperty.fromField("heading_deg"), ) symbol.changeSymbolLayer(0, svg_layer) layer.setRenderer(QgsSingleSymbolRenderer(symbol)) labels = QgsPalLayerSettings() labels.fieldName = "if(trim(display_id) = '', signal_uid, display_id)" labels.isExpression = True layer.setLabeling(QgsVectorLayerSimpleLabeling(labels)) layer.setLabelsEnabled(True) for field_name in ("signal_uid", "control_id", "approach_id", "source_way_id", "stop_lon", "stop_lat"): index = layer.fields().indexOf(field_name) if index >= 0: layer.setFieldConstraint(index, NOT_NULL_CONSTRAINT) form = layer.editFormConfig() form.setReadOnly(index, True) layer.setEditFormConfig(form) enabled_index = layer.fields().indexOf("enabled") if enabled_index >= 0: layer.setEditorWidgetSetup(enabled_index, QgsEditorWidgetSetup("CheckBox", {"CheckedState": "1", "UncheckedState": "0"})) phase_index = layer.fields().indexOf("phase_group") if phase_index >= 0: layer.setEditorWidgetSetup(phase_index, QgsEditorWidgetSetup("ValueMap", {"map": [{"Phase 0": 0}, {"Phase 1": 1}]})) return layer QgsApplication.setPrefixPath(QGIS_PREFIX, True) app = QgsApplication([], False) app.initQgis() project = QgsProject.instance() project.clear() project.setFileName(PROJECT_PATH) project.setCrs(QgsCoordinateReferenceSystem("EPSG:4326")) project.setPresetHomePath(str(Path(PROJECT_PATH).parent)) layers = { spec["id"]: make_layer( spec["id"], f"{LAYER_PREFIX} {spec['title']}", spec["fill"], spec["outline"], spec["outlineWidth"], ) for spec in LAYER_SPECS } signal_layer = make_signal_layer() layers["traffic_signal_assemblies"] = signal_layer draw_order = [spec["id"] for spec in LAYER_SPECS] for key in draw_order: project.addMapLayer(layers[key], False) project.addMapLayer(signal_layer, False) root = project.layerTreeRoot() for key in draw_order: root.insertLayer(0, layers[key]) root.insertLayer(0, signal_layer) if not project.write(PROJECT_PATH): raise RuntimeError(f"Failed to write {PROJECT_PATH}") settings = QgsMapSettings() settings.setLayers([layers[key] for key in reversed(draw_order)]) settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326")) settings.setExtent(QgsRectangle(*PREVIEW_EXTENT)) settings.setOutputSize(QSize(1600, 1100)) settings.setBackgroundColor(QColor(245, 245, 240)) image = QImage(settings.outputSize(), IMAGE_FORMAT) image.fill(settings.backgroundColor().rgba()) painter = QPainter(image) job = QgsMapRendererCustomPainterJob(settings, painter) job.start() job.waitForFinished() painter.end() image.save(PREVIEW_PATH) print(PROJECT_PATH) print(PREVIEW_PATH) app.exitQgis() `; } function fixCanvas(projectFile, extentCsv) { const fixScript = path.join(os.tmpdir(), `osm2streets_fix_canvas_${process.pid}.py`); const script = ` from pathlib import Path import shutil import zipfile import xml.etree.ElementTree as ET project_path = Path(${JSON.stringify(projectFile)}) extent_values = [${extentCsv.split(",").map(Number).join(", ")}] work_dir = Path(${JSON.stringify(path.join(os.tmpdir(), `osm2streets_qgz_fix_${process.pid}`))}) if work_dir.exists(): shutil.rmtree(work_dir) work_dir.mkdir(parents=True) with zipfile.ZipFile(project_path, "r") as zin: zin.extractall(work_dir) qgs_files = list(work_dir.glob("*.qgs")) if not qgs_files: raise RuntimeError("No .qgs file found inside project") qgs_path = qgs_files[0] tree = ET.parse(qgs_path) root = tree.getroot() old_canvas = root.find("mapcanvas") if old_canvas is not None: root.remove(old_canvas) project_crs = root.find("projectCrs/spatialrefsys") canvas = ET.Element("mapcanvas", {"name": "theMapCanvas", "annotationsVisible": "1"}) ET.SubElement(canvas, "units").text = "degrees" extent = ET.SubElement(canvas, "extent") for key, value in zip(["xmin", "ymin", "xmax", "ymax"], extent_values): ET.SubElement(extent, key).text = str(value) ET.SubElement(canvas, "rotation").text = "0" dest = ET.SubElement(canvas, "destinationsrs") if project_crs is not None: dest.append(ET.fromstring(ET.tostring(project_crs, encoding="unicode"))) ET.SubElement(canvas, "rendermaptile").text = "0" ET.SubElement(canvas, "expressionContextScope") layer_tree = root.find("layer-tree-group") insert_at = list(root).index(layer_tree) + 1 if layer_tree is not None else 1 root.insert(insert_at, canvas) ET.indent(tree, space=" ") tree.write(qgs_path, encoding="UTF-8", xml_declaration=True) tmp = project_path.with_suffix(".qgz.tmp") with zipfile.ZipFile(tmp, "w", compression=zipfile.ZIP_DEFLATED) as zout: for f in sorted(work_dir.iterdir()): zout.write(f, f.name) tmp.replace(project_path) shutil.rmtree(work_dir) `; fs.writeFileSync(fixScript, script); execFileSync("python3", [fixScript], { stdio: "inherit" }); fs.unlinkSync(fixScript); }