#!/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'); const { compileInput } = require('../src/compile/compiler'); 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 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; }); 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, 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 sendWorkbenchApp(response); if (request.method === 'GET' && url.pathname.startsWith('/vendor/')) return sendVendorFile(response, url.pathname, context.repoRoot); 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', '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) => { 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'), ); 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) => { 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(); 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(() => { if (!area || typeof context.compileFresh !== 'function') throw new Error('请先导入 OSM 文件。'); context.compileFresh(); sendJson(response, 200, state(session.area, junctionReference, debug)); }) .catch((error) => sendJson(response, 500, { ok: false, error: error.message })); if (request.method === 'GET' && !url.pathname.startsWith('/api/')) return sendWorkbenchAsset(response, url.pathname); sendJson(response, 404, { error: 'Not found' }); } function state(area, junctionReference = null, debug = false) { const nativeDir = area.outputs.nativeRoadDir; 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: 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 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: [] }; } 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 sendWorkbenchApp(response) { const built = path.join(__dirname, 'client', 'dist', 'index.html'); return sendFile( response, fs.existsSync(built) ? built : path.join(__dirname, 'client', 'index.html'), 'text/html; charset=utf-8', ); } function sendWorkbenchAsset(response, pathname) { const root = path.join(__dirname, 'client', 'dist'); const file = path.resolve(root, `.${pathname}`); if (file.startsWith(`${root}${path.sep}`) && fs.existsSync(file) && fs.statSync(file).isFile()) { const extension = path.extname(file); const types = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', }; return sendFile(response, file, types[extension] || 'application/octet-stream'); } return sendWorkbenchApp(response); } 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, readMultipart };