598 lines
24 KiB
JavaScript
598 lines
24 KiB
JavaScript
// 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 = '<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');
|
||
}
|
||
});
|