From a7d6db8177812787275970688fb604afcf61c415 Mon Sep 17 00:00:00 2001 From: zyphlar Date: Fri, 7 Aug 2026 10:29:26 -0700 Subject: [PATCH] Highlight possibly-conflicting (nearby) addresses in yellow --- compare-addresses.py | 26 +++++++++++++++++ convert-addresses.py | 6 ---- web/static/map.js | 63 +++++++++++++++++++++++++++++++++--------- web/templates/map.html | 4 +++ 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/compare-addresses.py b/compare-addresses.py index 0a1f60c..88ef42e 100644 --- a/compare-addresses.py +++ b/compare-addresses.py @@ -27,6 +27,9 @@ import warnings warnings.filterwarnings('ignore') +CONFLICT_RADIUS_METERS = 5.0 + + class AddressComparator: def __init__(self, tolerance_meters: float = 500.0): self.tolerance_meters = tolerance_meters @@ -62,6 +65,27 @@ class AddressComparator: s = re.sub(r'\s+', ' ', s).strip() return s + @staticmethod + def _flag_close_conflicts(addresses: List[Dict], radius_meters: float = CONFLICT_RADIUS_METERS) -> None: + """Mark each address dict's 'conflict' key True when another point in the + same list falls within radius_meters. Doesn't dedupe or exclude anything - + just surfaces tight clusters (e.g. ambiguous source data reusing the same + unit label across distinct points) for manual review in the map UI.""" + if not addresses: + return + geoms = [a['geometry'] for a in addresses] + tree = STRtree(geoms) + radius_deg = radius_meters / 111000.0 + for i, addr in enumerate(addresses): + conflict = False + for j in tree.query(geoms[i].buffer(radius_deg)): + if j == i: + continue + if geoms[i].distance(geoms[j]) * 111000.0 <= radius_meters: + conflict = True + break + addr['conflict'] = conflict + def compare_addresses(self, local_file: str, osm_file: str) -> Tuple[List[Dict], List[Dict], List[Dict]]: """Compare local and OSM address data. @@ -175,6 +199,8 @@ class AddressComparator: props['status'] = 'removed' removed_addresses.append({'geometry': osm_row.geometry, **props}) + self._flag_close_conflicts(new_addresses) + return new_addresses, existing_addresses, removed_addresses def save_results(self, new_addresses, existing_addresses, removed_addresses, output_dir): diff --git a/convert-addresses.py b/convert-addresses.py index b9d7d16..11a6d2a 100644 --- a/convert-addresses.py +++ b/convert-addresses.py @@ -160,12 +160,6 @@ def convert(zip_path, output_path): zip_path = Path(zip_path) output_path = Path(output_path) - # Skip if output is newer than input - if (output_path.exists() and zip_path.exists() and - output_path.stat().st_mtime > zip_path.stat().st_mtime): - print(f"Output is up to date: {output_path}") - return - print(f"Converting {zip_path} ...") exceptions = load_exceptions() diff --git a/web/static/map.js b/web/static/map.js index c87f6e1..b388e14 100644 --- a/web/static/map.js +++ b/web/static/map.js @@ -312,8 +312,10 @@ 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'); return { - color: isRemoved ? '#ff0000' : '#00ff00', + color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : '#00ff00'), weight: 3, opacity: 0.8 }; @@ -344,10 +346,12 @@ 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'); return { radius: 6, - fillColor: isRemoved ? '#ff0000' : '#00ff00', - color: isRemoved ? '#cc0000' : '#00cc00', + fillColor: isRemoved ? '#ff0000' : (isConflicted ? '#ffeb3b' : '#00ff00'), + color: isRemoved ? '#cc0000' : (isConflicted ? '#c9a800' : '#00cc00'), weight: 1, opacity: 0.8, fillOpacity: 0.7 @@ -457,17 +461,20 @@ function shouldShowFeature(feature) { if (isExcluded(feature)) return false; const props = feature.properties || {}; const isRemoved = props.removed === true || props.removed === 'True'; + const isConflicted = !isRemoved && (props.conflict === true || props.conflict === 'True'); const isService = props.highway === 'service' || props.highway === 'track'; const isUnclassified = props.highway === 'unclassified'; const showAdded = document.getElementById('showAdded').checked; + const showConflicted = document.getElementById('showConflicted').checked; const showRemoved = document.getElementById('showRemoved').checked; const hideService = document.getElementById('hideService').checked; const hideUnclassified = document.getElementById('hideUnclassified').checked; - // Check removed/added filter + // Check removed/added/conflicted filter if (isRemoved && !showRemoved) return false; - if (!isRemoved && !showAdded) return false; + if (isConflicted && !showConflicted) return false; + if (!isRemoved && !isConflicted && !showAdded) return false; // Check service/track and unclassified filters if (isService && hideService) return false; @@ -809,7 +816,10 @@ function showMultiFeaturePopup(latlng) { if (selectedFeatures.length === 1) { // Single selection - show specific status const isRemoved = selectedFeatures[0].properties && (selectedFeatures[0].properties.removed === true || selectedFeatures[0].properties.removed === 'True'); - html += `
Status: ${isRemoved ? 'Removed (Red)' : 'Added (Green)'}
`; + const isConflicted = !isRemoved && selectedFeatures[0].properties && + (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 (!isRemoved && osmVertices.length > 0) { html += connectivitySection(selectedFeatures[0]); } @@ -1491,6 +1501,10 @@ function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) { const osmNodes = props.osm_nodes; const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates; + if (removedFeat.geometry && removedFeat.geometry.type === 'Point') { + return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` }; + } + const tags = {}; Object.entries(props).forEach(([k, v]) => { if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) && @@ -1641,22 +1655,29 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { } const newWays = []; + const newNodes = []; // standalone point features (e.g. addresses), not way vertices const modifiedWays = []; const splitTailWays = []; const deleteWayIds = []; // [{id, version}] const deleteNodeIds = new Map(); // id -> version acceptedAdded.forEach(feat => { - const rawCoords = feat.geometry.coordinates; - const midCoords = (feat._snappedCoords || rawCoords).slice(1, -1); - const startId = allocEndpointId(rawCoords[0], feat._snapAEnabled !== false ? feat._snapA : null); - const endId = allocEndpointId(rawCoords[rawCoords.length - 1], feat._snapBEnabled !== false ? feat._snapB : null); - const ndRefs = [startId, ...midCoords.map(c => allocNodeId(c, null)), endId]; const tags = {}; Object.entries(feat.properties || {}).forEach(([k, v]) => { if (!['removed', 'status', 'accepted', 'osm_id', 'osm_type', 'osm_nodes', 'osm_version', 'osm_node_versions'].includes(k) && v !== null && v !== undefined && v !== '') tags[k] = String(v); }); + + if (feat.geometry.type === 'Point') { + newNodes.push({ id: nextNewId--, coord: feat.geometry.coordinates, tags }); + return; + } + + const rawCoords = feat.geometry.coordinates; + const midCoords = (feat._snappedCoords || rawCoords).slice(1, -1); + const startId = allocEndpointId(rawCoords[0], feat._snapAEnabled !== false ? feat._snapA : null); + const endId = allocEndpointId(rawCoords[rawCoords.length - 1], feat._snapBEnabled !== false ? feat._snapB : null); + const ndRefs = [startId, ...midCoords.map(c => allocNodeId(c, null)), endId]; newWays.push({ id: nextNewId--, ndRefs, tags }); }); @@ -1689,7 +1710,9 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { removalPlans.forEach((plan, i) => { if (!checkedPlanIndices.has(i) || !plan.osmId) return; - if (plan.type === 'delete') { + if (plan.type === 'deleteNode') { + deleteNodeIds.set(plan.osmId, plan.osmVersion || 1); + } 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)); } else if (plan.type === 'split') { @@ -1730,6 +1753,15 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { return x + ' \n'; } + function nodeXml(n) { + let x = ` \n'; + x += '>\n'; + tagEntries.forEach(([k, v]) => { x += ` \n`; }); + return x + ' \n'; + } + let xml = '\n\n\n'; referencedExisting.forEach(({ coord, version }, nid) => { @@ -1740,7 +1772,8 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { const [lon, lat] = key.split(',').map(Number); xml += ` \n`; }); - if (referencedExisting.size > 0 || [...nodeRegistry.values()].some(id => id < 0)) xml += '\n'; + newNodes.forEach(n => { xml += nodeXml(n); }); + if (referencedExisting.size > 0 || [...nodeRegistry.values()].some(id => id < 0) || newNodes.length > 0) xml += '\n'; newWays.forEach(w => { xml += wayXml(w, 'create'); }); modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); }); @@ -2113,6 +2146,10 @@ document.addEventListener('DOMContentLoaded', function() { createDiffLayer(); }); + document.getElementById('showConflicted').addEventListener('change', function() { + createDiffLayer(); + }); + document.getElementById('showRemoved').addEventListener('change', function() { createDiffLayer(); }); diff --git a/web/templates/map.html b/web/templates/map.html index ac9abcd..60b74ce 100644 --- a/web/templates/map.html +++ b/web/templates/map.html @@ -307,6 +307,10 @@ Show Added (Green) +