Add LIFECYCLE filtering, street exceptions UI, address matching improvements, map viewer fixes

This commit is contained in:
zyphlar
2026-04-09 09:36:14 -07:00
parent 449a182fa4
commit e8267b1eee
10 changed files with 544 additions and 33 deletions
+18
View File
@@ -1,5 +1,19 @@
# OSM Import Tools # OSM Import Tools
## Using
### Roads
### Addresses
Run after Roads to reduce validation errors. Easiest to hide existing (gray) and removed (red) and just focus on what new addresses to add.
Open in JOSM, and download the affected areas from OSM including highways with this Overpass query:
### Paths
Only Sumter currently has paths available.
## Docker Quick Start ## Docker Quick Start
### Using Docker Compose (Recommended) ### Using Docker Compose (Recommended)
@@ -47,6 +61,10 @@ docker stop osm-import-tools
docker rm osm-import-tools docker rm osm-import-tools
``` ```
### Deploying with Portainer
Just push to git and set up a pull from git; Portainer can build and run the project directly.
### Uploading to a registry ### Uploading to a registry
```bash ```bash
docker build -t osm-import-tools . docker build -t osm-import-tools .
+66 -19
View File
@@ -57,6 +57,20 @@ class AddressComparator:
# 1 degree latitude ≈ 111,000 meters # 1 degree latitude ≈ 111,000 meters
self.tolerance_deg = tolerance_meters / 111000.0 self.tolerance_deg = tolerance_meters / 111000.0
# Load street name exceptions from YAML
exceptions_path = Path('/data/exceptions.yml')
if exceptions_path.exists():
import yaml
with open(exceptions_path) as f:
data = yaml.safe_load(f) or {}
self._exceptions = {
item['from']: item['to']
for item in data.get('corrections', [])
if 'from' in item and 'to' in item
}
else:
self._exceptions = {}
def _get_county_area_id(self, county: str, state: str) -> int: def _get_county_area_id(self, county: str, state: str) -> int:
"""Get OSM area ID for a county using Nominatim.""" """Get OSM area ID for a county using Nominatim."""
search_query = f"{county} County, {state}, USA" search_query = f"{county} County, {state}, USA"
@@ -230,12 +244,24 @@ out geom;"""
# Load and process shapefile # Load and process shapefile
gdf = gpd.read_file(shp_file) gdf = gpd.read_file(shp_file)
# Filter to active records only (Sumter uses LIFECYCLE field)
LIFECYCLE_FIELD = 'LIFECYCLE'
ACTIVE_VALUE = 'Current'
if LIFECYCLE_FIELD in gdf.columns:
before = len(gdf)
gdf = gdf[gdf[LIFECYCLE_FIELD] == ACTIVE_VALUE].copy().reset_index(drop=True)
filtered = before - len(gdf)
if filtered:
print(f"Filtered out {filtered} non-active addresses (LIFECYCLE != '{ACTIVE_VALUE}')")
else:
print(f"All {len(gdf)} addresses are active (LIFECYCLE == '{ACTIVE_VALUE}')")
# Convert CRS to WGS84 if needed # Convert CRS to WGS84 if needed
if gdf.crs and gdf.crs != 'EPSG:4326': if gdf.crs and gdf.crs != 'EPSG:4326':
print(f"Converting from {gdf.crs} to EPSG:4326") print(f"Converting from {gdf.crs} to EPSG:4326")
gdf = gdf.to_crs('EPSG:4326') gdf = gdf.to_crs('EPSG:4326')
# Process address fields using existing logic from sumter-address-convert.py # Process address fields using existing logic from sumter-address-convert.py
gdf = self._process_address_fields(gdf) gdf = self._process_address_fields(gdf)
@@ -343,6 +369,13 @@ out geom;"""
address_mapping['addr:street'] = street_names address_mapping['addr:street'] = street_names
# Apply street name exceptions (e.g. county data quirks fixed for comparison/import)
if 'addr:street' in address_mapping and self._exceptions:
address_mapping['addr:street'] = [
self._exceptions.get(name, name) if name is not None else None
for name in address_mapping['addr:street']
]
# City - try multiple field names # City - try multiple field names
city_fields = ['POST_COMM', 'PostalCity', 'CITY', 'Jurisdicti'] city_fields = ['POST_COMM', 'PostalCity', 'CITY', 'Jurisdicti']
for field in city_fields: for field in city_fields:
@@ -375,6 +408,21 @@ out geom;"""
return processed_gdf return processed_gdf
@staticmethod
def _normalize_multi(value) -> str:
"""Normalize a semicolon-delimited field by sorting its parts.
Treats '101;201;301' and '301;101;201' as equal — only real
content differences (not ordering) count as added/removed.
"""
if value is None or (isinstance(value, float) and pd.isna(value)):
return ''
s = str(value).strip()
if not s or s == 'nan':
return ''
parts = sorted(p.strip().lower() for p in s.split(';') if p.strip())
return ';'.join(parts)
def _normalize_street_name(self, street: str) -> str: def _normalize_street_name(self, street: str) -> str:
""" """
Normalize street names for better matching. Normalize street names for better matching.
@@ -464,8 +512,9 @@ out geom;"""
distance = local_point.distance(osm_point) distance = local_point.distance(osm_point)
# Verify house number, street, and unit (if present) match # Verify house number, street, and unit (if present) match
local_house_num = str(local_row.get('addr:housenumber', '')) # _normalize_multi sorts semicolon-delimited values so order doesn't matter
osm_house_num = str(osm_row.get('addr:housenumber', '')) local_house_num = self._normalize_multi(local_row.get('addr:housenumber', ''))
osm_house_num = self._normalize_multi(osm_row.get('addr:housenumber', ''))
# Check street name match (required) - use normalization for better matching # Check street name match (required) - use normalization for better matching
local_street = self._normalize_street_name(str(local_row.get('addr:street', ''))) local_street = self._normalize_street_name(str(local_row.get('addr:street', '')))
@@ -473,16 +522,15 @@ out geom;"""
street_match = (local_street == osm_street and local_street != '') street_match = (local_street == osm_street and local_street != '')
# Check unit match - if either has a unit, both must match # Check unit match - if either has a unit, both must match
local_unit = local_row.get('addr:unit') local_unit_norm = self._normalize_multi(local_row.get('addr:unit'))
osm_unit = osm_row.get('addr:unit') osm_unit_norm = self._normalize_multi(osm_row.get('addr:unit'))
# Determine if each side has a unit local_has_unit = bool(local_unit_norm)
local_has_unit = local_unit is not None and pd.notna(local_unit) and str(local_unit).strip() != '' osm_has_unit = bool(osm_unit_norm)
osm_has_unit = osm_unit is not None and pd.notna(osm_unit) and str(osm_unit).strip() != ''
if local_has_unit and osm_has_unit: if local_has_unit and osm_has_unit:
# Both have units - they must match # Both have units - they must match (order-insensitive)
unit_match = (str(local_unit).strip().lower() == str(osm_unit).strip().lower()) unit_match = (local_unit_norm == osm_unit_norm)
elif local_has_unit or osm_has_unit: elif local_has_unit or osm_has_unit:
# One has unit, other doesn't - no match # One has unit, other doesn't - no match
unit_match = False unit_match = False
@@ -538,21 +586,20 @@ out geom;"""
distance_meters = local_point.distance(osm_point) * 111000.0 distance_meters = local_point.distance(osm_point) * 111000.0
# Verify house number, street, and unit (if present) match # Verify house number, street, and unit (if present) match
local_house_num = str(local_row.get('addr:housenumber', '')) local_house_num = self._normalize_multi(local_row.get('addr:housenumber', ''))
osm_house_num = str(osm_row.get('addr:housenumber', '')) osm_house_num = self._normalize_multi(osm_row.get('addr:housenumber', ''))
# Check street name match (required) - use normalization for better matching # Check street name match (required) - use normalization for better matching
local_street = self._normalize_street_name(str(local_row.get('addr:street', ''))) local_street = self._normalize_street_name(str(local_row.get('addr:street', '')))
osm_street = self._normalize_street_name(str(osm_row.get('addr:street', ''))) osm_street = self._normalize_street_name(str(osm_row.get('addr:street', '')))
street_match = (local_street == osm_street and local_street != '') street_match = (local_street == osm_street and local_street != '')
# Check unit match (only if both have units specified) # Check unit match (order-insensitive via _normalize_multi)
local_unit = local_row.get('addr:unit') local_unit_norm = self._normalize_multi(local_row.get('addr:unit'))
osm_unit = osm_row.get('addr:unit') osm_unit_norm = self._normalize_multi(osm_row.get('addr:unit'))
unit_match = True unit_match = True
if local_unit is not None and pd.notna(local_unit) and osm_unit is not None and pd.notna(osm_unit): if local_unit_norm and osm_unit_norm:
# Both have units - they must match unit_match = (local_unit_norm == osm_unit_norm)
unit_match = (str(local_unit).strip().lower() == str(osm_unit).strip().lower())
if (distance_meters <= self.tolerance_meters and if (distance_meters <= self.tolerance_meters and
local_house_num == osm_house_num and local_house_num == osm_house_num and
+15 -6
View File
@@ -13,15 +13,9 @@ TODO:
- ignore points outside of lines - ignore points outside of lines
- put properties properly on removed roads, so they're visible in JOSM - put properties properly on removed roads, so they're visible in JOSM
- handle polygons properly (on previous geojson step?) for circular roads - handle polygons properly (on previous geojson step?) for circular roads
- ignore roads that aren't LIFECYCLE ACTV or Active
- include OneWay=Y - include OneWay=Y
- handle C 44a -> County Road 44A - handle C 44a -> County Road 44A
- handle Tpke -> Turnpike
- handle Trce -> Trace/Terrace? - handle Trce -> Trace/Terrace?
- handle Cor -> Corner
- handle Obrien -> O'Brien
- handle Oday -> O'Day
- Ohara -> O'Hara
""" """
import json import json
@@ -461,6 +455,21 @@ class RoadComparator:
# Filter unnamed features from file1 (OSM data) if exclude_unnamed is set # Filter unnamed features from file1 (OSM data) if exclude_unnamed is set
gdf1 = self.load_geojson(file1_path, filter_unnamed=self.exclude_unnamed) gdf1 = self.load_geojson(file1_path, filter_unnamed=self.exclude_unnamed)
gdf2 = self.load_geojson(file2_path) gdf2 = self.load_geojson(file2_path)
# Filter county data (gdf2) to active/current roads only.
# Sumter uses LIFECYCLE='ACTV'. Lake has no lifecycle field — skip silently.
LIFECYCLE_FIELD = 'LIFECYCLE'
ACTIVE_VALUES = {'ACTV', 'Active', 'ACTIVE', 'Current', 'CURRENT'}
if LIFECYCLE_FIELD in gdf2.columns:
before = len(gdf2)
gdf2 = gdf2[gdf2[LIFECYCLE_FIELD].isin(ACTIVE_VALUES)].copy().reset_index(drop=True)
filtered = before - len(gdf2)
if filtered:
print(f"Filtered out {filtered} non-active county roads (LIFECYCLE not in {ACTIVE_VALUES})")
else:
print(f"All {len(gdf2)} county roads are active (LIFECYCLE={gdf2[LIFECYCLE_FIELD].iloc[0] if len(gdf2) else 'n/a'})")
else:
print(f"Note: county data has no {LIFECYCLE_FIELD!r} field — no lifecycle filtering applied")
# Ensure both are in the same CRS # Ensure both are in the same CRS
if gdf1.crs != gdf2.crs: if gdf1.crs != gdf2.crs:
+16 -1
View File
@@ -8,11 +8,18 @@ import re
# or >1 suffix-letters, like 12th Street or 243rd Ave. # or >1 suffix-letters, like 12th Street or 243rd Ave.
# #
_POST_TITLE_CORRECTIONS = [
(r'\bUs\b', 'US'), # "US 441" not "Us 441"
]
def title(s): def title(s):
return re.sub( result = re.sub(
r"[A-Za-z0-9]+('[A-Za-z0-9]+)?", r"[A-Za-z0-9]+('[A-Za-z0-9]+)?",
lambda word: word.group(0).capitalize(), lambda word: word.group(0).capitalize(),
s) s)
for pattern, replacement in _POST_TITLE_CORRECTIONS:
result = re.sub(pattern, replacement, result)
return result
# @qgsfunction(args='auto', group='Custom', referenced_columns=[]) # @qgsfunction(args='auto', group='Custom', referenced_columns=[])
def getstreetfromaddress(value1, feature, parent): def getstreetfromaddress(value1, feature, parent):
@@ -103,6 +110,8 @@ def formatstreetname(name):
if nameUp == "NW": if nameUp == "NW":
return "Northwest" return "Northwest"
# Names # Names
if nameUp == "FLORIDAS":
return "Florida's"
if nameUp == "MACLEAY": if nameUp == "MACLEAY":
return "MacLeay" return "MacLeay"
if nameUp == "MCCLAINE": if nameUp == "MCCLAINE":
@@ -218,6 +227,8 @@ def formatstreetname(name):
return "Mount" return "Mount"
if nameUp == "MTN": if nameUp == "MTN":
return "Mountain" return "Mountain"
if nameUp == "COR":
return "Corner"
if nameUp == "PARK": if nameUp == "PARK":
return "Park" return "Park"
if nameUp == "PASS": if nameUp == "PASS":
@@ -248,8 +259,12 @@ def formatstreetname(name):
return "Street" return "Street"
if nameUp == "TER": if nameUp == "TER":
return "Terrace" return "Terrace"
if nameUp == "TPKE":
return "Turnpike"
if nameUp == "TR": if nameUp == "TR":
return "Trail" return "Trail"
if nameUp == "TRCE":
return "Terrace"
if nameUp == "TRL": if nameUp == "TRL":
return "Trail" return "Trail"
if nameUp == "VW": if nameUp == "VW":
+1
View File
@@ -1,4 +1,5 @@
Flask==3.0.0 Flask==3.0.0
pyyaml>=6.0
numpy<2.0.0 numpy<2.0.0
geopandas>=0.14.0 geopandas>=0.14.0
pandas>=2.1.0 pandas>=2.1.0
+60
View File
@@ -8,6 +8,7 @@ import os
import threading import threading
import json import json
from datetime import datetime from datetime import datetime
import yaml
app = Flask(__name__, static_folder='static', template_folder='templates') app = Flask(__name__, static_folder='static', template_folder='templates')
@@ -295,5 +296,64 @@ def serve_data(filename):
"""Serve GeoJSON files""" """Serve GeoJSON files"""
return send_from_directory('/data', filename) return send_from_directory('/data', filename)
EXCEPTIONS_FILE = '/data/exceptions.yml'
DEFAULT_EXCEPTIONS = [
{'from': "D Angelo Lane", 'to': "D'Angelo Lane"},
{'from': "Lajolla Circle", 'to': "La Jolla Circle"},
{'from': "Gardena Court", 'to': "Gardenia Court"},
{'from': "Glenmont Court", 'to': "Glenmount Court"},
{'from': "Pawleys Island Path", 'to': "Pawley's Island Path"},
{'from': "Oday Street", 'to': "O'Day Street"},
{'from': "Obrien Place", 'to': "O'Brien Place"},
{'from': "Ohara Court", 'to': "O'Hara Court"},
]
def _ensure_exceptions_file():
if not os.path.exists(EXCEPTIONS_FILE):
os.makedirs(os.path.dirname(EXCEPTIONS_FILE), exist_ok=True)
with open(EXCEPTIONS_FILE, 'w') as f:
yaml.dump({'corrections': DEFAULT_EXCEPTIONS}, f,
default_flow_style=False, allow_unicode=True)
_ensure_exceptions_file()
@app.route('/exceptions')
def exceptions_page():
return render_template('exceptions.html')
@app.route('/api/exceptions', methods=['GET'])
def get_exceptions():
try:
with open(EXCEPTIONS_FILE) as f:
data = yaml.safe_load(f) or {}
return jsonify({'corrections': data.get('corrections', [])})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/exceptions', methods=['POST'])
def save_exceptions():
data = request.json
corrections = data.get('corrections', [])
# Validate
for item in corrections:
if not isinstance(item.get('from'), str) or not item['from'].strip():
return jsonify({'error': 'Each correction must have a non-empty "from" field'}), 400
if not isinstance(item.get('to'), str) or not item['to'].strip():
return jsonify({'error': 'Each correction must have a non-empty "to" field'}), 400
# Atomic write
tmp = EXCEPTIONS_FILE + '.tmp'
with open(tmp, 'w') as f:
yaml.dump({'corrections': corrections}, f,
default_flow_style=False, allow_unicode=True)
os.replace(tmp, EXCEPTIONS_FILE)
return jsonify({'success': True})
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)
+38 -5
View File
@@ -352,10 +352,13 @@ function countyMarkerStyle(feature) {
// Filter function for OSM features // Filter function for OSM features
function shouldShowOsmFeature(feature) { function shouldShowOsmFeature(feature) {
const props = feature.properties || {}; const props = feature.properties || {};
const isService = props.highway === 'service'; const isService = props.highway === 'service' || props.highway === 'track';
const isUnclassified = props.highway === 'unclassified';
const hideService = document.getElementById('hideService').checked; const hideService = document.getElementById('hideService').checked;
const hideUnclassified = document.getElementById('hideUnclassified').checked;
if (isService && hideService) return false; if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true; return true;
} }
@@ -424,18 +427,21 @@ function createOsmLayer() {
function shouldShowFeature(feature) { function shouldShowFeature(feature) {
const props = feature.properties || {}; const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True'; const isRemoved = props.removed === true || props.removed === 'True';
const isService = props.highway === 'service'; const isService = props.highway === 'service' || props.highway === 'track';
const isUnclassified = props.highway === 'unclassified';
const showAdded = document.getElementById('showAdded').checked; const showAdded = document.getElementById('showAdded').checked;
const showRemoved = document.getElementById('showRemoved').checked; const showRemoved = document.getElementById('showRemoved').checked;
const hideService = document.getElementById('hideService').checked; const hideService = document.getElementById('hideService').checked;
const hideUnclassified = document.getElementById('hideUnclassified').checked;
// Check removed/added filter // Check removed/added filter
if (isRemoved && !showRemoved) return false; if (isRemoved && !showRemoved) return false;
if (!isRemoved && !showAdded) return false; if (!isRemoved && !showAdded) return false;
// Check service filter // Check service/track and unclassified filters
if (isService && hideService) return false; if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true; return true;
} }
@@ -504,10 +510,13 @@ function createDiffLayer() {
// Filter function for county features // Filter function for county features
function shouldShowCountyFeature(feature) { function shouldShowCountyFeature(feature) {
const props = feature.properties || {}; const props = feature.properties || {};
const isService = props.highway === 'service'; const isService = props.highway === 'service' || props.highway === 'track';
const isUnclassified = props.highway === 'unclassified';
const hideService = document.getElementById('hideService').checked; const hideService = document.getElementById('hideService').checked;
const hideUnclassified = document.getElementById('hideUnclassified').checked;
if (isService && hideService) return false; if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true; return true;
} }
@@ -694,7 +703,20 @@ function showMultiFeaturePopup(latlng) {
'addr:postcode': 4, 'addr:postcode': 4,
'addr:state': 5, 'addr:state': 5,
'name': 10, 'name': 10,
'highway': 11 'highway': 11,
// County road fields
'Lifecycle': 20,
'LIFECYCLE': 20,
'FullStreetN': 21,
'FullName': 21,
'StreetClass': 22,
'STRCLASS': 22,
'SpeedLimit': 23,
'SPEEDLIMIT': 23,
'LeftCity': 24,
'RightCity': 25,
'LeftZip': 26,
'RightZip': 27,
}; };
const sortedKeys = Array.from(allKeys).sort((a, b) => { const sortedKeys = Array.from(allKeys).sort((a, b) => {
@@ -1201,6 +1223,17 @@ document.addEventListener('DOMContentLoaded', function() {
createCountyLayer(); createCountyLayer();
}); });
document.getElementById('hideUnclassified').addEventListener('change', function() {
createDiffLayer();
createOsmLayer();
createCountyLayer();
});
// Click on empty map area closes any open popup and clears selection
map.on('click', function() {
clearSelection();
});
// Load button // Load button
document.getElementById('loadButton').addEventListener('click', function() { document.getElementById('loadButton').addEventListener('click', function() {
loadFiles(); loadFiles();
+323
View File
@@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Street Exceptions — OSM Import Tools</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #eef0f3;
color: #222;
}
.page { display: flex; flex-direction: column; height: 100%; }
/* ── Top bar (matches index.html) ── */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #1a1a2e;
flex-shrink: 0;
}
.topbar h1 { font-size: 16px; font-weight: 700; color: #fff; }
.topbar-sep { width: 1px; height: 20px; background: #ffffff30; }
.btn-topbar {
padding: 5px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
background: #ffffff18;
color: #ddd;
text-decoration: none;
}
.btn-topbar:hover { background: #ffffff30; }
.btn-topbar.active { background: #ffffff30; color: #fff; }
.btn-topbar.purple { background: #6f42c1; color: white; }
.btn-topbar.purple:hover { background: #5a32a3; }
/* ── Main content ── */
.content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
max-width: 860px;
overflow: hidden;
}
.card-header {
padding: 14px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
font-size: 15px;
font-weight: 700;
color: #333;
}
.card-header p {
font-size: 12px;
color: #888;
margin-top: 2px;
}
/* ── Status bar ── */
#status {
padding: 9px 20px;
font-size: 13px;
display: none;
border-bottom: 1px solid #eee;
}
#status.success { background: #d4edda; color: #155724; }
#status.error { background: #f8d7da; color: #721c24; }
#status.info { background: #d1ecf1; color: #0c5460; }
/* ── Table ── */
table {
width: 100%;
border-collapse: collapse;
}
thead tr {
background: #f8f9fa;
border-bottom: 2px solid #eee;
}
th {
padding: 9px 14px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #888;
text-align: left;
}
th:last-child { width: 48px; text-align: center; }
tbody tr { border-bottom: 1px solid #f3f3f3; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: #fafafa; }
td { padding: 6px 14px; }
td input {
width: 100%;
border: none;
background: transparent;
font-size: 13px;
color: #333;
padding: 3px 0;
outline: none;
font-family: inherit;
}
td input:focus {
border-bottom: 2px solid #17a2b8;
margin-bottom: -2px;
}
td input::placeholder { color: #ccc; }
td:last-child { text-align: center; padding: 6px 8px; }
.btn-delete {
background: none;
border: none;
cursor: pointer;
color: #ccc;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: 3px;
}
.btn-delete:hover { color: #dc3545; background: #fdecea; }
/* ── Footer actions ── */
.card-footer {
padding: 12px 20px;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
align-items: center;
}
.btn {
padding: 7px 16px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.btn-add { background: #e8f5e9; color: #2e7d32; }
.btn-add:hover { background: #c8e6c9; }
.btn-save { background: #17a2b8; color: white; }
.btn-save:hover { filter: brightness(0.9); }
</style>
</head>
<body>
<div class="page">
<div class="topbar">
<h1>OSM Import Tools</h1>
<div class="topbar-sep"></div>
<a href="/" class="btn-topbar">&#8592; Back</a>
<div style="flex:1"></div>
<a href="/exceptions" class="btn-topbar active">Street Exceptions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
</div>
<div class="content">
<div class="card">
<div class="card-header">
<div>
<h2>Street Name Exceptions</h2>
<p>Applied to county address data after formatting, before comparison and import. Match is exact (case-sensitive).</p>
</div>
</div>
<div id="status"></div>
<table id="exceptionsTable">
<thead>
<tr>
<th>County data (from)</th>
<th>Corrected name (to)</th>
<th></th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="3" style="padding:20px;text-align:center;color:#aaa;font-size:13px">Loading...</td></tr>
</tbody>
</table>
<div class="card-footer">
<button class="btn btn-add" onclick="addRow()">+ Add row</button>
<button class="btn btn-save" onclick="save()">Save</button>
</div>
</div>
</div>
</div>
<script>
let corrections = [];
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = type;
el.style.display = 'block';
if (type === 'success') setTimeout(() => el.style.display = 'none', 3000);
}
function renderTable() {
const tbody = document.getElementById('tableBody');
if (corrections.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="padding:20px;text-align:center;color:#aaa;font-size:13px">No exceptions yet. Add a row to get started.</td></tr>';
return;
}
tbody.innerHTML = corrections.map((c, i) => `
<tr data-index="${i}">
<td><input type="text" value="${esc(c.from)}" placeholder="County street name"
oninput="corrections[${i}].from = this.value"></td>
<td><input type="text" value="${esc(c.to)}" placeholder="Corrected street name"
oninput="corrections[${i}].to = this.value"></td>
<td><button class="btn-delete" onclick="deleteRow(${i})" title="Delete">&#x2715;</button></td>
</tr>
`).join('');
}
function esc(s) {
return (s || '').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
}
function addRow() {
corrections.push({ from: '', to: '' });
renderTable();
// Focus the new "from" input
const rows = document.getElementById('tableBody').querySelectorAll('tr');
const lastRow = rows[rows.length - 1];
if (lastRow) lastRow.querySelector('input').focus();
}
function deleteRow(i) {
corrections.splice(i, 1);
renderTable();
}
function save() {
// Collect current input values (in case user typed without triggering oninput)
const rows = document.getElementById('tableBody').querySelectorAll('tr[data-index]');
rows.forEach(row => {
const i = parseInt(row.dataset.index);
const inputs = row.querySelectorAll('input');
corrections[i].from = inputs[0].value;
corrections[i].to = inputs[1].value;
});
// Validate
for (const c of corrections) {
if (!c.from.trim() || !c.to.trim()) {
showStatus('All rows must have both "from" and "to" values filled in.', 'error');
return;
}
}
fetch('/api/exceptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ corrections })
})
.then(r => r.json())
.then(data => {
if (data.error) {
showStatus(`Error: ${data.error}`, 'error');
} else {
showStatus('Saved successfully.', 'success');
}
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'));
}
// Load on page open
fetch('/api/exceptions')
.then(r => r.json())
.then(data => {
corrections = data.corrections || [];
renderTable();
})
.catch(err => {
document.getElementById('tableBody').innerHTML =
`<tr><td colspan="3" style="padding:20px;text-align:center;color:#c00">Failed to load: ${err.message}</td></tr>`;
});
</script>
</body>
</html>
+1
View File
@@ -330,6 +330,7 @@
<button class="btn-topbar" onclick="runScript('make-new-latest', '')">New Latest</button> <button class="btn-topbar" onclick="runScript('make-new-latest', '')">New Latest</button>
<button class="btn-topbar" onclick="runScript('ls', '')">List Files</button> <button class="btn-topbar" onclick="runScript('ls', '')">List Files</button>
<div style="flex:1"></div> <div style="flex:1"></div>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a> <a href="/map" class="btn-topbar purple">Open Map Viewer</a>
</div> </div>
+6 -2
View File
@@ -294,7 +294,7 @@
</div> </div>
<div class="layer-item" draggable="true" data-layer="osm"> <div class="layer-item" draggable="true" data-layer="osm">
<input type="checkbox" id="osmToggle" checked> <input type="checkbox" id="osmToggle" checked>
<span>OSM Roads (Gray)</span> <span>OSM Layer (Gray)</span>
</div> </div>
<div class="layer-item" draggable="true" data-layer="county"> <div class="layer-item" draggable="true" data-layer="county">
<input type="checkbox" id="countyToggle"> <input type="checkbox" id="countyToggle">
@@ -313,7 +313,11 @@
</label> </label>
<label> <label>
<input type="checkbox" id="hideService"> <input type="checkbox" id="hideService">
Hide highway=service Hide service &amp; track
</label>
<label>
<input type="checkbox" id="hideUnclassified">
Hide unclassified
</label> </label>
<h3 style="margin-top: 15px;">Selection Mode</h3> <h3 style="margin-top: 15px;">Selection Mode</h3>