Create new project design plans

This commit is contained in:
zyphlar
2026-08-28 14:23:26 -07:00
parent a7d6db8177
commit 7ce73f19c5
22 changed files with 2682 additions and 263 deletions
+478
View File
@@ -0,0 +1,478 @@
// Boundary Tracer: hand-click a polygon boundary while looking at a
// reference map in another tab, save it as GeoJSON for map-checker's
// point-in-polygon analysis. Tracing itself never fetches from any
// third-party site; the optional overlay layer is the one exception and
// only ever fetches a URL the user explicitly typed in.
const POLYGON_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'];
let map;
let polygons = []; // { id, name, latlngs: [[lat,lng],...], layer: L.Polygon }
let nextPolygonId = 1;
let drawingActive = false;
let drawingPoints = []; // [[lat,lng],...]
let drawingLayer = null; // preview L.Polyline/L.Polygon
let vertexMarkers = [];
// Overlay layer: the user pastes either a standard {z}/{x}/{y} tile URL, or
// the base URL of an ArcGIS tiled MapServer. Both are fetched directly by
// the browser from whatever URL the user gave - never proxied server-side.
//
// A generic {z}/{x}/{y} template is assumed to already follow the standard
// Web Mercator slippy-tile grid and is rendered with a plain L.tileLayer.
//
// An ArcGIS MapServer may instead publish tiles on its own scheme (see that
// service's own `?f=json` -> tileInfo: origin, per-level resolution, tile
// size). Many such caches - including local government ones - use a plain
// geographic (WGS84, wkid 4326) grid rather than Web Mercator, which a
// standard tile client can't address with z/x/y math. We handle that case by
// computing each source tile's true lat/lng box from tileInfo and dropping
// it in as an L.imageOverlay, which Leaflet projects correctly onto the
// Mercator base map regardless of the source's own projection.
let overlayMode = null; // 'xyz' | 'arcgis-geo'
let overlayTileLayer = null; // 'xyz' mode: L.tileLayer
let overlayGroup = null; // 'arcgis-geo' mode: L.layerGroup of L.imageOverlay
let overlayArcGisMeta = null; // 'arcgis-geo' mode: { baseUrl, tileInfo, opacity }
const OVERLAY_STORAGE_KEY = 'boundary-tracer-overlay';
const MAX_ARCGIS_OVERLAY_TILES = 80;
function initMap() {
map = initBaseMap('map', [28.7, -81.7], 10);
map.on('click', onMapClick);
}
function isArcGisMapServerUrl(url) {
return /\/MapServer\/?$/i.test(url);
}
function addOverlay(url, opacity) {
removeOverlay();
url = url.trim();
if (isArcGisMapServerUrl(url)) {
addArcGisGeoOverlay(url, opacity);
} else {
overlayMode = 'xyz';
overlayTileLayer = L.tileLayer(url, { opacity, maxZoom: 22, maxNativeZoom: 20 }).addTo(map);
document.getElementById('removeOverlayBtn').disabled = false;
document.getElementById('overlayOpacityRow').style.display = 'flex';
}
try { localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify({ url, opacity })); } catch (e) {}
}
function addArcGisGeoOverlay(baseUrl, opacity) {
const cleanUrl = baseUrl.replace(/\/$/, '');
fetch(cleanUrl + '?f=json')
.then(r => r.json())
.then(meta => {
const tileInfo = meta.tileInfo;
if (!tileInfo || !tileInfo.lods || !tileInfo.origin) {
showStatus('This ArcGIS service has no tileInfo - it may not be a cached/tiled MapServer.', 'error');
return;
}
const wkid = (tileInfo.spatialReference || {}).wkid;
if (wkid !== 4326) {
showStatus(`Unsupported tile spatial reference (wkid ${wkid}). Only geographic (wkid 4326) ArcGIS tile schemes are supported.`, 'error');
return;
}
overlayMode = 'arcgis-geo';
overlayArcGisMeta = { baseUrl: cleanUrl, tileInfo, opacity };
overlayGroup = L.layerGroup().addTo(map);
map.on('moveend', redrawArcGisOverlay);
document.getElementById('removeOverlayBtn').disabled = false;
document.getElementById('overlayOpacityRow').style.display = 'flex';
redrawArcGisOverlay();
showStatus('ArcGIS overlay loaded - it refreshes automatically as you pan/zoom.', 'success');
})
.catch(err => showStatus(`Failed to load ArcGIS service metadata: ${err.message}`, 'error'));
}
function pickArcGisLevel(tileInfo) {
// Match the current Leaflet zoom to the tileInfo LOD with the closest
// resolution, using the standard Web Mercator meters-per-pixel formula
// converted to degrees. This is an approximation (good enough to pick
// the nearest of a handful of power-of-two levels) not an exact reprojection.
const zoom = map.getZoom();
const metersPerPixel = 156543.03392 * Math.cos(map.getCenter().lat * Math.PI / 180) / Math.pow(2, zoom);
const degPerPixel = metersPerPixel / 111320;
let best = tileInfo.lods[0];
let bestDiff = Infinity;
tileInfo.lods.forEach(lod => {
const diff = Math.abs(lod.resolution - degPerPixel);
if (diff < bestDiff) { bestDiff = diff; best = lod; }
});
return best;
}
function redrawArcGisOverlay() {
if (!overlayGroup || !overlayArcGisMeta) return;
overlayGroup.clearLayers();
const { baseUrl, tileInfo, opacity } = overlayArcGisMeta;
const lod = pickArcGisLevel(tileInfo);
const tileSizeDeg = lod.resolution * tileInfo.cols;
const originX = tileInfo.origin.x, originY = tileInfo.origin.y;
const bounds = map.getBounds();
const colMin = Math.floor((bounds.getWest() - originX) / tileSizeDeg);
const colMax = Math.floor((bounds.getEast() - originX) / tileSizeDeg);
const rowMin = Math.floor((originY - bounds.getNorth()) / tileSizeDeg);
const rowMax = Math.floor((originY - bounds.getSouth()) / tileSizeDeg);
const tileCount = (colMax - colMin + 1) * (rowMax - rowMin + 1);
if (tileCount > MAX_ARCGIS_OVERLAY_TILES) {
showStatus(`Overlay area too large at this zoom (${tileCount} tiles needed) - zoom in further.`, 'error');
return;
}
for (let row = rowMin; row <= rowMax; row++) {
for (let col = colMin; col <= colMax; col++) {
if (row < 0 || col < 0) continue;
const tileNorth = originY - row * tileSizeDeg;
const tileSouth = originY - (row + 1) * tileSizeDeg;
const tileWest = originX + col * tileSizeDeg;
const tileEast = originX + (col + 1) * tileSizeDeg;
const url = `${baseUrl}/tile/${lod.level}/${row}/${col}`;
L.imageOverlay(url, [[tileSouth, tileWest], [tileNorth, tileEast]], { opacity }).addTo(overlayGroup);
}
}
}
function removeOverlay() {
if (overlayTileLayer) { map.removeLayer(overlayTileLayer); overlayTileLayer = null; }
if (overlayGroup) { map.removeLayer(overlayGroup); overlayGroup = null; }
map.off('moveend', redrawArcGisOverlay);
overlayArcGisMeta = null;
overlayMode = null;
document.getElementById('removeOverlayBtn').disabled = true;
document.getElementById('overlayOpacityRow').style.display = 'none';
try { localStorage.removeItem(OVERLAY_STORAGE_KEY); } catch (e) {}
}
function setOverlayOpacity(opacity) {
if (overlayMode === 'xyz' && overlayTileLayer) {
overlayTileLayer.setOpacity(opacity);
} else if (overlayMode === 'arcgis-geo' && overlayArcGisMeta) {
overlayArcGisMeta.opacity = opacity;
redrawArcGisOverlay();
}
try {
const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null');
if (saved) { saved.opacity = opacity; localStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(saved)); }
} catch (e) {}
}
function restoreOverlayFromStorage() {
try {
const saved = JSON.parse(localStorage.getItem(OVERLAY_STORAGE_KEY) || 'null');
if (saved && saved.url) {
document.getElementById('overlayUrlInput').value = saved.url;
document.getElementById('overlayOpacitySlider').value = Math.round((saved.opacity ?? 0.7) * 100);
addOverlay(saved.url, saved.opacity ?? 0.7);
}
} catch (e) {}
}
function colorForIndex(i) {
return POLYGON_COLORS[i % POLYGON_COLORS.length];
}
function onMapClick(e) {
if (!drawingActive) return;
drawingPoints.push([e.latlng.lat, e.latlng.lng]);
redrawPreview();
updateDrawButtons();
saveDraft();
}
function redrawPreview() {
if (drawingLayer) {
map.removeLayer(drawingLayer);
drawingLayer = null;
}
vertexMarkers.forEach(m => map.removeLayer(m));
vertexMarkers = [];
if (drawingPoints.length === 0) return;
if (drawingPoints.length >= 3) {
drawingLayer = L.polygon(drawingPoints, {
color: '#ffc107', weight: 2, fillOpacity: 0.15, dashArray: '5,5'
}).addTo(map);
} else {
drawingLayer = L.polyline(drawingPoints, {
color: '#ffc107', weight: 2, dashArray: '5,5'
}).addTo(map);
}
drawingPoints.forEach(pt => {
const marker = L.circleMarker(pt, {
radius: 4, fillColor: '#ffc107', color: '#a67c00', weight: 1, fillOpacity: 1
}).addTo(map);
vertexMarkers.push(marker);
});
}
function updateDrawButtons() {
document.getElementById('undoPointBtn').disabled = drawingPoints.length === 0;
document.getElementById('finishPolygonBtn').disabled = drawingPoints.length < 3;
const hint = document.getElementById('drawHint');
if (drawingActive) {
hint.textContent = `${drawingPoints.length} point(s) placed. Click the map to add more, then "Finish polygon".`;
}
}
function startDrawing() {
drawingActive = true;
drawingPoints = [];
redrawPreview();
document.getElementById('newPolygonBtn').disabled = true;
document.getElementById('undoPointBtn').disabled = true;
document.getElementById('finishPolygonBtn').disabled = true;
document.getElementById('cancelPolygonBtn').disabled = false;
document.getElementById('drawHint').textContent = 'Click the map to place vertices.';
}
function undoPoint() {
if (drawingPoints.length === 0) return;
drawingPoints.pop();
redrawPreview();
updateDrawButtons();
saveDraft();
}
function cancelDrawing() {
drawingActive = false;
drawingPoints = [];
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
vertexMarkers.forEach(m => map.removeLayer(m));
vertexMarkers = [];
document.getElementById('newPolygonBtn').disabled = false;
document.getElementById('undoPointBtn').disabled = true;
document.getElementById('finishPolygonBtn').disabled = true;
document.getElementById('cancelPolygonBtn').disabled = true;
document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.';
saveDraft();
}
function finishDrawing() {
if (drawingPoints.length < 3) return;
const defaultName = polygons.length > 0 ? polygons[polygons.length - 1].name : 'The Villages';
const name = window.prompt('Name for this polygon (e.g. "The Villages"):', defaultName);
if (name === null) return; // cancelled prompt - keep drawing state as-is
const latlngs = drawingPoints.slice();
if (drawingLayer) { map.removeLayer(drawingLayer); drawingLayer = null; }
vertexMarkers.forEach(m => map.removeLayer(m));
vertexMarkers = [];
addPolygon(name.trim() || 'Unnamed', latlngs);
drawingActive = false;
drawingPoints = [];
document.getElementById('newPolygonBtn').disabled = false;
document.getElementById('undoPointBtn').disabled = true;
document.getElementById('finishPolygonBtn').disabled = true;
document.getElementById('cancelPolygonBtn').disabled = true;
document.getElementById('drawHint').textContent = 'Click "New polygon" to start tracing.';
saveDraft();
}
function addPolygon(name, latlngs) {
const id = nextPolygonId++;
const color = colorForIndex(polygons.length);
const layer = L.polygon(latlngs, { color, weight: 2, fillOpacity: 0.2 }).addTo(map);
polygons.push({ id, name, latlngs, layer });
renderPolygonList();
}
function deletePolygon(id) {
const idx = polygons.findIndex(p => p.id === id);
if (idx === -1) return;
map.removeLayer(polygons[idx].layer);
polygons.splice(idx, 1);
// Recolor remaining layers to keep the palette assignment stable-looking
polygons.forEach((p, i) => p.layer.setStyle({ color: colorForIndex(i) }));
renderPolygonList();
saveDraft();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function renderPolygonList() {
const container = document.getElementById('polygonList');
if (polygons.length === 0) {
container.innerHTML = '<p style="font-size:11px;color:#999;">None yet.</p>';
return;
}
container.innerHTML = polygons.map((p, i) => `
<div class="polygon-item">
<span class="polygon-swatch" style="background:${colorForIndex(i)};"></span>
<span class="polygon-name">${escapeHtml(p.name)} (${p.latlngs.length} pts)</span>
<button onclick="window.__deletePolygon(${p.id})">Delete</button>
</div>
`).join('');
}
window.__deletePolygon = deletePolygon;
function polygonsToGeoJson() {
return {
type: 'FeatureCollection',
features: polygons.map(p => ({
type: 'Feature',
properties: { name: p.name },
geometry: {
type: 'Polygon',
// GeoJSON is [lng, lat] and rings must close (first == last)
coordinates: [[...p.latlngs.map(([lat, lng]) => [lng, lat]),
[p.latlngs[0][1], p.latlngs[0][0]]]]
}
}))
};
}
function explodeGeometry(geom) {
if (geom.type === 'Polygon') return [geom.coordinates];
if (geom.type === 'MultiPolygon') return geom.coordinates;
return [];
}
function loadExisting() {
fetch('/api/map-checker/boundary')
.then(r => r.json())
.then(geojson => {
(geojson.features || []).forEach(feat => {
const name = (feat.properties && feat.properties.name) || 'Unnamed';
explodeGeometry(feat.geometry).forEach(rings => {
const outerRing = rings[0]; // ignore holes
const latlngs = outerRing.slice(0, -1).map(([lng, lat]) => [lat, lng]); // drop closing point
if (latlngs.length >= 3) addPolygon(name, latlngs);
});
});
if (polygons.length > 0) {
const bounds = L.latLngBounds(polygons.flatMap(p => p.latlngs));
map.fitBounds(bounds, { padding: [50, 50] });
}
})
.catch(err => showStatus(`Failed to load existing boundary: ${err.message}`, 'error'));
}
// Autosave: fired after every vertex click, undo, delete, and finish so an
// in-progress trace survives a crashed tab or dropped connection without
// needing an explicit "Save to server" click.
function saveDraft() {
fetch('/api/map-checker/draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
polygons: polygons.map(p => ({ name: p.name, latlngs: p.latlngs })),
inProgress: drawingPoints
})
}).catch(err => showStatus(`Autosave failed: ${err.message}`, 'error'));
}
function loadInitialState() {
fetch('/api/map-checker/draft')
.then(r => r.json())
.then(draft => {
const hasDraft = draft && (
(draft.polygons && draft.polygons.length > 0) ||
(draft.inProgress && draft.inProgress.length > 0)
);
if (!hasDraft) {
loadExisting();
return;
}
(draft.polygons || []).forEach(p => {
if (p.latlngs && p.latlngs.length >= 3) addPolygon(p.name || 'Unnamed', p.latlngs);
});
if (draft.inProgress && draft.inProgress.length > 0) {
drawingActive = true;
drawingPoints = draft.inProgress.slice();
redrawPreview();
document.getElementById('newPolygonBtn').disabled = true;
document.getElementById('cancelPolygonBtn').disabled = false;
updateDrawButtons();
}
const allPts = polygons.flatMap(p => p.latlngs).concat(drawingPoints);
if (allPts.length > 0) {
map.fitBounds(L.latLngBounds(allPts), { padding: [50, 50] });
}
showStatus('Restored autosaved work from your last session.', 'success');
})
.catch(err => showStatus(`Failed to load autosaved draft: ${err.message}`, 'error'));
}
function showStatus(msg, type) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = `status ${type}`;
}
function saveToServer() {
if (polygons.length === 0) {
showStatus('No polygons to save yet.', 'error');
return;
}
fetch('/api/map-checker/boundary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(polygonsToGeoJson())
})
.then(r => r.json())
.then(data => {
if (data.error) { showStatus(`Error: ${data.error}`, 'error'); return; }
showStatus(`Saved ${data.features} polygon(s) to /data/map-checker/boundary.geojson`, 'success');
})
.catch(err => showStatus(`Error: ${err.message}`, 'error'));
}
function downloadGeoJson() {
if (polygons.length === 0) {
showStatus('No polygons to download yet.', 'error');
return;
}
const blob = new Blob([JSON.stringify(polygonsToGeoJson(), null, 2)], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'boundary.geojson';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
document.addEventListener('DOMContentLoaded', function() {
initMap();
initMobileControls();
loadInitialState();
restoreOverlayFromStorage();
document.getElementById('newPolygonBtn').addEventListener('click', startDrawing);
document.getElementById('undoPointBtn').addEventListener('click', undoPoint);
document.getElementById('finishPolygonBtn').addEventListener('click', finishDrawing);
document.getElementById('cancelPolygonBtn').addEventListener('click', cancelDrawing);
document.getElementById('saveBtn').addEventListener('click', saveToServer);
document.getElementById('downloadBtn').addEventListener('click', downloadGeoJson);
document.getElementById('addOverlayBtn').addEventListener('click', () => {
const url = document.getElementById('overlayUrlInput').value.trim();
if (!url) { showStatus('Enter a tile URL first.', 'error'); return; }
const hasXyzPlaceholders = /\{z\}/.test(url) && /\{x\}/.test(url) && /\{y\}/.test(url);
if (!hasXyzPlaceholders && !isArcGisMapServerUrl(url)) {
showStatus('URL should contain {z}, {x} and {y} placeholders, or be an ArcGIS .../MapServer base URL.', 'error');
return;
}
const opacity = document.getElementById('overlayOpacitySlider').value / 100;
addOverlay(url, opacity);
});
document.getElementById('removeOverlayBtn').addEventListener('click', removeOverlay);
document.getElementById('overlayOpacitySlider').addEventListener('input', (e) => {
setOverlayOpacity(e.target.value / 100);
});
});
+207
View File
@@ -0,0 +1,207 @@
/* Shared chrome for Leaflet map pages: full-screen map, the slide-out
controls panel, mobile hamburger toggle, and status banner. Page-specific
controls (layer lists, legends, buttons) are styled by each page. */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
#map {
flex: 1;
width: 100%;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
min-width: 175px;
max-height: calc(100vh - 20px);
overflow-y: auto;
}
.controls h3 {
margin: 0 0 6px 0;
font-size: 12px;
color: #333;
}
.controls label {
display: flex;
align-items: center;
margin: 4px 0;
cursor: pointer;
font-size: 11px;
}
.controls input[type="checkbox"] {
margin-right: 8px;
}
.controls select {
width: 100%;
font-size: 11px;
padding: 3px;
border: 1px solid #ccc;
border-radius: 4px;
}
.controls button {
width: 100%;
padding: 7px;
margin-top: 7px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
}
.controls button:hover {
background: #0056b3;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.status {
margin-top: 7px;
padding: 6px;
border-radius: 4px;
font-size: 11px;
text-align: center;
}
.status.success {
background: #d4edda;
color: #155724;
}
.status.error {
background: #f8d7da;
color: #721c24;
}
.status.hidden {
display: none;
}
.hamburger-button {
display: none;
position: absolute;
top: 10px;
right: 10px;
z-index: 1001;
background: white;
border: none;
border-radius: 4px;
padding: 10px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
width: 44px;
height: 44px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
}
.hamburger-button span {
display: block;
width: 24px;
height: 3px;
background: #333;
border-radius: 2px;
transition: all 0.3s ease;
}
.hamburger-button.active span:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px);
}
.hamburger-button.active span:nth-child(2) {
opacity: 0;
}
.hamburger-button.active span:nth-child(3) {
transform: rotate(-45deg) translate(6px, -6px);
}
@media (max-width: 768px) {
.hamburger-button {
display: flex;
}
.controls {
position: fixed;
top: 0;
right: -100%;
width: 80%;
max-width: 320px;
height: 100vh;
overflow-y: auto;
transition: right 0.3s ease;
border-radius: 0;
box-shadow: -2px 0 10px rgba(0,0,0,0.2);
padding-top: 50px;
}
.controls.open {
right: 0;
}
.controls h3 {
font-size: 13px;
}
.controls label {
font-size: 12px;
}
}
.overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 999;
}
.overlay.active {
display: block;
}
.btn-back {
display: block;
margin-bottom: 12px;
padding: 5px 10px;
background: #1a1a2e;
color: #ddd;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-decoration: none;
text-align: center;
}
.btn-back:hover { background: #2d2d4e; color: #fff; }
+41
View File
@@ -0,0 +1,41 @@
// Shared chrome for Leaflet-based pages: base tile layer and the mobile
// hamburger/controls-drawer behavior used by both the editing map viewer
// and read-only reports.
function initBaseMap(elementId, view, zoom) {
const map = L.map(elementId).setView(view || [28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
if (zoom) map.setZoom(zoom);
return map;
}
// Wires up the hamburger button / slide-out controls panel / overlay used
// on mobile. Expects #hamburgerButton, #controls, #overlay in the page.
function initMobileControls() {
const hamburgerButton = document.getElementById('hamburgerButton');
const controls = document.getElementById('controls');
const overlay = document.getElementById('overlay');
if (!hamburgerButton || !controls || !overlay) return;
function toggleControls() {
controls.classList.toggle('open');
hamburgerButton.classList.toggle('active');
overlay.classList.toggle('active');
}
function closeControls() {
controls.classList.remove('open');
hamburgerButton.classList.remove('active');
overlay.classList.remove('active');
}
window.closeMobileControls = closeControls;
hamburgerButton.addEventListener('click', toggleControls);
overlay.addEventListener('click', closeControls);
}
+3 -32
View File
@@ -23,7 +23,6 @@ let selectionBox = null;
let multiSelectMode = false;
// Mobile controls state
let mobileControlsOpen = false;
let loadedCounty = '';
let loadedDataType = '';
@@ -43,13 +42,7 @@ let popupLatlng = null;
// Initialize map
function initMap() {
map = L.map('map').setView([28.7, -81.7], 12);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors © CARTO',
subdomains: 'abcd',
maxZoom: 20
}).addTo(map);
map = initBaseMap('map', [28.7, -81.7], 12);
snapPreviewLayer = L.layerGroup().addTo(map);
@@ -2103,30 +2096,8 @@ function toggleLayer(layerId, layer) {
document.addEventListener('DOMContentLoaded', function() {
initMap();
// Mobile hamburger menu toggle
const hamburgerButton = document.getElementById('hamburgerButton');
const controls = document.getElementById('controls');
const overlay = document.getElementById('overlay');
function toggleControls() {
mobileControlsOpen = !mobileControlsOpen;
controls.classList.toggle('open');
hamburgerButton.classList.toggle('active');
overlay.classList.toggle('active');
}
function closeControls() {
mobileControlsOpen = false;
controls.classList.remove('open');
hamburgerButton.classList.remove('active');
overlay.classList.remove('active');
}
// Expose closeControls globally for other functions
window.closeMobileControls = closeControls;
hamburgerButton.addEventListener('click', toggleControls);
overlay.addEventListener('click', closeControls);
// Mobile hamburger menu toggle (shared helper in map-ui.js)
initMobileControls();
// Layer toggles
document.getElementById('osmToggle').addEventListener('change', function() {
+597
View File
@@ -0,0 +1,597 @@
// 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');
}
});