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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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], {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user