feat: complete native traffic signal workflow

This commit is contained in:
2026-08-18 11:46:19 +08:00
parent 7204c28161
commit 5accc0a4b1
17 changed files with 355 additions and 19 deletions

View File

@@ -22,7 +22,8 @@
"08-14-native-road-lane-markings",
"08-17-native-road-control-markings",
"08-17-native-road-center-lines",
"08-17-native-rounded-junctions"
"08-17-native-rounded-junctions",
"08-18-native-traffic-signal-parity"
],
"parent": null,
"relatedFiles": [],

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,55 @@
# Design
## Source Of Truth And Migration
The native compiler owns a versioned area-local signal model and override
artifact. It is generated from OSM controls and native geometry inputs, then
edited by the Workbench without QGIS. The existing
`traffic_signal_assemblies.geojson` is a migration adapter: it can be imported
into the native model and exported for legacy QGIS/reimport workflows, but a
native compile never requires it to exist.
The existing `buildTrafficSignalFeatures()` and validation functions remain the
compatibility implementation for initial generation and import/export. Imported
features retain their legacy `signal_uid` where valid; newly generated native
features use the same deterministic identity rule so downstream runtime IDs do
not fork.
## Workbench API And Editing
`GET /api/state` adds the normalized native signal model, source control
metadata, migration provenance, and derived runtime signal records. A signal
edit is represented as an atomic replacement of the validated native signal
override artifact through a dedicated signal save endpoint. A separate import
or export action handles the legacy QGIS collection; road overrides remain in
their existing file and schema.
The browser uses stable `signal_uid` values. It supports:
- generate: choose an OSM traffic-signal control and arm, then create the
deterministic assembly using the existing generator contract;
- move: update the Point coordinates while retaining stop-line/source fields;
- rotate: update `heading_deg` with normalized degrees;
- delete: remove the assembly from the editable collection;
- edit enabled state, display ID, mast reach, z offset, and phase group.
Every save validates uniqueness, identity, finite geometry, source references,
and field ranges before an atomic write. Deleted features are absent from the
runtime output; disabled features remain in the editable/QGIS layer but are
omitted by `buildTrafficSignalsFromFeatures()`.
## Delivery Flow
The native road compile result includes signal assemblies and derived runtime
metadata without adding them to road geometry layers. Native Blender/Cesium
stages consume `traffic_signals.json` and dynamic GLB inputs generated directly
from the native model. The legacy QGIS adapter may materialize the old GeoJSON,
but it is not in the native build's critical path.
## Compatibility And Rollback
QGIS reimport continues to read/export the compatibility GeoJSON while the
legacy path remains unchanged. If native signal editing fails validation, the
previous atomic native override remains in place and the user receives a
field-level error. Rollback is selecting the legacy provider or exporting the
last native state to the QGIS adapter.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,31 @@
# Implementation Plan
1. Define the native signal model/override artifact and migration adapter;
reuse existing generation, validation, and deterministic signal UID rules.
2. Add QGIS import/export commands that translate
`traffic_signal_assemblies.geojson` to/from the native artifact without
making native compile depend on QGIS.
3. Extend native compile/workbench state to expose signal assemblies, OSM
controls/arms, and derived runtime provenance.
4. Add validated atomic signal save operations for generate, move, rotate,
delete, and field edits; preserve legacy ID compatibility.
5. Add Workbench map styling, selection, editing controls, dirty state, save,
compile/reload, and clear error handling for signal assemblies.
6. Ensure native Blender/Cesium/preview stages consume runtime signal data
generated from the native model, while legacy stages remain compatible.
7. Add focused traffic-signal, Workbench, migration, and cross-layer round-trip tests;
run the existing legacy traffic-signal and preview suites.
## Validation
```bash
npm run test:traffic-signals
npm run test:preview-assets
npm run test:road-workbench
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run road:check -- --config config/areas/nantaizi-lake-innovation-valley.json
```
Manual acceptance must cover native-only generate/move/rotate/delete -> save ->
recompile -> runtime JSON and preview, plus QGIS import/export compatibility;
disabled signals must be omitted from runtime output.

