From 53808d40dd94c1ab0957aedbda648cd5fc35bba0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 01:25:30 +0000 Subject: [PATCH 1/2] Initial plan From e73c83ab130fc6f092f283fbd6a8c7e780820e6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 1 Jan 2026 01:38:20 +0000 Subject: [PATCH 2/2] feat: add observation overlays from new mosquito alert feeds Co-authored-by: JohnPalmer <623626+JohnPalmer@users.noreply.github.com> --- js/app.js | 93 +++++++++++++++++++++++++++++++++++ js/config.js | 46 +++++++++--------- js/dataLoader.js | 25 +++++++++- js/mapManager.js | 123 ++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 262 insertions(+), 25 deletions(-) diff --git a/js/app.js b/js/app.js index 1eb1c51..cc32acc 100644 --- a/js/app.js +++ b/js/app.js @@ -255,6 +255,23 @@ async function showRegion(regionKey) { return; } mapManager.currentRegion = regionKey; + const observationsLayerCheckbox = document.getElementById('observations-layer'); + const observationsLabel = observationsLayerCheckbox ? observationsLayerCheckbox.closest('label') : null; + const supportsObservations = Boolean( + region?.dataSources?.observationsUrl || + region?.dataSources?.mosquitoAlertES?.observationsUrl || + region?.dataSources?.mosquitoAlertBCN?.observationsUrl + ); + if (!supportsObservations && mapManager?.removeObservationLayer) { + mapManager.removeObservationLayer(); + } + if (observationsLayerCheckbox) { + observationsLayerCheckbox.disabled = !supportsObservations; + observationsLayerCheckbox.checked = supportsObservations; + } + if (observationsLabel) { + observationsLabel.classList.toggle('disabled', !supportsObservations); + } const selectedModel = getSelectedModel(); const modelSelector = document.getElementById('model-selector'); if (modelSelector) { @@ -437,6 +454,10 @@ async function showRegion(regionKey) { else { await mapManager.loadRegion(regionKey); } + + if (supportsObservations) { + await loadObservationOverlay(regionKey); + } // Create visualization await visualization.createRiskChart(regionKey); @@ -476,6 +497,78 @@ document.addEventListener('visibilitychange', function() { } }); +/** + * Load and display MosquitoAlert observation overlays for supported regions + * @param {string} regionKey - Region identifier + */ +async function loadObservationOverlay(regionKey) { + const region = CONFIG.regions[regionKey]; + if (!region) return; + + const observationsUrl = region?.dataSources?.observationsUrl || + region?.dataSources?.mosquitoAlertES?.observationsUrl || + region?.dataSources?.mosquitoAlertBCN?.observationsUrl; + + if (!observationsUrl) { + mapManager.removeObservationLayer(); + return; + } + + if (mapManager.mbMap && !mapManager.mbMap.isStyleLoaded()) { + mapManager.mbMap.once('load', () => loadObservationOverlay(regionKey)); + return; + } + + if (!mapManager.map && !mapManager.mbMap) { + return; + } + + const checkbox = document.getElementById('observations-layer'); + const visible = checkbox ? checkbox.checked : true; + + try { + const csvData = await dataLoader.loadCSV(observationsUrl); + const features = (csvData || []).map(row => { + const lat = parseFloat(row.lat ?? row.Latitude ?? row.latitude); + const lon = parseFloat(row.lon ?? row.Longitude ?? row.longitude); + const presenceRaw = row.presence ?? row.PRESENCE ?? row.Presence; + const date = row.date || row.Date || ''; + const isPresent = typeof presenceRaw === 'string' ? + presenceRaw.toLowerCase() === 'true' : + Boolean(presenceRaw); + + if (!Number.isFinite(lat) || !Number.isFinite(lon) || !isPresent) { + return null; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [lon, lat] + }, + properties: { + presence: isPresent, + date: date + } + }; + }).filter(Boolean); + + const geojson = { + type: 'FeatureCollection', + features + }; + + mapManager.addObservationLayer(geojson, visible, { + color: regionKey === 'barcelona' ? '#1f78b4' : '#ff7f00', + radius: regionKey === 'barcelona' ? 5 : 4, + opacity: 0.75 + }); + } catch (error) { + console.error('Failed to load observation overlay:', error); + } +} + /** * Load MosquitoAlert Spain data for a specific date * @param {string} date - Date in YYYY-MM-DD format diff --git a/js/config.js b/js/config.js index da7e641..0e25f0c 100644 --- a/js/config.js +++ b/js/config.js @@ -16,19 +16,20 @@ const CONFIG = { mosquitoAlertES: { enabled: true, baseUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/main/data/', - filePattern: 'muni_preds_{date}.json', - description: 'Data from MosquitoAlertES - Citizen science mosquito surveillance', - municipalityBoundariesLowRes: 'mapbox://johnrbpalmer.4bfv6pbn', - municipalityBoundariesHighRes: 'mapbox://johnrbpalmer.48qdct4s', - municipalitySourceLayerLowRes: 'spain_municipality_boundaries-7m7u82', - municipalitySourceLayerHighRes: 'spain_municipality_boundaries-dzvpt0', - mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww', - maxVRI: 0.3, - // File pattern for 1km grid GeoTIFFs from MosquitoAlertES repository - gridFilePattern: 'a004_MA_predictions_all_spain_aemet_weather_forecast_pred_raster_brms_MC10_1000_{date}.tiff', - gridMaxVRI: 0.3 - } - }, + filePattern: 'muni_preds_{date}.json', + description: 'Data from MosquitoAlertES - Citizen science mosquito surveillance', + municipalityBoundariesLowRes: 'mapbox://johnrbpalmer.4bfv6pbn', + municipalityBoundariesHighRes: 'mapbox://johnrbpalmer.48qdct4s', + municipalitySourceLayerLowRes: 'spain_municipality_boundaries-7m7u82', + municipalitySourceLayerHighRes: 'spain_municipality_boundaries-dzvpt0', + mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww', + maxVRI: 0.3, + observationsUrl: 'https://github.com/Mosquito-Alert/MosquitoAlertES/raw/refs/heads/main/data/model_training_reports_lonlat.csv.gz', + // File pattern for 1km grid GeoTIFFs from MosquitoAlertES repository + gridFilePattern: 'a004_MA_predictions_all_spain_aemet_weather_forecast_pred_raster_brms_MC10_1000_{date}.tiff', + gridMaxVRI: 0.3 + } + }, comingSoon: false }, brazil: { @@ -71,15 +72,16 @@ const CONFIG = { mosquitoAlertBCN: { enabled: true, baseUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/bcn/main/data/', - filePattern: 'vri{date}.tif', - description: 'Data from MosquitoAlert BCN - High-resolution vector risk index', - mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww', - mapboxStyleId: 'johnrbpalmer/cklcc4q673pe517k4n5co81sn', - maxVRI: 0.5 - } - }, - comingSoon: false - }, + filePattern: 'vri{date}.tif', + description: 'Data from MosquitoAlert BCN - High-resolution vector risk index', + mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww', + mapboxStyleId: 'johnrbpalmer/cklcc4q673pe517k4n5co81sn', + maxVRI: 0.5, + observationsUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/bcn/refs/heads/main/data/mosquito_alert_reports_used_in_model.csv' + } + }, + comingSoon: false + }, 'rio-de-janeiro': { name: 'Rio de Janeiro', type: 'city', diff --git a/js/dataLoader.js b/js/dataLoader.js index 66e2d0c..718b96f 100644 --- a/js/dataLoader.js +++ b/js/dataLoader.js @@ -19,7 +19,7 @@ class DataLoader { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - const text = await response.text(); + const text = await this.getCSVText(response, url); const data = this.parseCSV(text); this.cache.set(url, data); return data; @@ -29,6 +29,29 @@ class DataLoader { } } + /** + * Get CSV text, handling gzip-compressed responses when needed + * @param {Response} response - Fetch response object + * @param {string} url - Original URL (used to detect .gz files) + * @returns {Promise} CSV text + */ + async getCSVText(response, url) { + const isGzip = url.endsWith('.gz') || + response.headers.get('content-encoding') === 'gzip' || + (response.headers.get('content-type') || '').includes('gzip'); + + if (isGzip && typeof DecompressionStream !== 'undefined' && response.body?.pipeThrough) { + try { + const decompressedStream = response.body.pipeThrough(new DecompressionStream('gzip')); + return await new Response(decompressedStream).text(); + } catch (err) { + console.warn('Gzip decompression failed, falling back to text():', err); + } + } + + return await response.text(); + } + /** * Parse CSV text into an array of objects * Handles basic CSV parsing - for production, consider using a proper CSV library diff --git a/js/mapManager.js b/js/mapManager.js index e4244dd..c76c152 100644 --- a/js/mapManager.js +++ b/js/mapManager.js @@ -13,6 +13,8 @@ class MapManager { geotiff: null }; this.currentRegion = null; + this.observationSourceId = 'observations-source'; + this.observationLayerId = 'observations-layer'; } /** @@ -22,6 +24,10 @@ class MapManager { if (this.map) { this.map.remove(); } + if (this.mbMap) { + this.mbMap.remove(); + this.mbMap = null; + } this.map = L.map('map').setView(CONFIG.defaultCenter, CONFIG.defaultZoom); @@ -186,6 +192,65 @@ class MapManager { }).addTo(this.map); } + /** + * Add observation layer (points) to the current map + * @param {Object} geojsonData - GeoJSON FeatureCollection + * @param {boolean} visible - Whether the layer should be visible + * @param {Object} options - Styling options + */ + addObservationLayer(geojsonData, visible = true, options = {}) { + const color = options.color || '#ff7f00'; + const radius = options.radius || 4; + const opacity = options.opacity !== undefined ? options.opacity : 0.8; + + this.removeObservationLayer(); + + if (this.mbMap) { + if (!this.mbMap.isStyleLoaded()) { + this.mbMap.once('load', () => this.addObservationLayer(geojsonData, visible, options)); + return; + } + + this.mbMap.addSource(this.observationSourceId, { + type: 'geojson', + data: geojsonData + }); + + this.mbMap.addLayer({ + id: this.observationLayerId, + type: 'circle', + source: this.observationSourceId, + paint: { + 'circle-radius': radius, + 'circle-color': color, + 'circle-opacity': opacity + }, + layout: { + visibility: visible ? 'visible' : 'none' + } + }); + this.layers.observations = geojsonData; + return; + } + + if (this.map) { + this.layers.observations = L.geoJSON(geojsonData, { + pointToLayer: (_, latlng) => L.circleMarker(latlng, { + radius: radius, + color: color, + weight: 1, + fillColor: color, + fillOpacity: opacity, + opacity: opacity + }) + }); + + if (visible) { + this.layers.observations.addTo(this.map); + } + } + } + /** * Add GeoTIFF layer to the map * @param {Object} georaster - Parsed GeoTIFF data @@ -231,6 +296,26 @@ class MapManager { }; } + /** + * Remove observation layer and source if present + */ + removeObservationLayer() { + if (this.mbMap) { + if (this.mbMap.getLayer(this.observationLayerId)) { + this.mbMap.removeLayer(this.observationLayerId); + } + if (this.mbMap.getSource(this.observationSourceId)) { + this.mbMap.removeSource(this.observationSourceId); + } + } + + if (this.layers.observations && this.map) { + this.map.removeLayer(this.layers.observations); + } + + this.layers.observations = null; + } + /** * Get color for risk level * @param {string} riskLevel - Risk level @@ -246,11 +331,25 @@ class MapManager { */ clearLayers() { Object.keys(this.layers).forEach(key => { - if (this.layers[key]) { + if (this.layers[key] && this.map) { this.map.removeLayer(this.layers[key]); - this.layers[key] = null; } + this.layers[key] = null; }); + this.removeObservationLayer(); + + if (this.mbMap) { + ['muni-high-res', 'muni-low-res', this.observationLayerId].forEach(layerId => { + if (this.mbMap.getLayer(layerId)) { + this.mbMap.removeLayer(layerId); + } + }); + ['municipalities-low-res', 'municipalities-high-res', this.observationSourceId].forEach(sourceId => { + if (this.mbMap.getSource(sourceId)) { + this.mbMap.removeSource(sourceId); + } + }); + } } /** @@ -293,6 +392,25 @@ class MapManager { return; } + if (layerName === 'observations') { + if (this.mbMap && this.mbMap.getLayer(this.observationLayerId)) { + try { + this.mbMap.setLayoutProperty(this.observationLayerId, 'visibility', visible ? 'visible' : 'none'); + } catch (e) { + console.warn('Failed to toggle Mapbox observation layer:', e); + } + } + + if (this.layers.observations && this.map) { + if (visible) { + this.map.addLayer(this.layers.observations); + } else { + this.map.removeLayer(this.layers.observations); + } + } + return; + } + const layer = this.layers[layerName]; if (!layer || !this.map) return; @@ -345,6 +463,7 @@ class MapManager { * Destroy the map instance */ destroy() { + this.removeObservationLayer(); if (this.map) { this.map.remove(); this.map = null;