Files
osmWorkflow/scripts/workbench/app.js

127 lines
20 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.
import Map from "/vendor/ol/Map.js";
import View from "/vendor/ol/View.js";
import VectorLayer from "/vendor/ol/layer/Vector.js";
import VectorSource from "/vendor/ol/source/Vector.js";
import GeoJSON from "/vendor/ol/format/GeoJSON.js";
import Feature from "/vendor/ol/Feature.js";
import LineString from "/vendor/ol/geom/LineString.js";
import Point from "/vendor/ol/geom/Point.js";
import Style from "/vendor/ol/style/Style.js";
import Fill from "/vendor/ol/style/Fill.js";
import Stroke from "/vendor/ol/style/Stroke.js";
import CircleStyle from "/vendor/ol/style/Circle.js";
import RegularShape from "/vendor/ol/style/RegularShape.js";
import Select from "/vendor/ol/interaction/Select.js";
import { click } from "/vendor/ol/events/condition.js";
const geojson = new GeoJSON();
const areaLabel = document.querySelector("#area");
const status = document.querySelector("#status");
const form = document.querySelector("#road-form");
const hint = document.querySelector("#hint");
const roadName = document.querySelector("#road-name");
const movementSummary = document.querySelector("#movement-summary");
const laneConvention = document.querySelector("#lane-convention");
const directionSwitch = document.querySelector("#direction-switch");
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 diagnostics = document.querySelector("#diagnostics");
const summary = document.querySelector("#summary");
const connectionsBox = document.querySelector("#connections");
const addConnectionButton = document.querySelector("#add-connection");
const saveButton = document.querySelector("#save");
const compileButton = document.querySelector("#compile");
let state;
let selectedRoad = null;
let staged = [];
let manualFromEndpoint = null;
const source = () => new VectorSource();
const layers = {
reference: new VectorLayer({ source: source(), style: new Style({ fill: new Fill({ color: "rgba(123, 140, 148, .28)" }), stroke: new Stroke({ color: "#8999a0", width: 1 }) }) }),
native: new VectorLayer({ source: source(), style: (feature) => feature.get("native_id")?.startsWith("junction:") ? new Style({ fill: new Fill({ color: "rgba(12, 116, 91, .38)" }), stroke: new Stroke({ color: "#0e785f", width: 1.5 }) }) : new Style({ fill: new Fill({ color: "rgba(40, 105, 86, .35)" }), stroke: new Stroke({ color: "#296956", width: 1 }) }) }),
osm: new VectorLayer({ source: source(), style: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#263630", width: feature.get("road_id") === selectedRoad?.id ? 5 : 2 }) }) }),
lanes: new VectorLayer({ source: source(), style: laneStyle }),
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 }),
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 }),
};
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.osm, layers.lanes, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection], 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.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 8, style: new Style({ stroke: new Stroke({ color: "#005e89", width: 5 }), fill: new Fill({ color: "rgba(0, 94, 137, .18)" }) }) });
map.addInteraction(select);
select.on("select", ({ selected }) => { const feature = selected[0]; if (!feature) return; if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature)); selectRoad(roadForFeature(feature)); });
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
function message(text) { status.textContent = text; }
function roadLabel(road) { return road.tags.name || `${road.highway}OSM ${road.osmWayIds.join(", ")}`; }
function osmDirectionLabel(road) { return road?.direction === "forward" ? "沿 OSM 方向" : "逆 OSM 方向"; }
function roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
function laneIndex(laneId) { return Number(String(laneId).split(":").at(-1)); }
function lanePositionLabel(road, index) { return road?.laneCount === 1 ? "唯一车道" : `左起第 ${index} 车道`; }
function laneStyle(feature) { const selected = feature.get("road_id") === selectedRoad?.id; return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : 1.3, lineDash: [5, 4] }) }); }
function directionArrowFeature(geometry) { const middle = geometry.getCoordinateAt(.5); const before = geometry.getCoordinateAt(.48); const after = geometry.getCoordinateAt(.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < .01) return null; return new Feature({ geometry: new Point(middle), rotation: Math.atan2(after[1] - before[1], after[0] - before[0]) }); }
function refreshOsmDirection() { const directionSource = layers.osmDirection.getSource(); directionSource.clear(); if (!selectedRoad) return; const centerline = new LineString(selectedRoad.centerline).transform("EPSG:4326", "EPSG:3857"); const arrow = directionArrowFeature(centerline); if (arrow) directionSource.addFeature(arrow); }
function roadForFeature(feature) {
const properties = feature.getProperties();
const roadId = properties.road_id || properties.subjectId || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
}
function endpointFor(road, side) { return state.compiled.model.endpoints.find((endpoint) => endpoint.roadId === road?.id && endpoint.side === side) || null; }
function endpointsCompatible(from, to) { if (!from || !to || from.roadId === to.roadId || from.side !== "end" || to.side !== "start") return false; if (from.nodeId === to.nodeId) return true; const dx = (from.coordinate[0] - to.coordinate[0]) * 111320 * Math.cos(from.coordinate[1] * Math.PI / 180); const dy = (from.coordinate[1] - to.coordinate[1]) * 111320; return Math.hypot(dx, dy) <= 35; }
function readFeatures(collection) { return geojson.readFeatures(collection || { type: "FeatureCollection", features: [] }, { dataProjection: "EPSG:4326", featureProjection: "EPSG:3857" }); }
function rawRoadFeatures() { return state.compiled.model.roads.map((road) => new Feature({ geometry: new LineString(road.centerline).transform("EPSG:4326", "EPSG:3857"), road_id: road.id })); }
function updateSources() {
layers.reference.getSource().clear(); layers.reference.getSource().addFeatures(readFeatures(state.layers.osm2streetsRoadSurface));
layers.native.getSource().clear(); layers.native.getSource().addFeatures([...readFeatures(state.layers.nativeRoadSurface), ...readFeatures(state.layers.nativeIntersectionSurface)]);
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines));
layers.connectors.getSource().clear(); layers.connectors.getSource().addFeatures(readFeatures(state.layers.connectors));
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 });
}
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 selectRoad(road, note) {
selectedRoad = road; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection();
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;
roadName.textContent = `${roadLabel(road)}${osmDirectionLabel(road)}`;
widthInput.value = road.widthMeters; lanesInput.value = road.laneCount; leftInput.checked = road.sidewalkLeft; rightInput.checked = road.sidewalkRight;
evidence.textContent = JSON.stringify({ OSM道路: road.osmWayIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
laneConvention.textContent = road.laneCount === 1 ? "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。本方向只有一条车道。" : "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。车道按行驶方向从左向右编号。";
renderDirectionSwitch(road); renderMovementSummary(road); renderConnections(road); message(note || `已选中:${roadLabel(road)}`);
}
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 = "单向道路"; return; }
for (const item of alternatives) { const button = document.createElement("button"); button.type = "button"; button.textContent = osmDirectionLabel(item); button.disabled = item.id === road.id; button.onclick = () => selectRoad(item); directionSwitch.append(button); }
}
function renderMovementSummary(road) { const connectors = state.layers.connectors?.features.filter((feature) => roadIdFromLane(feature.properties.from_lane_id) === road.id && effectiveConnectorEnabled(feature.properties)) || []; const turns = connectors.reduce((result, item) => { result[item.properties.turn] = (result[item.properties.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; movementSummary.textContent = connectors.length ? `已生成 ${connectors.length} 条路径:${Object.entries(turns).map(([key, value]) => `${labels[key] || key} ${value}`).join("")}` : "当前方向没有已生成的转向路径"; }
function renderConnections(road) {
connectionsBox.innerHTML = ""; const endpoint = state.compiled.model.endpoints.find((item) => item.roadId === road.id && item.side === "end"); addConnectionButton.hidden = !endpoint; const rows = state.compiled.model.connections.filter((connection) => connection.fromEndpointId === endpoint?.id);
if (!rows.length) connectionsBox.textContent = "当前方向到达终点后没有已识别的驶出道路。";
for (const connection of rows) { const target = state.compiled.model.roads.find((item) => item.id === state.compiled.model.endpoints.find((endpointItem) => endpointItem.id === connection.toEndpointId)?.roadId); if (!target) continue; const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveConnectionEnabled(connection); input.onchange = () => { stageConnection(connection, input.checked); selectRoad(road, "有未保存修改:转向路径已即时更新"); }; label.append(input, ` ${turnName(road, target)}${roadLabel(target)}${osmDirectionLabel(target)}`); connectionsBox.append(label); renderLaneControls(connection); }
renderManualCandidates(endpoint, road);
}
function renderManualCandidates(endpoint, road) { const candidates = state.compiled.diagnostics.find((item) => item.endpointId === endpoint?.id)?.manualCandidates || []; if (!candidates.length) return; const title = document.createElement("p"); title.textContent = "附近可手工连接的驶出方向"; connectionsBox.append(title); for (const candidate of candidates) { const target = state.compiled.model.roads.find((item) => item.id === candidate.roadId); if (!target) continue; const button = document.createElement("button"); button.type = "button"; button.textContent = `${roadLabel(target)}${candidate.distanceMeters} 米)`; button.onclick = () => { stageConnection({ id: `connection:${endpoint.id}:${candidate.toEndpointId}`, fromEndpointId: endpoint.id, toEndpointId: candidate.toEndpointId }, true); selectRoad(road, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }; connectionsBox.append(button); } }
function renderLaneControls(connection) { const rows = state.layers.connectors?.features.filter((feature) => feature.properties.connection_id === connection.id).map((feature) => feature.properties) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === roadIdFromLane(row.to_lane_id)); const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.checked = effectiveLaneEnabled(row); input.onchange = () => { stageLaneConnection(row, input.checked); selectRoad(selectedRoad, "有未保存修改:转向路径已即时更新"); }; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.from_lane_id))}${lanePositionLabel(targetRoad, laneIndex(row.to_lane_id))}${osmDirectionLabel(targetRoad)}`); connectionsBox.append(label); } }
function turnName(from, to) { const heading = (a, b) => Math.atan2(b[0] - a[0], b[1] - a[1]) * 180 / Math.PI; const delta = ((heading(to.centerline[0], to.centerline[1]) - heading(from.centerline.at(-2), from.centerline.at(-1)) + 540) % 360) - 180; return Math.abs(delta) >= 150 ? "掉头" : Math.abs(delta) <= 30 ? "直行" : delta > 0 ? "右转" : "左转"; }
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 }); layers.connectors.changed(); }
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 }); layers.connectors.changed(); }
function chooseManualTarget(targetRoad) { const toEndpoint = endpointFor(targetRoad, "start"); if (!endpointsCompatible(manualFromEndpoint, toEndpoint)) return message("该方向的起点与当前道路终点不兼容:必须是同一路口,或相距不超过 35 米。"); const connection = { id: `connection:${manualFromEndpoint.id}:${toEndpoint.id}`, fromEndpointId: manualFromEndpoint.id, toEndpointId: toEndpoint.id }; manualFromEndpoint = null; stageConnection(connection, true); selectRoad(selectedRoad, "有未保存修改:手工连接已暂存;保存并重新生成后会出现转向路径"); }
addConnectionButton.onclick = () => { const endpoint = endpointFor(selectedRoad, "end"); if (!endpoint) return; manualFromEndpoint = endpoint; select.getFeatures().clear(); message("请在地图上点击目标方向的 OSM 中心线;仅同一路口或 35 米内的驶出方向可连接。"); };
function focusDiagnostic(item) { const feature = layers.diagnostics.getSource().getFeatures().find((candidate) => candidate.get("id") === item.id); if (feature) map.getView().fit(feature.getGeometry().getExtent(), { padding: [80, 80, 80, 360], maxZoom: 18, duration: 250 }); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
function renderDiagnostics() { diagnostics.innerHTML = ""; for (const item of state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface")) { const button = document.createElement("button"); button.textContent = item.message; button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
function renderSummary() { const comparison = state.comparison; const rows = [["方向道路", comparison.nativeRoadCount], ["路口面", comparison.nativeJunctionSurfaceFeatures], ["转向路径", comparison.nativeConnectorFeatures], ["内部断头", comparison.unconnectedInteriorRoadEnds], ["可手工复核", comparison.unconnectedEndsWithManualCandidates], ["osm2streets 参考", comparison.osm2streetsAvailable ? comparison.osm2streetsRoadSurfaceFeatures : "无"]]; summary.innerHTML = ""; for (const [label, value] of rows) { const term = document.createElement("dt"); const detail = document.createElement("dd"); term.textContent = label; detail.textContent = value; summary.append(term, detail); } }
form.onsubmit = (event) => { event.preventDefault(); const id = `道路:${selectedRoad.id}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "road", roadId: selectedRoad.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 result = await response.json(); if (!result.ok) return message(result.error); state.overrides = result.overrides; staged = []; message("已保存,点击“保存并重新生成”写入几何"); };
compileButton.onclick = async () => { message("正在重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateSources(); renderDiagnostics(); renderSummary(); selectRoad(selectedRoad ? state.compiled.model.roads.find((road) => road.id === selectedRoad.id) : null); message("已重新生成"); };
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { layers[input.dataset.layer].setVisible(input.checked); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(input.checked); };
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));