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));