// AQI color gradient: green→yellow→orange→red→purple
const AQI_GRADIENT = {
0.0: '#00e400', // Good (0-50)
0.17: '#ffff00', // Moderate (51-100)
0.33: '#ff7e00', // Unhealthy for sensitive (101-150)
0.50: '#ff0000', // Unhealthy (151-200)
0.67: '#8f3f97', // Very unhealthy (201-300)
1.0: '#7e0023', // Hazardous (301+)
};
const PM25_GRADIENT = {
0.0: '#00e5ff',
0.25: '#00ff88',
0.50: '#ffff00',
0.75: '#ff7e00',
1.0: '#ff0000',
};
function aqiLabel(aqi) {
if (aqi == null) return '—';
if (aqi <= 50) return `${aqi} Good`;
if (aqi <= 100) return `${aqi} Moderate`;
if (aqi <= 150) return `${aqi} Unhealthy (Sensitive)`;
if (aqi <= 200) return `${aqi} Unhealthy`;
if (aqi <= 300) return `${aqi} Very Unhealthy`;
return `${aqi} Hazardous`;
}
function aqiColor(aqi) {
if (aqi == null) return '#888';
if (aqi <= 50) return '#00e400';
if (aqi <= 100) return '#ffff00';
if (aqi <= 150) return '#ff7e00';
if (aqi <= 200) return '#ff0000';
if (aqi <= 300) return '#8f3f97';
return '#7e0023';
}
let airHeatAqi = null;
let airHeatPm25 = null;
let airMode = null; // null | 'aqi' | 'pm25'
async function fetchAirOverlay(metric) {
try {
const res = await fetch(
`https://api.adamaps.org/api/air/heatmap?metric=${metric}&hours=24`,
{ cache: 'no-store' }
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json(); // [[lat, lon, intensity], ...]
} catch (e) {
console.warn('Air overlay fetch failed:', e);
return [];
}
}
async function toggleAirOverlay(metric) {
// Clear both layers first
if (airHeatAqi) { map.removeLayer(airHeatAqi); airHeatAqi = null; }
if (airHeatPm25) { map.removeLayer(airHeatPm25); airHeatPm25 = null; }
// Toggle off if same metric clicked again
if (airMode === metric) {
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
statusEl.textContent = 'live · ' + new Date().toLocaleTimeString();
return;
}
airMode = metric;
btnAqi.classList.toggle('active', metric === 'aqi');
btnPm25.classList.toggle('active', metric === 'pm25');
statusEl.textContent = `loading ${metric.toUpperCase()} overlay...`;
const points = await fetchAirOverlay(metric);
if (!points.length) {
statusEl.textContent = 'no air quality data yet';
airMode = null;
btnAqi.classList.remove('active');
btnPm25.classList.remove('active');
return;
}
const gradient = metric === 'aqi' ? AQI_GRADIENT : PM25_GRADIENT;
const layer = L.heatLayer(points, {
radius: 25,
blur: 20,
maxZoom: 17,
gradient,
minOpacity: 0.3,
});
if (metric === 'aqi') airHeatAqi = layer;
else airHeatPm25 = layer;
layer.addTo(map);
statusEl.textContent = `${metric.toUpperCase()} overlay · ${points.length.toLocaleString()} pts`;
}
// ── Legend ────────────────────────────────────────────────────────────────────
function buildAirLegend() {
const legend = L.control({ position: 'bottomright' });
legend.onAdd = () => {
const div = L.DomUtil.create('div', 'air-legend');
div.innerHTML = `
AQI / PM2.5
Good (0-50)
Moderate (51-100)
Unhealthy* (101-150)
Unhealthy (151-200)
Very Unhealthy (201-300)
Hazardous (301+)
`;
return div;
};
return legend;
}
// Call this after map init — wires up buttons and legend
function initAirOverlay() {
buildAirLegend().addTo(map);
btnAqi.addEventListener('click', () => toggleAirOverlay('aqi'));
btnPm25.addEventListener('click', () => toggleAirOverlay('pm25'));
}
// Init air overlay buttons + legend after map is ready
initAirOverlay();