feat: add native road compiler workbench
This commit is contained in:
1
scripts/workbench/app.css
Normal file
1
scripts/workbench/app.css
Normal file
@@ -0,0 +1 @@
|
||||
*{box-sizing:border-box}body{margin:0;background:#eef1ef;color:#202523;font:14px system-ui,sans-serif}header{height:50px;display:flex;gap:12px;align-items:center;padding:0 16px;background:#183a32;color:#fff}header span{color:#c9d8d2}button{border:1px solid #82988f;background:#fff;color:#1d392f;padding:7px 10px;border-radius:3px;cursor:pointer}header button:first-of-type{margin-left:auto}main{display:grid;grid-template-columns:260px minmax(0,1fr) 320px;height:calc(100vh - 50px)}aside{overflow:auto;background:#fff;padding:16px}.issues{border-right:1px solid #d5dfda}.inspector{border-left:1px solid #d5dfda}.map{position:relative;background:#d7e2de;min-height:400px}canvas{width:100%;height:100%;display:block}.legend{position:absolute;bottom:12px;left:12px;background:#fff;padding:8px;box-shadow:0 1px 4px #0003}.legend i{display:inline-block;width:18px;height:7px;margin:0 4px -1px 10px}.reference{background:#a5b0b5}.native{background:#296654}.line{height:3px!important;background:#263630}.junction{width:10px!important;height:10px!important;background:#0e7860;border-radius:50%}.warning{width:10px!important;height:10px!important;background:#d49318;border-radius:50%}h1{font-size:16px;margin:0 0 8px}h2{font-size:14px;margin:12px 0 8px}.muted,output,pre{color:#52615b}.issues ul{list-style:none;padding:0;margin:0}.issues button{width:100%;text-align:left;margin:4px 0;background:#fff7e5;border-color:#e7c67b;color:#693c00}.issues button.error{background:#fff0ee;border-color:#e3a49b;color:#8d261a}label{display:block;margin:10px 0}input[type=number]{display:block;width:100%;padding:7px;border:1px solid #aab8b2;border-radius:2px}output,pre{display:block;white-space:pre-wrap;overflow-wrap:anywhere}form button{margin-top:8px;background:#286956;color:white;border:0}hr{border:0;border-top:1px solid #dde4e1;margin:16px 0}details{margin-top:16px}summary{cursor:pointer;font-weight:600}@media(max-width:900px){main{grid-template-columns:minmax(0,1fr)}.issues{display:none}.inspector{position:absolute;right:0;bottom:0;width:min(360px,100%);max-height:55vh;border-top:1px solid #d5dfda}}
|
||||
116
scripts/workbench/app.js
Normal file
116
scripts/workbench/app.js
Normal file
@@ -0,0 +1,116 @@
|
||||
"use strict";
|
||||
|
||||
const canvas = document.querySelector("#map");
|
||||
const context = canvas.getContext("2d");
|
||||
const status = document.querySelector("#status");
|
||||
const form = document.querySelector("#road-form");
|
||||
const areaLabel = document.querySelector("#area");
|
||||
const widthInput = document.querySelector("#width");
|
||||
const lanesInput = document.querySelector("#lanes");
|
||||
const leftInput = document.querySelector("#left");
|
||||
const rightInput = document.querySelector("#right");
|
||||
const evidence = document.querySelector("#evidence");
|
||||
const saveButton = document.querySelector("#save");
|
||||
const compileButton = document.querySelector("#compile");
|
||||
const diagnostics = document.querySelector("#diagnostics");
|
||||
const directionSwitch = document.querySelector("#direction-switch");
|
||||
let state;
|
||||
let selected = null;
|
||||
let staged = [];
|
||||
|
||||
function message(text) { status.textContent = text; }
|
||||
function roadLabel(road) { return road.tags.name || `${road.highway}(OSM ${road.osmWayIds.join(", ")})`; }
|
||||
function coord(point) { const b = state.bounds; return [(point[0] - b.minX) / (b.maxX - b.minX) * canvas.width, canvas.height - (point[1] - b.minY) / (b.maxY - b.minY) * canvas.height]; }
|
||||
function setBounds() { const points = state.compiled.model.roads.flatMap((road) => road.centerline); const xs = points.map((point) => point[0]); const ys = points.map((point) => point[1]); const pad = Math.max((Math.max(...xs) - Math.min(...xs)) * 0.06, 0.0001); state.bounds = { minX: Math.min(...xs) - pad, maxX: Math.max(...xs) + pad, minY: Math.min(...ys) - pad, maxY: Math.max(...ys) + pad }; }
|
||||
function resize() { canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; draw(); }
|
||||
function polygon(feature, fill) { const ring = feature.geometry?.coordinates?.[0]; if (!ring) return; context.beginPath(); ring.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.fillStyle = fill; context.fill(); }
|
||||
function draw() {
|
||||
if (!state) return;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const layers = state.layers || {};
|
||||
for (const feature of layers.osm2streetsRoadSurface?.features || []) polygon(feature, "#9ba8ae55");
|
||||
for (const feature of layers.nativeRoadSurface?.features || []) polygon(feature, "#28695666");
|
||||
for (const feature of layers.nativeIntersectionSurface?.features || []) polygon(feature, "#0e786066");
|
||||
for (const road of state.compiled.model.roads) {
|
||||
context.beginPath(); road.centerline.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); });
|
||||
context.strokeStyle = road.id === selected?.id ? "#006e91" : "#263630";
|
||||
context.lineWidth = (road.id === selected?.id ? 4 : 2) * devicePixelRatio;
|
||||
context.stroke();
|
||||
}
|
||||
if (selected) drawDirectionArrow(selected);
|
||||
for (const diagnostic of state.compiled.diagnostics) {
|
||||
if (!diagnostic.geometry) continue;
|
||||
const point = coord(diagnostic.geometry.coordinates); const isJunction = diagnostic.rule === "ordinary-junction-surface"; context.fillStyle = isJunction ? "#0e7860" : diagnostic.severity === "error" ? "#bf3b2e" : "#d49318"; context.beginPath(); context.arc(...point, isJunction ? 4 * devicePixelRatio : 5 * devicePixelRatio, 0, Math.PI * 2); context.fill();
|
||||
}
|
||||
if (state.focusedDiagnostic?.geometry) { const point = coord(state.focusedDiagnostic.geometry.coordinates); context.strokeStyle = "#006e91"; context.lineWidth = 3 * devicePixelRatio; context.beginPath(); context.arc(...point, 11 * devicePixelRatio, 0, Math.PI * 2); context.stroke(); }
|
||||
}
|
||||
|
||||
function drawDirectionArrow(road) {
|
||||
const middle = Math.max(1, Math.floor(road.centerline.length / 2));
|
||||
const a = coord(road.centerline[middle - 1]); const b = coord(road.centerline[middle]);
|
||||
const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); const size = 10 * devicePixelRatio;
|
||||
context.save(); context.translate(b[0], b[1]); context.rotate(angle); context.fillStyle = "#006e91";
|
||||
context.beginPath(); context.moveTo(size, 0); context.lineTo(-size * 0.8, -size * 0.6); context.lineTo(-size * 0.8, size * 0.6); context.closePath(); context.fill(); context.restore();
|
||||
}
|
||||
|
||||
function select(road) {
|
||||
selected = road; form.hidden = !road; document.querySelector("#hint").hidden = Boolean(road);
|
||||
if (!road) return;
|
||||
document.querySelector("#road-name").textContent = `${roadLabel(road)}(${road.direction === "forward" ? "沿 OSM 方向行驶" : "逆 OSM 方向行驶"})`;
|
||||
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
|
||||
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 名称: road.tags.name || "未标注", 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||||
renderDirectionSwitch(road);
|
||||
renderConnections(road); draw();
|
||||
}
|
||||
|
||||
function renderDirectionSwitch(road) {
|
||||
directionSwitch.innerHTML = "";
|
||||
const alternatives = state.compiled.model.roads.filter((item) => item.osmWayIds.join(",") === road.osmWayIds.join(","));
|
||||
if (alternatives.length < 2) { directionSwitch.textContent = "单向道路:沿 OSM 节点顺序行驶"; return; }
|
||||
const note = document.createElement("label"); note.textContent = "编辑方向(地图蓝色箭头表示当前方向)"; directionSwitch.append(note);
|
||||
for (const item of alternatives) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button"; button.textContent = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序";
|
||||
button.disabled = item.id === road.id; button.onclick = () => select(item); directionSwitch.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function renderConnections(road) {
|
||||
const box = document.querySelector("#connections"); box.innerHTML = "";
|
||||
const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end");
|
||||
const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
|
||||
if (!rows.length) { box.textContent = "当前行驶方向到达道路终点后,没有可编辑的驶出道路。"; return; }
|
||||
const intro = document.createElement("p"); intro.textContent = "到达终点路口后,允许驶入:"; box.append(intro);
|
||||
const seen = new Set();
|
||||
for (const connection of rows) {
|
||||
const targetEndpoint = state.compiled.model.endpoints.find((item) => item.id === connection.toEndpointId);
|
||||
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint?.roadId);
|
||||
if (!target || seen.has(target.id)) continue;
|
||||
seen.add(target.id);
|
||||
const input = document.createElement("input"); const label = document.createElement("label");
|
||||
input.type = "checkbox"; input.checked = connection.enabled; input.onchange = () => stageConnection(connection, input.checked);
|
||||
label.append(input, ` ${turnName(road, target)}:${roadLabel(target)}`); box.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
function turnName(from, to) {
|
||||
const a = heading(from.centerline.at(-2), from.centerline.at(-1));
|
||||
const b = heading(to.centerline[0], to.centerline[1]);
|
||||
const delta = ((b - a + 540) % 360) - 180;
|
||||
if (Math.abs(delta) >= 150) return "掉头";
|
||||
if (Math.abs(delta) <= 30) return "直行";
|
||||
return delta > 0 ? "右转" : "左转";
|
||||
}
|
||||
function heading(a, b) { return Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; }
|
||||
function stageConnection(connection, enabled) { const id = `连接:${connection.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "junction-connection", fromEndpointId: connection.fromEndpointId, toEndpointId: connection.toEndpointId, enabled }); message("有未保存修改"); }
|
||||
function pointToSegmentDistance(point, a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lengthSquared = dx * dx + dy * dy; const t = lengthSquared ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / lengthSquared)) : 0; return Math.hypot(point[0] - (a[0] + t * dx), point[1] - (a[1] + t * dy)); }
|
||||
function pickRoad(point) { let best = null; let distance = Infinity; for (const road of state.compiled.model.roads) for (let index = 1; index < road.centerline.length; index += 1) { const candidate = pointToSegmentDistance(point, coord(road.centerline[index - 1]), coord(road.centerline[index])); if (candidate < distance) { distance = candidate; best = road; } } return distance <= 18 * devicePixelRatio ? best : null; }
|
||||
canvas.onclick = (event) => { const rect = canvas.getBoundingClientRect(); select(pickRoad([(event.clientX - rect.left) * devicePixelRatio, (event.clientY - rect.top) * devicePixelRatio])); };
|
||||
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selected.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selected.id, widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }); message("有未保存修改"); };
|
||||
saveButton.onclick = async () => { const existing = state.overrides.overrides.filter((item) => !staged.some((change) => change.id === item.id)); const response = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ schema: "native-road-overrides/v1", overrides: [...existing, ...staged] }) }); const data = await response.json(); if (!data.ok) return message(data.error); state.overrides = data.overrides; staged = []; message("已保存,点击“保存并重新生成”生效"); };
|
||||
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; setup(); message("已按保存的修改重新生成"); };
|
||||
function focusDiagnostic(diagnostic) { state.focusedDiagnostic = diagnostic; const road = state.compiled.model.roads.find((item) => item.id === diagnostic.subjectId); if (road) { select(road); message(diagnostic.message); } else { draw(); message(diagnostic.message); } }
|
||||
function showInIssueList(diagnostic) { return diagnostic.severity === "error" || diagnostic.rule !== "ordinary-junction-surface"; }
|
||||
function setup() { areaLabel.textContent = state.areaId; diagnostics.innerHTML = ""; const issues = state.compiled.diagnostics.filter(showInIssueList); if (!issues.length) diagnostics.innerHTML = "<li>没有需要人工检查的问题。</li>"; for (const diagnostic of issues) { const item = document.createElement("li"); const button = document.createElement("button"); button.className = diagnostic.severity === "error" ? "error" : ""; button.textContent = diagnostic.message; button.onclick = () => focusDiagnostic(diagnostic); item.append(button); diagnostics.append(item); } setBounds(); resize(); select(null); }
|
||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; setup(); message(`${state.compiled.model.roads.length} 条方向道路,${state.compiled.diagnostics.length} 个待检查项`); }).catch((error) => message(error.message));
|
||||
window.onresize = resize;
|
||||
4
scripts/workbench/index.html
Normal file
4
scripts/workbench/index.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>道路编译工作台</title><link rel="stylesheet" href="/app.css"></head>
|
||||
<body><header><strong>道路编译工作台</strong><span id="area"></span><span id="status"></span><button id="save">保存修改</button><button id="compile">保存并重新生成</button></header>
|
||||
<main><aside class="issues"><h1>待检查问题</h1><p class="muted">点击问题可定位到道路或路口。</p><ul id="diagnostics"></ul></aside><section class="map"><canvas id="map"></canvas><div class="legend"><i class="reference"></i> osm2streets 参考面 <i class="native"></i> 自研道路面 <i class="line"></i> 道路中心线 <i class="junction"></i> 已识别路口 <i class="warning"></i> 待检查点</div></section><aside class="inspector"><h1>当前道路设置</h1><p id="hint">在地图中点击道路,查看和调整参数。</p><form id="road-form" hidden><label>道路</label><output id="road-name"></output><div id="direction-switch"></div><label>本方向道路宽度(米)<input id="width" type="number" min="1" step="0.1"></label><label>本方向车道数<input id="lanes" type="number" min="1" step="1"></label><label><input id="left" type="checkbox"> 左侧有人行道</label><label><input id="right" type="checkbox"> 右侧有人行道</label><button type="submit">暂存本道路修改</button></form><hr><h2>路口连接</h2><div id="connections">请选择一条道路。</div><details><summary>技术详情与来源</summary><pre id="evidence">无</pre></details></aside></main><script src="/app.js"></script></body></html>
|
||||
Reference in New Issue
Block a user