"use strict"; const fs = require("fs"); const path = require("path"); const OVERRIDE_SCHEMA = "native-road-overrides/v1"; const MOTOR_HIGHWAYS = new Set(["motorway", "trunk", "primary", "secondary", "tertiary", "unclassified", "residential", "living_street", "service"]); const DEFAULT_WIDTHS = { motorway: 12, trunk: 10, primary: 10, secondary: 8, tertiary: 7, unclassified: 6, residential: 6, living_street: 5, service: 4 }; function parseOsmRoads(xml) { const nodes = new Map(); for (const match of xml.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/node>)/g)) { const attrs = xmlAttrs(match[1]); if (attrs.action === "delete" || !attrs.id || attrs.lon === undefined || attrs.lat === undefined) continue; const coordinate = [Number(attrs.lon), Number(attrs.lat)]; if (coordinate.every(Number.isFinite)) nodes.set(String(attrs.id), coordinate); } const ways = []; for (const match of xml.matchAll(/]*)>([\s\S]*?)<\/way>/g)) { const attrs = xmlAttrs(match[1]); const body = match[2]; const tags = parseTags(body); if (attrs.action === "delete" || !MOTOR_HIGHWAYS.has(tags.highway || "")) continue; const refs = [...body.matchAll(/]*)\/?\s*>/g)].map((item) => xmlAttrs(item[1]).ref).filter(Boolean); const coords = refs.map((ref) => nodes.get(String(ref))).filter(Boolean); if (coords.length < 2 || coords.length !== refs.length) continue; ways.push({ id: String(attrs.id), refs: refs.map(String), coords, tags }); } return { nodes, ways }; } function compileRoadModel(xml, overrides) { const parsed = parseOsmRoads(xml); const diagnostics = []; const roads = []; const endpoints = []; const byNode = new Map(); for (const way of parsed.ways) { const directions = way.tags.oneway === "yes" || way.tags.oneway === "1" || way.tags.junction === "roundabout" ? ["forward"] : ["forward", "backward"]; for (const direction of directions) { const base = roadAttributes(way.tags, direction); const id = `road:way/${way.id}:${direction}`; const road = { id, osmWayIds: [way.id], direction, highway: way.tags.highway, centerline: direction === "forward" ? way.coords : [...way.coords].reverse(), sourceNodeIds: direction === "forward" ? [way.refs[0], way.refs.at(-1)] : [way.refs.at(-1), way.refs[0]], tags: way.tags, ...base, appliedOverrideIds: [], diagnostics: [] }; applyRoadOverrides(road, overrides, diagnostics); roads.push(road); for (const side of ["start", "end"]) { const nodeId = side === "start" ? road.sourceNodeIds[0] : road.sourceNodeIds[1]; const endpoint = { id: `endpoint:${road.id}:${side}`, roadId: id, side, nodeId, coordinate: side === "start" ? road.centerline[0] : road.centerline.at(-1), direction }; endpoints.push(endpoint); if (!byNode.has(nodeId)) byNode.set(nodeId, []); byNode.get(nodeId).push(endpoint); } } } const connections = resolveConnections(endpoints, byNode, overrides, diagnostics); const extent = roadExtent(roads); for (const [nodeId, items] of byNode) { if (items.length === 1 && distanceToExtentEdgeMeters(items[0].coordinate, extent) > 25) { const endpoint = items[0]; diagnostics.push({ ...diagnostic("warning", endpoint.roadId, [nodeId], "unconnected-interior-road-end", "道路在区域内部结束,未连接到其他机动车道路。请确认这是实际断头,还是 OSM 节点尚未连接。", endpoint.coordinate), endpointId: endpoint.id }); } } return { schema: "native-road-model/v1", roads, endpoints, connections, diagnostics }; } function roadExtent(roads) { const points = roads.flatMap((road) => road.centerline); return { minLon: Math.min(...points.map((point) => point[0])), maxLon: Math.max(...points.map((point) => point[0])), minLat: Math.min(...points.map((point) => point[1])), maxLat: Math.max(...points.map((point) => point[1])) }; } function distanceToExtentEdgeMeters(point, extent) { const lonScale = 111320 * Math.cos(point[1] * Math.PI / 180); return Math.min((point[0] - extent.minLon) * lonScale, (extent.maxLon - point[0]) * lonScale, (point[1] - extent.minLat) * 111320, (extent.maxLat - point[1]) * 111320); } function roadAttributes(tags, direction) { const directional = direction === "forward" ? "forward" : "backward"; const laneTag = tags[`lanes:${directional}`] ?? (tags.oneway === "yes" ? tags.lanes : null); const parsedLanes = positiveInteger(laneTag); const totalLanes = positiveInteger(tags.lanes); const lanes = parsedLanes || (totalLanes ? Math.max(1, Math.ceil(totalLanes / (tags.oneway === "yes" ? 1 : 2))) : 1); const parsedWidth = positiveNumber(tags.width); const forwardLanes = positiveInteger(tags["lanes:forward"]); const backwardLanes = positiveInteger(tags["lanes:backward"]); const directionalLaneTotal = forwardLanes && backwardLanes ? forwardLanes + backwardLanes : totalLanes; // `width` describes the whole OSM way. A directional road receives its lane // share; absent width falls back to a realistic per-lane carriageway width. const width = parsedWidth ? parsedWidth * lanes / (directionalLaneTotal || (tags.oneway === "yes" ? lanes : lanes * 2)) : lanes * 3.25; return { laneCount: lanes, widthMeters: width, sidewalkLeft: sidewalkState(tags, direction, "left"), sidewalkRight: sidewalkState(tags, direction, "right"), provenance: { laneCount: parsedLanes || totalLanes ? `tag:${parsedLanes ? `lanes:${directional}` : "lanes"}` : "inferred:default-lanes", widthMeters: parsedWidth ? "tag:width (按方向车道数分配)" : "inferred:3.25m-per-lane", }, }; } function sidewalkState(tags, direction, side) { const osmSide = direction === "forward" ? side : side === "left" ? "right" : "left"; const value = tags[`sidewalk:${osmSide}`] ?? tags.sidewalk; return value === "both" || value === "yes" || value === osmSide; } function loadOverrides(file) { if (!fs.existsSync(file)) return { schema: OVERRIDE_SCHEMA, overrides: [] }; return validateOverrides(JSON.parse(fs.readFileSync(file, "utf8"))); } function validateOverrides(value, model) { if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`); const ids = new Set(); const roadIds = model ? new Set(model.roads.map((road) => road.id)) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; for (const item of value.overrides) { if (!item || typeof item.id !== "string" || !item.id || ids.has(item.id)) throw new Error("Each override needs a unique id."); ids.add(item.id); if (item.kind === "road") { if (typeof item.roadId !== "string" || roadIds && !roadIds.has(item.roadId)) throw new Error(`Unknown road override target: ${item.roadId}`); for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined && (!Number.isFinite(item[key]) || item[key] <= 0 || (key === "laneCount" && !Number.isInteger(item[key])))) throw new Error(`Invalid road override ${key}.`); for (const key of ["sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined && typeof item[key] !== "boolean") throw new Error(`Invalid road override ${key}.`); } else if (item.kind === "junction-connection") { if (typeof item.fromEndpointId !== "string" || typeof item.toEndpointId !== "string" || typeof item.enabled !== "boolean" || (endpointIds && (!endpointIds.has(item.fromEndpointId) || !endpointIds.has(item.toEndpointId)))) throw new Error("Invalid junction connection override."); } else throw new Error(`Unsupported override kind: ${item.kind}`); } return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; } function applyRoadOverrides(road, overrides, diagnostics) { for (const item of overrides.overrides.filter((entry) => entry.kind === "road" && entry.roadId === road.id)) { for (const key of ["widthMeters", "laneCount", "sidewalkLeft", "sidewalkRight"]) if (item[key] !== undefined) road[key] = item[key]; road.appliedOverrideIds.push(item.id); for (const key of ["widthMeters", "laneCount"]) if (item[key] !== undefined) road.provenance[key] = `override:${item.id}`; } if (road.widthMeters < road.laneCount * 2.4) diagnostics.push(diagnostic("warning", road.id, road.osmWayIds, "narrow-lane-width", "Configured road width is narrow for the selected lane count.", road.centerline[0])); } function resolveConnections(endpoints, byNode, overrides, diagnostics) { const result = []; for (const [nodeId, items] of byNode) { const arrivals = items.filter((endpoint) => endpoint.side === "end"); const departures = items.filter((endpoint) => endpoint.side === "start"); for (const arrival of arrivals) for (const departure of departures) { if (arrival.roadId === departure.roadId) continue; const override = overrides.overrides.find((entry) => entry.kind === "junction-connection" && entry.fromEndpointId === arrival.id && entry.toEndpointId === departure.id); result.push({ id: `connection:${arrival.id}:${departure.id}`, nodeId, fromEndpointId: arrival.id, toEndpointId: departure.id, enabled: override ? override.enabled : true, provenance: override ? `override:${override.id}` : "osm:shared-node" }); } if (items.length > 8) diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "complex-junction", "Junction has more than eight directional endpoints and is not compiled as an ordinary junction.", items[0].coordinate)); } return result; } function compileGeometry(model) { const diagnostics = [...model.diagnostics]; const features = []; const emittedWays = new Set(); for (const road of model.roads) { const wayKey = road.osmWayIds.join(","); if (emittedWays.has(wayKey)) continue; emittedWays.add(wayKey); const directions = model.roads.filter((item) => item.osmWayIds.join(",") === wayKey); const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0); const ring = roadRing(road.centerline, totalWidth); if (!ring) { diagnostics.push(diagnostic("error", road.id, road.osmWayIds, "unclosed-road-surface", "Could not construct a valid road polygon from this centerline.", road.centerline[0])); continue; } features.push({ type: "Feature", properties: { native_id: `surface:way/${wayKey}`, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: wayKey, width_m: totalWidth, lane_count: directions.reduce((sum, item) => sum + item.laneCount, 0), provenance: JSON.stringify(directions.map((item) => item.provenance)), override_ids: directions.flatMap((item) => item.appliedOverrideIds).join(",") }, geometry: { type: "Polygon", coordinates: [ring] } }); } const junctionFeatures = ordinaryJunctionFeatures(model, diagnostics); return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, diagnostics }; } function ordinaryJunctionFeatures(model, diagnostics) { const byNode = new Map(); for (const endpoint of model.endpoints) { if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); byNode.get(endpoint.nodeId).push(endpoint); } const result = []; for (const [nodeId, endpoints] of byNode) { const wayIds = new Set(endpoints.map((endpoint) => endpoint.roadId.split(":")[1])); if (wayIds.size < 3 || wayIds.size > 4) continue; const roads = endpoints.map((endpoint) => model.roads.find((road) => road.id === endpoint.roadId)); const radius = Math.max(...roads.map((road) => road.widthMeters)) * 0.65; const ring = circleRing(endpoints[0].coordinate, radius, 16); result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: [...new Set(roads.map((road) => road.id))].join(","), rule: "ordinary-junction-disc/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "Generated a conservative ordinary junction surface; connector geometry is deferred.", endpoints[0].coordinate)); } return result; } function circleRing(center, radius, segments) { const origin = center; const ring = []; for (let index = 0; index <= segments; index += 1) { const angle = index / segments * Math.PI * 2; ring.push(unproject([Math.cos(angle) * radius, Math.sin(angle) * radius], origin)); } return ring; } function roadRing(line, width) { if (line.length < 2 || !Number.isFinite(width)) return null; const origin = line[0]; const points = line.map((point) => project(point, origin)); const left = []; const right = []; const half = width / 2; for (let i = 0; i < points.length; i += 1) { const prior = points[Math.max(0, i - 1)]; const next = points[Math.min(points.length - 1, i + 1)]; const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy); if (length < 0.01) return null; const nx = -dy / length * half; const ny = dx / length * half; left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin)); right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin)); } const ring = [...left, ...right.reverse(), left[0]]; return ring.every((point) => point.every(Number.isFinite)) ? ring : null; } function project(point, origin) { const scale = 111320; return [(point[0] - origin[0]) * scale * Math.cos(origin[1] * Math.PI / 180), (point[1] - origin[1]) * scale]; } function unproject(point, origin) { const scale = 111320; return [point[0] / (scale * Math.cos(origin[1] * Math.PI / 180)) + origin[0], point[1] / scale + origin[1]]; } function diagnostic(severity, subjectId, sourceIds, rule, message, coordinate) { return { id: `diagnostic:${rule}:${subjectId}`, severity, subjectId, sourceIds, rule, message, geometry: coordinate ? { type: "Point", coordinates: coordinate } : null }; } function xmlAttrs(text) { const attrs = {}; for (const match of text.matchAll(/([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) attrs[match[1]] = match[2] ?? match[3]; return attrs; } function parseTags(body) { const tags = {}; for (const match of body.matchAll(/]*)\/?\s*>/g)) { const attrs = xmlAttrs(match[1]); if (attrs.k) tags[attrs.k] = attrs.v || ""; } return tags; } function positiveInteger(value) { const number = Number(value); return Number.isInteger(number) && number > 0 ? number : null; } function positiveNumber(value) { const match = String(value ?? "").match(/^\s*(\d+(?:\.\d+)?)/); const number = match ? Number(match[1]) : null; return Number.isFinite(number) && number > 0 ? number : null; } function writeJsonAtomic(file, data) { fs.mkdirSync(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.tmp`; fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`); fs.renameSync(temporary, file); } module.exports = { OVERRIDE_SCHEMA, compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic };