Files
osm-import-tools/web/static/report-map.js
T
2026-08-28 14:23:26 -07:00

598 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// City Boundaries report. Two independent views over the same data:
// - Agreement Status: county vs OSM addr:city agreement, color-coded.
// - City Names: what one source (county or OSM) thinks each address's city
// is, color-coded by identity - a visual read of what that source's
// "boundaries" look like. No agreement analysis in this view.
const STATUS_CONFIG = {
match: { color: '#28a745', label: 'Match' },
mismatch: { color: '#dc3545', label: 'Mismatch' },
county_only: { color: '#fd7e14', label: 'Missing in OSM' },
osm_only: { color: '#17a2b8', label: 'Missing in county' },
both_missing: { color: '#6c757d', label: 'Both missing' },
};
// Categorical identity palette (dataviz skill reference instance, fixed hue
// order). Only city names among the 8 most common in this dataset get their
// own hue - the rest fold into "Other" per the skill's rule for identity
// channels with more categories than a palette can keep pairwise-distinct.
// Every marker is still identifiable exactly via its popup and the legend's
// text labels, so color is never the only channel carrying identity.
const CITY_PALETTE = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
const CITY_OTHER_COLOR = '#898781';
const CITY_OTHER_LABEL = 'Other';
const CITY_UNKNOWN_COLOR = '#c3c2b7';
const CITY_UNKNOWN_LABEL = '(no city)';
const CITY_UNKNOWN_KEY = '__unknown__';
const CITY_OTHER_KEY = '__other__';
// Boundary Check view: point-in-polygon results from map-checker/check-boundary.py
// against the traced/imported boundary at /data/map-checker/boundary.geojson.
// Independent of the county-vs-OSM tag comparison above - this is a separate
// data source (an explicit reference boundary) checked with actual geometry
// rather than by comparing text fields.
const FLAG_CONFIG = {
inside_but_not_labeled: { color: '#dc3545', label: "Inside boundary, not labeled expected city" },
outside_but_labeled: { color: '#fd7e14', label: "Outside boundary, labeled expected city" },
};
let boundaryOutlineLayer = null; // L.geoJSON of the traced boundary polygon(s)
let boundaryFlagLayers = {}; // flag -> L.layerGroup
let boundaryLoaded = false; // avoid re-fetching every time the tab is switched
let boundaryJobId = null;
let boundaryLogInterval = null;
let map;
let statusLayers = {}; // status -> L.layerGroup
let cityLayers = {}; // city name (or CITY_OTHER_KEY/CITY_UNKNOWN_KEY) -> L.layerGroup
let currentGeojson = null;
let currentView = 'agreement'; // 'agreement' | 'city-names' | 'boundary-check'
let compareField = 'postal_community'; // 'postal_community' | 'city' - which county attribute to compare against OSM
let citySource = 'county'; // 'county' | 'osm' - which side to color by, in the City Names view
let cityColorMap = {}; // canonical (lowercased) name -> hex, stable across county/osm toggle
let cityLabelMap = {}; // canonical name -> display label (most common raw casing)
let currentCountyId = null;
// The county-side property name for the currently selected Compare field.
function countyFieldName() {
return compareField === 'city' ? 'county_city' : 'county_postal_community';
}
// The status property name (precomputed server-side) for the current field.
function statusFieldName() {
return compareField === 'city' ? 'status_city' : 'status_postal_community';
}
// The property actually driving color in the City Names view right now.
function activeCityField() {
return citySource === 'osm' ? 'osm_city' : countyFieldName();
}
function initMap() {
map = initBaseMap('map', [28.7, -81.7], 11);
Object.keys(STATUS_CONFIG).forEach(status => {
statusLayers[status] = L.layerGroup();
});
Object.keys(FLAG_CONFIG).forEach(flag => {
boundaryFlagLayers[flag] = L.layerGroup();
});
}
function markerStyle(color) {
return {
radius: 6,
fillColor: color,
color: '#fff',
weight: 1,
opacity: 1,
fillOpacity: 0.85,
};
}
function escapeHtml(s) {
if (s === null || s === undefined) return '';
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function popupHtml(feature) {
const p = feature.properties || {};
const activeStatus = p[statusFieldName()];
const cfg = STATUS_CONFIG[activeStatus] || {};
const compareLabel = compareField === 'city' ? 'city' : 'postal community';
const addrParts = [p['addr:housenumber'], p['addr:street']].filter(Boolean).join(' ');
const unit = p['addr:unit'] ? ` Unit ${escapeHtml(p['addr:unit'])}` : '';
let html = '<div style="font-size:12px;">';
html += `<div style="font-weight:600;margin-bottom:4px;">${escapeHtml(addrParts) || '(no address)'}${unit}</div>`;
html += `<div><strong>County postal community:</strong> ${escapeHtml(p.county_postal_community) || '<em>none</em>'}</div>`;
html += `<div><strong>County city:</strong> ${escapeHtml(p.county_city) || '<em>none</em>'}</div>`;
html += `<div><strong>OSM city:</strong> ${escapeHtml(p.osm_city) || '<em>none</em>'}</div>`;
html += `<div style="margin-top:6px;"><strong>Agreement (vs ${compareLabel}):</strong> ${escapeHtml(cfg.label || activeStatus)}</div>`;
if (p.distance_meters !== undefined && p.distance_meters !== null) {
html += `<div><strong>Match distance:</strong> ${Number(p.distance_meters).toFixed(1)} m</div>`;
}
html += '</div>';
return html;
}
// ---- Agreement Status view ----
function updateStatusCounts(counts) {
Object.keys(STATUS_CONFIG).forEach(status => {
const el = document.getElementById(`count-${status}`);
if (el) el.textContent = counts[status] || 0;
});
}
function renderStatusLayers(geojson) {
Object.values(statusLayers).forEach(layer => layer.clearLayers());
const field = statusFieldName();
const counts = {};
geojson.features.forEach(feature => {
const status = feature.properties && feature.properties[field];
const cfg = STATUS_CONFIG[status] || STATUS_CONFIG.both_missing;
const layer = statusLayers[status] || statusLayers.both_missing;
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(cfg.color));
marker.bindPopup(popupHtml(feature), { maxWidth: 280 });
layer.addLayer(marker);
counts[status] = (counts[status] || 0) + 1;
});
updateStatusCounts(counts);
}
function applyStatusLayerVisibility() {
document.querySelectorAll('#legend .legend-item').forEach(item => {
const status = item.dataset.status;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = statusLayers[status];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
// ---- City Names view ----
function cityKey(name) {
const trimmed = name && String(name).trim();
return trimmed ? trimmed : null;
}
// Case-insensitive grouping key so source data noise like "Clermont" vs
// "CLermont" doesn't fragment into separate colors/legend rows.
function canonicalKey(rawName) {
return rawName ? rawName.toLowerCase() : null;
}
// Colors are assigned once per dataset load, ranked by combined frequency
// across BOTH the active county field (postal community or city, per the
// Compare dropdown) and osm_city. This keeps a given city's color fixed
// regardless of which side (county/OSM) is currently selected or which
// layers are toggled - identity never gets repainted out from under the
// user. Colors ARE re-ranked when the Compare field itself changes, since
// that's genuinely a different attribute.
//
// pinnedNames (raw strings, e.g. from counties.yml's pinned_cities) always
// claim a palette slot ahead of frequency ranking, as long as they actually
// appear in the data - so a place like "The Villages" that a county considers
// important never gets folded into "Other" just because it's a numeric
// minority in this particular dataset.
function buildCityColorMap(geojson, pinnedNames, countyField) {
const counts = new Map(); // canonical key -> total count
const labelCounts = new Map(); // canonical key -> Map(raw label -> count)
geojson.features.forEach(feature => {
const p = feature.properties || {};
[cityKey(p[countyField]), cityKey(p.osm_city)].forEach(raw => {
if (!raw) return;
const key = canonicalKey(raw);
counts.set(key, (counts.get(key) || 0) + 1);
if (!labelCounts.has(key)) labelCounts.set(key, new Map());
const lm = labelCounts.get(key);
lm.set(raw, (lm.get(raw) || 0) + 1);
});
});
const rankedByFrequency = [...counts.keys()].sort((a, b) => counts.get(b) - counts.get(a));
const pinnedKeys = (pinnedNames || [])
.map(canonicalKey)
.filter(key => counts.has(key)); // only pin names that actually occur here
const pinnedSet = new Set(pinnedKeys);
// Pinned names first (in the order given), then the rest by frequency.
const ranked = [...pinnedKeys, ...rankedByFrequency.filter(key => !pinnedSet.has(key))];
cityColorMap = {};
cityLabelMap = {};
ranked.forEach((key, i) => {
cityColorMap[key] = i < CITY_PALETTE.length ? CITY_PALETTE[i] : CITY_OTHER_COLOR;
// Display label: whichever raw casing was most common for this key.
let bestLabel = key, bestCount = -1;
labelCounts.get(key).forEach((c, label) => {
if (c > bestCount) { bestCount = c; bestLabel = label; }
});
cityLabelMap[key] = bestLabel;
});
}
function colorForCity(canonKey) {
if (canonKey === null) return CITY_UNKNOWN_COLOR;
return cityColorMap[canonKey] || CITY_OTHER_COLOR;
}
function renderCityLayers(geojson, field) {
Object.values(cityLayers).forEach(layer => { layer.clearLayers(); if (map.hasLayer(layer)) map.removeLayer(layer); });
cityLayers = {};
// Per-field counts for the legend (independent of the shared color ranking).
const counts = new Map();
geojson.features.forEach(feature => {
const raw = cityKey((feature.properties || {})[field]);
const canon = canonicalKey(raw);
const color = colorForCity(canon);
// Named (palette) cities each get their own bucket; everything folded
// to CITY_OTHER_COLOR shares a single "Other" bucket/legend row.
let bucketKey;
if (canon === null) bucketKey = CITY_UNKNOWN_KEY;
else if (color === CITY_OTHER_COLOR) bucketKey = CITY_OTHER_KEY;
else bucketKey = canon;
if (!cityLayers[bucketKey]) cityLayers[bucketKey] = L.layerGroup();
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(color));
marker.bindPopup(popupHtml(feature), { maxWidth: 280 });
cityLayers[bucketKey].addLayer(marker);
counts.set(bucketKey, (counts.get(bucketKey) || 0) + 1);
});
renderCityLegend(counts);
applyCityLayerVisibility();
}
function renderCityLegend(counts) {
const container = document.getElementById('cityLegend');
container.innerHTML = '';
// Named cities first, ranked by their assigned palette slot (fixed order),
// then Other, then Unknown - each only if present in this field's data.
const namedEntries = Object.keys(cityColorMap)
.filter(key => counts.has(key) && cityColorMap[key] !== CITY_OTHER_COLOR)
.sort((a, b) => CITY_PALETTE.indexOf(cityColorMap[a]) - CITY_PALETTE.indexOf(cityColorMap[b]));
const rows = namedEntries.map(key => ({ key, label: cityLabelMap[key], color: cityColorMap[key] }));
if (counts.has(CITY_OTHER_KEY)) rows.push({ key: CITY_OTHER_KEY, label: CITY_OTHER_LABEL, color: CITY_OTHER_COLOR });
if (counts.has(CITY_UNKNOWN_KEY)) rows.push({ key: CITY_UNKNOWN_KEY, label: CITY_UNKNOWN_LABEL, color: CITY_UNKNOWN_COLOR });
rows.forEach(({ key, label, color }) => {
const item = document.createElement('label');
item.className = 'legend-item';
item.dataset.cityKey = key;
item.innerHTML = `
<input type="checkbox" checked>
<span class="legend-swatch" style="background:${color};"></span>
<span class="legend-label">${escapeHtml(label)}</span>
<span class="legend-count">${counts.get(key) || 0}</span>
`;
item.querySelector('input').addEventListener('change', function() {
const layer = cityLayers[key];
if (!layer) return;
if (this.checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
container.appendChild(item);
});
}
function applyCityLayerVisibility() {
document.querySelectorAll('#cityLegend .legend-item').forEach(item => {
const key = item.dataset.cityKey;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = cityLayers[key];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
// ---- Boundary Check view ----
function boundaryPopupHtml(feature) {
const p = feature.properties || {};
const cfg = FLAG_CONFIG[p.flag] || {};
const addrParts = [p['addr:housenumber'], p['addr:street']].filter(Boolean).join(' ');
const unit = p['addr:unit'] ? ` Unit ${escapeHtml(p['addr:unit'])}` : '';
let html = '<div style="font-size:12px;">';
html += `<div style="font-weight:600;margin-bottom:4px;">${escapeHtml(addrParts) || '(no address)'}${unit}</div>`;
html += `<div><strong>County:</strong> ${escapeHtml(p.county) || ''}</div>`;
html += `<div><strong>County postal community:</strong> ${escapeHtml(p.county_postal_community) || '<em>none</em>'}</div>`;
html += `<div><strong>County city:</strong> ${escapeHtml(p.county_city) || '<em>none</em>'}</div>`;
html += `<div><strong>OSM city:</strong> ${escapeHtml(p.osm_city) || '<em>none</em>'}</div>`;
html += `<div style="margin-top:6px;"><strong>Flag:</strong> ${escapeHtml(cfg.label || p.flag)}</div>`;
html += '</div>';
return html;
}
function renderBoundaryOutline(geojson) {
if (boundaryOutlineLayer) { map.removeLayer(boundaryOutlineLayer); boundaryOutlineLayer = null; }
boundaryOutlineLayer = L.geoJSON(geojson, {
style: { color: '#6f42c1', weight: 2, fillOpacity: 0.05 }
}).bindPopup(layer => (layer.feature.properties || {}).name || 'Boundary');
}
function renderBoundaryFlags(geojson) {
Object.values(boundaryFlagLayers).forEach(layer => layer.clearLayers());
const counts = {};
(geojson.features || []).forEach(feature => {
const flag = feature.properties && feature.properties.flag;
const layer = boundaryFlagLayers[flag];
if (!layer) return; // unknown flag value - skip rather than guess a bucket
const cfg = FLAG_CONFIG[flag];
const coords = feature.geometry.coordinates;
const marker = L.circleMarker(L.latLng(coords[1], coords[0]), markerStyle(cfg.color));
marker.bindPopup(boundaryPopupHtml(feature), { maxWidth: 280 });
layer.addLayer(marker);
counts[flag] = (counts[flag] || 0) + 1;
});
Object.keys(FLAG_CONFIG).forEach(flag => {
const el = document.getElementById(`count-${flag}`);
if (el) el.textContent = counts[flag] || 0;
});
}
function applyBoundaryLayerVisibility() {
document.querySelectorAll('#boundaryLegend .legend-item').forEach(item => {
const flag = item.dataset.flag;
const checked = item.querySelector('input[type="checkbox"]').checked;
const layer = flag === '__outline__' ? boundaryOutlineLayer : boundaryFlagLayers[flag];
if (!layer) return;
if (checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
}
function loadBoundarySection(force) {
if (boundaryLoaded && !force) {
applyBoundaryLayerVisibility();
return;
}
fetch('/data/map-checker/boundary.geojson')
.then(r => r.ok ? r.json() : null)
.then(geojson => {
if (boundaryOutlineLayer) { map.removeLayer(boundaryOutlineLayer); boundaryOutlineLayer = null; }
if (geojson && (geojson.features || []).length) renderBoundaryOutline(geojson);
if (currentView === 'boundary-check') applyBoundaryLayerVisibility();
})
.catch(() => {});
fetch('/data/map-checker/flagged.geojson')
.then(resp => {
if (!resp.ok) throw new Error('not generated yet');
const lastMod = resp.headers.get('Last-Modified');
document.getElementById('boundaryMeta').textContent = lastMod ? `Last run: ${new Date(lastMod).toLocaleString()}` : '';
return resp.json();
})
.then(geojson => {
document.getElementById('boundaryNoData').style.display = 'none';
renderBoundaryFlags(geojson);
boundaryLoaded = true;
if (currentView === 'boundary-check') applyBoundaryLayerVisibility();
})
.catch(() => {
document.getElementById('boundaryNoData').style.display = '';
document.getElementById('boundaryMeta').textContent = '';
Object.keys(FLAG_CONFIG).forEach(flag => {
const el = document.getElementById(`count-${flag}`);
if (el) el.textContent = '–';
});
if (currentView === 'boundary-check' && boundaryOutlineLayer) applyBoundaryLayerVisibility();
});
}
function requireLoginForBoundaryCheck() {
showStatus('Not authenticated – please log in.', 'error');
setTimeout(() => { window.location.href = '/login?next=' + encodeURIComponent(window.location.pathname); }, 1200);
}
function runBoundaryCheck() {
if (!IS_AUTHENTICATED) return requireLoginForBoundaryCheck();
const btn = document.getElementById('runBoundaryCheckBtn');
const logEl = document.getElementById('boundaryLog');
btn.disabled = true;
logEl.style.display = 'block';
logEl.textContent = '';
document.getElementById('boundaryMeta').textContent = 'Running…';
fetch('/api/run-script', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: 'boundary-check' })
})
.then(r => r.json())
.then(data => {
if (data.error) {
logEl.textContent = `Error: ${data.error}`;
btn.disabled = false;
return;
}
boundaryJobId = data.job_id;
if (boundaryLogInterval) clearInterval(boundaryLogInterval);
boundaryLogInterval = setInterval(checkBoundaryJobStatus, 1000);
})
.catch(err => {
logEl.textContent = `Error: ${err.message}`;
btn.disabled = false;
});
}
function checkBoundaryJobStatus() {
if (!boundaryJobId) return;
fetch(`/api/job-status/${boundaryJobId}`)
.then(r => r.json())
.then(data => {
const logEl = document.getElementById('boundaryLog');
logEl.textContent = data.logs.join('');
logEl.scrollTop = logEl.scrollHeight;
if (!data.running) {
clearInterval(boundaryLogInterval);
boundaryJobId = null;
document.getElementById('runBoundaryCheckBtn').disabled = false;
loadBoundarySection(true);
}
})
.catch(err => console.error('Boundary check status error:', err));
}
// ---- View switching ----
function setView(view) {
currentView = view;
document.querySelectorAll('.view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === view);
});
document.getElementById('agreementSection').style.display = view === 'agreement' ? '' : 'none';
document.getElementById('cityNameSection').style.display = view === 'city-names' ? '' : 'none';
document.getElementById('boundarySection').style.display = view === 'boundary-check' ? '' : 'none';
document.getElementById('countySelect').closest('.file-input-group').style.display = view === 'boundary-check' ? 'none' : '';
document.getElementById('fieldSelect').closest('.file-input-group').style.display = view === 'boundary-check' ? 'none' : '';
Object.values(statusLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
Object.values(cityLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
Object.values(boundaryFlagLayers).forEach(layer => { if (map.hasLayer(layer)) map.removeLayer(layer); });
if (boundaryOutlineLayer && map.hasLayer(boundaryOutlineLayer)) map.removeLayer(boundaryOutlineLayer);
if (view === 'boundary-check') {
loadBoundarySection();
return;
}
if (!currentGeojson) return;
if (view === 'agreement') {
applyStatusLayerVisibility();
} else {
renderCityLayers(currentGeojson, activeCityField());
}
}
// ---- Data loading ----
function renderGeojson(geojson) {
currentGeojson = geojson;
renderStatusLayers(geojson);
const pinned = (currentCountyId && PINNED_CITIES[currentCountyId]) || [];
buildCityColorMap(geojson, pinned, countyFieldName());
const bounds = L.latLngBounds([]);
geojson.features.forEach(feature => {
const coords = feature.geometry.coordinates;
bounds.extend(L.latLng(coords[1], coords[0]));
});
if (bounds.isValid()) map.fitBounds(bounds, { padding: [50, 50] });
setView(currentView);
}
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = `status ${type}`;
}
function hideStatus() {
const el = document.getElementById('status');
el.className = 'status hidden';
}
function loadCounty(countyId) {
if (!countyId) return;
currentCountyId = countyId;
hideStatus();
showStatus(`Loading ${countyId}...`, 'success');
fetch(`/data/${countyId}/city-comparison.geojson`)
.then(resp => {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
})
.then(geojson => {
renderGeojson(geojson);
hideStatus();
})
.catch(err => {
showStatus(`No city comparison data for ${countyId} yet. Run "Diff vs OSM" then "Generate City Boundaries Report" first. (${err.message})`, 'error');
});
}
document.addEventListener('DOMContentLoaded', function() {
initMap();
initMobileControls();
document.querySelectorAll('#legend .legend-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', function() {
const item = this.closest('.legend-item');
const layer = statusLayers[item.dataset.status];
if (!layer) return;
if (this.checked) layer.addTo(map);
else if (map.hasLayer(layer)) map.removeLayer(layer);
});
});
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => setView(btn.dataset.view));
});
document.querySelectorAll('#boundaryLegend .legend-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('change', applyBoundaryLayerVisibility);
});
document.getElementById('runBoundaryCheckBtn').addEventListener('click', runBoundaryCheck);
document.getElementById('citySourceSelect').addEventListener('change', function() {
citySource = this.value;
if (currentGeojson && currentView === 'city-names') {
renderCityLayers(currentGeojson, activeCityField());
}
});
document.getElementById('fieldSelect').addEventListener('change', function() {
compareField = this.value;
if (!currentGeojson) return;
const pinned = (currentCountyId && PINNED_CITIES[currentCountyId]) || [];
buildCityColorMap(currentGeojson, pinned, countyFieldName());
if (currentView === 'agreement') {
renderStatusLayers(currentGeojson);
applyStatusLayerVisibility();
} else {
renderCityLayers(currentGeojson, activeCityField());
}
});
const countySelect = document.getElementById('countySelect');
countySelect.addEventListener('change', function() {
loadCounty(this.value);
});
const firstAvailable = AVAILABLE_COUNTIES && AVAILABLE_COUNTIES.length ? AVAILABLE_COUNTIES[0] : null;
if (firstAvailable) {
countySelect.value = firstAvailable;
loadCounty(firstAvailable);
} else {
showStatus('No counties have city comparison data yet. Run "Diff vs OSM" then "Generate City Boundaries Report" first.', 'error');
}
});