Skip to content

Commit 8c15d49

Browse files
authored
Merge pull request #40 from E4Warning/copilot/update-mapbox-geojson-export
Restore layer toggles and load Spain observations from GeoJSON (presence-only)
2 parents 599cf79 + e7ccdbc commit 8c15d49

3 files changed

Lines changed: 126 additions & 90 deletions

File tree

index.html

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,15 @@ <h3>Basemap</h3>
154154
<h3>Layers</h3>
155155
<div class="layer-controls">
156156
<label>
157-
<input type="radio" name="layer-toggle" id="risk-layer" checked>
157+
<input type="checkbox" id="risk-layer" checked>
158158
Risk Level
159159
</label>
160160
<label class="disabled">
161-
<input type="radio" name="layer-toggle" id="observations-layer" disabled>
161+
<input type="checkbox" id="observations-layer" disabled>
162162
Observations
163163
</label>
164164
<!-- <label class="disabled">
165-
<input type="radio" name="layer-toggle" id="range-layer" disabled>
165+
<input type="checkbox" id="range-layer" disabled>
166166
Range
167167
</label> -->
168168
</div>
@@ -194,10 +194,6 @@ <h3>Observations Legend</h3>
194194
<span class="legend-color obs-presence"></span>
195195
<span>Presence</span>
196196
</div>
197-
<div class="legend-item">
198-
<span class="legend-color obs-absence"></span>
199-
<span>Absence</span>
200-
</div>
201197
</div>
202198
</div>
203199

js/app.js

Lines changed: 109 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,58 @@ function sanitizeHtml(html) {
2424
return doc.body.innerHTML;
2525
}
2626

