#!/usr/bin/env node "use strict"; const fs = require("fs"); const http = require("http"); const path = require("path"); const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/compile/native-road"); const { generate, validateDocument, runtime } = require("../src/native-traffic-signals"); const { convertGeoJson } = require("../src/reference/gaode"); const { exportNativeRoadPackage } = require("../src/export/native-road-package"); function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConfig, junctionReference = null, debug = false, port = 8787 }) { if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference); // `--debug` surfaces advisory compiler findings that have no geometry layer of // their own — currently the complex-junction candidates. Off by default so the // normal editing view stays uncluttered. if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535]."); const context = { repoRoot, configPath, compileFresh, readAreaConfig }; const server = http.createServer((request, response) => handle(request, response, area, context, junctionReference, debug)); server.on("error", (error) => { console.error(`Road Workbench failed to listen: ${error.message}`); process.exitCode = 1; }); server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`)); return server; } function handle(request, response, area, context, junctionReference, debug = false) { const url = new URL(request.url, "http://127.0.0.1"); if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(__dirname, "client", "index.html"), "text/html; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(__dirname, "client", "app.js"), "text/javascript; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(__dirname, "client", "app.css"), "text/css; charset=utf-8"); if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname, context.repoRoot); if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference, debug)); if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => { const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir); response.writeHead(200, { "Content-Type": "application/zip", "Content-Disposition": `attachment; filename="${area.id}.native-road.zip"`, "Content-Length": exported.bytes.length, "Cache-Control": "no-store", }); response.end(Buffer.from(exported.bytes)); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); writeJsonAtomic(area.outputs.nativeTrafficSignals, document); sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) }); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => { const compiled = readCompiled(area); const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson"))); const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")); const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid)); current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid))); writeJsonAtomic(area.outputs.nativeTrafficSignals, current); sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) }); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => { const compiled = readCompiled(area); const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints }); writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides); sendJson(response, 200, { ok: true, overrides }); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/junction-clusters") return readBody(request).then((body) => { if (!debug) throw new Error("该接口仅在 --debug 模式下可用。"); const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot); context.compileFresh(); const refreshed = context.readAreaConfig(context.configPath, { repoRoot: context.repoRoot }); sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) }); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => { context.compileFresh(); sendJson(response, 200, state(area, junctionReference)); }).catch((error) => sendJson(response, 500, { ok: false, error: error.message })); sendJson(response, 404, { error: "Not found" }); } function state(area, junctionReference = null, debug = false) { const nativeDir = area.outputs.nativeRoadDir; const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) : { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; const trafficRuntime = runtime(trafficSignals); const compiled = readCompiled(area); return { areaId: area.id, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; } // The compiler reports candidates as advisory diagnostics. Lift them into their // own payload with a stable index so the map can label them "#1, #2, ..." and // the inspector can offer a ready-to-paste cluster配置. // Append one detected cluster to the hand-authored area config. The candidate // must still be present in the latest compile, so a stale browser tab cannot // write a cluster that no longer exists. The edited config is validated by the // real loader before it replaces the file: an invalid write would break every // later command, and the file is git-tracked so a bad accept stays revertible. function addJunctionCluster(configPath, body, compiled, readAreaConfig, repoRoot) { const index = Number(body?.index); if (!Number.isInteger(index)) throw new Error("请求缺少候选编号 index。"); const candidate = junctionCandidates(compiled).find((item) => item.index === index); if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`); const raw = readJson(configPath); const templates = raw.nativeRoad?.junctionTemplates; if (!templates) throw new Error("区域配置缺少 nativeRoad.junctionTemplates,请先手工建立该节点。"); const clusters = Array.isArray(templates.clusters) ? templates.clusters : []; const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String))); const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId))); if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`); const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id))); const cluster = { id, template: candidate.template, coreRadiusMeters: candidate.coreRadiusMeters, cornerRadiusMeters: 12, outerRadiusExtraMeters: 18, nodeIds: candidate.nodeIds.map(String), }; const next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } }; const staging = `${configPath}.candidate-${process.pid}.json`; fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`); try { readAreaConfig(staging, { repoRoot }); } catch (error) { fs.unlinkSync(staging); throw new Error(`写入后的配置无法通过校验,已放弃:${error.message}`); } fs.unlinkSync(staging); writeJsonAtomic(configPath, next); return cluster; } function uniqueClusterId(base, taken) { if (!taken.has(base)) return base; for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`; throw new Error("无法生成唯一的 cluster id。"); } function junctionCandidates(compiled) { return (compiled?.diagnostics || []) .filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster) .sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters) .map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster })); } function readJunctionReference(file) { if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`); const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8"))); return { source: file, coordinateSystem: "GCJ-02", converted }; } function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); } function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); } function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; } function readBody(request) { return new Promise((resolve, reject) => { let body = ""; request.setEncoding("utf8"); request.on("data", (part) => { body += part; if (body.length > 1024 * 1024) request.destroy(); }); request.on("end", () => { try { resolve(JSON.parse(body)); } catch (_) { reject(new Error("Request body must be JSON.")); } }); request.on("error", reject); }); } function sendFile(response, file, type) { response.writeHead(200, { "Content-Type": type, "Cache-Control": "no-store" }); fs.createReadStream(file).pipe(response); } function sendVendorFile(response, pathname, repoRoot) { const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname); if (!match) return sendJson(response, 404, { error: "Not found" }); const root = path.join(repoRoot, "node_modules", match[1]); const file = path.resolve(root, match[2]); if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return sendJson(response, 404, { error: "Not found" }); return sendFile(response, file, file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8"); } function sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); } module.exports = { startWorkbench };