Files
osmWorkflow/scripts/workbench/app.js

104 lines
14 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 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 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 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 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 = [];
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: (feature) => new Style({ stroke: new Stroke({ color: feature.get("road_id") === selectedRoad?.id ? "#006e91" : "#f5f6ee", width: feature.get("road_id") === selectedRoad?.id ? 3 : 1.3, lineDash: [5, 4] }) }) }),
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 }) }) }) }),
};
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.osm, layers.lanes, layers.connectors, layers.diagnostics], view: new View({ center: [0, 0], zoom: 2 }) });
const select = new Select({ condition: click, layers: [layers.osm, layers.lanes, layers.connectors, layers.native], 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) 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 roadIdFromLane(laneId) { return typeof laneId === "string" ? laneId.slice(5, laneId.lastIndexOf(":")) : ""; }
function roadForFeature(feature) {
const properties = feature.getProperties();
const roadId = properties.road_id || roadIdFromLane(properties.from_lane_id) || properties.directional_road_ids?.split(",")[0];
return state.compiled.model.roads.find((road) => road.id === roadId) || null;
}
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) {
selectedRoad = road; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed();
form.hidden = !road; hint.hidden = Boolean(road); if (!road) return;
roadName.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.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
renderDirectionSwitch(road); renderMovementSummary(road); renderConnections(road); message(`已选中:${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 = item.direction === "forward" ? "沿 OSM 节点顺序" : "逆 OSM 节点顺序"; 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 = "当前方向到达终点后没有候选驶出道路。"; return; }
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)}`); connectionsBox.append(label); renderLaneControls(connection); }
}
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 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, `${row.from_lane_id.split(":").at(-1)} 车道 → 第 ${row.to_lane_id.split(":").at(-1)} 车道`); 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 }); 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 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 = () => selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId)); diagnostics.append(button); } }
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(); 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); };
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateSources(); renderDiagnostics(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));