fix: correct native road edge and junction boundary geometry

修复四处几何缺陷,并新增复杂路口候选识别与工作台调试视图。

几何修复:
- junctionBoundary 的进口边改用 cutback 处朝向 (headingAtCutback)。原先取节点端
  首段朝向, 与 roadRing 的端边不平行, 15 个进口有偏差 (最大 152°), 路口面与道路面
  之间张开楔形。凸包兜底路口 11 -> 8。
- compileEdgeLines 偏移量改用 totalWidth/2。原用单方向 road.widthMeters/2,
  双向段的路缘线画在车行道正中间 (实测中位偏差 1.63m = w/2)。同时以实际发出的
  路面为准过滤无路面的路, 并统一复杂路口的裁剪口径。
  顶点偏离 >0.5m: 64% -> 0%, 最大偏离 4.53m -> 0.06m (= 半个带宽, 理论最优)。
- roadRing 新增 removeOffsetFolds。定宽法线偏移无斜接限制, 转弯处相邻段较短时
  偏移点前后颠倒, 多边形折回自身, 按 even-odd 渲染成叶片状空洞。
  道路面自交 1 -> 0, 最尖角 0.1° -> 18.8°。
- junctionBoundary 新增 mergeParallelApproachPoints。双幅路两幅同向汇入时,
  四个侧点按角度排序后交错, 在交错点之间插入的圆角曲线从外圈深挖回节点,
  渲染成拱形凹口。合并近平行 (<25°) 进口为单一面。
  路口面最尖角 6.9° -> 全部 >=82.6°, 自交 0。

新增:
- detectComplexJunctionCandidates: 按"短链接连接的路口节点"聚类并做紧凑度过滤
  (直径 <=45m), 输出 complex-junction-candidate 诊断与建议参数。纯诊断,
  不启用模板、不改几何; test:native-road 断言了这条契约。
- road:workbench --debug: 地图标注候选编号与包络, 点选给出可粘贴配置片段,
  并提供 POST /api/junction-clusters 一键写入区域配置。写入前用 readAreaConfig
  校验、检测节点冲突、要求候选存在于最新编译结果, 原子落盘。

Regression: test:native-road / test:road-workbench / test:gaode-junction-reference /
test:preflight / test:native-preview-traffic / test:package-contract /
test:traffic-signals 全绿; road:check ok=true, errors=[]。
源 OSM 与参考 GeoJSON 校验和未变。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 17:45:20 +08:00
parent 8ecd794633
commit a16400ff96
4 changed files with 395 additions and 18 deletions

View File

