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:postcode=33513
addr:state=FL addr:state=FL
addr:street=Hideaway Circle 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 - 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. - 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 from typing import Dict, List, Tuple
import geopandas as gpd import geopandas as gpd
import numpy as np
import pandas as pd import pandas as pd
from shapely.strtree import STRtree from shapely.strtree import STRtree
import warnings import warnings
@@ -28,6 +29,7 @@ warnings.filterwarnings('ignore')
CONFLICT_RADIUS_METERS = 5.0 CONFLICT_RADIUS_METERS = 5.0
RENAME_RADIUS_METERS = 100.0
class AddressComparator: class AddressComparator:
@@ -86,6 +88,53 @@ class AddressComparator:
break break
addr['conflict'] = conflict 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]]: def compare_addresses(self, local_file: str, osm_file: str) -> Tuple[List[Dict], List[Dict], List[Dict]]:
"""Compare local and OSM address data. """Compare local and OSM address data.
@@ -197,10 +246,17 @@ class AddressComparator:
for idx, osm_row in osm_gdf.iterrows(): for idx, osm_row in osm_gdf.iterrows():
if idx not in matched_osm: if idx not in matched_osm:
props = dict(osm_row.drop('geometry')) 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' props['status'] = 'removed'
removed_addresses.append({'geometry': osm_row.geometry, **props}) removed_addresses.append({'geometry': osm_row.geometry, **props})
self._flag_close_conflicts(new_addresses) 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 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, # Addresses: include nwr so building ways and relations are captured too,
# but use "out center;" instead of "out geom;" so ways/relations return a # but use "out center;" instead of "out geom;" so ways/relations return a
# single centroid rather than their full coordinate list — much smaller payload. # 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": if data_type == "addresses":
query = f"""[out:json][timeout:180]; query = f"""[out:json][timeout:180];
area(id:{area_id})->.searchArea; area(id:{area_id})->.searchArea;
nwr["addr:housenumber"](area.searchArea); nwr["addr:housenumber"](area.searchArea);
out center;""" out meta center;"""
return query return query
base_query = f"""[out:json][timeout:180]; base_query = f"""[out:json][timeout:180];
@@ -145,9 +147,13 @@ def convert_to_geojson(overpass_data):
if element["type"] == "node": if element["type"] == "node":
if not element.get("tags"): if not element.get("tags"):
continue # Skip topology-only nodes (no tags); versions collected above 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 = { feature = {
"type": "Feature", "type": "Feature",
"properties": element.get("tags", {}), "properties": props,
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [element["lon"], element["lat"]] "coordinates": [element["lon"], element["lat"]]
@@ -177,10 +183,20 @@ def convert_to_geojson(overpass_data):
features.append(feature) features.append(feature)
elif "center" in element: elif "center" in element:
# out center; — single centroid point (used for addresses on buildings) # 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"] 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 = { feature = {
"type": "Feature", "type": "Feature",
"properties": element.get("tags", {}), "properties": props,
"geometry": { "geometry": {
"type": "Point", "type": "Point",
"coordinates": [c["lon"], c["lat"]] "coordinates": [c["lon"], c["lat"]]
+22 -14
View File
@@ -10,6 +10,7 @@ import re
_POST_TITLE_CORRECTIONS = [ _POST_TITLE_CORRECTIONS = [
(r'\bUs\b', 'US'), # "US 441" not "Us 441" (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): def title(s):
@@ -51,23 +52,28 @@ def getunitfromaddress(value1, feature, parent):
# @qgsfunction(args='auto', group='Custom', referenced_columns=[]) # @qgsfunction(args='auto', group='Custom', referenced_columns=[])
def formatstreet(value1, feature, parent): def formatstreet(value1, feature, parent):
parts = value1.split() parts = value1.split()
# Handle the special case of a street name starting with "ST" # A leading directional ("N ST CLAIR AVE", "W C 48") doesn't change how
# which is almost always "Saint __" and not "Street __" # the word after it reads, so the rules below look past it.
if parts[0].upper() == "ST": first = 1 if len(parts) > 1 and parts[0].upper() in _DIRECTIONALS else 0
parts[0] = "Saint" for i, part in enumerate(parts):
if parts[0].upper() == "ROYAL" and parts[1].upper() == "ST": up = part.upper()
parts[0] = "Royal" has_next = i + 1 < len(parts)
parts[1] = "Saint" # "ST" starting a name and followed by more words is almost always
# And "CR" as a first part (County Road) vs last part (Circle) # "Saint __" ("ST CLAIR ST", "N ST LOUIS AVE"); elsewhere, or alone
if parts[0].upper() == "C ": # ("N ST"), it's "Street"
parts[0] = "County Road " if up == "ST" and has_next and (i == first or (i == 1 and parts[0].upper() == "ROYAL")):
if parts[0].upper() == "CR": parts[i] = "Saint"
parts[0] = "County Road" # "CR"/"C" before a route number is County Road ("CR 44A", "W C 48",
if parts[0].upper() == "SR": # "OLD C 470"); a trailing "CR" is Circle (handled by formatstreetname)
parts[0] = "State Road" 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) parts = map(formatstreetname, parts)
return " ".join(parts) return " ".join(parts)
_DIRECTIONALS = {'N', 'S', 'E', 'W', 'NE', 'NW', 'SE', 'SW'}
# @qgsfunction(args='auto', group='Custom', referenced_columns=[]) # @qgsfunction(args='auto', group='Custom', referenced_columns=[])
def formatname(value1, feature, parent): def formatname(value1, feature, parent):
@@ -256,6 +262,8 @@ def formatstreetname(name):
return "Path" return "Path"
if nameUp == "PKWY": if nameUp == "PKWY":
return "Parkway" return "Parkway"
if nameUp == "PKY":
return "Parkway"
if nameUp == "PL": if nameUp == "PL":
return "Place" return "Place"
if nameUp == "PLZ": 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 // can steal the click. This variant picks the nearest one instead (ties
// still go to the topmost). Otherwise mirrors Leaflet 1.9.4's // still go to the topmost). Otherwise mirrors Leaflet 1.9.4's
// Canvas._onClick / _handleMouseHover. // 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({ 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) { _nearestLayer: function(point, e) {
let best = null, bestDist = Infinity; let best = null, bestDist = Infinity;
for (let order = this._drawFirst; order; order = order.next) { for (let order = this._drawFirst; order; order = order.next) {
@@ -72,14 +89,14 @@ const NearestHitCanvas = L.Canvas.extend({
}, },
_onClick: function(e) { _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); this._fireEvent(clickedLayer ? [clickedLayer] : false, e);
}, },
_handleMouseHover: function(e, point) { _handleMouseHover: function(e, point) {
if (this._mouseHoverThrottled) return; if (this._mouseHoverThrottled) return;
const candidateHoveredLayer = this._nearestLayer(point); const candidateHoveredLayer = this._hitTest(point);
if (candidateHoveredLayer !== this._hoveredLayer) { if (candidateHoveredLayer !== this._hoveredLayer) {
this._handleMouseOut(e); this._handleMouseOut(e);
@@ -124,11 +141,18 @@ let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
let snapPreviewLayer = null; let snapPreviewLayer = null;
let popupLatlng = 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 // Initialize map
function initMap() { function initMap() {
map = initBaseMap('map', [28.7, -81.7], 12); map = initBaseMap('map', [28.7, -81.7], 12);
snapPreviewLayer = L.layerGroup().addTo(map); snapPreviewLayer = L.layerGroup().addTo(map);
hoverSnapLayer = L.layerGroup().addTo(map);
// Create custom panes for layer ordering // Create custom panes for layer ordering
map.createPane('osmPane'); map.createPane('osmPane');
@@ -414,20 +438,33 @@ function diffStyle(feature) {
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
const isConflicted = !isRemoved && feature.properties && const isConflicted = !isRemoved && feature.properties &&
(feature.properties.conflict === true || feature.properties.conflict === 'True'); (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 { return {
color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : '#00ff00'), color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : addedColor),
weight: 3, weight: 3,
opacity: 0.8 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) { function diffMarkerStyle(feature) {
// Check if feature is accepted or rejected // Check if feature is accepted or rejected
if (acceptedFeatures.has(feature)) { if (acceptedFeatures.has(feature)) {
// Darker blue: accepted as a street rename of the existing OSM address
const rename = !!feature._acceptRename;
return { return {
radius: 6, radius: 6,
fillColor: '#007bff', fillColor: rename ? '#0b3d91' : '#007bff',
color: '#0056b3', color: rename ? '#061f4a' : '#0056b3',
weight: 1, weight: 1,
opacity: 0.8, opacity: 0.8,
fillOpacity: 0.7 fillOpacity: 0.7
@@ -448,10 +485,17 @@ function diffMarkerStyle(feature) {
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True'); const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
const isConflicted = !isRemoved && feature.properties && const isConflicted = !isRemoved && feature.properties &&
(feature.properties.conflict === true || feature.properties.conflict === 'True'); (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 { return {
radius: 6, radius: 6,
fillColor: isRemoved ? '#ff0000' : (isConflicted ? '#ffeb3b' : '#00ff00'), fillColor: fill,
color: isRemoved ? '#cc0000' : (isConflicted ? '#c9a800' : '#00cc00'), color: stroke,
weight: 1, weight: 1,
opacity: 0.8, opacity: 0.8,
fillOpacity: 0.7 fillOpacity: 0.7
@@ -610,6 +654,7 @@ function createDiffLayer() {
layer.on('mouseover', function(e) { layer.on('mouseover', function(e) {
const isSelected = selectedLayers.some(l => l.layer === layer); const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isPoint) showHoverSnapDots(feature);
if (!isSelected) { if (!isSelected) {
if (isPoint) { if (isPoint) {
layer.setStyle({ layer.setStyle({
@@ -627,6 +672,7 @@ function createDiffLayer() {
layer.on('mouseout', function(e) { layer.on('mouseout', function(e) {
const isSelected = selectedLayers.some(l => l.layer === layer); const isSelected = selectedLayers.some(l => l.layer === layer);
if (!isPoint) scheduleHideHoverSnap();
if (!isSelected) { if (!isSelected) {
if (isPoint) { if (isPoint) {
layer.setStyle(diffMarkerStyle(feature)); layer.setStyle(diffMarkerStyle(feature));
@@ -924,6 +970,11 @@ function showMultiFeaturePopup(latlng) {
(selectedFeatures[0].properties.conflict === true || selectedFeatures[0].properties.conflict === 'True'); (selectedFeatures[0].properties.conflict === true || selectedFeatures[0].properties.conflict === 'True');
const statusLabel = isRemoved ? 'Removed (Red)' : (isConflicted ? 'Possibly Conflicted (Yellow)' : 'Added (Green)'); const statusLabel = isRemoved ? 'Removed (Red)' : (isConflicted ? 'Possibly Conflicted (Yellow)' : 'Added (Green)');
html += `<div style="margin-top: 8px;"><strong>Status:</strong> ${statusLabel}</div>`; 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) { if (!isRemoved && osmVertices.length > 0) {
html += connectivitySection(selectedFeatures[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>`; 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) { if (selectedFeatures.length > 1 && osmVertices.length > 0) {
selectedLayers.forEach(({ type }, i) => { selectedLayers.forEach(({ type }, i) => {
if (type !== 'diff') return; if (type !== 'diff') return;
const feat = selectedFeatures[i]; const feat = selectedFeatures[i];
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True'); const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True');
if (isRemoved) return; if (isRemoved) return;
const name = (feat.properties && feat.properties.name) || `Road ${i + 1}`; html += connectivitySection(feat, i, true);
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);
}); });
} }
} }
@@ -957,7 +1006,8 @@ function showMultiFeaturePopup(latlng) {
if (acceptedCount > 0 || rejectedCount > 0) { if (acceptedCount > 0 || rejectedCount > 0) {
html += '<div style="margin-top: 8px; font-size: 11px;">'; 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>`; if (rejectedCount > 0) html += `<div style="color: #4a4a4a;">✗ ${rejectedCount} rejected</div>`;
html += '</div>'; html += '</div>';
} }
@@ -968,6 +1018,11 @@ function showMultiFeaturePopup(latlng) {
const buttonText = selectedFeatures.length === 1 ? '' : ' All'; const buttonText = selectedFeatures.length === 1 ? '' : ' All';
html += '<div style="margin-top: 10px; display: flex; gap: 5px;">'; 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>`; 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 += `<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>';
html += '<div style="margin-top: 5px;">'; html += '<div style="margin-top: 5px;">';
@@ -1019,9 +1074,11 @@ function showMultiFeaturePopup(latlng) {
function acceptAllFeatures() { function acceptAllFeatures() {
if (selectedFeatures.length === 0) return; if (selectedFeatures.length === 0) return;
const unpaired = [];
selectedFeatures.forEach((feature, index) => { selectedFeatures.forEach((feature, index) => {
// Remove from rejected if present // Remove from rejected if present
rejectedFeatures.delete(feature); rejectedFeatures.delete(feature);
unpaired.push(...clearAcceptedRename(feature));
// Add to accepted // Add to accepted
acceptedFeatures.add(feature); acceptedFeatures.add(feature);
@@ -1042,6 +1099,8 @@ function acceptAllFeatures() {
} }
}); });
restyleDiffFeatures(unpaired);
// Close popup // Close popup
if (featurePopup) { if (featurePopup) {
map.closePopup(featurePopup); map.closePopup(featurePopup);
@@ -1060,9 +1119,11 @@ function acceptAllFeatures() {
function rejectAllFeatures() { function rejectAllFeatures() {
if (selectedFeatures.length === 0) return; if (selectedFeatures.length === 0) return;
const unpaired = [];
selectedFeatures.forEach((feature, index) => { selectedFeatures.forEach((feature, index) => {
// Remove from accepted if present // Remove from accepted if present
acceptedFeatures.delete(feature); acceptedFeatures.delete(feature);
unpaired.push(...clearAcceptedRename(feature));
// Add to rejected // Add to rejected
rejectedFeatures.add(feature); rejectedFeatures.add(feature);
@@ -1079,6 +1140,8 @@ function rejectAllFeatures() {
} }
}); });
restyleDiffFeatures(unpaired);
// Close popup // Close popup
if (featurePopup) { if (featurePopup) {
map.closePopup(featurePopup); map.closePopup(featurePopup);
@@ -1161,7 +1224,73 @@ async function excludeSelectedFeatures() {
showStatus(`Excluded: ${names}`, 'success'); 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 // Expose functions globally for onclick handlers
window.acceptRenameFeatures = acceptRenameFeatures;
window.acceptAllFeatures = acceptAllFeatures; window.acceptAllFeatures = acceptAllFeatures;
window.rejectAllFeatures = rejectAllFeatures; window.rejectAllFeatures = rejectAllFeatures;
window.excludeSelectedFeatures = excludeSelectedFeatures; window.excludeSelectedFeatures = excludeSelectedFeatures;
@@ -1253,6 +1382,45 @@ function segIntersect(p1, p2, p3, p4) {
return [p1[0] + t * d1x, p1[1] + t * d1y]; 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) { function findOsmCrossings(newCoords) {
if (!osmData) return []; if (!osmData) return [];
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity; let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
@@ -1386,8 +1554,8 @@ function countyPropsToOsmTags(props) {
// ---- Snap preview markers ---- // ---- Snap preview markers ----
// Draw snap preview for one feature onto snapPreviewLayer. // Draw snap preview for one feature onto snapPreviewLayer.
// interactive=true also adds clickable endpoint dots (only used for the selected feature). // interactive=true also adds clickable endpoint dots (selected or hovered features).
function drawSnapPreviewForFeature(feature, interactive) { function drawSnapPreviewForFeature(feature, interactive, target = snapPreviewLayer) {
if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return; if (!feature || !feature.geometry || feature.geometry.type !== 'LineString') return;
const rawCoords = feature.geometry.coordinates; 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; if (Math.hypot(finalCoord[0] - rawCoord[0], finalCoord[1] - rawCoord[1]) < 1e-8) return;
L.polyline([[rawCoord[1], rawCoord[0]], [finalCoord[1], finalCoord[0]]], { L.polyline([[rawCoord[1], rawCoord[0]], [finalCoord[1], finalCoord[0]]], {
color: '#ff6b00', weight: 3, opacity: 0.85, dashArray: '7,5', pane: 'snapPane' color: '#ff6b00', weight: 3, opacity: 0.85, dashArray: '7,5', pane: 'snapPane'
}).addTo(snapPreviewLayer); }).addTo(target);
}); });
if (!interactive) return; if (!interactive) return;
ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => { ends.forEach(({ isStart, snap, segSnap, enabledFlag, isEnabled, finalCoord }) => {
let color, canToggle; // Orange: this end snaps (matches the dashed connector); red: its snap
if (snap) { // is turned off; gray: nothing nearby to snap to.
color = isEnabled ? '#28a745' : '#dc3545'; canToggle = true; const canToggle = !!(snap || segSnap);
} else if (segSnap) { const color = !canToggle ? '#6c757d' : (isEnabled ? '#ff6b00' : '#dc3545');
color = isEnabled ? '#007bff' : '#dc3545'; canToggle = true; const endLabel = isStart ? 'Start' : 'End';
} else {
color = '#ffc107'; canToggle = false;
}
const marker = L.circleMarker([finalCoord[1], finalCoord[0]], { const marker = L.circleMarker([finalCoord[1], finalCoord[0]], {
radius: 8, fillColor: color, color: '#fff', weight: 2, fillOpacity: 0.9, pane: 'snapPane' 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) { if (canToggle) {
const target = snap ? `"${snap.name || 'unnamed road'}"` : 'the nearby road';
marker.bindTooltip( 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 } { sticky: true }
); );
marker.on('click', function(e) { marker.on('click', function(e) {
L.DomEvent.stopPropagation(e); L.DomEvent.stopPropagation(e);
feature[enabledFlag] = !isEnabled; feature[enabledFlag] = !isEnabled;
updateSnapPreview(); updateSnapPreview();
if (hoverSnapFeature === feature) showHoverSnapDots(feature);
if (popupLatlng) showMultiFeaturePopup(popupLatlng); if (popupLatlng) showMultiFeaturePopup(popupLatlng);
}); });
} else { } 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: // Redraw snap preview layer:
// - all accepted added features get a preview line when the checkbox is on // - 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 // - the currently selected diff added feature always gets the interactive dot markers
function updateSnapPreview() { function updateSnapPreview() {
if (!snapPreviewLayer) return; if (!snapPreviewLayer) return;
snapPreviewLayer.clearLayers(); 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; if (osmVertices.length === 0) return;
const showAll = document.getElementById('showSnapPreview')?.checked; const showAll = document.getElementById('showSnapPreview')?.checked;
@@ -1477,19 +1685,13 @@ function updateSnapPreview() {
}); });
} }
window.toggleSnapA = function(idx) { // Hovering a road's line in a multi-select popup highlights that road on the map.
idx = idx === undefined ? 0 : idx; window.highlightSelectedRoad = function(idx, on) {
if (idx >= selectedFeatures.length) return; const entry = selectedLayers[idx];
selectedFeatures[idx]._snapAEnabled = selectedFeatures[idx]._snapAEnabled !== false ? false : true; if (!entry || !entry.layer.setStyle) return;
updateSnapPreview(); entry.layer.setStyle(on ? { weight: 10, opacity: 1, color: '#00b8d4' }
if (popupLatlng) showMultiFeaturePopup(popupLatlng); : { weight: 6, opacity: 1, color: '#ffc107' });
}; if (on && entry.layer.bringToFront) entry.layer.bringToFront();
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);
}; };
window.importCountyFeature = function() { window.importCountyFeature = function() {
@@ -1514,17 +1716,17 @@ window.importCountyFeature = function() {
if (popupLatlng) showMultiFeaturePopup(popupLatlng); 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 (idx === undefined) idx = 0;
if (!feature.geometry || feature.geometry.type !== 'LineString') return ''; if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) { if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) {
cacheSnapInfo(feature); cacheSnapInfo(feature);
} }
const coords = feature.geometry.coordinates; 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) // Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
function findDiffConnection(coord) { function findDiffConnection(coord) {
@@ -1541,46 +1743,59 @@ function connectivitySection(feature, idx) {
return best; return best;
} }
function endpointHtml(label, snap, segSnap, toggleFn) { // {icon, color, text, short} describing one end of the road
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled'; function endpointInfo(isStart) {
const isEnabled = feature[enabledFlag] !== false; const snap = isStart ? feature._snapA : feature._snapB;
const toggleBtn = (snap || segSnap) const segSnap = isStart ? feature._segSnapA : feature._segSnapB;
? ` <button onclick="${toggleFn}(${idx})" style="font-size:10px;padding:1px 5px;cursor:pointer;">${isEnabled ? 'Disable snap' : 'Enable snap'}</button>` const isEnabled = feature[isStart ? '_snapAEnabled' : '_snapBEnabled'] !== false;
: ''; if (snap || segSnap) {
const m = Math.round((snap || segSnap).dist * 111320);
if (snap) { const target = snap
const isEndpoint = snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1; ? `${snap.posInWay === 0 || snap.posInWay === snap.wayLen - 1 ? 'endpoint' : 'midpoint'} of "${snap.name || 'unnamed road'}"`
const m = Math.round(snap.dist * 111320); : `"${(segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway)) || 'unnamed road'}" (new node)`;
const roadName = snap.name || 'unnamed road'; if (!isEnabled) {
const typeStr = isEndpoint ? 'endpoint of' : 'midpoint of'; return { icon: '&#10007;', color: '#dc3545', short: 'snap off', text: `snap off (would join ${target}, ${m} m)` };
const color = isEnabled ? '#155724' : '#6c757d';
return `<div style="color:${color};">&#10003; ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}</div>`;
} }
if (segSnap) { return { icon: '&#10003;', color: '#155724', short: 'snaps', text: `snaps to ${target} (${m} m)` };
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>`;
} }
const diffConn = findDiffConnection(label === 'Start' ? coords[0] : coords[coords.length - 1]); const diffConn = findDiffConnection(isStart ? coords[0] : coords[coords.length - 1]);
if (diffConn) { 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 crossings = findOsmCrossings(coords);
const newName = feature.properties && feature.properties.name; 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;">'; let html = '<div style="margin-top:8px;font-size:11px;">';
html += '<strong>Connectivity:</strong>'; html += '<strong>Connectivity:</strong>';
html += endpointHtml('Start', snapA, segA, 'toggleSnapA'); ['Start', 'End'].forEach((label, i) => {
html += endpointHtml('End', snapB, segB, 'toggleSnapB'); html += `<div style="color:${ends[i].color};">${ends[i].icon} ${label}: ${ends[i].text}</div>`;
});
crossings.slice(0, 3).forEach(c => { crossings.slice(0, 3).forEach(c => {
const sameName = newName && c.name && c.name.toLowerCase() === newName.toLowerCase(); const hint = isRename(c) ? ' &mdash; <em>possible rename/reroute</em>' : '';
const hint = sameName ? ' &mdash; <em>possible rename/reroute</em>' : '';
html += `<div style="color:#004085;">&#8644; Crosses "${c.name || 'unnamed'}"${hint}</div>`; 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 (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>'; html += '</div>';
return html; return html;
} }
@@ -1604,9 +1819,9 @@ const NON_OSM_PROPERTIES = new Set([
// map viewer state // map viewer state
'removed', 'status', 'accepted', 'removed', 'status', 'accepted',
// OSM identity/version info carried along from the Overpass download // 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 // 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 // Turn feature properties into an OSM tag map, dropping internal keys and
@@ -1619,6 +1834,16 @@ function osmTagsFrom(props) {
return tags; 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) { function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
const props = removedFeat.properties || {}; const props = removedFeat.properties || {};
const osmId = props.osm_id || props['@id']; const osmId = props.osm_id || props['@id'];
@@ -1627,6 +1852,15 @@ function computeRemovalPlan(removedFeat, acceptedAddedFeats, nodeRefCount) {
const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates; const wayCoords = removedFeat.geometry && removedFeat.geometry.coordinates;
if (removedFeat.geometry && removedFeat.geometry.type === 'Point') { 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 : ''}` }; return { type: 'deleteNode', osmId, osmVersion, description: `Delete node${osmId ? ' ' + osmId : ''}` };
} }
@@ -1735,7 +1969,7 @@ function showAutoFixModal(plans) {
// ---- JOSM Export: XML generation ---- // ---- JOSM Export: XML generation ----
function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) { function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices, elementEdits = []) {
const nodeRegistry = new Map(); // "lon7,lat7" → id const nodeRegistry = new Map(); // "lon7,lat7" → id
const referencedExisting = new Map(); // nodeId (positive) → {coord, version} const referencedExisting = new Map(); // nodeId (positive) → {coord, version}
let nextNewId = -1; let nextNewId = -1;
@@ -1825,6 +2059,8 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
if (plan.type === 'deleteNode') { if (plan.type === 'deleteNode') {
deleteNodeIds.set(plan.osmId, plan.osmVersion || 1); deleteNodeIds.set(plan.osmId, plan.osmVersion || 1);
} else if (plan.type === 'stripAddress') {
elementEdits.push(plan.edit);
} else if (plan.type === 'delete') { } else if (plan.type === 'delete') {
deleteWayIds.push({ id: plan.osmId, version: plan.osmVersion || 1 }); deleteWayIds.push({ id: plan.osmId, version: plan.osmVersion || 1 });
if (plan.deleteNodeIds) plan.deleteNodeIds.forEach(nid => deleteNodeIds.set(nid, osmNodeVersionById.get(nid) || 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'; 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) { function nodeXml(n) {
let x = ` <node id="${n.id}" action="create" lat="${n.coord[1].toFixed(7)}" lon="${n.coord[0].toFixed(7)}"`; 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); const tagEntries = Object.entries(n.tags);
@@ -1890,6 +2141,9 @@ function generateOsmXml(acceptedAdded, removalPlans, checkedPlanIndices) {
newWays.forEach(w => { xml += wayXml(w, 'create'); }); newWays.forEach(w => { xml += wayXml(w, 'create'); });
modifiedWays.forEach(w => { xml += wayXml(w, 'modify'); }); 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'); }); splitTailWays.forEach(w => { xml += wayXml(w, 'create'); });
deleteWayIds.forEach(w => { xml += ` <way id="${w.id}" action="delete" version="${w.version}"/>\n`; }); 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`; }); deleteNodeIds.forEach((version, nid) => { xml += ` <node id="${nid}" action="delete" version="${version}"/>\n`; });
@@ -1906,8 +2160,18 @@ async function exportToOsm() {
const acceptedAdded = []; const acceptedAdded = [];
const acceptedRemoved = []; const acceptedRemoved = [];
const renameEdits = [];
acceptedFeatures.forEach(feat => { acceptedFeatures.forEach(feat => {
const isRemoved = feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True'); 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); if (isRemoved) acceptedRemoved.push(feat);
else acceptedAdded.push(feat); else acceptedAdded.push(feat);
}); });
@@ -1936,7 +2200,7 @@ async function exportToOsm() {
checkedPlanIndices = new Set(removalPlans.keys()); 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 dataDate = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const filename = `${loadedCounty || 'osm'}-${loadedDataType || 'export'}-${dataDate}.osm`; const filename = `${loadedCounty || 'osm'}-${loadedDataType || 'export'}-${dataDate}.osm`;
@@ -1950,7 +2214,7 @@ async function exportToOsm() {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); 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; window.exportToOsm = exportToOsm;
@@ -2008,7 +2272,7 @@ async function loadFiles() {
osmFile = `${county}/osm-addresses.geojson`; osmFile = `${county}/osm-addresses.geojson`;
diffAddedFile = `${county}/addresses-to-add.geojson`; diffAddedFile = `${county}/addresses-to-add.geojson`;
diffRemovedFile = `${county}/addresses-potentially-removed.geojson`; diffRemovedFile = `${county}/addresses-potentially-removed.geojson`;
countyFile = `${county}/addresses.shp_converted.geojson`; countyFile = `${county}/county-addresses.geojson`;
} }
// Load files from server // Load files from server
@@ -2062,6 +2326,7 @@ async function loadFiles() {
// Create layers // Create layers
createOsmLayer(); createOsmLayer();
buildOsmNodeIndex(); buildOsmNodeIndex();
markPossibleRenames();
createDiffLayer(); createDiffLayer();
createCountyLayer(); createCountyLayer();
+4 -2
View File
@@ -130,6 +130,8 @@
.leaflet-container.multi-select-active.leaflet-grab { .leaflet-container.multi-select-active.leaflet-grab {
cursor: crosshair !important; cursor: crosshair !important;
} }
/* One line per road in a multi-select popup; hover highlights it on the map */
.conn-row:hover { background: #e0f7fa; }
</style> </style>
</head> </head>
<body> <body>
@@ -175,7 +177,7 @@
<h3 style="margin-top: 10px;">Diff Filters</h3> <h3 style="margin-top: 10px;">Diff Filters</h3>
<label> <label>
<input type="checkbox" id="showAdded" checked> <input type="checkbox" id="showAdded" checked>
Show Added (Green) Show Added (Green; darker = possible rename/reroute)
</label> </label>
<label> <label>
<input type="checkbox" id="showConflicted" checked> <input type="checkbox" id="showConflicted" checked>
@@ -183,7 +185,7 @@
</label> </label>
<label> <label>
<input type="checkbox" id="showRemoved"> <input type="checkbox" id="showRemoved">
Show Removed (Red) Show Removed (Red; darker = possible rename)
</label> </label>
<label> <label>
<input type="checkbox" id="hideService"> <input type="checkbox" id="hideService">