From 1e50d38927cb9eba26ddc1e8f7b480f577835224 Mon Sep 17 00:00:00 2001 From: zyphlar Date: Sat, 25 Jul 2026 11:23:15 -0700 Subject: [PATCH] improve snapping issues --- README.md | 7 ++ web/static/map.js | 228 +++++++++++++++++++++++++++++------------ web/templates/map.html | 56 +++++----- 3 files changed, 201 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index ebd7792..5ef2610 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,12 @@ docker-compose up -d ## 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?) - 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 "" it should be cleared @@ -212,3 +218,4 @@ addr:postcode=33513 addr:state=FL addr:street=Hideaway Circle - Sr is SR (State Route) +- Pky is Parkway diff --git a/web/static/map.js b/web/static/map.js index 69d0558..c87f6e1 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -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 += '
'; + let propsHtml = '
'; 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 += `
${key}: ${displayValue}
`; + propsHtml += `
${key}: ${displayValue}
`; } - html += '
'; + propsHtml += '
'; - // Show layer types if mixed - const layerTypes = selectedLayers.map(l => l.type); - const uniqueLayerTypes = [...new Set(layerTypes)]; + if (allCounty) { + html += `
Raw county data${propsHtml}
`; + } else { + html += propsHtml; + } if (uniqueLayerTypes.length > 1) { html += `
Layers: ${uniqueLayerTypes.join(', ')}
`; } else { @@ -818,6 +821,19 @@ function showMultiFeaturePopup(latlng) { } else if (removedCount > 0) { html += `
Status: All Removed (Red)
`; } + + // 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 += `
${name}
`; + 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) - ? ` ` + ? ` ` : ''; 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; diff --git a/web/templates/map.html b/web/templates/map.html index 1cac434..ac9abcd 100644 --- a/web/templates/map.html +++ b/web/templates/map.html @@ -30,37 +30,37 @@ right: 10px; z-index: 1000; background: white; - padding: 15px; + padding: 10px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); - min-width: 200px; + min-width: 175px; max-height: calc(100vh - 20px); overflow-y: auto; } .controls h3 { - margin: 0 0 10px 0; - font-size: 14px; + margin: 0 0 6px 0; + font-size: 12px; color: #333; } .controls label { display: flex; align-items: center; - margin: 8px 0; + margin: 4px 0; cursor: pointer; - font-size: 13px; + font-size: 11px; } .layer-item { display: flex; align-items: center; - margin: 8px 0; - padding: 5px; + margin: 4px 0; + padding: 3px 5px; background: #f8f9fa; border-radius: 4px; cursor: move; - font-size: 13px; + font-size: 11px; } .layer-item.dragging { @@ -77,14 +77,14 @@ .controls button { width: 100%; - padding: 10px; - margin-top: 10px; + padding: 7px; + margin-top: 7px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; - font-size: 13px; + font-size: 12px; font-weight: 500; } @@ -98,10 +98,10 @@ } .status { - margin-top: 10px; - padding: 8px; + margin-top: 7px; + padding: 6px; border-radius: 4px; - font-size: 12px; + font-size: 11px; text-align: center; } @@ -120,8 +120,8 @@ } .file-input-group { - margin-bottom: 15px; - padding-bottom: 15px; + margin-bottom: 8px; + padding-bottom: 8px; border-bottom: 1px solid #eee; } @@ -131,7 +131,7 @@ .file-input-group label { display: block; - margin-bottom: 5px; + margin-bottom: 3px; font-weight: 500; } @@ -139,7 +139,7 @@ .file-input-group select { width: 100%; font-size: 11px; - padding: 5px; + padding: 3px; border: 1px solid #ccc; border-radius: 4px; } @@ -302,7 +302,7 @@ -

Diff Filters

+

Diff Filters

+ + -

Selection Mode

+

Selection Mode

-
+
Tip: Enable multi-select or hold Shift to select multiple features by clicking or dragging a box
-

Load Data

+

Load Data