improve snapping issues
This commit is contained in:
+161
-67
@@ -27,12 +27,15 @@ let mobileControlsOpen = false;
|
||||
let loadedCounty = '';
|
||||
let loadedDataType = '';
|
||||
|
||||
// Tracks county features that have been imported (so countyStyle can highlight them)
|
||||
let importedCountyFeatures = new Set();
|
||||
|
||||
// OSM node index for JOSM export snapping
|
||||
let osmVertices = [];
|
||||
let osmNodeById = new Map();
|
||||
let osmCoordToNodeId = new Map();
|
||||
let osmNodeVersionById = new Map();
|
||||
const SNAP_TOL = 0.000135; // ~15m in degrees
|
||||
let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
|
||||
|
||||
// Snap preview layer and popup state
|
||||
let snapPreviewLayer = null;
|
||||
@@ -54,6 +57,8 @@ function initMap() {
|
||||
map.createPane('osmPane');
|
||||
map.createPane('diffPane');
|
||||
map.createPane('countyPane');
|
||||
map.createPane('snapPane');
|
||||
map.getPane('snapPane').style.zIndex = 660; // above road panes (~652), below popups (700)
|
||||
|
||||
// Set initial z-indices for panes based on layerOrder
|
||||
updateLayerZIndex();
|
||||
@@ -216,7 +221,7 @@ function clearSelection() {
|
||||
|
||||
selectedFeatures = [];
|
||||
selectedLayers = [];
|
||||
if (snapPreviewLayer) snapPreviewLayer.clearLayers();
|
||||
updateSnapPreview();
|
||||
|
||||
if (featurePopup) {
|
||||
map.closePopup(featurePopup);
|
||||
@@ -350,22 +355,17 @@ function diffMarkerStyle(feature) {
|
||||
}
|
||||
|
||||
function countyStyle(feature) {
|
||||
return {
|
||||
color: '#ff00ff',
|
||||
weight: 3,
|
||||
opacity: 0.8
|
||||
};
|
||||
if (importedCountyFeatures.has(feature)) {
|
||||
return { color: '#007bff', weight: 3, opacity: 0.8 };
|
||||
}
|
||||
return { color: '#ff00ff', weight: 3, opacity: 0.8 };
|
||||
}
|
||||
|
||||
function countyMarkerStyle(feature) {
|
||||
return {
|
||||
radius: 6,
|
||||
fillColor: '#ff00ff',
|
||||
color: '#cc00cc',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6
|
||||
};
|
||||
if (importedCountyFeatures.has(feature)) {
|
||||
return { radius: 6, fillColor: '#007bff', color: '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 };
|
||||
}
|
||||
return { radius: 6, fillColor: '#ff00ff', color: '#cc00cc', weight: 1, opacity: 0.8, fillOpacity: 0.6 };
|
||||
}
|
||||
|
||||
// Filter function for OSM features
|
||||
@@ -699,11 +699,7 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
|
||||
|
||||
// Show popup for single or multiple features
|
||||
showMultiFeaturePopup(e.latlng);
|
||||
|
||||
if (layerType === 'diff' && feature.geometry.type === 'LineString') {
|
||||
const isAdded = !feature.properties || (feature.properties.removed !== true && feature.properties.removed !== 'True');
|
||||
if (isAdded && osmVertices.length > 0) updateSnapPreview(feature);
|
||||
}
|
||||
updateSnapPreview();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,8 +708,9 @@ function showMultiFeaturePopup(latlng) {
|
||||
if (selectedFeatures.length === 0) return;
|
||||
popupLatlng = latlng;
|
||||
|
||||
// Remove old popup if exists
|
||||
// Remove old popup without triggering clearSelection
|
||||
if (featurePopup) {
|
||||
featurePopup.off('remove');
|
||||
map.closePopup(featurePopup);
|
||||
}
|
||||
|
||||
@@ -764,9 +761,15 @@ function showMultiFeaturePopup(latlng) {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// Compute layer types early so property display can adapt
|
||||
const layerTypes = selectedLayers.map(l => l.type);
|
||||
const uniqueLayerTypes = [...new Set(layerTypes)];
|
||||
const allCounty = uniqueLayerTypes.length === 1 && uniqueLayerTypes[0] === 'county';
|
||||
|
||||
// For each property, check if all values are the same
|
||||
html += '<div style="font-size: 11px;">';
|
||||
let propsHtml = '<div style="font-size: 11px;">';
|
||||
for (const key of sortedKeys) {
|
||||
if (key === 'osm_nodes' || key === 'osm_node_versions') continue;
|
||||
const values = selectedFeatures
|
||||
.map(f => f.properties[key])
|
||||
.filter(v => v !== null && v !== undefined);
|
||||
@@ -777,20 +780,20 @@ function showMultiFeaturePopup(latlng) {
|
||||
|
||||
let displayValue;
|
||||
if (uniqueValues.length === 1) {
|
||||
// All values are the same
|
||||
displayValue = uniqueValues[0];
|
||||
} else {
|
||||
// Different values
|
||||
displayValue = `<${uniqueValues.length} different values>`;
|
||||
}
|
||||
|
||||
html += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${displayValue}</div>`;
|
||||
propsHtml += `<div style="margin: 2px 0;"><strong>${key}:</strong> ${displayValue}</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
propsHtml += '</div>';
|
||||
|
||||
// Show layer types if mixed
|
||||
const layerTypes = selectedLayers.map(l => l.type);
|
||||
const uniqueLayerTypes = [...new Set(layerTypes)];
|
||||
if (allCounty) {
|
||||
html += `<details style="margin-top:4px;"><summary style="font-size:11px;cursor:pointer;color:#555;">Raw county data</summary>${propsHtml}</details>`;
|
||||
} else {
|
||||
html += propsHtml;
|
||||
}
|
||||
if (uniqueLayerTypes.length > 1) {
|
||||
html += `<div style="margin-top: 8px;"><strong>Layers:</strong> ${uniqueLayerTypes.join(', ')}</div>`;
|
||||
} else {
|
||||
@@ -818,6 +821,19 @@ function showMultiFeaturePopup(latlng) {
|
||||
} else if (removedCount > 0) {
|
||||
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`;
|
||||
}
|
||||
|
||||
// Per-feature connectivity / snap toggles for multiselect
|
||||
if (selectedFeatures.length > 1 && osmVertices.length > 0) {
|
||||
selectedLayers.forEach(({ type }, i) => {
|
||||
if (type !== 'diff') return;
|
||||
const feat = selectedFeatures[i];
|
||||
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
||||
if (isRemoved) return;
|
||||
const name = (feat.properties && feat.properties.name) || `Road ${i + 1}`;
|
||||
html += `<div style="margin-top:6px;padding-top:4px;border-top:1px solid #eee;font-size:11px;font-weight:bold;">${name}</div>`;
|
||||
html += connectivitySection(feat, i);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show accept/reject status if any are from diff layer
|
||||
@@ -1160,6 +1176,9 @@ function cacheSnapInfo(feature) {
|
||||
const coords = feature.geometry.coordinates;
|
||||
feature._snapA = findNearestOsmVertex(coords[0]);
|
||||
feature._snapB = findNearestOsmVertex(coords[coords.length - 1]);
|
||||
feature._segSnapA = feature._snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
|
||||
feature._segSnapB = feature._snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
|
||||
feature._snapTol = SNAP_TOL;
|
||||
feature._snappedCoords = [
|
||||
feature._snapA ? feature._snapA.coord : coords[0],
|
||||
...coords.slice(1, -1),
|
||||
@@ -1167,6 +1186,15 @@ function cacheSnapInfo(feature) {
|
||||
];
|
||||
}
|
||||
|
||||
function precalculateSnaps() {
|
||||
if (!diffData || osmVertices.length === 0) return;
|
||||
diffData.features.forEach(feat => {
|
||||
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
||||
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
||||
if (!isRemoved) cacheSnapInfo(feat);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- County road tag conversion (mirrors diff-highways.py logic) ----
|
||||
|
||||
function formatStreetName(s) {
|
||||
@@ -1243,68 +1271,110 @@ function countyPropsToOsmTags(props) {
|
||||
|
||||
// ---- Snap preview markers ----
|
||||
|
||||
function updateSnapPreview(feature) {
|
||||
if (!snapPreviewLayer) return;
|
||||
snapPreviewLayer.clearLayers();
|
||||
// Draw snap preview for one feature onto snapPreviewLayer.
|
||||
// interactive=true also adds clickable endpoint dots (only used for the selected feature).
|
||||
function drawSnapPreviewForFeature(feature, interactive) {
|
||||
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
|
||||
|
||||
cacheSnapInfo(feature);
|
||||
const rawCoords = feature.geometry.coordinates;
|
||||
|
||||
[0, rawCoords.length - 1].forEach(i => {
|
||||
const ends = [0, rawCoords.length - 1].map(i => {
|
||||
const isStart = i === 0;
|
||||
const snap = isStart ? feature._snapA : feature._snapB;
|
||||
const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const segSnap = !snap ? findNearestOsmWayPoint(rawCoords[i], SNAP_TOL * 3) : null;
|
||||
const segSnap = isStart ? feature._segSnapA : feature._segSnapB;
|
||||
const rawCoord = rawCoords[i];
|
||||
let finalCoord = rawCoord;
|
||||
if (isEnabled && snap) finalCoord = snap.coord;
|
||||
else if (isEnabled && segSnap) finalCoord = segSnap.coord;
|
||||
return { isStart, snap, segSnap, enabledFlag, isEnabled, rawCoord, finalCoord };
|
||||
});
|
||||
|
||||
let coord, color, canToggle;
|
||||
// Draw only the connector segment where an endpoint actually moves (raw → snapped).
|
||||
// Don't redraw the full road — that duplicates what's already visible in the diff layer.
|
||||
ends.forEach(({ rawCoord, finalCoord }) => {
|
||||
if (Math.hypot(finalCoord[0] - rawCoord[0], finalCoord[1] - rawCoord[1]) < 1e-8) return;
|
||||
L.polyline([[rawCoord[1], rawCoord[0]], [finalCoord[1], finalCoord[0]]], {
|
||||
color: '#ff6b00', weight: 3, opacity: 0.85, dashArray: '7,5', pane: 'snapPane'
|
||||
}).addTo(snapPreviewLayer);
|
||||
});
|
||||
|
||||
if (!interactive) return;
|
||||
|
||||
ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => {
|
||||
let color, canToggle;
|
||||
if (snap) {
|
||||
coord = snap.coord;
|
||||
color = isEnabled ? '#28a745' : '#dc3545';
|
||||
canToggle = true;
|
||||
color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true;
|
||||
} else if (segSnap) {
|
||||
coord = segSnap.coord;
|
||||
color = isEnabled ? '#007bff' : '#dc3545';
|
||||
canToggle = true;
|
||||
color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true;
|
||||
} else {
|
||||
coord = rawCoords[i];
|
||||
color = '#ffc107';
|
||||
canToggle = false;
|
||||
color = '#ffc107'; canToggle = false;
|
||||
}
|
||||
|
||||
const marker = L.circleMarker([coord[1], coord[0]], {
|
||||
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9
|
||||
const marker = L.circleMarker([finalCoord[1], finalCoord[0]], {
|
||||
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane'
|
||||
});
|
||||
|
||||
if (canToggle) {
|
||||
marker.bindTooltip(isEnabled ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap', { sticky: true });
|
||||
marker.bindTooltip(
|
||||
isEnabled ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap',
|
||||
{ sticky: true }
|
||||
);
|
||||
marker.on('click', function(e) {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
feature[enabledFlag] = !isEnabled;
|
||||
updateSnapPreview(feature);
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
});
|
||||
} else {
|
||||
marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
|
||||
}
|
||||
|
||||
snapPreviewLayer.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSnapA = function() {
|
||||
if (selectedFeatures.length !== 1) return;
|
||||
const feat = selectedFeatures[0];
|
||||
feat._snapAEnabled = feat._snapAEnabled !== false ? false : true;
|
||||
updateSnapPreview(feat);
|
||||
// Redraw snap preview layer:
|
||||
// - all accepted added features get a preview line when the checkbox is on
|
||||
// - the currently selected diff added feature always gets the interactive dot markers
|
||||
function updateSnapPreview() {
|
||||
if (!snapPreviewLayer) return;
|
||||
snapPreviewLayer.clearLayers();
|
||||
if (osmVertices.length === 0) return;
|
||||
|
||||
const showAll = document.getElementById('showSnapPreview')?.checked;
|
||||
const selectedSet = new Set(selectedFeatures);
|
||||
|
||||
if (showAll) {
|
||||
acceptedFeatures.forEach(feat => {
|
||||
if (!feat.geometry || feat.geometry.type !== 'LineString') return;
|
||||
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
||||
if (isRemoved || selectedSet.has(feat)) return;
|
||||
drawSnapPreviewForFeature(feat, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Selected diff added features always get interactive dots regardless of checkbox
|
||||
selectedLayers.forEach(({ type }, i) => {
|
||||
if (type !== 'diff') return;
|
||||
const feat = selectedFeatures[i];
|
||||
if (!feat || !feat.geometry || feat.geometry.type !== 'LineString') return;
|
||||
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
|
||||
if (!isRemoved) drawSnapPreviewForFeature(feat, true);
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSnapA = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapAEnabled = selectedFeatures[idx]._snapAEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
window.toggleSnapB = function() {
|
||||
if (selectedFeatures.length !== 1) return;
|
||||
const feat = selectedFeatures[0];
|
||||
feat._snapBEnabled = feat._snapBEnabled !== false ? false : true;
|
||||
updateSnapPreview(feat);
|
||||
window.toggleSnapB = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapBEnabled = selectedFeatures[idx]._snapBEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
@@ -1319,19 +1389,28 @@ window.importCountyFeature = function() {
|
||||
properties: osmTags,
|
||||
_countySource: source
|
||||
};
|
||||
importedCountyFeatures.add(source);
|
||||
acceptedFeatures.add(synthetic);
|
||||
cacheSnapInfo(synthetic);
|
||||
if (countyLayer) {
|
||||
countyLayer.eachLayer(l => { if (l.feature === source) l.setStyle(countyStyle(source)); });
|
||||
}
|
||||
updateSnapPreview();
|
||||
showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
function connectivitySection(feature) {
|
||||
function connectivitySection(feature, idx) {
|
||||
if (idx === undefined) idx = 0;
|
||||
if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
|
||||
if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) {
|
||||
cacheSnapInfo(feature);
|
||||
}
|
||||
const coords = feature.geometry.coordinates;
|
||||
const snapA = findNearestOsmVertex(coords[0]);
|
||||
const snapB = findNearestOsmVertex(coords[coords.length - 1]);
|
||||
const segA = snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3);
|
||||
const segB = snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3);
|
||||
const snapA = feature._snapA;
|
||||
const snapB = feature._snapB;
|
||||
const segA = feature._segSnapA;
|
||||
const segB = feature._segSnapB;
|
||||
|
||||
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
|
||||
function findDiffConnection(coord) {
|
||||
@@ -1352,7 +1431,7 @@ function connectivitySection(feature) {
|
||||
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const toggleBtn = (snap || segSnap)
|
||||
? ` <button onclick="${toggleFn}()" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>`
|
||||
? ` <button onclick="${toggleFn}(${idx})" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>`
|
||||
: '';
|
||||
|
||||
if (snap) {
|
||||
@@ -2050,6 +2129,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
createCountyLayer();
|
||||
});
|
||||
|
||||
document.getElementById('showSnapPreview').addEventListener('change', function() {
|
||||
updateSnapPreview();
|
||||
});
|
||||
|
||||
document.getElementById('snapDistance').addEventListener('change', function() {
|
||||
const meters = Math.max(1, parseFloat(this.value) || 10);
|
||||
this.value = meters;
|
||||
SNAP_TOL = meters / 111320;
|
||||
acceptedFeatures.forEach(feat => {
|
||||
if (feat.geometry && feat.geometry.type === 'LineString') cacheSnapInfo(feat);
|
||||
});
|
||||
updateSnapPreview();
|
||||
if (popupLatlng && selectedFeatures.length > 0) showMultiFeaturePopup(popupLatlng);
|
||||
});
|
||||
|
||||
// Click on empty map area closes any open popup and clears selection
|
||||
map.on('click', function(e) {
|
||||
if (multiSelectMode) return;
|
||||
|
||||
Reference in New Issue
Block a user