1508 lines
55 KiB
JavaScript
Executable File
1508 lines
55 KiB
JavaScript
Executable File
#!/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 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 qgisMacOS = path.join(qgisApp, "Contents", "MacOS");
|
|
const qgisPython = path.join(qgisMacOS, "python3.12");
|
|
const ogr2ogr = path.join(qgisMacOS, "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";
|
|
|
|
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,
|
|
);
|
|
writeJson(path.join(outDir, "road_surface.geojson"), split.roadSurface);
|
|
writeJson(path.join(outDir, "intersection_surface.geojson"), split.intersectionSurface);
|
|
writeJson(path.join(outDir, "sidewalks.geojson"), split.sidewalks);
|
|
writeJson(path.join(outDir, "lane_separators.geojson"), split.laneSeparators);
|
|
writeJson(path.join(outDir, "center_lines.geojson"), split.centerLines);
|
|
writeJson(path.join(outDir, "vehicle_stop_lines.geojson"), split.vehicleStopLines);
|
|
writeJson(path.join(outDir, "lane_arrows_webscale.geojson"), split.laneArrows);
|
|
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, "sidewalk_corners.geojson"), split.sidewalkCorners);
|
|
writeJson(path.join(outDir, "crosswalks.geojson"), split.crosswalks);
|
|
writeJson(path.join(outDir, "osm2streets_scene.geojson"), mergedScene(split));
|
|
fs.writeFileSync(
|
|
path.join(outDir, "osm2streets_scene_style.json"),
|
|
JSON.stringify(sceneStyle(), null, 2),
|
|
);
|
|
|
|
if (fs.existsSync(gpkgPath)) {
|
|
fs.unlinkSync(gpkgPath);
|
|
}
|
|
const ogrEnv = qgisEnv();
|
|
importLayer(gpkgPath, path.join(outDir, "road_surface.geojson"), "road_surface", false, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "intersection_surface.geojson"), "intersection_surface", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "sidewalks.geojson"), "sidewalks", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "sidewalk_corners.geojson"), "sidewalk_corners", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "lane_separators.geojson"), "lane_separators", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "center_lines.geojson"), "center_lines", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "vehicle_stop_lines.geojson"), "vehicle_stop_lines", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "lane_arrows_webscale.geojson"), "lane_arrows_webscale", true, ogrEnv);
|
|
importLayer(gpkgPath, path.join(outDir, "crosswalks.geojson"), "crosswalks", 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({
|
|
qgisMacOS,
|
|
gpkgPath,
|
|
projectPath,
|
|
previewPath,
|
|
layerPrefix,
|
|
canvasExtent: config.canvasExtent || extentString(expandBounds(bbox, canvasPad)),
|
|
previewExtent: config.previewExtent || defaultPreviewExtent,
|
|
}));
|
|
|
|
execFileSync(qgisPython, [qgisScript], {
|
|
stdio: "inherit",
|
|
env: {
|
|
...process.env,
|
|
...qgisEnv(),
|
|
QT_QPA_PLATFORM: "offscreen",
|
|
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
|
|
PYTHONPATH: [
|
|
path.join(qgisApp, "Contents", "Resources", "python"),
|
|
path.join(qgisApp, "Contents", "Resources", "python3.11", "site-packages"),
|
|
].join(":"),
|
|
DYLD_LIBRARY_PATH: [
|
|
qgisMacOS,
|
|
path.join(qgisApp, "Contents", "Frameworks"),
|
|
].join(":"),
|
|
},
|
|
});
|
|
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 qgisEnv() {
|
|
return {
|
|
PROJ_LIB: path.join(qgisApp, "Contents", "Resources", "qgis", "proj"),
|
|
GDAL_DATA: path.join(qgisApp, "Contents", "Resources", "qgis", "gdal"),
|
|
};
|
|
}
|
|
|
|
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(/<bounds\b([^>]*)\/?\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(/<node\b([^>]*)>/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(/<node\b([^>]*?)(?:\/>|>([\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(/<way\b([^>]*)>([\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(/<nd\b([^>]*)\/>/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(/<tag\b([^>]*)\/>/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 mergedScene(split) {
|
|
const layers = [
|
|
["road_surface", 10, split.roadSurface],
|
|
["intersection_surface", 20, split.intersectionSurface],
|
|
["sidewalks", 30, split.sidewalks],
|
|
["sidewalk_corners", 40, split.sidewalkCorners],
|
|
["lane_separators", 50, split.laneSeparators],
|
|
["center_lines", 60, split.centerLines],
|
|
["crosswalks", 70, split.crosswalks],
|
|
["vehicle_stop_lines", 80, split.vehicleStopLines],
|
|
["lane_arrows_webscale", 90, split.laneArrows],
|
|
];
|
|
return {
|
|
type: "FeatureCollection",
|
|
features: layers.flatMap(([renderLayer, zIndex, collection]) => (
|
|
(collection.features || []).map((feature) => ({
|
|
...feature,
|
|
properties: {
|
|
...(feature.properties || {}),
|
|
render_layer: renderLayer,
|
|
z_index: zIndex,
|
|
},
|
|
}))
|
|
)),
|
|
};
|
|
}
|
|
|
|
function sceneStyle() {
|
|
return {
|
|
version: 1,
|
|
geometry: "polygon",
|
|
sortProperty: "z_index",
|
|
layerProperty: "render_layer",
|
|
layers: [
|
|
{ id: "road_surface", zIndex: 10, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 },
|
|
{ id: "intersection_surface", zIndex: 20, fill: "#2b2b28", outline: "#1e1e1c", outlineWidth: 0.04 },
|
|
{ id: "sidewalks", zIndex: 30, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 },
|
|
{ id: "sidewalk_corners", zIndex: 40, fill: "#bebeb6", outline: "#9c9c94", outlineWidth: 0.025 },
|
|
{ id: "lane_separators", zIndex: 50, fill: "#eeeee6", outline: null, outlineWidth: 0 },
|
|
{ id: "center_lines", zIndex: 60, fill: "#f5be2a", outline: null, outlineWidth: 0 },
|
|
{ id: "crosswalks", zIndex: 70, fill: "#fffff6", outline: null, outlineWidth: 0 },
|
|
{ id: "vehicle_stop_lines", zIndex: 80, fill: "#fffff6", outline: null, outlineWidth: 0 },
|
|
{ id: "lane_arrows_webscale", zIndex: 90, fill: "#fffff6", outline: "#2b2b28", outlineWidth: 0.015 },
|
|
],
|
|
};
|
|
}
|
|
|
|
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);
|
|
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 === "vehicle stop line" && !isInAnyPolygon(feature, crosswalkData.stopLineExclusionZones, "intersects")) {
|
|
out.vehicleStopLines.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 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) {
|
|
const crossingNodes = markedCrossingNodes(osm);
|
|
const clusterCenters = crossingClusters(crossingNodes);
|
|
const stripes = emptyCollection();
|
|
const stopLines = emptyCollection();
|
|
const zones = [];
|
|
const stopLineExclusionZones = [];
|
|
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));
|
|
if (!crosswalk) continue;
|
|
stripes.features.push(...crosswalk.stripes);
|
|
if (crosswalk.stopLine) stopLines.features.push(crosswalk.stopLine);
|
|
zones.push({
|
|
bbox: featureBounds(crosswalk.zone),
|
|
rings: polygonRings(crosswalk.zone.geometry),
|
|
});
|
|
stopLineExclusionZones.push({
|
|
bbox: featureBounds(crosswalk.stopLineExclusionZone),
|
|
rings: polygonRings(crosswalk.stopLineExclusionZone.geometry),
|
|
});
|
|
}
|
|
return { stripes, stopLines, zones, stopLineExclusionZones };
|
|
}
|
|
|
|
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) {
|
|
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 roadUnit = normalizeMetersVector(vector, meters);
|
|
if (!roadUnit) return null;
|
|
const acrossUnit = [-roadUnit[1], roadUnit[0]];
|
|
const center = [node.lon, node.lat];
|
|
const zoneCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 0.8, total + 0.8, meters);
|
|
const stopLineExclusionCoords = rectangleMeters(center, roadUnit, acrossUnit, stripeLength + 5, total + 1.5, meters);
|
|
const zone = {
|
|
type: "Feature",
|
|
properties: {
|
|
type: "crosswalk zone",
|
|
crossing_node_id: node.id,
|
|
},
|
|
geometry: { type: "Polygon", coordinates: [zoneCoords] },
|
|
};
|
|
const stopLineExclusionZone = {
|
|
type: "Feature",
|
|
properties: {
|
|
type: "crosswalk stop line exclusion zone",
|
|
crossing_node_id: node.id,
|
|
},
|
|
geometry: { type: "Polygon", coordinates: [stopLineExclusionCoords] },
|
|
};
|
|
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,
|
|
zone,
|
|
stopLineExclusionZone,
|
|
stopLine: syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, total, intersectionCenter, meters),
|
|
};
|
|
}
|
|
|
|
function syntheticStopLine(node, way, roadUnit, acrossUnit, stripeLength, crosswalkWidth, intersectionCenter, meters) {
|
|
if (!intersectionCenter) return null;
|
|
const offset = stripeLength / 2 + 1.2;
|
|
const candidateA = addMeters([node.lon, node.lat], roadUnit, offset, meters);
|
|
const candidateB = addMeters([node.lon, node.lat], 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: node.id,
|
|
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,
|
|
...qgisEnv(),
|
|
QT_QPA_PLATFORM: "offscreen",
|
|
PYTHONHOME: path.join(qgisApp, "Contents", "Frameworks"),
|
|
PYTHONPATH: [
|
|
path.join(qgisApp, "Contents", "Resources", "python"),
|
|
path.join(qgisApp, "Contents", "Resources", "python", "plugins"),
|
|
].join(path.delimiter),
|
|
},
|
|
});
|
|
}
|
|
|
|
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 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,
|
|
QgsFillSymbol,
|
|
QgsMapRendererCustomPainterJob,
|
|
QgsMapSettings,
|
|
QgsProject,
|
|
QgsRectangle,
|
|
QgsSingleSymbolRenderer,
|
|
QgsVectorLayer,
|
|
)
|
|
|
|
QGIS_PREFIX = ${JSON.stringify(options.qgisMacOS)}
|
|
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")}
|
|
|
|
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
|
|
|
|
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 = {
|
|
"road_surface": make_layer("road_surface", f"{LAYER_PREFIX} road surface", "43,43,40,255", "30,30,28,255", "0.04"),
|
|
"intersection_surface": make_layer("intersection_surface", f"{LAYER_PREFIX} intersection surface", "43,43,40,255", "30,30,28,255", "0.04"),
|
|
"sidewalks": make_layer("sidewalks", f"{LAYER_PREFIX} sidewalks", "190,190,182,255", "156,156,148,255", "0.025"),
|
|
"sidewalk_corners": make_layer("sidewalk_corners", f"{LAYER_PREFIX} sidewalk corners", "190,190,182,255", "156,156,148,255", "0.025"),
|
|
"crosswalks": make_layer("crosswalks", f"{LAYER_PREFIX} crosswalks", "255,255,246,255"),
|
|
"lane_separators": make_layer("lane_separators", f"{LAYER_PREFIX} lane separators", "238,238,230,255"),
|
|
"center_lines": make_layer("center_lines", f"{LAYER_PREFIX} center lines", "245,190,42,255"),
|
|
"vehicle_stop_lines": make_layer("vehicle_stop_lines", f"{LAYER_PREFIX} vehicle stop lines", "255,255,246,255"),
|
|
"lane_arrows": make_layer("lane_arrows_webscale", f"{LAYER_PREFIX} lane arrows", "255,255,246,255", "43,43,40,200", "0.015"),
|
|
}
|
|
draw_order = ["road_surface", "intersection_surface", "sidewalks", "sidewalk_corners", "lane_separators", "center_lines", "crosswalks", "vehicle_stop_lines", "lane_arrows"]
|
|
for key in draw_order:
|
|
project.addMapLayer(layers[key], False)
|
|
root = project.layerTreeRoot()
|
|
for key in draw_order:
|
|
root.insertLayer(0, layers[key])
|
|
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(), QImage.Format_ARGB32_Premultiplied)
|
|
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);
|
|
}
|