@@ -246,9 +246,97 @@ function nearbyManualCandidates(endpoints, from) {
return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 })); return endpoints.filter((to) => to.side === "start" && to.roadId !== from.roadId && !sameOsmWay(endpoints, from.roadId, to.roadId)).map((to) => ({ to, distanceMeters: distanceMeters(from.coordinate, to.coordinate) })).filter((item) => item.distanceMeters <= 35).sort((a, b) => a.distanceMeters - b.distanceMeters).slice(0, 3).map(({ to, distanceMeters: meters }) => ({ toEndpointId: to.id, roadId: to.roadId, distanceMeters: Math.round(meters * 10) / 10 }));
} }
// A single physical intersection is often mapped as several nodes joined by
// short links: a dual carriageway crossing, a slip lane, a staggered junction.
// Each node then compiles its own surface and the shared area between them is
// left as ordinary road, which is what produces the width jumps and stray
// medians around those nodes. Report the clusters so they can be configured as
// `complex-junction-v1`. Detection is advisory only — it never enables a
// template or changes geometry, because flipping a junction between the
// ordinary and complex paths silently on an OSM edit would be unpredictable.
const COMPLEX_CANDIDATE_MAX_LINK_METERS = 30;
const COMPLEX_CANDIDATE_MIN_NODES = 2;
// Short links chain transitively, so a run of closely spaced junctions along
// one street unions into a single 'cluster' that is really a corridor. A real
// intersection stays compact, so bound the cluster by its own diameter: the
// surveyed 珠山湖大道 cluster spans 24.6 m on its four mapped nodes and 40.7 m
// once the neighbouring service-road junction is folded in.
const COMPLEX_CANDIDATE_MAX_DIAMETER_METERS = 45;
function detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics) {
const configured = new Set((options.junctionTemplates?.clusters || []).flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const segmentsByNode = new Map();
for (const endpoint of model.endpoints) {
const key = String(endpoint.nodeId);
if (!segmentsByNode.has(key)) segmentsByNode.set(key, new Set());
segmentsByNode.get(key).add(endpoint.roadId.replace(/:(forward|backward)$/, ""));
}
const junctionNodes = new Set([...segmentsByNode].filter(([, segments]) => segments.size >= 3).map(([nodeId]) => nodeId));
const parent = new Map();
const find = (id) => {
if (!parent.has(id)) parent.set(id, id);
while (parent.get(id) !== id) { parent.set(id, parent.get(parent.get(id))); id = parent.get(id); }
return id;
};
const union = (first, second) => { const a = find(first); const b = find(second); if (a !== b) parent.set(a, b); };
const links = new Map();
const seenSegments = new Set();
for (const road of model.roads) {
if (seenSegments.has(road.segmentId)) continue;
seenSegments.add(road.segmentId);
const start = String(road.sourceNodeIds[0]); const end = String(road.sourceNodeIds.at(-1));
if (start === end || !junctionNodes.has(start) || !junctionNodes.has(end)) continue;
const length = lineLengthMeters(road.centerline);
if (length > COMPLEX_CANDIDATE_MAX_LINK_METERS) continue;
union(start, end);
links.set(road.segmentId, { start, end, length });
}
const clusters = new Map();
for (const nodeId of parent.keys()) {
const root = find(nodeId);
if (!clusters.has(root)) clusters.set(root, []);
clusters.get(root).push(nodeId);
}
for (const nodeIds of clusters.values()) {
if (nodeIds.length < COMPLEX_CANDIDATE_MIN_NODES) continue;
if (nodeIds.some((nodeId) => configured.has(nodeId))) continue;
const points = nodeIds.map((nodeId) => junctionPlans.get(nodeId)?.node).filter(Boolean);
if (points.length !== nodeIds.length) continue;
const center = points.reduce((sum, point) => [sum[0] + point[0] / points.length, sum[1] + point[1] / points.length], [0, 0]);
const spreadMeters = Math.max(...points.map((point) => distanceMeters(center, point)));
let diameterMeters = 0;
for (let first = 0; first < points.length; first += 1) {
for (let second = first + 1; second < points.length; second += 1) diameterMeters = Math.max(diameterMeters, distanceMeters(points[first], points[second]));
}
if (diameterMeters > COMPLEX_CANDIDATE_MAX_DIAMETER_METERS) continue;
const inner = [...links.values()].filter((link) => nodeIds.includes(link.start) && nodeIds.includes(link.end));
const widths = nodeIds.flatMap((nodeId) => (junctionPlans.get(nodeId)?.approaches || []).map((approach) => approach.widthMeters));
const widestApproach = widths.length ? Math.max(...widths) : 0;
// Enough core to cover every member node plus the widest approach's half
// width, with a little slack. A starting point for tuning, not a result.
const suggestedCoreRadius = Math.min(80, Math.max(12, Math.round(spreadMeters + widestApproach / 2 + 4)));
diagnostics.push({
...diagnostic("info", `junction-cluster-candidate:${nodeIds.slice().sort().join("+")}`, nodeIds, "complex-junction-candidate",
`检测到 ${nodeIds.length} 个路口节点由 ${inner.length} 条短路段(最长 ${Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10} 米)相连,可能是同一个物理路口。当前按独立路口编译;如需合并请在 nativeRoad.junctionTemplates.clusters 中配置。`,
center),
suggestedCluster: {
template: "complex-junction-v1",
nodeIds: nodeIds.slice().sort(),
nodeCount: nodeIds.length,
spreadMeters: Math.round(spreadMeters * 10) / 10,
diameterMeters: Math.round(diameterMeters * 10) / 10,
longestLinkMeters: Math.round(Math.max(...inner.map((link) => link.length)) * 10) / 10,
widestApproachMeters: Math.round(widestApproach * 100) / 100,
coreRadiusMeters: suggestedCoreRadius,
},
});
}
}
function compileGeometry(model, overrides = { overrides: [] }, options = {}) { function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
const diagnostics = [...model.diagnostics]; const diagnostics = [...model.diagnostics];
const junctionPlans = compileJunctionPlans(model, options, diagnostics); const junctionPlans = compileJunctionPlans(model, options, diagnostics);
detectComplexJunctionCandidates(model, junctionPlans, options, diagnostics);
const features = []; const features = [];
const activeClusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : []; const activeClusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster]))); const clusterByNode = new Map(activeClusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
@@ -311,7 +399,7 @@ function compileGeometry(model, overrides = { overrides: [] }, options = {}) {
diagnostics.push(...generated.diagnostics); diagnostics.push(...generated.diagnostics);
} }
const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines }); const lanes = compileLaneCenterlines(model, diagnostics, junctionPlans, options, { crosswalks: generatedComplexCrosswalks, stopLines: generatedComplexStopLines });
const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans); const edgeLines = options.edgeLines === false ? [] : compileEdgeLines(model, overrides, junctionPlans, options, features);
const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans); const controls = compileControlMarkings(model, lanes, diagnostics, junctionPlans);
const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] }; const allControls = { crosswalks: [...controls.crosswalks, ...generatedComplexCrosswalks], stopLines: [...controls.stopLines, ...generatedComplexStopLines] };
const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options); const centerLines = compileCenterLines(model, overrides, junctionPlans, allControls, diagnostics, options);
@@ -415,11 +503,30 @@ function laneWasClipped(original, visible) {
return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01; return Boolean(visible) && lineLengthMeters(visible) < lineLengthMeters(original) - .01;
} }
function compileEdgeLines(model, overrides, junctionPlans) { function compileEdgeLines(model, overrides, junctionPlans, options = {}, roadSurfaces = []) {
const features = []; const features = [];
const clusters = options.junctionTemplates?.enabled ? (options.junctionTemplates.clusters || []) : [];
const clusterByNode = new Map(clusters.flatMap((cluster) => cluster.nodeIds.map((nodeId) => [String(nodeId), cluster])));
const clusterCenters = new Map(clusters.map((cluster) => [cluster.id, clusterCenter(cluster, junctionPlans)]));
// Roads swallowed by a complex cluster never get a surface. Deriving their
// edge from the centerline anyway paints a curb across bare ground, so take
// the surfaces actually emitted as the authority on what can be outlined.
const surfaced = new Set(roadSurfaces.flatMap((feature) => String(feature.properties?.directional_road_ids || "").split(",")).filter(Boolean));
for (const road of model.roads) { for (const road of model.roads) {
const line = trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans); if (surfaced.size && !surfaced.has(road.id)) continue;
const bidirectional = model.roads.some((item) => item.segmentId === road.segmentId && item.id !== road.id); // The road surface is one polygon per segment, centred on this centerline
// and spanning the sum of both directions. Deriving the edge from a single
// direction's width puts it half a carriageway inside the asphalt.
const directions = model.roads.filter((item) => item.segmentId === road.segmentId);
const totalWidth = directions.reduce((sum, item) => sum + item.widthMeters, 0);
const bidirectional = directions.length > 1;
// Match the surface's trim exactly. A cluster road is cut at the cluster
// boundary, not at the ordinary junction cutback; using the cutback here
// runs the edge line out past the asphalt it is supposed to outline.
const cluster = clusterByNode.get(String(road.sourceNodeIds[0])) || clusterByNode.get(String(road.sourceNodeIds.at(-1)));
const line = cluster?.template === "complex-junction-v1"
? trimLineAtComplexCluster(road.centerline, road.sourceNodeIds, junctionPlans, cluster, clusterCenters.get(cluster.id))
: trimLineAtJunctions(road.centerline, road.sourceNodeIds, junctionPlans);
// On a two-way segment, the inner edge is the road centre boundary and is // On a two-way segment, the inner edge is the road centre boundary and is
// owned by center_lines. Emit only each directional carriageway's outer // owned by center_lines. Emit only each directional carriageway's outer
// edge; emitting both sides makes the layer look like a second centreline. // edge; emitting both sides makes the layer look like a second centreline.
@@ -427,7 +534,8 @@ function compileEdgeLines(model, overrides, junctionPlans) {
for (const offset of offsets) { for (const offset of offsets) {
const side = offset < 0 ? "right" : "left"; const side = offset < 0 ? "right" : "left";
const style = edgeLineStyle(overrides, road.id, side); const style = edgeLineStyle(overrides, road.id, side);
const centerline = offsetLine(line, offset * road.widthMeters / 2); const centerline = offsetLine(line, offset * totalWidth / 2);
if (!centerline) continue;
if (style.pattern === "solid") { if (style.pattern === "solid") {
const ring = roadRing(centerline, .12); const ring = roadRing(centerline, .12);
if (ring) features.push(edgeLineFeature(road, side, style, ring)); if (ring) features.push(edgeLineFeature(road, side, style, ring));
@@ -1180,17 +1288,64 @@ function junctionApproaches(model, endpoints) {
}); });
} }
// `roadRing` builds the trimmed road's end edge from the direction at the
// cutback point, not at the node. Taking the node-side heading here instead
// leaves the two edges non-parallel whenever the way bends inside the cutback,
// and the junction surface then opens a wedge against the road it should meet.
function headingAtCutback(line, cutbackMeters) {
const point = pointAlongLine(line, cutbackMeters);
if (!point) return null;
let traversed = 0;
for (let index = 1; index < line.length; index += 1) {
traversed += distanceMeters(line[index - 1], line[index]);
// The first original vertex past the cutback is what the trimmed line
// carries as its second point, so match that pair exactly.
if (traversed > cutbackMeters + 1e-9) return headingDegrees(point, line[index]);
}
return headingAtEndpoint(line);
}
// A dual carriageway reaches a node as two approaches on almost the same
// bearing. Their four side points interleave once sorted by angle, and because
// consecutive points then belong to different segments a rounded corner gets
// inserted between each interleaved pair. Those curves dive back toward the
// node and render as arch-shaped holes in the junction. Merge such approaches
// into one face and keep only its outermost edges.
const PARALLEL_APPROACH_DEGREES = 25;
function signedHeadingDelta(value) { return ((value + 180) % 360 + 360) % 360 - 180; }
function mergeParallelApproachPoints(points, node) {
const groups = [];
for (const item of points) {
const group = groups.find((candidate) => Math.abs(signedHeadingDelta(candidate.heading - item.outwardHeading)) < PARALLEL_APPROACH_DEGREES);
if (group) { group.points.push(item); continue; }
groups.push({ heading: item.outwardHeading, points: [item] });
}
return groups.flatMap((group) => {
const segments = [...new Set(group.points.map((item) => item.segmentId))];
if (segments.length < 2) return group.points;
// Order across the face by bearing measured from the group's own heading,
// so the comparison never straddles the +/-180 discontinuity.
const sorted = [...group.points].sort((first, second) =>
signedHeadingDelta(headingDegrees(node, first.point) - group.heading) - signedHeadingDelta(headingDegrees(node, second.point) - group.heading));
const merged = segments.sort().join("+");
return [sorted[0], sorted.at(-1)].map((item) => ({ ...item, segmentId: merged, sourceWayKey: merged }));
});
}
function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) { function junctionBoundary(approaches, node, cutbackMeters, cornerRadiusMultiplier = 1, approachWidthMultiplier = 1) {
const points = []; const points = [];
for (const approach of approaches) { for (const approach of approaches) {
const cutback = pointAlongLine(approach.line, cutbackMeters); const cutback = pointAlongLine(approach.line, cutbackMeters);
if (!cutback) continue; if (!cutback) continue;
const heading = headingAtEndpoint(approach.line); const heading = headingAtCutback(approach.line, cutbackMeters) ?? headingAtEndpoint(approach.line);
const half = approach.widthMeters * approachWidthMultiplier / 2; const half = approach.widthMeters * approachWidthMultiplier / 2;
points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); points.push({ point: offsetCoordinate(cutback, heading + 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading }); points.push({ point: offsetCoordinate(cutback, heading - 90, half), segmentId: approach.segmentId, sourceWayKey: approach.sourceWayKey, outwardHeading: heading });
} }
const ordered = points.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point)); const faces = mergeParallelApproachPoints(points, node);
const ordered = faces.sort((a, b) => angleAround(node, a.point) - angleAround(node, b.point));
if (ordered.length < 3) return { points: [], mode: "approach-envelope" }; if (ordered.length < 3) return { points: [], mode: "approach-envelope" };
const boundary = []; const boundary = [];
let rounded = 0; let rounded = 0;
@@ -1447,6 +1602,31 @@ function circleRing(center, radius, segments) {
return ring; return ring;
} }
// Offsetting every vertex by a fixed distance along its averaged normal has no
// miter limit: where the centerline turns and the neighbouring segment is short
// — typically the stub left after junction trimming — consecutive offset points
// swap order and the edge doubles back. The ring then self-intersects and the
// folded lobe renders as a hole. Drop the reversed vertices so each offset
// edge keeps travelling the same way as the centerline segment it follows.
function removeOffsetFolds(offset, centerline) {
let kept = offset.map((point, index) => ({ point, index }));
for (let guard = 0; guard < offset.length && kept.length > 2; guard += 1) {
let removed = false;
for (let position = 0; position < kept.length - 1; position += 1) {
const from = kept[position]; const to = kept[position + 1];
const alongCenter = [centerline[to.index][0] - centerline[from.index][0], centerline[to.index][1] - centerline[from.index][1]];
const alongOffset = [to.point[0] - from.point[0], to.point[1] - from.point[1]];
if (alongCenter[0] * alongOffset[0] + alongCenter[1] * alongOffset[1] >= 0) continue;
// Keep both termini: they are where the surface meets its junctions.
kept.splice(position + 1 === kept.length - 1 ? position : position + 1, 1);
removed = true;
break;
}
if (!removed) break;
}
return kept.map((item) => item.point);
}
function roadRing(line, width) { function roadRing(line, width) {
if (line.length < 2 || !Number.isFinite(width)) return null; if (line.length < 2 || !Number.isFinite(width)) return null;
const origin = line[0]; const origin = line[0];
@@ -1458,10 +1638,13 @@ function roadRing(line, width) {
const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy); const dx = next[0] - prior[0]; const dy = next[1] - prior[1]; const length = Math.hypot(dx, dy);
if (length < 0.01) return null; if (length < 0.01) return null;
const nx = -dy / length * half; const ny = dx / length * half; const nx = -dy / length * half; const ny = dx / length * half;
left.push(unproject([points[i][0] + nx, points[i][1] + ny], origin)); left.push([points[i][0] + nx, points[i][1] + ny]);
right.push(unproject([points[i][0] - nx, points[i][1] - ny], origin)); right.push([points[i][0] - nx, points[i][1] - ny]);
} }
const ring = [...left, ...right.reverse(), left[0]]; const leftEdge = removeOffsetFolds(left, points).map((point) => unproject(point, origin));
const rightEdge = removeOffsetFolds(right, points).map((point) => unproject(point, origin));
if (leftEdge.length < 2 || rightEdge.length < 2) return null;
const ring = [...leftEdge, ...rightEdge.reverse(), leftEdge[0]];
return ring.every((point) => point.every(Number.isFinite)) ? ring : null; return ring.every((point) => point.every(Number.isFinite)) ? ring : null;
} }

