diff --git a/README.md b/README.md index bf50133..16b4dcc 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,5 @@ addr:housenumber=5740 addr:postcode=33513 addr:state=FL addr:street=Hideaway Circle -- Sr is SR (State Route) -- Pky is Parkway - The county sometimes lists garages or other units in the street name like "Vista Hills Street Garage 2" or "... Gs2" / "... Gs 2" which needs to be properly split out into street and unit - Roads/lines need to be more clickable on the web UI, it's hard to click a ~3px element. \ No newline at end of file diff --git a/compare-addresses.py b/compare-addresses.py index 2dfe4af..c36802b 100644 --- a/compare-addresses.py +++ b/compare-addresses.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Dict, List, Tuple import geopandas as gpd +import numpy as np import pandas as pd from shapely.strtree import STRtree import warnings @@ -28,6 +29,7 @@ warnings.filterwarnings('ignore') CONFLICT_RADIUS_METERS = 5.0 +RENAME_RADIUS_METERS = 100.0 class AddressComparator: @@ -86,6 +88,53 @@ class AddressComparator: break addr['conflict'] = conflict + def _flag_possible_renames(self, new_addresses: List[Dict], removed_addresses: List[Dict], + radius_meters: float = RENAME_RADIUS_METERS) -> int: + """Pair each new county address with an unmatched OSM address nearby that + has the same house number (and a compatible unit) but a different street + name, e.g. county "828 Donelin Circle" vs OSM "828 Donelin CR". Both sides + get 'possible_rename' set to the other side's street so the map can show + them as a likely rename/misnaming rather than an unrelated add + removal, + and a shared 'rename_pair' id so the map can accept both together. + Returns the number of pairs found.""" + if not new_addresses or not removed_addresses: + return 0 + geoms = [a['geometry'] for a in removed_addresses] + tree = STRtree(geoms) + radius_deg = radius_meters / 111000.0 + paired = set() + count = 0 + for addr in new_addresses: + num = self._normalize_multi(addr.get('addr:housenumber')) + street = self._normalize_street_name(str(addr.get('addr:street', ''))) + unit = self._normalize_multi(addr.get('addr:unit')) + if not num or not street: + continue + best, best_dist = None, float('inf') + for j in tree.query(addr['geometry'].buffer(radius_deg)): + if j in paired: + continue + osm = removed_addresses[j] + if self._normalize_multi(osm.get('addr:housenumber')) != num: + continue + osm_street = self._normalize_street_name(str(osm.get('addr:street', ''))) + if not osm_street or osm_street == street: + continue + osm_unit = self._normalize_multi(osm.get('addr:unit')) + if unit and osm_unit and unit != osm_unit: + continue + d = addr['geometry'].distance(geoms[j]) * 111000.0 + if d <= radius_meters and d < best_dist: + best, best_dist = j, d + if best is not None: + paired.add(best) + osm = removed_addresses[best] + addr['possible_rename'] = str(osm.get('addr:street')) + osm['possible_rename'] = str(addr.get('addr:street')) + count += 1 + addr['rename_pair'] = osm['rename_pair'] = count + return count + def compare_addresses(self, local_file: str, osm_file: str) -> Tuple[List[Dict], List[Dict], List[Dict]]: """Compare local and OSM address data. @@ -197,10 +246,17 @@ class AddressComparator: for idx, osm_row in osm_gdf.iterrows(): if idx not in matched_osm: props = dict(osm_row.drop('geometry')) + # geopandas reads JSON int arrays (osm_nodes) as numpy arrays, + # which would be written back out as a string + for k, v in props.items(): + if isinstance(v, np.ndarray): + props[k] = [int(x) for x in v.tolist()] props['status'] = 'removed' removed_addresses.append({'geometry': osm_row.geometry, **props}) self._flag_close_conflicts(new_addresses) + renames = self._flag_possible_renames(new_addresses, removed_addresses) + print(f"Possible street renames (same number nearby, different street): {renames}") return new_addresses, existing_addresses, removed_addresses diff --git a/download-overpass.py b/download-overpass.py index 83e2848..5159bf1 100644 --- a/download-overpass.py +++ b/download-overpass.py @@ -50,11 +50,13 @@ def build_overpass_query(county_name, state_name, data_type="highways"): # Addresses: include nwr so building ways and relations are captured too, # but use "out center;" instead of "out geom;" so ways/relations return a # single centroid rather than their full coordinate list — much smaller payload. + # "meta" adds each element's version, so the map viewer can export edits + # (e.g. fixing addr:street) to the existing element instead of replacing it. if data_type == "addresses": query = f"""[out:json][timeout:180]; area(id:{area_id})->.searchArea; nwr["addr:housenumber"](area.searchArea); -out center;""" +out meta center;""" return query base_query = f"""[out:json][timeout:180]; @@ -145,9 +147,13 @@ def convert_to_geojson(overpass_data): if element["type"] == "node": if not element.get("tags"): continue # Skip topology-only nodes (no tags); versions collected above + props = dict(element["tags"]) + props["osm_id"] = element["id"] + props["osm_type"] = "node" + props["osm_version"] = element.get("version", 1) feature = { "type": "Feature", - "properties": element.get("tags", {}), + "properties": props, "geometry": { "type": "Point", "coordinates": [element["lon"], element["lat"]] @@ -177,10 +183,20 @@ def convert_to_geojson(overpass_data): features.append(feature) elif "center" in element: # out center; — single centroid point (used for addresses on buildings) + # Node refs / members are kept so the element can be exported + # as a tag-only modify without re-downloading it. c = element["center"] + props = dict(element.get("tags", {})) + props["osm_id"] = element["id"] + props["osm_type"] = element["type"] + props["osm_version"] = element.get("version", 1) + if "nodes" in element: + props["osm_nodes"] = element["nodes"] + if "members" in element: + props["osm_members"] = element["members"] feature = { "type": "Feature", - "properties": element.get("tags", {}), + "properties": props, "geometry": { "type": "Point", "coordinates": [c["lon"], c["lat"]] diff --git a/qgis-functions.py b/qgis-functions.py index a459853..27a6696 100644 --- a/qgis-functions.py +++ b/qgis-functions.py @@ -10,6 +10,7 @@ import re _POST_TITLE_CORRECTIONS = [ (r'\bUs\b', 'US'), # "US 441" not "Us 441" + (r'\b(\d{1,4})([a-z])\b', lambda m: m.group(1) + m.group(2).upper()), # "CR 44A" not "CR 44a" ] def title(s): @@ -51,23 +52,28 @@ def getunitfromaddress(value1, feature, parent): # @qgsfunction(args='auto', group='Custom', referenced_columns=[]) def formatstreet(value1, feature, parent): parts = value1.split() - # Handle the special case of a street name starting with "ST" - # which is almost always "Saint __" and not "Street __" - if parts[0].upper() == "ST": - parts[0] = "Saint" - if parts[0].upper() == "ROYAL" and parts[1].upper() == "ST": - parts[0] = "Royal" - parts[1] = "Saint" - # And "CR" as a first part (County Road) vs last part (Circle) - if parts[0].upper() == "C ": - parts[0] = "County Road " - if parts[0].upper() == "CR": - parts[0] = "County Road" - if parts[0].upper() == "SR": - parts[0] = "State Road" + # A leading directional ("N ST CLAIR AVE", "W C 48") doesn't change how + # the word after it reads, so the rules below look past it. + first = 1 if len(parts) > 1 and parts[0].upper() in _DIRECTIONALS else 0 + for i, part in enumerate(parts): + up = part.upper() + has_next = i + 1 < len(parts) + # "ST" starting a name and followed by more words is almost always + # "Saint __" ("ST CLAIR ST", "N ST LOUIS AVE"); elsewhere, or alone + # ("N ST"), it's "Street" + if up == "ST" and has_next and (i == first or (i == 1 and parts[0].upper() == "ROYAL")): + parts[i] = "Saint" + # "CR"/"C" before a route number is County Road ("CR 44A", "W C 48", + # "OLD C 470"); a trailing "CR" is Circle (handled by formatstreetname) + elif up in ("CR", "C") and has_next and parts[i + 1][:1].isdigit(): + parts[i] = "County Road" + elif up == "SR" and i == first: + parts[i] = "State Road" parts = map(formatstreetname, parts) return " ".join(parts) +_DIRECTIONALS = {'N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW'} + # @qgsfunction(args='auto', group='Custom', referenced_columns=[]) def formatname(value1, feature, parent): @@ -256,6 +262,8 @@ def formatstreetname(name): return "Path" if nameUp == "PKWY": return "Parkway" + if nameUp == "PKY": + return "Parkway" if nameUp == "PL": return "Place" if nameUp == "PLZ": diff --git a/web/static/map.js b/web/static/map.js index 1e841d9..534d8c6 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -55,7 +55,24 @@ function distanceToLayer(layer, p) { // can steal the click. This variant picks the nearest one instead (ties // still go to the topmost). Otherwise mirrors Leaflet 1.9.4's // Canvas._onClick / _handleMouseHover. +// +// Each road layer also has its own canvas in its own pane (so the layer-order +// controls can restack them), and the topmost canvas covers the whole map, so +// it receives every mouse event. Whichever canvas gets the event therefore +// hit-tests every pane, topmost first, falling through to the next pane down +// only when nothing in the one above is within tolerance. const NearestHitCanvas = L.Canvas.extend({ + _hitTest: function(point, e) { + const renderers = Object.values(paneRenderers) + .filter(r => r._map) + .sort((a, b) => (parseInt(b.getPane().style.zIndex, 10) || 0) - (parseInt(a.getPane().style.zIndex, 10) || 0)); + for (const r of renderers) { + const layer = r._nearestLayer(point, e); + if (layer) return layer; + } + return null; + }, + _nearestLayer: function(point, e) { let best = null, bestDist = Infinity; for (let order = this._drawFirst; order; order = order.next) { @@ -72,14 +89,14 @@ const NearestHitCanvas = L.Canvas.extend({ }, _onClick: function(e) { - const clickedLayer = this._nearestLayer(this._map.mouseEventToLayerPoint(e), e); + const clickedLayer = this._hitTest(this._map.mouseEventToLayerPoint(e), e); this._fireEvent(clickedLayer ? [clickedLayer] : false, e); }, _handleMouseHover: function(e, point) { if (this._mouseHoverThrottled) return; - const candidateHoveredLayer = this._nearestLayer(point); + const candidateHoveredLayer = this._hitTest(point); if (candidateHoveredLayer !== this._hoveredLayer) { this._handleMouseOut(e); @@ -124,11 +141,18 @@ let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI let snapPreviewLayer = null; let popupLatlng = null; +// Snap end dots for the road under the mouse (any added road, selected or +// not), so an end can be toggled without opening a popup that may cover it. +let hoverSnapLayer = null; +let hoverSnapFeature = null; +let hoverSnapHideTimer = null; + // Initialize map function initMap() { map = initBaseMap('map', [28.7, -81.7], 12); snapPreviewLayer = L.layerGroup().addTo(map); + hoverSnapLayer = L.layerGroup().addTo(map); // Create custom panes for layer ordering map.createPane('osmPane'); @@ -414,20 +438,33 @@ function diffStyle(feature) { const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); const isConflicted = !isRemoved && feature.properties && (feature.properties.conflict === true || feature.properties.conflict === 'True'); + // Medium green: crosses an existing OSM road of the same name (see markPossibleRenames) + const addedColor = feature._possibleRename ? '#1f9e3a' : '#00ff00'; return { - color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : '#00ff00'), + color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : addedColor), weight: 3, opacity: 0.8 }; } +function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function hasPossibleRename(feature) { + const v = feature.properties && feature.properties.possible_rename; + return typeof v === 'string' && v !== '' && v !== 'None' && v !== 'nan'; +} + function diffMarkerStyle(feature) { // Check if feature is accepted or rejected if (acceptedFeatures.has(feature)) { + // Darker blue: accepted as a street rename of the existing OSM address + const rename = !!feature._acceptRename; return { radius: 6, - fillColor: '#007bff', - color: '#0056b3', + fillColor: rename ? '#0b3d91' : '#007bff', + color: rename ? '#061f4a' : '#0056b3', weight: 1, opacity: 0.8, fillOpacity: 0.7 @@ -448,10 +485,17 @@ function diffMarkerStyle(feature) { const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); const isConflicted = !isRemoved && feature.properties && (feature.properties.conflict === true || feature.properties.conflict === 'True'); + // Darker: same house number nearby on a differently named street (see + // compare-addresses.py _flag_possible_renames) + const rename = hasPossibleRename(feature); + const fill = isRemoved ? (rename ? '#a00000' : '#ff0000') + : (isConflicted ? '#ffeb3b' : (rename ? '#1f9e3a' : '#00ff00')); + const stroke = isRemoved ? (rename ? '#600000' : '#cc0000') + : (isConflicted ? '#c9a800' : (rename ? '#0f5c21' : '#00cc00')); return { radius: 6, - fillColor: isRemoved ? '#ff0000' : (isConflicted ? '#ffeb3b' : '#00ff00'), - color: isRemoved ? '#cc0000' : (isConflicted ? '#c9a800' : '#00cc00'), + fillColor: fill, + color: stroke, weight: 1, opacity: 0.8, fillOpacity: 0.7 @@ -610,6 +654,7 @@ function createDiffLayer() { layer.on('mouseover', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); + if (!isPoint) showHoverSnapDots(feature); if (!isSelected) { if (isPoint) { layer.setStyle({ @@ -627,6 +672,7 @@ function createDiffLayer() { layer.on('mouseout', function(e) { const isSelected = selectedLayers.some(l => l.layer === layer); + if (!isPoint) scheduleHideHoverSnap(); if (!isSelected) { if (isPoint) { layer.setStyle(diffMarkerStyle(feature)); @@ -924,6 +970,11 @@ function showMultiFeaturePopup(latlng) { (selectedFeatures[0].properties.conflict === true || selectedFeatures[0].properties.conflict === 'True'); const statusLabel = isRemoved ? 'Removed (Red)' : (isConflicted ? 'Possibly Conflicted (Yellow)' : 'Added (Green)'); html += `
Status: ${statusLabel}
`; + if (hasPossibleRename(selectedFeatures[0])) { + const other = isRemoved ? 'County data has' : 'OSM has'; + const num = selectedFeatures[0].properties['addr:housenumber'] || ''; + html += `
Possible rename: ${other} "${escapeHtml(String(num + ' ' + selectedFeatures[0].properties.possible_rename).trim())}" nearby
`; + } if (!isRemoved && osmVertices.length > 0) { html += connectivitySection(selectedFeatures[0]); } @@ -936,16 +987,14 @@ function showMultiFeaturePopup(latlng) { html += `
Status: All Removed (Red)
`; } - // Per-feature connectivity / snap toggles for multiselect + // One compact connectivity line per selected road 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); + html += connectivitySection(feat, i, true); }); } } @@ -957,7 +1006,8 @@ function showMultiFeaturePopup(latlng) { if (acceptedCount > 0 || rejectedCount > 0) { html += '
'; - if (acceptedCount > 0) html += `
✓ ${acceptedCount} accepted
`; + const renameCount = selectedFeatures.filter(f => acceptedFeatures.has(f) && f._acceptRename).length; + if (acceptedCount > 0) html += `
✓ ${acceptedCount} accepted${renameCount ? ` (${renameCount} as rename)` : ''}
`; if (rejectedCount > 0) html += `
✗ ${rejectedCount} rejected
`; html += '
'; } @@ -968,6 +1018,11 @@ function showMultiFeaturePopup(latlng) { const buttonText = selectedFeatures.length === 1 ? '' : ' All'; html += '
'; html += ``; + const renamable = selectedFeatures.filter(hasPossibleRename).length; + if (renamable > 0) { + const label = selectedFeatures.length === 1 ? 'Accept Rename' : `Accept ${renamable} Rename${renamable === 1 ? '' : 's'}`; + html += ``; + } html += ``; html += '
'; html += '
'; @@ -1019,9 +1074,11 @@ function showMultiFeaturePopup(latlng) { function acceptAllFeatures() { if (selectedFeatures.length === 0) return; + const unpaired = []; selectedFeatures.forEach((feature, index) => { // Remove from rejected if present rejectedFeatures.delete(feature); + unpaired.push(...clearAcceptedRename(feature)); // Add to accepted acceptedFeatures.add(feature); @@ -1042,6 +1099,8 @@ function acceptAllFeatures() { } }); + restyleDiffFeatures(unpaired); + // Close popup if (featurePopup) { map.closePopup(featurePopup); @@ -1060,9 +1119,11 @@ function acceptAllFeatures() { function rejectAllFeatures() { if (selectedFeatures.length === 0) return; + const unpaired = []; selectedFeatures.forEach((feature, index) => { // Remove from accepted if present acceptedFeatures.delete(feature); + unpaired.push(...clearAcceptedRename(feature)); // Add to rejected rejectedFeatures.add(feature); @@ -1079,6 +1140,8 @@ function rejectAllFeatures() { } }); + restyleDiffFeatures(unpaired); + // Close popup if (featurePopup) { map.closePopup(featurePopup); @@ -1161,7 +1224,73 @@ async function excludeSelectedFeatures() { showStatus(`Excluded: ${names}`, 'success'); } +// ---- Address renames ---- +// compare-addresses.py pairs a new county address with a nearby OSM address +// that has the same number on a differently named street, giving both the +// same 'rename_pair'. Accepting the rename keeps the OSM element and exports +// only its corrected addr:street, instead of adding one node and deleting the +// other. + +function renamePartner(feature) { + const id = feature.properties && feature.properties.rename_pair; + if (id === undefined || id === null || !diffData) return null; + return diffData.features.find(f => f !== feature && f.properties && f.properties.rename_pair === id) || null; +} + +function restyleDiffFeatures(features) { + if (!diffLayer || features.length === 0) return; + const set = new Set(features); + diffLayer.eachLayer(l => { + if (set.has(l.feature)) l.setStyle(l.feature.geometry.type === 'Point' ? diffMarkerStyle(l.feature) : diffStyle(l.feature)); + }); +} + +// Undo an accepted rename on both sides of its pair (the other side was only +// accepted as part of the rename). Returns the other side if it changed. +function clearAcceptedRename(feature) { + if (!feature._acceptRename) return []; + delete feature._acceptRename; + const partner = renamePartner(feature); + if (partner && partner._acceptRename) { + delete partner._acceptRename; + acceptedFeatures.delete(partner); + return [partner]; + } + return []; +} + +function acceptRenameFeatures() { + const touched = []; + let accepted = 0, missingId = 0; + selectedFeatures.forEach(feature => { + if (!hasPossibleRename(feature)) return; + const partner = renamePartner(feature); + if (!partner) return; + const osmSide = isRemovedFeature(feature) ? feature : partner; + if (!osmSide.properties.osm_id) { missingId++; return; } + [feature, partner].forEach(f => { + rejectedFeatures.delete(f); + acceptedFeatures.add(f); + f._acceptRename = true; + touched.push(f); + }); + accepted++; + }); + + if (featurePopup) map.closePopup(featurePopup); + clearSelection(); + restyleDiffFeatures(touched); + updateSaveButton(); + + if (missingId > 0) { + showStatus(`${missingId} rename${missingId === 1 ? '' : 's'} not accepted: the OSM addresses have no element ids. Re-run "Download OSM data" and "Diff vs OSM" for addresses.`, 'error'); + } else { + showStatus(`${accepted} rename${accepted === 1 ? '' : 's'} accepted; ${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success'); + } +} + // Expose functions globally for onclick handlers +window.acceptRenameFeatures = acceptRenameFeatures; window.acceptAllFeatures = acceptAllFeatures; window.rejectAllFeatures = rejectAllFeatures; window.excludeSelectedFeatures = excludeSelectedFeatures; @@ -1253,6 +1382,45 @@ function segIntersect(p1, p2, p3, p4) { return [p1[0] + t * d1x, p1[1] + t * d1y]; } +// Flags added diff roads that cross an existing OSM road with the same name, +// which usually means the road was renamed or rerouted rather than being new. +// Same rule as the "possible rename/reroute" hint in the connectivity popup. +function markPossibleRenames() { + if (!diffData || !diffData.features) return; + const lineBbox = coords => { + let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity; + coords.forEach(([lon, lat]) => { + if (lon < minLon) minLon = lon; if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; + }); + return { minLon, maxLon, minLat, maxLat }; + }; + // Named OSM ways, grouped by lowercased name + const osmByName = new Map(); + ((osmData && osmData.features) || []).forEach(feat => { + const coords = feat.geometry && feat.geometry.type === 'LineString' && feat.geometry.coordinates; + const name = feat.properties && feat.properties.name; + if (!coords || coords.length < 2 || !name) return; + const key = name.toLowerCase(); + if (!osmByName.has(key)) osmByName.set(key, []); + osmByName.get(key).push({ coords, bbox: lineBbox(coords) }); + }); + diffData.features.forEach(feature => { + feature._possibleRename = false; + const props = feature.properties || {}; + const coords = feature.geometry && feature.geometry.type === 'LineString' && feature.geometry.coordinates; + if (!coords || coords.length < 2 || !props.name || props.removed === true || props.removed === 'True') return; + const ways = osmByName.get(props.name.toLowerCase()); + if (!ways) return; + const b = lineBbox(coords); + feature._possibleRename = ways.some(way => + way.bbox.maxLon >= b.minLon - SNAP_TOL && way.bbox.minLon <= b.maxLon + SNAP_TOL && + way.bbox.maxLat >= b.minLat - SNAP_TOL && way.bbox.minLat <= b.maxLat + SNAP_TOL && + coords.some((p, i) => i < coords.length - 1 && way.coords.some((q, j) => + j < way.coords.length - 1 && segIntersect(p, coords[i + 1], q, way.coords[j + 1])))); + }); +} + function findOsmCrossings(newCoords) { if (!osmData) return []; let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity; @@ -1386,8 +1554,8 @@ function countyPropsToOsmTags(props) { // ---- Snap preview markers ---- // 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) { +// interactive=true also adds clickable endpoint dots (selected or hovered features). +function drawSnapPreviewForFeature(feature, interactive, target = snapPreviewLayer) { if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return; const rawCoords = feature.geometry.coordinates; @@ -1410,49 +1578,89 @@ function drawSnapPreviewForFeature(feature, interactive) { 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); + }).addTo(target); }); if (!interactive) return; ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => { - let color, canToggle; - if (snap) { - color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true; - } else if (segSnap) { - color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true; - } else { - color = '#ffc107'; canToggle = false; - } + // Orange: this end snaps (matches the dashed connector); red: its snap + // is turned off; gray: nothing nearby to snap to. + const canToggle = !!(snap || segSnap); + const color = !canToggle ? '#6c757d' : (isEnabled ? '#ff6b00' : '#dc3545'); + const endLabel = isStart ? 'Start' : 'End'; const marker = L.circleMarker([finalCoord[1], finalCoord[0]], { radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane' }); + marker.on('mouseover', () => { + marker.setStyle({ radius: 11, weight: 3 }); + if (target === hoverSnapLayer) clearTimeout(hoverSnapHideTimer); + }); + marker.on('mouseout', () => { + marker.setStyle({ radius: 8, weight: 2 }); + if (target === hoverSnapLayer) scheduleHideHoverSnap(); + }); if (canToggle) { + const target = snap ? `"${snap.name || 'unnamed road'}"` : 'the nearby road'; marker.bindTooltip( - isEnabled ? `Click to disable ${isStart ? 'start' : 'end'} snap` : 'Click to re-enable snap', + isEnabled ? `${endLabel} snaps to ${target} — click to turn off` + : `${endLabel} snap is off — click to snap to ${target}`, { sticky: true } ); marker.on('click', function(e) { L.DomEvent.stopPropagation(e); feature[enabledFlag] = !isEnabled; updateSnapPreview(); + if (hoverSnapFeature === feature) showHoverSnapDots(feature); if (popupLatlng) showMultiFeaturePopup(popupLatlng); }); } else { - marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true }); + marker.bindTooltip(`${endLabel}: no nearby road to snap to`, { sticky: true }); } - snapPreviewLayer.addLayer(marker); + target.addLayer(marker); }); } +function isRemovedFeature(feat) { + return !!(feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True')); +} + +// Show clickable end dots for a hovered added road. Selected roads already +// have theirs on snapPreviewLayer. +function showHoverSnapDots(feature) { + if (!hoverSnapLayer) return; + clearTimeout(hoverSnapHideTimer); + hoverSnapLayer.clearLayers(); + hoverSnapFeature = null; + if (osmVertices.length === 0 || !feature.geometry || feature.geometry.type !== 'LineString') return; + if (isRemovedFeature(feature) || selectedFeatures.includes(feature)) return; + if (feature._snapA === undefined || feature._snapTol !== SNAP_TOL) cacheSnapInfo(feature); + hoverSnapFeature = feature; + drawSnapPreviewForFeature(feature, true, hoverSnapLayer); +} + +// Linger briefly so the mouse can travel from the road onto one of its dots. +function scheduleHideHoverSnap() { + clearTimeout(hoverSnapHideTimer); + hoverSnapHideTimer = setTimeout(() => { + if (hoverSnapLayer) hoverSnapLayer.clearLayers(); + hoverSnapFeature = null; + }, 700); +} + // 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(); + // A road that just became selected gets its dots from here instead. + if (hoverSnapFeature && selectedFeatures.includes(hoverSnapFeature)) { + hoverSnapLayer.clearLayers(); + hoverSnapFeature = null; + } if (osmVertices.length === 0) return; const showAll = document.getElementById('showSnapPreview')?.checked; @@ -1477,19 +1685,13 @@ function updateSnapPreview() { }); } -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(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); +// Hovering a road's line in a multi-select popup highlights that road on the map. +window.highlightSelectedRoad = function(idx, on) { + const entry = selectedLayers[idx]; + if (!entry || !entry.layer.setStyle) return; + entry.layer.setStyle(on ? { weight: 10, opacity: 1, color: '#00b8d4' } + : { weight: 6, opacity: 1, color: '#ffc107' }); + if (on && entry.layer.bringToFront) entry.layer.bringToFront(); }; window.importCountyFeature = function() { @@ -1514,17 +1716,17 @@ window.importCountyFeature = function() { if (popupLatlng) showMultiFeaturePopup(popupLatlng); }; -function connectivitySection(feature, idx) { +// Connectivity summary for an added road. Snapping at each end is toggled by +// clicking the end's dot on the map, not from here. With `compact` (used for +// each road in a multi-selection) it is one line; hovering that line +// highlights the road on the map. +function connectivitySection(feature, idx, compact) { 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 = 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) { @@ -1541,46 +1743,59 @@ function connectivitySection(feature, idx) { return best; } - function endpointHtml(label, snap, segSnap, toggleFn) { - const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled'; - const isEnabled = feature[enabledFlag] !== false; - const toggleBtn = (snap || segSnap) - ? ` ` - : ''; - - if (snap) { - const isEndpoint = snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1; - const m = Math.round(snap.dist * 111320); - const roadName = snap.name || 'unnamed road'; - const typeStr = isEndpoint ? 'endpoint of' : 'midpoint of'; - const color = isEnabled ? '#155724' : '#6c757d'; - return `
✓ ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}
`; + // {icon, color, text, short} describing one end of the road + function endpointInfo(isStart) { + const snap = isStart ? feature._snapA : feature._snapB; + const segSnap = isStart ? feature._segSnapA : feature._segSnapB; + const isEnabled = feature[isStart ? '_snapAEnabled' : '_snapBEnabled'] !== false; + if (snap || segSnap) { + const m = Math.round((snap || segSnap).dist * 111320); + const target = snap + ? `${snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1 ? 'endpoint' : 'midpoint'} of "${snap.name || 'unnamed road'}"` + : `"${(segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway)) || 'unnamed road'}" (new node)`; + if (!isEnabled) { + return { icon: '✗', color: '#dc3545', short: 'snap off', text: `snap off (would join ${target}, ${m} m)` }; + } + return { icon: '✓', color: '#155724', short: 'snaps', text: `snaps to ${target} (${m} m)` }; } - if (segSnap) { - const m = Math.round(segSnap.dist * 111320); - const roadName = segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway) || 'unnamed road'; - const color = isEnabled ? '#155724' : '#6c757d'; - return `
✓ ${label}: will insert node into "${roadName}" (${m} m) on export${toggleBtn}
`; - } - const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]); + const diffConn = findDiffConnection(isStart ? coords[0] : coords[coords.length - 1]); if (diffConn) { - return `
✓ ${label}: connects to new "${diffConn}"
`; + return { icon: '✓', color: '#155724', short: 'joins new', text: `connects to new "${diffConn}"` }; } - return `
⚠ ${label}: no nearby road — dangling
`; + return { icon: '⚠', color: '#856404', short: 'dangling', text: 'no nearby road — dangling' }; } + const ends = [endpointInfo(true), endpointInfo(false)]; const crossings = findOsmCrossings(coords); const newName = feature.properties && feature.properties.name; + const isRename = c => newName && c.name && c.name.toLowerCase() === newName.toLowerCase(); + + if (compact) { + const name = newName || `Road ${idx + 1}`; + const title = ['Start', 'End'].map((l, i) => `${l}: ${ends[i].text}`).join('\n').replace(/—/g, '-').replace(/&[^;]+;/g, ''); + let line = ['S', 'E'].map((l, i) => + `${ends[i].icon} ${l}: ${ends[i].short}`).join(' · '); + if (crossings.length) line += ` · ⇄ ${crossings.length}`; + if (crossings.some(isRename)) line += ' · possible rename'; + return `
` + + `
${name}
${line}
`; + } + let html = '
'; html += 'Connectivity:'; - html += endpointHtml('Start', snapA, segA, 'toggleSnapA'); - html += endpointHtml('End', snapB, segB, 'toggleSnapB'); + ['Start', 'End'].forEach((label, i) => { + html += `
${ends[i].icon} ${label}: ${ends[i].text}
`; + }); crossings.slice(0, 3).forEach(c => { - const sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase(); - const hint = sameName ? ' — possible rename/reroute' : ''; + const hint = isRename(c) ? ' — possible rename/reroute' : ''; html += `
⇄ Crosses "${c.name || 'unnamed'}"${hint}
`; }); if (crossings.length > 3) html += `
… and ${crossings.length - 3} more
`; + if (feature._snapA || feature._segSnapA || feature._snapB || feature._segSnapB) { + html += '
Click an orange/red end dot on the map to toggle its snap.
'; + } html += '
'; return html; } @@ -1604,9 +1819,9 @@ const NON_OSM_PROPERTIES = new Set([ // map viewer state 'removed', 'status', 'accepted', // OSM identity/version info carried along from the Overpass download - 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions', + 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions', 'osm_members', // compare-addresses.py annotations - 'conflict', 'county_city', 'county_postal_community', + 'conflict', 'county_city', 'county_postal_community', 'possible_rename', 'rename_pair', ]); // Turn feature properties into an OSM tag map, dropping internal keys and @@ -1619,6 +1834,16 @@ function osmTagsFrom(props) { return tags; } +// A tag-only modify of an existing OSM element (from an address download, +// which keeps each element's id, version, node refs and members). +function elementEdit(feat, tags) { + const p = feat.properties || {}; + return { + type: p.osm_type || 'node', id: p.osm_id, version: p.osm_version || 1, + coord: feat.geometry.coordinates, nodes: p.osm_nodes, members: p.osm_members, tags + }; +} + function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) { const props = removedFeat.properties || {}; const osmId = props.osm_id || props['@id']; @@ -1627,6 +1852,15 @@ function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) { const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates; if (removedFeat.geometry && removedFeat.geometry.type === 'Point') { + // An address on a building/area, or on a node that is also something + // else (a shop, a POI), loses only its addr:* tags; the element stays. + const elType = props.osm_type || 'node'; + const tags = osmTagsFrom(props); + if (elType !== 'node' || Object.keys(tags).some(k => !k.startsWith('addr:'))) { + const kept = Object.fromEntries(Object.entries(tags).filter(([k]) => !k.startsWith('addr:'))); + return { type: 'stripAddress', osmId, edit: elementEdit(removedFeat, kept), + description: `Remove address tags from ${elType}${osmId ? ' ' + osmId : ''}` }; + } return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` }; } @@ -1735,7 +1969,7 @@ function showAutoFixModal(plans) { // ---- JOSM Export: XML generation ---- -function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { +function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, elementEdits = []) { const nodeRegistry = new Map(); // "lon7,lat7" → id const referencedExisting = new Map(); // nodeId (positive) → {coord, version} let nextNewId = -1; @@ -1825,6 +2059,8 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { if (plan.type === 'deleteNode') { deleteNodeIds.set(plan.osmId, plan.osmVersion || 1); + } else if (plan.type === 'stripAddress') { + elementEdits.push(plan.edit); } else if (plan.type === 'delete') { deleteWayIds.push({ id: plan.osmId, version: plan.osmVersion || 1 }); if (plan.deleteNodeIds) plan.deleteNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 1)); @@ -1866,6 +2102,21 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { return x + ' \n'; } + // Existing element with changed tags only. A way/relation lists its node + // refs/members unchanged; the referenced elements themselves aren't needed. + function elementEditXml(e) { + const tagXml = Object.entries(e.tags).map(([k, v]) => ` \n`).join(''); + if (e.type === 'node') { + return ` \n${tagXml} \n`; + } + if (e.type === 'way') { + const nds = (e.nodes || []).map(ref => ` \n`).join(''); + return ` \n${nds}${tagXml} \n`; + } + const members = (e.members || []).map(m => ` \n`).join(''); + return ` \n${members}${tagXml} \n`; + } + function nodeXml(n) { let x = ` { xml += wayXml(w, 'create'); }); modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); }); + elementEdits + .filter(e => e.id && (e.type === 'node' || (e.type === 'way' && e.nodes && e.nodes.length) || (e.type === 'relation' && e.members))) + .forEach(e => { xml += elementEditXml(e); }); splitTailWays.forEach(w => { xml += wayXml(w, 'create'); }); deleteWayIds.forEach(w => { xml += ` \n`; }); deleteNodeIds.forEach((version, nid) => { xml += ` \n`; }); @@ -1906,8 +2160,18 @@ async function exportToOsm() { const acceptedAdded = []; const acceptedRemoved = []; + const renameEdits = []; acceptedFeatures.forEach(feat => { const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True'); + if (feat._acceptRename) { + // The OSM side carries the edit; the county side adds nothing. + if (isRemoved) { + const tags = osmTagsFrom(feat.properties); + tags['addr:street'] = String(feat.properties.possible_rename); + renameEdits.push(elementEdit(feat, tags)); + } + return; + } if (isRemoved) acceptedRemoved.push(feat); else acceptedAdded.push(feat); }); @@ -1936,7 +2200,7 @@ async function exportToOsm() { checkedPlanIndices = new Set(removalPlans.keys()); } - const xml = generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices); + const xml = generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, renameEdits); const dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const filename = `${loadedCounty || 'osm'}-${loadedDataType || 'export'}-${dataDate}.osm`; @@ -1950,7 +2214,7 @@ async function exportToOsm() { document.body.removeChild(a); URL.revokeObjectURL(url); - showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed as JOSM .osm`, 'success'); + showStatus(`Exported ${acceptedAdded.length} added, ${acceptedRemoved.length} removed${renameEdits.length ? `, ${renameEdits.length} renamed` : ''} as JOSM .osm`, 'success'); } window.exportToOsm = exportToOsm; @@ -2008,7 +2272,7 @@ async function loadFiles() { osmFile = `${county}/osm-addresses.geojson`; diffAddedFile = `${county}/addresses-to-add.geojson`; diffRemovedFile = `${county}/addresses-potentially-removed.geojson`; - countyFile = `${county}/addresses.shp_converted.geojson`; + countyFile = `${county}/county-addresses.geojson`; } // Load files from server @@ -2062,6 +2326,7 @@ async function loadFiles() { // Create layers createOsmLayer(); buildOsmNodeIndex(); + markPossibleRenames(); createDiffLayer(); createCountyLayer(); diff --git a/web/templates/map.html b/web/templates/map.html index e905d76..e8fbfa4 100644 --- a/web/templates/map.html +++ b/web/templates/map.html @@ -130,6 +130,8 @@ .leaflet-container.multi-select-active.leaflet-grab { cursor: crosshair !important; } + /* One line per road in a multi-select popup; hover highlights it on the map */ + .conn-row:hover { background: #e0f7fa; } @@ -175,7 +177,7 @@

Diff Filters