// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = '
'; html += `
${escapeHtml(addrParts) || '(no address)'}${unit}
`; html += `
County postal community: ${escapeHtml(p.county_postal_community) || 'none'}
`; html += `
County city: ${escapeHtml(p.county_city) || 'none'}
`; html += `
OSM city: ${escapeHtml(p.osm_city) || 'none'}
`; html += `
Agreement (vs ${compareLabel}): ${escapeHtml(cfg.label || activeStatus)}
`; if (p.distance_meters !== undefined && p.distance_meters !== null) { html += `
Match distance: ${Number(p.distance_meters).toFixed(1)} m
`; } html += '
'; 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 = ` ${escapeHtml(label)} ${counts.get(key) || 0} `; 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 = '
'; html += `
${escapeHtml(addrParts) || '(no address)'}${unit}
`; html += `
County: ${escapeHtml(p.county) || ''}
`; html += `
County postal community: ${escapeHtml(p.county_postal_community) || 'none'}
`; html += `
County city: ${escapeHtml(p.county_city) || 'none'}
`; html += `
OSM city: ${escapeHtml(p.osm_city) || 'none'}
`; html += `
Flag: ${escapeHtml(cfg.label || p.flag)}
`; html += '
'; 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'); } });