Files
road-compiler/workbench/client/app.js

460 lines
54 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 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 Text from "/vendor/ol/style/Text.js";
import Polygon from "/vendor/ol/geom/Polygon.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");
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 centerLineForm = document.querySelector("#center-line-form");
const centerLineSegment = document.querySelector("#center-line-segment");
const markingStyleHeading = document.querySelector("#marking-style-heading");
const centerLineStyleInput = document.querySelector("#center-line-style");
const doubleYellowOption = document.createElement("option");
doubleYellowOption.value = "double-yellow-solid"; doubleYellowOption.textContent = "双黄实线"; centerLineStyleInput.append(doubleYellowOption);
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 centerLinesToggle = document.createElement("label");
centerLinesToggle.innerHTML = '<input data-layer="centerLines" type="checkbox" checked> 道路中心线';
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> 斑马线与停止线';
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);
// Only offered when the server was started with --debug; without it the state
// carries no candidates and an empty toggle would just be confusing.
const candidateAction = document.createElement("section");
document.querySelector(".inspector").insertBefore(candidateAction, document.querySelector(".inspector details"));
const candidatesToggle = document.createElement("label");
candidatesToggle.hidden = true;
candidatesToggle.innerHTML = '<input data-layer="junctionCandidates" type="checkbox" checked> 复杂路口候选debug';
signalsToggle.after(candidatesToggle);
const gaodeReferenceColors = { 1: "#2563eb", 2: "#0f766e", 3: "#7c3aed", 4: "#ea580c", 5: "#64748b" };
let state;
let selectedRoad = null;
let selectedMovement = null;
let selectedJunction = null;
let staged = [];
let manualFromEndpoint = null;
let diagnosticFilter = "all";
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 }) }) }),
gaodeReference: new VectorLayer({ source: source(), visible: true, zIndex: 1, style: (feature) => { const color = gaodeReferenceColors[feature.get("type")] || "#475569"; return new Style({ fill: new Fill({ color: `${color}26` }), stroke: new Stroke({ color, width: 1.5 }) }); } }),
native: new VectorLayer({ source: source(), style: nativeSurfaceStyle }),
edgeLines: new VectorLayer({ source: source(), visible: false, style: markingStyle }),
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 }),
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 }),
junctionCandidates: new VectorLayer({ source: source(), visible: true, zIndex: 25, style: (feature) => [
new Style({ fill: new Fill({ color: "rgba(219, 39, 119, .12)" }), stroke: new Stroke({ color: "#db2777", width: 2, lineDash: [8, 5] }) }),
new Style({ text: new Text({ text: `#${feature.get("index")} ${feature.get("nodeCount")}节点`, font: "bold 13px system-ui, sans-serif", fill: new Fill({ color: "#831843" }), stroke: new Stroke({ color: "#fff", width: 3 }), offsetY: -14 }) }),
] }),
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.gaodeReference, 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.junctionCandidates, 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.junctionCandidates, 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("candidate_index")) return selectJunctionCandidate(feature);
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);
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 centerLine = provenance === "native-road-center-line/v1";
const edgeLine = provenance === "native-road-edge-line/v1";
const crosswalk = provenance === "native-road-crosswalk/v1";
const stopLine = provenance === "native-road-stop-line/v1";
const markingType = centerLine ? "道路中心线" : edgeLine ? "道路外缘线" : crosswalk ? "斑马线" : stopLine ? "停止线" : directionArrow ? "道路方向箭头" : turnArrow ? "路口转向箭头" : "车道分隔线";
selectRoad(road);
if (centerLine) selectCenterLine(feature); else if (edgeLine) selectEdgeLine(feature); else if (provenance === "native-road-lane-separator/v1") selectLaneSeparator(feature); else clearCenterLineSelection();
evidence.textContent = JSON.stringify({
标线类型: markingType,
人行横道节点: crosswalk || stopLine ? feature.get("crossing_node_id") : null,
OSM道路: feature.get("osm_way_ids"),
原生道路: feature.get("road_id"),
道路段: centerLine ? feature.get("segment_id") : null,
方向: 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,
样式: centerLine || edgeLine || provenance === "native-road-lane-separator/v1" ? feature.get("effective_style") : 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 roadId = feature.get("road_id"); const selected = Boolean(roadId && selectedRoad && roadId === selectedRoad.id); const composite = feature.get("cluster_preview"); return new Style({ stroke: new Stroke({ color: selected ? "#006e91" : "#f5f6ee", width: selected ? 3 : composite ? 1.6 : 1.3, lineDash: composite ? [7, 5] : [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
// are editing aids, not physical seams, so scene mode must render fills only.
if (scenePreview) return new Style({ fill: new Fill({ color: "#3f4b50" }) });
if (feature.get("cluster_id") && feature.get("complex_part")) return new Style({ fill: new Fill({ color: "#6f948a" }) });
if (feature.get("kind") === "cluster") return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .5)" }), stroke: new Stroke({ color: "#075e4f", width: 4, lineDash: [10, 5] }) });
if (feature.get("template")) return new Style({ fill: new Fill({ color: "rgba(20, 132, 112, .46)" }), stroke: new Stroke({ color: "#087c67", width: 3, lineDash: [7, 4] }) });
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:") || id?.startsWith("junction-cluster:") ? 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, predicate = null) { const source = collection || { type: "FeatureCollection", features: [] }; const filtered = predicate ? { ...source, features: (source.features || []).filter(predicate) } : source; return geojson.readFeatures(filtered, { 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.gaodeReference.getSource().clear(); layers.gaodeReference.getSource().addFeatures(readFeatures(state.junctionReference?.converted));
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, (feature) => !feature.properties?.cluster_internal && !feature.properties?.cluster_preview_hidden));
layers.edgeLines.getSource().clear(); layers.edgeLines.getSource().addFeatures(readFeatures(state.layers.edgeLines));
layers.directionArrows.getSource().clear(); layers.directionArrows.getSource().addFeatures(readFeatures(state.layers.directionArrows, (feature) => !feature.properties?.cluster_preview_hidden));
layers.markings.getSource().clear(); layers.markings.getSource().addFeatures([...readFeatures(state.layers.laneSeparators, (feature) => !feature.properties?.cluster_preview_hidden), ...readFeatures(state.layers.turnArrows, (feature) => !feature.properties?.cluster_preview_hidden)]);
layers.centerLines.getSource().clear(); layers.centerLines.getSource().addFeatures(readFeatures(state.layers.centerLines, (feature) => !feature.properties?.cluster_preview_hidden));
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, (feature) => !feature.properties?.cluster_internal));
candidatesToggle.hidden = !(state.debug?.junctionCandidates || []).length;
renderJunctionCandidates();
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 });
}
// Draw one convex-ish hull per candidate cluster so the whole intersection is
// outlined, not just its centre, and label it with the same index the inspector
// and the console listing use.
function renderJunctionCandidates() {
const source = layers.junctionCandidates.getSource();
source.clear();
const list = state.debug?.junctionCandidates || [];
if (!list.length) return;
for (const candidate of list) {
const nodes = candidate.nodeIds.map((nodeId) => nodeCoordinate(nodeId)).filter(Boolean);
if (!nodes.length) continue;
const ring = candidateRing(nodes, Math.max(12, candidate.coreRadiusMeters * .6));
source.addFeature(new Feature({ geometry: new Polygon([ring]), candidate_index: candidate.index, index: candidate.index, nodeCount: candidate.nodeCount, candidate_id: candidate.id }));
}
}
function nodeCoordinate(nodeId) {
for (const road of state.compiled.model.roads) {
const at = road.sourceNodeIds.findIndex((item) => String(item) === String(nodeId));
if (at === 0) return road.centerline[0];
if (at === road.sourceNodeIds.length - 1) return road.centerline.at(-1);
}
return null;
}
// A rounded envelope around the member nodes: sample a circle of `padMeters`
// around each node and take the outer boundary by angle from the centroid.
function candidateRing(nodes, padMeters) {
const centre = nodes.reduce((sum, point) => [sum[0] + point[0] / nodes.length, sum[1] + point[1] / nodes.length], [0, 0]);
const metresPerLon = 111320 * Math.cos(centre[1] * Math.PI / 180);
const points = [];
for (let degrees = 0; degrees < 360; degrees += 12) {
const radians = degrees * Math.PI / 180;
let best = null;
for (const node of nodes) {
const point = [node[0] + Math.sin(radians) * padMeters / metresPerLon, node[1] + Math.cos(radians) * padMeters / 111320];
const reach = (point[0] - centre[0]) * metresPerLon * Math.sin(radians) + (point[1] - centre[1]) * 111320 * Math.cos(radians);
if (!best || reach > best.reach) best = { point, reach };
}
points.push(fromLonLat(best.point));
}
return [...points, points[0]];
}
function selectJunctionCandidate(feature) {
const candidate = (state.debug?.junctionCandidates || []).find((item) => item.index === feature.get("candidate_index"));
if (!candidate) return;
selectedRoad = null; selectedMovement = null; selectedJunction = null;
form.hidden = true; hint.hidden = false; selectedJunctionPanel.hidden = true;
hint.textContent = `复杂路口候选 #${candidate.index}${candidate.nodeCount} 个节点,直径 ${candidate.diameterMeters} 米。下方是可直接粘贴到 config 的片段。`;
evidence.textContent = JSON.stringify({
说明: "复制到区域配置文件的 nativeRoad.junctionTemplates.clusters 数组,然后重新编译",
片段: {
id: `cluster-${candidate.nodeIds[0]}`,
template: candidate.template,
nodeIds: candidate.nodeIds,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
},
实测: { 节点数: candidate.nodeCount, 直径米: candidate.diameterMeters, 最长内部连接米: candidate.longestLinkMeters, 最宽进口米: candidate.widestApproachMeters },
提示: "coreRadiusMeters 是按节点跨度估的起点,配好后按实际效果调整;有高德参考几何时它会被校准值覆盖。",
}, null, 2);
renderCandidateAction(candidate);
message(`已选中复杂路口候选 #${candidate.index}`);
}
// The accept button lives beside the snippet so the manual path stays available
// if the write is refused; both describe the same cluster.
function renderCandidateAction(candidate) {
candidateAction.replaceChildren();
const button = document.createElement("button");
button.type = "button";
button.textContent = `把候选 #${candidate.index} 加入配置并重新编译`;
button.onclick = async () => {
button.disabled = true;
message(`正在把候选 #${candidate.index} 写入区域配置...`);
try {
const response = await fetch("/api/junction-clusters", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ index: candidate.index }), cache: "no-store" });
const result = await response.json();
if (!response.ok || result.ok === false) throw new Error(result.error || `HTTP ${response.status}`);
state = result;
staged = [];
updateDirtyState(); updateSources(); renderDiagnostics(); renderSummary();
candidateAction.replaceChildren();
hint.textContent = `已加入复杂路口 ${result.added.id}${result.added.nodeIds.length} 个节点),配置已更新并重新编译。`;
message(`已加入 ${result.added.id};如需撤销可 git checkout 区域配置`);
} catch (error) {
button.disabled = false;
message(`加入失败:${error.message}`);
}
};
candidateAction.append(button);
}
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) {
candidateAction.replaceChildren();
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) {
candidateAction.replaceChildren();
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.osm_node_ids, 类型: properties.kind === "cluster" ? "复合路口簇" : properties.kind === "t" ? "T字路口" : "十字路口", 参与方向道路: roads.map((road) => ({ 道路: roadLabel(road), OSM道路: road.osmWayIds, 节点顺序: road.sourceNodeIds })), 构面规则: properties.rule, 模板: properties.template, 边界策略: 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 || properties.osm_node_ids}`);
}
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) {
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.nativeCenterLineFeatures],
["道路方向箭头", comparison.nativeDirectionArrowFeatures],
["路口转向箭头", comparison.nativeTurnArrowFeatures],
["斑马线条带", comparison.nativeCrosswalkFeatures],
["停止线", comparison.nativeVehicleStopLineFeatures],
["红绿灯设施", state.trafficSignals?.assemblies?.features?.length || 0],
["行驶动作", 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 }); }
function setMarkingStyleForm(title, target, style, allowsDouble) { centerLineForm.hidden = false; markingStyleHeading.textContent = title; centerLineSegment.textContent = target; doubleYellowOption.hidden = !allowsDouble; centerLineStyleInput.value = style || "yellow-dashed"; }
function selectCenterLine(feature) { selectedCenterLineSegment = feature.get("segment_id"); selectedLaneSeparator = null; setMarkingStyleForm("道路中心线样式", `道路段:${selectedCenterLineSegment}`, feature.get("effective_style"), true); }
function selectLaneSeparator(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = feature.getProperties(); setMarkingStyleForm("车道分隔线样式", `${selectedLaneSeparator.left_lane_index} 与第 ${selectedLaneSeparator.right_lane_index} 车道之间`, selectedLaneSeparator.effective_style || "white-dashed", false); }
function selectEdgeLine(feature) { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = feature.getProperties(); setMarkingStyleForm("道路外缘线样式", `${selectedEdgeLine.side === "left" ? "左" : "右"}侧外缘`, selectedEdgeLine.effective_style || "white-solid", false); }
function clearCenterLineSelection() { selectedCenterLineSegment = null; selectedLaneSeparator = null; selectedEdgeLine = null; centerLineForm.hidden = true; }
function stageCenterLineStyle(segmentId, style) { const parts = style.split("-"); const double = parts[0] === "double"; const [color, pattern] = double ? parts.slice(1) : parts; const id = `道路中心线:${segmentId}`; 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: "center-line-style", segmentId, color, pattern, double }); }
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 ? "有未保存修改:双向道路的路缘与步行带已按实际侧边同步" : "有未保存修改"); };
function stageSelectedCenterLineStyle() { if (!selectedCenterLineSegment && !selectedLaneSeparator && !selectedEdgeLine) return; if (selectedCenterLineSegment) stageCenterLineStyle(selectedCenterLineSegment, centerLineStyleInput.value); else { const [color, pattern] = centerLineStyleInput.value.split("-"); const item = selectedLaneSeparator || selectedEdgeLine; const id = selectedLaneSeparator ? `车道分隔线:${item.road_id}:${item.left_lane_index}-${item.right_lane_index}` : `道路外缘线:${item.road_id}:${item.side}`; staged = staged.filter((change) => change.id !== id); staged.push(selectedLaneSeparator ? { id, kind: "lane-separator-style", roadId: item.road_id, leftLaneIndex: item.left_lane_index, rightLaneIndex: item.right_lane_index, color, pattern } : { id, kind: "edge-line-style", roadId: item.road_id, side: item.side, color, pattern }); } updateDirtyState(); message("有未保存修改:线样式"); }
centerLineForm.onsubmit = (event) => { event.preventDefault(); stageSelectedCenterLineStyle(); };
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", cache: "no-store" });
const nextState = await response.json();
if (!response.ok || nextState.ok === false || !nextState.compiled?.model || !nextState.layers) {
message(`重新生成失败:${nextState.error || `HTTP ${response.status}`}`);
return;
}
state = nextState;
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 = () => { 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]")) {
const layer = input.dataset.layer;
if (["osm", "lanes", "reference", "gaodeReference"].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.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; 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));