varroa/docs/adamaps-index-preview.html

545 lines
18 KiB
HTML

<!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: '&copy; 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);
// 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();
if (airLegend) { map.removeControl(airLegend); airLegend = null; }
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`;
// Show legend when overlay is active
if (!airLegend) { airLegend = buildAirLegend(); airLegend.addTo(map); }
}
// ── 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;
}
let airLegend = null;
// Call this after map init — wires up buttons and legend
function initAirOverlay() {
btnAqi.addEventListener('click', () => toggleAirOverlay('aqi'));
btnPm25.addEventListener('click', () => toggleAirOverlay('pm25'));
}
// Init air overlay buttons + legend after map is ready
initAirOverlay();
</script>
</body>
</html>