View File

@@ -19,23 +19,27 @@ function main() {
if (args.noCompile !== "true") compileArea(configPath); if (args.noCompile !== "true") compileArea(configPath);
const area = readAreaConfig(configPath, { repoRoot }); const area = readAreaConfig(configPath, { repoRoot });
const junctionReference = args.junctionReference ? readJunctionReference(path.resolve(args.junctionReference)) : null; const junctionReference = args.junctionReference ? readJunctionReference(path.resolve(args.junctionReference)) : null;
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
const debug = args.debug === "true";
const port = Number(args.port || 8787); const port = Number(args.port || 8787);
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535]."); if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("--port must be an integer in [1024, 65535].");
const server = http.createServer((request, response) => handle(request, response, area, configPath, junctionReference)); const server = http.createServer((request, response) => handle(request, response, area, configPath, junctionReference, debug));
server.on("error", (error) => { server.on("error", (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`); console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1; process.exitCode = 1;
}); });
server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/`)); server.listen(port, "127.0.0.1", () => console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? " (debug: 复杂路口候选已开启)" : ""}`));
} }
function handle(request, response, area, configPath, junctionReference) { function handle(request, response, area, configPath, junctionReference, debug = false) {
const url = new URL(request.url, "http://127.0.0.1"); const url = new URL(request.url, "http://127.0.0.1");
if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8"); if (request.method === "GET" && url.pathname === "/") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "index.html"), "text/html; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.js") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.js"), "text/javascript; charset=utf-8");
if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8"); if (request.method === "GET" && url.pathname === "/app.css") return sendFile(response, path.join(repoRoot, "scripts", "workbench", "app.css"), "text/css; charset=utf-8");
if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname); if (request.method === "GET" && url.pathname.startsWith("/vendor/")) return sendVendorFile(response, url.pathname);
if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference)); if (request.method === "GET" && url.pathname === "/api/state") return sendJson(response, 200, state(area, junctionReference, debug));
if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => { if (request.method === "POST" && url.pathname === "/api/traffic-signals") return readBody(request).then((body) => {
const document = validateDocument(body, fs.readFileSync(area.input, "utf8")); const document = validateDocument(body, fs.readFileSync(area.input, "utf8"));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document); writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
@@ -56,6 +60,13 @@ function handle(request, response, area, configPath, junctionReference) {
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides); writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides }); sendJson(response, 200, { ok: true, overrides });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message })); }).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/junction-clusters") return readBody(request).then((body) => {
if (!debug) throw new Error("该接口仅在 --debug 模式下可用。");
const added = addJunctionCluster(configPath, body, readCompiled(area));
compileFresh(configPath);
const refreshed = readAreaConfig(configPath, { repoRoot });
sendJson(response, 200, { ok: true, added, ...state(refreshed, junctionReference, debug) });
}).catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => { if (request.method === "POST" && url.pathname === "/api/compile") return Promise.resolve().then(() => {
compileFresh(configPath); compileFresh(configPath);
sendJson(response, 200, state(area, junctionReference)); sendJson(response, 200, state(area, junctionReference));
@@ -63,15 +74,71 @@ function handle(request, response, area, configPath, junctionReference) {
sendJson(response, 404, { error: "Not found" }); sendJson(response, 404, { error: "Not found" });
} }
function state(area, junctionReference = null) { function state(area, junctionReference = null, debug = false) {
const nativeDir = area.outputs.nativeRoadDir; const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson"); const osm2streetsRoadSurface = path.join(area.outputs.geojsonDir, "road_surface.geojson");
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals) const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8")) ? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, "utf8"))
: { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } }; : { schema: "native-traffic-signals/v1", provenance: "empty", assemblies: { type: "FeatureCollection", features: [] } };
const trafficRuntime = runtime(trafficSignals); const trafficRuntime = runtime(trafficSignals);
return { areaId: area.id, compiled: readCompiled(area), overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } }; const compiled = readCompiled(area);
return { areaId: area.id, debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null, compiled, overrides: loadOverrides(area.outputs.nativeRoadOverrides), trafficSignals, trafficRuntime, comparison: readJson(path.join(nativeDir, "comparison.json")), junctionReference, layers: { nativeRoadSurface: readLayer(path.join(nativeDir, "layers", "road_surface.geojson")), edgeLines: readLayer(path.join(nativeDir, "layers", "edge_lines.geojson")), nativeSidewalkSurface: readLayer(path.join(nativeDir, "layers", "sidewalk_surface.geojson")), nativeIntersectionSurface: readLayer(path.join(nativeDir, "layers", "intersection_surface.geojson")), laneCenterlines: readLayer(path.join(nativeDir, "layers", "lane_centerlines.geojson")), laneSeparators: readLayer(path.join(nativeDir, "layers", "lane_separators.geojson")), centerLines: readLayer(path.join(nativeDir, "layers", "center_lines.geojson")), directionArrows: readLayer(path.join(nativeDir, "layers", "direction_arrows.geojson")), turnArrows: readLayer(path.join(nativeDir, "layers", "turn_arrows.geojson")), crosswalks: readLayer(path.join(nativeDir, "layers", "crosswalks.geojson")), vehicleStopLines: readLayer(path.join(nativeDir, "layers", "vehicle_stop_lines.geojson")), connectors: readLayer(path.join(nativeDir, "layers", "connectors.geojson")), osm2streetsRoadSurface: fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null } };
} }
// The compiler reports candidates as advisory diagnostics. Lift them into their
// own payload with a stable index so the map can label them "#1, #2, ..." and
// the inspector can offer a ready-to-paste cluster配置.
// Append one detected cluster to the hand-authored area config. The candidate
// must still be present in the latest compile, so a stale browser tab cannot
// write a cluster that no longer exists. The edited config is validated by the
// real loader before it replaces the file: an invalid write would break every
// later command, and the file is git-tracked so a bad accept stays revertible.
function addJunctionCluster(configPath, body, compiled) {
const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error("请求缺少候选编号 index。");
const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const raw = readJson(configPath);
const templates = raw.nativeRoad?.junctionTemplates;
if (!templates) throw new Error("区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。");
const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join("、")} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
template: candidate.template,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String),
};
const next = { ...raw, nativeRoad: { ...raw.nativeRoad, junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] } } };
const staging = `${configPath}.candidate-${process.pid}.json`;
fs.writeFileSync(staging, `${JSON.stringify(next, null, 2)}\n`);
try {
readAreaConfig(staging, { repoRoot });
} catch (error) {
fs.unlinkSync(staging);
throw new Error(`写入后的配置无法通过校验,已放弃:${error.message}`);
}
fs.unlinkSync(staging);
writeJsonAtomic(configPath, next);
return cluster;
}
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error("无法生成唯一的 cluster id。");
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === "complex-junction-candidate" && item.suggestedCluster)
.sort((first, second) => second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount || first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters)
.map((item, index) => ({ index: index + 1, id: item.id, message: item.message, coordinate: item.geometry?.coordinates || null, ...item.suggestedCluster }));
}
function compileFresh(configPath) { function compileFresh(configPath) {
try { try {
return execFileSync(process.execPath, [path.join(repoRoot, "scripts", "compile-native-roads.js"), "--config", configPath], { return execFileSync(process.execPath, [path.join(repoRoot, "scripts", "compile-native-roads.js"), "--config", configPath], {

View File

@@ -77,6 +77,24 @@ assert.ok(geometry.intersectionSurface.features.every((feature) => feature.prope
assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode))); assert.ok(geometry.intersectionSurface.features.every((feature) => ["approach-envelope", "rounded-approach-envelope", "connector-convex-fallback"].includes(feature.properties.boundary_mode)));
assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1)); assert.ok(geometry.intersectionSurface.features.every((feature) => feature.properties.approach_area_m2 > 0 && feature.properties.surface_area_m2 > 0 && feature.properties.expansion_ratio >= 1));
for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback")); for (const feature of geometry.intersectionSurface.features.filter((item) => item.properties.boundary_mode === "connector-convex-fallback")) assert.ok(geometry.diagnostics.some((item) => item.subjectId === feature.properties.native_id && item.rule === "junction-connector-envelope-fallback"));
// Complex-junction candidate detection: two junction nodes joined by a short
// link are one physical intersection; the same pair pushed far apart is a
// corridor and must not be reported. Detection is advisory only, so the
// geometry must be identical either way.
const candidateOsm = (lonB) => `<osm><node id="1" lon="113.999" lat="30"/><node id="2" lon="114" lat="30"/><node id="3" lon="${lonB}" lat="30"/><node id="4" lon="114.01" lat="30"/><node id="5" lon="114" lat="30.001"/><node id="6" lon="${lonB}" lat="29.999"/><way id="70"><nd ref="1"/><nd ref="2"/><tag k="highway" v="residential"/></way><way id="71"><nd ref="2"/><nd ref="3"/><tag k="highway" v="residential"/></way><way id="72"><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="73"><nd ref="2"/><nd ref="5"/><tag k="highway" v="residential"/></way><way id="74"><nd ref="3"/><nd ref="6"/><tag k="highway" v="residential"/></way></osm>`;
const nearGeometry = compileGeometry(compileRoadModel(candidateOsm("114.0002"), empty));
const nearCandidates = nearGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate");
assert.equal(nearCandidates.length, 1);
assert.deepEqual(nearCandidates[0].suggestedCluster.nodeIds, ["2", "3"]);
assert.equal(nearCandidates[0].suggestedCluster.template, "complex-junction-v1");
assert.equal(nearCandidates[0].severity, "info");
assert.ok(nearCandidates[0].suggestedCluster.diameterMeters > 0 && nearCandidates[0].suggestedCluster.coreRadiusMeters >= 12);
// Same topology, nodes 2 and 3 now ~965 m apart: no cluster, and no template.
const farGeometry = compileGeometry(compileRoadModel(candidateOsm("114.01"), empty));
assert.equal(farGeometry.diagnostics.filter((item) => item.rule === "complex-junction-candidate").length, 0);
assert.ok(nearGeometry.intersectionSurface.features.every((feature) => feature.properties.template === null));
assert.ok(nearGeometry.roadSurface.features.every((feature) => !feature.properties.cluster_id));
const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`; const controlOsm = `<osm><node id="1" lon="114" lat="30"/><node id="2" lon="114.00080" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="zebra"/></node><node id="3" lon="114.001" lat="30"/><node id="4" lon="114.002" lat="30"><tag k="highway" v="crossing"/><tag k="crossing:markings" v="unmarked"/></node><node id="5" lon="114.0035" lat="30"><tag k="highway" v="crossing"/></node><node id="6" lon="114.004" lat="30"/><node id="7" lon="114.001" lat="30.001"/><way id="60"><nd ref="1"/><nd ref="2"/><nd ref="3"/><nd ref="4"/><tag k="highway" v="residential"/></way><way id="61"><nd ref="5"/><nd ref="6"/><tag k="highway" v="footway"/></way><way id="62"><nd ref="3"/><nd ref="7"/><tag k="highway" v="residential"/></way></osm>`;
const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty)); const controlGeometry = compileGeometry(compileRoadModel(controlOsm, empty));
assert.equal(controlGeometry.crosswalks.features.length, 6); assert.equal(controlGeometry.crosswalks.features.length, 6);

View File

@@ -10,6 +10,8 @@ import Style from "/vendor/ol/style/Style.js";
import Fill from "/vendor/ol/style/Fill.js"; import Fill from "/vendor/ol/style/Fill.js";
import Stroke from "/vendor/ol/style/Stroke.js"; import Stroke from "/vendor/ol/style/Stroke.js";
import CircleStyle from "/vendor/ol/style/Circle.js"; import CircleStyle from "/vendor/ol/style/Circle.js";
import Text from "/vendor/ol/style/Text.js";
import Polygon from "/vendor/ol/geom/Polygon.js";
import RegularShape from "/vendor/ol/style/RegularShape.js"; import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js"; import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js"; import { click } from "/vendor/ol/events/condition.js";
@@ -61,6 +63,14 @@ controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked
const signalsToggle = document.createElement("label"); const signalsToggle = document.createElement("label");
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施'; signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle); document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
// Only offered when the server was started with --debug; without it the state
// carries no candidates and an empty toggle would just be confusing.
const candidateAction = document.createElement("section");
document.querySelector(".inspector").insertBefore(candidateAction, document.querySelector(".inspector details"));
const candidatesToggle = document.createElement("label");
candidatesToggle.hidden = true;
candidatesToggle.innerHTML = '<input data-layer="junctionCandidates" type="checkbox" checked> 复杂路口候选debug';
signalsToggle.after(candidatesToggle);
const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" }; const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
let state; let state;
@@ -97,16 +107,21 @@ const layers = {
signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }), signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }),
osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }), osmDirection: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new RegularShape({ points: 3, radius: 9, rotation: feature.get("rotation"), fill: new Fill({ color: "#006e91" }), stroke: new Stroke({ color: "#fff", width: 1.5 }) }) }), zIndex: 11 }),
connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }), connectors: new VectorLayer({ source: source(), style: (feature) => effectiveConnectorEnabled(feature.getProperties()) ? new Style({ stroke: new Stroke({ color: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? "#d1226f" : "#ad3a76", width: roadIdFromLane(feature.get("from_lane_id")) === selectedRoad?.id ? 4 : 2, lineDash: [7, 5] }) }) : null }),
junctionCandidates: new VectorLayer({ source: source(), visible: true, zIndex: 25, style: (feature) => [
new Style({ fill: new Fill({ color: "rgba(219, 39, 119, .12)" }), stroke: new Stroke({ color: "#db2777", width: 2, lineDash: [8, 5] }) }),
new Style({ text: new Text({ text: `#${feature.get("index")} ${feature.get("nodeCount")}节点`, font: "bold 13px system-ui, sans-serif", fill: new Fill({ color: "#831843" }), stroke: new Stroke({ color: "#fff", width: 3 }), offsetY: -14 }) }),
] }),
diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }), diagnostics: new VectorLayer({ source: source(), style: (feature) => new Style({ image: new CircleStyle({ radius: 6, fill: new Fill({ color: feature.get("severity") === "error" ? "#bf3b2e" : "#d49318" }), stroke: new Stroke({ color: "#fff", width: 1 }) }) }) }),
selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }), selectedRoad: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#00a5cf", width: 8 }) }), zIndex: 10 }),
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }), selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
}; };
const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) }); const map = new Map({ target: "map", layers: [layers.gaodeReference, layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, layers.connectors, layers.junctionCandidates, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null }); const select = new Select({ condition: click, layers: (layer) => manualFromEndpoint ? layer === layers.osm : [layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.junctionCandidates, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
map.addInteraction(select); map.addInteraction(select);
select.on("select", ({ selected }) => { select.on("select", ({ selected }) => {
const feature = selected[0]; const feature = selected[0];
if (!feature) return; if (!feature) return;
if (feature.get("candidate_index")) return selectJunctionCandidate(feature);
if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature); if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature);
if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature));
const junction = junctionForFeature(feature); const junction = junctionForFeature(feature);
@@ -204,15 +219,108 @@ function updateSources() {
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]); layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue; const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal)); layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors, (feature) => !feature.properties?.cluster_internal));
candidatesToggle.hidden = !(state.debug?.junctionCandidates || []).length;
renderJunctionCandidates();
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 });
} }
// Draw one convex-ish hull per candidate cluster so the whole intersection is
// outlined, not just its centre, and label it with the same index the inspector
// and the console listing use.
function renderJunctionCandidates() {
const source = layers.junctionCandidates.getSource();
source.clear();
const list = state.debug?.junctionCandidates || [];
if (!list.length) return;
for (const candidate of list) {
const nodes = candidate.nodeIds.map((nodeId) => nodeCoordinate(nodeId)).filter(Boolean);
if (!nodes.length) continue;
const ring = candidateRing(nodes, Math.max(12, candidate.coreRadiusMeters * .6));
source.addFeature(new Feature({ geometry: new Polygon([ring]), candidate_index: candidate.index, index: candidate.index, nodeCount: candidate.nodeCount, candidate_id: candidate.id }));
}
}
function nodeCoordinate(nodeId) {
for (const road of state.compiled.model.roads) {
const at = road.sourceNodeIds.findIndex((item) => String(item) === String(nodeId));
if (at === 0) return road.centerline[0];
if (at === road.sourceNodeIds.length - 1) return road.centerline.at(-1);
}
return null;
}
// A rounded envelope around the member nodes: sample a circle of `padMeters`
// around each node and take the outer boundary by angle from the centroid.
function candidateRing(nodes, padMeters) {
const centre = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const metresPerLon = 111320 * Math.cos(centre[1] * Math.PI / 180);
const points = [];
for (let degrees = 0; degrees < 360; degrees += 12) {
const radians = degrees * Math.PI / 180;
let best = null;
for (const node of nodes) {
const point = [node[0] + Math.sin(radians) * padMeters / metresPerLon, node[1] + Math.cos(radians) * padMeters / 111320];
const reach = (point[0] - centre[0]) * metresPerLon * Math.sin(radians) + (point[1] - centre[1]) * 111320 * Math.cos(radians);
if (!best || reach > best.reach) best = { point, reach };
}
points.push(fromLonLat(best.point));
}
return [...points, points[0]];
}
function selectJunctionCandidate(feature) {
const candidate = (state.debug?.junctionCandidates || []).find((item) => item.index === feature.get("candidate_index"));
if (!candidate) return;
selectedRoad = null; selectedMovement = null; selectedJunction = null;
form.hidden = true; hint.hidden = false; selectedJunctionPanel.hidden = true;
hint.textContent = `复杂路口候选 #${candidate.index}${candidate.nodeCount} 个节点,直径 ${candidate.diameterMeters} 米。下方是可直接粘贴到 config 的片段。`;
evidence.textContent = JSON.stringify({
说明: "复制到 config/areas/<区域>.json 的 nativeRoad.junctionTemplates.clusters 数组,然后重新编译",
片段: {
id: `cluster-${candidate.nodeIds[0]}`,
template: candidate.template,
nodeIds: candidate.nodeIds,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
},
实测: { 节点数: candidate.nodeCount, 直径米: candidate.diameterMeters, 最长内部连接米: candidate.longestLinkMeters, 最宽进口米: candidate.widestApproachMeters },
提示: "coreRadiusMeters 是按节点跨度估的起点,配好后按实际效果调整;有高德参考几何时它会被校准值覆盖。",
}, null, 2);
renderCandidateAction(candidate);
message(`已选中复杂路口候选 #${candidate.index}`);
}
// The accept button lives beside the snippet so the manual path stays available
// if the write is refused; both describe the same cluster.
function renderCandidateAction(candidate) {
candidateAction.replaceChildren();
const button = document.createElement("button");
button.type = "button";
button.textContent = `把候选 #${candidate.index} 加入配置并重新编译`;
button.onclick = async () => {
button.disabled = true;
message(`正在把候选 #${candidate.index} 写入区域配置...`);
try {
const response = await fetch("/api/junction-clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: candidate.index }), cache: "no-store" });
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || `HTTP ${response.status}`);
state = result;
staged = [];
updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary();
candidateAction.replaceChildren();
hint.textContent = `已加入复杂路口 ${result.added.id}${result.added.nodeIds.length} 个节点),配置已更新并重新编译。`;
message(`已加入 ${result.added.id};如需撤销可 git checkout 区域配置`);
} catch (error) {
button.disabled = false;
message(`加入失败:${error.message}`);
}
};
candidateAction.append(button);
}
function laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; } 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 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)); }
function selectRoad(road, note, movement = null) { function selectRoad(road, note, movement = null) {
candidateAction.replaceChildren();
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement(); selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") })); layers.selectedRoad.getSource().clear(); if (road) layers.selectedRoad.getSource().addFeature(new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857") }));
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return; form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
@@ -223,6 +331,7 @@ function selectRoad(road, note, movement = null) {
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`)); renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
} }
function selectJunction(feature) { function selectJunction(feature) {
candidateAction.replaceChildren();
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false; selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean); const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean); const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);