feat: add web OSM import workflow
This commit is contained in:
2
.trellis/tasks/08-26-web-osm-import/check.jsonl
Normal file
2
.trellis/tasks/08-26-web-osm-import/check.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify upload-to-compile data flow and session boundary."}
|
||||||
|
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Verify no duplicate compiler or parser logic was introduced."}
|
||||||
39
.trellis/tasks/08-26-web-osm-import/design.md
Normal file
39
.trellis/tasks/08-26-web-osm-import/design.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Technical Design
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- `bin/road-workbench.js` owns process startup and creates an import session/workspace.
|
||||||
|
- `workbench/server.js` owns HTTP routes and session-local files; it continues to call `compileInput()` for compilation.
|
||||||
|
- `workbench/client/index.html` and `workbench/client/app.js` add the upload/bootstrap state only; existing editing panels remain unchanged.
|
||||||
|
- `src/compile/compiler.js` and `src/osm.js` remain the source of truth for input validation and OSM processing.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. CLI starts a workbench with an optional existing `--input` for compatibility, or with no input for the new import-first flow.
|
||||||
|
2. The browser submits multipart OSM content (bounded by a server upload limit) to a new session/import endpoint.
|
||||||
|
3. The server validates the filename/content, creates a unique workspace directory under a configured workbench data root, and writes:
|
||||||
|
- `source.osm`
|
||||||
|
- `native-road-overrides.json` with the existing empty schema
|
||||||
|
- `native-traffic-signals.json` with the existing empty schema
|
||||||
|
- `RoadCompilerInput.json` using conservative defaults and workspace-relative output paths
|
||||||
|
4. The server calls `compileInput()` and swaps the active session from the import screen to the existing state/map view.
|
||||||
|
5. Existing routes operate against the active session's `area` object. A failed import/compile removes only the new staging directory and leaves any prior active session untouched.
|
||||||
|
|
||||||
|
## Defaults and Compatibility
|
||||||
|
|
||||||
|
- Area id is derived from a sanitized user-provided name or uploaded basename, with a unique suffix when necessary.
|
||||||
|
- `options.edgeLines` and `options.junctionTemplates` use the same shape required by `validateInput()`; no new compiler options are introduced.
|
||||||
|
- Existing `--input RoadCompilerInput.json` startup remains supported by adapting it into the same active-session context.
|
||||||
|
- The server binds to localhost as it does today and does not add authentication or remote persistence.
|
||||||
|
|
||||||
|
## Error and Recovery
|
||||||
|
|
||||||
|
- Reject empty/non-XML uploads, oversized bodies, malformed OSM, and compile failures with JSON errors suitable for the browser.
|
||||||
|
- Stage all files before switching the active session; cleanup on failure.
|
||||||
|
- Keep workspace directories recoverable on disk; do not delete an existing user workspace during a new import.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- Unit/integration tests for workspace initialization, default input generation, upload size/content validation, and failed-import cleanup.
|
||||||
|
- HTTP smoke test for import -> state -> compile/export using the existing fixture OSM.
|
||||||
|
- Existing compiler and fixture tests remain the regression gate.
|
||||||
2
.trellis/tasks/08-26-web-osm-import/implement.jsonl
Normal file
2
.trellis/tasks/08-26-web-osm-import/implement.jsonl
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Import spans CLI, HTTP server, filesystem workspace, compiler, and browser state."}
|
||||||
|
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"Reuse existing compiler input validation, atomic JSON writes, and workbench state routes."}
|
||||||
19
.trellis/tasks/08-26-web-osm-import/implement.md
Normal file
19
.trellis/tasks/08-26-web-osm-import/implement.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
1. Read frontend/backend project specs and map the current workbench startup contract.
|
||||||
|
2. Extract small, testable helpers for session workspace creation, default `RoadCompilerInput`, and bounded OSM request handling.
|
||||||
|
3. Repair `bin/road-workbench.js` to support import-first startup while adapting legacy `--input` files.
|
||||||
|
4. Add server endpoints for upload/bootstrap and active-session state, preserving all existing edit routes.
|
||||||
|
5. Add a minimal import screen and transition in the existing client; keep the map/editor UI intact.
|
||||||
|
6. Add tests for helper validation and an HTTP import smoke path using `test/fixtures/fengshu-er-road.osm`.
|
||||||
|
7. Run `npm test`, targeted workbench tests, and manual localhost smoke checks; fix issues found.
|
||||||
|
8. Run Trellis quality check, update relevant specs if a durable convention is discovered, then commit.
|
||||||
|
|
||||||
|
Validation commands:
|
||||||
|
|
||||||
|
- `npm test`
|
||||||
|
- `node --check bin/road-workbench.js`
|
||||||
|
- `node --check workbench/server.js`
|
||||||
|
- targeted workbench test command added by this task
|
||||||
|
|
||||||
|
Rollback points: CLI/server changes can be reverted independently of compiler sources; workspace staging ensures an import failure does not alter an existing session.
|
||||||
40
.trellis/tasks/08-26-web-osm-import/prd.md
Normal file
40
.trellis/tasks/08-26-web-osm-import/prd.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Web OSM 导入工作流
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
让用户无需预先编写 `RoadCompilerInput.json` 或宿主区域配置,即可通过 Web 工作台上传一个 `.osm` 文件,创建一次可编译的工作区并开始查看、调整和导出道路结果。
|
||||||
|
|
||||||
|
## Confirmed Facts
|
||||||
|
|
||||||
|
- `src/compile/compiler.js` 已提供完整的 `compileInput()`,负责读取 OSM、加载 overrides、生成原生道路图层、诊断和交通信号运行时资产。
|
||||||
|
- `src/osm.js` 已提供 OSM XML 解析;`src/compile/native-road.js` 已提供道路模型、几何编译和 overrides 校验。
|
||||||
|
- `workbench/server.js` 已提供地图状态、覆盖项保存、交通信号编辑/生成、重新编译和 ZIP 导出接口,但入口假定已有 `area`、配置加载器和编译回调。
|
||||||
|
- `workbench/client/` 已有完整的 OpenLayers 编辑界面,当前通过 `/api/state` 加载既有编译结果。
|
||||||
|
- `bin/road-workbench.js` 当前要求 `--input <RoadCompilerInput.json>`,却把 `{ input, inputFile, port }` 传给期待另一种上下文的 `startWorkbench()`,无法独立启动现有工作台。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. 工作台启动后提供 OSM 文件导入入口;成功导入后自动建立编译所需的工作区文件和默认参数。
|
||||||
|
2. 导入流程复用现有 `compileInput()` 与已有编辑 API,不复制道路解析或几何编译逻辑。
|
||||||
|
3. 导入后自动执行首次编译,并让现有地图、诊断、覆盖项、交通信号编辑和 ZIP 导出继续可用。
|
||||||
|
4. 导入失败时返回可理解的错误,不破坏当前已加载的工作区。
|
||||||
|
5. 保留通过现有 `RoadCompilerInput.json` 启动工作台的兼容路径(若当前入口契约可修复则继续支持)。
|
||||||
|
6. 默认参数应明确、可追溯,并允许用户在首次编译后通过已有工作台控件调整;本任务不重新设计道路算法或参数模型。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- 用户运行工作台命令并打开页面,可以选择 `.osm` 文件并提交。
|
||||||
|
- 服务端保存上传内容,生成有效的 overrides、traffic-signals、输出目录和 `options`,然后完成一次 `compileInput()`;页面显示道路图层和编译诊断。
|
||||||
|
- `/api/state`、`/api/overrides`、`/api/traffic-signals`、`/api/compile`、`/api/export.zip` 在导入工作区中均正常工作。
|
||||||
|
- 非法文件、空文件、超过限制的上传或编译错误不会留下半成品工作区,并在页面显示错误。
|
||||||
|
- 现有测试继续通过,并新增覆盖入口/上传/初始化链路的自动化测试。
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- 修改 OSM 解析规则、道路几何算法、交通信号生成算法或导出包格式。
|
||||||
|
- 多用户认证、远程持久化、数据库、云端 OSM 下载和在线协作。
|
||||||
|
- 重新设计现有工作台地图编辑 UI。
|
||||||
|
|
||||||
|
## Key Decision
|
||||||
|
|
||||||
|
每次导入创建独立的本地工作区目录并在当前工作台会话中使用,避免覆盖已有区域配置。工作区保留在磁盘上,后续可通过兼容的 `--input` 方式恢复。
|
||||||
26
.trellis/tasks/08-26-web-osm-import/task.json
Normal file
26
.trellis/tasks/08-26-web-osm-import/task.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "web-osm-import",
|
||||||
|
"name": "web-osm-import",
|
||||||
|
"title": "Web OSM 导入工作流",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "dingkang",
|
||||||
|
"assignee": "dingkang",
|
||||||
|
"createdAt": "2026-08-26",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
|
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { startWorkbench } = require("../workbench/server");
|
const { startWorkbench } = require("../workbench/server");
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const index = args.indexOf("--input");
|
const index = args.indexOf("--input");
|
||||||
if (index < 0 || !args[index + 1]) throw new Error("Usage: road-workbench --input <RoadCompilerInput.json> [--port <port>]");
|
const inputFile = index >= 0 && args[index + 1] ? path.resolve(args[index + 1]) : null;
|
||||||
const inputFile = path.resolve(args[index + 1]);
|
const input = inputFile ? JSON.parse(fs.readFileSync(inputFile, "utf8")) : null;
|
||||||
const input = require(inputFile);
|
|
||||||
const portIndex = args.indexOf("--port");
|
const portIndex = args.indexOf("--port");
|
||||||
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787;
|
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 8787;
|
||||||
startWorkbench({ input, inputFile, port });
|
startWorkbench({ input, inputFile, repoRoot: path.resolve(__dirname, ".."), port });
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@osm-asset/road-compiler",
|
"name": "@osm-asset/road-compiler",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@osm-asset/road-compiler",
|
"name": "@osm-asset/road-compiler",
|
||||||
"version": "0.2.2",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fflate": "0.8.3",
|
"fflate": "0.8.3",
|
||||||
"ol": "10.10.0"
|
"ol": "10.10.0"
|
||||||
|
|||||||
@@ -456,4 +456,10 @@ scenePreviewToggle.onchange = () => {
|
|||||||
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
|
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
|
||||||
};
|
};
|
||||||
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
|
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
|
||||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; const signalUid = new URLSearchParams(location.search).get("signal"); const signal = signalUid && poleFeatureForSignal(signalUid); if (signal) selectSignal(signal); message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
function showImportScreen() {
|
||||||
|
const panel = document.createElement("form"); panel.style.cssText = "position:fixed;inset:90px 25%;z-index:20;background:#fff;padding:32px;box-shadow:0 8px 30px #0003";
|
||||||
|
panel.innerHTML = "<h1>导入 OSM 地图</h1><p>选择一个 .osm 文件开始创建工作区。</p><input type=\"file\" accept=\".osm,application/xml,text/xml\" required><button type=\"submit\">导入并编译</button><output style=\"display:block;margin-top:12px\"></output>";
|
||||||
|
panel.onsubmit = async (event) => { event.preventDefault(); const file = panel.querySelector("input").files[0]; const output = panel.querySelector("output"); if (!file) return; output.textContent = "正在导入并编译..."; const body = new FormData(); body.append("file", file, file.name); try { const response = await fetch("/api/import", { method: "POST", body }); const result = await response.json(); if (!response.ok || !result.ok) throw new Error(result.error || `HTTP ${response.status}`); location.reload(); } catch (error) { output.textContent = error.message; } };
|
||||||
|
document.body.append(panel);
|
||||||
|
}
|
||||||
|
fetch("/api/state").then((response) => response.json()).then((value) => { if (!value.active && !value.compiled) return showImportScreen(); state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; const signalUid = new URLSearchParams(location.search).get("signal"); const signal = signalUid && poleFeatureForSignal(signalUid); if (signal) selectSignal(signal); message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
||||||
|
|||||||
@@ -8,15 +8,20 @@ const { loadOverrides, validateOverrides, writeJsonAtomic } = require("../src/co
|
|||||||
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
|
const { generate, validateDocument, runtime } = require("../src/native-traffic-signals");
|
||||||
const { convertGeoJson } = require("../src/reference/gaode");
|
const { convertGeoJson } = require("../src/reference/gaode");
|
||||||
const { exportNativeRoadPackage } = require("../src/export/native-road-package");
|
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);
|
if (typeof junctionReference === "string") junctionReference = readJunctionReference(junctionReference);
|
||||||
// `--debug` surfaces advisory compiler findings that have no geometry layer of
|
// `--debug` surfaces advisory compiler findings that have no geometry layer of
|
||||||
// their own — currently the complex-junction candidates. Off by default so the
|
// their own — currently the complex-junction candidates. Off by default so the
|
||||||
// normal editing view stays uncluttered.
|
// normal editing view stays uncluttered.
|
||||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
|
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 session = { area, context: { repoRoot, configPath, compileFresh, readAreaConfig }, junctionReference, debug, dataRoot };
|
||||||
const server = http.createServer((request, response) => handle(request, response, area, context, junctionReference, debug));
|
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) => {
|
server.on("error", (error) => {
|
||||||
console.error(`Road Workbench failed to listen: ${error.message}`);
|
console.error(`Road Workbench failed to listen: ${error.message}`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -25,14 +30,21 @@ function startWorkbench({ area, configPath, repoRoot, compileFresh, readAreaConf
|
|||||||
return server;
|
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");
|
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 === "/") 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.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 === "/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.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 (request.method === "GET" && url.pathname === "/api/export.zip") return Promise.resolve().then(() => {
|
||||||
|
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||||
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
|
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
@@ -43,11 +55,13 @@ function handle(request, response, area, context, junctionReference, debug = fal
|
|||||||
response.end(Buffer.from(exported.bytes));
|
response.end(Buffer.from(exported.bytes));
|
||||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
}).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 (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"));
|
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
|
||||||
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
|
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
|
||||||
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
|
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
|
||||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
}).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 (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
|
||||||
|
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||||
const compiled = readCompiled(area);
|
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 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 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) });
|
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 }));
|
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||||
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
|
||||||
|
if (!area) throw new Error("请先导入 OSM 文件。");
|
||||||
const compiled = readCompiled(area);
|
const compiled = readCompiled(area);
|
||||||
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
|
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
|
||||||
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
|
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
|
||||||
sendJson(response, 200, { ok: true, overrides });
|
sendJson(response, 200, { ok: true, overrides });
|
||||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
}).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 (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 模式下可用。");
|
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
|
||||||
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot);
|
const added = addJunctionCluster(context.configPath, body, readCompiled(area), context.readAreaConfig, context.repoRoot);
|
||||||
context.compileFresh();
|
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) });
|
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
|
||||||
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||||
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
|
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
|
||||||
|
if (!area || typeof context.compileFresh !== "function") throw new Error("请先导入 OSM 文件。");
|
||||||
context.compileFresh();
|
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 }));
|
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
|
||||||
sendJson(response, 404, { error: "Not found" });
|
sendJson(response, 404, { error: "Not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function state(area, junctionReference = null, debug = false) {
|
function state(area, junctionReference = null, debug = false) {
|
||||||
const nativeDir = area.outputs.nativeRoadDir;
|
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)
|
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
|
||||||
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
|
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
|
||||||
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
|
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
|
||||||
const trafficRuntime = runtime(trafficSignals);
|
const trafficRuntime = runtime(trafficSignals);
|
||||||
const compiled = readCompiled(area);
|
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
|
// 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
|
// 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")));
|
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||||
return { source: file, coordinateSystem: "GCJ-02", converted };
|
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 readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
|
||||||
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
||||||
function readLayer(file) { return fs.existsSync(file) ? readJson(file) : { type: "FeatureCollection", features: [] }; }
|
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");
|
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`); }
|
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