Add exclusions feature: map popup Exclude button, exclusions page, server API

This commit is contained in:
zyphlar
2026-04-24 01:02:39 -07:00
parent 67f038e7f0
commit 8150e2ca1f
6 changed files with 507 additions and 1 deletions
+77 -1
View File
@@ -426,7 +426,7 @@ def serve_data(filename):
# ── Config file download / upload ──────────────────────────────────────────────
_ALLOWED_CONFIGS = {'counties.yml', 'exceptions.yml'}
_ALLOWED_CONFIGS = {'counties.yml', 'exceptions.yml', 'exclusions.yml'}
@app.route('/api/config/<filename>', methods=['GET'])
@require_auth
@@ -450,6 +450,8 @@ def upload_config(filename):
return jsonify({'error': 'Invalid counties.yml: missing counties list'}), 400
if filename == 'exceptions.yml' and not isinstance((data or {}).get('corrections'), list):
return jsonify({'error': 'Invalid exceptions.yml: missing corrections list'}), 400
if filename == 'exclusions.yml' and not isinstance((data or {}).get('exclusions'), list):
return jsonify({'error': 'Invalid exclusions.yml: missing exclusions list'}), 400
except Exception as e:
return jsonify({'error': f'Invalid YAML: {e}'}), 400
dest = f'/data/{filename}'
@@ -459,6 +461,80 @@ def upload_config(filename):
os.replace(tmp, dest)
return jsonify({'success': True})
# ── Exclusions config ─────────────────────────────────────────────────────────
EXCLUSIONS_FILE = '/data/exclusions.yml'
def _ensure_exclusions_file():
if not os.path.exists(EXCLUSIONS_FILE):
os.makedirs(os.path.dirname(EXCLUSIONS_FILE), exist_ok=True)
with open(EXCLUSIONS_FILE, 'w') as f:
yaml.dump({'exclusions': []}, f, default_flow_style=False, allow_unicode=True)
_ensure_exclusions_file()
def get_exclusions():
try:
with open(EXCLUSIONS_FILE) as f:
data = yaml.safe_load(f) or {}
return data.get('exclusions', [])
except Exception:
return []
@app.route('/exclusions')
def exclusions_page():
return render_template('exclusions.html', current_user=session.get('username'))
@app.route('/api/exclusions', methods=['GET'])
def get_exclusions_api():
return jsonify({'exclusions': get_exclusions()})
@app.route('/api/exclusions', methods=['POST'])
@require_auth
def save_exclusions():
data = request.json
items = data.get('exclusions', [])
for item in items:
if not isinstance(item.get('field'), str) or not item['field'].strip():
return jsonify({'error': 'Each exclusion must have a non-empty "field"'}), 400
if not isinstance(item.get('value'), str) or not item['value'].strip():
return jsonify({'error': 'Each exclusion must have a non-empty "value"'}), 400
tmp = EXCLUSIONS_FILE + '.tmp'
with open(tmp, 'w') as f:
yaml.dump({'exclusions': items}, f, default_flow_style=False, allow_unicode=True)
os.replace(tmp, EXCLUSIONS_FILE)
return jsonify({'success': True})
@app.route('/api/exclusions/add', methods=['POST'])
@require_auth
def add_exclusions():
"""Append new exclusion entries, skipping duplicates."""
data = request.json
new_items = data.get('exclusions', [])
existing = get_exclusions()
existing_keys = {(e['field'], e['value']) for e in existing}
added = 0
for item in new_items:
field = (item.get('field') or '').strip()
value = (item.get('value') or '').strip()
if not field or not value:
continue
if (field, value) not in existing_keys:
existing.append({'field': field, 'value': value})
existing_keys.add((field, value))
added += 1
tmp = EXCLUSIONS_FILE + '.tmp'
with open(tmp, 'w') as f:
yaml.dump({'exclusions': existing}, f, default_flow_style=False, allow_unicode=True)
os.replace(tmp, EXCLUSIONS_FILE)
return jsonify({'success': True, 'added': added})
# ── Counties config ────────────────────────────────────────────────────────────
COUNTIES_FILE = '/data/counties.yml'
+95
View File
@@ -13,6 +13,9 @@ let rejectedFeatures = new Set();
let featurePopup = null;
let layerOrder = ['diff', 'osm', 'county']; // Default layer order (top to bottom)
// Exclusions loaded from server
let exclusions = [];
// Drag selection state
let isDragging = false;
let dragStartPoint = null;
@@ -425,8 +428,19 @@ function createOsmLayer() {
updateLayerZIndex();
}
// Check if a feature matches any server-side exclusion
function isExcluded(feature) {
if (!exclusions.length) return false;
const props = feature.properties || {};
return exclusions.some(excl => {
const val = props[excl.field];
return val !== undefined && val !== null && String(val) === String(excl.value);
});
}
// Filter function for diff features
function shouldShowFeature(feature) {
if (isExcluded(feature)) return false;
const props = feature.properties || {};
const isRemoved = props.removed === true || props.removed === 'True';
const isService = props.highway === 'service' || props.highway === 'track';
@@ -803,6 +817,9 @@ function showMultiFeaturePopup(latlng) {
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="rejectAllFeatures()" style="flex: 1; padding: 5px; background: #6c757d; color: white; border: none; border-radius: 3px; cursor: pointer;">Reject${buttonText}</button>`;
html += '</div>';
html += '<div style="margin-top: 5px;">';
html += `<button onclick="excludeSelectedFeatures()" style="width: 100%; padding: 5px; background: #fd7e14; color: white; border: none; border-radius: 3px; cursor: pointer;">Exclude${buttonText} from future diffs</button>`;
html += '</div>';
}
}
@@ -899,9 +916,78 @@ function rejectAllFeatures() {
showStatus(`${acceptedFeatures.size} accepted, ${rejectedFeatures.size} rejected`, 'success');
}
// Exclude selected features by name/street and refresh diff layer
async function excludeSelectedFeatures() {
if (selectedFeatures.length === 0) return;
const toExclude = [];
const seen = new Set();
for (const feature of selectedFeatures) {
const props = feature.properties || {};
let field, value;
if (props['name']) {
field = 'name';
value = props['name'];
} else if (props['addr:street']) {
field = 'addr:street';
value = props['addr:street'];
} else {
continue;
}
const key = `${field}\0${value}`;
if (!seen.has(key)) {
seen.add(key);
toExclude.push({ field, value });
}
}
if (toExclude.length === 0) {
showStatus('No name or street property found to exclude by.', 'error');
return;
}
try {
const resp = await fetch('/api/exclusions/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclusions: toExclude })
});
const data = await resp.json();
if (data.error) {
if (resp.status === 401) {
showStatus('Not authenticated — please log in to exclude features.', 'error');
} else {
showStatus(`Error: ${data.error}`, 'error');
}
return;
}
} catch (err) {
showStatus(`Error: ${err.message}`, 'error');
return;
}
// Update local list and refresh
for (const e of toExclude) {
if (!exclusions.some(ex => ex.field === e.field && ex.value === e.value)) {
exclusions.push(e);
}
}
if (featurePopup) map.closePopup(featurePopup);
clearSelection();
createDiffLayer();
const names = toExclude.map(e => e.value).join(', ');
showStatus(`Excluded: ${names}`, 'success');
}
// Expose functions globally for onclick handlers
window.acceptAllFeatures = acceptAllFeatures;
window.rejectAllFeatures = rejectAllFeatures;
window.excludeSelectedFeatures = excludeSelectedFeatures;
// Update save button state
function updateSaveButton() {
@@ -931,6 +1017,15 @@ async function loadFiles() {
loadedCounty = county;
loadedDataType = dataType;
// Load exclusions
try {
const exclResp = await fetch('/api/exclusions');
if (exclResp.ok) {
const exclData = await exclResp.json();
exclusions = exclData.exclusions || [];
}
} catch (_) {}
// Build file paths based on county and data type
let osmFile, diffFile, countyFile, diffAddedFile, diffRemovedFile;
+1
View File
@@ -206,6 +206,7 @@
<div style="flex:1"></div>
<a href="/counties" class="btn-topbar active">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
</div>
+1
View File
@@ -205,6 +205,7 @@
<div style="flex:1"></div>
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar active">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
</div>
+332
View File
@@ -0,0 +1,332 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Exclusions — 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%; }
.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; }
.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 {
padding: 9px 20px;
font-size: 13px;
display: none;
border-bottom: 1px solid #eee;
}
#status.success { background: #d4edda; color: #155724; display: block; }
#status.error { background: #f8d7da; color: #721c24; display: block; }
#status.info { background: #d1ecf1; color: #0c5460; display: block; }
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, td select {
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 #fd7e14; margin-bottom: -2px; }
td select:focus { border-bottom: 2px solid #fd7e14; 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; }
.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: #fff3e0; color: #e65100; }
.btn-add:hover { background: #ffe0b2; }
.btn-save { background: #fd7e14; color: white; }
.btn-save:hover { filter: brightness(0.9); }
.btn-dl {
padding: 7px 12px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
background: #e9ecef;
color: #555;
text-decoration: none;
}
.btn-dl:hover { background: #dee2e6; }
</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="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar active">Exclusions</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>Diff Exclusions</h2>
<p>Features matching these field/value pairs are hidden from the map diff view. Add entries here or click Exclude in the map popup.</p>
</div>
</div>
<div id="status"></div>
<table id="exclusionsTable">
<thead>
<tr>
<th>Field</th>
<th>Value</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>
{% if current_user %}
<div style="flex:1"></div>
<a href="/api/config/exclusions.yml" class="btn-dl" title="Download exclusions.yml">&#x2193; Download</a>
<label class="btn-dl" style="cursor:pointer" title="Upload exclusions.yml">&#x2191; Upload<input type="file" style="display:none" accept=".yml,.yaml" onchange="uploadConfig(this)"></label>
{% endif %}
</div>
</div>
</div>
</div>
<script>
let exclusions = [];
const FIELD_OPTIONS = [
{ value: 'name', label: 'name (roads/paths)' },
{ value: 'addr:street', label: 'addr:street (addresses)' },
{ value: 'ref', label: 'ref' },
];
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = type;
if (type === 'success') setTimeout(() => el.style.display = 'none', 3000);
}
function fieldSelect(current, index) {
const opts = FIELD_OPTIONS.map(o =>
`<option value="${esc(o.value)}" ${o.value === current ? 'selected' : ''}>${esc(o.label)}</option>`
).join('');
// Also add the current value as an option if it's not in the list
const known = FIELD_OPTIONS.some(o => o.value === current);
const extra = (!known && current) ? `<option value="${esc(current)}" selected>${esc(current)}</option>` : '';
return `<select oninput="exclusions[${index}].field = this.value" style="width:100%;border:none;background:transparent;font-size:13px;color:#333;padding:3px 0;outline:none;font-family:inherit;">${extra}${opts}</select>`;
}
function renderTable() {
const tbody = document.getElementById('tableBody');
if (exclusions.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="padding:20px;text-align:center;color:#aaa;font-size:13px">No exclusions yet. Add a row or click Exclude in the map.</td></tr>';
return;
}
tbody.innerHTML = exclusions.map((e, i) => `
<tr data-index="${i}">
<td>${fieldSelect(e.field, i)}</td>
<td><input type="text" value="${esc(e.value)}" placeholder="e.g. State Road 471"
oninput="exclusions[${i}].value = 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() {
exclusions.push({ field: 'name', value: '' });
renderTable();
const rows = document.getElementById('tableBody').querySelectorAll('tr');
const last = rows[rows.length - 1];
if (last) last.querySelector('input').focus();
}
function deleteRow(i) {
exclusions.splice(i, 1);
renderTable();
}
function save() {
const rows = document.getElementById('tableBody').querySelectorAll('tr[data-index]');
rows.forEach(row => {
const i = parseInt(row.dataset.index);
const sel = row.querySelector('select');
const inp = row.querySelector('input');
if (sel) exclusions[i].field = sel.value;
if (inp) exclusions[i].value = inp.value;
});
for (const e of exclusions) {
if (!e.field.trim() || !e.value.trim()) {
showStatus('All rows must have both field and value filled in.', 'error');
return;
}
}
fetch('/api/exclusions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ exclusions })
})
.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'));
}
function uploadConfig(input) {
if (!input.files.length) return;
const formData = new FormData();
formData.append('file', input.files[0]);
showStatus('Uploading\u2026', 'info');
fetch('/api/config/exclusions.yml', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
if (data.error) { showStatus(`Error: ${data.error}`, 'error'); return; }
showStatus('Uploaded \u2014 reloading\u2026', 'success');
setTimeout(() => window.location.reload(), 800);
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'))
.finally(() => { input.value = ''; });
}
fetch('/api/exclusions')
.then(r => r.json())
.then(data => { exclusions = data.exclusions || []; 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
@@ -360,6 +360,7 @@
<div style="flex:1"></div>
<a href="/counties" class="btn-topbar">Counties</a>
<a href="/exceptions" class="btn-topbar">Street Exceptions</a>
<a href="/exclusions" class="btn-topbar">Exclusions</a>
<a href="/map" class="btn-topbar purple">Open Map Viewer</a>
<div class="topbar-sep"></div>
{% if current_user %}