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));