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: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.
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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, '&').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) {
|
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} — click to turn off`
|
||||||
|
: `${endLabel} snap is off — 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: '✗', color: '#dc3545', short: 'snap off', text: `snap off (would join ${target}, ${m} m)` };
|
||||||
const color = isEnabled ? '#155724' : '#6c757d';
|
|
||||||
return `<div style="color:${color};">✓ ${label}: snaps to ${typeStr} "${roadName}" (${m} m)${toggleBtn}</div>`;
|
|
||||||
}
|
}
|
||||||
if (segSnap) {
|
return { icon: '✓', 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};">✓ ${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;">✓ ${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 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(/—/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;">';
|
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) ? ' — <em>possible rename/reroute</em>' : '';
|
||||||
const hint = sameName ? ' — <em>possible rename/reroute</em>' : '';
|
|
||||||
html += `<div style="color:#004085;">⇄ Crosses "${c.name || 'unnamed'}"${hint}</div>`;
|
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 (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>';
|
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();
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user