27+
/**
28+
* Build a GeoJSON point feature for an observation if presence is true and coordinates are valid.
29+
* Returns null for absence entries or invalid coordinates to skip rendering.
30+
* @param {number} lat
31+
* @param {number} lon
32+
* @param {boolean|string} presenceRaw
33+
* @param {string} date
34+
* @param {Object} properties
35+
* @returns {Object|null}
36+
*/
37+
function buildObservationFeature(lat, lon, presenceRaw, date, properties = {}) {
38+
const isPresent = typeof presenceRaw === 'string'
39+
? presenceRaw.toLowerCase() === 'true'
40+
: Boolean(presenceRaw);
41+
42+
if (!isPresent || !Number.isFinite(lat) || !Number.isFinite(lon)) {
43+
return null;
44+
}
45+
46+
return {
47+
type: 'Feature',
48+
geometry: {
49+
type: 'Point',
50+
coordinates: [lon, lat]
51+
},
52+
properties: {
53+
...properties,
54+
presence: true,
55+
date
56+
}
57+
};
58+
}
59+
60+
/**
61+
* Attempt to load observation data as GeoJSON when available
62+
* @param {string} url
63+
* @returns {Promise<Object|null>}
64+
*/
65+
async function tryLoadObservationGeoJSON(url) {
66+
const lowerUrl = url.toLowerCase();
67+
try {
68+
const response = await fetch(url);
69+
const contentType = (response.headers.get('content-type') || '').toLowerCase();
70+
if (response.ok && (contentType.includes('json') || lowerUrl.endsWith('.json') || lowerUrl.endsWith('.geojson'))) {
71+
return await response.json();
72+
}
73+
} catch (err) {
74+
console.warn('Could not load observations as GeoJSON:', err);
75+
}
76+
return null;
77+
}
78+
2779
/**
2880
* Get the currently selected model
2981
* @returns {string}
@@ -184,22 +236,18 @@ function setupNavigation() {
184236
* Set up layer control event listeners
185237
*/
186238
function setupLayerControls() {
187-
const riskLayerRadio = document.getElementById('risk-layer');
188-
const observationsLayerRadio = document.getElementById('observations-layer');
239+
const riskLayerCheckbox = document.getElementById('risk-layer');
240+
const observationsLayerCheckbox = document.getElementById('observations-layer');
189241

190-
if (riskLayerRadio) {
191-
riskLayerRadio.addEventListener('change', async function() {
192-
if (this.checked) {
193-
await handleLayerSelection('risk');
194-
}
242+
if (riskLayerCheckbox) {
243+
riskLayerCheckbox.addEventListener('change', async function() {
244+
await handleLayerSelection('risk', this.checked);
195245
});
196246
}
197247

198-
if (observationsLayerRadio) {
199-
observationsLayerRadio.addEventListener('change', async function() {
200-
if (this.checked) {
201-
await handleLayerSelection('observations');
202-
}
248+
if (observationsLayerCheckbox) {
249+
observationsLayerCheckbox.addEventListener('change', async function() {
250+
await handleLayerSelection('observations', this.checked);
203251
});
204252
}
205253

@@ -242,54 +290,46 @@ function setupLayerControls() {
242290
}
243291

244292
/**
245-
* Ensure only one layer is active and handle map transitions for observations
293+
* Toggle layer visibility and ensure data is available
246294
* @param {string} selectedLayer
295+
* @param {boolean} isVisible
296+
* @returns {Promise<void>}
247297
*/
248-
async function handleLayerSelection(selectedLayer) {
298+
async function handleLayerSelection(selectedLayer, isVisible) {
249299
if (!mapManager || !mapManager.currentRegion) {
250300
return;
251301
}
252302

253303
const regionKey = mapManager.currentRegion;
254304
const region = CONFIG.regions[regionKey];
255-
const riskLayerRadio = document.getElementById('risk-layer');
256-
const observationsLayerRadio = document.getElementById('observations-layer');
257-
258305
if (selectedLayer === 'observations') {
259-
if (riskLayerRadio) riskLayerRadio.checked = false;
260-
if (observationsLayerRadio) observationsLayerRadio.checked = true;
261-
262-
// If a Mapbox map is active, replace it with Leaflet so observations render correctly
263-
const wasMapbox = Boolean(mapManager.mbMap);
264-
if (mapManager.mbMap) {
265-
mapManager.mbMap.remove();
266-
mapManager.mbMap = null;
267-
}
268-
269-
if (!mapManager.map || wasMapbox) {
270-
mapManager.initMap();
271-
if (region?.center && region?.zoom) {
272-
mapManager.map.setView(region.center, region.zoom);
273-
}
306+
if (!isVisible) {
307+
mapManager.toggleLayer('observations', false);
308+
return;
274309
}
275-
276-
// Pass `true` to disable the selector while observations are shown
277-
setBasemapSelectorAvailability(true, 'Basemap selection is disabled in observations view');
278-
mapManager.toggleLayer('risk', false);
279310
await loadObservationOverlay(regionKey);
280311
mapManager.toggleLayer('observations', true);
281312
return;
282313
}
283314

284315
if (selectedLayer === 'risk') {
285-
if (riskLayerRadio) riskLayerRadio.checked = true;
286-
if (observationsLayerRadio) observationsLayerRadio.checked = false;
316+
if (!isVisible) {
317+
mapManager.toggleLayer('risk', false);
318+
return;
319+
}
287320

288321
const selectedModel = getSelectedModel();
289322
if (regionKey === 'spain' && region?.dataSources?.mosquitoAlertES?.enabled && selectedModel === 'mosquito-alert-municipalities') {
290-
const datePicker = document.getElementById('data-date-picker');
291-
const dateToLoad = datePicker && datePicker.value ? datePicker.value : new Date().toISOString().split('T')[0];
292-
await loadSpainMosquitoAlertData(dateToLoad, selectedModel);
323+
const mbMap = mapManager.mbMap;
324+
const hasMapboxLayers = Boolean(
325+
mbMap?.getLayer('muni-high-res') ||
326+
mbMap?.getLayer('muni-low-res')
327+
);
328+
if (!hasMapboxLayers) {
329+
const datePicker = document.getElementById('data-date-picker');
330+
const dateToLoad = datePicker && datePicker.value ? datePicker.value : new Date().toISOString().split('T')[0];
331+
await loadSpainMosquitoAlertData(dateToLoad, selectedModel);
332+
}
293333
} else if (regionKey === 'barcelona' && region?.dataSources?.mosquitoAlertBCN?.enabled && !mapManager.layers.geotiff) {
294334
const datePicker = document.getElementById('data-date-picker');
295335
const dateToLoad = datePicker && datePicker.value ? datePicker.value : new Date().toISOString().split('T')[0];
@@ -298,7 +338,6 @@ async function handleLayerSelection(selectedLayer) {
298338
await mapManager.loadRegion(regionKey);
299339
}
300340

301-
mapManager.toggleLayer('observations', false);
302341
mapManager.toggleLayer('risk', true);
303342
}
304343
}
@@ -666,33 +705,35 @@ async function loadObservationOverlay(regionKey) {
666705
const visible = checkbox ? checkbox.checked : false;
667706

668707
try {
669-
const csvData = await dataLoader.loadCSV(observationsUrl);
670-
const features = (csvData || []).map(row => {
671-
const lat = parseFloat(row.lat ?? row.Latitude ?? row.latitude);
672-
const lon = parseFloat(row.lon ?? row.Longitude ?? row.longitude);
673-
const presenceRaw = row.presence ?? row.PRESENCE ?? row.Presence;
674-
const date = row.date || row.Date || '';
675-
const isPresent = typeof presenceRaw === 'string' ?
676-
presenceRaw.toLowerCase() === 'true' :
677-
Boolean(presenceRaw);
678-
679-
// Only filter out invalid coordinates, not based on presence
680-
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
681-
return null;
682-
}
708+
let features = [];
709+
const geojsonData = await tryLoadObservationGeoJSON(observationsUrl);
710+
711+
if (geojsonData && Array.isArray(geojsonData.features)) {
712+
features = geojsonData.features.map(feature => {
713+
const coords = feature?.geometry?.coordinates || [];
714+
const lon = parseFloat(coords[0]);
715+
const lat = parseFloat(coords[1]);
716+
const presenceRaw = feature?.properties?.presence ??
717+
feature?.properties?.PRESENCE ??
718+
feature?.properties?.Presence;
719+
const date = feature?.properties?.date ||
720+
feature?.properties?.Date ||
721+
feature?.properties?.DATE ||
722+
'';
723+
return buildObservationFeature(lat, lon, presenceRaw, date, feature.properties || {});
724+
}).filter(Boolean);
725+
}
683726

684-
return {
685-
type: 'Feature',
686-
geometry: {
687-
type: 'Point',
688-
coordinates: [lon, lat]
689-
},
690-
properties: {
691-
presence: isPresent,
692-
date: date
693-
}
694-
};
695-
}).filter(Boolean);
727+
if (!features.length) {
728+
const csvData = await dataLoader.loadCSV(observationsUrl);
729+
features = (csvData || []).map(row => {
730+
const lat = parseFloat(row.lat ?? row.Latitude ?? row.latitude);
731+
const lon = parseFloat(row.lon ?? row.Longitude ?? row.longitude);
732+
const presenceRaw = row.presence ?? row.PRESENCE ?? row.Presence;
733+
const date = row.date || row.Date || '';
734+
return buildObservationFeature(lat, lon, presenceRaw, date, {});
735+
}).filter(Boolean);
736+
}
696737

697738
const geojson = {
698739
type: 'FeatureCollection',
@@ -701,7 +742,6 @@ async function loadObservationOverlay(regionKey) {
701742

702743
mapManager.addObservationLayer(geojson, visible, {
703744
fillColor: '#ffd92f', // Yellow for presence points
704-
fillColorAbsence: '#a6cee3', // Light blue for absence points
705745
strokeColor: '#000000',
706746
strokeWidth: 1.5,
707747
radius: regionKey === 'barcelona' ? 7 : 6,

js/config.js

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,22 +13,22 @@ const CONFIG = {
1313
timeseries: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/main/data/time_profile_country.json',
1414
ccaaTimeseries: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/main/data/time_profile_ccaas.json',
1515
reportUrl: 'data/spain-daily-report.html',
16-
mosquitoAlertES: {
17-
enabled: true,
18-
baseUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/main/data/',
19-
filePattern: 'muni_preds_{date}.json',
16+
mosquitoAlertES: {
17+
enabled: true,
18+
baseUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/main/data/',
19+
filePattern: 'muni_preds_{date}.json',
2020
description: 'Data from MosquitoAlertES - Citizen science mosquito surveillance',
2121
municipalityBoundariesLowRes: 'mapbox://johnrbpalmer.4bfv6pbn',
22-
municipalityBoundariesHighRes: 'mapbox://johnrbpalmer.48qdct4s',
23-
municipalitySourceLayerLowRes: 'spain_municipality_boundaries-7m7u82',
24-
municipalitySourceLayerHighRes: 'spain_municipality_boundaries-dzvpt0',
25-
mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww',
26-
maxVRI: 0.3,
27-
observationsUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/refs/heads/main/data/model_training_reports_lonlat.csv',
28-
// File pattern for 1km grid GeoTIFFs from MosquitoAlertES repository
29-
gridFilePattern: 'a004_MA_predictions_all_spain_aemet_weather_forecast_pred_raster_brms_MC10_1000_{date}.tiff',
30-
gridMaxVRI: 0.3
31-
}
22+
municipalityBoundariesHighRes: 'mapbox://johnrbpalmer.48qdct4s',
23+
municipalitySourceLayerLowRes: 'spain_municipality_boundaries-7m7u82',
24+
municipalitySourceLayerHighRes: 'spain_municipality_boundaries-dzvpt0',
25+
mapboxAccessToken: 'pk.eyJ1Ijoiam9obnJicGFsbWVyIiwiYSI6ImFRTXhoaHcifQ.UwIptK0Is5dJdN8q-1djww',
26+
maxVRI: 0.3,
27+
observationsUrl: 'https://raw.githubusercontent.com/Mosquito-Alert/MosquitoAlertES/refs/heads/main/data/model_training_reports_lonlat.json',
28+
// File pattern for 1km grid GeoTIFFs from MosquitoAlertES repository
29+
gridFilePattern: 'a004_MA_predictions_all_spain_aemet_weather_forecast_pred_raster_brms_MC10_1000_{date}.tiff',
30+
gridMaxVRI: 0.3
31+
}
3232
},
3333
comingSoon: false
3434
},

0 commit comments

Comments
 (0)