feat: add canonical road movements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("已重新生成"); };
|
||||||
|
|||||||
Reference in New Issue
Block a user