feat: add native road compiler workbench

This commit is contained in:
2026-08-13 18:01:20 +08:00
parent ddd15f68b3
commit b5fa4482f0
16 changed files with 794 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Check command ownership, disk artifact boundaries, and legacy pipeline compatibility."}
{"file":".trellis/spec/preview/index.md","reason":"Check browser workbench state, error handling, and no-build browser constraints."}
{"file":".trellis/spec/config/index.md","reason":"Check new area output/config normalization and compatibility."}

View File

@@ -0,0 +1,100 @@
# Native Road Compiler Workbench Design
## Architecture
The native compiler is an additive pipeline path. It owns a separate output
directory under an area and never writes into `osm2streets_web_out/`.
```text
OSM XML + native-road-overrides.json
|
v
Canonical Road Model
roads / endpoints / junction candidates / provenance
|
v
Native Geometry Compiler
road surfaces / initial junction surfaces / diagnostics
|
+--> native-road/compiled.json
+--> native-road/layers/*.geojson
+--> native-road/diagnostics.json
+--> native-road/comparison.json
|
v
Road Workbench HTTP service
browser map + inspect/edit/save API
```
The canonical model is the authority. Render layers, browser display data, and
future Blender compatibility adapters are derived from it.
## Commands And Ownership
- `npm run road:compile -- --config <area-config>` performs no browser work.
It reads OSM plus the persisted override file, writes a staged native-road
result, validates it, and atomically promotes the result directory.
- `npm run road:workbench -- --config <area-config>` compiles first unless
`--no-compile` is supplied, then starts a local HTTP server scoped to that
one area.
- The server exposes read-only compiler artifacts and one explicit save API
for validated overrides. It does not expose arbitrary filesystem paths.
- Existing `build:area`, `intermediates`, QGIS, Blender, Cesium, and package
paths remain unchanged in the first iteration.
## Data Contracts
### Canonical road model
Each road direction carries a stable ID derived from OSM identifiers, source
way IDs, endpoint node IDs, centerline, explicit/inferred attributes, applied
override IDs, and diagnostics. Junction candidates likewise use their OSM node
ID when available. Values include provenance such as `tag:lanes:forward`,
`inferred:highway-default`, or `override:<id>`.
### Override file
`<area>/native-road-overrides.json` is versioned and human-reviewable. It
contains an array of uniquely identified changes whose targets are stable road
or endpoint IDs. Supported v1 records are `road` parameter overrides and
`junction-connection` decisions. The save endpoint validates schema, target
existence, finite values, and duplicate/conflicting edits before atomic write.
### Compiler artifacts
`<area>/native-road/compiled.json` is the workbench's single read model.
`layers/` contains generated GeoJSON with source/provenance properties.
`diagnostics.json` contains severity, stable subject ID, source IDs, rule,
message, and optional geometry. `comparison.json` reports counts and coverage
against available osm2streets layers; it does not claim quality solely from
visual differences.
## Browser Workbench
The browser uses no framework or map runtime in v1. A Canvas/SVG map renders
fit-to-data OSM centerlines, native surfaces, optional osm2streets reference
layers, diagnostics, selected-object provenance, and overrides. This keeps the
first interactive path dependency-free and permits precise local coordinates.
The user can select a road or endpoint, edit only v1 fields, inspect the
resulting override record, explicitly save it, and recompile/reload. Saved
state is visibly differentiated from unsaved state. The workbench must not
offer freehand final-polygon editing, since that would break reproducibility.
## Geometry And Validation
V1 produces road segments from projected centerline offsets and terminal
cross-sections. It only generates a junction surface when endpoints satisfy
the supported ordinary T/cross shape and geometry checks; otherwise it emits a
diagnostic rather than inventing an invalid polygon. Validation detects
dangling endpoints, unclosed/self-intersecting rings, non-finite coordinates,
unsupported multi-level intersections, and source/topology ambiguity. Small
numerical cleanup may be explicit and recorded; semantic failures are never
silently repaired.
## Compatibility And Rollout
The first compiler's layers use existing render-layer names where meaningful,
but are stored separately. A later, explicitly enabled Blender provider option
may consume native layers after comparison gates pass. Delete/replace behavior
is out of scope; rollback is selecting the existing osm2streets pipeline.

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/index.md","reason":"Native compiler commands, artifacts, and area config extend the Node pipeline while preserving legacy stages."}
{"file":".trellis/spec/preview/index.md","reason":"The browser workbench is a new DOM runtime and must follow local preview loading and state conventions where applicable."}
{"file":".trellis/spec/config/index.md","reason":"New native-road output paths and config behavior extend the normalized area contract."}

