improve snapping issues

This commit is contained in:
zyphlar
2026-07-25 11:23:15 -07:00
parent cc925fd345
commit 1e50d38927
3 changed files with 201 additions and 90 deletions
+7
View File
@@ -194,6 +194,12 @@ docker-compose up -d
## TODO ## TODO
- If >50% of a road is parallel and nearby a similar road, consider omitting the portion of the new road which is parallel.
- Highlight snap segments during multi-select, and move the enable/disable snap function to the snap geometry itself instead of the popup
- don't snap a node to a road if the node already contains a segment that's closer to the road, or if this segment already has a closer node. (prevent double snapping)
- use third party validators to check ourselves
- allow adjusting of per-node snap distances that override the default
- Click-to-remove OSM elements: allow selecting existing OSM ways/nodes in the map viewer and marking them for deletion in the JOSM export (analogous to accepting a "removed" diff feature but user-initiated)
- Validate whether a similar address is within 50 feet (same number?) - Validate whether a similar address is within 50 feet (same number?)
- If addr:unit is like "Apartment 123" or "Apartment 123;Apartment 124;Apartment 125" remove the word Apartment so it's just "123" or "123;124;125" - If addr:unit is like "Apartment 123" or "Apartment 123;Apartment 124;Apartment 125" remove the word Apartment so it's just "123" or "123;124;125"
- If an item gets through processing and is "<Null>" it should be cleared - If an item gets through processing and is "<Null>" it should be cleared
@@ -212,3 +218,4 @@ addr:postcode=33513
addr:state=FL addr:state=FL
addr:street=Hideaway Circle addr:street=Hideaway Circle
- Sr is SR (State Route) - Sr is SR (State Route)
- Pky is Parkway
+161 -67
View File
@@ -27,12 +27,15 @@ let mobileControlsOpen = false;
let loadedCounty = ''; let loadedCounty = '';
let loadedDataType = ''; 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 // OSM node index for JOSM export snapping
let osmVertices = []; let osmVertices = [];
let osmNodeById = new Map(); let osmNodeById = new Map();
let osmCoordToNodeId = new Map(); let osmCoordToNodeId = new Map();
let osmNodeVersionById = 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 // Snap preview layer and popup state
let snapPreviewLayer = null; let snapPreviewLayer = null;
@@ -54,6 +57,8 @@ function initMap() {
map.createPane('osmPane'); map.createPane('osmPane');
map.createPane('diffPane'); map.createPane('diffPane');
map.createPane('countyPane'); 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 // Set initial z-indices for panes based on layerOrder
updateLayerZIndex(); updateLayerZIndex();
@@ -216,7 +221,7 @@ function clearSelection() {
selectedFeatures = []; selectedFeatures = [];
selectedLayers = []; selectedLayers = [];
if (snapPreviewLayer) snapPreviewLayer.clearLayers(); updateSnapPreview();
if (featurePopup) { if (featurePopup) {
map.closePopup(featurePopup); map.closePopup(featurePopup);
@@ -350,22 +355,17 @@ function diffMarkerStyle(feature) {
} }
function countyStyle(feature) { function countyStyle(feature) {
return { if (importedCountyFeatures.has(feature)) {
color: '#ff00ff', return { color: '#007bff', weight: 3, opacity: 0.8 };
weight: 3, }
opacity: 0.8 return { color: '#ff00ff', weight: 3, opacity: 0.8 };
};
} }
function countyMarkerStyle(feature) { function countyMarkerStyle(feature) {
return { if (importedCountyFeatures.has(feature)) {
radius: 6, return { radius: 6, fillColor: '#007bff', color: '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 };
fillColor: '#ff00ff', }
color: '#cc00cc', return { radius: 6, fillColor: '#ff00ff', color: '#cc00cc', weight: 1, opacity: 0.8, fillOpacity: 0.6 };
weight: 1,
opacity: 0.8,
fillOpacity: 0.6
};
} }
// Filter function for OSM features // Filter function for OSM features
@@ -699,11 +699,7 @@ function selectFeature(feature, layer, e, layerType = 'diff') {
// Show popup for single or multiple features // Show popup for single or multiple features
showMultiFeaturePopup(e.latlng); showMultiFeaturePopup(e.latlng);
updateSnapPreview();
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);
}
} }
} }
@@ -712,8 +708,9 @@ function showMultiFeaturePopup(latlng) {
if (selectedFeatures.length === 0) return; if (selectedFeatures.length === 0) return;
popupLatlng = latlng; popupLatlng = latlng;
// Remove old popup if exists // Remove old popup without triggering clearSelection
if (featurePopup) { if (featurePopup) {
featurePopup.off('remove');
map.closePopup(featurePopup); map.closePopup(featurePopup);
} }
@@ -764,9 +761,15 @@ function showMultiFeaturePopup(latlng) {
return a.localeCompare(b); 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 // 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) { for (const key of sortedKeys) {
if (key === 'osm_nodes' || key === 'osm_node_versions') continue;
const values = selectedFeatures const values = selectedFeatures
.map(f => f.properties[key]) .map(f => f.properties[key])
.filter(v => v !== null && v !== undefined); .filter(v => v !== null && v !== undefined);
@@ -777,20 +780,20 @@ function showMultiFeaturePopup(latlng) {
let displayValue; let displayValue;
if (uniqueValues.length === 1) { if (uniqueValues.length === 1) {
// All values are the same
displayValue = uniqueValues[0]; displayValue = uniqueValues[0];
} else { } else {
// Different values
displayValue = `<${uniqueValues.length} 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 if (allCounty) {
const layerTypes = selectedLayers.map(l => l.type); html += `<details style="margin-top:4px;"><summary style="font-size:11px;cursor:pointer;color:#555;">Raw county data</summary>${propsHtml}</details>`;
const uniqueLayerTypes = [...new Set(layerTypes)]; } else {
html += propsHtml;
}
if (uniqueLayerTypes.length > 1) { if (uniqueLayerTypes.length > 1) {
html += `<div style="margin-top: 8px;"><strong>Layers:</strong> ${uniqueLayerTypes.join(', ')}</div>`; html += `<div style="margin-top: 8px;"><strong>Layers:</strong> ${uniqueLayerTypes.join(', ')}</div>`;
} else { } else {
@@ -818,6 +821,19 @@ function showMultiFeaturePopup(latlng) {
} else if (removedCount > 0) { } else if (removedCount > 0) {
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`; 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 // Show accept/reject status if any are from diff layer
@@ -1160,6 +1176,9 @@ function cacheSnapInfo(feature) {
const coords = feature.geometry.coordinates; const coords = feature.geometry.coordinates;
feature._snapA = findNearestOsmVertex(coords[0]); feature._snapA = findNearestOsmVertex(coords[0]);
feature._snapB = findNearestOsmVertex(coords[coords.length - 1]); 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._snappedCoords = [
feature._snapA ? feature._snapA.coord : coords[0], feature._snapA ? feature._snapA.coord : coords[0],
...coords.slice(1, -1), ...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) ---- // ---- County road tag conversion (mirrors diff-highways.py logic) ----
function formatStreetName(s) { function formatStreetName(s) {
@@ -1243,68 +1271,110 @@ function countyPropsToOsmTags(props) {
// ---- Snap preview markers ---- // ---- Snap preview markers ----
function updateSnapPreview(feature) { // Draw snap preview for one feature onto snapPreviewLayer.
if (!snapPreviewLayer) return; // interactive=true also adds clickable endpoint dots (only used for the selected feature).
snapPreviewLayer.clearLayers(); function drawSnapPreviewForFeature(feature, interactive) {
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return; if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
cacheSnapInfo(feature);
const rawCoords = feature.geometry.coordinates; const rawCoords = feature.geometry.coordinates;
[0, rawCoords.length - 1].forEach(i => { const ends = [0, rawCoords.length - 1].map(i => {
const isStart = i === 0; const isStart = i === 0;
const snap = isStart ? feature._snapA : feature._snapB; const snap = isStart ? feature._snapA : feature._snapB;
const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled'; const enabledFlag = isStart ? '_snapAEnabled' : '_snapBEnabled';
const isEnabled = feature[enabledFlag] !== false; 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) { if (snap) {
coord = snap.coord; color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true;
color = isEnabled ? '#28a745' : '#dc3545';
canToggle = true;
} else if (segSnap) { } else if (segSnap) {
coord = segSnap.coord; color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true;
color = isEnabled ? '#007bff' : '#dc3545';
canToggle = true;
} else { } else {
coord = rawCoords[i]; color = '#ffc107'; canToggle = false;
color = '#ffc107';
canToggle = false;
} }
const marker = L.circleMarker([coord[1], coord[0]], { const marker = L.circleMarker([finalCoord[1], finalCoord[0]], {
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9 radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane'
}); });
if (canToggle) { 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) { marker.on('click', function(e) {
L.DomEvent.stopPropagation(e); L.DomEvent.stopPropagation(e);
feature[enabledFlag] = !isEnabled; feature[enabledFlag] = !isEnabled;
updateSnapPreview(feature); updateSnapPreview();
if (popupLatlng) showMultiFeaturePopup(popupLatlng); if (popupLatlng) showMultiFeaturePopup(popupLatlng);
}); });
} else { } else {
marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true }); marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
} }
snapPreviewLayer.addLayer(marker); snapPreviewLayer.addLayer(marker);
}); });
} }
window.toggleSnapA = function() { // Redraw snap preview layer:
if (selectedFeatures.length !== 1) return; // - all accepted added features get a preview line when the checkbox is on
const feat = selectedFeatures[0]; // - the currently selected diff added feature always gets the interactive dot markers
feat._snapAEnabled = feat._snapAEnabled !== false ? false : true; function updateSnapPreview() {
updateSnapPreview(feat); 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); if (popupLatlng) showMultiFeaturePopup(popupLatlng);
}; };
window.toggleSnapB = function() { window.toggleSnapB = function(idx) {
if (selectedFeatures.length !== 1) return; idx = idx === undefined ? 0 : idx;
const feat = selectedFeatures[0]; if (idx >= selectedFeatures.length) return;
feat._snapBEnabled = feat._snapBEnabled !== false ? false : true; selectedFeatures[idx]._snapBEnabled = selectedFeatures[idx]._snapBEnabled !== false ? false : true;
updateSnapPreview(feat); updateSnapPreview();
if (popupLatlng) showMultiFeaturePopup(popupLatlng); if (popupLatlng) showMultiFeaturePopup(popupLatlng);
}; };
@@ -1319,19 +1389,28 @@ window.importCountyFeature = function() {
properties: osmTags, properties: osmTags,
_countySource: source _countySource: source
}; };
importedCountyFeatures.add(source);
acceptedFeatures.add(synthetic); acceptedFeatures.add(synthetic);
cacheSnapInfo(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'); showStatus(`Imported "${osmTags.name || 'unnamed'}" from county data`, 'success');
if (popupLatlng) showMultiFeaturePopup(popupLatlng); 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 (!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 coords = feature.geometry.coordinates;
const snapA = findNearestOsmVertex(coords[0]); const snapA = feature._snapA;
const snapB = findNearestOsmVertex(coords[coords.length - 1]); const snapB = feature._snapB;
const segA = snapA ? null : findNearestOsmWayPoint(coords[0], SNAP_TOL * 3); const segA = feature._segSnapA;
const segB = snapB ? null : findNearestOsmWayPoint(coords[coords.length - 1], SNAP_TOL * 3); const segB = feature._segSnapB;
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance) // Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
function findDiffConnection(coord) { function findDiffConnection(coord) {
@@ -1352,7 +1431,7 @@ function connectivitySection(feature) {
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled'; const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
const isEnabled = feature[enabledFlag] !== false; const isEnabled = feature[enabledFlag] !== false;
const toggleBtn = (snap || segSnap) 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) { if (snap) {
@@ -2050,6 +2129,21 @@ document.addEventListener('DOMContentLoaded', function() {
createCountyLayer(); 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 // Click on empty map area closes any open popup and clears selection
map.on('click', function(e) { map.on('click', function(e) {
if (multiSelectMode) return; if (multiSelectMode) return;
+33 -23
View File
@@ -30,37 +30,37 @@
right: 10px; right: 10px;
z-index: 1000; z-index: 1000;
background: white; background: white;
padding: 15px; padding: 10px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2); box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 200px; min-width: 175px;
max-height: calc(100vh - 20px); max-height: calc(100vh - 20px);
overflow-y: auto; overflow-y: auto;
} }
.controls h3 { .controls h3 {
margin: 0 0 10px 0; margin: 0 0 6px 0;
font-size: 14px; font-size: 12px;
color: #333; color: #333;
} }
.controls label { .controls label {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 8px 0; margin: 4px 0;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 11px;
} }
.layer-item { .layer-item {
display: flex; display: flex;
align-items: center; align-items: center;
margin: 8px 0; margin: 4px 0;
padding: 5px; padding: 3px 5px;
background: #f8f9fa; background: #f8f9fa;
border-radius: 4px; border-radius: 4px;
cursor: move; cursor: move;
font-size: 13px; font-size: 11px;
} }
.layer-item.dragging { .layer-item.dragging {
@@ -77,14 +77,14 @@
.controls button { .controls button {
width: 100%; width: 100%;
padding: 10px; padding: 7px;
margin-top: 10px; margin-top: 7px;
background: #007bff; background: #007bff;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 12px;
font-weight: 500; font-weight: 500;
} }
@@ -98,10 +98,10 @@
} }
.status { .status {
margin-top: 10px; margin-top: 7px;
padding: 8px; padding: 6px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 11px;
text-align: center; text-align: center;
} }
@@ -120,8 +120,8 @@
} }
.file-input-group { .file-input-group {
margin-bottom: 15px; margin-bottom: 8px;
padding-bottom: 15px; padding-bottom: 8px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
@@ -131,7 +131,7 @@
.file-input-group label { .file-input-group label {
display: block; display: block;
margin-bottom: 5px; margin-bottom: 3px;
font-weight: 500; font-weight: 500;
} }
@@ -139,7 +139,7 @@
.file-input-group select { .file-input-group select {
width: 100%; width: 100%;
font-size: 11px; font-size: 11px;
padding: 5px; padding: 3px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
} }
@@ -302,7 +302,7 @@
</div> </div>
</div> </div>
<h3 style="margin-top: 15px;">Diff Filters</h3> <h3 style="margin-top: 10px;">Diff Filters</h3>
<label> <label>
<input type="checkbox" id="showAdded" checked> <input type="checkbox" id="showAdded" checked>
Show Added (Green) Show Added (Green)
@@ -319,16 +319,26 @@
<input type="checkbox" id="hideUnclassified"> <input type="checkbox" id="hideUnclassified">
Hide unclassified Hide unclassified
</label> </label>
<label>
<input type="checkbox" id="showSnapPreview" checked>
Show snap preview
</label>
<label>
Snap distance:
<input type="number" id="snapDistance" value="10" min="1" max="500" step="1"
style="width:42px;margin:0 3px;padding:1px 3px;border:1px solid #ccc;border-radius:3px;font-size:11px;">
m
</label>
<h3 style="margin-top: 15px;">Selection Mode</h3> <h3 style="margin-top: 10px;">Selection Mode</h3>
<button id="multiSelectToggle" style="width: 100%; padding: 8px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;"> <button id="multiSelectToggle" style="width: 100%; padding: 8px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;">
Multi-Select: OFF Multi-Select: OFF
</button> </button>
<div style="margin-top: 8px; padding: 8px; background: #e7f3ff; border-radius: 4px; font-size: 11px; color: #004085;"> <div style="margin-top: 5px; padding: 5px; background: #e7f3ff; border-radius: 4px; font-size: 10px; color: #004085;">
<strong>Tip:</strong> Enable multi-select or hold Shift to select multiple features by clicking or dragging a box <strong>Tip:</strong> Enable multi-select or hold Shift to select multiple features by clicking or dragging a box
</div> </div>
<h3 style="margin-top: 15px;">Load Data</h3> <h3 style="margin-top: 10px;">Load Data</h3>
<div class="file-input-group"> <div class="file-input-group">
<label for="countySelect">County:</label> <label for="countySelect">County:</label>
<select id="countySelect"> <select id="countySelect">