Files
osmWorkflow/scripts/workbench/app.js

172 lines
17 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");
const addConnectionButton = document.querySelector("#add-connection");
const movementSummary = document.querySelector("#movement-summary");
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 layerLine(feature, stroke, width, dashed) { const points = feature.geometry?.coordinates; if (!points?.length) return; context.save(); context.setLineDash(dashed ? [5 * devicePixelRatio, 4 * devicePixelRatio] : []); context.beginPath(); points.forEach((point, index) => { const point2d = coord(point); index ? context.lineTo(...point2d) : context.moveTo(...point2d); }); context.strokeStyle = stroke; context.lineWidth = width * devicePixelRatio; context.stroke(); context.restore(); }
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 feature of layers.laneCenterlines?.features || []) {
const active = feature.properties.road_id === selected?.id;
layerLine(feature, active ? "#006e91" : "#f5f6ee", active ? 2.4 : 1.2, true);
}
for (const feature of layers.connectors?.features || []) {
if (!effectiveConnectorEnabled(feature.properties)) continue;
const active = feature.properties.from_lane_id.startsWith(`lane:${selected?.id}:`);
layerLine(feature, active ? "#d1226f" : "#ad3a76", active ? 3 : 1.8, true);
}
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);
renderMovementSummary(road);
renderDirectionSwitch(road);
renderConnections(road); draw();
}
function renderMovementSummary(road) {
const connectors = (state.layers.connectors?.features || []).filter((feature) => feature.properties.from_lane_id.startsWith(`lane:${road.id}:`) && effectiveConnectorEnabled(feature.properties));
const byTurn = connectors.reduce((result, feature) => { result[feature.properties.turn] = (result[feature.properties.turn] || 0) + 1; return result; }, {});
const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" };
movementSummary.textContent = connectors.length ? `当前方向已生成 ${connectors.length} 条路径:${Object.entries(byTurn).map(([turn, count]) => `${labels[turn] || turn} ${count}`).join("")}` : "当前方向没有已生成的转向路径";
}
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");
addConnectionButton.hidden = !endpoint;
addConnectionButton.onclick = () => renderAdditionalConnections(road, endpoint);
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 = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); renderConnections(road); draw(); };
label.append(input, ` ${turnName(road, target)}${roadLabel(target)}`); box.append(label);
renderLaneConnectionControls(box, connection);
}
}
function renderLaneConnectionControls(box, connection) {
const connectors = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id) || [];
const rows = connectors.map((feature) => ({ from_lane_id: feature.properties.from_lane_id, to_lane_id: feature.properties.to_lane_id, enabled: true }));
const sourceRoad = connection.fromEndpointId.slice("endpoint:".length).replace(/:end$/, "");
const targetRoad = connection.toEndpointId.slice("endpoint:".length).replace(/:start$/, "");
for (const override of state.overrides.overrides.filter((item) => item.kind === "lane-connection" && item.fromLaneId.startsWith(`lane:${sourceRoad}:`) && item.toLaneId.startsWith(`lane:${targetRoad}:`))) {
if (!rows.some((row) => row.from_lane_id === override.fromLaneId && row.to_lane_id === override.toLaneId)) rows.push({ from_lane_id: override.fromLaneId, to_lane_id: override.toLaneId, enabled: override.enabled });
}
for (const row of rows) appendLaneConnectionControl(box, row);
}
function appendLaneConnectionControl(box, row) { const fromIndex = row.from_lane_id.split(":").at(-1); const toIndex = row.to_lane_id.split(":").at(-1); const input = document.createElement("input"); const label = document.createElement("label"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); if (selected) { renderConnections(selected); renderMovementSummary(selected); draw(); } }; label.append(input, `${fromIndex} 车道 → 第 ${toIndex} 车道`); box.append(label); }
function renderAdditionalConnections(road, endpoint) {
const box = document.querySelector("#connections");
const existing = new Set(state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint.id).map((connection) => connection.toEndpointId));
const candidates = state.compiled.model.endpoints.filter((item) => item.side === "start" && item.roadId !== road.id && !existing.has(item.id) && endpointDistanceMeters(endpoint, item) <= 35);
box.innerHTML = "";
if (!candidates.length) { box.textContent = "附近 35 米内没有可新增的驶出道路。请检查 OSM 路口节点,或选择另一条道路。"; return; }
const intro = document.createElement("p"); intro.textContent = "选择要新增允许驶入的道路:"; box.append(intro);
for (const targetEndpoint of candidates) {
const target = state.compiled.model.roads.find((item) => item.id === targetEndpoint.roadId);
const button = document.createElement("button"); button.type = "button"; button.textContent = `${turnName(road, target)}${roadLabel(target)}(相距 ${endpointDistanceMeters(endpoint, targetEndpoint).toFixed(1)} 米)`;
button.onclick = () => { stageConnection({ id: `manual:${endpoint.id}:${targetEndpoint.id}`, fromEndpointId: endpoint.id, toEndpointId: targetEndpoint.id }, true); renderConnections(road); };
box.append(button);
}
}
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 endpointDistanceMeters(a, b) { const dx = (a.coordinate[0] - b.coordinate[0]) * 111320 * Math.cos(a.coordinate[1] * Math.PI / 180); const dy = (a.coordinate[1] - b.coordinate[1]) * 111320; return Math.hypot(dx, dy); }
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 stageLaneConnection(connector, enabled) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId: connector.from_lane_id, toLaneId: connector.to_lane_id, enabled }); message("有未保存修改"); }
function effectiveLaneEnabled(connector) { const id = `车道连接:${connector.from_lane_id}->${connector.to_lane_id}`; 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 effectiveConnectorEnabled(connector) { const connection = state.compiled.model.connections.find((item) => item.id === connector.connection_id); return effectiveLaneEnabled(connector) && (!connection || effectiveConnectionEnabled(connection)); }
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;