feat: add native road marking semantics

This commit is contained in:
2026-08-17 16:08:56 +08:00
parent bd38aa55d3
commit 9bb97d4507
13 changed files with 209 additions and 17 deletions

View File

@@ -515,6 +515,10 @@ Road Workbench 选中 `native-road-center-line/v1` 要素后,可为其 `segmen
- `color` 只能是 `yellow``white``pattern` 只能是 `dashed``solid` - `color` 只能是 `yellow``white``pattern` 只能是 `dashed``solid`
目标 `segmentId` 必须属于当前 native road model。 目标 `segmentId` 必须属于当前 native road model。
- `double: true` 只表示双黄实线,因此只能与 `color: "yellow"`
`pattern: "solid"` 组合。编译器为每段的每个有效实线 mark 写两条相距 0.32m 的
平行 Polygon并记录 `double: true``effective_style:
"double-yellow-solid"`;不得把它实现为可任意组合的双线开关。
- 未覆写段保持黄色虚线2m dash、2m gap`solid` 为 0 gap但相邻的 2m - 未覆写段保持黄色虚线2m dash、2m gap`solid` 为 0 gap但相邻的 2m
几何块须重叠 `0.04m`,避免投影精度造成可见裂缝。 几何块须重叠 `0.04m`,避免投影精度造成可见裂缝。
- 每个生成面记录 `color``pattern``effective_style`。Workbench 以这些属性 - 每个生成面记录 `color``pattern``effective_style`。Workbench 以这些属性
@@ -551,6 +555,30 @@ Road Workbench 选中 `native-road-center-line/v1` 要素后,可为其 `segmen
正确:小幅重叠相邻块,并让 Workbench 实线 stroke 与 fill 同色。 正确:小幅重叠相邻块,并让 Workbench 实线 stroke 与 fill 同色。
## Native 道路外缘线样式覆写
`edge_lines.geojson` 是每条 directional native road 两侧的外缘标线,使用
`native-road-edge-line/v1` provenance。默认是白色实线必须带 `road_id``side`
`left` / `right`)、`osm_way_ids``color``pattern``effective_style`
Road Workbench 点选外缘线后以中文显示其方向侧边,可暂存以下 area-local 覆写:
```json
{
"id": "道路外缘线:road:way/123:forward:left",
"kind": "edge-line-style",
"roadId": "road:way/123:forward",
"side": "left",
"color": "white",
"pattern": "solid"
}
```
`roadId` 必须是当前 directional native road 的精确 ID`side` 必须为 `left``right`
实线使用连续 offset line虚线使用确定性
2m mark / 2m gap。工作台的暂存与“保存并重新生成”是唯一的写盘路径不能只改浏览器填充色
native Blender 将该层映射到既有 `lane_separators` material layer。
## Native 控制标线 ## Native 控制标线
### 1. 范围与触发条件 ### 1. 范围与触发条件

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Check compiler and workbench marking contracts."}
{"file":".trellis/spec/blender/testing.md","reason":"Run required Blender test suite."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Verify browser, compiler, and Blender consume the same output."}

View File

@@ -0,0 +1,22 @@
# Design
The compiler owns marking semantics. `center-line-style` remains segment
scoped because a bidirectional road segment shares one centre-line decision.
`edge-line-style` is scoped to `{ roadId, side }`, since a directional
carriageway has independently editable left and right outside edges.
`double: true` is valid only with yellow solid centre lines. Generation emits
two 0.32m-separated parallel polygons for each normal solid mark, preserving
junction cutback and crosswalk/stop-line clearance. This keeps the existing
solid line implementation and avoids a separate geometry pipeline.
Road edge lines use the existing road-edge offset geometry. A solid style is
one continuous buffered offset line; a dashed style uses deterministic 2m
marks at 4m spacing. Both carry `effective_style`, source IDs, side, and
native provenance.
The Workbench uses one marking form with an explicit selected target type.
Only a centre-line target exposes the double-yellow option; lane separators
and edge lines retain the ordinary colour/pattern choices. Staged changes pass
through the existing save and regenerate flow, so browser, compiler and
Blender read the same persisted override.

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/pipeline/cli-and-stages.md","reason":"Native-road output, override, and stage contracts."}
{"file":".trellis/spec/blender/asset-generation.md","reason":"Native provider adapter and Blender output requirements."}
{"file":".trellis/spec/guides/artifact-parity-guide.md","reason":"Intentional render-output change validation."}

View File

@@ -0,0 +1,10 @@
# Implementation
1. Extend override validation and native marking generation for explicit
double-yellow centre lines and per-side edge-line styles.
2. Extend the Chinese Workbench selection/form flow without adding another
editor or output format.
3. Add compiler and Workbench regression coverage for valid and invalid
payloads, generated geometry, and layer adapter completeness.
4. Compile/check Nantaizi, run native/Workbench/build-stage/Blender tests,
then run the native Blender/Cesium/preview chain.

View File

@@ -0,0 +1,36 @@
# Native road marking semantics
## Goal
Finish the native road marking model so the Nantaizi Road Workbench can
persist meaningful centre-line and road-edge-line styles, regenerate native
geometry deterministically, and deliver the same result to Blender/Cesium.
## Requirements
- Add an explicit double-yellow-solid centre-line semantic. It must not be a
generic double-line switch that permits invalid colour/pattern combinations.
- Keep all marking edits area-local in `native-road-overrides.json`; generated
GeoJSON remains derived output.
- Make road edge lines selectable and style-editable in the Chinese Road
Workbench, alongside existing centre lines and lane separators.
- Preserve the default Nantaizi output unless a user supplies an override.
- Native geometry must remain independent of osm2streets render geometry.
## Acceptance Criteria
- [ ] A `center-line-style` override with `double: true`, `yellow`, and
`solid` emits two offset centre-line polygons with an explicit effective
style, while invalid double combinations are rejected.
- [ ] An `edge-line-style` override targets one directional road side and
supports white/yellow plus solid/dashed styles.
- [ ] The Workbench exposes Chinese selection feedback, stages the correct
override payload, and only shows the double-yellow choice for centre lines.
- [ ] Nantaizi native compile/check, Workbench/native tests, Blender pure
tests, and native Blender/Cesium/preview build pass.
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.

View File

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

View File

@@ -52,6 +52,7 @@ class RoadLayerCatalogTest(unittest.TestCase):
[(entry["source"], entry["material_layer"]) [(entry["source"], entry["material_layer"])
for entry in NATIVE_ROAD_LAYERS], for entry in NATIVE_ROAD_LAYERS],
[("road_surface", "road_surface"), [("road_surface", "road_surface"),
("edge_lines", "lane_separators"),
("intersection_surface", "intersection_surface"), ("intersection_surface", "intersection_surface"),
("sidewalk_surface", "sidewalks"), ("sidewalk_surface", "sidewalks"),
("lane_separators", "lane_separators"), ("lane_separators", "lane_separators"),

View File

@@ -150,6 +150,7 @@ function validateOverrides(value, model) {
if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`); if (!value || value.schema !== OVERRIDE_SCHEMA || !Array.isArray(value.overrides)) throw new Error(`Overrides must use ${OVERRIDE_SCHEMA}.`);
const ids = new Set(); const ids = new Set();
const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null; const roadIds = model ? new Set(model.roads.flatMap((road) => [road.id, road.sourceRoadId])) : null;
const directionalRoadIds = model ? new Set(model.roads.map((road) => road.id)) : null;
const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null; const endpointIds = model ? new Set(model.endpoints.map((endpoint) => endpoint.id)) : null;
const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null; const laneIds = model ? new Set(model.roads.flatMap((road) => Array.from({ length: road.laneCount }, (_, index) => `lane:${road.id}:${index + 1}`))) : null;
const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null; const segmentIds = model ? new Set(model.roads.map((road) => road.segmentId)) : null;
@@ -166,9 +167,11 @@ function validateOverrides(value, model) {
} else if (item.kind === "lane-connection") { } else if (item.kind === "lane-connection") {
if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override."); if (typeof item.fromLaneId !== "string" || typeof item.toLaneId !== "string" || typeof item.enabled !== "boolean" || (laneIds && (!laneIds.has(item.fromLaneId) || !laneIds.has(item.toLaneId)))) throw new Error("Invalid lane connection override.");
} else if (item.kind === "center-line-style") { } else if (item.kind === "center-line-style") {
if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override."); if (typeof item.segmentId !== "string" || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern) || (item.double !== undefined && typeof item.double !== "boolean") || (item.double && (item.color !== "yellow" || item.pattern !== "solid")) || (segmentIds && !segmentIds.has(item.segmentId))) throw new Error("Invalid center line style override.");
} else if (item.kind === "lane-separator-style") { } else if (item.kind === "lane-separator-style") {
if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override."); if (typeof item.roadId !== "string" || (roadIds && !roadIds.has(item.roadId)) || !Number.isInteger(item.leftLaneIndex) || item.rightLaneIndex !== item.leftLaneIndex + 1 || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid lane separator style override.");
} else if (item.kind === "edge-line-style") {
if (typeof item.roadId !== "string" || (directionalRoadIds && !directionalRoadIds.has(item.roadId)) || !["left", "right"].includes(item.side) || !CENTER_LINE_COLORS.has(item.color) || !CENTER_LINE_PATTERNS.has(item.pattern)) throw new Error("Invalid edge line style override.");
} else throw new Error(`Unsupported override kind: ${item.kind}`); } else throw new Error(`Unsupported override kind: ${item.kind}`);
} }
return { schema: OVERRIDE_SCHEMA, overrides: value.overrides }; return { schema: OVERRIDE_SCHEMA, overrides: value.overrides };
@@ -255,7 +258,7 @@ function compileGeometry(model, overrides = { overrides: [] }) {
features.push({ type: "Feature", properties: { native_id: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 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: surfaceId, directional_road_ids: directions.map((item) => item.id).join(","), osm_way_ids: road.osmWayIds.join(","), source_road_id: road.sourceRoadId, width_m: totalWidth, lane_count: directions.reduce((sum, item) => item.laneCount + sum, 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, junctionPlans); const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans);
const edgeLines = compileEdgeLines(model, junctionPlans); const edgeLines = compileEdgeLines(model, overrides, junctionPlans);
const controls = compileControlMarkings(model, lanes, diagnostics); const controls = compileControlMarkings(model, lanes, diagnostics);
const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics); const centerLines = compileCenterLines(model, overrides, junctionPlans, controls, diagnostics);
const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls); const markings = compileLaneMarkings(model, overrides, lanes, diagnostics, junctionPlans, controls);
@@ -266,7 +269,32 @@ function compileGeometry(model, overrides = { overrides: [] }) {
return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics }; return { roadSurface: { type: "FeatureCollection", features }, edgeLines: { type: "FeatureCollection", features: edgeLines }, sidewalkSurface: { type: "FeatureCollection", features: sidewalks }, intersectionSurface: { type: "FeatureCollection", features: junctionFeatures }, laneCenterlines: { type: "FeatureCollection", features: lanes.features }, laneSeparators: { type: "FeatureCollection", features: markings.separators }, centerLines: { type: "FeatureCollection", features: centerLines }, directionArrows: { type: "FeatureCollection", features: markings.directionArrows }, turnArrows: { type: "FeatureCollection", features: markings.turnArrows }, crosswalks: { type: "FeatureCollection", features: controls.crosswalks }, vehicleStopLines: { type: "FeatureCollection", features: controls.stopLines }, connectors: { type: "FeatureCollection", features: connectorResult.features }, movements: connectorResult.movements, diagnostics };
} }
function compileEdgeLines(model, junctionPlans) { const features=[]; for (const road of model.roads) { const line=trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); for (const side of [-1,1]) { const ring=roadRing(offsetLine(line, side * road.widthMeters / 2), .12); if (ring) features.push({type:"Feature",properties:{native_id:`edge-line:${road.id}:${side<0?"right":"left"}`,road_id:road.id,side:side<0?"right":"left",color:"white",pattern:"solid",provenance:"native-road-edge-line/v1"},geometry:{type:"Polygon",coordinates:[ring]}}); } } return features; } function compileEdgeLines(model, overrides, junctionPlans) {
const features = [];
for (const road of model.roads) {
const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
for (const offset of [-1, 1]) {
const side = offset < 0 ? "right" : "left";
const style = edgeLineStyle(overrides, road.id, side);
const centerline = offsetLine(line, offset * road.widthMeters / 2);
if (style.pattern === "solid") {
const ring = roadRing(centerline, .12);
if (ring) features.push(edgeLineFeature(road, side, style, ring));
continue;
}
for (let distance = 1, part = 1; distance + 1 <= lineLengthMeters(centerline); distance += 4, part += 1) {
const placement = pointAndAxisAlongLine(centerline, distance);
if (!placement) continue;
features.push(edgeLineFeature(road, side, style, rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], 2, .12, 0), part));
}
}
}
return features;
}
function edgeLineFeature(road, side, style, ring, part = null) {
return { type: "Feature", properties: { native_id: `edge-line:${road.id}:${side}${part ? `:${part}` : ""}`, road_id: road.id, side, osm_way_ids: road.osmWayIds.join(","), color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, provenance: "native-road-edge-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } };
}
function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) { function compileCenterLines(model, overrides, junctionPlans, controls, diagnostics) {
const features = []; const features = [];
@@ -289,10 +317,9 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti
for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) { for (let start = 0, dashIndex = 1; start + markLength <= length; start += CENTER_LINE_DASH_LENGTH_METERS + gap, dashIndex += 1) {
const placement = pointAndAxisAlongLine(line, start + markLength / 2); const placement = pointAndAxisAlongLine(line, start + markLength / 2);
if (!placement) continue; if (!placement) continue;
const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, 0);
const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0); const clearanceRing = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, CENTER_LINE_WIDTH_METERS + CENTER_LINE_CONTROL_CLEARANCE_METERS * 2, 0);
if (ringsOverlapControl([clearanceRing], controlFeatures)) continue; if (ringsOverlapControl([clearanceRing], controlFeatures)) continue;
features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, effective_style: `${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); for (const offset of style.double ? [-.16, .16] : [0]) { const ring = rectangleAt(placement.point, placement.axis, [-placement.axis[1], placement.axis[0]], markLength, CENTER_LINE_WIDTH_METERS, offset); features.push({ type: "Feature", properties: { native_id: `center-line:${segmentId}:${dashIndex}:${offset}`, segment_id: segmentId, road_id: forward.id, directional_road_ids: roads.map((road) => road.id).join(","), osm_way_ids: forward.osmWayIds.join(","), dash_index: dashIndex, dash_length_m: markLength, dash_gap_m: gap, color: style.color, pattern: style.pattern, double: Boolean(style.double), effective_style: `${style.double ? "double-" : ""}${style.color}-${style.pattern}`, placement_rule: "native-bidirectional-centerline/v1", provenance: "native-road-center-line/v1" }, geometry: { type: "Polygon", coordinates: [ring] } }); }
} }
} }
return features; return features;
@@ -300,7 +327,12 @@ function compileCenterLines(model, overrides, junctionPlans, controls, diagnosti
function centerLineStyle(overrides, segmentId) { function centerLineStyle(overrides, segmentId) {
const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId); const override = overrides.overrides.find((item) => item.kind === "center-line-style" && item.segmentId === segmentId);
return override ? { color: override.color, pattern: override.pattern } : { color: "yellow", pattern: "dashed" }; return override ? { color: override.color, pattern: override.pattern, double: Boolean(override.double) } : { color: "yellow", pattern: "dashed", double: false };
}
function edgeLineStyle(overrides, roadId, side) {
const value = overrides.overrides.find((item) => item.kind === "edge-line-style" && item.roadId === roadId && item.side === side);
return value ? { color: value.color, pattern: value.pattern } : { color: "white", pattern: "solid" };
} }
function compileControlMarkings(model, lanes, diagnostics) { function compileControlMarkings(model, lanes, diagnostics) {

View File

@@ -43,7 +43,25 @@ const centerLineOverride = validateOverrides({ schema: "native-road-overrides/v1
const styledCenterLines = compileGeometry(model, centerLineOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId); const styledCenterLines = compileGeometry(model, centerLineOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
assert.ok(styledCenterLines.length > 0); assert.ok(styledCenterLines.length > 0);
assert.ok(styledCenterLines.every((feature) => feature.properties.color === "white" && feature.properties.pattern === "solid" && feature.properties.effective_style === "white-solid" && feature.properties.dash_gap_m === 0)); assert.ok(styledCenterLines.every((feature) => feature.properties.color === "white" && feature.properties.pattern === "solid" && feature.properties.effective_style === "white-solid" && feature.properties.dash_gap_m === 0));
const doubleCenterOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "center-double-yellow", kind: "center-line-style", segmentId: target.segmentId, color: "yellow", pattern: "solid", double: true }] }, model);
const doubleCenterLines = compileGeometry(model, doubleCenterOverride).centerLines.features.filter((feature) => feature.properties.segment_id === target.segmentId);
assert.equal(doubleCenterLines.length, styledCenterLines.length * 2);
assert.ok(doubleCenterLines.every((feature) => feature.properties.double === true && feature.properties.effective_style === "double-yellow-solid"));
assert.equal(new Set(doubleCenterLines.map((feature) => feature.properties.dash_index)).size, styledCenterLines.length);
for (const dashIndex of new Set(doubleCenterLines.map((feature) => feature.properties.dash_index))) {
const pair = doubleCenterLines.filter((feature) => feature.properties.dash_index === dashIndex);
const centers = pair.map((feature) => feature.geometry.coordinates[0].slice(0, 4).reduce((sum, point) => [sum[0] + point[0] / 4, sum[1] + point[1] / 4], [0, 0]));
assert.ok(Math.hypot((centers[0][0] - centers[1][0]) * 96400, (centers[0][1] - centers[1][1]) * 111320) > .25);
}
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-double-center", kind: "center-line-style", segmentId: target.segmentId, color: "white", pattern: "solid", double: true }] }, model), /Invalid center line style override/);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-center", kind: "center-line-style", segmentId: target.segmentId, color: "blue", pattern: "solid" }] }, model), /Invalid center line style override/); assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-center", kind: "center-line-style", segmentId: target.segmentId, color: "blue", pattern: "solid" }] }, model), /Invalid center line style override/);
const edgeLine = geometry.edgeLines.features[0];
assert.ok(edgeLine && edgeLine.properties.effective_style === "white-solid");
const edgeOverride = validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "edge-yellow-dashed", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: edgeLine.properties.side, color: "yellow", pattern: "dashed" }] }, model);
const styledEdgeLines = compileGeometry(model, edgeOverride).edgeLines.features.filter((feature) => feature.properties.road_id === edgeLine.properties.road_id && feature.properties.side === edgeLine.properties.side);
assert.ok(styledEdgeLines.length > 1 && styledEdgeLines.every((feature) => feature.properties.effective_style === "yellow-dashed"));
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "bad-edge", kind: "edge-line-style", roadId: edgeLine.properties.road_id, side: "middle", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
assert.throws(() => validateOverrides({ schema: "native-road-overrides/v1", overrides: [{ id: "missing-edge-road", kind: "edge-line-style", roadId: "road:way/missing:forward", side: "left", color: "yellow", pattern: "solid" }] }, model), /Invalid edge line style override/);
assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32)); assert.ok(geometry.directionArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-direction-arrow/v1" && feature.properties.placement_interval_meters === 32));
assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1")); assert.ok(geometry.turnArrows.features.every((feature) => feature.geometry.type === "Polygon" && feature.properties.provenance === "native-road-turn-arrow/v1"));
assert.ok(geometry.connectors.features.length > 0); assert.ok(geometry.connectors.features.length > 0);

View File

@@ -27,7 +27,7 @@ assert.match(app, /外缘扩张倍率/);
assert.match(app, /最大外缘扩张/); assert.match(app, /最大外缘扩张/);
assert.match(app, /道路方向箭头/); assert.match(app, /道路方向箭头/);
assert.match(app, /native-road-direction-arrow\/v1/); assert.match(app, /native-road-direction-arrow\/v1/);
assert.match(app, /data-layer="centerLines" type="checkbox" checked> 道路中心线/); assert.match(app, /data-layer="centerLines" type="checkbox" checked> 道路中心线/);
assert.match(app, /centerLines: new VectorLayer/); assert.match(app, /centerLines: new VectorLayer/);
assert.match(app, /state\.layers\.centerLines/); assert.match(app, /state\.layers\.centerLines/);
assert.match(app, /native-road-center-line\/v1/); assert.match(app, /native-road-center-line\/v1/);
@@ -37,6 +37,12 @@ assert.match(app, /center-line-style/);
assert.match(app, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/); assert.match(app, /centerLineStyleInput\.onchange = stageSelectedCenterLineStyle/);
assert.match(html, /道路中心线样式/); assert.match(html, /道路中心线样式/);
assert.match(html, /white-solid/); assert.match(html, /white-solid/);
assert.match(app, /double-yellow-solid/);
assert.match(app, /const double = parts\[0\] === "double"/);
assert.match(app, /function selectEdgeLine\(feature\)/);
assert.match(app, /edge-line-style/);
assert.match(app, /道路外缘线样式/);
assert.match(html, /id="marking-style-heading"/);
assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/); assert.match(app, /data-layer="controls" type="checkbox" checked> 斑马线与停止线/);
assert.match(app, /controls: new VectorLayer/); assert.match(app, /controls: new VectorLayer/);
assert.match(app, /state\.layers\.crosswalks/); assert.match(app, /state\.layers\.crosswalks/);

