air quality: API routes + frontend heatmap overlay (pending Cobb approval)
This commit is contained in:
parent
28cacd540f
commit
33cdc3a50c
2 changed files with 690 additions and 0 deletions
145
docs/AIR-API-PATCH.py
Normal file
145
docs/AIR-API-PATCH.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
AIR QUALITY API — Routes to add to /app/app.py on Rackham (adamaps-api container)
|
||||
Review this file, then apply via: docker exec adamaps-api python3 /tmp/apply_air_patch.py
|
||||
DO NOT apply until Cobb approves.
|
||||
"""
|
||||
|
||||
# ─── EPA AQI calculation ──────────────────────────────────────────────────────
|
||||
def pm25_to_aqi(pm25):
|
||||
if pm25 is None: return None
|
||||
breakpoints = [
|
||||
(0.0, 12.0, 0, 50),
|
||||
(12.1, 35.4, 51, 100),
|
||||
(35.5, 55.4, 101, 150),
|
||||
(55.5, 150.4, 151, 200),
|
||||
(150.5, 250.4, 201, 300),
|
||||
(250.5, 350.4, 301, 400),
|
||||
(350.5, 500.4, 401, 500),
|
||||
]
|
||||
for c_lo, c_hi, i_lo, i_hi in breakpoints:
|
||||
if c_lo <= pm25 <= c_hi:
|
||||
return round((i_hi - i_lo) / (c_hi - c_lo) * (pm25 - c_lo) + i_lo)
|
||||
return 500 if pm25 > 500 else 0
|
||||
|
||||
def init_air_table():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS air_quality (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_id VARCHAR(64),
|
||||
sampled_at TIMESTAMP,
|
||||
lat DOUBLE PRECISION,
|
||||
lon DOUBLE PRECISION,
|
||||
alt DOUBLE PRECISION,
|
||||
gps_fix BOOLEAN DEFAULT FALSE,
|
||||
pm1_0 FLOAT,
|
||||
pm2_5 FLOAT,
|
||||
pm10 FLOAT,
|
||||
temperature_c FLOAT,
|
||||
humidity_pct FLOAT,
|
||||
pressure_hpa FLOAT,
|
||||
gas_resistance_ohm FLOAT,
|
||||
aqi INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS air_latlon_idx ON air_quality (lat, lon)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS air_sampled_idx ON air_quality (sampled_at DESC)")
|
||||
conn.commit(); cur.close(); conn.close()
|
||||
except Exception as e:
|
||||
print(f"air table init: {e}")
|
||||
|
||||
# ─── /api/ingest/air ──────────────────────────────────────────────────────────
|
||||
# POST — auth required (X-AdaMaps-Key)
|
||||
# Body: {"device_id": "blackbox-pi", "readings": [{sampled_at, lat, lon, pm2_5_ug_m3, ...}]}
|
||||
# Returns: {"inserted": N}
|
||||
def ingest_air():
|
||||
if request.headers.get("X-AdaMaps-Key") != API_KEY:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
data = request.json
|
||||
if not data: return jsonify({"error": "invalid"}), 400
|
||||
device_id = data.get("device_id", "unknown")
|
||||
readings = data.get("readings", [data] if "sampled_at" in data else [])
|
||||
if not readings: return jsonify({"error": "no readings"}), 400
|
||||
init_air_table()
|
||||
inserted = 0
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
for r in readings:
|
||||
try:
|
||||
pm25 = r.get("pm2_5_ug_m3") or r.get("pm2_5")
|
||||
cur.execute("""
|
||||
INSERT INTO air_quality
|
||||
(device_id, sampled_at, lat, lon, alt, gps_fix,
|
||||
pm1_0, pm2_5, pm10, temperature_c, humidity_pct,
|
||||
pressure_hpa, gas_resistance_ohm, aqi)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
r.get("device_id", device_id), r.get("sampled_at"),
|
||||
r.get("lat"), r.get("lon"), r.get("alt"), r.get("gps_fix", False),
|
||||
r.get("pm1_0_ug_m3"), pm25, r.get("pm10_ug_m3"),
|
||||
r.get("temperature_c"), r.get("humidity_pct"),
|
||||
r.get("pressure_hpa"), r.get("gas_resistance_ohm"),
|
||||
pm25_to_aqi(pm25)
|
||||
))
|
||||
inserted += 1
|
||||
except: conn.rollback()
|
||||
conn.commit(); cur.close(); conn.close()
|
||||
except Exception as e:
|
||||
return jsonify({"error": "db_unavailable", "detail": str(e)}), 503
|
||||
return jsonify({"inserted": inserted, "device_id": device_id})
|
||||
|
||||
# ─── /api/air/heatmap ─────────────────────────────────────────────────────────
|
||||
# GET ?metric=aqi|pm2_5 &hours=24
|
||||
# Returns [[lat, lon, intensity_0_to_1], ...] for Leaflet.heat
|
||||
def air_heatmap():
|
||||
metric = request.args.get("metric", "aqi")
|
||||
hours = request.args.get("hours", 24, type=int)
|
||||
col = "aqi" if metric == "aqi" else "pm2_5"
|
||||
max_val = 300.0 if metric == "aqi" else 150.0
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT lat, lon, {col} FROM air_quality
|
||||
WHERE sampled_at > NOW() - INTERVAL '%s hours'
|
||||
AND lat IS NOT NULL AND lon IS NOT NULL
|
||||
AND {col} IS NOT NULL AND gps_fix = TRUE
|
||||
ORDER BY sampled_at DESC LIMIT 50000
|
||||
""", (hours,))
|
||||
rows = [[float(r[0]), float(r[1]), min(float(r[2]) / max_val, 1.0)]
|
||||
for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
return jsonify(rows)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# ─── /api/air/latest ──────────────────────────────────────────────────────────
|
||||
# GET — most recent reading per device
|
||||
def air_latest():
|
||||
try:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT ON (device_id)
|
||||
device_id, sampled_at, lat, lon,
|
||||
pm1_0, pm2_5, pm10, temperature_c, humidity_pct, aqi
|
||||
FROM air_quality
|
||||
WHERE sampled_at > NOW() - INTERVAL '1 hour'
|
||||
ORDER BY device_id, sampled_at DESC
|
||||
""")
|
||||
rows = [{"device_id": r[0], "sampled_at": r[1].isoformat() if r[1] else None,
|
||||
"lat": float(r[2]) if r[2] else None, "lon": float(r[3]) if r[3] else None,
|
||||
"pm1_0": r[4], "pm2_5": r[5], "pm10": r[6],
|
||||
"temperature_c": r[7], "humidity_pct": r[8], "aqi": r[9]}
|
||||
for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
return jsonify(rows)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# ─── Flask route registration (add to app after existing routes) ───────────────
|
||||
# app.add_url_rule("/api/ingest/air", "ingest_air", ingest_air, methods=["POST"])
|
||||
# app.add_url_rule("/api/air/heatmap", "air_heatmap", air_heatmap, methods=["GET"])
|
||||
# app.add_url_rule("/api/air/latest", "air_latest", air_latest, methods=["GET"])
|
||||
545
docs/adamaps-index-preview.html
Normal file
545
docs/adamaps-index-preview.html
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AdaMaps — Verified Sign Map</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css" />
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0d0d0d;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Courier New', monospace;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#header {
|
||||
background: #111;
|
||||
border-bottom: 1px solid #222;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 1000;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
color: #00e5ff;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#status {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
}
|
||||
#status.live { color: #00e5ff; }
|
||||
#status.error { color: #ff4444; }
|
||||
#map {
|
||||
flex: 1;
|
||||
background: #111;
|
||||
}
|
||||
.leaflet-tile-pane { filter: brightness(0.7) invert(1) hue-rotate(180deg) saturate(0.6); }
|
||||
.leaflet-popup-content-wrapper {
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #333;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.leaflet-popup-tip { background: #1a1a1a; }
|
||||
.leaflet-popup-content { margin: 10px 14px; }
|
||||
.popup-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
|
||||
.popup-row { color: #aaa; }
|
||||
.popup-row span { color: #e0e0e0; }
|
||||
.popup-verified { color: #00ff88; font-weight: bold; }
|
||||
.popup-unverified { color: #ffaa00; }
|
||||
.popup-img {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
border: 1px solid #333;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
.popup-img:hover { border-color: #00e5ff; }
|
||||
|
||||
/* Marker styles */
|
||||
.verified-marker {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #00ff88;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 6px rgba(0,255,136,0.5);
|
||||
}
|
||||
.unverified-marker {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ffaa00;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.multi-obs-marker {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #00e5ff;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
#count-badge {
|
||||
background: #00e5ff;
|
||||
color: #000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#verified-badge {
|
||||
background: #00ff88;
|
||||
color: #000;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.toggle-btn {
|
||||
background: #222;
|
||||
border: 1px solid #444;
|
||||
color: #e0e0e0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
background: #333;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: #00e5ff;
|
||||
color: #000;
|
||||
border-color: #00e5ff;
|
||||
}
|
||||
|
||||
.marker-cluster-small {
|
||||
background-color: rgba(0, 229, 255, 0.4);
|
||||
}
|
||||
.marker-cluster-small div {
|
||||
background-color: rgba(0, 229, 255, 0.8);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
.marker-cluster-medium {
|
||||
background-color: rgba(0, 200, 255, 0.4);
|
||||
}
|
||||
.marker-cluster-medium div {
|
||||
background-color: rgba(0, 200, 255, 0.8);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
.marker-cluster-large {
|
||||
background-color: rgba(0, 150, 255, 0.4);
|
||||
}
|
||||
.air-legend {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
padding: 8px 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.72rem;
|
||||
color: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.legend-title { color: #00e5ff; font-weight: bold; margin-bottom: 4px; }
|
||||
.legend-row { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||
.legend-row span {
|
||||
display: inline-block; width: 12px; height: 12px;
|
||||
border-radius: 2px; flex-shrink: 0;
|
||||
}
|
||||
.marker-cluster-large div {
|
||||
background-color: rgba(0, 150, 255, 0.8);
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>AdaMaps</h1>
|
||||
<span id="count-badge">0 signs</span>
|
||||
<span id="verified-badge">0 verified</span>
|
||||
<button class="toggle-btn active" id="btn-signs">Signs</button>
|
||||
<button class="toggle-btn" id="btn-raw">Raw</button>
|
||||
<button class="toggle-btn" id="btn-verified">Verified Only</button>
|
||||
<span style="color:#333;margin:0 4px">|</span>
|
||||
<button class="toggle-btn" id="btn-aqi">AQI Heat</button>
|
||||
<button class="toggle-btn" id="btn-pm25">PM2.5 Heat</button>
|
||||
<span id="status">loading...</span>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script>
|
||||
const map = L.map('map', {
|
||||
center: [33.88, -118.0],
|
||||
zoom: 10,
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
});
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const countBadge = document.getElementById('count-badge');
|
||||
const verifiedBadge = document.getElementById('verified-badge');
|
||||
const btnSigns = document.getElementById('btn-signs');
|
||||
const btnRaw = document.getElementById('btn-raw');
|
||||
const btnVerified = document.getElementById('btn-verified');
|
||||
const btnAqi = document.getElementById('btn-aqi');
|
||||
const btnPm25 = document.getElementById('btn-pm25');
|
||||
|
||||
const markerCluster = L.markerClusterGroup({
|
||||
chunkedLoading: true,
|
||||
maxClusterRadius: 50,
|
||||
spiderfyOnMaxZoom: true,
|
||||
showCoverageOnHover: false,
|
||||
disableClusteringAtZoom: 16
|
||||
});
|
||||
map.addLayer(markerCluster);
|
||||
|
||||
let currentMode = 'signs';
|
||||
let verifiedOnly = false;
|
||||
|
||||
function createVerifiedIcon() {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="verified-marker"></div>',
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
popupAnchor: [0, -10]
|
||||
});
|
||||
}
|
||||
|
||||
function createUnverifiedIcon() {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div class="unverified-marker"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5],
|
||||
popupAnchor: [0, -8]
|
||||
});
|
||||
}
|
||||
|
||||
function createMultiObsIcon(count) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="multi-obs-marker">${count}</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
popupAnchor: [0, -10]
|
||||
});
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return 'unknown';
|
||||
try { return new Date(ts).toLocaleString(); } catch(e) { return ts; }
|
||||
}
|
||||
|
||||
function buildSignPopup(s) {
|
||||
const verifiedClass = s.verified ? 'popup-verified' : 'popup-unverified';
|
||||
const verifiedText = s.verified ? '✓ VERIFIED' : 'unverified';
|
||||
return `
|
||||
<div class="popup-title">${s.sign_type || 'Unknown Sign'}</div>
|
||||
<div class="popup-row ${verifiedClass}">${verifiedText}</div>
|
||||
<div class="popup-row">Observations: <span>${s.observations}</span></div>
|
||||
<div class="popup-row">Devices: <span>${s.devices}</span></div>
|
||||
<div class="popup-row">Confidence: <span>${(s.confidence * 100).toFixed(1)}%</span></div>
|
||||
<div class="popup-row">Heading: <span>${s.heading || '—'}</span></div>
|
||||
<div class="popup-row">First seen: <span>${formatTimestamp(s.first_seen)}</span></div>
|
||||
<div class="popup-row">Last seen: <span>${formatTimestamp(s.last_seen)}</span></div>
|
||||
<div class="popup-row">Coords: <span>${s.lat.toFixed(6)}, ${s.lon.toFixed(6)}</span></div>
|
||||
${s.image_url ? `<img class="popup-img" src="${s.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${s.image_url}','_blank')">` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function buildDetectionPopup(d) {
|
||||
return `
|
||||
<div class="popup-title">${d.class_label || 'Detection'}</div>
|
||||
<div class="popup-row">Device: <span>${d.device_id || '—'}</span></div>
|
||||
<div class="popup-row">Time: <span>${formatTimestamp(d.ts)}</span></div>
|
||||
<div class="popup-row">Confidence: <span>${d.confidence ? (d.confidence * 100).toFixed(1) + '%' : '—'}</span></div>
|
||||
<div class="popup-row">Coords: <span>${d.lat?.toFixed(6)}, ${d.lon?.toFixed(6)}</span></div>
|
||||
${d.image_url ? `<img class="popup-img" src="${d.image_url}" loading="lazy" alt="detection photo" onclick="window.open('${d.image_url}','_blank')">` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchAndPlot() {
|
||||
statusEl.textContent = 'fetching...';
|
||||
statusEl.className = '';
|
||||
|
||||
try {
|
||||
let url, data;
|
||||
|
||||
if (currentMode === 'signs') {
|
||||
url = verifiedOnly
|
||||
? 'https://api.adamaps.org/api/signs?verified=true&limit=5000'
|
||||
: 'https://api.adamaps.org/api/signs?limit=5000';
|
||||
} else {
|
||||
url = 'https://api.adamaps.org/api/detections?limit=5000';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) {
|
||||
if (res.status === 404 && currentMode === 'signs') {
|
||||
statusEl.textContent = 'Run clustering first';
|
||||
statusEl.className = 'error';
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
data = await res.json();
|
||||
|
||||
markerCluster.clearLayers();
|
||||
const bounds = [];
|
||||
let verifiedCount = 0;
|
||||
|
||||
if (currentMode === 'signs') {
|
||||
data.forEach(s => {
|
||||
if (s.lat == null || s.lon == null) return;
|
||||
|
||||
let icon;
|
||||
if (s.verified) {
|
||||
verifiedCount++;
|
||||
icon = s.observations > 1 ? createMultiObsIcon(s.observations) : createVerifiedIcon();
|
||||
} else {
|
||||
icon = createUnverifiedIcon();
|
||||
}
|
||||
|
||||
const marker = L.marker([s.lat, s.lon], { icon });
|
||||
marker.bindPopup(buildSignPopup(s));
|
||||
markerCluster.addLayer(marker);
|
||||
bounds.push([s.lat, s.lon]);
|
||||
});
|
||||
|
||||
countBadge.textContent = `${data.length.toLocaleString()} sign${data.length !== 1 ? 's' : ''}`;
|
||||
verifiedBadge.textContent = `${verifiedCount} verified`;
|
||||
} else {
|
||||
data.forEach(d => {
|
||||
const lat = d.lat ?? d.latitude;
|
||||
const lng = d.lng ?? d.longitude ?? d.lon;
|
||||
if (lat == null || lng == null) return;
|
||||
|
||||
const marker = L.marker([lat, lng], { icon: createUnverifiedIcon() });
|
||||
marker.bindPopup(buildDetectionPopup(d));
|
||||
markerCluster.addLayer(marker);
|
||||
bounds.push([lat, lng]);
|
||||
});
|
||||
|
||||
countBadge.textContent = `${data.length.toLocaleString()} detection${data.length !== 1 ? 's' : ''}`;
|
||||
verifiedBadge.textContent = '—';
|
||||
}
|
||||
|
||||
statusEl.textContent = `live · ${new Date().toLocaleTimeString()}`;
|
||||
statusEl.className = 'live';
|
||||
|
||||
if (bounds.length > 0 && map.getZoom() < 8) {
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
} catch(err) {
|
||||
statusEl.textContent = `error: ${err.message}`;
|
||||
statusEl.className = 'error';
|
||||
console.error('AdaMaps fetch error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Button handlers
|
||||
btnSigns.addEventListener('click', () => {
|
||||
currentMode = 'signs';
|
||||
verifiedOnly = false;
|
||||
btnSigns.classList.add('active');
|
||||
btnRaw.classList.remove('active');
|
||||
btnVerified.classList.remove('active');
|
||||
fetchAndPlot();
|
||||
});
|
||||
|
||||
btnRaw.addEventListener('click', () => {
|
||||
currentMode = 'raw';
|
||||
verifiedOnly = false;
|
||||
btnRaw.classList.add('active');
|
||||
btnSigns.classList.remove('active');
|
||||
btnVerified.classList.remove('active');
|
||||
fetchAndPlot();
|
||||
});
|
||||
|
||||
btnVerified.addEventListener('click', () => {
|
||||
currentMode = 'signs';
|
||||
verifiedOnly = true;
|
||||
btnVerified.classList.add('active');
|
||||
btnSigns.classList.remove('active');
|
||||
btnRaw.classList.remove('active');
|
||||
fetchAndPlot();
|
||||
});
|
||||
|
||||
fetchAndPlot();
|
||||
setInterval(fetchAndPlot, 60000);
|
||||
|
||||
// ============ AIR QUALITY OVERLAY (add to adamaps.org/index.html) ============
|
||||
// Requires: leaflet-heat plugin
|
||||
// Add to <head>:
|
||||
// <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
|
||||
// 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 = `
|
||||
<div class="legend-title">AQI / PM2.5</div>
|
||||
<div class="legend-row"><span style="background:#00e400"></span> Good (0-50)</div>
|
||||
<div class="legend-row"><span style="background:#ffff00"></span> Moderate (51-100)</div>
|
||||
<div class="legend-row"><span style="background:#ff7e00"></span> Unhealthy* (101-150)</div>
|
||||
<div class="legend-row"><span style="background:#ff0000"></span> Unhealthy (151-200)</div>
|
||||
<div class="legend-row"><span style="background:#8f3f97"></span> Very Unhealthy (201-300)</div>
|
||||
<div class="legend-row"><span style="background:#7e0023"></span> Hazardous (301+)</div>
|
||||
`;
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue