// 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'}
`;
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'}