104 lines
14 KiB
JavaScript
104 lines
14 KiB
JavaScript
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));
|