View File

@@ -0,0 +1,32 @@
# Implementation Plan
1. Add area output/config normalization and command entrypoints for the native
compiler, preserving existing stage behavior and paths.
2. Implement a shared OSM road parser and canonical road/endpoints model with
source provenance, explicit versus inferred properties, and stable IDs.
3. Implement v1 override schema, validation, load/apply behavior, atomic save,
and focused unit tests.
4. Implement projected road segment geometry, supported T/cross junction
detection, native GeoJSON artifact emission, diagnostics, and comparison
summary.
5. Implement a dependency-free local workbench server and browser UI with
selection, provenance display, v1 parameter/topology editing, explicit save,
compile/reload, and error states.
6. Add native compiler tests using focused fixtures plus nantaizi analysis;
run existing relevant Node tests to confirm legacy behavior remains intact.
7. Compare nantaizi and at least one supplied problematic OSM sample. Record
metrics, unsupported cases, and follow-up work in task research.
## Validation
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run test:build-stages
npm run test:preflight
npm run test:preview-assets
```
Browser validation includes loading the workbench, editing a road parameter,
saving, verifying the override file, recompiling, reloading, and confirming
provenance identifies the saved override.

View File

@@ -0,0 +1,93 @@
# Native road compiler workbench
## Goal
Build an incremental native road compiler for Chinese urban and campus OSM
data that can progressively exceed osm2streets in geometry quality,
explainability, and repeatable correction. The existing osm2streets pipeline
must remain usable while the native compiler is developed and compared.
The first deliverable is a browser-based Road Workbench. It must expose the
native compiler's source data, generated geometry, and diagnostics, allow
users to make small semantic/topology corrections, persist those corrections
as versionable overrides, and reload them automatically in later runs.
## Confirmed Facts
- Nantaizi currently works relatively well because its OSM data received
deliberate supplemental tagging; it still has missing boundaries and
polygons that cannot be closed.
- Other tested OSM inputs expose osm2streets sensitivity to input structure
and leave too much opaque, final-polygon repair work in QGIS.
- Existing Blender consumes the nine GeoJSON render layers from
`osm2streets_web_out/`; QGIS GeoPackage edits can currently be reimported
only as a whole batch.
- The repository has no existing interactive browser editing service. Existing
Cesium preview is a static, generated verification page.
## Requirements
- R1: Add a native-road-compiler path without replacing or regressing the
existing osm2streets path.
- R2: Parse OSM into a canonical, source-traceable road model with stable
references to OSM ways and nodes, explicit values versus inferred values,
and diagnostics.
- R3: Compile at least ordinary road segments and the initial supported
junction subset into the existing render-layer contract, allowing existing
Blender/Cesium consumers to be reused.
- R4: Provide a browser Road Workbench that overlays raw OSM topology,
generated geometry, osm2streets comparison geometry when available, and
compiler diagnostics.
- R5: The workbench must permit scoped user adjustments and save them to an
area-local, human-reviewable override file. Future compile and workbench
runs must load that file automatically.
- R6: Each generated object and diagnostic must be traceable to OSM source
IDs, compiler rule/inference evidence, and relevant override IDs.
- R7: Validate topology and geometry before publishing generated layers;
report unresolved semantic errors instead of silently disguising them as
geometric repair.
- R8: Develop against nantaizi plus problem inputs and report native versus
osm2streets comparison metrics.
## Scope Boundaries
- First implementation targets Chinese urban/campus roads, ordinary road
segments, T/cross junctions, directed/multi-lane roads, and data already
tagged in nantaizi where possible.
- Existing Blender, Cesium export, package format, building, vegetation, and
water generators are out of scope unless a compatibility adapter requires a
narrowly scoped change.
- Directly editing final render polygons is not the intended correction model;
generated layers remain derived output.
- Complex interchanges, arbitrary multilayer junctions, and full worldwide OSM
coverage are deferred until driven by concrete samples.
## Acceptance Criteria
- [ ] A native compile command produces a canonical road model, generated
layers, diagnostics, and comparison artifacts for a configured area without
changing the osm2streets output path.
- [ ] A browser command serves a Road Workbench for an area and clearly shows
source topology, generated output, diagnostics, provenance, and saved
overrides.
- [ ] A user can make the agreed first-scope override edits in the browser,
save them explicitly, and receive a durable area-local override artifact.
- [ ] Re-running compile or reopening the workbench applies saved overrides
automatically and exposes their provenance.
- [ ] The compiler reports invalid/unclosed geometry, dangling road ends,
and unresolved junction/lane ambiguity with source IDs.
- [ ] Nantaizi and at least one known problematic area can run through the
native analysis/preview path, with comparison metrics captured rather than
a claim based only on visual inspection.
## Key Decisions
- The first browser editing surface supports road parameters (width, directed
lane counts, left/right sidewalk state) plus junction endpoint
connect/disconnect decisions.
- Turn restrictions, stop lines, and crosswalk placement are deferred until
the compiler has a validated road/junction editing loop.
- Overrides are a versioned, human-reviewable JSON artifact owned by the area,
not edits to generated polygon layers.
- Native output and osm2streets output remain parallel during development;
neither silently overwrites the other.

View File

@@ -0,0 +1,26 @@
{
"id": "native-road-compiler",
"name": "native-road-compiler",
"title": "Native road compiler workbench",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "dingkang",
"assignee": "dingkang",
"createdAt": "2026-08-13",
"completedAt": null,
"branch": null,
"base_branch": "feature/native-road-compiler",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -96,6 +96,30 @@ npm run preflight:area -- --config config/areas/nantaizi-lake-innovation-valley.
预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次 预检有 error 时退出非零且不会更新记录;通过后写 `preflight.manifest.json`,保留本次
验证的 config / OSM 文件摘要和检查结果。 验证的 config / OSM 文件摘要和检查结果。
## Native Road Workbench
`road:compile` 是独立于 osm2streets 的实验性道路编译器入口。它从 OSM 生成可追溯的
道路模型、基础道路面、诊断与对比摘要,写入 `outputs/<area-id>/native-road/`,不会覆盖
`osm2streets_web_out/` 或影响现有 Blender/Cesium 构建:
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
```
启动本地浏览器工作台:
```bash
npm run road:workbench -- --config config/areas/nantaizi-lake-innovation-valley.json
```
工作台默认先编译,并在 `http://127.0.0.1:8787/` 展示 OSM 道路、原生结果、诊断和每个值的
来源。可编辑道路宽度、车道数、两侧人行道,以及候选路口端点连接。保存写入
`outputs/<area-id>/native-road-overrides.json`;此文件是版本化的可审查输入,下一次编译和
启动工作台时会自动加载。编辑不会直接修改最终 polygon。
道路按行驶方向显示:点击道路的任意线段后,右侧“路口连接”只列出该方向到达终点路口后
可驶入的目标道路,并标记为左转、直行、右转或掉头;每一个目标道路只出现一次。
诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、 诊断会输出 OSM bounds、building way / multipolygon relation、显式高度、植被数量、
现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。 现有产物状态,以及 GLB 的 size / nodes / meshes / materials / images / extensions。
缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings` 缺少已期望的基线产物、异常 building relation、GLB 超过保守预算等会进入 `Warnings`

View File

@@ -11,6 +11,9 @@
"compress:glb": "node scripts/compress-glb.js", "compress:glb": "node scripts/compress-glb.js",
"diagnose:area": "node scripts/diagnose-area.js", "diagnose:area": "node scripts/diagnose-area.js",
"preflight:area": "node scripts/preflight-area.js", "preflight:area": "node scripts/preflight-area.js",
"road:compile": "node scripts/compile-native-roads.js",
"road:workbench": "node scripts/road-workbench.js",
"test:native-road": "node scripts/test-native-road.js",
"test:preflight": "node scripts/test-area-preflight.js", "test:preflight": "node scripts/test-area-preflight.js",
"test:build-stages": "node scripts/test-build-stages.js", "test:build-stages": "node scripts/test-build-stages.js",
"test:budgets": "node scripts/test-asset-budgets.js", "test:budgets": "node scripts/test-asset-budgets.js",

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { compileRoadModel, compileGeometry, loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const repoRoot = path.resolve(__dirname, "..");
function parseArgs(argv) {
const result = {};
for (let index = 0; index < argv.length; index += 1) {
if (!argv[index].startsWith("--")) continue;
const key = argv[index].slice(2).replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
result[key] = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : "true";
}
return result;
}
function compileArea(configPath) {
const area = readAreaConfig(configPath, { repoRoot });
const overrides = loadOverrides(area.outputs.nativeRoadOverrides);
const model = compileRoadModel(fs.readFileSync(area.input, "utf8"), overrides);
validateOverrides(overrides, model);
const compiled = compileGeometry(model);
const staging = fs.mkdtempSync(path.join(area.outputs.pipelineDir, "native-road-"));
try {
const result = {
schema: "native-road-compiled/v1",
areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson" },
};
const comparison = compareOsm2Streets(area, result.model.roads.length);
writeJsonAtomic(path.join(staging, "compiled.json"), result);
writeJsonAtomic(path.join(staging, "diagnostics.json"), { schema: "native-road-diagnostics/v1", diagnostics: compiled.diagnostics });
writeJsonAtomic(path.join(staging, "comparison.json"), comparison);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "intersection_surface.geojson"), compiled.intersectionSurface);
fs.rmSync(area.outputs.nativeRoadDir, { recursive: true, force: true });
fs.renameSync(staging, area.outputs.nativeRoadDir);
return { area, result, comparison };
} catch (error) {
fs.rmSync(staging, { recursive: true, force: true });
throw error;
}
}
function compareOsm2Streets(area, nativeRoadCount) {
const source = path.join(area.outputs.geojsonDir, "road_surface.geojson");
let featureCount = null;
if (fs.existsSync(source)) {
const collection = JSON.parse(fs.readFileSync(source, "utf8"));
featureCount = Array.isArray(collection.features) ? collection.features.length : null;
}
return { schema: "native-road-comparison/v1", nativeRoadCount, osm2streetsRoadSurfaceFeatures: featureCount, osm2streetsAvailable: featureCount !== null, note: "Counts are coverage evidence only; geometry quality requires diagnostic and visual review." };
}
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
const { area, result, comparison } = compileArea(configPath);
console.log(`NATIVE_ROAD_COMPILE_DONE ${JSON.stringify({ areaId: area.id, roads: result.model.roads.length, endpoints: result.model.endpoints.length, diagnostics: result.diagnostics.length, output: area.outputs.nativeRoadDir, comparison })}`);
}
if (require.main === module) main();
module.exports = { compileArea, parseArgs };

View File

@@ -32,9 +32,12 @@ function normalizeAreaConfig(raw, options = {}) {
const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime")); const packageStagingRuntimeDir = path.resolve(outputOverrides.packageStagingRuntimeDir || path.join(packageStagingDir, "runtime"));
const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview")); const previewDir = path.resolve(outputOverrides.previewDir || path.join(areaDir, "_preview"));
const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")); const geojsonDir = path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out"));
const nativeRoadDir = path.resolve(outputOverrides.nativeRoadDir || path.join(areaDir, "native-road"));
const outputs = { const outputs = {
areaDir, areaDir,
geojsonDir, geojsonDir,
nativeRoadDir,
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)), gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)), qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)), qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),

229
scripts/lib/native-road.js Normal file
View File

@@ -0,0 +1,229 @@
"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(/<node\b([^>]*?)(?:\/>|>([\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(/<way\b([^>]*)>([\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(/<nd\b([^>]*)\/?\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(/<tag\b([^>]*)\/?\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 };

58
scripts/road-workbench.js Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env node
"use strict";
const fs = require("fs");
const http = require("http");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const { compileArea, parseArgs } = require("./compile-native-roads");
const repoRoot = path.resolve(__dirname, "..");
function main() {
const args = parseArgs(process.argv.slice(2));
const configPath = path.resolve(args.config || path.join(repoRoot, "config", "areas", "nantaizi-lake-innovation-valley.json"));
if (args.noCompile !== "true") compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot });
const port = Number(args.port || 8787);
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const server = http.createServer((request, response) => handle(request, response, area, configPath));
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}/`));
}
function handle(request, response, area, configPath) {
const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
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/compile") return Promise.resolve().then(() => {
compileArea(configPath);
sendJson(response, 200, state(area));
}).catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
sendJson(response, 404, { error: "Not found" });
}
function state(area) {
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), comparison: readJson(path.join(nativeDir, "comparison.json")), layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
}
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 sendJson(response, status, value) { response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" }); response.end(`${JSON.stringify(value)}\n`); }
if (require.main === module) main();

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
"use strict";
const assert = require("assert");
const { compileRoadModel, compileGeometry, validateOverrides } = require("./lib/native-road");
const osm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.001" lat="30"/><node id="3" lon="114.001" lat="30.001"/><way id="10"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/><tag k="lanes" v="2"/><tag k="sidewalk" v="both"/></way><way id="11"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/><tag k="oneway" v="yes"/></way></osm>`;
const empty = { schema: "native-road-overrides/v1", overrides: [] };
const initial = compileRoadModel(osm, empty);
assert.equal(initial.roads.length, 3);
const target = initial.roads.find((road) => road.id === "road:way/10:forward");
const overrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "road-width", kind: "road", roadId: target.id, widthMeters: 9, laneCount: 2, sidewalkLeft: false }] }, initial);
const model = compileRoadModel(osm, overrides);
const edited = model.roads.find((road) => road.id === target.id);
assert.equal(edited.widthMeters, 9);
assert.equal(edited.provenance.widthMeters, "override:road-width");
assert.equal(edited.sidewalkLeft, false);
const geometry = compileGeometry(model);
assert.equal(geometry.roadSurface.features.length, 2);
assert.ok(geometry.roadSurface.features.every((feature) => feature.geometry.coordinates[0].length >= 5));
const connection = initial.connections[0];
assert.ok(initial.connections.every((item) => item.fromEndpointId.endsWith(":end") && item.toEndpointId.endsWith(":start")));
assert.equal(initial.connections.length, new Set(initial.connections.map((item) => `${item.fromEndpointId}->${item.toEndpointId}`)).size);
const connectionOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "disconnect", kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled: false }] }, initial);
assert.equal(validateOverrides(connectionOverrides).overrides.length, 1);
assert.equal(compileRoadModel(osm, connectionOverrides).connections.find((item) => item.id === connection.id).enabled, false);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
console.log("native road tests passed");

