397 lines
13 KiB
HTML
397 lines
13 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 - Iteration 12: Refined Aesthetics & Beautiful Nodes</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
|
|
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Inter', 'Roboto', sans-serif;
|
|
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 50%, #0a0e27 100%);
|
|
color: #ffffff;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#container {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
position: relative;
|
|
}
|
|
|
|
svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.edge {
|
|
fill: none;
|
|
stroke-width: 2;
|
|
opacity: 0.6;
|
|
transition: opacity 0.3s ease, stroke-width 0.3s ease;
|
|
}
|
|
|
|
.edge:hover {
|
|
opacity: 1;
|
|
stroke-width: 3;
|
|
}
|
|
|
|
.node {
|
|
cursor: move;
|
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.4));
|
|
transition: filter 0.3s ease;
|
|
}
|
|
|
|
.node:hover {
|
|
filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.6));
|
|
}
|
|
|
|
.node-circle {
|
|
transition: transform 0.3s ease, filter 0.3s ease;
|
|
}
|
|
|
|
.node:hover .node-circle {
|
|
transform: scale(1.2);
|
|
filter: brightness(1.2);
|
|
}
|
|
|
|
.node-label {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
fill: #ffffff;
|
|
text-anchor: middle;
|
|
pointer-events: none;
|
|
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8),
|
|
-1px -1px 3px rgba(0, 0, 0, 0.8);
|
|
user-select: none;
|
|
}
|
|
|
|
.legend {
|
|
position: absolute;
|
|
top: 30px;
|
|
right: 30px;
|
|
background: rgba(15, 20, 40, 0.92);
|
|
padding: 20px 25px;
|
|
border-radius: 12px;
|
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.legend-title {
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
margin-bottom: 15px;
|
|
color: #ffffff;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 12px 0;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
margin-right: 12px;
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.legend-color.topic {
|
|
background: linear-gradient(135deg, #0066FF 0%, #0044CC 100%);
|
|
border: 3px solid #FFD700;
|
|
box-shadow: 0 0 15px rgba(0, 102, 255, 0.5);
|
|
}
|
|
|
|
.legend-color.datasource {
|
|
background: linear-gradient(135deg, #FF0000 0%, #CC0000 100%);
|
|
border: 3px solid #FFFFFF;
|
|
box-shadow: 0 0 15px rgba(255, 0, 0, 0.5);
|
|
}
|
|
|
|
.info {
|
|
position: absolute;
|
|
bottom: 30px;
|
|
left: 30px;
|
|
font-size: 13px;
|
|
color: rgba(255, 255, 255, 0.6);
|
|
font-weight: 500;
|
|
}
|
|
|
|
.title {
|
|
position: absolute;
|
|
top: 30px;
|
|
left: 30px;
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
color: #ffffff;
|
|
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.6);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div class="title">SDG Network Dashboard</div>
|
|
<svg id="svg"></svg>
|
|
<div class="legend">
|
|
<div class="legend-title">Node Types</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color topic"></div>
|
|
<span>SDG Topics</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color datasource"></div>
|
|
<span>Data Sources</span>
|
|
</div>
|
|
</div>
|
|
<div class="info">Drag nodes to explore connections | Hover for details</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Web Source: https://observablehq.com/@d3/color-schemes
|
|
// Learning Applied: Color harmony with complementary colors (blue/yellow, red/white)
|
|
// Contrast ratios for accessibility, gradient color schemes for depth
|
|
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
const svg = d3.select("#svg")
|
|
.attr("viewBox", [0, 0, width, height]);
|
|
|
|
// Define gradients for nodes
|
|
const defs = svg.append("defs");
|
|
|
|
// Topic gradient (blue)
|
|
const topicGradient = defs.append("radialGradient")
|
|
.attr("id", "topicGradient");
|
|
topicGradient.append("stop")
|
|
.attr("offset", "0%")
|
|
.attr("stop-color", "#3399FF");
|
|
topicGradient.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", "#0044CC");
|
|
|
|
// Data source gradient (red)
|
|
const datasourceGradient = defs.append("radialGradient")
|
|
.attr("id", "datasourceGradient");
|
|
datasourceGradient.append("stop")
|
|
.attr("offset", "0%")
|
|
.attr("stop-color", "#FF3333");
|
|
datasourceGradient.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", "#CC0000");
|
|
|
|
// Inner glow filter for nodes
|
|
const glowFilter = defs.append("filter")
|
|
.attr("id", "glow");
|
|
glowFilter.append("feGaussianBlur")
|
|
.attr("stdDeviation", "3")
|
|
.attr("result", "coloredBlur");
|
|
const feMerge = glowFilter.append("feMerge");
|
|
feMerge.append("feMergeNode")
|
|
.attr("in", "coloredBlur");
|
|
feMerge.append("feMergeNode")
|
|
.attr("in", "SourceGraphic");
|
|
|
|
// Data: Bipartite network with SDG topics and data sources
|
|
const topics = [
|
|
{ id: "SDG1", name: "No Poverty", type: "topic" },
|
|
{ id: "SDG3", name: "Good Health", type: "topic" },
|
|
{ id: "SDG4", name: "Quality Education", type: "topic" },
|
|
{ id: "SDG7", name: "Clean Energy", type: "topic" },
|
|
{ id: "SDG13", name: "Climate Action", type: "topic" },
|
|
{ id: "SDG15", name: "Life on Land", type: "topic" }
|
|
];
|
|
|
|
const dataSources = [
|
|
{ id: "UN", name: "UN Statistics", type: "datasource" },
|
|
{ id: "WorldBank", name: "World Bank", type: "datasource" },
|
|
{ id: "WHO", name: "WHO Data", type: "datasource" },
|
|
{ id: "IEA", name: "IEA Reports", type: "datasource" },
|
|
{ id: "NASA", name: "NASA Climate", type: "datasource" },
|
|
{ id: "FAO", name: "FAO Statistics", type: "datasource" }
|
|
];
|
|
|
|
const nodes = [...topics, ...dataSources];
|
|
|
|
// Create meaningful connections
|
|
const links = [
|
|
{ source: "SDG1", target: "UN" },
|
|
{ source: "SDG1", target: "WorldBank" },
|
|
{ source: "SDG3", target: "WHO" },
|
|
{ source: "SDG3", target: "UN" },
|
|
{ source: "SDG4", target: "UN" },
|
|
{ source: "SDG4", target: "WorldBank" },
|
|
{ source: "SDG7", target: "IEA" },
|
|
{ source: "SDG7", target: "WorldBank" },
|
|
{ source: "SDG13", target: "NASA" },
|
|
{ source: "SDG13", target: "IEA" },
|
|
{ source: "SDG13", target: "UN" },
|
|
{ source: "SDG15", target: "FAO" },
|
|
{ source: "SDG15", target: "NASA" },
|
|
{ source: "SDG15", target: "UN" }
|
|
];
|
|
|
|
// Bipartite layout with perfect spacing
|
|
const leftX = width * 0.25;
|
|
const rightX = width * 0.75;
|
|
const verticalSpacing = height / (Math.max(topics.length, dataSources.length) + 1);
|
|
|
|
topics.forEach((node, i) => {
|
|
node.x = leftX;
|
|
node.y = verticalSpacing * (i + 1.5);
|
|
node.fx = leftX;
|
|
node.fy = node.y;
|
|
});
|
|
|
|
dataSources.forEach((node, i) => {
|
|
node.x = rightX;
|
|
node.y = verticalSpacing * (i + 1.5);
|
|
node.fx = rightX;
|
|
node.fy = node.y;
|
|
});
|
|
|
|
// Create gradient definitions for edges
|
|
links.forEach((link, i) => {
|
|
const gradientId = `edgeGradient${i}`;
|
|
const gradient = defs.append("linearGradient")
|
|
.attr("id", gradientId)
|
|
.attr("gradientUnits", "userSpaceOnUse");
|
|
|
|
gradient.append("stop")
|
|
.attr("offset", "0%")
|
|
.attr("stop-color", "#0066FF")
|
|
.attr("stop-opacity", 0.6);
|
|
|
|
gradient.append("stop")
|
|
.attr("offset", "100%")
|
|
.attr("stop-color", "#FF0000")
|
|
.attr("stop-opacity", 0.6);
|
|
|
|
link.gradientId = gradientId;
|
|
});
|
|
|
|
// Draw edges
|
|
const edgeGroup = svg.append("g").attr("class", "edges");
|
|
|
|
const edges = edgeGroup.selectAll("path")
|
|
.data(links)
|
|
.join("path")
|
|
.attr("class", "edge")
|
|
.attr("stroke", d => `url(#${d.gradientId})`)
|
|
.attr("d", d => {
|
|
const sourceNode = nodes.find(n => n.id === d.source);
|
|
const targetNode = nodes.find(n => n.id === d.target);
|
|
|
|
const dx = targetNode.x - sourceNode.x;
|
|
const dy = targetNode.y - sourceNode.y;
|
|
const dr = Math.sqrt(dx * dx + dy * dy) * 0.5;
|
|
|
|
return `M ${sourceNode.x},${sourceNode.y} Q ${(sourceNode.x + targetNode.x) / 2},${(sourceNode.y + targetNode.y) / 2 - 50} ${targetNode.x},${targetNode.y}`;
|
|
});
|
|
|
|
// Draw nodes
|
|
const nodeGroup = svg.append("g").attr("class", "nodes");
|
|
|
|
const nodeElements = nodeGroup.selectAll("g")
|
|
.data(nodes)
|
|
.join("g")
|
|
.attr("class", "node")
|
|
.attr("transform", d => `translate(${d.x},${d.y})`)
|
|
.call(d3.drag()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended));
|
|
|
|
// Large beautiful node circles with borders and effects
|
|
nodeElements.append("circle")
|
|
.attr("class", "node-circle")
|
|
.attr("r", 30)
|
|
.attr("fill", d => d.type === "topic" ? "url(#topicGradient)" : "url(#datasourceGradient)")
|
|
.attr("stroke", d => d.type === "topic" ? "#FFD700" : "#FFFFFF")
|
|
.attr("stroke-width", 4)
|
|
.attr("filter", "url(#glow)");
|
|
|
|
// Node labels
|
|
nodeElements.append("text")
|
|
.attr("class", "node-label")
|
|
.attr("dy", 50)
|
|
.text(d => d.name)
|
|
.style("font-size", "15px")
|
|
.style("font-weight", "600");
|
|
|
|
// Update edge gradients based on actual positions
|
|
function updateEdgeGradients() {
|
|
links.forEach((link, i) => {
|
|
const sourceNode = nodes.find(n => n.id === link.source);
|
|
const targetNode = nodes.find(n => n.id === link.target);
|
|
|
|
d3.select(`#${link.gradientId}`)
|
|
.attr("x1", sourceNode.x)
|
|
.attr("y1", sourceNode.y)
|
|
.attr("x2", targetNode.x)
|
|
.attr("y2", targetNode.y);
|
|
});
|
|
}
|
|
|
|
updateEdgeGradients();
|
|
|
|
// Drag functions with working behavior
|
|
function dragstarted(event, d) {
|
|
d3.select(this).raise();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
d.x = event.x;
|
|
d.y = event.y;
|
|
|
|
d3.select(this)
|
|
.attr("transform", `translate(${d.x},${d.y})`);
|
|
|
|
// Update connected edges
|
|
edges.attr("d", link => {
|
|
const sourceNode = nodes.find(n => n.id === link.source);
|
|
const targetNode = nodes.find(n => n.id === link.target);
|
|
|
|
const dx = targetNode.x - sourceNode.x;
|
|
const dy = targetNode.y - sourceNode.y;
|
|
|
|
return `M ${sourceNode.x},${sourceNode.y} Q ${(sourceNode.x + targetNode.x) / 2},${(sourceNode.y + targetNode.y) / 2 - 50} ${targetNode.x},${targetNode.y}`;
|
|
});
|
|
|
|
updateEdgeGradients();
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
// Keep node at dragged position
|
|
}
|
|
|
|
// Hover effects for connected edges
|
|
nodeElements.on("mouseenter", function(event, d) {
|
|
edges.style("opacity", link => {
|
|
return (link.source === d.id || link.target === d.id) ? 1 : 0.2;
|
|
});
|
|
}).on("mouseleave", function() {
|
|
edges.style("opacity", 0.6);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |