// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function renderPolygonList() { const container = document.getElementById('polygonList'); if (polygons.length === 0) { container.innerHTML = '

None yet.

'; return; } container.innerHTML = polygons.map((p, i) => `
${escapeHtml(p.name)} (${p.latlngs.length} pts)
`).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); }); });