feat: add canonical road movements

This commit is contained in:
2026-08-14 10:15:47 +08:00
parent 1876472bf8
commit a556c0fc97
6 changed files with 98 additions and 22 deletions

View File

@@ -52,6 +52,13 @@ override IDs, and diagnostics. Junction candidates likewise use their OSM node
ID when available. Values include provenance such as `tag:lanes:forward`, ID when available. Values include provenance such as `tag:lanes:forward`,
`inferred:highway-default`, or `override:<id>`. `inferred:highway-default`, or `override:<id>`.
Each `Movement` is a stable semantic record joining a connection, source and
target road/lane, turn class, provenance, and an optional connector geometry.
`geometryStatus="connector"` publishes a connector curve;
`geometryStatus="continuous"` means the lane centerlines meet at the node and
does not invent a zero-length curve; `deferred-too-long` retains the movement
while withholding unsafe geometry.
### Override file ### Override file
`<area>/native-road-overrides.json` is versioned and human-reviewable. It `<area>/native-road-overrides.json` is versioned and human-reviewable. It
@@ -62,7 +69,8 @@ existence, finite values, and duplicate/conflicting edits before atomic write.
### Compiler artifacts ### Compiler artifacts
`<area>/native-road/compiled.json` is the workbench's single read model. `<area>/native-road/compiled.json` is the workbench's single read model and
contains the canonical `movements` list.
`layers/` contains generated GeoJSON with source/provenance properties. `layers/` contains generated GeoJSON with source/provenance properties.
`diagnostics.json` contains severity, stable subject ID, source IDs, rule, `diagnostics.json` contains severity, stable subject ID, source IDs, rule,
message, and optional geometry. `comparison.json` reports counts and coverage message, and optional geometry. `comparison.json` reports counts and coverage
@@ -71,10 +79,12 @@ visual differences.
## Browser Workbench ## Browser Workbench
The browser uses no framework or map runtime in v1. A Canvas/SVG map renders The browser uses OpenLayers as its sole GIS runtime, served directly from the
fit-to-data OSM centerlines, native surfaces, optional osm2streets reference local allowlisted `node_modules` packages with a browser import map. The map
layers, diagnostics, selected-object provenance, and overrides. This keeps the renders fit-to-data OSM centerlines, native surfaces, optional osm2streets
first interactive path dependency-free and permits precise local coordinates. reference layers, diagnostics, selected-object provenance, movements, and
overrides. This provides mature map selection and hit detection without a
framework or bundler.
The user can select a road or endpoint, edit only v1 fields, inspect the The user can select a road or endpoint, edit only v1 fields, inspect the
resulting override record, explicitly save it, and recompile/reload. Saved resulting override record, explicitly save it, and recompile/reload. Saved

View File

@@ -0,0 +1,50 @@
# Two-Area Native Road Comparison
## Runs
2026-08-14:
```bash
npm run road:compile -- --config config/areas/nantaizi-lake-innovation-valley.json
npm run build:area -- --config config/areas/hanyang-block.json --stages intermediates
npm run road:compile -- --config config/areas/hanyang-block.json
```
`comparison.json` is a coverage and diagnostic record. Feature counts are not
a geometry-quality score: osm2streets and the native compiler segment roads at
different levels.
| Area | Directional roads | Native surfaces | Native junctions | Movements | Internal ends | Manual candidates | osm2streets road surfaces |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| nantaizi-lake-innovation-valley | 34 | 19 | 6 | 46 | 4 | 2 | 50 |
| hanyang-block | 475 | 352 | 20 | 369 | 161 | 83 | 1826 |
## Observed Failure Modes
The hanyang osm2streets/QGIS run completed, but its log reported repeated:
- roads trimmed into oblivion;
- degenerate intersections that could not be collapsed because layers, names,
highway types, or lane specifications differ;
- intersection polygon requests with no roads.
The native compiler did not hide the related uncertainty. It reported 161
internal road ends. Eighty-three have one or more nearby, direction-compatible
candidate departures within 35 metres; these remain suggestions for explicit
review rather than automatic topology edits. The initial five one-way junctions
that lacked connector curves now publish road-surface envelopes based on their
semantic movements. Across hanyang, 369 movements are identified: 243 require
a connector curve, while 126 are continuous at their OSM node and intentionally
have no separate geometry.
## Resulting Priorities
1. Keep manual candidate suggestions and semantic overrides as the correction
path for near-miss topology. Do not bulk-connect candidates.
2. Expand ordinary junction support from 3/4 physical approaches only after
identifying a repeated unsupported topology; one-way movements that are
continuous at a node are already supported without fake connector geometry.
3. Add an inspectable movement artifact so turn geometry is not the only
representation of a road-to-road movement.
4. Use a visual review of a few explicit hanyang diagnostics before changing
connection-distance or road-class rules.

