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)` : ''}