Address rename detection with Accept Rename tag-edit export, hover-to-toggle snap dots, cross-layer click fix, fix county street naming (Saint, County Road, Parkway), and keep OSM ids in address downloads

This commit is contained in:
zyphlar
2026-10-21 18:14:33 -07:00
parent b44ec0341f
commit 28550984be
6 changed files with 443 additions and 98 deletions
-2
View File
@@ -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.
+56
View File
@@ -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
+19 -3
View File
@@ -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"]]
+22 -14
View File
@@ -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":
+341 -76
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 += `<div style="margin-top: 8px;"><strong>Status:</strong> ${statusLabel}</div>`;
if (hasPossibleRename(selectedFeatures[0])) {
const other = isRemoved ? 'County data has' : 'OSM has';
const num = selectedFeatures[0].properties['addr:housenumber'] || '';
html += `<div style="margin-top: 4px; color: ${isRemoved ? '#a00000' : '#1f9e3a'};"><strong>Possible rename:</strong> ${other} "${escapeHtml(String(num + ' ' + selectedFeatures[0].properties.possible_rename).trim())}" nearby</div>`;
}
if (!isRemoved && osmVertices.length > 0) {
html += connectivitySection(selectedFeatures[0]);
}
@@ -936,16 +987,14 @@ function showMultiFeaturePopup(latlng) {
html += `<div style="margin-top: 8px;"><strong>Status:</strong> All Removed (Red)</div>`;
}
// 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 += `<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);
html += connectivitySection(feat, i, true);
});
}
}
@@ -957,7 +1006,8 @@ function showMultiFeaturePopup(latlng) {
if (acceptedCount > 0 || rejectedCount > 0) {
html += '<div style="margin-top: 8px; font-size: 11px;">';
if (acceptedCount > 0) html += `<div style="color: #007bff;">✓ ${acceptedCount} accepted</div>`;
const renameCount = selectedFeatures.filter(f => acceptedFeatures.has(f) && f._acceptRename).length;
if (acceptedCount > 0) html += `<div style="color: #007bff;">✓ ${acceptedCount} accepted${renameCount ? ` (${renameCount} as rename)` : ''}</div>`;
if (rejectedCount > 0) html += `<div style="color: #4a4a4a;">✗ ${rejectedCount} rejected</div>`;
html += '</div>';
}
@@ -968,6 +1018,11 @@ function showMultiFeaturePopup(latlng) {
const buttonText = selectedFeatures.length === 1 ? '' : ' All';
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">';
html += `<button onclick="acceptAllFeatures()" style="flex: 1; padding: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">Accept${buttonText}</button>`;
const renamable = selectedFeatures.filter(hasPossibleRename).length;
if (renamable > 0) {
const label = selectedFeatures.length === 1 ? 'Accept Rename' : `Accept ${renamable} Rename${renamable === 1 ? '' : 's'}`;
html += `<button onclick="acceptRenameFeatures()" title="Keep the existing OSM address and change its street to the county's" style="flex: 1; padding: 5px; background: #0b3d91; color: white; border: none; border-radius: 3px; cursor: pointer;">${label}</button>`;
}
html += `<button onclick="rejectAllFeatures()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject${buttonText}</button>`;
html += '</div>';
html += '<div style="margin-top: 5px;">';
@@ -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} &mdash; click to turn off`
: `${endLabel} snap is off &mdash; 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)
? ` <button onclick="${toggleFn}(${idx})" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>`
: '';
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 `<div style="color:${color};">&#10003; ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}</div>`;
// {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: '&#10007;', color: '#dc3545', short: 'snap off', text: `snap off (would join ${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 `<div style="color:${color};">&#10003; ${label}: will insert node into "${roadName}" (${m} m) on export${toggleBtn}</div>`;
return { icon: '&#10003;', color: '#155724', short: 'snaps', text: `snaps to ${target} (${m} m)` };
}
const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]);
const diffConn = findDiffConnection(isStart ? coords[0] : coords[coords.length - 1]);
if (diffConn) {
return `<div style="color:#155724;">&#10003; ${label}: connects to new "${diffConn}"</div>`;
return { icon: '&#10003;', color: '#155724', short: 'joins new', text: `connects to new "${diffConn}"` };
}
return `<div style="color:#856404;">&#9888; ${label}: no nearby road &mdash; dangling</div>`;
return { icon: '&#9888;', color: '#856404', short: 'dangling', text: 'no nearby road &mdash; 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(/&mdash;/g, '-').replace(/&[^;]+;/g, '');
let line = ['S', 'E'].map((l, i) =>
`<span style="color:${ends[i].color};white-space:nowrap;">${ends[i].icon} ${l}: ${ends[i].short}</span>`).join(' &middot; ');
if (crossings.length) line += ` &middot; <span style="color:#004085;white-space:nowrap;">&#8644; ${crossings.length}</span>`;
if (crossings.some(isRename)) line += ' &middot; <em style="color:#1f9e3a;">possible rename</em>';
return `<div class="conn-row" title="${title.replace(/"/g, '&quot;')}" ` +
`onmouseenter="highlightSelectedRoad(${idx}, true)" onmouseleave="highlightSelectedRoad(${idx}, false)" ` +
`style="margin-top:3px;padding:3px 4px;border-radius:3px;font-size:11px;cursor:default;">` +
`<div style="font-weight:bold;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${name}</div>${line}</div>`;
}
let html = '<div style="margin-top:8px;font-size:11px;">';
html += '<strong>Connectivity:</strong>';
html += endpointHtml('Start', snapA, segA, 'toggleSnapA');
html += endpointHtml('End', snapB, segB, 'toggleSnapB');
['Start', 'End'].forEach((label, i) => {
html += `<div style="color:${ends[i].color};">${ends[i].icon} ${label}: ${ends[i].text}</div>`;
});
crossings.slice(0, 3).forEach(c => {
const sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
const hint = sameName ? ' &mdash; <em>possible rename/reroute</em>' : '';
const hint = isRename(c) ? ' &mdash; <em>possible rename/reroute</em>' : '';
html += `<div style="color:#004085;">&#8644; Crosses "${c.name || 'unnamed'}"${hint}</div>`;
});
if (crossings.length > 3) html += `<div style="color:#004085;">&hellip; and ${crossings.length - 3} more</div>`;
if (feature._snapA || feature._segSnapA || feature._snapB || feature._segSnapB) {
html += '<div style="color:#777;margin-top:2px;">Click an orange/red end dot on the map to toggle its snap.</div>';
}
html += '</div>';
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 + ' </way>\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]) => ` <tag k="${esc(k)}" v="${esc(v)}"/>\n`).join('');
if (e.type === 'node') {
return ` <node id="${e.id}" action="modify" version="${e.version}" lat="${e.coord[1].toFixed(7)}" lon="${e.coord[0].toFixed(7)}">\n${tagXml} </node>\n`;
}
if (e.type === 'way') {
const nds = (e.nodes || []).map(ref => ` <nd ref="${ref}"/>\n`).join('');
return ` <way id="${e.id}" action="modify" version="${e.version}">\n${nds}${tagXml} </way>\n`;
}
const members = (e.members || []).map(m => ` <member type="${esc(m.type)}" ref="${m.ref}" role="${esc(m.role || '')}"/>\n`).join('');
return ` <relation id="${e.id}" action="modify" version="${e.version}">\n${members}${tagXml} </relation>\n`;
}
function nodeXml(n) {
let x = ` <node id="${n.id}" action="create" lat="${n.coord[1].toFixed(7)}" lon="${n.coord[0].toFixed(7)}"`;
const tagEntries = Object.entries(n.tags);
@@ -1890,6 +2141,9 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
newWays.forEach(w => { 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 += ` <way id="${w.id}" action="delete" version="${w.version}"/>\n`; });
deleteNodeIds.forEach((version, nid) => { xml += ` <node id="${nid}" action="delete" version="${version}"/>\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();
+4 -2
View File
@@ -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; }
</style>
</head>
<body>
@@ -175,7 +177,7 @@
<h3 style="margin-top: 10px;">Diff Filters</h3>
<label>
<input type="checkbox" id="showAdded" checked>
Show Added (Green)
Show Added (Green; darker = possible rename/reroute)
</label>
<label>
<input type="checkbox" id="showConflicted" checked>
@@ -183,7 +185,7 @@
</label>
<label>
<input type="checkbox" id="showRemoved">
Show Removed (Red)
Show Removed (Red; darker = possible rename)
</label>
<label>
<input type="checkbox" id="hideService">