View File

@@ -32,6 +32,7 @@ function compileArea(configPath) {
areaId: area.id, areaId: area.id,
source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides }, source: { osm: area.input, overrides: area.outputs.nativeRoadOverrides },
model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections }, model: { roads: model.roads, endpoints: model.endpoints, connections: model.connections },
movements: compiled.movements,
diagnostics: compiled.diagnostics, diagnostics: compiled.diagnostics,
layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" }, layers: { roadSurface: "layers/road_surface.geojson", intersectionSurface: "layers/intersection_surface.geojson", laneCenterlines: "layers/lane_centerlines.geojson", connectors: "layers/connectors.geojson" },
}; };
@@ -73,6 +74,8 @@ function compareOsm2Streets(area, model, compiled) {
nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length, nativeJunctionSurfaceFeatures: compiled.intersectionSurface.features.length,
nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length, nativeLaneCenterlineFeatures: compiled.laneCenterlines.features.length,
nativeConnectorFeatures: compiled.connectors.features.length, nativeConnectorFeatures: compiled.connectors.features.length,
nativeMovementCount: compiled.movements.length,
nativePublishedMovementCount: compiled.movements.filter((movement) => movement.geometryPublished).length,
nativeConnectionCount: model.connections.length, nativeConnectionCount: model.connections.length,
unconnectedInteriorRoadEnds: dangling.length, unconnectedInteriorRoadEnds: dangling.length,
unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length, unconnectedEndsWithManualCandidates: dangling.filter((item) => item.manualCandidates?.length).length,

View File

@@ -203,10 +203,10 @@ function compileGeometry(model, overrides = { overrides: [] }) {
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] } }); 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 lanes = compileLaneCenterlines(model, diagnostics); const lanes = compileLaneCenterlines(model, diagnostics);
const connectors = compileConnectors(model, lanes, diagnostics, overrides); const connectorResult = compileConnectors(model, lanes, diagnostics, overrides);
const junctionFeatures = compileJunctionSurfaces(model, lanes, connectors, diagnostics); const junctionFeatures = compileJunctionSurfaces(model, lanes, connectorResult.features, connectorResult.movements, diagnostics);
validateConnectorContainment(connectors, junctionFeatures, diagnostics); validateConnectorContainment(connectorResult.features, junctionFeatures, diagnostics);
return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectors }, diagnostics }; return { roadSurface: { type: "FeatureCollection", features }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
} }
function validateConnectorContainment(connectors, junctionFeatures, diagnostics) { function validateConnectorContainment(connectors, junctionFeatures, diagnostics) {
@@ -265,6 +265,7 @@ function compileLaneCenterlines(model, diagnostics) {
function compileConnectors(model, lanes, diagnostics, overrides) { function compileConnectors(model, lanes, diagnostics, overrides) {
const features = []; const features = [];
const movements = [];
for (const connection of model.connections.filter((item) => item.enabled)) { for (const connection of model.connections.filter((item) => item.enabled)) {
const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId)); const fromRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.fromEndpointId));
const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId)); const toRoad = model.roads.find((road) => road.id === endpointRoadId(model, connection.toEndpointId));
@@ -277,17 +278,22 @@ function compileConnectors(model, lanes, diagnostics, overrides) {
const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex]; const defaultFromLane = fromLanes[index]; const defaultToLane = toLanes[defaultTargetIndex];
const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id); const override = laneOverride(overrides, defaultFromLane.id, defaultToLane.id);
if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue; if ((!laneAllowsTurn(fromRoad, index, turn) && override?.enabled !== true) || override?.enabled === false) continue;
const targetIndex = defaultTargetIndex;
const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0]; const from = defaultFromLane.coordinates.at(-1); const to = defaultToLane.coordinates[0];
const control = connectorControlPoint(model, connection, from, to); const control = connectorControlPoint(model, connection, from, to);
const coordinates = quadraticCurve(from, control, to, 12); const coordinates = quadraticCurve(from, control, to, 12);
const length = lineLengthMeters(coordinates); const length = lineLengthMeters(coordinates);
if (length < 0.4) continue; const id = `movement:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`;
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,已跳过;请检查路口拓扑或人工连接。", from)); continue; } const provenance = override ? `override:${override.id}` : connection.provenance;
features.push({ type: "Feature", properties: { native_id: `connector:${connection.id}:${defaultFromLane.id}->${defaultToLane.id}`, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance: override ? `override:${override.id}` : connection.provenance }, geometry: { type: "LineString", coordinates } }); const connectorId = `connector:${id}`;
const geometryStatus = length < .4 ? "continuous" : length > 80 ? "deferred-too-long" : "connector";
const movement = { id, connectorId, connectionId: connection.id, nodeId: connection.nodeId, fromRoadId: fromRoad.id, toRoadId: defaultToLane.roadId, fromLaneId: defaultFromLane.id, toLaneId: defaultToLane.id, turn, provenance, appliedOverrideIds: override ? [override.id] : [], geometryPublished: geometryStatus === "connector", geometryStatus };
if (length < .4) { movements.push(movement); continue; }
if (length > 80) { diagnostics.push(diagnostic("warning", connection.id, [connection.nodeId], "connector-too-long", "转向路径超过 80 米,未发布几何;请检查路口拓扑或人工连接。", from)); movements.push(movement); continue; }
features.push({ type: "Feature", properties: { native_id: connectorId, movement_id: id, connection_id: connection.id, node_id: connection.nodeId, from_lane_id: defaultFromLane.id, to_lane_id: defaultToLane.id, turn, provenance }, geometry: { type: "LineString", coordinates } });
movements.push(movement);
} }
} }
return features; return { features, movements };
} }
function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); } function laneOverride(overrides, fromLaneId, toLaneId) { return overrides.overrides.find((item) => item.kind === "lane-connection" && item.fromLaneId === fromLaneId && item.toLaneId === toLaneId); }
@@ -347,7 +353,7 @@ function offsetLine(line, offsetMeters) {
function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); } function lineLengthMeters(line) { return line.slice(1).reduce((sum, point, index) => { const previous = line[index]; const dx = (point[0] - previous[0]) * 111320 * Math.cos(point[1] * Math.PI / 180); const dy = (point[1] - previous[1]) * 111320; return sum + Math.hypot(dx, dy); }, 0); }
function compileJunctionSurfaces(model, lanes, connectors, diagnostics) { function compileJunctionSurfaces(model, lanes, connectors, movements, diagnostics) {
const byNode = new Map(); const byNode = new Map();
for (const endpoint of model.endpoints) { for (const endpoint of model.endpoints) {
if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []); if (!byNode.has(endpoint.nodeId)) byNode.set(endpoint.nodeId, []);
@@ -362,7 +368,8 @@ function compileJunctionSurfaces(model, lanes, connectors, diagnostics) {
const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4; const cutbackMeters = Math.max(...approaches.map((approach) => approach.widthMeters)) * 1.4;
const boundary = junctionBoundary(approaches, node, cutbackMeters); const boundary = junctionBoundary(approaches, node, cutbackMeters);
const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId); const junctionConnectors = connectors.filter((feature) => feature.properties.node_id === nodeId);
if (boundary.length < 3 || !junctionConnectors.length) { const junctionMovements = movements.filter((movement) => movement.nodeId === nodeId);
if (boundary.length < 3 || !junctionMovements.length) {
diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node)); diagnostics.push(diagnostic("warning", `junction:node/${nodeId}`, [nodeId], "junction-surface-deferred", "路口缺少足够的截面或转向路径,暂不生成路口面。", node));
continue; continue;
} }
@@ -377,7 +384,7 @@ function compileJunctionSurfaces(model, lanes, connectors, diagnostics) {
diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node)); diagnostics.push(diagnostic("error", `junction:node/${nodeId}`, [nodeId], "invalid-junction-surface", "路口截面边界发生自相交,未发布路口面。请检查道路方向或路口拓扑。", node));
continue; continue;
} }
result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } }); result.push({ type: "Feature", properties: { native_id: `junction:node/${nodeId}`, osm_node_id: nodeId, kind: wayIds.size === 3 ? "t" : "cross", source_road_ids: approaches.flatMap((approach) => approach.roadIds).join(","), cutback_m: cutbackMeters, movement_count: junctionMovements.length, connector_count: junctionConnectors.length, boundary_mode: boundaryMode, rule: "junction-approach-envelope/v2" }, geometry: { type: "Polygon", coordinates: [ring] } });
diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node)); diagnostics.push(diagnostic("info", `junction:node/${nodeId}`, [nodeId], "ordinary-junction-surface", "已按道路截面与转向路径生成普通路口面。", node));
} }
return result; return result;

