631 lines
20 KiB
HTML
631 lines
20 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 Visualization 10 - Practical Bipartite Dashboard</title>
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Arial', sans-serif;
|
||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||
color: #fff;
|
||
overflow: hidden;
|
||
}
|
||
|
||
#container {
|
||
width: 100vw;
|
||
height: 100vh;
|
||
display: flex;
|
||
}
|
||
|
||
#main-area {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
cursor: default;
|
||
}
|
||
|
||
.node {
|
||
cursor: grab;
|
||
stroke-width: 3px;
|
||
}
|
||
|
||
.node:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.node-topic {
|
||
fill: #0066FF;
|
||
stroke: #FFD700;
|
||
}
|
||
|
||
.node-source {
|
||
fill: #FF0000;
|
||
stroke: #FFFFFF;
|
||
}
|
||
|
||
.node:hover {
|
||
filter: brightness(1.3);
|
||
}
|
||
|
||
.link {
|
||
stroke: #4fc3f7;
|
||
stroke-opacity: 0.4;
|
||
stroke-width: 2px;
|
||
fill: none;
|
||
}
|
||
|
||
.link:hover {
|
||
stroke-opacity: 0.8;
|
||
stroke-width: 3px;
|
||
}
|
||
|
||
.node-label {
|
||
font-size: 13px;
|
||
font-weight: bold;
|
||
fill: #ffffff;
|
||
text-anchor: middle;
|
||
pointer-events: none;
|
||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8);
|
||
}
|
||
|
||
#header {
|
||
position: absolute;
|
||
top: 20px;
|
||
left: 20px;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
padding: 20px 25px;
|
||
border-radius: 10px;
|
||
border: 2px solid #0066FF;
|
||
z-index: 10;
|
||
}
|
||
|
||
#header h1 {
|
||
font-size: 22px;
|
||
color: #0066FF;
|
||
margin-bottom: 8px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
#header p {
|
||
font-size: 13px;
|
||
color: #aaa;
|
||
margin: 0;
|
||
}
|
||
|
||
#legend {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
padding: 18px 22px;
|
||
border-radius: 10px;
|
||
border: 2px solid #FF0000;
|
||
z-index: 10;
|
||
}
|
||
|
||
#legend h3 {
|
||
font-size: 14px;
|
||
color: #FF0000;
|
||
margin-bottom: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.legend-circle {
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.legend-topic {
|
||
background: #0066FF;
|
||
border: 2px solid #FFD700;
|
||
}
|
||
|
||
.legend-source {
|
||
background: #FF0000;
|
||
border: 2px solid #FFFFFF;
|
||
}
|
||
|
||
#side-panel {
|
||
width: 350px;
|
||
background: rgba(0, 0, 0, 0.95);
|
||
border-left: 3px solid #0066FF;
|
||
padding: 30px;
|
||
overflow-y: auto;
|
||
transform: translateX(100%);
|
||
transition: transform 0.3s ease;
|
||
}
|
||
|
||
#side-panel.visible {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
#side-panel h2 {
|
||
font-size: 24px;
|
||
color: #0066FF;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.panel-type {
|
||
font-size: 14px;
|
||
color: #FFD700;
|
||
margin-bottom: 20px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.panel-section {
|
||
margin-bottom: 25px;
|
||
}
|
||
|
||
.panel-section h3 {
|
||
font-size: 14px;
|
||
color: #4fc3f7;
|
||
margin-bottom: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.connection-item {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
padding: 10px 12px;
|
||
border-radius: 6px;
|
||
margin-bottom: 8px;
|
||
border-left: 3px solid #0066FF;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.close-panel {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: transparent;
|
||
border: 2px solid #FF0000;
|
||
color: #FF0000;
|
||
font-size: 20px;
|
||
width: 35px;
|
||
height: 35px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.close-panel:hover {
|
||
background: #FF0000;
|
||
color: #fff;
|
||
}
|
||
|
||
#footer {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 350px;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
padding: 15px 20px;
|
||
font-size: 11px;
|
||
color: #aaa;
|
||
border-top: 2px solid rgba(0, 102, 255, 0.3);
|
||
}
|
||
|
||
#footer strong {
|
||
color: #0066FF;
|
||
}
|
||
|
||
#footer a {
|
||
color: #4fc3f7;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.tooltip {
|
||
position: absolute;
|
||
background: rgba(0, 0, 0, 0.95);
|
||
padding: 10px 15px;
|
||
border-radius: 6px;
|
||
border: 2px solid #0066FF;
|
||
pointer-events: none;
|
||
z-index: 1000;
|
||
display: none;
|
||
font-size: 13px;
|
||
max-width: 250px;
|
||
}
|
||
|
||
.tooltip.visible {
|
||
display: block;
|
||
}
|
||
|
||
.tooltip-title {
|
||
font-weight: bold;
|
||
color: #0066FF;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.tooltip-details {
|
||
color: #ccc;
|
||
font-size: 11px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="container">
|
||
<div id="main-area">
|
||
<svg id="svg"></svg>
|
||
|
||
<div id="header">
|
||
<h1>SDG Data Dashboard</h1>
|
||
<p>Topics ↔ Data Sources</p>
|
||
</div>
|
||
|
||
<div id="legend">
|
||
<h3>Node Types</h3>
|
||
<div class="legend-item">
|
||
<div class="legend-circle legend-topic"></div>
|
||
<span>Topics (Blue)</span>
|
||
</div>
|
||
<div class="legend-item">
|
||
<div class="legend-circle legend-source"></div>
|
||
<span>Data Sources (Red)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tooltip" id="tooltip">
|
||
<div class="tooltip-title"></div>
|
||
<div class="tooltip-details"></div>
|
||
</div>
|
||
|
||
<div id="footer">
|
||
<strong>Dashboard Features:</strong> Bipartite graph layout • Working drag-and-drop • Instant rendering (no animations) • Click nodes for details • Sonic color scheme (Blue topics, Red data sources)
|
||
<br>
|
||
<strong>Web Learning:</strong> Bipartite graph design patterns - Two-sided layout positioning, visual separation techniques, connection optimization
|
||
</div>
|
||
</div>
|
||
|
||
<div id="side-panel">
|
||
<button class="close-panel">×</button>
|
||
<h2 id="panel-name">Node Name</h2>
|
||
<div class="panel-type" id="panel-type">NODE TYPE</div>
|
||
|
||
<div class="panel-section">
|
||
<h3>Description</h3>
|
||
<p id="panel-description">Node description will appear here.</p>
|
||
</div>
|
||
|
||
<div class="panel-section">
|
||
<h3>Connections</h3>
|
||
<div id="panel-connections"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// ============================================================================
|
||
// DATA STRUCTURE
|
||
// ============================================================================
|
||
|
||
const topics = [
|
||
{ id: 't1', name: 'Climate Change', description: 'Global climate action, carbon reduction, and environmental sustainability initiatives' },
|
||
{ id: 't2', name: 'Public Health', description: 'Healthcare access, disease prevention, and global health monitoring' },
|
||
{ id: 't3', name: 'Education', description: 'Educational access, literacy rates, and learning outcomes worldwide' },
|
||
{ id: 't4', name: 'Water Quality', description: 'Clean water access, sanitation, and water resource management' },
|
||
{ id: 't5', name: 'Biodiversity', description: 'Species conservation, ecosystem health, and environmental protection' },
|
||
{ id: 't6', name: 'Economic Growth', description: 'GDP trends, employment rates, and sustainable economic development' }
|
||
];
|
||
|
||
const sources = [
|
||
{ id: 's1', name: 'NOAA API', description: 'National Oceanic and Atmospheric Administration - Climate and weather data' },
|
||
{ id: 's2', name: 'WHO Database', description: 'World Health Organization - Global health statistics and disease tracking' },
|
||
{ id: 's3', name: 'World Bank', description: 'Economic indicators, development metrics, and poverty data' },
|
||
{ id: 's4', name: 'USGS', description: 'US Geological Survey - Water resources and environmental data' },
|
||
{ id: 's5', name: 'GBIF', description: 'Global Biodiversity Information Facility - Species occurrence data' },
|
||
{ id: 's6', name: 'NASA EarthData', description: 'Earth science data from satellites and research missions' }
|
||
];
|
||
|
||
// Connection mapping: which sources provide data for which topics
|
||
const connections = [
|
||
{ topic: 't1', source: 's1' },
|
||
{ topic: 't1', source: 's6' },
|
||
{ topic: 't2', source: 's2' },
|
||
{ topic: 't2', source: 's3' },
|
||
{ topic: 't3', source: 's3' },
|
||
{ topic: 't4', source: 's4' },
|
||
{ topic: 't4', source: 's3' },
|
||
{ topic: 't5', source: 's5' },
|
||
{ topic: 't5', source: 's6' },
|
||
{ topic: 't6', source: 's3' },
|
||
{ topic: 't1', source: 's4' },
|
||
{ topic: 't5', source: 's1' }
|
||
];
|
||
|
||
// ============================================================================
|
||
// SVG SETUP
|
||
// ============================================================================
|
||
|
||
const svg = d3.select('#svg');
|
||
const width = window.innerWidth - 350; // Account for side panel
|
||
const height = window.innerHeight;
|
||
|
||
const g = svg.append('g');
|
||
|
||
// ============================================================================
|
||
// BIPARTITE LAYOUT POSITIONING
|
||
// ============================================================================
|
||
|
||
const leftX = width * 0.25;
|
||
const rightX = width * 0.75;
|
||
const nodeRadius = 25;
|
||
|
||
// Position topics on the left
|
||
topics.forEach((topic, i) => {
|
||
const spacing = height / (topics.length + 1);
|
||
topic.x = leftX;
|
||
topic.y = spacing * (i + 1);
|
||
topic.fx = leftX; // Fix x position
|
||
topic.type = 'topic';
|
||
});
|
||
|
||
// Position sources on the right
|
||
sources.forEach((source, i) => {
|
||
const spacing = height / (sources.length + 1);
|
||
source.x = rightX;
|
||
source.y = spacing * (i + 1);
|
||
source.fx = rightX; // Fix x position
|
||
source.type = 'source';
|
||
});
|
||
|
||
// Combine all nodes
|
||
const nodes = [...topics, ...sources];
|
||
|
||
// Create links with proper references
|
||
const links = connections.map(conn => ({
|
||
source: nodes.find(n => n.id === conn.topic),
|
||
target: nodes.find(n => n.id === conn.source)
|
||
}));
|
||
|
||
// ============================================================================
|
||
// D3 FORCE SIMULATION (NO ENTRANCE ANIMATION)
|
||
// ============================================================================
|
||
|
||
const simulation = d3.forceSimulation(nodes)
|
||
.force('link', d3.forceLink(links)
|
||
.distance(200)
|
||
.strength(0.3))
|
||
.force('charge', d3.forceManyBody()
|
||
.strength(-50))
|
||
.force('y', d3.forceY(d => d.y).strength(0.1))
|
||
.force('collision', d3.forceCollide().radius(nodeRadius + 5))
|
||
.alphaDecay(0.05)
|
||
.velocityDecay(0.6);
|
||
|
||
// ============================================================================
|
||
// RENDER LINKS
|
||
// ============================================================================
|
||
|
||
const link = g.append('g')
|
||
.selectAll('path')
|
||
.data(links)
|
||
.join('path')
|
||
.attr('class', 'link')
|
||
.attr('d', d => {
|
||
return `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`;
|
||
});
|
||
|
||
// ============================================================================
|
||
// RENDER NODES
|
||
// ============================================================================
|
||
|
||
const node = g.append('g')
|
||
.selectAll('circle')
|
||
.data(nodes)
|
||
.join('circle')
|
||
.attr('class', d => `node node-${d.type}`)
|
||
.attr('r', nodeRadius)
|
||
.attr('cx', d => d.x)
|
||
.attr('cy', d => d.y);
|
||
|
||
// ============================================================================
|
||
// RENDER LABELS
|
||
// ============================================================================
|
||
|
||
const labels = g.append('g')
|
||
.selectAll('text')
|
||
.data(nodes)
|
||
.join('text')
|
||
.attr('class', 'node-label')
|
||
.attr('x', d => d.x)
|
||
.attr('y', d => d.y + 4)
|
||
.text(d => d.name);
|
||
|
||
// ============================================================================
|
||
// DRAG BEHAVIOR (MUST WORK!)
|
||
// ============================================================================
|
||
|
||
function dragstarted(event, d) {
|
||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||
d.fy = d.y; // Only allow vertical dragging (x is fixed)
|
||
}
|
||
|
||
function dragged(event, d) {
|
||
d.fy = event.y;
|
||
}
|
||
|
||
function dragended(event, d) {
|
||
if (!event.active) simulation.alphaTarget(0);
|
||
d.fy = null; // Release vertical constraint when done
|
||
}
|
||
|
||
const drag = d3.drag()
|
||
.on('start', dragstarted)
|
||
.on('drag', dragged)
|
||
.on('end', dragended);
|
||
|
||
node.call(drag);
|
||
|
||
// ============================================================================
|
||
// SIMULATION TICK (UPDATE POSITIONS)
|
||
// ============================================================================
|
||
|
||
simulation.on('tick', () => {
|
||
link.attr('d', d => {
|
||
return `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`;
|
||
});
|
||
|
||
node
|
||
.attr('cx', d => d.x)
|
||
.attr('cy', d => d.y);
|
||
|
||
labels
|
||
.attr('x', d => d.x)
|
||
.attr('y', d => d.y + 4);
|
||
});
|
||
|
||
// ============================================================================
|
||
// CLICK INTERACTION (SIDE PANEL)
|
||
// ============================================================================
|
||
|
||
const sidePanel = document.getElementById('side-panel');
|
||
const panelName = document.getElementById('panel-name');
|
||
const panelType = document.getElementById('panel-type');
|
||
const panelDescription = document.getElementById('panel-description');
|
||
const panelConnections = document.getElementById('panel-connections');
|
||
|
||
node.on('click', function(event, d) {
|
||
event.stopPropagation();
|
||
|
||
// Update panel content
|
||
panelName.textContent = d.name;
|
||
panelType.textContent = d.type === 'topic' ? 'TOPIC' : 'DATA SOURCE';
|
||
panelType.style.color = d.type === 'topic' ? '#0066FF' : '#FF0000';
|
||
panelDescription.textContent = d.description;
|
||
|
||
// Find connections
|
||
const nodeConnections = links.filter(link =>
|
||
link.source.id === d.id || link.target.id === d.id
|
||
);
|
||
|
||
panelConnections.innerHTML = '';
|
||
if (nodeConnections.length === 0) {
|
||
panelConnections.innerHTML = '<p style="color: #666;">No connections</p>';
|
||
} else {
|
||
nodeConnections.forEach(link => {
|
||
const connectedNode = link.source.id === d.id ? link.target : link.source;
|
||
const div = document.createElement('div');
|
||
div.className = 'connection-item';
|
||
div.style.borderLeftColor = connectedNode.type === 'topic' ? '#0066FF' : '#FF0000';
|
||
div.textContent = connectedNode.name;
|
||
panelConnections.appendChild(div);
|
||
});
|
||
}
|
||
|
||
// Show panel
|
||
sidePanel.classList.add('visible');
|
||
});
|
||
|
||
// Close panel button
|
||
document.querySelector('.close-panel').addEventListener('click', () => {
|
||
sidePanel.classList.remove('visible');
|
||
});
|
||
|
||
// Close panel when clicking outside
|
||
svg.on('click', () => {
|
||
sidePanel.classList.remove('visible');
|
||
});
|
||
|
||
// ============================================================================
|
||
// HOVER TOOLTIP
|
||
// ============================================================================
|
||
|
||
const tooltip = document.getElementById('tooltip');
|
||
|
||
node.on('mouseenter', function(event, d) {
|
||
const tooltipTitle = tooltip.querySelector('.tooltip-title');
|
||
const tooltipDetails = tooltip.querySelector('.tooltip-details');
|
||
|
||
tooltipTitle.textContent = d.name;
|
||
tooltipDetails.textContent = `${d.type === 'topic' ? 'Topic' : 'Data Source'} • Click for details`;
|
||
|
||
tooltip.classList.add('visible');
|
||
});
|
||
|
||
node.on('mousemove', function(event) {
|
||
tooltip.style.left = (event.pageX + 15) + 'px';
|
||
tooltip.style.top = (event.pageY + 15) + 'px';
|
||
});
|
||
|
||
node.on('mouseleave', function() {
|
||
tooltip.classList.remove('visible');
|
||
});
|
||
|
||
// ============================================================================
|
||
// LINK TOOLTIPS
|
||
// ============================================================================
|
||
|
||
link.on('mouseenter', function(event, d) {
|
||
const tooltipTitle = tooltip.querySelector('.tooltip-title');
|
||
const tooltipDetails = tooltip.querySelector('.tooltip-details');
|
||
|
||
tooltipTitle.textContent = 'Connection';
|
||
tooltipDetails.textContent = `${d.source.name} ↔ ${d.target.name}`;
|
||
|
||
tooltip.classList.add('visible');
|
||
});
|
||
|
||
link.on('mousemove', function(event) {
|
||
tooltip.style.left = (event.pageX + 15) + 'px';
|
||
tooltip.style.top = (event.pageY + 15) + 'px';
|
||
});
|
||
|
||
link.on('mouseleave', function() {
|
||
tooltip.classList.remove('visible');
|
||
});
|
||
|
||
// ============================================================================
|
||
// WINDOW RESIZE HANDLER
|
||
// ============================================================================
|
||
|
||
window.addEventListener('resize', () => {
|
||
location.reload(); // Simple solution for demo
|
||
});
|
||
|
||
// ============================================================================
|
||
// INSTANT RENDER (NO ENTRANCE ANIMATIONS)
|
||
// ============================================================================
|
||
|
||
// Simulation runs a few ticks immediately to settle the layout
|
||
for (let i = 0; i < 50; i++) {
|
||
simulation.tick();
|
||
}
|
||
|
||
// Update initial positions
|
||
link.attr('d', d => `M ${d.source.x} ${d.source.y} L ${d.target.x} ${d.target.y}`);
|
||
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
||
labels.attr('x', d => d.x).attr('y', d => d.y + 4);
|
||
|
||
// Then let simulation continue naturally for smooth interactions
|
||
simulation.alpha(0.3).restart();
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|