feat: add web OSM import workflow
This commit is contained in:
@@ -8,15 +8,20 @@ const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/co
|
||||
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
|
||||
const { convertGeoJson } = require("../src/reference/gaode");
|
||||
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
|
||||
const { compileInput } = require("../src/compile/compiler");
|
||||
|
||||
function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConfig, junctionReference = null, debug = false, port = 8787 }) {
|
||||
function startWorkbench({ area = null, input = null, inputFile = null, repoRoot = process.cwd(), dataRoot = path.join(repoRoot, "workbench-data"), configPath = null, compileFresh = null, readAreaConfig = null, 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));
|
||||
const session = { area, context: { repoRoot, configPath, compileFresh, readAreaConfig }, junctionReference, debug, dataRoot };
|
||||
if (input) {
|
||||
session.context.compileFresh = () => { const compiled = compileInput(input); session.area = compiled.area; return compiled; };
|
||||
session.context.compileFresh();
|
||||
}
|
||||
const server = http.createServer((request, response) => handle(request, response, session));
|
||||
server.on("error", (error) => {
|
||||
console.error(`Road Workbench failed to listen: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
@@ -25,14 +30,21 @@ function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConf
|
||||
return server;
|
||||
}
|
||||
|
||||
function handle(request, response, area, context, junctionReference, debug = false) {
|
||||
function handle(request, response, session) {
|
||||
const area = session.area;
|
||||
const context = session.context;
|
||||
const junctionReference = session.junctionReference;
|
||||
const debug = session.debug;
|
||||
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/state") return area ? sendJson(response, 200, state(area, junctionReference, debug)) : sendJson(response, 200, { active: false });
|
||||
if (request.method === "GET" && url.pathname === "/api/session") return sendJson(response, 200, { active: Boolean(session.area), areaId: session.area?.id || null });
|
||||
if (request.method === "POST" && url.pathname === "/api/import") return readUpload(request, session).then((result) => sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) })).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||
if (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => {
|
||||
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/zip",
|
||||
@@ -43,11 +55,13 @@ function handle(request, response, area, context, junctionReference, debug = fal
|
||||
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) => {
|
||||
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||
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(() => {
|
||||
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||
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"));
|
||||
@@ -57,12 +71,14 @@ function handle(request, response, area, context, junctionReference, debug = fal
|
||||
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) => {
|
||||
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||
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 (!area) throw new Error("请先导入 OSM 文件。");
|
||||
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
|
||||
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot);
|
||||
context.compileFresh();
|
||||
@@ -70,21 +86,22 @@ function handle(request, response, area, context, junctionReference, debug = fal
|
||||
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(() => {
|
||||
if (!area || typeof context.compileFresh !== "function") throw new Error("请先导入 OSM 文件。");
|
||||
context.compileFresh();
|
||||
sendJson(response, 200, state(area, junctionReference));
|
||||
sendJson(response, 200, state(session.area, junctionReference, debug));
|
||||
}).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 osm2streetsRoadSurface = area.outputs.geojsonDir ? path.join(area.outputs.geojsonDir, "road_surface.geojson") : null;
|
||||
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 } };
|
||||
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: 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
|
||||
@@ -146,6 +163,28 @@ function readJunctionReference(file) {
|
||||
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
return { source: file, coordinateSystem: "GCJ-02", converted };
|
||||
}
|
||||
function readUpload(request, session) {
|
||||
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
|
||||
if (!filename || !/\.osm$/i.test(filename)) throw new Error("请选择 .osm 文件。");
|
||||
if (!data.length) throw new Error("OSM 文件不能为空。");
|
||||
const base = path.basename(filename, path.extname(filename)).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "osm-import";
|
||||
fs.mkdirSync(session.dataRoot, { recursive: true });
|
||||
const root = fs.mkdtempSync(path.join(session.dataRoot, "import-"));
|
||||
const areaId = `${base}-${path.basename(root).slice(-6)}`;
|
||||
const outRoot = path.join(root, "outputs");
|
||||
const input = { areaId, osmFile: path.join(root, "source.osm"), outDir: path.join(outRoot, "native-road"), stagingDir: path.join(outRoot, "_pipeline"), overridesFile: path.join(root, "native-road-overrides.json"), trafficSignalsFile: path.join(root, "native-traffic-signals.json"), options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } } };
|
||||
fs.writeFileSync(input.osmFile, data);
|
||||
fs.writeFileSync(input.overridesFile, JSON.stringify({ schema: "native-road-overrides/v1", overrides: [] }, null, 2));
|
||||
fs.writeFileSync(input.trafficSignalsFile, JSON.stringify({ schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }, null, 2));
|
||||
try { const compiled = compileInput(input); session.area = compiled.area; return compiled; } catch (error) { fs.rmSync(root, { recursive: true, force: true }); throw error; }
|
||||
});
|
||||
}
|
||||
function readMultipart(request, limit) { return new Promise((resolve, reject) => {
|
||||
const type = request.headers["content-type"] || ""; const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type); if (!match) return reject(new Error("请使用 multipart/form-data 上传 OSM 文件。"));
|
||||
const boundary = `--${match[1] || match[2]}`; const chunks = []; let size = 0;
|
||||
request.on("data", (chunk) => { size += chunk.length; if (size > limit) { reject(new Error("上传文件超过 20 MB 限制。")); request.destroy(); return; } chunks.push(chunk); });
|
||||
request.on("error", reject); request.on("end", () => { const body = Buffer.concat(chunks); const start = body.indexOf(Buffer.from("\r\n\r\n")); const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`)); if (start < 0 || end < start) return reject(new Error("上传内容格式无效。")); const header = body.slice(0, start).toString(); const name = /filename="([^"]*)"/i.exec(header)?.[1] || "upload.osm"; resolve({ filename: name, data: body.slice(start + 4, end) }); });
|
||||
}); }
|
||||
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: [] }; }
|
||||
@@ -160,4 +199,4 @@ function sendVendorFile(response, pathname, repoRoot) {
|
||||
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 };
|
||||
module.exports = { startWorkbench, readMultipart };
|
||||
|
||||
Reference in New Issue
Block a user