View File

@@ -0,0 +1,55 @@
# Native traffic signal parity with QGIS
## Goal
Move traffic-signal ownership from the QGIS editing chain into the native road
compiler and Workbench. Existing QGIS signal assemblies remain import/export
compatibility data during migration, but native overrides become the long-term
source for generation, editing, and Blender/Cesium/preview delivery.
## Confirmed Facts
- QGIS currently edits `traffic_signal_assemblies.geojson`; this is the
migration input/output contract, not the desired long-term authority.
- `scripts/reimport-gpkg.js` validates and reimports that legacy layer.
- `scripts/build-area.js` currently derives runtime `traffic_signals.json` from
the edited assemblies; the native path must replace this dependency.
- Existing native road layers do not read traffic-signal artifacts. Historical
native Cesium work intentionally omitted signal runtime assets.
- Stable signal identity and editable fields already include `signal_uid`,
enabled state, source/control/approach IDs, arm direction, pose, mast reach,
and phase-group/runtime data.
## Requirements
- R1: Native compile/workbench must own a versioned area-local signal model and
override artifact, with an explicit one-time/import compatibility path from
existing QGIS assemblies.
- R2: Workbench state must expose signal provenance and the existing editable
signal fields using stable IDs, and support generating, moving, rotating,
and deleting signal assemblies.
- R3: Native compile and preview delivery must generate runtime signal data
directly from the native model, preserving enabled/disabled state, arm
direction/pose, and phase-group data without requiring QGIS.
- R4: A compatibility adapter must import/export the existing QGIS assembly
format during migration and preserve legacy signal IDs where possible.
## Scope Boundary
- Native signal model and overrides are the long-term source of truth.
- QGIS GeoJSON/GeoPackage support is transitional compatibility only; do not
make native compile depend on QGIS or regenerate native edits from QGIS.
- Do not redesign signal geometry, timing logic, or vehicle behavior in this
task.
## Acceptance Criteria
- [ ] A native compile/reopen round trip preserves edited signal assemblies,
stable IDs, enabled state, and provenance without QGIS running.
- [ ] The Road Workbench can generate, inspect, move, rotate, and delete signal
assemblies, then save durable edits without breaking QGIS reimport.
- [ ] Native Blender/Cesium/preview consume runtime signal data generated from
the native model, including disabled signals being omitted from runtime.
- [ ] QGIS import/export compatibility and legacy build stages continue to
pass while the migration adapter exists.
- [ ] Nantaizi has documented native-only and QGIS-imported round trips.

View File

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

View File

@@ -835,7 +835,7 @@ def build(args):
_roads.assemble_osm_fallback(
ways, projector, roads_c, road_mats["road_surface"])
traffic_signal_path = os.path.join(geojson_dir or "", "traffic_signals.json")
traffic_signal_path = args.get("traffic_signals") or os.path.join(geojson_dir or "", "traffic_signals.json")
dynamic_signal_objects = 0
if os.path.exists(traffic_signal_path):
try:

View File

@@ -247,6 +247,7 @@ function buildBlenderScene(area, roadProvider) {
];
if (roadProvider === "native") {
blenderArgs.push("--native-road", area.outputs.nativeRoadDir);
blenderArgs.push("--traffic-signals", path.join(area.outputs.nativeRoadDir, "traffic-signals.json"));
} else {
blenderArgs.push("--geojson", area.outputs.geojsonDir);
}

View File

