infinite-agents-public/sdg_viz/sdg_viz_4.html

720 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDG Network - Air Quality Monitoring (Performance Optimized)</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
#container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
header {
background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
color: white;
padding: 30px;
text-align: center;
}
h1 {
margin: 0 0 10px 0;
font-size: 2.5em;
font-weight: 600;
}
.subtitle {
font-size: 1.1em;
opacity: 0.9;
margin: 0;
}
#status-bar {
background: #f8fafc;
padding: 15px 30px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: white;
border-radius: 20px;
font-size: 0.9em;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.cached {
background: #10b981;
}
.status-dot.live {
background: #3b82f6;
}
.status-dot.loading {
background: #f59e0b;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
#controls {
padding: 20px 30px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.95em;
font-weight: 500;
transition: background 0.2s;
}
button:hover {
background: #2563eb;
}
button.secondary {
background: #64748b;
}
button.secondary:hover {
background: #475569;
}
#visualization {
padding: 30px;
position: relative;
}
svg {
display: block;
margin: 0 auto;
background: #fafafa;
border-radius: 8px;
}
.node {
cursor: pointer;
transition: all 0.3s;
}
.node:hover {
stroke: #000;
stroke-width: 3px;
}
.link {
stroke: #94a3b8;
stroke-opacity: 0.6;
transition: all 0.3s;
}
.link:hover {
stroke: #475569;
stroke-opacity: 1;
stroke-width: 3px;
}
.node-label {
font-size: 12px;
font-weight: 500;
pointer-events: none;
text-shadow: 0 0 3px white, 0 0 3px white;
}
#legend {
position: absolute;
top: 50px;
right: 50px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
max-width: 250px;
}
.legend-title {
font-weight: 600;
margin-bottom: 12px;
font-size: 1.1em;
color: #1e293b;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 0.9em;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
margin-right: 10px;
border: 1px solid #e2e8f0;
}
#stats {
padding: 30px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-label {
font-size: 0.85em;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.stat-value {
font-size: 2em;
font-weight: 700;
color: #1e293b;
}
footer {
padding: 25px 30px;
background: #1e293b;
color: #cbd5e1;
font-size: 0.9em;
line-height: 1.6;
}
footer strong {
color: #f1f5f9;
}
.footer-section {
margin-bottom: 15px;
}
.footer-section:last-child {
margin-bottom: 0;
}
.perf-badge {
display: inline-block;
background: #10b981;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 600;
margin-left: 8px;
}
</style>
</head>
<body>
<div id="container">
<header>
<h1>🌍 Global Air Quality Network</h1>
<p class="subtitle">SDG 11: Sustainable Cities & Communities - Performance Optimized Edition</p>
</header>
<div id="status-bar">
<div class="status-indicator">
<div class="status-dot loading" id="status-dot"></div>
<span id="status-text">Initializing...</span>
</div>
<div class="status-indicator">
<span id="perf-text">Load time: --</span>
</div>
</div>
<div id="controls">
<button onclick="refreshData()">🔄 Refresh Data</button>
<button onclick="clearCache()" class="secondary">🗑️ Clear Cache</button>
<button onclick="togglePhysics()" class="secondary" id="physics-btn">⏸️ Pause Physics</button>
</div>
<div id="visualization">
<svg id="network"></svg>
<div id="legend">
<div class="legend-title">Air Quality Categories</div>
<div id="legend-items"></div>
</div>
</div>
<div id="stats">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Cities Monitored</div>
<div class="stat-value" id="stat-cities">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Network Connections</div>
<div class="stat-value" id="stat-connections">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg PM2.5 (μg/m³)</div>
<div class="stat-value" id="stat-avg">--</div>
</div>
<div class="stat-card">
<div class="stat-label">Data Source</div>
<div class="stat-value" id="stat-source" style="font-size: 1.2em;">--</div>
</div>
</div>
</div>
<footer>
<div class="footer-section">
<strong>Performance Optimization Strategy:</strong><span class="perf-badge">CACHED</span>
<br>✅ localStorage caching with 24-hour TTL for instant subsequent loads
<br>✅ Embedded sample dataset for instant initial render (&lt;100ms)
<br>✅ Background API fetch with progressive enhancement
<br>✅ Single optimized endpoint (OpenAQ API) with 5-second timeout
</div>
<div class="footer-section">
<strong>Color Encoding Fix:</strong>
<br>✅ D3 categorical color scheme (d3.schemeTableau10) for distinct category colors
<br>✅ Proper ordinal scale mapping quality levels to unique colors
<br>✅ Clear visual legend showing all categories
</div>
<div class="footer-section">
<strong>Web Learning Source:</strong> Observable D3 Color Schemes (https://observablehq.com/@d3/color-schemes)
<br><strong>Techniques Applied:</strong> Categorical palettes (Tableau10), ordinal scale usage, color accessibility
</div>
<div class="footer-section">
<strong>Iteration:</strong> sdg_viz_4.html | <strong>Generated:</strong> 2025-10-09 | <strong>Focus:</strong> Data Caching & Performance
</div>
</footer>
</div>
<script>
// EMBEDDED SAMPLE DATA for instant render
const SAMPLE_DATA = {
nodes: [
{ id: "London", country: "UK", pm25: 12.5, quality: "Good", lat: 51.5, lon: -0.1 },
{ id: "Paris", country: "France", pm25: 15.2, quality: "Good", lat: 48.8, lon: 2.3 },
{ id: "Delhi", country: "India", pm25: 153.7, quality: "Unhealthy", lat: 28.6, lon: 77.2 },
{ id: "Beijing", country: "China", pm25: 89.4, quality: "Moderate", lat: 39.9, lon: 116.4 },
{ id: "Tokyo", country: "Japan", pm25: 18.3, quality: "Good", lat: 35.6, lon: 139.7 },
{ id: "New York", country: "USA", pm25: 22.1, quality: "Good", lat: 40.7, lon: -74.0 },
{ id: "Los Angeles", country: "USA", pm25: 35.6, quality: "Moderate", lat: 34.0, lon: -118.2 },
{ id: "Mumbai", country: "India", pm25: 127.3, quality: "Unhealthy", lat: 19.0, lon: 72.8 },
{ id: "São Paulo", country: "Brazil", pm25: 28.9, quality: "Good", lat: -23.5, lon: -46.6 },
{ id: "Sydney", country: "Australia", pm25: 11.7, quality: "Good", lat: -33.8, lon: 151.2 },
{ id: "Cairo", country: "Egypt", pm25: 94.2, quality: "Moderate", lat: 30.0, lon: 31.2 },
{ id: "Lagos", country: "Nigeria", pm25: 67.5, quality: "Moderate", lat: 6.5, lon: 3.4 },
{ id: "Mexico City", country: "Mexico", pm25: 42.8, quality: "Moderate", lat: 19.4, lon: -99.1 },
{ id: "Singapore", country: "Singapore", pm25: 19.4, quality: "Good", lat: 1.3, lon: 103.8 },
{ id: "Dubai", country: "UAE", pm25: 71.2, quality: "Moderate", lat: 25.2, lon: 55.2 }
],
links: [],
timestamp: Date.now(),
source: "embedded"
};
// Generate links based on geographic proximity and quality similarity
SAMPLE_DATA.nodes.forEach((node, i) => {
SAMPLE_DATA.nodes.forEach((other, j) => {
if (i < j) {
const dist = Math.sqrt(
Math.pow(node.lat - other.lat, 2) +
Math.pow(node.lon - other.lon, 2)
);
const qualitySimilar = node.quality === other.quality;
// Connect if close geographically OR same quality category
if (dist < 50 || qualitySimilar) {
SAMPLE_DATA.links.push({
source: node.id,
target: other.id,
value: qualitySimilar ? 2 : 1
});
}
}
});
});
// CACHE CONFIGURATION
const CACHE_KEY = 'sdg_air_quality_data';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
// PERFORMANCE TRACKING
const startTime = performance.now();
// D3 CATEGORICAL COLOR SCHEME (from web learning)
const qualityCategories = ["Good", "Moderate", "Unhealthy", "Very Unhealthy", "Hazardous"];
const colorScale = d3.scaleOrdinal()
.domain(qualityCategories)
.range(d3.schemeTableau10);
// VISUALIZATION STATE
let simulation;
let physicsEnabled = true;
// DOM ELEMENTS
const svg = d3.select("#network");
const width = 1200;
const height = 700;
svg.attr("width", width).attr("height", height);
// UPDATE STATUS INDICATOR
function updateStatus(text, type = 'loading') {
document.getElementById('status-text').textContent = text;
const dot = document.getElementById('status-dot');
dot.className = `status-dot ${type}`;
}
// CHECK CACHE
function getCachedData() {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const data = JSON.parse(cached);
const age = Date.now() - data.timestamp;
if (age > CACHE_TTL) {
localStorage.removeItem(CACHE_KEY);
return null;
}
return data;
} catch (e) {
console.error('Cache read error:', e);
return null;
}
}
// SAVE TO CACHE
function setCachedData(data) {
try {
data.timestamp = Date.now();
localStorage.setItem(CACHE_KEY, JSON.stringify(data));
} catch (e) {
console.error('Cache write error:', e);
}
}
// FETCH LIVE DATA FROM API
async function fetchLiveData() {
updateStatus('Fetching live data...', 'loading');
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
// OpenAQ API - fast and reliable
const response = await fetch(
'https://api.openaq.org/v2/latest?limit=20&parameter=pm25&order_by=city',
{ signal: controller.signal }
);
clearTimeout(timeout);
if (!response.ok) throw new Error('API request failed');
const json = await response.json();
const apiData = transformOpenAQData(json.results);
setCachedData(apiData);
updateStatus('Using live data', 'live');
return apiData;
} catch (error) {
console.error('API fetch error:', error);
updateStatus('API timeout - using cached/sample data', 'cached');
return null;
}
}
// TRANSFORM OpenAQ API DATA
function transformOpenAQData(results) {
const nodes = results
.filter(r => r.measurements && r.measurements[0])
.slice(0, 15)
.map(r => {
const pm25 = r.measurements[0].value;
let quality = "Good";
if (pm25 > 150) quality = "Hazardous";
else if (pm25 > 100) quality = "Very Unhealthy";
else if (pm25 > 50) quality = "Unhealthy";
else if (pm25 > 25) quality = "Moderate";
return {
id: r.city,
country: r.country,
pm25: pm25,
quality: quality,
lat: r.coordinates?.latitude || 0,
lon: r.coordinates?.longitude || 0
};
});
const links = [];
nodes.forEach((node, i) => {
nodes.forEach((other, j) => {
if (i < j && node.quality === other.quality) {
links.push({
source: node.id,
target: other.id,
value: 2
});
}
});
});
return { nodes, links, source: "live" };
}
// RENDER VISUALIZATION
function renderNetwork(data) {
svg.selectAll("*").remove();
const g = svg.append("g");
// Create force simulation
simulation = d3.forceSimulation(data.nodes)
.force("link", d3.forceLink(data.links).id(d => d.id).distance(120))
.force("charge", d3.forceManyBody().strength(-400))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(40));
// Links
const link = g.append("g")
.selectAll("line")
.data(data.links)
.join("line")
.attr("class", "link")
.attr("stroke-width", d => d.value);
// Nodes
const node = g.append("g")
.selectAll("circle")
.data(data.nodes)
.join("circle")
.attr("class", "node")
.attr("r", d => 8 + d.pm25 / 10)
.attr("fill", d => colorScale(d.quality))
.attr("stroke", "#fff")
.attr("stroke-width", 2)
.call(drag(simulation))
.on("mouseover", function(event, d) {
d3.select(this).attr("stroke-width", 4);
})
.on("mouseout", function() {
d3.select(this).attr("stroke-width", 2);
});
// Labels
const label = g.append("g")
.selectAll("text")
.data(data.nodes)
.join("text")
.attr("class", "node-label")
.text(d => `${d.id} (${d.pm25.toFixed(1)})`)
.attr("text-anchor", "middle")
.attr("dy", -20);
// Simulation tick
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
label
.attr("x", d => d.x)
.attr("y", d => d.y);
});
// Zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.5, 3])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Update stats
updateStats(data);
updateLegend();
}
// DRAG BEHAVIOR
function drag(simulation) {
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
// UPDATE STATISTICS
function updateStats(data) {
document.getElementById('stat-cities').textContent = data.nodes.length;
document.getElementById('stat-connections').textContent = data.links.length;
const avgPM25 = d3.mean(data.nodes, d => d.pm25);
document.getElementById('stat-avg').textContent = avgPM25.toFixed(1);
const sourceText = data.source === 'live' ? '🌐 Live' :
data.source === 'cached' ? '💾 Cached' : '📦 Sample';
document.getElementById('stat-source').textContent = sourceText;
}
// UPDATE LEGEND
function updateLegend() {
const legendItems = d3.select("#legend-items");
legendItems.selectAll("*").remove();
qualityCategories.forEach(category => {
const item = legendItems.append("div")
.attr("class", "legend-item");
item.append("div")
.attr("class", "legend-color")
.style("background-color", colorScale(category));
item.append("span")
.text(category);
});
}
// REFRESH DATA
async function refreshData() {
const liveData = await fetchLiveData();
if (liveData) {
renderNetwork(liveData);
}
}
// CLEAR CACHE
function clearCache() {
localStorage.removeItem(CACHE_KEY);
updateStatus('Cache cleared - using sample data', 'loading');
alert('Cache cleared! Click "Refresh Data" to fetch new live data.');
}
// TOGGLE PHYSICS
function togglePhysics() {
physicsEnabled = !physicsEnabled;
const btn = document.getElementById('physics-btn');
if (physicsEnabled) {
simulation.alphaTarget(0.3).restart();
btn.textContent = '⏸️ Pause Physics';
} else {
simulation.stop();
btn.textContent = '▶️ Resume Physics';
}
}
// INITIALIZE APPLICATION
async function initialize() {
updateStatus('Checking cache...', 'loading');
// Check cache first
const cached = getCachedData();
if (cached) {
// Use cached data immediately
updateStatus('Using cached data (fast load)', 'cached');
cached.source = 'cached';
renderNetwork(cached);
const loadTime = (performance.now() - startTime).toFixed(0);
document.getElementById('perf-text').textContent = `Load time: ${loadTime}ms ⚡`;
} else {
// Use sample data for instant render
updateStatus('Rendering sample data...', 'loading');
renderNetwork(SAMPLE_DATA);
const loadTime = (performance.now() - startTime).toFixed(0);
document.getElementById('perf-text').textContent = `Load time: ${loadTime}ms ⚡`;
// Fetch live data in background
setTimeout(async () => {
const liveData = await fetchLiveData();
if (liveData) {
renderNetwork(liveData);
}
}, 100);
}
}
// START
initialize();
</script>
</body>
</html>