260 lines
32 KiB
JavaScript
260 lines
32 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 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 selectedMovementPanel = document.querySelector("#selected-movement");
|
||
const movementDetail = document.querySelector("#movement-detail");
|
||
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 diagnosticFilters = document.querySelector("#diagnostic-filters");
|
||
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");
|
||
const dirtyState = document.querySelector("#dirty-state");
|
||
const scenePreviewToggle = document.querySelector("#scene-preview");
|
||
const selectedJunctionPanel = document.querySelector("#selected-junction");
|
||
const junctionDetail = document.querySelector("#junction-detail");
|
||
const directionArrowsToggle = document.createElement("label");
|
||
directionArrowsToggle.innerHTML = '<input data-layer="directionArrows" type="checkbox" checked> 道路方向箭头';
|
||
const markingsToggle = document.createElement("label");
|
||
markingsToggle.innerHTML = '<input data-layer="markings" type="checkbox" checked> 车道分隔线与路口转向箭头';
|
||
const controlsToggle = document.createElement("label");
|
||
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
|
||
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, controlsToggle);
|
||
|
||
let state;
|
||
let selectedRoad = null;
|
||
let selectedMovement = null;
|
||
let selectedJunction = null;
|
||
let staged = [];
|
||
let manualFromEndpoint = null;
|
||
let diagnosticFilter = "all";
|
||
let scenePreview = false;
|
||
const source = () => new VectorSource();
|
||
const layers = {
|
||
reference: new VectorLayer({ source: source(), visible: false, 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: nativeSurfaceStyle }),
|
||
sidewalks: new VectorLayer({ source: source(), style: sidewalkSurfaceStyle }),
|
||
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 }),
|
||
directionArrows: new VectorLayer({ source: source(), style: markingStyle }),
|
||
markings: new VectorLayer({ source: source(), style: markingStyle }),
|
||
controls: new VectorLayer({ source: source(), style: markingStyle }),
|
||
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 }),
|
||
selectedMovement: new VectorLayer({ source: source(), style: new Style({ stroke: new Stroke({ color: "#f0b323", width: 6 }) }), zIndex: 12 }),
|
||
};
|
||
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.controls, layers.connectors, layers.diagnostics, layers.selectedRoad, layers.osmDirection, layers.selectedMovement], 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.directionArrows, layers.markings, layers.controls, 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));
|
||
const junction = junctionForFeature(feature);
|
||
if (junction) return selectJunction(junction);
|
||
const provenance = feature.get("provenance");
|
||
if (provenance?.startsWith("native-road-")) {
|
||
const road = roadForFeature(feature);
|
||
const directionArrow = provenance === "native-road-direction-arrow/v1";
|
||
const turnArrow = provenance === "native-road-turn-arrow/v1";
|
||
const crosswalk = provenance === "native-road-crosswalk/v1";
|
||
const stopLine = provenance === "native-road-stop-line/v1";
|
||
const markingType = crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
|
||
selectRoad(road);
|
||
evidence.textContent = JSON.stringify({
|
||
标线类型: markingType,
|
||
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
|
||
OSM道路: feature.get("osm_way_ids"),
|
||
原生道路: feature.get("road_id"),
|
||
方向: feature.get("direction"),
|
||
车道: feature.get("lane_id") || feature.get("lane_index") || `${feature.get("left_lane_index")} 与 ${feature.get("right_lane_index")} 之间`,
|
||
转向: turnArrow ? feature.get("maneuver") : null,
|
||
放置方法: feature.get("placement_method") || null,
|
||
道路内距离米: feature.get("distance_along_lane_meters") || null,
|
||
路口前距离米: feature.get("placement_distance_meters") || null,
|
||
来源: provenance,
|
||
}, null, 2);
|
||
return message(`已选中${markingType}`);
|
||
}
|
||
const movement = state.compiled.movements?.find((item) => item.id === feature.get("movement_id")) || null;
|
||
selectRoad(roadForFeature(feature), undefined, movement);
|
||
});
|
||
map.on("pointermove", (event) => { map.getTargetElement().style.cursor = map.hasFeatureAtPixel(event.pixel, { hitTolerance: 8 }) ? "pointer" : ""; });
|
||
|
||
function message(text) { status.textContent = text; }
|
||
function updateDirtyState() {
|
||
const count = staged.length;
|
||
dirtyState.textContent = count ? `未保存修改 ${count} 项` : "所有修改已保存";
|
||
dirtyState.classList.toggle("dirty", count > 0);
|
||
saveButton.disabled = count === 0;
|
||
}
|
||
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 markingStyle() { return new Style({ fill: new Fill({ color: "#f5f6ee" }), stroke: new Stroke({ color: "#d9dacf", width: 1 }) }); }
|
||
function nativeSurfaceStyle(feature) {
|
||
// Split road features meet at OSM junction nodes. Their per-feature outlines
|
||
// are editing aids, not physical seams, so scene mode must render fills only.
|
||
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
|
||
return 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 }) });
|
||
}
|
||
function sidewalkSurfaceStyle() {
|
||
return scenePreview
|
||
? new Style({ fill: new Fill({ color: "#b7b9ad" }) })
|
||
: new Style({ fill: new Fill({ color: "rgba(218, 191, 137, .6)" }), stroke: new Stroke({ color: "#9b7c40", width: 1 }) });
|
||
}
|
||
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 refreshSelectedMovement() { const movementSource = layers.selectedMovement.getSource(); movementSource.clear(); if (!selectedMovement?.geometryPublished || !effectiveConnectorEnabled({ connection_id: selectedMovement.connectionId, fromLaneId: selectedMovement.fromLaneId, toLaneId: selectedMovement.toLaneId })) return; const feature = layers.connectors.getSource().getFeatures().find((candidate) => candidate.get("movement_id") === selectedMovement.id); if (feature) movementSource.addFeature(new Feature({ geometry: feature.getGeometry().clone() })); }
|
||
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 junctionForFeature(feature) { const id = feature.get("native_id"); return id?.startsWith("junction:") ? layers.native.getSource().getFeatures().find((item) => item.get("native_id") === id) : 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.sidewalks.getSource().clear(); layers.sidewalks.getSource().addFeatures(readFeatures(state.layers.nativeSidewalkSurface));
|
||
layers.osm.getSource().clear(); layers.osm.getSource().addFeatures(rawRoadFeatures());
|
||
layers.lanes.getSource().clear(); layers.lanes.getSource().addFeatures(readFeatures(state.layers.laneCenterlines));
|
||
layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows));
|
||
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]);
|
||
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
|
||
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 laneId(connection, side) { return connection[side === "from" ? "fromLaneId" : "toLaneId"] || connection[side === "from" ? "from_lane_id" : "to_lane_id"]; }
|
||
function effectiveLaneEnabled(connector) { const id = `车道连接:${laneId(connector, "from")}->${laneId(connector, "to")}`; 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, movement = null) {
|
||
selectedRoad = road; selectedMovement = movement; selectedJunction = null; selectedJunctionPanel.hidden = true; layers.osm.changed(); layers.lanes.changed(); layers.connectors.changed(); refreshOsmDirection(); refreshSelectedMovement();
|
||
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.sourceNodeIds, 参数来源: road.provenance, 已应用修改: road.appliedOverrideIds, 原始标签: road.tags }, null, 2);
|
||
laneConvention.textContent = road.laneCount === 1 ? "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。本方向只有一条车道。" : "蓝色箭头在 OSM 原始中心线上,表示当前方向;“沿 OSM 方向”即节点顺序。车道按行驶方向从左向右编号。";
|
||
renderDirectionSwitch(road); renderMovementSummary(road); renderSelectedMovement(); renderConnections(road); message(note || (selectedMovement ? `已选中行驶动作:${turnLabel(selectedMovement.turn)}` : `已选中:${roadLabel(road)}`));
|
||
}
|
||
function selectJunction(feature) {
|
||
selectedJunction = feature; selectedRoad = null; selectedMovement = null; form.hidden = true; hint.hidden = true; selectedJunctionPanel.hidden = false;
|
||
const properties = feature.getProperties(); const roadIds = String(properties.source_road_ids || "").split(",").filter(Boolean);
|
||
const roads = roadIds.map((id) => state.compiled.model.roads.find((road) => road.id === id)).filter(Boolean);
|
||
junctionDetail.textContent = JSON.stringify({ OSM节点: properties.osm_node_id, 类型: properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 边界策略: properties.boundary_mode, 基础截面面积平方米: properties.approach_area_m2, 最终路口面积平方米: properties.surface_area_m2, 外缘扩张倍率: properties.expansion_ratio, 路口退让距离米: properties.cutback_m, 行驶动作数: properties.movement_count, 已绘制连接数: properties.connector_count }, null, 2);
|
||
message(`已选中路口:OSM 节点 ${properties.osm_node_id}`);
|
||
}
|
||
function turnLabel(turn) { return { left: "左转", through: "直行", right: "右转", uturn: "掉头" }[turn] || turn; }
|
||
function renderSelectedMovement() { selectedMovementPanel.hidden = !selectedMovement; if (!selectedMovement) return; const targetRoad = state.compiled.model.roads.find((road) => road.id === selectedMovement.toRoadId); const geometry = selectedMovement.geometryStatus === "connector" ? "已绘制路径" : selectedMovement.geometryStatus === "continuous" ? "节点连续" : "路径过长未绘制"; movementDetail.textContent = `${turnLabel(selectedMovement.turn)}:${lanePositionLabel(selectedRoad, laneIndex(selectedMovement.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(selectedMovement.toLaneId))}\n目标:${roadLabel(targetRoad)}(${osmDirectionLabel(targetRoad)})\n来源端点:${selectedRoad.sourceNodeIds.at(-1)};目标端点:${targetRoad.sourceNodeIds[0]}\n路口节点:${selectedMovement.nodeId}\n状态:${geometry}\n来源:${selectedMovement.provenance}`; }
|
||
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 movements = state.compiled.movements?.filter((movement) => movement.fromRoadId === road.id && effectiveConnectorEnabled({ connection_id: movement.connectionId, fromLaneId: movement.fromLaneId, toLaneId: movement.toLaneId })) || []; const turns = movements.reduce((result, item) => { result[item.turn] = (result[item.turn] || 0) + 1; return result; }, {}); const labels = { left: "左转", through: "直行", right: "右转", uturn: "掉头" }; const published = movements.filter((movement) => movement.geometryPublished).length; movementSummary.textContent = movements.length ? `已识别 ${movements.length} 个行驶动作,${published} 条已绘制路径:${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.compiled.movements?.filter((movement) => movement.connectionId === connection.id) || []; for (const row of rows) { const targetRoad = state.compiled.model.roads.find((road) => road.id === row.toRoadId); 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, "有未保存修改:转向路径已即时更新"); }; const geometryNote = row.geometryStatus === "continuous" ? ",节点连续" : row.geometryStatus === "deferred-too-long" ? ",路径过长未绘制" : ""; label.append(input, ` ${lanePositionLabel(selectedRoad, laneIndex(row.fromLaneId))} → ${lanePositionLabel(targetRoad, laneIndex(row.toLaneId))}(${osmDirectionLabel(targetRoad)}${geometryNote})`); 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(); updateDirtyState(); }
|
||
function stageLaneConnection(connector, enabled) { const fromLaneId = laneId(connector, "from"); const toLaneId = laneId(connector, "to"); const id = `车道连接:${fromLaneId}->${toLaneId}`; staged = staged.filter((item) => item.id !== id); staged.push({ id, kind: "lane-connection", fromLaneId, toLaneId, enabled }); layers.connectors.changed(); updateDirtyState(); }
|
||
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 }); const junction = layers.native.getSource().getFeatures().find((candidate) => candidate.get("native_id") === item.subjectId); if (junction) return selectJunction(junction); selectRoad(state.compiled.model.roads.find((road) => road.id === item.subjectId), `已定位:${item.message}`); }
|
||
function diagnosticLabel(item) { const road = state.compiled.model.roads.find((candidate) => candidate.id === item.subjectId); if (item.rule !== "unconnected-interior-road-end" || !road) return item.message; const candidateCount = item.manualCandidates?.length || 0; return `${roadLabel(road)}(${osmDirectionLabel(road)},节点 ${item.sourceIds[0]}):内部端点未连接${candidateCount ? `,附近有 ${candidateCount} 个可手工连接候选` : ""}`; }
|
||
function renderDiagnostics() { const all = state.compiled.diagnostics.filter((diagnostic) => diagnostic.rule !== "ordinary-junction-surface"); const counts = { all: all.length, candidates: all.filter((item) => item.manualCandidates?.length).length, other: all.filter((item) => !item.manualCandidates?.length).length }; for (const button of diagnosticFilters.querySelectorAll("button")) { const filter = button.dataset.diagnosticFilter; button.classList.toggle("active", filter === diagnosticFilter); button.textContent = `${filter === "all" ? "全部" : filter === "candidates" ? "可连接" : "其他"}(${counts[filter]})`; } const visible = all.filter((item) => diagnosticFilter === "all" || diagnosticFilter === "candidates" ? Boolean(item.manualCandidates?.length) : !item.manualCandidates?.length).sort((a, b) => (b.manualCandidates?.length || 0) - (a.manualCandidates?.length || 0)); diagnostics.innerHTML = ""; for (const item of visible) { const button = document.createElement("button"); button.textContent = diagnosticLabel(item); button.onclick = () => focusDiagnostic(item); diagnostics.append(button); } }
|
||
function renderSummary() {
|
||
const comparison = state.comparison;
|
||
const rows = [
|
||
["方向道路", comparison.nativeRoadCount],
|
||
["路缘与步行带", comparison.nativeSidewalkSurfaceFeatures],
|
||
["路口面", comparison.nativeJunctionSurfaceFeatures],
|
||
["普通构面路口", comparison.nativeApproachEnvelopeJunctions],
|
||
["兜底构面路口", comparison.nativeFallbackJunctions],
|
||
["最大外缘扩张", comparison.nativeMaxJunctionExpansionRatio],
|
||
["道路方向箭头", comparison.nativeDirectionArrowFeatures],
|
||
["路口转向箭头", comparison.nativeTurnArrowFeatures],
|
||
["斑马线条带", comparison.nativeCrosswalkFeatures],
|
||
["停止线", comparison.nativeVehicleStopLineFeatures],
|
||
["行驶动作", comparison.nativeMovementCount],
|
||
["已绘制路径", comparison.nativePublishedMovementCount],
|
||
["可手工复核", comparison.unconnectedEndsWithManualCandidates],
|
||
["内部断头", comparison.unconnectedInteriorRoadEnds],
|
||
["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);
|
||
}
|
||
}
|
||
function stageRoadOverride(road, changes) { const id = `道路:${road.id}`; const existing = staged.find((item) => item.id === id) || state.overrides.overrides.find((item) => item.id === id); staged = staged.filter((item) => item.id !== id); staged.push({ ...existing, id, kind: "road", roadId: road.id, ...changes }); }
|
||
form.onsubmit = (event) => { event.preventDefault(); const roadChanges = { widthMeters: Number(widthInput.value), laneCount: Number(lanesInput.value), sidewalkLeft: leftInput.checked, sidewalkRight: rightInput.checked }; stageRoadOverride(selectedRoad, roadChanges); const opposite = state.compiled.model.roads.find((road) => road.id !== selectedRoad.id && road.segmentId === selectedRoad.segmentId); if (opposite) stageRoadOverride(opposite, { sidewalkLeft: rightInput.checked, sidewalkRight: leftInput.checked }); updateDirtyState(); message(opposite ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
|
||
async function saveStagedChanges() { if (!staged.length) return true; 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) { message(result.error); return false; } state.overrides = result.overrides; staged = []; updateDirtyState(); return true; }
|
||
saveButton.onclick = async () => { if (await saveStagedChanges()) message("已保存,点击“保存并重新生成”写入几何"); };
|
||
compileButton.onclick = async () => { if (!await saveStagedChanges()) return; message("正在保存修改并重新生成..."); const response = await fetch("/api/compile", { method: "POST" }); state = await response.json(); staged = []; updateDirtyState(); 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); };
|
||
scenePreviewToggle.onchange = () => {
|
||
scenePreview = scenePreviewToggle.checked;
|
||
for (const input of document.querySelectorAll("[data-layer]")) {
|
||
const layer = input.dataset.layer;
|
||
if (["osm", "lanes", "reference"].includes(layer)) layers[layer].setVisible(!scenePreview && input.checked);
|
||
}
|
||
layers.osmDirection.setVisible(!scenePreview && document.querySelector('[data-layer="osm"]').checked);
|
||
layers.connectors.setVisible(!scenePreview && document.querySelector('[data-layer="lanes"]').checked);
|
||
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
|
||
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
|
||
layers.diagnostics.setVisible(!scenePreview);
|
||
layers.native.changed(); layers.sidewalks.changed();
|
||
message(scenePreview ? "场景效果预览:当前编译面" : "编辑图层预览");
|
||
};
|
||
for (const button of diagnosticFilters.querySelectorAll("button")) button.onclick = () => { diagnosticFilter = button.dataset.diagnosticFilter; renderDiagnostics(); };
|
||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|