@@ -5,6 +5,7 @@ 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 { loadOrGenerate, runtime } = require("./lib/native-traffic-signals");
const repoRoot = path.resolve(__dirname, "..");
@@ -25,14 +26,19 @@ function compileArea(configPath) {
validateOverrides(overrides, model);
fs.mkdirSync(area.outputs.pipelineDir, { recursive: true });
const compiled = compileGeometry(model, overrides, { edgeLines: area.nativeRoad.edgeLines });
const signalDocument = loadOrGenerate(area.outputs.nativeTrafficSignals, fs.readFileSync(area.input, "utf8"), compiled.vehicleStopLines, compiled.intersectionSurface);
const signalRuntime = runtime(signalDocument);
// Persist validation normalization, including one-time legacy heading migration.
writeJsonAtomic(area.outputs.nativeTrafficSignals, signalDocument);
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 },
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides, trafficSignals: area.outputs.nativeTrafficSignals },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements,
trafficSignals: { assemblies: "traffic-signal-assemblies.json", runtime: "traffic-signals.json", count: signalRuntime.signals.length },
diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", edgeLines: "layers/edge_lines.geojson", sidewalkSurface: "layers/sidewalk_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", laneSeparators: "layers/lane_separators.geojson", centerLines: "layers/center_lines.geojson", directionArrows: "layers/direction_arrows.geojson", turnArrows: "layers/turn_arrows.geojson", crosswalks: "layers/crosswalks.geojson", vehicleStopLines: "layers/vehicle_stop_lines.geojson", connectors: "layers/connectors.geojson" },
};
@@ -40,6 +46,8 @@ function compileArea(configPath) {
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, "traffic-signal-assemblies.json"), signalDocument.assemblies);
writeJsonAtomic(path.join(staging, "traffic-signals.json"), signalRuntime);
writeJsonAtomic(path.join(staging, "layers", "road_surface.geojson"), compiled.roadSurface);
writeJsonAtomic(path.join(staging, "layers", "edge_lines.geojson"), compiled.edgeLines);
writeJsonAtomic(path.join(staging, "layers", "sidewalk_surface.geojson"), compiled.sidewalkSurface);

View File

