Files
osmWorkflow/scripts/workbench/app.js

117 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;