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 = ' 道路方向箭头'; const markingsToggle = document.createElement('label'); markingsToggle.innerHTML = ' 车道分隔线与路口转向箭头'; const centerLinesToggle = document.createElement('label'); centerLinesToggle.innerHTML = ' 道路中心线'; const edgeLinesToggle = document.createElement('label'); edgeLinesToggle.innerHTML = ' 道路外缘线'; const controlsToggle = document.createElement('label'); controlsToggle.innerHTML = ' 斑马线与停止线'; const signalsToggle = document.createElement('label'); signalsToggle.innerHTML = ' 红绿灯设施'; 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 = ' 复杂路口候选(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 = '

原生红绿灯

'; 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' ? 0.25 : 0.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(0.5); const before = geometry.getCoordinateAt(0.48); const after = geometry.getCoordinateAt(0.52); const length = Math.hypot(after[0] - before[0], after[1] - before[1]); if (length < 0.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 * 0.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(); }; function showImportScreen() { const panel = document.createElement('form'); panel.style.cssText = 'position:fixed;inset:90px 25%;z-index:20;background:#fff;padding:32px;box-shadow:0 8px 30px #0003'; panel.innerHTML = '

导入 OSM 地图

选择一个 .osm 文件开始创建工作区。

'; panel.onsubmit = async (event) => { event.preventDefault(); const file = panel.querySelector('input').files[0]; const output = panel.querySelector('output'); if (!file) return; output.textContent = '正在导入并编译...'; const body = new FormData(); body.append('file', file, file.name); try { const response = await fetch('/api/import', { method: 'POST', body }); const result = await response.json(); if (!response.ok || !result.ok) throw new Error(result.error || `HTTP ${response.status}`); location.reload(); } catch (error) { output.textContent = error.message; } }; document.body.append(panel); } fetch('/api/state') .then((response) => response.json()) .then((value) => { if (!value.active && !value.compiled) return showImportScreen(); 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));