@@ -38,6 +38,7 @@ function normalizeAreaConfig(raw, options = {}) {
geojsonDir,
nativeRoadDir,
nativeRoadOverrides: path.resolve(outputOverrides.nativeRoadOverrides || path.join(areaDir, "native-road-overrides.json")),
nativeTrafficSignals: path.resolve(outputOverrides.nativeTrafficSignals || path.join(areaDir, "native-traffic-signals.json")),
gpkg: path.resolve(outputOverrides.gpkg || path.join(areaDir, `${fileStem}.gpkg`)),
qgisProject: path.resolve(outputOverrides.qgisProject || path.join(areaDir, `${fileStem}.qgz`)),
qgisPreview: path.resolve(outputOverrides.qgisPreview || path.join(areaDir, `${fileStem}-preview.png`)),

View File

@@ -0,0 +1,33 @@
"use strict";
const fs = require("fs");
const { parseOsm } = require("./osm");
const {
buildTrafficSignalFeatures,
buildTrafficSignalsFromFeatures,
validateTrafficSignalFeatures,
validateTrafficSignalSourceReferences,
} = require("./traffic-signals");
const SCHEMA = "native-traffic-signals/v1";
function loadOrGenerate(file, osmText, stopLines, intersections) {
if (fs.existsSync(file)) return validateDocument(JSON.parse(fs.readFileSync(file, "utf8")), osmText);
return generate(osmText, stopLines, intersections);
}
function generate(osmText, stopLines, intersections) {
const controls = parseOsm(osmText).trafficSignalControls;
return { schema: SCHEMA, provenance: "generated:osm-controls", assemblies: buildTrafficSignalFeatures(stopLines, intersections, controls) };
}
function validateDocument(value, osmText) {
if (value?.schema !== SCHEMA) throw new Error(`Expected ${SCHEMA} signal document`);
const assemblies = validateTrafficSignalFeatures(value.assemblies);
if (osmText) validateTrafficSignalSourceReferences(assemblies, parseOsm(osmText).trafficSignalControls);
return { schema: SCHEMA, provenance: value.provenance || "native", assemblies };
}
function runtime(document) { return buildTrafficSignalsFromFeatures(document.assemblies); }
module.exports = { SCHEMA, generate, loadOrGenerate, validateDocument, runtime };

View File

@@ -59,7 +59,11 @@ function buildTrafficSignalFeatures(stopLines, intersections, controls = []) {
properties: {
signal_uid: signalUid, display_id: signalUid, control_id: String(control.id),
approach_id: approachId, source_way_id: sourceWayId,
heading_deg: candidate.headingDegrees, phase_group: groups[index],
// These are independent assembly controls. heading_deg remains a
// migration hint for older native documents only.
mast_heading_deg: normalizeDegrees(candidate.headingDegrees - 90),
face_heading_deg: normalizeDegrees(candidate.headingDegrees + 180),
phase_group: groups[index],
mast_reach_m: MAST_REACH_METERS,
stop_lon: candidate.center[0], stop_lat: candidate.center[1],
enabled: true, z_offset_m: 0,
@@ -114,13 +118,30 @@ function validateTrafficSignalFeatures(collection) {
if (!approachId.startsWith(`${sourceWayId}:`)) throw new Error(`${label}: approach_id does not match source_way_id`);
const expectedUid = `osm-${controlId}-${approachId.replace(":", "-")}`;
if (signalUid !== expectedUid) throw new Error(`${label}: signal_uid does not match source identity (expected '${expectedUid}')`);
const legacyHeading = input.heading_deg == null || input.heading_deg === "" ? null : normalizeDegrees(number("heading_deg"));
if (legacyHeading == null && (input.mast_heading_deg == null || input.mast_heading_deg === "")) {
throw new Error(`${label}: missing mast_heading_deg`);
}
if (legacyHeading == null && (input.face_heading_deg == null || input.face_heading_deg === "")) {
throw new Error(`${label}: missing face_heading_deg`);
}
const mastHeading = input.mast_heading_deg == null || input.mast_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) - 90)
: normalizeDegrees(number("mast_heading_deg"));
const faceHeading = input.face_heading_deg == null || input.face_heading_deg === ""
? normalizeDegrees((legacyHeading == null ? 0 : legacyHeading) + 180)
: normalizeDegrees(number("face_heading_deg"));
return {
type: "Feature",
geometry: { type: "Point", coordinates: feature.geometry.coordinates.slice(0, 2).map(Number) },
properties: {
...input, signal_uid: signalUid, display_id: displayId,
control_id: controlId, approach_id: approachId,
source_way_id: sourceWayId, heading_deg: normalizeDegrees(number("heading_deg")),
source_way_id: sourceWayId,
// Retain the legacy value only for migration compatibility. Runtime
// geometry is entirely defined by mast_heading_deg and face_heading_deg.
heading_deg: legacyHeading,
mast_heading_deg: mastHeading, face_heading_deg: faceHeading,
phase_group: phaseGroup, mast_reach_m: number("mast_reach_m", { min: 0.1, max: 30 }),
stop_lon: number("stop_lon", { min: -180, max: 180 }),
stop_lat: number("stop_lat", { min: -90, max: 90 }),
@@ -136,16 +157,20 @@ function buildTrafficSignalsFromFeatures(collection) {
const signals = normalized.features.filter((feature) => feature.properties.enabled).map((feature) => {
const p = feature.properties;
const point = feature.geometry.coordinates;
const axis = headingVector(p.heading_deg);
const mastAxis = headingVector(p.mast_heading_deg);
return {
id: p.signal_uid, signalUid: p.signal_uid, displayId: p.display_id,
nodeKey: signalNodeKey(p.signal_uid),
controlId: p.control_id, approachId: p.approach_id, sourceWayId: p.source_way_id,
phaseGroup: p.phase_group, longitude: point[0], latitude: point[1],
stopLongitude: p.stop_lon, stopLatitude: p.stop_lat,
headingDegrees: p.heading_deg, mastReachMeters: p.mast_reach_m,
// Existing Blender readers require headingDegrees. It is a compatibility
// alias only; the independent mast/face fields below define all geometry.
headingDegrees: p.heading_deg == null ? p.mast_heading_deg : p.heading_deg,
mastHeadingDegrees: p.mast_heading_deg,
faceHeadingDegrees: p.face_heading_deg, mastReachMeters: p.mast_reach_m,
zOffsetMeters: p.z_offset_m,
pose: buildSignalPose(point, axis, p.mast_reach_m, p.z_offset_m),
pose: buildSignalPose(point, mastAxis, p.face_heading_deg, p.mast_reach_m, p.z_offset_m),
};
});
return { version: 3, layout: SIGNAL_LAYOUT, signals };
@@ -235,12 +260,13 @@ function phaseGroups(arms) {
groups[main[0]] = 0; groups[main[1]] = 0; return groups;
}
function buildSignalPose(pole, axis, mastReach, zOffset = 0) {
const lateral = [axis[1], -axis[0]]; const face = [-axis[0], -axis[1]];
const head = moveMeters(pole, lateral, -mastReach); const faceHeadingDegrees = Math.atan2(face[0], face[1]) * 180 / Math.PI;
function buildSignalPose(pole, mastAxis, faceHeadingDegrees, mastReach, zOffset = 0) {
const face = headingVector(faceHeadingDegrees);
const head = moveMeters(pole, mastAxis, mastReach);
const position = (point, height) => ({ longitude: point[0], latitude: point[1], height: height + zOffset });
const lensPoint = moveMeters(head, face, SIGNAL_LAYOUT.lensFaceOffsetMeters);
const board = moveMeters(moveMeters(head, lateral, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
const faceRight = [-face[1], face[0]];
const board = moveMeters(moveMeters(head, faceRight, SIGNAL_LAYOUT.countdownLateralMeters), face, SIGNAL_LAYOUT.countdownFaceOffsetMeters);
return { pole: position(pole, 0), arm: { from: position(pole, SIGNAL_LAYOUT.mastHeightMeters), to: position(head, SIGNAL_LAYOUT.mastHeightMeters) }, head: { ...position(head, SIGNAL_LAYOUT.headCenterHeightMeters), faceHeadingDegrees }, lenses: ["red", "yellow", "green"].map((state, index) => ({ state, ...position(lensPoint, SIGNAL_LAYOUT.headCenterHeightMeters + SIGNAL_LAYOUT.lensVerticalOffsetsMeters[index]) })), countdown: { ...position(board, SIGNAL_LAYOUT.mastHeightMeters), faceHeadingDegrees } };
}

View File

@@ -6,6 +6,7 @@ const http = require("http");
const path = require("path");
const { readAreaConfig } = require("./lib/area-config");
const { loadOverrides, validateOverrides, writeJsonAtomic } = require("./lib/native-road");
const { generate, validateDocument, runtime } = require("./lib/native-traffic-signals");
const { compileArea, parseArgs } = require("./compile-native-roads");
const repoRoot = path.resolve(__dirname, "..");
@@ -32,6 +33,20 @@ function handle(request, response, area, configPath) {
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.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/traffic-signals/generate") return Promise.resolve().then(() => {
const compiled = readCompiled(area);
const generated = generate(fs.readFileSync(area.input, "utf8"), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "vehicle_stop_lines.geojson")), readLayer(path.join(area.outputs.nativeRoadDir, "layers", "intersection_surface.geojson")));
const current = validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"));
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)));
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, { ok: true, trafficSignals: current, runtime: runtime(current), generated: generated.assemblies.features.length, compiled: Boolean(compiled) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/overrides") return readBody(request).then((body) => {
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
@@ -48,7 +63,11 @@ function handle(request, response, area, configPath) {
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")), 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 } };
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);
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), 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 } };
}
function readCompiled(area) { return readJson(path.join(area.outputs.nativeRoadDir, "compiled.json")); }
function readJson(file) { return JSON.parse(fs.readFileSync(file, "utf8")); }

View File

@@ -52,4 +52,29 @@ assert.match(app, /state\.layers\.vehicleStopLines/);
assert.match(app, /native-road-crosswalk\/v1/);
assert.match(app, /native-road-stop-line\/v1/);
assert.match(app, /term\.textContent = label; detail\.textContent = value; summary\.append\(term, detail\)/);
assert.match(app, /原生红绿灯/);
assert.match(app, /function selectSignal\(feature\)/);
assert.match(app, /\/api\/traffic-signals/);
assert.match(app, /从 OSM 生成缺失信号灯/);
assert.match(app, /data-layer="signals" type="checkbox" checked> 红绿灯设施/);
assert.match(app, /红绿灯设施/);
assert.match(app, /signalAssemblyStyle/);
assert.match(app, /signal_component: "mast"/);
assert.match(app, /signal_component: "face"/);
assert.match(app, /signal_component: "head"/);
assert.match(app, /faceHeadingDegrees/);
assert.match(app, /横杆方向(度)/);
assert.match(app, /横杆长度(米)/);
assert.match(app, /灯面朝向(度)/);
assert.match(app, /检查信号灯<select name="signal-picker">/);
assert.match(app, /signalPicker\.onchange/);
assert.match(app, /map\.getView\(\)\.fit\(\[x - 25, y - 25, x \+ 25, y \+ 25\]/);
assert.match(app, /fromLonLat/);
assert.match(app, /armFeatures\.push\(new Feature/);
assert.match(app, /headFeatures\.push\(new Feature/);
assert.match(app, /faceFeatures\.push\(new Feature/);
const server = fs.readFileSync(path.join(__dirname, "road-workbench.js"), "utf8");
assert.match(server, /\/api\/traffic-signals\/generate/);
assert.doesNotMatch(app, /导入 QGIS|导出 QGIS/);
assert.doesNotMatch(server, /traffic-signals\/(?:import|export)-qgis/);
console.log("road workbench tests passed");

View File

@@ -47,6 +47,9 @@ const originalStop = [first.properties.stop_lon, first.properties.stop_lat];
first.geometry.coordinates[0] += 0.0001;
first.properties.display_id = "A-01";
first.properties.heading_deg = 42;
first.properties.mast_heading_deg = 17;
first.properties.mast_reach_m = 8.5;
first.properties.face_heading_deg = 203;
first.properties.z_offset_m = 1.25;
const runtime = buildTrafficSignalsFromFeatures(edited);
assert.equal(new Set(runtime.signals.map((signal) => signal.nodeKey)).size, runtime.signals.length);
@@ -61,9 +64,22 @@ const changed = runtime.signals.find((signal) => signal.id === first.properties.
assert.equal(changed.displayId, "A-01");
assert.equal(changed.longitude, first.geometry.coordinates[0]);
assert.equal(changed.headingDegrees, 42);
assert.equal(changed.mastHeadingDegrees, 17);
assert.equal(changed.faceHeadingDegrees, 203);
assert.equal(changed.mastReachMeters, 8.5);
assert.deepEqual([changed.stopLongitude, changed.stopLatitude], originalStop, "moving a pole preserves the stop point");
assert.equal(changed.pose.pole.height, 1.25);
assert.equal(changed.pose.arm.from.height, 7.5);
assert.ok(changed.pose.arm.to.latitude > changed.pose.arm.from.latitude, "mast direction controls the arm independently");
assert.equal(changed.pose.head.faceHeadingDegrees, 203, "face direction is independent from mast direction");
const legacy = structuredClone(cross);
legacy.features[0].properties.heading_deg = 42;
delete legacy.features[0].properties.mast_heading_deg;
delete legacy.features[0].properties.face_heading_deg;
const migrated = validateTrafficSignalFeatures(legacy).features[0].properties;
assert.equal(migrated.mast_heading_deg, 312, "legacy heading preserves the historic mast direction");
assert.equal(migrated.face_heading_deg, 222, "legacy heading preserves the historic face direction");
edited.features[1].properties.enabled = "0";
assert.equal(buildTrafficSignalsFromFeatures(edited).signals.length, 3, "disabled assemblies are omitted");
@@ -100,7 +116,7 @@ for (const disabledValue of [false, 0, "0", "false", "no"]) {
disabled.features[0].properties.enabled = disabledValue;
assert.equal(buildTrafficSignalsFromFeatures(disabled).signals.length, 3);
}
for (const key of ["heading_deg", "phase_group", "stop_lon", "stop_lat", "z_offset_m"]) {
for (const key of ["phase_group", "stop_lon", "stop_lat", "z_offset_m"]) {
const missingNumber = structuredClone(cross);
missingNumber.features[0].properties[key] = null;
assert.throws(
@@ -109,5 +125,8 @@ for (const key of ["heading_deg", "phase_group", "stop_lon", "stop_lat", "z_offs
`${key} must not silently coerce null to zero`,
);
}
const missingDirections = structuredClone(cross);
for (const key of ["heading_deg", "mast_heading_deg", "face_heading_deg"]) missingDirections.features[0].properties[key] = null;
assert.throws(() => validateTrafficSignalFeatures(missingDirections), /missing mast_heading_deg/);
console.log("Traffic signal tests passed.");

View File

@@ -13,6 +13,7 @@ import CircleStyle from "/vendor/ol/style/Circle.js";
import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js";
import { fromLonLat } from "/vendor/ol/proj.js";
const geojson = new GeoJSON();
const areaLabel = document.querySelector("#area");
@@ -57,7 +58,9 @@ const edgeLinesToggle = document.createElement("label");
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox"> 道路外缘线';
const controlsToggle = document.createElement("label");
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle);
const signalsToggle = document.createElement("label");
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
let state;
let selectedRoad = null;
@@ -70,6 +73,13 @@ let scenePreview = false;
let selectedCenterLineSegment = null;
let selectedLaneSeparator = null;
let selectedEdgeLine = null;
let selectedSignal = null;
const signalPanel = document.createElement("section");
signalPanel.innerHTML = '<hr><h2>原生红绿灯</h2><button type="button" data-signal="generate">从 OSM 生成缺失信号灯</button><label>检查信号灯<select name="signal-picker"><option value="">选择设施</option></select></label><form hidden><output></output><label>灯杆经度<input name="lon" type="number" min="-180" max="180" step="0.000001"></label><label>灯杆纬度<input name="lat" type="number" min="-90" max="90" step="0.000001"></label><label>横杆方向(度)<input name="mastHeading" type="number" min="0" max="360" step="1"></label><label>横杆长度(米)<input name="mastReach" type="number" min="0.1" max="30" step="0.1"></label><label>灯面朝向(度)<input name="faceHeading" type="number" min="0" max="360" step="1"></label><label>相位组<input name="phase" type="number" min="0" max="1" step="1"></label><label><input name="enabled" type="checkbox"> 启用</label><button type="submit">保存信号灯</button><button type="button" data-signal="delete">删除信号灯</button></form>';
document.querySelector(".inspector").insertBefore(signalPanel, document.querySelector(".inspector details"));
const signalForm = signalPanel.querySelector("form");
const signalOutput = signalForm.querySelector("output");
const signalPicker = signalPanel.querySelector('[name="signal-picker"]');
const source = () => new VectorSource();
const layers = {
reference: new VectorLayer({ source: source(), visible: false, style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
@@ -82,18 +92,20 @@ const layers = {
markings: new VectorLayer({ source: source(), style: markingStyle }),
centerLines: new VectorLayer({ source: source(), style: centerLineStyle }),
controls: new VectorLayer({ source: source(), style: markingStyle }),
signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
};
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
map.addInteraction(select);
select.on("select", ({ selected }) => {
const feature = selected[0];
if (!feature) return;
if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature);
if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature));
const junction = junctionForFeature(feature);
if (junction) return selectJunction(junction);
@@ -145,6 +157,7 @@ function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); }
function markingStyle(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); }
function signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); }
function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); }
function nativeSurfaceStyle(feature) {
// Split road features meet at OSM junction nodes. Their per-feature outlines
@@ -183,6 +196,7 @@ function updateSources() {
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]);
layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines));
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
layers.diagnostics.getSource().clear(); layers.diagnostics.getSource().addFeatures(readFeatures({ type: "FeatureCollection", features: state.compiled.diagnostics.filter((item) => item.geometry).map(({ geometry, ...properties }) => ({ type: "Feature", properties, geometry })) }));
const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
@@ -209,6 +223,23 @@ function selectJunction(feature) {
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
message(`已选中路口OSM 节点 ${properties.osm_node_id}`);
}
function selectSignal(feature) {
selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal;
const [x, y] = feature.getGeometry().getCoordinates();
map.getView().fit([x - 25, y - 25, x + 25, y + 25], { padding: [80, 80, 80, 360], maxZoom: 22, duration: 250 });
signalForm.hidden = false; signalOutput.textContent = `${p.display_id || p.signal_uid}${p.signal_uid}`;
signalForm.lon.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[0];
signalForm.lat.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[1];
signalForm.mastHeading.value = p.mast_heading_deg; signalForm.mastReach.value = p.mast_reach_m; signalForm.faceHeading.value = p.face_heading_deg; signalForm.phase.value = p.phase_group; signalForm.enabled.checked = p.enabled;
evidence.textContent = JSON.stringify({ 信号灯: p.signal_uid, 控制节点: p.control_id, 路口方向: p.approach_id, 来源: state.trafficSignals.provenance }, null, 2); message("已选中原生红绿灯");
}
function poleFeatureForSignal(signalUid) { return layers.signals.getSource().getFeatures().find((item) => item.get("signal_uid") === signalUid && !item.get("signal_component")); }
async function saveSignals(document) { const response = await fetch("/api/traffic-signals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(document) }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); renderSummary(); }
function signalDocumentWithChange(change) { const document = structuredClone(state.trafficSignals); document.assemblies.features = change(document.assemblies.features); return document; }
signalForm.onsubmit = async (event) => { event.preventDefault(); try { await saveSignals(signalDocumentWithChange((features) => features.map((feature) => feature.properties.signal_uid !== selectedSignal ? feature : { ...feature, geometry: { type: "Point", coordinates: [Number(signalForm.lon.value), Number(signalForm.lat.value)] }, properties: { ...feature.properties, mast_heading_deg: Number(signalForm.mastHeading.value), mast_reach_m: Number(signalForm.mastReach.value), face_heading_deg: Number(signalForm.faceHeading.value), phase_group: Number(signalForm.phase.value), enabled: signalForm.enabled.checked } }))); message("红绿灯已保存"); } catch (error) { message(error.message); } };
signalPanel.querySelector('[data-signal="delete"]').onclick = async () => { try { await saveSignals(signalDocumentWithChange((features) => features.filter((feature) => feature.properties.signal_uid !== selectedSignal))); signalForm.hidden = true; selectedSignal = null; message("红绿灯已删除"); } catch (error) { message(error.message); } };
signalPanel.querySelector('[data-signal="generate"]').onclick = async () => { try { const response = await fetch("/api/traffic-signals/generate", { method: "POST" }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); message("已补充 OSM 信号灯"); } catch (error) { message(error.message); } };
signalPicker.onchange = () => { const feature = poleFeatureForSignal(signalPicker.value); if (feature) selectSignal(feature); };
function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; }
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}${osmDirectionLabel(targetRoad)}\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
function renderDirectionSwitch(road) {
@@ -247,6 +278,7 @@ function renderSummary() {
["路口转向箭头", comparison.nativeTurnArrowFeatures],
["斑马线条带", comparison.nativeCrosswalkFeatures],
["停止线", comparison.nativeVehicleStopLineFeatures],
["红绿灯设施", state.trafficSignals?.assemblies?.features?.length || 0],
["行驶动作", comparison.nativeMovementCount],
["已绘制路径", comparison.nativePublishedMovementCount],
["可手工复核", comparison.unconnectedEndsWithManualCandidates],
@@ -273,7 +305,7 @@ centerLineStyleInput.onchange = stageSelectedCenterLineStyle;
async function saveStagedChanges() { if (!staged.length) return true; 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 result = await response.json(); if (!result.ok) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已保存并重新生成"); };
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); };
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); };
scenePreviewToggle.onchange = () => {
scenePreview = scenePreviewToggle.checked;
for (const input of document.querySelectorAll("[data-layer]")) {
@@ -285,9 +317,11 @@ scenePreviewToggle.onchange = () => {
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
layers.centerLines.setVisible(document.querySelector('[data-layer="centerLines"]').checked);
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
const signalsVisible = document.querySelector('[data-layer="signals"]').checked;
layers.signals.setVisible(signalsVisible);
layers.diagnostics.setVisible(!scenePreview);
layers.native.changed(); layers.sidewalks.changed();
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
};
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; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
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));