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
## 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
### Using Docker Compose (Recommended)
@@ -47,6 +61,10 @@ docker stop 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
```bash
docker build -t osm-import-tools .
+64 -17
View File
@@ -57,6 +57,20 @@ class AddressComparator:
# 1 degree latitude ≈ 111,000 meters
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:
"""Get OSM area ID for a county using Nominatim."""
search_query = f"{county} County, {state}, USA"
@@ -231,6 +245,18 @@ out geom;"""
# Load and process shapefile
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
if gdf.crs and gdf.crs != 'EPSG:4326':
print(f"Converting from {gdf.crs} to EPSG:4326")
@@ -343,6 +369,13 @@ out geom;"""
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_fields = ['POST_COMM', 'PostalCity', 'CITY', 'Jurisdicti']
for field in city_fields:
@@ -375,6 +408,21 @@ out geom;"""
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:
"""
Normalize street names for better matching.
@@ -464,8 +512,9 @@ out geom;"""
distance = local_point.distance(osm_point)
# Verify house number, street, and unit (if present) match
local_house_num = str(local_row.get('addr:housenumber', ''))
osm_house_num = str(osm_row.get('addr:housenumber', ''))
# _normalize_multi sorts semicolon-delimited values so order doesn't matter
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
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 != '')
# Check unit match - if either has a unit, both must match
local_unit = local_row.get('addr:unit')
osm_unit = osm_row.get('addr:unit')
local_unit_norm = self._normalize_multi(local_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 = local_unit is not None and pd.notna(local_unit) and str(local_unit).strip() != ''
osm_has_unit = osm_unit is not None and pd.notna(osm_unit) and str(osm_unit).strip() != ''
local_has_unit = bool(local_unit_norm)
osm_has_unit = bool(osm_unit_norm)
if local_has_unit and osm_has_unit:
# Both have units - they must match
unit_match = (str(local_unit).strip().lower() == str(osm_unit).strip().lower())
# Both have units - they must match (order-insensitive)
unit_match = (local_unit_norm == osm_unit_norm)
elif local_has_unit or osm_has_unit:
# One has unit, other doesn't - no match
unit_match = False
@@ -538,21 +586,20 @@ out geom;"""
distance_meters = local_point.distance(osm_point) * 111000.0
# Verify house number, street, and unit (if present) match
local_house_num = str(local_row.get('addr:housenumber', ''))
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
local_street = self._normalize_street_name(str(local_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 != '')
# Check unit match (only if both have units specified)
local_unit = local_row.get('addr:unit')
osm_unit = osm_row.get('addr:unit')
# Check unit match (order-insensitive via _normalize_multi)
local_unit_norm = self._normalize_multi(local_row.get('addr:unit'))
osm_unit_norm = self._normalize_multi(osm_row.get('addr:unit'))
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):
# Both have units - they must match
unit_match = (str(local_unit).strip().lower() == str(osm_unit).strip().lower())
if local_unit_norm and osm_unit_norm:
unit_match = (local_unit_norm == osm_unit_norm)
if (distance_meters <= self.tolerance_meters and
local_house_num == osm_house_num and
+15 -6
View File
@@ -13,15 +13,9 @@ TODO:
- ignore points outside of lines
- put properties properly on removed roads, so they're visible in JOSM
- handle polygons properly (on previous geojson step?) for circular roads
- ignore roads that aren't LIFECYCLE ACTV or Active
- include OneWay=Y
- handle C 44a -> County Road 44A
- handle Tpke -> Turnpike
- handle Trce -> Trace/Terrace?
- handle Cor -> Corner
- handle Obrien -> O'Brien
- handle Oday -> O'Day
- Ohara -> O'Hara
"""
import json
@@ -462,6 +456,21 @@ class RoadComparator:
gdf1 = self.load_geojson(file1_path, filter_unnamed=self.exclude_unnamed)
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
if gdf1.crs != gdf2.crs:
print(f"Warning: CRS mismatch. Converting {file2_path} to match {file1_path}")
+16 -1
View File
@@ -8,11 +8,18 @@ import re
# 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):
return re.sub(
result = re.sub(
r"[A-Za-z0-9]+('[A-Za-z0-9]+)?",
lambda word: word.group(0).capitalize(),
s)
for pattern, replacement in _POST_TITLE_CORRECTIONS:
result = re.sub(pattern, replacement, result)
return result
# @qgsfunction(args='auto', group='Custom', referenced_columns=[])
def getstreetfromaddress(value1, feature, parent):
@@ -103,6 +110,8 @@ def formatstreetname(name):
if nameUp == "NW":
return "Northwest"
# Names
if nameUp == "FLORIDAS":
return "Florida's"
if nameUp == "MACLEAY":
return "MacLeay"
if nameUp == "MCCLAINE":
@@ -218,6 +227,8 @@ def formatstreetname(name):
return "Mount"
if nameUp == "MTN":
return "Mountain"
if nameUp == "COR":
return "Corner"
if nameUp == "PARK":
return "Park"
if nameUp == "PASS":
@@ -248,8 +259,12 @@ def formatstreetname(name):
return "Street"
if nameUp == "TER":
return "Terrace"
if nameUp == "TPKE":
return "Turnpike"
if nameUp == "TR":
return "Trail"
if nameUp == "TRCE":
return "Terrace"
if nameUp == "TRL":
return "Trail"
if nameUp == "VW":
+1
View File
@@ -1,4 +1,5 @@
Flask==3.0.0
pyyaml>=6.0
numpy<2.0.0
geopandas>=0.14.0
pandas>=2.1.0
+60
View File
@@ -8,6 +8,7 @@ import os
import threading
import json
from datetime import datetime
import yaml
app = Flask(__name__, static_folder='static', template_folder='templates')
@@ -295,5 +296,64 @@ def serve_data(filename):
"""Serve GeoJSON files"""
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__':
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
function shouldShowOsmFeature(feature) {
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 hideUnclassified = document.getElementById('hideUnclassified').checked;
if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true;
}
@@ -424,18 +427,21 @@ function createOsmLayer() {
function shouldShowFeature(feature) {
const props = feature.properties || {};
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 showRemoved = document.getElementById('showRemoved').checked;
const hideService = document.getElementById('hideService').checked;
const hideUnclassified = document.getElementById('hideUnclassified').checked;
// Check removed/added filter
if (isRemoved && !showRemoved) return false;
if (!isRemoved && !showAdded) return false;
// Check service filter
// Check service/track and unclassified filters
if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true;
}
@@ -504,10 +510,13 @@ function createDiffLayer() {
// Filter function for county features
function shouldShowCountyFeature(feature) {
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 hideUnclassified = document.getElementById('hideUnclassified').checked;
if (isService && hideService) return false;
if (isUnclassified && hideUnclassified) return false;
return true;
}
@@ -694,7 +703,20 @@ function showMultiFeaturePopup(latlng) {
'addr:postcode': 4,
'addr:state': 5,
'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) => {
@@ -1201,6 +1223,17 @@ document.addEventListener('DOMContentLoaded', function() {
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
document.getElementById('loadButton').addEventListener('click', function() {
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('ls', '')">List Files</button>
<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>
</div>
+6 -2
View File
@@ -294,7 +294,7 @@
</div>
<div class="layer-item" draggable="true" data-layer="osm">
<input type="checkbox" id="osmToggle" checked>
<span>OSM Roads (Gray)</span>
<span>OSM Layer (Gray)</span>
</div>
<div class="layer-item" draggable="true" data-layer="county">
<input type="checkbox" id="countyToggle">
@@ -313,7 +313,11 @@
</label>
<label>
<input type="checkbox" id="hideService">
Hide highway=service
Hide service &amp; track
</label>
<label>
<input type="checkbox" id="hideUnclassified">
Hide unclassified
</label>
<h3 style="margin-top: 15px;">Selection Mode</h3>