feat: complete native traffic signal workflow
This commit is contained in:
@@ -13,6 +13,7 @@ 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";
|
||||
import { fromLonLat } from "/vendor/ol/proj.js";
|
||||
|
||||
const geojson = new GeoJSON();
|
||||
const areaLabel = document.querySelector("#area");
|
||||
@@ -57,7 +58,9 @@ const edgeLinesToggle = document.createElement("label");
|
||||
edgeLinesToggle.innerHTML = '<input data-layer="edgeLines" type="checkbox"> 道路外缘线';
|
||||
const controlsToggle = document.createElement("label");
|
||||
controlsToggle.innerHTML = '<input data-layer="controls" type="checkbox" checked> 斑马线与停止线';
|
||||
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle);
|
||||
const signalsToggle = document.createElement("label");
|
||||
signalsToggle.innerHTML = '<input data-layer="signals" type="checkbox" checked> 红绿灯设施';
|
||||
document.querySelector('[data-layer="lanes"]').closest("label").after(directionArrowsToggle, markingsToggle, centerLinesToggle, edgeLinesToggle, controlsToggle, signalsToggle);
|
||||
|
||||
let state;
|
||||
let selectedRoad = null;
|
||||
@@ -70,6 +73,13 @@ let scenePreview = false;
|
||||
let selectedCenterLineSegment = null;
|
||||
let selectedLaneSeparator = null;
|
||||
let selectedEdgeLine = null;
|
||||
let selectedSignal = null;
|
||||
const signalPanel = document.createElement("section");
|
||||
signalPanel.innerHTML = '<hr><h2>原生红绿灯</h2><button type="button" data-signal="generate">从 OSM 生成缺失信号灯</button><label>检查信号灯<select name="signal-picker"><option value="">选择设施</option></select></label><form hidden><output></output><label>灯杆经度<input name="lon" type="number" min="-180" max="180" step="0.000001"></label><label>灯杆纬度<input name="lat" type="number" min="-90" max="90" step="0.000001"></label><label>横杆方向(度)<input name="mastHeading" type="number" min="0" max="360" step="1"></label><label>横杆长度(米)<input name="mastReach" type="number" min="0.1" max="30" step="0.1"></label><label>灯面朝向(度)<input name="faceHeading" type="number" min="0" max="360" step="1"></label><label>相位组<input name="phase" type="number" min="0" max="1" step="1"></label><label><input name="enabled" type="checkbox"> 启用</label><button type="submit">保存信号灯</button><button type="button" data-signal="delete">删除信号灯</button></form>';
|
||||
document.querySelector(".inspector").insertBefore(signalPanel, document.querySelector(".inspector details"));
|
||||
const signalForm = signalPanel.querySelector("form");
|
||||
const signalOutput = signalForm.querySelector("output");
|
||||
const signalPicker = signalPanel.querySelector('[name="signal-picker"]');
|
||||
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 }) }) }),
|
||||
@@ -82,18 +92,20 @@ const layers = {
|
||||
markings: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
centerLines: new VectorLayer({ source: source(), style: centerLineStyle }),
|
||||
controls: new VectorLayer({ source: source(), style: markingStyle }),
|
||||
signals: new VectorLayer({ source: source(), style: signalAssemblyStyle, zIndex: 30 }),
|
||||
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.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, 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.centerLines, layers.edgeLines, 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)" }) }) });
|
||||
const map = new Map({ target: "map", layers: [layers.reference, layers.native, layers.edgeLines, layers.sidewalks, layers.osm, layers.lanes, layers.directionArrows, layers.markings, layers.centerLines, layers.controls, layers.signals, 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.centerLines, layers.edgeLines, layers.controls, layers.signals, layers.connectors, layers.native, layers.diagnostics].includes(layer), hitTolerance: 12, style: null });
|
||||
map.addInteraction(select);
|
||||
select.on("select", ({ selected }) => {
|
||||
const feature = selected[0];
|
||||
if (!feature) return;
|
||||
if (feature.get("signal_uid")) return selectSignal(poleFeatureForSignal(feature.get("signal_uid")) || feature);
|
||||
if (manualFromEndpoint) return chooseManualTarget(roadForFeature(feature));
|
||||
const junction = junctionForFeature(feature);
|
||||
if (junction) return selectJunction(junction);
|
||||
@@ -145,6 +157,7 @@ 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(feature) { const yellow = feature?.get("color") === "yellow"; return new Style({ fill: new Fill({ color: yellow ? "#f5be2a" : "#f5f6ee" }), stroke: new Stroke({ color: yellow ? "#d29d16" : "#d9dacf", width: 1 }) }); }
|
||||
function signalAssemblyStyle(feature) { const component = feature.get("signal_component"); if (component === "mast") return [new Style({ stroke: new Stroke({ color: "#fff", width: 9 }) }), new Style({ stroke: new Stroke({ color: "#007f99", width: 5 }) })]; if (component === "face") return [new Style({ stroke: new Stroke({ color: "#fff", width: 7 }) }), new Style({ stroke: new Stroke({ color: "#df2435", width: 3 }) })]; if (component === "head") { const heading = Number(feature.get("face_heading_deg")) || 0; return new Style({ image: new RegularShape({ points: 3, radius: 8, rotation: heading * Math.PI / 180, fill: new Fill({ color: "#df2435" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); } return new Style({ image: new RegularShape({ points: 4, radius: 6, angle: Math.PI / 4, fill: new Fill({ color: "#263630" }), stroke: new Stroke({ color: "#fff", width: 2 }) }) }); }
|
||||
function centerLineStyle(feature) { const white = feature.get("color") === "white"; const color = white ? "#faf9ee" : "#f5be2a"; return new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: feature.get("pattern") === "solid" ? color : white ? "#aeb0aa" : "#d29d16", width: feature.get("pattern") === "solid" ? .25 : .8 }) }); }
|
||||
function nativeSurfaceStyle(feature) {
|
||||
// Split road features meet at OSM junction nodes. Their per-feature outlines
|
||||
@@ -183,6 +196,7 @@ function updateSources() {
|
||||
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators), ...readFeatures(state.layers.turnArrows)]);
|
||||
layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines));
|
||||
layers.controls.getSource().clear(); layers.controls.getSource().addFeatures([...readFeatures(state.layers.crosswalks), ...readFeatures(state.layers.vehicleStopLines)]);
|
||||
const signalFeatures = readFeatures(state.trafficSignals?.assemblies || { type: "FeatureCollection", features: [] }); const armFeatures = []; const faceFeatures = []; const headFeatures = []; for (const signal of state.trafficRuntime?.signals || []) { const arm = signal.pose?.arm; const head = signal.pose?.head; if (!arm || !head) continue; const properties = { signal_uid: signal.id }; const headPoint = fromLonLat([head.longitude, head.latitude]); const radians = Number(head.faceHeadingDegrees) * Math.PI / 180; const faceEnd = [headPoint[0] + Math.sin(radians) * 2.5, headPoint[1] + Math.cos(radians) * 2.5]; armFeatures.push(new Feature({ geometry: new LineString([fromLonLat([arm.from.longitude, arm.from.latitude]), fromLonLat([arm.to.longitude, arm.to.latitude])]), signal_component: "mast", ...properties })); faceFeatures.push(new Feature({ geometry: new LineString([headPoint, faceEnd]), signal_component: "face", ...properties })); headFeatures.push(new Feature({ geometry: new Point(faceEnd), signal_component: "head", face_heading_deg: head.faceHeadingDegrees, ...properties })); } layers.signals.getSource().clear(); layers.signals.getSource().addFeatures([...armFeatures, ...faceFeatures, ...signalFeatures, ...headFeatures]); const pickerValue = signalPicker.value; signalPicker.replaceChildren(new Option("选择设施", "")); signalFeatures.forEach((feature) => signalPicker.add(new Option(feature.get("display_id") || feature.get("signal_uid"), feature.get("signal_uid")))); signalPicker.value = pickerValue;
|
||||
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 });
|
||||
@@ -209,6 +223,23 @@ function selectJunction(feature) {
|
||||
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 selectSignal(feature) {
|
||||
selectedSignal = feature.get("signal_uid"); const p = feature.getProperties(); signalPicker.value = selectedSignal;
|
||||
const [x, y] = feature.getGeometry().getCoordinates();
|
||||
map.getView().fit([x - 25, y - 25, x + 25, y + 25], { padding: [80, 80, 80, 360], maxZoom: 22, duration: 250 });
|
||||
signalForm.hidden = false; signalOutput.textContent = `${p.display_id || p.signal_uid}(${p.signal_uid})`;
|
||||
signalForm.lon.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[0];
|
||||
signalForm.lat.value = feature.getGeometry().clone().transform("EPSG:3857", "EPSG:4326").getCoordinates()[1];
|
||||
signalForm.mastHeading.value = p.mast_heading_deg; signalForm.mastReach.value = p.mast_reach_m; signalForm.faceHeading.value = p.face_heading_deg; signalForm.phase.value = p.phase_group; signalForm.enabled.checked = p.enabled;
|
||||
evidence.textContent = JSON.stringify({ 信号灯: p.signal_uid, 控制节点: p.control_id, 路口方向: p.approach_id, 来源: state.trafficSignals.provenance }, null, 2); message("已选中原生红绿灯");
|
||||
}
|
||||
function poleFeatureForSignal(signalUid) { return layers.signals.getSource().getFeatures().find((item) => item.get("signal_uid") === signalUid && !item.get("signal_component")); }
|
||||
async function saveSignals(document) { const response = await fetch("/api/traffic-signals", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(document) }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); renderSummary(); }
|
||||
function signalDocumentWithChange(change) { const document = structuredClone(state.trafficSignals); document.assemblies.features = change(document.assemblies.features); return document; }
|
||||
signalForm.onsubmit = async (event) => { event.preventDefault(); try { await saveSignals(signalDocumentWithChange((features) => features.map((feature) => feature.properties.signal_uid !== selectedSignal ? feature : { ...feature, geometry: { type: "Point", coordinates: [Number(signalForm.lon.value), Number(signalForm.lat.value)] }, properties: { ...feature.properties, mast_heading_deg: Number(signalForm.mastHeading.value), mast_reach_m: Number(signalForm.mastReach.value), face_heading_deg: Number(signalForm.faceHeading.value), phase_group: Number(signalForm.phase.value), enabled: signalForm.enabled.checked } }))); message("红绿灯已保存"); } catch (error) { message(error.message); } };
|
||||
signalPanel.querySelector('[data-signal="delete"]').onclick = async () => { try { await saveSignals(signalDocumentWithChange((features) => features.filter((feature) => feature.properties.signal_uid !== selectedSignal))); signalForm.hidden = true; selectedSignal = null; message("红绿灯已删除"); } catch (error) { message(error.message); } };
|
||||
signalPanel.querySelector('[data-signal="generate"]').onclick = async () => { try { const response = await fetch("/api/traffic-signals/generate", { method: "POST" }); const result = await response.json(); if (!result.ok) throw new Error(result.error); state.trafficSignals = result.trafficSignals; state.trafficRuntime = result.runtime; updateSources(); message("已补充 OSM 信号灯"); } catch (error) { message(error.message); } };
|
||||
signalPicker.onchange = () => { const feature = poleFeatureForSignal(signalPicker.value); if (feature) selectSignal(feature); };
|
||||
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) {
|
||||
@@ -247,6 +278,7 @@ function renderSummary() {
|
||||
["路口转向箭头", comparison.nativeTurnArrowFeatures],
|
||||
["斑马线条带", comparison.nativeCrosswalkFeatures],
|
||||
["停止线", comparison.nativeVehicleStopLineFeatures],
|
||||
["红绿灯设施", state.trafficSignals?.assemblies?.features?.length || 0],
|
||||
["行驶动作", comparison.nativeMovementCount],
|
||||
["已绘制路径", comparison.nativePublishedMovementCount],
|
||||
["可手工复核", comparison.unconnectedEndsWithManualCandidates],
|
||||
@@ -273,7 +305,7 @@ centerLineStyleInput.onchange = stageSelectedCenterLineStyle;
|
||||
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); };
|
||||
for (const input of document.querySelectorAll("[data-layer]")) input.onchange = () => { const visible = input.checked; layers[input.dataset.layer].setVisible(visible); if (input.dataset.layer === "osm") layers.osmDirection.setVisible(visible); };
|
||||
scenePreviewToggle.onchange = () => {
|
||||
scenePreview = scenePreviewToggle.checked;
|
||||
for (const input of document.querySelectorAll("[data-layer]")) {
|
||||
@@ -285,9 +317,11 @@ scenePreviewToggle.onchange = () => {
|
||||
layers.sidewalks.setVisible(document.querySelector('[data-layer="sidewalks"]').checked);
|
||||
layers.centerLines.setVisible(document.querySelector('[data-layer="centerLines"]').checked);
|
||||
layers.controls.setVisible(document.querySelector('[data-layer="controls"]').checked);
|
||||
const signalsVisible = document.querySelector('[data-layer="signals"]').checked;
|
||||
layers.signals.setVisible(signalsVisible);
|
||||
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));
|
||||
fetch("/api/state").then((response) => response.json()).then((value) => { state = value; updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary(); areaLabel.textContent = state.areaId; const signalUid = new URLSearchParams(location.search).get("signal"); const signal = signalUid && poleFeatureForSignal(signalUid); if (signal) selectSignal(signal); message(`已加载 ${state.compiled.model.roads.length} 条方向道路`); }).catch((error) => message(error.message));
|
||||
|
||||
Reference in New Issue
Block a user