1441 lines
60 KiB
JavaScript
1441 lines
60 KiB
JavaScript
import Map from '/vendor/ol/Map.js';
|
||
import View from '/vendor/ol/View.js';
|
||
import VectorLayer from '/vendor/ol/layer/Vector.js';
|
||
import VectorSource from '/vendor/ol/source/Vector.js';
|
||
import GeoJSON from '/vendor/ol/format/GeoJSON.js';
|
||
import Feature from '/vendor/ol/Feature.js';
|
||
import LineString from '/vendor/ol/geom/LineString.js';
|
||
import Point from '/vendor/ol/geom/Point.js';
|
||
import Style from '/vendor/ol/style/Style.js';
|
||
import Fill from '/vendor/ol/style/Fill.js';
|
||
import Stroke from '/vendor/ol/style/Stroke.js';
|
||
import CircleStyle from '/vendor/ol/style/Circle.js';
|
||
import 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' ? 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 =
|
||
'<h1>导入 OSM 地图</h1><p>选择一个 .osm 文件开始创建工作区。</p><input type="file" accept=".osm,application/xml,text/xml" required><button type="submit">导入并编译</button><output style="display:block;margin-top:12px"></output>';
|
||
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));
|