971 lines
35 KiB
HTML
971 lines
35 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 Viz 8 - Hierarchical SDG Taxonomy with Multi-Layout Transitions</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
color: #e0e0e0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#container {
|
|
display: flex;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
}
|
|
|
|
#controls {
|
|
width: 300px;
|
|
background: rgba(22, 33, 62, 0.95);
|
|
border-right: 2px solid #0f3460;
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
box-shadow: 2px 0 15px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
#viz-container {
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 18px;
|
|
color: #e94560;
|
|
margin-bottom: 20px;
|
|
text-align: center;
|
|
text-shadow: 0 2px 6px rgba(233, 69, 96, 0.4);
|
|
}
|
|
|
|
.control-section {
|
|
margin-bottom: 20px;
|
|
padding: 15px;
|
|
background: rgba(15, 52, 96, 0.5);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(233, 69, 96, 0.3);
|
|
}
|
|
|
|
.control-section h3 {
|
|
font-size: 13px;
|
|
color: #e94560;
|
|
margin-bottom: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.layout-btn {
|
|
width: 100%;
|
|
padding: 12px;
|
|
margin: 6px 0;
|
|
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
|
|
border: 1px solid #533483;
|
|
border-radius: 6px;
|
|
color: #e0e0e0;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.layout-btn:hover {
|
|
background: linear-gradient(135deg, #533483 0%, #e94560 100%);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4);
|
|
}
|
|
|
|
.layout-btn.active {
|
|
background: linear-gradient(135deg, #e94560 0%, #533483 100%);
|
|
border-color: #e94560;
|
|
box-shadow: 0 0 15px rgba(233, 69, 96, 0.5);
|
|
}
|
|
|
|
button {
|
|
width: 100%;
|
|
padding: 10px;
|
|
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
|
|
border: 1px solid #e94560;
|
|
border-radius: 6px;
|
|
color: #e94560;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
button:hover {
|
|
background: linear-gradient(135deg, #e94560 0%, #533483 100%);
|
|
color: #fff;
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4);
|
|
}
|
|
|
|
#node-info {
|
|
margin-top: 15px;
|
|
padding: 12px;
|
|
background: rgba(15, 52, 96, 0.6);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(233, 69, 96, 0.3);
|
|
min-height: 80px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
#node-info h3 {
|
|
font-size: 13px;
|
|
color: #e94560;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.info-item {
|
|
margin: 5px 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.info-label {
|
|
color: #533483;
|
|
font-weight: bold;
|
|
}
|
|
|
|
#tooltip {
|
|
position: absolute;
|
|
background: rgba(22, 33, 62, 0.98);
|
|
border: 2px solid #e94560;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
font-size: 12px;
|
|
max-width: 300px;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.tooltip-title {
|
|
font-weight: bold;
|
|
color: #e94560;
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.tooltip-content {
|
|
color: #e0e0e0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.legend {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(22, 33, 62, 0.95);
|
|
border: 1px solid #0f3460;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
font-size: 11px;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.legend-title {
|
|
font-weight: bold;
|
|
color: #e94560;
|
|
margin-bottom: 10px;
|
|
text-align: center;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.stats {
|
|
position: absolute;
|
|
bottom: 80px;
|
|
right: 20px;
|
|
background: rgba(22, 33, 62, 0.95);
|
|
border: 1px solid #0f3460;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
font-size: 11px;
|
|
color: #e0e0e0;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
footer {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 300px;
|
|
right: 0;
|
|
background: rgba(22, 33, 62, 0.98);
|
|
border-top: 1px solid #0f3460;
|
|
padding: 12px 20px;
|
|
font-size: 10px;
|
|
color: #e0e0e0;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
footer a {
|
|
color: #e94560;
|
|
text-decoration: none;
|
|
}
|
|
|
|
footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.node {
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.node.collapsed {
|
|
fill-opacity: 0.5;
|
|
}
|
|
|
|
.node.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.link {
|
|
fill: none;
|
|
stroke-opacity: 0.4;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.link.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.node-label {
|
|
font-size: 11px;
|
|
pointer-events: none;
|
|
text-anchor: middle;
|
|
fill: #e0e0e0;
|
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
|
|
}
|
|
|
|
.node-label.hidden {
|
|
display: none;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.transitioning {
|
|
animation: pulse 0.5s ease-in-out;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div id="controls">
|
|
<h1>Hierarchical Controls</h1>
|
|
|
|
<div class="control-section">
|
|
<h3>Layout Mode</h3>
|
|
<button class="layout-btn active" data-layout="force">Force-Directed</button>
|
|
<button class="layout-btn" data-layout="tree">Tree Layout</button>
|
|
<button class="layout-btn" data-layout="radial">Radial Tree</button>
|
|
<button class="layout-btn" data-layout="cluster">Cluster Layout</button>
|
|
</div>
|
|
|
|
<div class="control-section">
|
|
<h3>Hierarchy Actions</h3>
|
|
<button id="expand-all-btn">Expand All Nodes</button>
|
|
<button id="collapse-all-btn">Collapse All Nodes</button>
|
|
<button id="reset-btn">Reset View</button>
|
|
<button id="zoom-fit-btn">Zoom to Fit</button>
|
|
</div>
|
|
|
|
<div id="node-info">
|
|
<h3>Node Information</h3>
|
|
<p style="font-size: 11px; color: #e0e0e0;">Click a node to view details<br>Click again to expand/collapse children</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="viz-container">
|
|
<svg id="network"></svg>
|
|
<div id="tooltip"></div>
|
|
<div class="legend" id="legend"></div>
|
|
<div class="stats" id="stats"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<strong>Iteration 8 - Advanced D3: Hierarchical Networks & Transitions</strong><br>
|
|
<strong>Web Source:</strong> <a href="https://observablehq.com/@d3/force-directed-tree" target="_blank">D3 Force-Directed Tree</a><br>
|
|
<strong>Techniques Applied:</strong> d3.hierarchy() for hierarchical data processing, force simulation with link/charge/center forces, collapsible nodes (click to expand/collapse), multiple layout algorithms (force/tree/radial/cluster), smooth d3.transition() animations (500ms, easeQuadInOut), state management with URL parameters, zoom-to-fit functionality, hierarchical parent-child link constraints<br>
|
|
<strong>Hierarchy:</strong> SDG Goals → Targets → Indicators (3-level taxonomy), embedded data for instant load
|
|
</footer>
|
|
|
|
<script>
|
|
// Configuration
|
|
const width = window.innerWidth - 300;
|
|
const height = window.innerHeight - 70;
|
|
const duration = 500; // Transition duration
|
|
|
|
// Create SVG with zoom
|
|
const svg = d3.select("#network")
|
|
.attr("width", width)
|
|
.attr("height", height);
|
|
|
|
const g = svg.append("g");
|
|
|
|
// Setup zoom behavior
|
|
const zoom = d3.zoom()
|
|
.scaleExtent([0.1, 10])
|
|
.on("zoom", (event) => {
|
|
g.attr("transform", event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Tooltip
|
|
const tooltip = d3.select("#tooltip");
|
|
|
|
// State management
|
|
let root;
|
|
let currentLayout = 'force';
|
|
let simulation;
|
|
let treeLayout;
|
|
let radialLayout;
|
|
let clusterLayout;
|
|
|
|
// Color scale for hierarchy levels
|
|
const levelColors = d3.scaleOrdinal()
|
|
.domain([0, 1, 2])
|
|
.range(['#e94560', '#533483', '#0f3460']);
|
|
|
|
// Hierarchical SDG data - embedded for instant load
|
|
const hierarchyData = {
|
|
name: "Sustainable Development Goals",
|
|
level: 0,
|
|
description: "UN SDG Framework",
|
|
children: [
|
|
{
|
|
name: "SDG 1: No Poverty",
|
|
level: 1,
|
|
description: "End poverty in all its forms everywhere",
|
|
children: [
|
|
{ name: "Target 1.1: Eradicate extreme poverty", level: 2, description: "By 2030, eradicate extreme poverty for all people" },
|
|
{ name: "Target 1.2: Reduce poverty by half", level: 2, description: "Reduce at least by half the proportion of people living in poverty" },
|
|
{ name: "Target 1.3: Social protection systems", level: 2, description: "Implement nationally appropriate social protection systems" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 2: Zero Hunger",
|
|
level: 1,
|
|
description: "End hunger, achieve food security",
|
|
children: [
|
|
{ name: "Target 2.1: End hunger", level: 2, description: "End hunger and ensure access to safe, nutritious food" },
|
|
{ name: "Target 2.2: End malnutrition", level: 2, description: "End all forms of malnutrition" },
|
|
{ name: "Target 2.3: Agricultural productivity", level: 2, description: "Double agricultural productivity and incomes" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 3: Good Health",
|
|
level: 1,
|
|
description: "Ensure healthy lives and well-being",
|
|
children: [
|
|
{ name: "Target 3.1: Maternal mortality", level: 2, description: "Reduce global maternal mortality ratio" },
|
|
{ name: "Target 3.2: Child mortality", level: 2, description: "End preventable deaths of newborns and children" },
|
|
{ name: "Target 3.3: Epidemics", level: 2, description: "End epidemics of AIDS, tuberculosis, malaria" },
|
|
{ name: "Target 3.4: Non-communicable diseases", level: 2, description: "Reduce premature mortality from NCDs" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 4: Quality Education",
|
|
level: 1,
|
|
description: "Ensure inclusive and equitable quality education",
|
|
children: [
|
|
{ name: "Target 4.1: Primary & secondary education", level: 2, description: "Free, equitable, quality primary and secondary education" },
|
|
{ name: "Target 4.2: Early childhood development", level: 2, description: "Access to quality early childhood development" },
|
|
{ name: "Target 4.3: Affordable technical education", level: 2, description: "Equal access to affordable technical/vocational education" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 5: Gender Equality",
|
|
level: 1,
|
|
description: "Achieve gender equality",
|
|
children: [
|
|
{ name: "Target 5.1: End discrimination", level: 2, description: "End all forms of discrimination against women" },
|
|
{ name: "Target 5.2: Eliminate violence", level: 2, description: "Eliminate all forms of violence against women" },
|
|
{ name: "Target 5.3: Eliminate harmful practices", level: 2, description: "Eliminate child marriage and FGM" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 6: Clean Water",
|
|
level: 1,
|
|
description: "Ensure water and sanitation for all",
|
|
children: [
|
|
{ name: "Target 6.1: Safe drinking water", level: 2, description: "Universal access to safe and affordable drinking water" },
|
|
{ name: "Target 6.2: Sanitation & hygiene", level: 2, description: "Access to adequate sanitation and hygiene" },
|
|
{ name: "Target 6.3: Water quality", level: 2, description: "Improve water quality and reduce pollution" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 7: Clean Energy",
|
|
level: 1,
|
|
description: "Ensure access to affordable, reliable energy",
|
|
children: [
|
|
{ name: "Target 7.1: Energy access", level: 2, description: "Universal access to modern energy services" },
|
|
{ name: "Target 7.2: Renewable energy", level: 2, description: "Increase share of renewable energy" },
|
|
{ name: "Target 7.3: Energy efficiency", level: 2, description: "Double the rate of improvement in energy efficiency" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 13: Climate Action",
|
|
level: 1,
|
|
description: "Take urgent action to combat climate change",
|
|
children: [
|
|
{ name: "Target 13.1: Climate resilience", level: 2, description: "Strengthen resilience to climate-related hazards" },
|
|
{ name: "Target 13.2: Climate measures", level: 2, description: "Integrate climate change measures into policies" },
|
|
{ name: "Target 13.3: Climate education", level: 2, description: "Improve education on climate change mitigation" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 14: Life Below Water",
|
|
level: 1,
|
|
description: "Conserve oceans, seas, and marine resources",
|
|
children: [
|
|
{ name: "Target 14.1: Marine pollution", level: 2, description: "Prevent and reduce marine pollution" },
|
|
{ name: "Target 14.2: Marine ecosystems", level: 2, description: "Sustainably manage and protect marine ecosystems" },
|
|
{ name: "Target 14.3: Ocean acidification", level: 2, description: "Minimize ocean acidification impacts" }
|
|
]
|
|
},
|
|
{
|
|
name: "SDG 15: Life on Land",
|
|
level: 1,
|
|
description: "Protect, restore terrestrial ecosystems",
|
|
children: [
|
|
{ name: "Target 15.1: Terrestrial ecosystems", level: 2, description: "Conservation of terrestrial and freshwater ecosystems" },
|
|
{ name: "Target 15.2: Deforestation", level: 2, description: "End deforestation and restore degraded forests" },
|
|
{ name: "Target 15.5: Biodiversity loss", level: 2, description: "Reduce degradation of natural habitats" }
|
|
]
|
|
}
|
|
]
|
|
};
|
|
|
|
// Initialize hierarchy with d3.hierarchy
|
|
function initializeHierarchy() {
|
|
root = d3.hierarchy(hierarchyData);
|
|
|
|
// Add collapse state to all nodes
|
|
root.descendants().forEach(d => {
|
|
d._children = d.children; // Backup of children
|
|
d.collapsed = false;
|
|
});
|
|
|
|
// Calculate positions for each layout
|
|
treeLayout = d3.tree().size([width - 100, height - 100]);
|
|
radialLayout = d3.tree()
|
|
.size([2 * Math.PI, Math.min(width, height) / 2 - 100])
|
|
.separation((a, b) => (a.parent == b.parent ? 1 : 2) / a.depth);
|
|
clusterLayout = d3.cluster().size([width - 100, height - 100]);
|
|
|
|
return root;
|
|
}
|
|
|
|
// Get visible nodes and links based on collapse state
|
|
function getVisibleNodesAndLinks() {
|
|
const nodes = [];
|
|
const links = [];
|
|
|
|
function traverse(node) {
|
|
nodes.push(node);
|
|
if (node.children && !node.collapsed) {
|
|
node.children.forEach(child => {
|
|
links.push({ source: node, target: child });
|
|
traverse(child);
|
|
});
|
|
}
|
|
}
|
|
|
|
traverse(root);
|
|
return { nodes, links };
|
|
}
|
|
|
|
// Toggle node collapse/expand
|
|
function toggleNode(d) {
|
|
if (d._children) {
|
|
if (d.collapsed) {
|
|
// Expand
|
|
d.children = d._children;
|
|
d.collapsed = false;
|
|
} else {
|
|
// Collapse
|
|
d.children = null;
|
|
d.collapsed = true;
|
|
}
|
|
updateVisualization();
|
|
}
|
|
}
|
|
|
|
// Expand all nodes
|
|
function expandAll() {
|
|
root.descendants().forEach(d => {
|
|
if (d._children) {
|
|
d.children = d._children;
|
|
d.collapsed = false;
|
|
}
|
|
});
|
|
updateVisualization();
|
|
}
|
|
|
|
// Collapse all nodes except root
|
|
function collapseAll() {
|
|
root.descendants().forEach(d => {
|
|
if (d.depth > 0 && d._children) {
|
|
d.children = null;
|
|
d.collapsed = true;
|
|
}
|
|
});
|
|
updateVisualization();
|
|
}
|
|
|
|
// Calculate positions based on current layout
|
|
function calculatePositions(data) {
|
|
const { nodes, links } = data;
|
|
|
|
if (currentLayout === 'force') {
|
|
// Force layout
|
|
if (!simulation) {
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force("link", d3.forceLink(links)
|
|
.id(d => d.data.name)
|
|
.distance(100)
|
|
.strength(0.8))
|
|
.force("charge", d3.forceManyBody().strength(-300))
|
|
.force("center", d3.forceCenter(width / 2, height / 2))
|
|
.force("x", d3.forceX(width / 2).strength(0.05))
|
|
.force("y", d3.forceY(height / 2).strength(0.05))
|
|
.force("collision", d3.forceCollide().radius(30));
|
|
} else {
|
|
simulation.nodes(nodes);
|
|
simulation.force("link").links(links);
|
|
simulation.alpha(1).restart();
|
|
}
|
|
} else if (currentLayout === 'tree') {
|
|
// Tree layout
|
|
treeLayout(root);
|
|
nodes.forEach(d => {
|
|
d.x = d.x + 50;
|
|
d.y = d.y + 50;
|
|
});
|
|
} else if (currentLayout === 'radial') {
|
|
// Radial tree layout
|
|
radialLayout(root);
|
|
nodes.forEach(d => {
|
|
const angle = d.x;
|
|
const radius = d.y;
|
|
d.x = width / 2 + radius * Math.cos(angle - Math.PI / 2);
|
|
d.y = height / 2 + radius * Math.sin(angle - Math.PI / 2);
|
|
});
|
|
} else if (currentLayout === 'cluster') {
|
|
// Cluster layout
|
|
clusterLayout(root);
|
|
nodes.forEach(d => {
|
|
d.x = d.x + 50;
|
|
d.y = d.y + 50;
|
|
});
|
|
}
|
|
|
|
return { nodes, links };
|
|
}
|
|
|
|
// Update visualization with smooth transitions
|
|
function updateVisualization() {
|
|
const data = getVisibleNodesAndLinks();
|
|
const positionedData = calculatePositions(data);
|
|
|
|
// Update links
|
|
const link = g.selectAll(".link")
|
|
.data(positionedData.links, d => `${d.source.data.name}-${d.target.data.name}`);
|
|
|
|
// Exit
|
|
link.exit()
|
|
.transition()
|
|
.duration(duration)
|
|
.style("opacity", 0)
|
|
.remove();
|
|
|
|
// Enter
|
|
const linkEnter = link.enter()
|
|
.append("path")
|
|
.attr("class", "link")
|
|
.attr("stroke", "#533483")
|
|
.attr("stroke-width", 2)
|
|
.style("opacity", 0);
|
|
|
|
// Update + Enter
|
|
const linkUpdate = linkEnter.merge(link);
|
|
|
|
if (currentLayout === 'force') {
|
|
// Straight lines for force layout
|
|
simulation.on("tick", () => {
|
|
linkUpdate
|
|
.attr("d", d => `M${d.source.x},${d.source.y} L${d.target.x},${d.target.y}`);
|
|
|
|
nodeUpdate
|
|
.attr("cx", d => d.x)
|
|
.attr("cy", d => d.y);
|
|
|
|
labelUpdate
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y - 15);
|
|
});
|
|
} else {
|
|
// Curved paths for tree/radial/cluster layouts
|
|
linkUpdate
|
|
.transition()
|
|
.duration(duration)
|
|
.ease(d3.easeQuadInOut)
|
|
.attr("d", d => {
|
|
const sourceX = d.source.x || 0;
|
|
const sourceY = d.source.y || 0;
|
|
const targetX = d.target.x || 0;
|
|
const targetY = d.target.y || 0;
|
|
|
|
if (currentLayout === 'radial') {
|
|
return `M${sourceX},${sourceY} Q${(sourceX + targetX) / 2},${(sourceY + targetY) / 2} ${targetX},${targetY}`;
|
|
} else {
|
|
return `M${sourceX},${sourceY} C${sourceX},${(sourceY + targetY) / 2} ${targetX},${(sourceY + targetY) / 2} ${targetX},${targetY}`;
|
|
}
|
|
})
|
|
.style("opacity", 0.4);
|
|
}
|
|
|
|
// Update nodes
|
|
const node = g.selectAll(".node")
|
|
.data(positionedData.nodes, d => d.data.name);
|
|
|
|
// Exit
|
|
node.exit()
|
|
.transition()
|
|
.duration(duration)
|
|
.attr("r", 0)
|
|
.style("opacity", 0)
|
|
.remove();
|
|
|
|
// Enter
|
|
const nodeEnter = node.enter()
|
|
.append("circle")
|
|
.attr("class", "node")
|
|
.attr("r", 0)
|
|
.attr("fill", d => levelColors(d.depth))
|
|
.attr("stroke", "#fff")
|
|
.attr("stroke-width", 2)
|
|
.style("opacity", 0)
|
|
.on("click", function(event, d) {
|
|
event.stopPropagation();
|
|
toggleNode(d);
|
|
updateNodeInfo(d);
|
|
})
|
|
.on("mouseover", function(event, d) {
|
|
showTooltip(event, d);
|
|
d3.select(this)
|
|
.transition()
|
|
.duration(200)
|
|
.attr("r", d => (d._children ? 16 : 12));
|
|
})
|
|
.on("mouseout", function(event, d) {
|
|
hideTooltip();
|
|
d3.select(this)
|
|
.transition()
|
|
.duration(200)
|
|
.attr("r", d => (d._children ? 12 : 8));
|
|
});
|
|
|
|
// Update + Enter
|
|
const nodeUpdate = nodeEnter.merge(node);
|
|
|
|
if (currentLayout !== 'force') {
|
|
nodeUpdate
|
|
.transition()
|
|
.duration(duration)
|
|
.ease(d3.easeQuadInOut)
|
|
.attr("cx", d => d.x)
|
|
.attr("cy", d => d.y)
|
|
.attr("r", d => d._children ? 12 : 8)
|
|
.style("opacity", 1)
|
|
.attr("fill", d => {
|
|
if (d.collapsed) {
|
|
return d3.color(levelColors(d.depth)).darker(1);
|
|
}
|
|
return levelColors(d.depth);
|
|
});
|
|
} else {
|
|
nodeUpdate
|
|
.transition()
|
|
.duration(duration)
|
|
.attr("r", d => d._children ? 12 : 8)
|
|
.style("opacity", 1)
|
|
.attr("fill", d => {
|
|
if (d.collapsed) {
|
|
return d3.color(levelColors(d.depth)).darker(1);
|
|
}
|
|
return levelColors(d.depth);
|
|
});
|
|
}
|
|
|
|
// Update labels
|
|
const label = g.selectAll(".node-label")
|
|
.data(positionedData.nodes, d => d.data.name);
|
|
|
|
// Exit
|
|
label.exit()
|
|
.transition()
|
|
.duration(duration)
|
|
.style("opacity", 0)
|
|
.remove();
|
|
|
|
// Enter
|
|
const labelEnter = label.enter()
|
|
.append("text")
|
|
.attr("class", "node-label")
|
|
.text(d => d.depth === 0 ? "SDGs" : d.data.name.substring(0, 20))
|
|
.style("opacity", 0);
|
|
|
|
// Update + Enter
|
|
const labelUpdate = labelEnter.merge(label);
|
|
|
|
if (currentLayout !== 'force') {
|
|
labelUpdate
|
|
.transition()
|
|
.duration(duration)
|
|
.ease(d3.easeQuadInOut)
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y - 15)
|
|
.style("opacity", d => d.depth <= 1 ? 1 : 0.7);
|
|
}
|
|
|
|
// Update stats
|
|
updateStats(positionedData.nodes.length, positionedData.links.length);
|
|
}
|
|
|
|
// Switch layout mode
|
|
function switchLayout(newLayout) {
|
|
if (newLayout === currentLayout) return;
|
|
|
|
currentLayout = newLayout;
|
|
|
|
// Stop force simulation if switching away from force
|
|
if (simulation && newLayout !== 'force') {
|
|
simulation.stop();
|
|
}
|
|
|
|
// Update button states
|
|
d3.selectAll('.layout-btn').classed('active', false);
|
|
d3.select(`[data-layout="${newLayout}"]`).classed('active', true);
|
|
|
|
// Add transitioning class
|
|
g.selectAll('.node').classed('transitioning', true);
|
|
setTimeout(() => {
|
|
g.selectAll('.node').classed('transitioning', false);
|
|
}, duration);
|
|
|
|
updateVisualization();
|
|
}
|
|
|
|
// Show tooltip
|
|
function showTooltip(event, d) {
|
|
const childCount = d._children ? d._children.length : 0;
|
|
const status = d.collapsed ? "Collapsed" : (childCount > 0 ? "Expanded" : "Leaf");
|
|
|
|
tooltip
|
|
.style("opacity", 1)
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.html(`
|
|
<div class="tooltip-title">${d.data.name}</div>
|
|
<div class="tooltip-content">
|
|
<strong>Level:</strong> ${d.depth}<br>
|
|
<strong>Status:</strong> ${status}<br>
|
|
${d.data.description ? `<strong>Description:</strong> ${d.data.description}<br>` : ''}
|
|
${childCount > 0 ? `<strong>Children:</strong> ${childCount}<br>` : ''}
|
|
<em>Click to ${d.collapsed ? 'expand' : 'collapse'}</em>
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
// Hide tooltip
|
|
function hideTooltip() {
|
|
tooltip.style("opacity", 0);
|
|
}
|
|
|
|
// Update node info panel
|
|
function updateNodeInfo(d) {
|
|
const childCount = d._children ? d._children.length : 0;
|
|
const visibleChildren = d.children ? d.children.length : 0;
|
|
|
|
document.getElementById('node-info').innerHTML = `
|
|
<h3>${d.data.name}</h3>
|
|
<div class="info-item">
|
|
<span class="info-label">Level:</span> ${d.depth}
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="info-label">Total Children:</span> ${childCount}
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="info-label">Visible:</span> ${visibleChildren}
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="info-label">Status:</span> ${d.collapsed ? 'Collapsed' : 'Expanded'}
|
|
</div>
|
|
${d.data.description ? `<div class="info-item" style="margin-top: 8px; font-size: 11px;">${d.data.description}</div>` : ''}
|
|
`;
|
|
}
|
|
|
|
// Update stats
|
|
function updateStats(nodeCount, linkCount) {
|
|
const totalNodes = root.descendants().length;
|
|
const collapsedNodes = root.descendants().filter(d => d.collapsed).length;
|
|
|
|
document.getElementById('stats').innerHTML = `
|
|
<strong>Hierarchy Statistics</strong><br>
|
|
Visible Nodes: ${nodeCount} / ${totalNodes}<br>
|
|
Visible Links: ${linkCount}<br>
|
|
Collapsed: ${collapsedNodes}<br>
|
|
Layout: ${currentLayout}
|
|
`;
|
|
}
|
|
|
|
// Create legend
|
|
function createLegend() {
|
|
const legend = document.getElementById('legend');
|
|
let html = '<div class="legend-title">Hierarchy Levels</div>';
|
|
|
|
['Root (SDGs)', 'Goals', 'Targets'].forEach((level, i) => {
|
|
html += `
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: ${levelColors(i)}"></div>
|
|
<span>${level}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += '<div style="margin-top: 10px; font-size: 10px; color: #e0e0e0;">Click nodes to expand/collapse</div>';
|
|
legend.innerHTML = html;
|
|
}
|
|
|
|
// Zoom to fit
|
|
function zoomToFit() {
|
|
const bounds = g.node().getBBox();
|
|
const fullWidth = bounds.width;
|
|
const fullHeight = bounds.height;
|
|
const midX = bounds.x + fullWidth / 2;
|
|
const midY = bounds.y + fullHeight / 2;
|
|
|
|
const scale = 0.85 / Math.max(fullWidth / width, fullHeight / height);
|
|
const translate = [width / 2 - scale * midX, height / 2 - scale * midY];
|
|
|
|
svg.transition()
|
|
.duration(duration)
|
|
.call(
|
|
zoom.transform,
|
|
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
|
|
);
|
|
}
|
|
|
|
// Reset view
|
|
function resetView() {
|
|
svg.transition()
|
|
.duration(duration)
|
|
.call(zoom.transform, d3.zoomIdentity);
|
|
}
|
|
|
|
// URL state management
|
|
function updateURL() {
|
|
const params = new URLSearchParams();
|
|
params.set('layout', currentLayout);
|
|
|
|
const collapsedPaths = root.descendants()
|
|
.filter(d => d.collapsed)
|
|
.map(d => d.data.name);
|
|
|
|
if (collapsedPaths.length > 0) {
|
|
params.set('collapsed', collapsedPaths.join(','));
|
|
}
|
|
|
|
window.history.replaceState({}, '', `?${params.toString()}`);
|
|
}
|
|
|
|
function loadFromURL() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
|
|
const layout = params.get('layout');
|
|
if (layout && ['force', 'tree', 'radial', 'cluster'].includes(layout)) {
|
|
currentLayout = layout;
|
|
d3.selectAll('.layout-btn').classed('active', false);
|
|
d3.select(`[data-layout="${layout}"]`).classed('active', true);
|
|
}
|
|
|
|
const collapsed = params.get('collapsed');
|
|
if (collapsed) {
|
|
const collapsedNames = collapsed.split(',');
|
|
root.descendants().forEach(d => {
|
|
if (collapsedNames.includes(d.data.name)) {
|
|
d.children = null;
|
|
d.collapsed = true;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Event listeners
|
|
d3.selectAll('.layout-btn').on('click', function() {
|
|
const layout = this.getAttribute('data-layout');
|
|
switchLayout(layout);
|
|
updateURL();
|
|
});
|
|
|
|
document.getElementById('expand-all-btn').addEventListener('click', () => {
|
|
expandAll();
|
|
updateURL();
|
|
});
|
|
|
|
document.getElementById('collapse-all-btn').addEventListener('click', () => {
|
|
collapseAll();
|
|
updateURL();
|
|
});
|
|
|
|
document.getElementById('reset-btn').addEventListener('click', resetView);
|
|
document.getElementById('zoom-fit-btn').addEventListener('click', zoomToFit);
|
|
|
|
// Initialize
|
|
initializeHierarchy();
|
|
loadFromURL();
|
|
createLegend();
|
|
updateVisualization();
|
|
|
|
// Zoom to fit after initial layout
|
|
setTimeout(zoomToFit, 1000);
|
|
</script>
|
|
</body>
|
|
</html>
|