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:
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
+342
-77
@@ -55,7 +55,24 @@ function distanceToLayer(layer, p) {
|
||||
// can steal the click. This variant picks the nearest one instead (ties
|
||||
// still go to the topmost). Otherwise mirrors Leaflet 1.9.4's
|
||||
// Canvas._onClick / _handleMouseHover.
|
||||
//
|
||||
// Each road layer also has its own canvas in its own pane (so the layer-order
|
||||
// controls can restack them), and the topmost canvas covers the whole map, so
|
||||
// it receives every mouse event. Whichever canvas gets the event therefore
|
||||
// hit-tests every pane, topmost first, falling through to the next pane down
|
||||
// only when nothing in the one above is within tolerance.
|
||||
const NearestHitCanvas = L.Canvas.extend({
|
||||
_hitTest: function(point, e) {
|
||||
const renderers = Object.values(paneRenderers)
|
||||
.filter(r => r._map)
|
||||
.sort((a, b) => (parseInt(b.getPane().style.zIndex, 10) || 0) - (parseInt(a.getPane().style.zIndex, 10) || 0));
|
||||
for (const r of renderers) {
|
||||
const layer = r._nearestLayer(point, e);
|
||||
if (layer) return layer;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
_nearestLayer: function(point, e) {
|
||||
let best = null, bestDist = Infinity;
|
||||
for (let order = this._drawFirst; order; order = order.next) {
|
||||
@@ -72,14 +89,14 @@ const NearestHitCanvas = L.Canvas.extend({
|
||||
},
|
||||
|
||||
_onClick: function(e) {
|
||||
const clickedLayer = this._nearestLayer(this._map.mouseEventToLayerPoint(e), e);
|
||||
const clickedLayer = this._hitTest(this._map.mouseEventToLayerPoint(e), e);
|
||||
this._fireEvent(clickedLayer ? [clickedLayer] : false, e);
|
||||
},
|
||||
|
||||
_handleMouseHover: function(e, point) {
|
||||
if (this._mouseHoverThrottled) return;
|
||||
|
||||
const candidateHoveredLayer = this._nearestLayer(point);
|
||||
const candidateHoveredLayer = this._hitTest(point);
|
||||
|
||||
if (candidateHoveredLayer !== this._hoveredLayer) {
|
||||
this._handleMouseOut(e);
|
||||
@@ -124,11 +141,18 @@ let SNAP_TOL = 10 / 111320; // default 10m in degrees; updated from UI
|
||||
let snapPreviewLayer = null;
|
||||
let popupLatlng = null;
|
||||
|
||||
// Snap end dots for the road under the mouse (any added road, selected or
|
||||
// not), so an end can be toggled without opening a popup that may cover it.
|
||||
let hoverSnapLayer = null;
|
||||
let hoverSnapFeature = null;
|
||||
let hoverSnapHideTimer = null;
|
||||
|
||||
// Initialize map
|
||||
function initMap() {
|
||||
map = initBaseMap('map', [28.7, -81.7], 12);
|
||||
|
||||
snapPreviewLayer = L.layerGroup().addTo(map);
|
||||
hoverSnapLayer = L.layerGroup().addTo(map);
|
||||
|
||||
// Create custom panes for layer ordering
|
||||
map.createPane('osmPane');
|
||||
@@ -414,20 +438,33 @@ function diffStyle(feature) {
|
||||
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
||||
const isConflicted = !isRemoved && feature.properties &&
|
||||
(feature.properties.conflict === true || feature.properties.conflict === 'True');
|
||||
// Medium green: crosses an existing OSM road of the same name (see markPossibleRenames)
|
||||
const addedColor = feature._possibleRename ? '#1f9e3a' : '#00ff00';
|
||||
return {
|
||||
color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : '#00ff00'),
|
||||
color: isRemoved ? '#ff0000' : (isConflicted ? '#c9a800' : addedColor),
|
||||
weight: 3,
|
||||
opacity: 0.8
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function hasPossibleRename(feature) {
|
||||
const v = feature.properties && feature.properties.possible_rename;
|
||||
return typeof v === 'string' && v !== '' && v !== 'None' && v !== 'nan';
|
||||
}
|
||||
|
||||
function diffMarkerStyle(feature) {
|
||||
// Check if feature is accepted or rejected
|
||||
if (acceptedFeatures.has(feature)) {
|
||||
// Darker blue: accepted as a street rename of the existing OSM address
|
||||
const rename = !!feature._acceptRename;
|
||||
return {
|
||||
radius: 6,
|
||||
fillColor: '#007bff',
|
||||
color: '#0056b3',
|
||||
fillColor: rename ? '#0b3d91' : '#007bff',
|
||||
color: rename ? '#061f4a' : '#0056b3',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.7
|
||||
@@ -448,10 +485,17 @@ function diffMarkerStyle(feature) {
|
||||
const isRemoved = feature.properties && (feature.properties.removed === true || feature.properties.removed === 'True');
|
||||
const isConflicted = !isRemoved && feature.properties &&
|
||||
(feature.properties.conflict === true || feature.properties.conflict === 'True');
|
||||
// Darker: same house number nearby on a differently named street (see
|
||||
// compare-addresses.py _flag_possible_renames)
|
||||
const rename = hasPossibleRename(feature);
|
||||
const fill = isRemoved ? (rename ? '#a00000' : '#ff0000')
|
||||
: (isConflicted ? '#ffeb3b' : (rename ? '#1f9e3a' : '#00ff00'));
|
||||
const stroke = isRemoved ? (rename ? '#600000' : '#cc0000')
|
||||
: (isConflicted ? '#c9a800' : (rename ? '#0f5c21' : '#00cc00'));
|
||||
return {
|
||||
radius: 6,
|
||||
fillColor: isRemoved ? '#ff0000' : (isConflicted ? '#ffeb3b' : '#00ff00'),
|
||||
color: isRemoved ? '#cc0000' : (isConflicted ? '#c9a800' : '#00cc00'),
|
||||
fillColor: fill,
|
||||
color: stroke,
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.7
|
||||
@@ -610,6 +654,7 @@ function createDiffLayer() {
|
||||
|
||||
layer.on('mouseover', function(e) {
|
||||
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||
if (!isPoint) showHoverSnapDots(feature);
|
||||
if (!isSelected) {
|
||||
if (isPoint) {
|
||||
layer.setStyle({
|
||||
@@ -627,6 +672,7 @@ function createDiffLayer() {
|
||||
|
||||
layer.on('mouseout', function(e) {
|
||||
const isSelected = selectedLayers.some(l => l.layer === layer);
|
||||
if (!isPoint) scheduleHideHoverSnap();
|
||||
if (!isSelected) {
|
||||
if (isPoint) {
|
||||
layer.setStyle(diffMarkerStyle(feature));
|
||||
@@ -924,6 +970,11 @@ function showMultiFeaturePopup(latlng) {
|
||||
(selectedFeatures[0].properties.conflict === true || selectedFeatures[0].properties.conflict === 'True');
|
||||
const statusLabel = isRemoved ? 'Removed (Red)' : (isConflicted ? 'Possibly Conflicted (Yellow)' : 'Added (Green)');
|
||||
html += `<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} — click to turn off`
|
||||
: `${endLabel} snap is off — click to snap to ${target}`,
|
||||
{ sticky: true }
|
||||
);
|
||||
marker.on('click', function(e) {
|
||||
L.DomEvent.stopPropagation(e);
|
||||
feature[enabledFlag] = !isEnabled;
|
||||
updateSnapPreview();
|
||||
if (hoverSnapFeature === feature) showHoverSnapDots(feature);
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
});
|
||||
} else {
|
||||
marker.bindTooltip(`${isStart ? 'Start' : 'End'}: dangling`, { sticky: true });
|
||||
marker.bindTooltip(`${endLabel}: no nearby road to snap to`, { sticky: true });
|
||||
}
|
||||
snapPreviewLayer.addLayer(marker);
|
||||
target.addLayer(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function isRemovedFeature(feat) {
|
||||
return !!(feat.properties && (feat.properties.removed === true || feat.properties.removed === 'True'));
|
||||
}
|
||||
|
||||
// Show clickable end dots for a hovered added road. Selected roads already
|
||||
// have theirs on snapPreviewLayer.
|
||||
function showHoverSnapDots(feature) {
|
||||
if (!hoverSnapLayer) return;
|
||||
clearTimeout(hoverSnapHideTimer);
|
||||
hoverSnapLayer.clearLayers();
|
||||
hoverSnapFeature = null;
|
||||
if (osmVertices.length === 0 || !feature.geometry || feature.geometry.type !== 'LineString') return;
|
||||
if (isRemovedFeature(feature) || selectedFeatures.includes(feature)) return;
|
||||
if (feature._snapA === undefined || feature._snapTol !== SNAP_TOL) cacheSnapInfo(feature);
|
||||
hoverSnapFeature = feature;
|
||||
drawSnapPreviewForFeature(feature, true, hoverSnapLayer);
|
||||
}
|
||||
|
||||
// Linger briefly so the mouse can travel from the road onto one of its dots.
|
||||
function scheduleHideHoverSnap() {
|
||||
clearTimeout(hoverSnapHideTimer);
|
||||
hoverSnapHideTimer = setTimeout(() => {
|
||||
if (hoverSnapLayer) hoverSnapLayer.clearLayers();
|
||||
hoverSnapFeature = null;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
// Redraw snap preview layer:
|
||||
// - all accepted added features get a preview line when the checkbox is on
|
||||
// - the currently selected diff added feature always gets the interactive dot markers
|
||||
function updateSnapPreview() {
|
||||
if (!snapPreviewLayer) return;
|
||||
snapPreviewLayer.clearLayers();
|
||||
// A road that just became selected gets its dots from here instead.
|
||||
if (hoverSnapFeature && selectedFeatures.includes(hoverSnapFeature)) {
|
||||
hoverSnapLayer.clearLayers();
|
||||
hoverSnapFeature = null;
|
||||
}
|
||||
if (osmVertices.length === 0) return;
|
||||
|
||||
const showAll = document.getElementById('showSnapPreview')?.checked;
|
||||
@@ -1477,19 +1685,13 @@ function updateSnapPreview() {
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleSnapA = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapAEnabled = selectedFeatures[idx]._snapAEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
window.toggleSnapB = function(idx) {
|
||||
idx = idx === undefined ? 0 : idx;
|
||||
if (idx >= selectedFeatures.length) return;
|
||||
selectedFeatures[idx]._snapBEnabled = selectedFeatures[idx]._snapBEnabled !== false ? false : true;
|
||||
updateSnapPreview();
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
// Hovering a road's line in a multi-select popup highlights that road on the map.
|
||||
window.highlightSelectedRoad = function(idx, on) {
|
||||
const entry = selectedLayers[idx];
|
||||
if (!entry || !entry.layer.setStyle) return;
|
||||
entry.layer.setStyle(on ? { weight: 10, opacity: 1, color: '#00b8d4' }
|
||||
: { weight: 6, opacity: 1, color: '#ffc107' });
|
||||
if (on && entry.layer.bringToFront) entry.layer.bringToFront();
|
||||
};
|
||||
|
||||
window.importCountyFeature = function() {
|
||||
@@ -1514,17 +1716,17 @@ window.importCountyFeature = function() {
|
||||
if (popupLatlng) showMultiFeaturePopup(popupLatlng);
|
||||
};
|
||||
|
||||
function connectivitySection(feature, idx) {
|
||||
// Connectivity summary for an added road. Snapping at each end is toggled by
|
||||
// clicking the end's dot on the map, not from here. With `compact` (used for
|
||||
// each road in a multi-selection) it is one line; hovering that line
|
||||
// highlights the road on the map.
|
||||
function connectivitySection(feature, idx, compact) {
|
||||
if (idx === undefined) idx = 0;
|
||||
if (!feature.geometry || feature.geometry.type !== 'LineString') return '';
|
||||
if (osmVertices.length > 0 && (feature._snapA === undefined || feature._snapTol !== SNAP_TOL)) {
|
||||
cacheSnapInfo(feature);
|
||||
}
|
||||
const coords = feature.geometry.coordinates;
|
||||
const snapA = feature._snapA;
|
||||
const snapB = feature._snapB;
|
||||
const segA = feature._segSnapA;
|
||||
const segB = feature._segSnapB;
|
||||
|
||||
// Check if an endpoint connects to another new diff road (shares coordinate within tolerance)
|
||||
function findDiffConnection(coord) {
|
||||
@@ -1541,46 +1743,59 @@ function connectivitySection(feature, idx) {
|
||||
return best;
|
||||
}
|
||||
|
||||
function endpointHtml(label, snap, segSnap, toggleFn) {
|
||||
const enabledFlag = toggleFn === 'toggleSnapA' ? '_snapAEnabled' : '_snapBEnabled';
|
||||
const isEnabled = feature[enabledFlag] !== false;
|
||||
const toggleBtn = (snap || segSnap)
|
||||
? ` <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};">✓ ${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: '✗', color: '#dc3545', short: 'snap off', text: `snap off (would join ${target}, ${m} m)` };
|
||||
}
|
||||
return { icon: '✓', color: '#155724', short: 'snaps', text: `snaps to ${target} (${m} m)` };
|
||||
}
|
||||
if (segSnap) {
|
||||
const m = Math.round(segSnap.dist * 111320);
|
||||
const roadName = segSnap.feat.properties && (segSnap.feat.properties.name || segSnap.feat.properties.highway) || 'unnamed road';
|
||||
const color = isEnabled ? '#155724' : '#6c757d';
|
||||
return `<div style="color:${color};">✓ ${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) {
|
||||
return `<div style="color:#155724;">✓ ${label}: connects to new "${diffConn}"</div>`;
|
||||
return { icon: '✓', color: '#155724', short: 'joins new', text: `connects to new "${diffConn}"` };
|
||||
}
|
||||
return `<div style="color:#856404;">⚠ ${label}: no nearby road — dangling</div>`;
|
||||
return { icon: '⚠', color: '#856404', short: 'dangling', text: 'no nearby road — dangling' };
|
||||
}
|
||||
|
||||
const ends = [endpointInfo(true), endpointInfo(false)];
|
||||
const crossings = findOsmCrossings(coords);
|
||||
const newName = feature.properties && feature.properties.name;
|
||||
const isRename = c => newName && c.name && c.name.toLowerCase() === newName.toLowerCase();
|
||||
|
||||
if (compact) {
|
||||
const name = newName || `Road ${idx + 1}`;
|
||||
const title = ['Start', 'End'].map((l, i) => `${l}: ${ends[i].text}`).join('\n').replace(/—/g, '-').replace(/&[^;]+;/g, '');
|
||||
let line = ['S', 'E'].map((l, i) =>
|
||||
`<span style="color:${ends[i].color};white-space:nowrap;">${ends[i].icon} ${l}: ${ends[i].short}</span>`).join(' · ');
|
||||
if (crossings.length) line += ` · <span style="color:#004085;white-space:nowrap;">⇄ ${crossings.length}</span>`;
|
||||
if (crossings.some(isRename)) line += ' · <em style="color:#1f9e3a;">possible rename</em>';
|
||||
return `<div class="conn-row" title="${title.replace(/"/g, '"')}" ` +
|
||||
`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 ? ' — <em>possible rename/reroute</em>' : '';
|
||||
const hint = isRename(c) ? ' — <em>possible rename/reroute</em>' : '';
|
||||
html += `<div style="color:#004085;">⇄ Crosses "${c.name || 'unnamed'}"${hint}</div>`;
|
||||
});
|
||||
if (crossings.length > 3) html += `<div style="color:#004085;">… 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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user