Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -437,6 +454,10 @@ async function showRegion(regionKey) {
else {
await mapManager.loadRegion(regionKey);
}

if (supportsObservations) {
await loadObservationOverlay(regionKey);
}

// Create visualization
await visualization.createRiskChart(regionKey);
Expand Down Expand Up @@ -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
Expand Down
46 changes: 24 additions & 22 deletions js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand Down
25 changes: 24 additions & 1 deletion js/dataLoader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>} 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
Expand Down
123 changes: 121 additions & 2 deletions js/mapManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class MapManager {
geotiff: null
};
this.currentRegion = null;
this.observationSourceId = 'observations-source';
this.observationLayerId = 'observations-layer';
}

/**
Expand All @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
});
}
}

/**
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -345,6 +463,7 @@ class MapManager {
* Destroy the map instance
*/
destroy() {
this.removeObservationLayer();
if (this.map) {
this.map.remove();
this.map = null;
Expand Down