View File

@@ -0,0 +1 @@
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}

116
scripts/workbench/app.js Normal file
View File

@@ -0,0 +1,116 @@
"use strict";
const canvas = document.querySelector("#map");
const context = canvas.getContext("2d");
const status = document.querySelector("#status");
const form = document.querySelector("#road-form");
const areaLabel = document.querySelector("#area");
const widthInput = document.querySelector("#width");
const lanesInput = document.querySelector("#lanes");
const leftInput = document.querySelector("#left");
const rightInput = document.querySelector("#right");
const evidence = document.querySelector("#evidence");
const saveButton = document.querySelector("#save");
const compileButton = document.querySelector("#compile");
const diagnostics = document.querySelector("#diagnostics");
const directionSwitch = document.querySelector("#direction-switch");
let state;
let selected = null;
let staged = [];
function message(text) { status.textContent = text; }
function roadLabel(road) { return road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(", ")}`; }
function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; }
function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; }
function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); }
function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); }
function draw() {
if (!state) return;
context.clearRect(0, 0, canvas.width, canvas.height);
const layers = state.layers || {};
for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55");
for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666");
for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066");
for (const road of state.compiled.model.roads) {
context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); });
context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630";
context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio;
context.stroke();
}
if (selected) drawDirectionArrow(selected);
for (const diagnostic of state.compiled.diagnostics) {
if (!diagnostic.geometry) continue;
const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill();
}
if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); }
}
function drawDirectionArrow(road) {
const middle = Math.max(1, Math.floor(road.centerline.length / 2));
const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]);
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio;
context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91";
context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore();
}
function select(road) {
selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road);
if (!road) return;
document.querySelector("#road-name").textContent = `${roadLabel(road)}${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"}`;
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
renderDirectionSwitch(road);
renderConnections(road); draw();
}
function renderDirectionSwitch(road) {
directionSwitch.innerHTML = "";
const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路:沿 OSM 节点顺序行驶"; return; }
const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note);
for (const item of alternatives) {
const button = document.createElement("button");
button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序";
button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button);
}
}
function renderConnections(road) {
const box = document.querySelector("#connections"); box.innerHTML = "";
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end");
const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; }
const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro);
const seen = new Set();
for (const connection of rows) {
const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId);
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId);
if (!target || seen.has(target.id)) continue;
seen.add(target.id);
const input = document.createElement("input"); const label = document.createElement("label");
input.type = "checkbox"; input.checked = connection.enabled; input.onchange = () => stageConnection(connection, input.checked);
label.append(input, ` ${turnName(road, target)}${roadLabel(target)}`); box.append(label);
}
}
function turnName(from, to) {
const a = heading(from.centerline.at(-2), from.centerline.at(-1));
const b = heading(to.centerline[0], to.centerline[1]);
const delta = ((b - a + 540) % 360) - 180;
if (Math.abs(delta) >= 150) return "掉头";
if (Math.abs(delta) <= 30) return "直行";
return delta > 0 ? "右转" : "左转";
}
function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; }
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); }
function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); }
function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; }
canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); };
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); };
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); };
function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } }
function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; }
function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "<li>没有需要人工检查的问题。</li>"; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); }
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message));
window.onresize = resize;

View File

@@ -0,0 +1,4 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>待检查问题</h1><p class="muted">点击问题可定位到道路或路口。</p><ul id="diagnostics"></ul></aside><section class="map"><canvas id="map"></canvas><div class="legend"><i class="reference"></i> osm2streets 参考面 <i class="native"></i> 自研道路面 <i class="line"></i> 道路中心线 <i class="junction"></i> 已识别路口 <i class="warning"></i> 待检查点</div></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">在地图中点击道路,查看和调整参数。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有人行道</label><label><input id="right" type="checkbox"> 右侧有人行道</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script src="/app.js"></script></body></html>