View File

@@ -26,6 +26,9 @@ assert.equal(geometry.laneCenterlines.features.length, model.roads.reduce((sum,
assert.ok(geometry.connectors.features.length > 0); assert.ok(geometry.connectors.features.length > 0);
assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13)); assert.ok(geometry.connectors.features.every((feature) => feature.geometry.coordinates.length === 13));
assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id)); assert.ok(geometry.connectors.features.every((feature) => feature.properties.node_id));
assert.ok(geometry.movements.length >= geometry.connectors.features.length);
assert.ok(geometry.movements.every((movement) => movement.id.startsWith("movement:") && movement.connectorId.startsWith("connector:")));
assert.ok(geometry.movements.every((movement) => ["connector", "continuous", "deferred-too-long"].includes(movement.geometryStatus)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-approach-envelope/v2")); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.rule === "junction-approach-envelope/v2"));
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
const connection = initial.connections[0]; const connection = initial.connections[0];
@@ -50,6 +53,7 @@ assert.match(turnGeometry.connectors.features[0].properties.from_lane_id, /road:
assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/); assert.match(turnGeometry.connectors.features[0].properties.to_lane_id, /road:way\/31:forward:1$/);
const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel); const laneOverrides = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "block-left", kind: "lane-connection", fromLaneId: turnGeometry.connectors.features[0].properties.from_lane_id, toLaneId: turnGeometry.connectors.features[0].properties.to_lane_id, enabled: false }] }, turnModel);
assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0); assert.equal(compileGeometry(turnModel, laneOverrides).connectors.features.length, 0);
assert.equal(compileGeometry(turnModel, laneOverrides).movements.length, 0);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/); assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad", kind: "road", roadId: "missing", widthMeters: 4 }] }, initial), /Unknown road/);
const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-")); const freshArea = fs.mkdtempSync(path.join(os.tmpdir(), "native-road-fresh-area-"));
try { try {
@@ -63,6 +67,7 @@ try {
assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json"))); assert.ok(fs.existsSync(path.join(outputRoot, "fresh", "native-road", "compiled.json")));
assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2"); assert.equal(compiledArea.comparison.schema, "native-road-comparison/v2");
assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length); assert.equal(compiledArea.comparison.nativeRoadCount, compiledArea.result.model.roads.length);
assert.equal(compiledArea.comparison.nativePublishedMovementCount, compiledArea.result.movements.filter((movement) => movement.geometryPublished).length);
} finally { } finally {
fs.rmSync(freshArea, { recursive: true, force: true }); fs.rmSync(freshArea, { recursive: true, force: true });
} }