View File

@@ -31,7 +31,10 @@ const leftInput = document.querySelector("#left");
const rightInput = document.querySelector("#right"); const rightInput = document.querySelector("#right");
const centerLineForm = document.querySelector("#center-line-form"); const centerLineForm = document.querySelector("#center-line-form");
const centerLineSegment = document.querySelector("#center-line-segment"); const centerLineSegment = document.querySelector("#center-line-segment");
const markingStyleHeading = document.querySelector("#marking-style-heading");
const centerLineStyleInput = document.querySelector("#center-line-style"); const centerLineStyleInput = document.querySelector("#center-line-style");
const doubleYellowOption = document.createElement("option");
doubleYellowOption.value = "double-yellow-solid"; doubleYellowOption.textContent = "双黄实线"; centerLineStyleInput.append(doubleYellowOption);
const evidence = document.querySelector("#evidence"); const evidence = document.querySelector("#evidence");
const diagnostics = document.querySelector("#diagnostics"); const diagnostics = document.querySelector("#diagnostics");
const diagnosticFilters = document.querySelector("#diagnostic-filters"); const diagnosticFilters = document.querySelector("#diagnostic-filters");
@@ -49,7 +52,7 @@ directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="che
const markingsToggle = document.createElement("label"); const markingsToggle = document.createElement("label");
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头'; markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
const centerLinesToggle = document.createElement("label"); const centerLinesToggle = document.createElement("label");
centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心线'; centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心线';
const edgeLinesToggle = document.createElement("label"); const edgeLinesToggle = document.createElement("label");
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox" checked> 道路外缘线'; edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox" checked> 道路外缘线';
const controlsToggle = document.createElement("label"); const controlsToggle = document.createElement("label");
@@ -66,6 +69,7 @@ let diagnosticFilter = "all";
let scenePreview = false; let scenePreview = false;
let selectedCenterLineSegment = null; let selectedCenterLineSegment = null;
let selectedLaneSeparator = null; let selectedLaneSeparator = null;
let selectedEdgeLine = null;
const source = () => new VectorSource(); const source = () => new VectorSource();
const layers = { 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 }) }) }), 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 }) }) }),
@@ -99,11 +103,12 @@ select.on("select", ({ selected }) => {
const directionArrow = provenance === "native-road-direction-arrow/v1"; const directionArrow = provenance === "native-road-direction-arrow/v1";
const turnArrow = provenance === "native-road-turn-arrow/v1"; const turnArrow = provenance === "native-road-turn-arrow/v1";
const centerLine = provenance === "native-road-center-line/v1"; const centerLine = provenance === "native-road-center-line/v1";
const edgeLine = provenance === "native-road-edge-line/v1";
const crosswalk = provenance === "native-road-crosswalk/v1"; const crosswalk = provenance === "native-road-crosswalk/v1";
const stopLine = provenance === "native-road-stop-line/v1"; const stopLine = provenance === "native-road-stop-line/v1";
const markingType = centerLine ? "道路中心线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线"; const markingType = centerLine ? "道路中心线" : edgeLine ? "道路外缘线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
selectRoad(road); selectRoad(road);
if (centerLine) selectCenterLine(feature); else if (provenance === "native-road-lane-separator/v1") selectLaneSeparator(feature); else clearCenterLineSelection(); if (centerLine) selectCenterLine(feature); else if (edgeLine) selectEdgeLine(feature); else if (provenance === "native-road-lane-separator/v1") selectLaneSeparator(feature); else clearCenterLineSelection();
evidence.textContent = JSON.stringify({ evidence.textContent = JSON.stringify({
标线类型: markingType, 标线类型: markingType,
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null, 人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
@@ -113,7 +118,7 @@ select.on("select", ({ selected }) => {
方向: feature.get("direction"), 方向: feature.get("direction"),
车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")}${feature.get("right_lane_index")} 之间`, 车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")}${feature.get("right_lane_index")} 之间`,
转向: turnArrow ? feature.get("maneuver") : null, 转向: turnArrow ? feature.get("maneuver") : null,
样式: centerLine ? feature.get("effective_style") : null, 样式: centerLine || edgeLine || provenance === "native-road-lane-separator/v1" ? feature.get("effective_style") : null,
放置方法: feature.get("placement_method") || null, 放置方法: feature.get("placement_method") || null,
道路内距离米: feature.get("distance_along_lane_meters") || null, 道路内距离米: feature.get("distance_along_lane_meters") || null,
路口前距离米: feature.get("placement_distance_meters") || null, 路口前距离米: feature.get("placement_distance_meters") || null,
@@ -255,12 +260,14 @@ function renderSummary() {
} }
} }
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); } function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); centerLineForm.hidden = false; centerLineSegment.textContent = `道路段:${selectedCenterLineSegment}`; centerLineStyleInput.value = feature.get("effective_style") || "yellow-dashed"; } function setMarkingStyleForm(title, target, style, allowsDouble) { centerLineForm.hidden = false; markingStyleHeading.textContent = title; centerLineSegment.textContent = target; doubleYellowOption.hidden = !allowsDouble; centerLineStyleInput.value = style || "yellow-dashed"; }
function selectLaneSeparator(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = feature.getProperties(); centerLineForm.hidden = false; centerLineSegment.textContent = `车道分隔线:第 ${selectedLaneSeparator.left_lane_index} 与第 ${selectedLaneSeparator.right_lane_index} 车道之间`; centerLineStyleInput.value = selectedLaneSeparator.effective_style || "white-dashed"; } function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); selectedLaneSeparator = null; setMarkingStyleForm("道路中心线样式", `道路段:${selectedCenterLineSegment}`, feature.get("effective_style"), true); }
function clearCenterLineSelection() { selectedCenterLineSegment = null; selectedLaneSeparator = null; centerLineForm.hidden = true; } function selectLaneSeparator(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = feature.getProperties(); setMarkingStyleForm("车道分隔线样式", `${selectedLaneSeparator.left_lane_index} 与第 ${selectedLaneSeparator.right_lane_index} 车道之间`, selectedLaneSeparator.effective_style || "white-dashed", false); }
function stageCenterLineStyle(segmentId, style) { const [color, pattern] = style.split("-"); const id = `道路中心线:${segmentId}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "center-line-style", segmentId, color, pattern }); } function selectEdgeLine(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = feature.getProperties(); setMarkingStyleForm("道路外缘线样式", `${selectedEdgeLine.side === "left" ? "左" : "右"}侧外缘`, selectedEdgeLine.effective_style || "white-solid", false); }
function clearCenterLineSelection() { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = null; centerLineForm.hidden = true; }
function stageCenterLineStyle(segmentId, style) { const parts = style.split("-"); const double = parts[0] === "double"; const [color, pattern] = double ? parts.slice(1) : parts; const id = `道路中心线:${segmentId}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "center-line-style", segmentId, color, pattern, double }); }
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); }; form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment && !selectedLaneSeparator) return; if (selectedCenterLineSegment) stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); else { const [color, pattern] = centerLineStyleInput.value.split("-"); const item = selectedLaneSeparator; const id = `车道分隔线:${item.road_id}:${item.left_lane_index}-${item.right_lane_index}`; staged = staged.filter((change) => change.id !== id); staged.push({ id, kind: "lane-separator-style", roadId: item.road_id, leftLaneIndex: item.left_lane_index, rightLaneIndex: item.right_lane_index, color, pattern }); } updateDirtyState(); message("有未保存修改:线样式"); } function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment && !selectedLaneSeparator && !selectedEdgeLine) return; if (selectedCenterLineSegment) stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); else { const [color, pattern] = centerLineStyleInput.value.split("-"); const item = selectedLaneSeparator || selectedEdgeLine; const id = selectedLaneSeparator ? `车道分隔线:${item.road_id}:${item.left_lane_index}-${item.right_lane_index}` : `道路外缘线:${item.road_id}:${item.side}`; staged = staged.filter((change) => change.id !== id); staged.push(selectedLaneSeparator ? { id, kind: "lane-separator-style", roadId: item.road_id, leftLaneIndex: item.left_lane_index, rightLaneIndex: item.right_lane_index, color, pattern } : { id, kind: "edge-line-style", roadId: item.road_id, side: item.side, color, pattern }); } updateDirtyState(); message("有未保存修改:线样式"); }
centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); }; centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); };
centerLineStyleInput.onchange = stageSelectedCenterLineStyle; 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; } 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; }

View File

@@ -1,4 +1,4 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/vendor/ol/ol.css"><link rel="stylesheet" href="/app.css"></head>
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header> <body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><span id="dirty-state" aria-live="polite"></span><label style="display:inline;margin:0 0 0 auto;white-space:nowrap"><input id="scene-preview" type="checkbox"> 场景效果</label><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
<main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2>道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存中心线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html> <main><aside class="issues"><h1>图层</h1><label><input data-layer="osm" type="checkbox" checked> OSM 道路中心线</label><label><input data-layer="native" type="checkbox" checked> 自研道路与路口面</label><label><input data-layer="sidewalks" type="checkbox" checked> 路缘与步行带</label><label><input data-layer="lanes" type="checkbox" checked> 车道与转向路径</label><label><input data-layer="reference" type="checkbox"> osm2streets 参考面</label><hr><h1>当前编译概览</h1><dl id="summary"></dl><hr><h1>待检查问题</h1><div id="diagnostic-filters" class="segmented"><button data-diagnostic-filter="all" type="button">全部</button><button data-diagnostic-filter="candidates" type="button">可连接</button><button data-diagnostic-filter="other" type="button">其他</button></div><ul id="diagnostics"></ul></aside><section id="map" class="map"></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">点击道路、车道、转向路径或路口面以查看详情。</p><section id="selected-junction" hidden><h2>当前路口</h2><output id="junction-detail"></output></section><form id="road-form" hidden><label>道路</label><output id="road-name"></output><output id="movement-summary"></output><output id="lane-convention"></output><section id="selected-movement" hidden><h2>当前行驶动作</h2><output id="movement-detail"></output></section><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.01"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有路缘与步行带</label><label><input id="right" type="checkbox"> 右侧有路缘与步行带</label><button type="submit">暂存本道路修改</button></form><form id="center-line-form" hidden><h2 id="marking-style-heading">道路中心线样式</h2><output id="center-line-segment"></output><label>样式<select id="center-line-style"><option value="yellow-dashed">黄色虚线(默认)</option><option value="white-dashed">白色虚线</option><option value="yellow-solid">黄色实线</option><option value="white-solid">白色实线</option></select></label><button type="submit">暂存线样式</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><button id="add-connection" type="button" hidden>手工新增驶出连接</button><details><summary>技术详情与来源</summary><pre id="evidence"></pre></details></aside></main><script type="importmap">{"imports":{"rbush":"/vendor/rbush/index.js","quickselect":"/vendor/quickselect/index.js"}}</script><script type="module" src="/app.js"></script></body></html>