720 lines
23 KiB
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 (<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¶meter=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> |