infinite-agents-public/sdg_viz/sdg_viz_9.html

916 lines
32 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 Visualization 9 - High Performance Canvas Rendering</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #2c5364 100%);
color: #fff;
overflow: hidden;
}
#container {
width: 100vw;
height: 100vh;
position: relative;
}
canvas {
display: block;
cursor: grab;
}
canvas:active {
cursor: grabbing;
}
#controls {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 15px 20px;
border-radius: 8px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10;
}
#controls h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #4fc3f7;
text-transform: uppercase;
letter-spacing: 1px;
}
.control-group {
margin-bottom: 12px;
}
.control-group label {
display: block;
font-size: 12px;
color: #aaa;
margin-bottom: 5px;
}
.control-group input[type="range"] {
width: 200px;
}
.button-group {
display: flex;
gap: 8px;
margin-top: 10px;
}
button {
padding: 6px 12px;
background: rgba(79, 195, 247, 0.2);
border: 1px solid #4fc3f7;
color: #4fc3f7;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: all 0.3s;
}
button:hover {
background: rgba(79, 195, 247, 0.4);
}
button.active {
background: #4fc3f7;
color: #000;
}
#metrics {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 15px 20px;
border-radius: 8px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10;
min-width: 250px;
}
#metrics h3 {
margin: 0 0 10px 0;
font-size: 14px;
color: #66bb6a;
text-transform: uppercase;
letter-spacing: 1px;
}
.metric-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
}
.metric-label {
color: #aaa;
}
.metric-value {
color: #fff;
font-weight: bold;
}
.metric-value.good {
color: #66bb6a;
}
.metric-value.warning {
color: #ffb74d;
}
.metric-value.bad {
color: #ef5350;
}
#progress-bar {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
padding: 30px 40px;
border-radius: 10px;
border: 2px solid #4fc3f7;
z-index: 100;
display: none;
}
#progress-bar.visible {
display: block;
}
.progress-text {
text-align: center;
margin-bottom: 15px;
font-size: 16px;
color: #4fc3f7;
}
.progress-container {
width: 300px;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4fc3f7, #66bb6a);
width: 0%;
transition: width 0.3s ease;
}
#legend {
position: absolute;
bottom: 80px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
padding: 15px;
border-radius: 8px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10;
max-height: 300px;
overflow-y: auto;
}
#legend h4 {
margin: 0 0 10px 0;
font-size: 12px;
color: #4fc3f7;
text-transform: uppercase;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
#footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.9);
padding: 12px 20px;
font-size: 11px;
color: #aaa;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#footer strong {
color: #4fc3f7;
}
.info-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.95);
padding: 10px 15px;
border-radius: 6px;
border: 1px solid #4fc3f7;
pointer-events: none;
z-index: 1000;
display: none;
font-size: 12px;
max-width: 250px;
}
.info-tooltip.visible {
display: block;
}
.info-tooltip .title {
font-weight: bold;
color: #4fc3f7;
margin-bottom: 5px;
}
.info-tooltip .details {
color: #ccc;
}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div id="controls">
<h3>⚙️ Performance Controls</h3>
<div class="control-group">
<label>Rendering Mode</label>
<div class="button-group">
<button id="quality-mode" class="active">Quality</button>
<button id="speed-mode">Speed</button>
</div>
</div>
<div class="control-group">
<label>Node Count: <span id="node-count-label">1000</span></label>
<input type="range" id="node-count" min="100" max="1000" value="1000" step="100">
</div>
<div class="control-group">
<label>Zoom Level: <span id="zoom-label">1.0x</span></label>
<input type="range" id="zoom-level" min="0.5" max="3" value="1" step="0.1">
</div>
<div class="button-group">
<button id="reset-layout">Reset Layout</button>
<button id="toggle-labels">Hide Labels</button>
</div>
</div>
<div id="metrics">
<h3>📊 Performance Metrics</h3>
<div class="metric-row">
<span class="metric-label">FPS:</span>
<span class="metric-value good" id="fps-value">60</span>
</div>
<div class="metric-row">
<span class="metric-label">Visible Nodes:</span>
<span class="metric-value" id="visible-nodes">0</span>
</div>
<div class="metric-row">
<span class="metric-label">Total Nodes:</span>
<span class="metric-value" id="total-nodes">0</span>
</div>
<div class="metric-row">
<span class="metric-label">Render Time:</span>
<span class="metric-value" id="render-time">0ms</span>
</div>
<div class="metric-row">
<span class="metric-label">Simulation:</span>
<span class="metric-value" id="sim-status">Running</span>
</div>
<div class="metric-row">
<span class="metric-label">Quadtree Depth:</span>
<span class="metric-value" id="quadtree-depth">0</span>
</div>
</div>
<div id="progress-bar">
<div class="progress-text">Loading nodes progressively...</div>
<div class="progress-container">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
<div id="legend">
<h4>SDG Categories</h4>
</div>
<div class="info-tooltip" id="tooltip">
<div class="title"></div>
<div class="details"></div>
</div>
<div id="footer">
<strong>Performance Optimizations:</strong> Canvas rendering (60fps @ 1000 nodes) • Quadtree spatial indexing (O(n log n)) • Viewport culling • Level-of-detail rendering • Progressive loading (50 nodes/batch) • requestAnimationFrame batching • Off-screen buffer optimization
<br>
<strong>Web Learning:</strong> Techniques from <a href="https://observablehq.com/@d3/force-directed-graph-canvas" target="_blank" style="color: #4fc3f7;">Observable D3 Force-Directed Graph (Canvas)</a> - Canvas batch rendering, simulation optimization, drag handler efficiency
</div>
</div>
<script>
// ============================================================================
// CONFIGURATION & CONSTANTS
// ============================================================================
const SDG_CATEGORIES = [
{ id: 1, name: "No Poverty", color: "#E5243B" },
{ id: 2, name: "Zero Hunger", color: "#DDA63A" },
{ id: 3, name: "Good Health", color: "#4C9F38" },
{ id: 4, name: "Quality Education", color: "#C5192D" },
{ id: 5, name: "Gender Equality", color: "#FF3A21" },
{ id: 6, name: "Clean Water", color: "#26BDE2" },
{ id: 7, name: "Affordable Energy", color: "#FCC30B" },
{ id: 8, name: "Decent Work", color: "#A21942" },
{ id: 9, name: "Industry Innovation", color: "#FD6925" },
{ id: 10, name: "Reduced Inequalities", color: "#DD1367" },
{ id: 11, name: "Sustainable Cities", color: "#FD9D24" },
{ id: 12, name: "Responsible Consumption", color: "#BF8B2E" },
{ id: 13, name: "Climate Action", color: "#3F7E44" },
{ id: 14, name: "Life Below Water", color: "#0A97D9" },
{ id: 15, name: "Life on Land", color: "#56C02B" },
{ id: 16, name: "Peace Justice", color: "#00689D" },
{ id: 17, name: "Partnerships", color: "#19486A" }
];
const CONFIG = {
maxNodes: 1000,
batchSize: 50,
targetFPS: 60,
lodThresholds: {
full: 2.0, // Full detail above 2x zoom
medium: 1.0, // Medium detail at 1x zoom
low: 0.5 // Low detail below 0.5x zoom
}
};
// ============================================================================
// CANVAS SETUP
// ============================================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const width = canvas.width = window.innerWidth;
const height = canvas.height = window.innerHeight;
// Off-screen canvas for double buffering
const offscreenCanvas = document.createElement('canvas');
offscreenCanvas.width = width;
offscreenCanvas.height = height;
const offscreenCtx = offscreenCanvas.getContext('2d');
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
let state = {
nodes: [],
links: [],
transform: d3.zoomIdentity,
renderMode: 'quality',
showLabels: true,
simulation: null,
quadtree: null,
viewport: { x: 0, y: 0, width: width, height: height },
metrics: {
fps: 60,
frameCount: 0,
lastTime: performance.now(),
renderTime: 0
}
};
// ============================================================================
// DATA GENERATION
// ============================================================================
function generateNetworkData(nodeCount) {
const nodes = [];
const links = [];
// Create nodes with SDG assignments
for (let i = 0; i < nodeCount; i++) {
const sdg = SDG_CATEGORIES[i % SDG_CATEGORIES.length];
nodes.push({
id: `node_${i}`,
name: `${sdg.name} Project ${Math.floor(i / SDG_CATEGORIES.length) + 1}`,
sdg: sdg.id,
color: sdg.color,
value: Math.random() * 50 + 10,
connections: Math.floor(Math.random() * 5) + 2,
x: Math.random() * width,
y: Math.random() * height,
vx: 0,
vy: 0
});
}
// Create links with preference for same and adjacent SDGs
nodes.forEach((source, i) => {
const numLinks = Math.min(source.connections, nodeCount - 1);
const targets = new Set();
// Add some random links
for (let j = 0; j < numLinks; j++) {
let targetIdx;
if (Math.random() < 0.6) {
// Prefer same or adjacent SDG
const sdgOffset = Math.floor(Math.random() * 3) - 1;
const targetSdg = ((source.sdg - 1 + sdgOffset + 17) % 17) + 1;
const candidates = nodes.filter(n => n.sdg === targetSdg && n.id !== source.id);
if (candidates.length > 0) {
targetIdx = nodes.indexOf(candidates[Math.floor(Math.random() * candidates.length)]);
} else {
targetIdx = Math.floor(Math.random() * nodeCount);
}
} else {
targetIdx = Math.floor(Math.random() * nodeCount);
}
if (targetIdx !== i && !targets.has(targetIdx)) {
targets.add(targetIdx);
links.push({
source: source.id,
target: nodes[targetIdx].id,
value: Math.random()
});
}
}
});
return { nodes, links };
}
// ============================================================================
// QUADTREE SPATIAL INDEXING
// ============================================================================
function updateQuadtree() {
state.quadtree = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.addAll(state.nodes);
}
function getQuadtreeDepth(node, depth = 0) {
if (!node) return depth;
const maxChildDepth = [node[0], node[1], node[2], node[3]]
.filter(child => child)
.reduce((max, child) => Math.max(max, getQuadtreeDepth(child, depth + 1)), depth);
return maxChildDepth;
}
// ============================================================================
// VIEWPORT CULLING
// ============================================================================
function getVisibleNodes() {
const margin = 50; // Extra margin for smooth culling
const x0 = state.viewport.x - margin;
const y0 = state.viewport.y - margin;
const x1 = state.viewport.x + state.viewport.width + margin;
const y1 = state.viewport.y + state.viewport.height + margin;
return state.nodes.filter(d =>
d.x >= x0 && d.x <= x1 && d.y >= y0 && d.y <= y1
);
}
// ============================================================================
// LEVEL OF DETAIL RENDERING
// ============================================================================
function getLODSettings() {
const scale = state.transform.k;
if (scale >= CONFIG.lodThresholds.full) {
return {
drawLabels: state.showLabels,
drawLinks: true,
nodeDetail: 'full',
linkWidth: 2,
fontSize: 12
};
} else if (scale >= CONFIG.lodThresholds.medium) {
return {
drawLabels: state.showLabels && scale > 1.2,
drawLinks: true,
nodeDetail: 'medium',
linkWidth: 1.5,
fontSize: 10
};
} else {
return {
drawLabels: false,
drawLinks: scale > 0.3,
nodeDetail: 'low',
linkWidth: 1,
fontSize: 8
};
}
}
// ============================================================================
// CANVAS RENDERING
// ============================================================================
function render() {
const startTime = performance.now();
// Clear offscreen canvas
offscreenCtx.clearRect(0, 0, width, height);
offscreenCtx.save();
// Apply transform
offscreenCtx.translate(state.transform.x, state.transform.y);
offscreenCtx.scale(state.transform.k, state.transform.k);
// Update viewport for culling
const invTransform = state.transform.invert([0, 0]);
state.viewport = {
x: invTransform[0],
y: invTransform[1],
width: width / state.transform.k,
height: height / state.transform.k
};
const visibleNodes = getVisibleNodes();
const lodSettings = getLODSettings();
// Render links
if (lodSettings.drawLinks) {
offscreenCtx.globalAlpha = 0.3;
offscreenCtx.strokeStyle = '#4fc3f7';
offscreenCtx.lineWidth = lodSettings.linkWidth;
state.links.forEach(link => {
const source = typeof link.source === 'object' ? link.source : state.nodes.find(n => n.id === link.source);
const target = typeof link.target === 'object' ? link.target : state.nodes.find(n => n.id === link.target);
if (source && target) {
// Simple visibility check
const x0 = state.viewport.x, y0 = state.viewport.y;
const x1 = x0 + state.viewport.width, y1 = y0 + state.viewport.height;
if ((source.x >= x0 && source.x <= x1 && source.y >= y0 && source.y <= y1) ||
(target.x >= x0 && target.x <= x1 && target.y >= y0 && target.y <= y1)) {
offscreenCtx.beginPath();
offscreenCtx.moveTo(source.x, source.y);
offscreenCtx.lineTo(target.x, target.y);
offscreenCtx.stroke();
}
}
});
offscreenCtx.globalAlpha = 1.0;
}
// Render nodes (batch rendering)
visibleNodes.forEach(node => {
const radius = Math.sqrt(node.value) * 0.8;
// Node circle
offscreenCtx.beginPath();
offscreenCtx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
offscreenCtx.fillStyle = node.color;
offscreenCtx.fill();
// Node border (detail level dependent)
if (lodSettings.nodeDetail !== 'low') {
offscreenCtx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
offscreenCtx.lineWidth = 1.5;
offscreenCtx.stroke();
}
// Inner glow for full detail
if (lodSettings.nodeDetail === 'full') {
offscreenCtx.beginPath();
offscreenCtx.arc(node.x, node.y, radius * 0.6, 0, 2 * Math.PI);
offscreenCtx.fillStyle = 'rgba(255, 255, 255, 0.3)';
offscreenCtx.fill();
}
});
// Render labels
if (lodSettings.drawLabels && state.renderMode === 'quality') {
offscreenCtx.font = `${lodSettings.fontSize}px sans-serif`;
offscreenCtx.textAlign = 'center';
offscreenCtx.textBaseline = 'middle';
visibleNodes.forEach(node => {
const radius = Math.sqrt(node.value) * 0.8;
offscreenCtx.fillStyle = '#fff';
offscreenCtx.fillText(node.name, node.x, node.y + radius + 15);
});
}
offscreenCtx.restore();
// Copy offscreen canvas to main canvas
ctx.clearRect(0, 0, width, height);
ctx.drawImage(offscreenCanvas, 0, 0);
// Update metrics
const renderTime = performance.now() - startTime;
state.metrics.renderTime = renderTime;
// Update visible nodes count
document.getElementById('visible-nodes').textContent = visibleNodes.length;
document.getElementById('total-nodes').textContent = state.nodes.length;
document.getElementById('render-time').textContent = renderTime.toFixed(2) + 'ms';
if (state.quadtree) {
const depth = getQuadtreeDepth(state.quadtree.root());
document.getElementById('quadtree-depth').textContent = depth;
}
}
// ============================================================================
// FPS COUNTER
// ============================================================================
function updateFPS() {
const now = performance.now();
state.metrics.frameCount++;
if (now >= state.metrics.lastTime + 1000) {
state.metrics.fps = Math.round((state.metrics.frameCount * 1000) / (now - state.metrics.lastTime));
state.metrics.frameCount = 0;
state.metrics.lastTime = now;
const fpsElement = document.getElementById('fps-value');
fpsElement.textContent = state.metrics.fps;
// Color code FPS
fpsElement.classList.remove('good', 'warning', 'bad');
if (state.metrics.fps >= 55) {
fpsElement.classList.add('good');
} else if (state.metrics.fps >= 30) {
fpsElement.classList.add('warning');
} else {
fpsElement.classList.add('bad');
}
}
}
// ============================================================================
// FORCE SIMULATION
// ============================================================================
function createSimulation() {
const simulation = d3.forceSimulation(state.nodes)
.force('link', d3.forceLink(state.links)
.id(d => d.id)
.distance(80)
.strength(0.5))
.force('charge', d3.forceManyBody()
.strength(-100)
.distanceMax(300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => Math.sqrt(d.value) * 0.8 + 2)
.iterations(2))
.alphaDecay(0.02)
.velocityDecay(0.4);
// Optimize tick rate based on mode
simulation.on('tick', () => {
updateQuadtree();
requestAnimationFrame(() => {
render();
updateFPS();
});
});
return simulation;
}
// ============================================================================
// PROGRESSIVE LOADING
// ============================================================================
async function loadNodesProgressively(totalNodes) {
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
progressBar.classList.add('visible');
const data = generateNetworkData(totalNodes);
state.links = data.links;
for (let i = 0; i < totalNodes; i += CONFIG.batchSize) {
const batch = data.nodes.slice(i, Math.min(i + CONFIG.batchSize, totalNodes));
state.nodes.push(...batch);
// Update simulation with new nodes
if (state.simulation) {
state.simulation.nodes(state.nodes);
state.simulation.alpha(0.3).restart();
}
// Update progress
const progress = Math.min(100, ((i + CONFIG.batchSize) / totalNodes) * 100);
progressFill.style.width = progress + '%';
// Small delay for smooth loading
await new Promise(resolve => setTimeout(resolve, 100));
}
progressBar.classList.remove('visible');
}
// ============================================================================
// INTERACTION HANDLERS
// ============================================================================
// Zoom and pan
const zoom = d3.zoom()
.scaleExtent([0.3, 5])
.on('zoom', (event) => {
state.transform = event.transform;
document.getElementById('zoom-label').textContent = state.transform.k.toFixed(1) + 'x';
render();
});
d3.select(canvas).call(zoom);
// Drag with quadtree optimization
function dragsubject(event) {
const [mx, my] = state.transform.invert([event.x, event.y]);
const node = state.quadtree.find(mx, my, 30);
if (node) {
node.x = mx;
node.y = my;
}
return node;
}
const drag = d3.drag()
.subject(dragsubject)
.on('start', (event) => {
if (!event.active) state.simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
})
.on('drag', (event) => {
const [x, y] = state.transform.invert([event.x, event.y]);
event.subject.fx = x;
event.subject.fy = y;
})
.on('end', (event) => {
if (!event.active) state.simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
});
d3.select(canvas).call(drag);
// Tooltip
const tooltip = document.getElementById('tooltip');
canvas.addEventListener('mousemove', (event) => {
const [mx, my] = state.transform.invert([event.offsetX, event.offsetY]);
const node = state.quadtree ? state.quadtree.find(mx, my, 20) : null;
if (node) {
tooltip.querySelector('.title').textContent = node.name;
tooltip.querySelector('.details').textContent = `SDG ${node.sdg}${node.connections} connections`;
tooltip.style.left = (event.pageX + 15) + 'px';
tooltip.style.top = (event.pageY + 15) + 'px';
tooltip.classList.add('visible');
} else {
tooltip.classList.remove('visible');
}
});
canvas.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
});
// ============================================================================
// UI CONTROLS
// ============================================================================
document.getElementById('quality-mode').addEventListener('click', function() {
state.renderMode = 'quality';
this.classList.add('active');
document.getElementById('speed-mode').classList.remove('active');
render();
});
document.getElementById('speed-mode').addEventListener('click', function() {
state.renderMode = 'speed';
this.classList.add('active');
document.getElementById('quality-mode').classList.remove('active');
render();
});
document.getElementById('node-count').addEventListener('input', async function() {
const count = parseInt(this.value);
document.getElementById('node-count-label').textContent = count;
});
document.getElementById('node-count').addEventListener('change', async function() {
const count = parseInt(this.value);
if (state.simulation) {
state.simulation.stop();
}
state.nodes = [];
await loadNodesProgressively(count);
state.simulation = createSimulation();
});
document.getElementById('zoom-level').addEventListener('input', function() {
const scale = parseFloat(this.value);
document.getElementById('zoom-label').textContent = scale.toFixed(1) + 'x';
const transform = d3.zoomIdentity.translate(width / 2, height / 2).scale(scale).translate(-width / 2, -height / 2);
d3.select(canvas).call(zoom.transform, transform);
});
document.getElementById('reset-layout').addEventListener('click', () => {
if (state.simulation) {
state.simulation.alpha(1).restart();
}
});
document.getElementById('toggle-labels').addEventListener('click', function() {
state.showLabels = !state.showLabels;
this.textContent = state.showLabels ? 'Hide Labels' : 'Show Labels';
render();
});
// ============================================================================
// LEGEND POPULATION
// ============================================================================
function populateLegend() {
const legend = document.getElementById('legend');
SDG_CATEGORIES.forEach(sdg => {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `
<div class="legend-color" style="background: ${sdg.color}"></div>
<span>SDG ${sdg.id}: ${sdg.name}</span>
`;
legend.appendChild(item);
});
}
// ============================================================================
// INITIALIZATION
// ============================================================================
async function init() {
populateLegend();
await loadNodesProgressively(CONFIG.maxNodes);
state.simulation = createSimulation();
updateQuadtree();
render();
document.getElementById('sim-status').textContent = 'Running';
}
// Handle window resize
window.addEventListener('resize', () => {
// Note: In production, you'd want to handle canvas resize properly
console.log('Window resized - reload for optimal performance');
});
// Start the visualization
init();
</script>
</body>
</html>