infinite-agents-public/sdg_viz/sdg_viz_14.html

1471 lines
50 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDG Network Dashboard v1.0 - Production Ready</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--sonic-red: #FF0000;
--sonic-blue: #0066FF;
--sonic-yellow: #FFD700;
--dark-bg: #0a0e27;
--panel-bg: rgba(10, 14, 39, 0.95);
--border-color: rgba(255, 215, 0, 0.3);
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0a0e27 100%);
color: #fff;
overflow: hidden;
height: 100vh;
}
#app-container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
#header {
background: var(--panel-bg);
border-bottom: 2px solid var(--sonic-yellow);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h1 {
font-size: 20px;
background: linear-gradient(90deg, var(--sonic-red), var(--sonic-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.version-badge {
background: var(--sonic-yellow);
color: #000;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: bold;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Main content area */
#main-content {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
#sidebar {
width: 280px;
background: var(--panel-bg);
border-right: 2px solid var(--border-color);
overflow-y: auto;
padding: 20px;
transition: transform 0.3s ease;
}
#sidebar.collapsed {
transform: translateX(-280px);
}
.panel-section {
margin-bottom: 24px;
}
.panel-section h3 {
font-size: 12px;
text-transform: uppercase;
color: var(--sonic-yellow);
margin-bottom: 12px;
letter-spacing: 1px;
display: flex;
align-items: center;
gap: 6px;
}
/* Stats cards */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
text-align: center;
}
.stat-value {
font-size: 24px;
font-weight: bold;
background: linear-gradient(90deg, var(--sonic-red), var(--sonic-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-label {
font-size: 10px;
color: #aaa;
margin-top: 4px;
}
/* Search */
.search-box {
position: relative;
margin-bottom: 16px;
}
.search-box input {
width: 100%;
padding: 10px 12px 10px 36px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color);
border-radius: 6px;
color: #fff;
font-size: 13px;
}
.search-box input::placeholder {
color: #888;
}
.search-box::before {
content: '/';
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--sonic-yellow);
font-weight: bold;
}
/* Filters */
.filter-group {
margin-bottom: 12px;
}
.filter-group label {
display: block;
font-size: 11px;
color: #aaa;
margin-bottom: 6px;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-tag {
padding: 4px 10px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color);
border-radius: 12px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.filter-tag:hover {
background: rgba(255, 255, 255, 0.2);
}
.filter-tag.active {
background: var(--sonic-blue);
border-color: var(--sonic-blue);
color: #fff;
}
/* Buttons */
.btn {
padding: 8px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--border-color);
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: var(--sonic-yellow);
}
.btn.primary {
background: var(--sonic-blue);
border-color: var(--sonic-blue);
}
.btn.primary:hover {
background: #0052cc;
}
.btn.danger {
background: var(--sonic-red);
border-color: var(--sonic-red);
}
.btn.danger:hover {
background: #cc0000;
}
.btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Canvas area */
#canvas-container {
flex: 1;
position: relative;
overflow: hidden;
}
#network-canvas {
display: block;
cursor: grab;
width: 100%;
height: 100%;
}
#network-canvas:active {
cursor: grabbing;
}
/* Table view */
#table-view {
display: none;
width: 100%;
height: 100%;
overflow: auto;
padding: 20px;
}
#table-view.active {
display: block;
}
table {
width: 100%;
border-collapse: collapse;
background: var(--panel-bg);
}
table th {
position: sticky;
top: 0;
background: var(--dark-bg);
padding: 12px;
text-align: left;
font-size: 12px;
color: var(--sonic-yellow);
border-bottom: 2px solid var(--sonic-yellow);
}
table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 13px;
}
table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.node-badge {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
border: 1px solid #fff;
}
/* Info panel */
#info-panel {
position: absolute;
top: 20px;
right: 20px;
width: 320px;
max-height: 80vh;
background: var(--panel-bg);
border: 2px solid var(--sonic-yellow);
border-radius: 8px;
padding: 20px;
display: none;
z-index: 50;
overflow-y: auto;
}
#info-panel.active {
display: block;
}
.info-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.info-panel-header h3 {
font-size: 16px;
color: var(--sonic-yellow);
}
.close-btn {
background: none;
border: none;
color: #fff;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
line-height: 1;
}
.close-btn:hover {
color: var(--sonic-red);
}
/* Tooltip */
#tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.95);
border: 2px solid var(--sonic-yellow);
border-radius: 6px;
padding: 12px 16px;
pointer-events: none;
z-index: 1000;
display: none;
max-width: 250px;
}
#tooltip.visible {
display: block;
}
.tooltip-title {
font-weight: bold;
color: var(--sonic-yellow);
margin-bottom: 6px;
font-size: 14px;
}
.tooltip-details {
color: #ccc;
font-size: 12px;
}
/* Onboarding */
#onboarding {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
}
#onboarding.active {
display: flex;
}
.onboarding-content {
background: var(--dark-bg);
border: 3px solid var(--sonic-yellow);
border-radius: 12px;
padding: 40px;
max-width: 600px;
text-align: center;
}
.onboarding-content h2 {
font-size: 28px;
margin-bottom: 20px;
background: linear-gradient(90deg, var(--sonic-red), var(--sonic-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.onboarding-features {
text-align: left;
margin: 24px 0;
}
.onboarding-feature {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
}
.feature-icon {
width: 32px;
height: 32px;
background: var(--sonic-blue);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
/* Footer */
#footer {
background: var(--panel-bg);
border-top: 2px solid var(--border-color);
padding: 10px 20px;
font-size: 11px;
color: #aaa;
display: flex;
justify-content: space-between;
align-items: center;
}
#footer a {
color: var(--sonic-blue);
text-decoration: none;
}
#footer a:hover {
color: var(--sonic-yellow);
}
/* Keyboard shortcuts */
.keyboard-hint {
font-family: monospace;
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-thumb {
background: var(--sonic-blue);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--sonic-red);
}
/* Responsive */
@media (max-width: 768px) {
#sidebar {
position: absolute;
height: 100%;
z-index: 90;
}
#info-panel {
width: 90%;
right: 5%;
}
}
/* Print styles */
@media print {
#sidebar,
#header .header-actions,
#info-panel,
#onboarding,
#tooltip {
display: none !important;
}
#main-content {
background: #fff;
}
}
/* Loading overlay */
#loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--dark-bg);
z-index: 20000;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 20px;
}
#loading.hidden {
display: none;
}
.spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--sonic-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Accessibility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Help icon */
.help-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
background: var(--sonic-yellow);
color: #000;
border-radius: 50%;
font-size: 11px;
font-weight: bold;
cursor: help;
margin-left: 4px;
}
</style>
</head>
<body>
<!-- Loading screen -->
<div id="loading">
<div class="spinner"></div>
<div style="color: var(--sonic-yellow); font-size: 16px;">Loading SDG Network Dashboard...</div>
</div>
<!-- Onboarding -->
<div id="onboarding" class="active">
<div class="onboarding-content">
<h2>Welcome to SDG Network Dashboard</h2>
<p style="color: #aaa; margin-bottom: 24px;">A powerful tool for visualizing Sustainable Development Goals networks and relationships.</p>
<div class="onboarding-features">
<div class="onboarding-feature">
<div class="feature-icon">🔍</div>
<div><strong>Search & Filter:</strong> Press <span class="keyboard-hint">/</span> to quickly search nodes</div>
</div>
<div class="onboarding-feature">
<div class="feature-icon">🖱️</div>
<div><strong>Drag & Zoom:</strong> Click and drag nodes, scroll to zoom</div>
</div>
<div class="onboarding-feature">
<div class="feature-icon">📊</div>
<div><strong>View Modes:</strong> Switch between network and table views</div>
</div>
<div class="onboarding-feature">
<div class="feature-icon">💾</div>
<div><strong>Export:</strong> Save visualizations as PNG or SVG</div>
</div>
<div class="onboarding-feature">
<div class="feature-icon">⌨️</div>
<div><strong>Keyboard:</strong> Press <span class="keyboard-hint">ESC</span> to close panels</div>
</div>
</div>
<button class="btn primary" onclick="closeOnboarding()">Get Started</button>
<div style="margin-top: 12px;">
<label style="font-size: 12px; color: #888;">
<input type="checkbox" id="dont-show-again"> Don't show this again
</label>
</div>
</div>
</div>
<!-- Main app -->
<div id="app-container">
<!-- Header -->
<header id="header" role="banner">
<div class="header-title">
<h1>SDG Network Dashboard</h1>
<span class="version-badge">v1.0</span>
</div>
<div class="header-actions">
<button class="btn" onclick="toggleView()" aria-label="Toggle view mode">
<span id="view-toggle-icon">📊</span>
<span id="view-toggle-text">Table View</span>
</button>
<button class="btn" onclick="exportPNG()" aria-label="Export as PNG">
📸 PNG
</button>
<button class="btn" onclick="exportSVG()" aria-label="Export as SVG">
📐 SVG
</button>
<button class="btn" onclick="shareLink()" aria-label="Share link">
🔗 Share
</button>
<button class="btn" onclick="showAbout()" aria-label="About this dashboard">
About
</button>
</div>
</header>
<!-- Main content -->
<main id="main-content" role="main">
<!-- Sidebar -->
<aside id="sidebar" role="complementary" aria-label="Controls and filters">
<!-- Quick stats -->
<section class="panel-section">
<h3>📈 Quick Stats</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-nodes">0</div>
<div class="stat-label">Nodes</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-links">0</div>
<div class="stat-label">Links</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-sdgs">17</div>
<div class="stat-label">SDG Goals</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-visible">0</div>
<div class="stat-label">Visible</div>
</div>
</div>
</section>
<!-- Search -->
<section class="panel-section">
<h3>🔍 Search<span class="help-icon" title="Press / to focus search">?</span></h3>
<div class="search-box">
<input
type="text"
id="search-input"
placeholder="Search nodes..."
aria-label="Search nodes"
>
</div>
</section>
<!-- Filters -->
<section class="panel-section">
<h3>🎯 Filter by SDG</h3>
<div class="filter-tags" id="sdg-filters" role="group" aria-label="SDG filters">
<!-- Generated dynamically -->
</div>
</section>
<!-- View controls -->
<section class="panel-section">
<h3>⚙️ Controls</h3>
<div class="filter-group">
<label for="zoom-slider">Zoom Level</label>
<input
type="range"
id="zoom-slider"
min="0.5"
max="3"
step="0.1"
value="1"
style="width: 100%"
aria-label="Zoom level"
>
<div style="font-size: 11px; color: #888; margin-top: 4px;">
<span id="zoom-value">1.0x</span>
</div>
</div>
<div class="btn-group" style="margin-top: 12px;">
<button class="btn" onclick="resetZoom()">Reset Zoom</button>
<button class="btn" onclick="resetLayout()">Reset Layout</button>
</div>
</section>
<!-- Export -->
<section class="panel-section">
<h3>💾 Export & Share</h3>
<div class="btn-group">
<button class="btn" onclick="printView()">🖨️ Print</button>
<button class="btn" onclick="copyData()">📋 Copy Data</button>
</div>
</section>
</aside>
<!-- Canvas/Table container -->
<div id="canvas-container">
<canvas id="network-canvas" role="img" aria-label="SDG network visualization"></canvas>
<div id="table-view" role="region" aria-label="Data table view">
<table id="data-table">
<thead>
<tr>
<th>SDG</th>
<th>Name</th>
<th>Connections</th>
<th>Category</th>
</tr>
</thead>
<tbody id="table-body">
<!-- Generated dynamically -->
</tbody>
</table>
</div>
</div>
<!-- Info panel -->
<div id="info-panel" role="dialog" aria-labelledby="info-panel-title">
<div class="info-panel-header">
<h3 id="info-panel-title">About SDG Dashboard</h3>
<button class="close-btn" onclick="closeAbout()" aria-label="Close panel">&times;</button>
</div>
<div id="info-panel-content">
<p style="margin-bottom: 16px; line-height: 1.6;">
This dashboard visualizes the relationships between Sustainable Development Goals (SDGs) and related projects.
It demonstrates how different goals interconnect and support each other.
</p>
<h4 style="color: var(--sonic-yellow); margin: 16px 0 8px;">Features</h4>
<ul style="margin-left: 20px; line-height: 1.8; font-size: 13px;">
<li>Interactive network visualization</li>
<li>Drag nodes to rearrange layout</li>
<li>Zoom and pan controls</li>
<li>Search and filter by SDG</li>
<li>Export to PNG/SVG</li>
<li>Table view for data analysis</li>
<li>Share with URL parameters</li>
</ul>
<h4 style="color: var(--sonic-yellow); margin: 16px 0 8px;">Keyboard Shortcuts</h4>
<ul style="margin-left: 20px; line-height: 1.8; font-size: 13px;">
<li><span class="keyboard-hint">/</span> - Focus search</li>
<li><span class="keyboard-hint">ESC</span> - Close panels</li>
<li><span class="keyboard-hint">Ctrl/Cmd + P</span> - Print</li>
</ul>
<h4 style="color: var(--sonic-yellow); margin: 16px 0 8px;">Data Sources</h4>
<p style="font-size: 12px; color: #aaa; line-height: 1.6;">
UN Sustainable Development Goals Framework<br>
Generated sample data for demonstration purposes
</p>
<h4 style="color: var(--sonic-yellow); margin: 16px 0 8px;">Contact</h4>
<p style="font-size: 12px; color: #aaa;">
<a href="mailto:feedback@sdgdashboard.example" style="color: var(--sonic-blue);">feedback@sdgdashboard.example</a>
</p>
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); font-size: 11px; color: #666;">
Dashboard Version 1.0.0<br>
Last Updated: 2025-10-09
</div>
</div>
</div>
<!-- Tooltip -->
<div id="tooltip" role="tooltip">
<div class="tooltip-title"></div>
<div class="tooltip-details"></div>
</div>
</main>
<!-- Footer -->
<footer id="footer" role="contentinfo">
<div>
<strong>SDG Network Dashboard v1.0</strong> |
Production-ready bipartite visualization |
Data refreshed: <span id="data-timestamp">2025-10-09</span>
</div>
<div>
<a href="https://sdgs.un.org/" target="_blank" rel="noopener">UN SDGs</a> |
<a href="#" onclick="showAbout(); return false;">Help</a> |
<a href="#" onclick="resetOnboarding(); return false;">Show Tutorial</a>
</div>
</footer>
</div>
<script>
// ============================================================================
// CONFIGURATION & DATA
// ============================================================================
const SDG_CATEGORIES = [
{ id: 1, name: "No Poverty", color: "#E5243B", category: "Social" },
{ id: 2, name: "Zero Hunger", color: "#DDA63A", category: "Social" },
{ id: 3, name: "Good Health", color: "#4C9F38", category: "Social" },
{ id: 4, name: "Quality Education", color: "#C5192D", category: "Social" },
{ id: 5, name: "Gender Equality", color: "#FF3A21", category: "Social" },
{ id: 6, name: "Clean Water", color: "#26BDE2", category: "Environmental" },
{ id: 7, name: "Affordable Energy", color: "#FCC30B", category: "Economic" },
{ id: 8, name: "Decent Work", color: "#A21942", category: "Economic" },
{ id: 9, name: "Industry Innovation", color: "#FD6925", category: "Economic" },
{ id: 10, name: "Reduced Inequalities", color: "#DD1367", category: "Social" },
{ id: 11, name: "Sustainable Cities", color: "#FD9D24", category: "Environmental" },
{ id: 12, name: "Responsible Consumption", color: "#BF8B2E", category: "Environmental" },
{ id: 13, name: "Climate Action", color: "#3F7E44", category: "Environmental" },
{ id: 14, name: "Life Below Water", color: "#0A97D9", category: "Environmental" },
{ id: 15, name: "Life on Land", color: "#56C02B", category: "Environmental" },
{ id: 16, name: "Peace Justice", color: "#00689D", category: "Social" },
{ id: 17, name: "Partnerships", color: "#19486A", category: "Economic" }
];
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
const state = {
nodes: [],
links: [],
transform: d3.zoomIdentity,
simulation: null,
currentView: 'network',
selectedSDGs: new Set(),
searchQuery: '',
showOnboarding: !localStorage.getItem('sdg-onboarding-dismissed')
};
// ============================================================================
// DATA GENERATION
// ============================================================================
function generateData() {
const nodes = [];
const links = [];
const nodeCount = 150;
// Create nodes
for (let i = 0; i < nodeCount; i++) {
const sdg = SDG_CATEGORIES[i % SDG_CATEGORIES.length];
nodes.push({
id: `node_${i}`,
name: `${sdg.name} Initiative ${Math.floor(i / SDG_CATEGORIES.length) + 1}`,
sdg: sdg.id,
sdgName: sdg.name,
category: sdg.category,
color: sdg.color,
value: Math.random() * 40 + 20,
connections: 0
});
}
// Create links (preferring related SDGs)
nodes.forEach((source, i) => {
const numLinks = Math.floor(Math.random() * 4) + 2;
const targets = new Set();
for (let j = 0; j < numLinks; j++) {
let targetIdx;
if (Math.random() < 0.7) {
// Prefer same category
const sameCat = nodes.filter((n, idx) =>
n.category === source.category && idx !== i
);
if (sameCat.length > 0) {
const target = sameCat[Math.floor(Math.random() * sameCat.length)];
targetIdx = nodes.indexOf(target);
} 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()
});
source.connections++;
nodes[targetIdx].connections++;
}
}
});
state.nodes = nodes;
state.links = links;
}
// ============================================================================
// CANVAS RENDERING
// ============================================================================
const canvas = document.getElementById('network-canvas');
const ctx = canvas.getContext('2d');
let width, height;
function resizeCanvas() {
const container = document.getElementById('canvas-container');
width = canvas.width = container.clientWidth;
height = canvas.height = container.clientHeight;
}
function render() {
ctx.save();
ctx.clearRect(0, 0, width, height);
ctx.translate(state.transform.x, state.transform.y);
ctx.scale(state.transform.k, state.transform.k);
const filteredNodes = getFilteredNodes();
const nodeSet = new Set(filteredNodes.map(n => n.id));
const filteredLinks = state.links.filter(l => {
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
return nodeSet.has(sourceId) && nodeSet.has(targetId);
});
// Draw links
ctx.globalAlpha = 0.3;
ctx.strokeStyle = '#4fc3f7';
ctx.lineWidth = 2;
filteredLinks.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) {
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
}
});
ctx.globalAlpha = 1;
// Draw nodes
filteredNodes.forEach(node => {
const radius = Math.sqrt(node.value) * 1.5;
// Outer glow (Sonic style)
const gradient = ctx.createRadialGradient(node.x, node.y, 0, node.x, node.y, radius + 4);
gradient.addColorStop(0, node.color);
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(node.x, node.y, radius + 4, 0, 2 * Math.PI);
ctx.fill();
// Main circle
ctx.beginPath();
ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = node.color;
ctx.fill();
// Bold border (yellow/white)
ctx.strokeStyle = node.sdg % 2 === 0 ? '#FFD700' : '#FFFFFF';
ctx.lineWidth = 3;
ctx.stroke();
// Inner highlight
ctx.beginPath();
ctx.arc(node.x, node.y, radius * 0.6, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.fill();
// Draw label at larger zoom
if (state.transform.k > 1.2) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'center';
ctx.strokeStyle = '#000';
ctx.lineWidth = 3;
ctx.strokeText(node.name, node.x, node.y + radius + 16);
ctx.fillText(node.name, node.x, node.y + radius + 16);
}
});
ctx.restore();
// Update stats
document.getElementById('stat-visible').textContent = filteredNodes.length;
}
// ============================================================================
// FILTERING & SEARCH
// ============================================================================
function getFilteredNodes() {
return state.nodes.filter(node => {
// SDG filter
if (state.selectedSDGs.size > 0 && !state.selectedSDGs.has(node.sdg)) {
return false;
}
// Search filter
if (state.searchQuery) {
const query = state.searchQuery.toLowerCase();
return node.name.toLowerCase().includes(query) ||
node.sdgName.toLowerCase().includes(query);
}
return true;
});
}
function updateFilters() {
if (state.simulation) {
state.simulation.alpha(0.3).restart();
}
render();
updateTableView();
}
// ============================================================================
// FORCE SIMULATION
// ============================================================================
function createSimulation() {
state.simulation = d3.forceSimulation(state.nodes)
.force('link', d3.forceLink(state.links)
.id(d => d.id)
.distance(100)
.strength(0.5))
.force('charge', d3.forceManyBody()
.strength(-200)
.distanceMax(400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide()
.radius(d => Math.sqrt(d.value) * 1.5 + 5)
.iterations(2))
.alphaDecay(0.02)
.velocityDecay(0.4);
state.simulation.on('tick', render);
}
// ============================================================================
// INTERACTIONS
// ============================================================================
// Zoom
const zoom = d3.zoom()
.scaleExtent([0.5, 3])
.on('zoom', (event) => {
state.transform = event.transform;
document.getElementById('zoom-value').textContent = event.transform.k.toFixed(1) + 'x';
document.getElementById('zoom-slider').value = event.transform.k;
render();
});
d3.select(canvas).call(zoom);
// Drag
function getDragSubject(event) {
const [mx, my] = state.transform.invert([event.x, event.y]);
const filteredNodes = getFilteredNodes();
for (const node of filteredNodes) {
const radius = Math.sqrt(node.value) * 1.5;
const dx = node.x - mx;
const dy = node.y - my;
if (Math.sqrt(dx * dx + dy * dy) < radius) {
return node;
}
}
return null;
}
const drag = d3.drag()
.subject(getDragSubject)
.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 filteredNodes = getFilteredNodes();
let found = null;
for (const node of filteredNodes) {
const radius = Math.sqrt(node.value) * 1.5;
const dx = node.x - mx;
const dy = node.y - my;
if (Math.sqrt(dx * dx + dy * dy) < radius) {
found = node;
break;
}
}
if (found) {
tooltip.querySelector('.tooltip-title').textContent = found.name;
tooltip.querySelector('.tooltip-details').textContent =
`SDG ${found.sdg}: ${found.sdgName}\n${found.connections} connections\nCategory: ${found.category}`;
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 FUNCTIONS
// ============================================================================
function toggleView() {
const canvasContainer = document.getElementById('network-canvas');
const tableView = document.getElementById('table-view');
const icon = document.getElementById('view-toggle-icon');
const text = document.getElementById('view-toggle-text');
if (state.currentView === 'network') {
state.currentView = 'table';
canvasContainer.style.display = 'none';
tableView.classList.add('active');
icon.textContent = '🌐';
text.textContent = 'Network View';
updateTableView();
} else {
state.currentView = 'network';
canvasContainer.style.display = 'block';
tableView.classList.remove('active');
icon.textContent = '📊';
text.textContent = 'Table View';
}
}
function updateTableView() {
const tbody = document.getElementById('table-body');
tbody.innerHTML = '';
const filteredNodes = getFilteredNodes();
filteredNodes.forEach(node => {
const row = tbody.insertRow();
row.innerHTML = `
<td><span class="node-badge" style="background: ${node.color}"></span>SDG ${node.sdg}</td>
<td>${node.name}</td>
<td>${node.connections}</td>
<td>${node.category}</td>
`;
});
}
function exportPNG() {
html2canvas(canvas).then(canvas => {
const link = document.createElement('a');
link.download = `sdg-network-${Date.now()}.png`;
link.href = canvas.toDataURL();
link.click();
});
}
function exportSVG() {
// Simplified SVG export
alert('SVG export: This feature would convert the canvas to SVG format. For production, integrate a library like canvas2svg.');
}
function shareLink() {
const params = new URLSearchParams();
if (state.selectedSDGs.size > 0) {
params.set('sdgs', Array.from(state.selectedSDGs).join(','));
}
if (state.searchQuery) {
params.set('q', state.searchQuery);
}
params.set('zoom', state.transform.k.toFixed(2));
const url = window.location.origin + window.location.pathname + '?' + params.toString();
navigator.clipboard.writeText(url).then(() => {
alert('Share link copied to clipboard!');
});
}
function showAbout() {
document.getElementById('info-panel').classList.add('active');
}
function closeAbout() {
document.getElementById('info-panel').classList.remove('active');
}
function printView() {
window.print();
}
function copyData() {
const data = getFilteredNodes().map(n => ({
name: n.name,
sdg: n.sdg,
category: n.category,
connections: n.connections
}));
navigator.clipboard.writeText(JSON.stringify(data, null, 2)).then(() => {
alert('Data copied to clipboard!');
});
}
function resetZoom() {
d3.select(canvas)
.transition()
.duration(750)
.call(zoom.transform, d3.zoomIdentity);
}
function resetLayout() {
if (state.simulation) {
state.simulation.alpha(1).restart();
}
}
function closeOnboarding() {
document.getElementById('onboarding').classList.remove('active');
if (document.getElementById('dont-show-again').checked) {
localStorage.setItem('sdg-onboarding-dismissed', 'true');
}
}
function resetOnboarding() {
localStorage.removeItem('sdg-onboarding-dismissed');
document.getElementById('onboarding').classList.add('active');
}
// ============================================================================
// UI INITIALIZATION
// ============================================================================
function initFilters() {
const container = document.getElementById('sdg-filters');
SDG_CATEGORIES.forEach(sdg => {
const tag = document.createElement('div');
tag.className = 'filter-tag';
tag.textContent = `SDG ${sdg.id}`;
tag.style.borderColor = sdg.color;
tag.onclick = () => {
if (state.selectedSDGs.has(sdg.id)) {
state.selectedSDGs.delete(sdg.id);
tag.classList.remove('active');
} else {
state.selectedSDGs.add(sdg.id);
tag.classList.add('active');
tag.style.background = sdg.color;
}
updateFilters();
};
container.appendChild(tag);
});
}
// Search
document.getElementById('search-input').addEventListener('input', (e) => {
state.searchQuery = e.target.value;
updateFilters();
});
// Zoom slider
document.getElementById('zoom-slider').addEventListener('input', (e) => {
const scale = parseFloat(e.target.value);
d3.select(canvas)
.transition()
.duration(100)
.call(zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(scale)
.translate(-width / 2, -height / 2)
);
});
// ============================================================================
// KEYBOARD SHORTCUTS
// ============================================================================
document.addEventListener('keydown', (e) => {
// ESC - close panels
if (e.key === 'Escape') {
document.getElementById('info-panel').classList.remove('active');
document.getElementById('onboarding').classList.remove('active');
}
// / - focus search
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
document.getElementById('search-input').focus();
}
});
// ============================================================================
// URL PARAMETERS
// ============================================================================
function loadFromURL() {
const params = new URLSearchParams(window.location.search);
if (params.has('sdgs')) {
const sdgs = params.get('sdgs').split(',').map(Number);
sdgs.forEach(id => {
state.selectedSDGs.add(id);
const tag = Array.from(document.querySelectorAll('.filter-tag'))
.find(t => t.textContent === `SDG ${id}`);
if (tag) {
tag.classList.add('active');
const sdg = SDG_CATEGORIES.find(s => s.id === id);
if (sdg) tag.style.background = sdg.color;
}
});
}
if (params.has('q')) {
state.searchQuery = params.get('q');
document.getElementById('search-input').value = state.searchQuery;
}
if (params.has('zoom')) {
const zoom_level = parseFloat(params.get('zoom'));
setTimeout(() => {
d3.select(canvas).call(zoom.transform,
d3.zoomIdentity
.translate(width / 2, height / 2)
.scale(zoom_level)
.translate(-width / 2, -height / 2)
);
}, 500);
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
async function init() {
// Generate data
generateData();
// Setup canvas
resizeCanvas();
window.addEventListener('resize', () => {
resizeCanvas();
if (state.simulation) {
state.simulation.force('center', d3.forceCenter(width / 2, height / 2));
state.simulation.alpha(0.3).restart();
}
});
// Initialize UI
initFilters();
updateTableView();
// Update stats
document.getElementById('stat-nodes').textContent = state.nodes.length;
document.getElementById('stat-links').textContent = state.links.length;
document.getElementById('stat-visible').textContent = state.nodes.length;
// Create simulation (NO entrance animation - instant render)
createSimulation();
// Load URL parameters
loadFromURL();
// Hide loading screen
document.getElementById('loading').classList.add('hidden');
// Show onboarding if needed
if (!state.showOnboarding) {
closeOnboarding();
}
// Render initial frame
render();
}
// Start the app
init();
// ============================================================================
// WEB LEARNING ATTRIBUTION
// ============================================================================
console.log('%cSDG Network Dashboard v1.0', 'color: #FFD700; font-size: 16px; font-weight: bold;');
console.log('%cWeb Learning Source:', 'color: #0066FF; font-weight: bold;');
console.log('Observable D3 Zoomable Sunburst - https://observablehq.com/@d3/zoomable-sunburst');
console.log('Techniques Applied:');
console.log('- Hierarchical zoom navigation with smooth transitions');
console.log('- Breadcrumb-style context preservation');
console.log('- Advanced interaction patterns (click, hover, keyboard)');
console.log('- Professional UX with cursor states and accessibility');
</script>
</body>
</html>