View File

@@ -83,7 +83,8 @@ function updateSources() {
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 })) })); 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 }); const extent = layers.osm.getSource().getExtent(); if (Number.isFinite(extent[0])) map.getView().fit(extent, { padding: [48, 48, 48, 48], maxZoom: 19 });
} }
function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; } function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; }
function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connector.enabled !== false; }
function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; } function effectiveConnectionEnabled(connection) { const id = `连接:${connection.id}`; const override = [...staged, ...state.overrides.overrides].find((item) => item.id === id); return override ? override.enabled : connection.enabled; }
function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); } function effectiveConnectorEnabled(connector) { const connection = state?.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
@@ -102,7 +103,7 @@ function renderDirectionSwitch(road) {
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; } if (alternatives.length < 2) { directionSwitch.textContent = "单向道路"; return; }
for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = osmDirectionLabel(item); button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); } for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = osmDirectionLabel(item); button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); }
} }
function renderMovementSummary(road) { const connectors = state.layers.connectors?.features.filter((feature) => roadIdFromLane(feature.properties.from_lane_id) === road.id && effectiveConnectorEnabled(feature.properties)) || []; const turns = connectors.reduce((result, item) => { result[item.properties.turn] = (result[item.properties.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; movementSummary.textContent = connectors.length ? `生成 ${connectors.length} 路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join("")}` : "当前方向没有已生成的转向路径"; } function renderMovementSummary(road) { const movements = state.compiled.movements?.filter((movement) => movement.fromRoadId === road.id && effectiveConnectorEnabled({ connection_id: movement.connectionId, fromLaneId: movement.fromLaneId, toLaneId: movement.toLaneId })) || []; const turns = movements.reduce((result, item) => { result[item.turn] = (result[item.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; const published = movements.filter((movement) => movement.geometryPublished).length; movementSummary.textContent = movements.length ? `识别 ${movements.length} 个行驶动作,${published} 条已绘制路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join("")}` : "当前方向没有已识别的行驶动作"; }
function renderConnections(road) { function renderConnections(road) {
connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id); connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
if (!rows.length) connectionsBox.textContent = "当前方向到达终点后没有已识别的驶出道路。"; if (!rows.length) connectionsBox.textContent = "当前方向到达终点后没有已识别的驶出道路。";
@@ -110,15 +111,15 @@ function renderConnections(road) {
renderManualCandidates(endpoint, road); renderManualCandidates(endpoint, road);
} }
function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } } function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } }
function renderLaneControls(connection) { const rows = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id).map((feature) => feature.properties) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === roadIdFromLane(row.to_lane_id)); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.from_lane_id))}${lanePositionLabel(targetRoad, laneIndex(row.to_lane_id))}${osmDirectionLabel(targetRoad)}`); connectionsBox.append(label); } } function renderLaneControls(connection) { const rows = state.compiled.movements?.filter((movement) => movement.connectionId === connection.id) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === row.toRoadId); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; const geometryNote = row.geometryStatus === "continuous" ? ",节点连续" : row.geometryStatus === "deferred-too-long" ? ",路径过长未绘制" : ""; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.fromLaneId))}${lanePositionLabel(targetRoad, laneIndex(row.toLaneId))}${osmDirectionLabel(targetRoad)}${geometryNote}`); connectionsBox.append(label); } }
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; } function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
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 }); layers.connectors.changed(); } 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 }); layers.connectors.changed(); }
function stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); layers.connectors.changed(); } function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); }
function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); } function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }
addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); }; addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); };
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); } function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
function renderDiagnostics() { diagnostics.innerHTML = ""; for (const item of state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface")) { const button = document.createElement("button"); button.textContent = item.message; button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } } function renderDiagnostics() { diagnostics.innerHTML = ""; for (const item of state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface")) { const button = document.createElement("button"); button.textContent = item.message; button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["转向路径", comparison.nativeConnectorFeatures], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } } function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["行驶动作", comparison.nativeMovementCount], ["已绘制路径", comparison.nativePublishedMovementCount], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selectedRoad.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selectedRoad.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); }; form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selectedRoad.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selectedRoad.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 result = await response.json(); if (!result.ok) return message(result.error); state.overrides = result.overrides; staged = []; 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 result = await response.json(); if (!result.ok) return message(result.error); state.overrides = result.overrides; staged = []; message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已重新生成"); }; compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已重新生成"); };