916 lines
32 KiB
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> |