545 lines
18 KiB
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: '© 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>
|