1015 lines
36 KiB
HTML
1015 lines
36 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 3 - Global Biodiversity Interaction Network</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, Cantarell, sans-serif;
|
|
background: linear-gradient(135deg, #0a1f2e 0%, #1a3a4a 100%);
|
|
color: #e0e0e0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#container {
|
|
display: flex;
|
|
height: 100vh;
|
|
width: 100vw;
|
|
}
|
|
|
|
#controls {
|
|
width: 320px;
|
|
background: rgba(26, 58, 74, 0.95);
|
|
border-right: 2px solid #2a5a7a;
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
#viz-container {
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 20px;
|
|
color: #4fc3f7;
|
|
margin-bottom: 20px;
|
|
text-align: center;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.control-section {
|
|
margin-bottom: 25px;
|
|
padding: 15px;
|
|
background: rgba(42, 90, 122, 0.4);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(79, 195, 247, 0.2);
|
|
}
|
|
|
|
.control-section h3 {
|
|
font-size: 14px;
|
|
color: #81d4fa;
|
|
margin-bottom: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
input[type="text"] {
|
|
width: 100%;
|
|
padding: 10px;
|
|
background: rgba(10, 31, 46, 0.8);
|
|
border: 1px solid #2a5a7a;
|
|
border-radius: 4px;
|
|
color: #e0e0e0;
|
|
font-size: 13px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
input[type="text"]:focus {
|
|
outline: none;
|
|
border-color: #4fc3f7;
|
|
box-shadow: 0 0 8px rgba(79, 195, 247, 0.3);
|
|
}
|
|
|
|
.filter-group {
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.filter-checkbox {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 8px 0;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
padding: 6px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.filter-checkbox:hover {
|
|
background: rgba(79, 195, 247, 0.1);
|
|
}
|
|
|
|
.filter-checkbox input {
|
|
margin-right: 10px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.filter-checkbox label {
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
flex: 1;
|
|
}
|
|
|
|
.count {
|
|
font-size: 11px;
|
|
color: #81d4fa;
|
|
margin-left: 5px;
|
|
}
|
|
|
|
button {
|
|
width: 100%;
|
|
padding: 12px;
|
|
background: linear-gradient(135deg, #2a5a7a 0%, #1a3a4a 100%);
|
|
border: 1px solid #4fc3f7;
|
|
border-radius: 6px;
|
|
color: #4fc3f7;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
button:hover {
|
|
background: linear-gradient(135deg, #4fc3f7 0%, #2a5a7a 100%);
|
|
color: #0a1f2e;
|
|
box-shadow: 0 4px 12px rgba(79, 195, 247, 0.4);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
button:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
#node-details {
|
|
margin-top: 20px;
|
|
padding: 15px;
|
|
background: rgba(42, 90, 122, 0.6);
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(79, 195, 247, 0.3);
|
|
min-height: 100px;
|
|
}
|
|
|
|
#node-details h3 {
|
|
font-size: 14px;
|
|
color: #81d4fa;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.detail-item {
|
|
margin: 8px 0;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.detail-label {
|
|
color: #81d4fa;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.detail-value {
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
#tooltip {
|
|
position: absolute;
|
|
background: rgba(10, 31, 46, 0.95);
|
|
border: 2px solid #4fc3f7;
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
font-size: 12px;
|
|
max-width: 250px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
z-index: 1000;
|
|
}
|
|
|
|
.tooltip-title {
|
|
font-weight: bold;
|
|
color: #4fc3f7;
|
|
margin-bottom: 6px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.tooltip-content {
|
|
color: #e0e0e0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.legend {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: rgba(10, 31, 46, 0.9);
|
|
border: 1px solid #2a5a7a;
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.legend-title {
|
|
font-weight: bold;
|
|
color: #4fc3f7;
|
|
margin-bottom: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
margin-right: 8px;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.stats {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: rgba(10, 31, 46, 0.9);
|
|
border: 1px solid #2a5a7a;
|
|
border-radius: 8px;
|
|
padding: 12px 16px;
|
|
font-size: 11px;
|
|
color: #81d4fa;
|
|
}
|
|
|
|
.loading {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 18px;
|
|
color: #4fc3f7;
|
|
text-align: center;
|
|
}
|
|
|
|
.spinner {
|
|
border: 3px solid rgba(79, 195, 247, 0.3);
|
|
border-top: 3px solid #4fc3f7;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 15px;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
footer {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 320px;
|
|
right: 0;
|
|
background: rgba(10, 31, 46, 0.95);
|
|
border-top: 1px solid #2a5a7a;
|
|
padding: 12px 20px;
|
|
font-size: 11px;
|
|
color: #81d4fa;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
footer a {
|
|
color: #4fc3f7;
|
|
text-decoration: none;
|
|
}
|
|
|
|
footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.highlight {
|
|
stroke: #ffeb3b !important;
|
|
stroke-width: 3px !important;
|
|
}
|
|
|
|
.dimmed {
|
|
opacity: 0.2 !important;
|
|
}
|
|
|
|
.focused {
|
|
stroke: #ff5722 !important;
|
|
stroke-width: 4px !important;
|
|
}
|
|
|
|
.link-highlighted {
|
|
stroke: #ffeb3b !important;
|
|
stroke-width: 2px !important;
|
|
opacity: 1 !important;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { r: 8; }
|
|
50% { r: 12; }
|
|
}
|
|
|
|
.searching {
|
|
animation: pulse 1s infinite;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="container">
|
|
<div id="controls">
|
|
<h1>Interactive Controls</h1>
|
|
|
|
<div class="control-section">
|
|
<h3>Search Species</h3>
|
|
<input type="text" id="search-input" placeholder="Type species name or taxonomic group...">
|
|
<button id="search-btn">Search & Highlight</button>
|
|
</div>
|
|
|
|
<div class="control-section">
|
|
<h3>Filter by Taxonomic Rank</h3>
|
|
<div class="filter-group" id="rank-filters"></div>
|
|
</div>
|
|
|
|
<div class="control-section">
|
|
<h3>Actions</h3>
|
|
<button id="reset-btn">Reset View</button>
|
|
<button id="zoom-fit-btn">Zoom to Fit</button>
|
|
<button id="reheat-btn">Reheat Simulation</button>
|
|
</div>
|
|
|
|
<div id="node-details">
|
|
<h3>Node Details</h3>
|
|
<p style="font-size: 12px; color: #81d4fa;">Click a node to see details</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="viz-container">
|
|
<div class="loading" id="loading">
|
|
<div class="spinner"></div>
|
|
Loading biodiversity data from GBIF...
|
|
</div>
|
|
<svg id="network"></svg>
|
|
<div id="tooltip"></div>
|
|
<div class="legend" id="legend"></div>
|
|
<div class="stats" id="stats"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<footer>
|
|
<strong>Data Source:</strong> <a href="https://www.gbif.org" target="_blank">GBIF (Global Biodiversity Information Facility) API</a> - Species occurrences and taxonomic hierarchy<br>
|
|
<strong>Web Learning:</strong> <a href="https://observablehq.com/@d3/temporal-force-directed-graph" target="_blank">D3 Temporal Force-Directed Graph</a><br>
|
|
<strong>Techniques Applied:</strong> Advanced drag behavior with alpha targeting, dynamic graph updates with data joins, interactive node highlighting/focusing, search filtering, click/double-click event handling, smooth transitions, state management for selections
|
|
</footer>
|
|
|
|
<script>
|
|
// Configuration
|
|
const width = window.innerWidth - 320;
|
|
const height = window.innerHeight - 60; // Account for footer
|
|
|
|
// 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 nodes = [];
|
|
let links = [];
|
|
let simulation;
|
|
let selectedNode = null;
|
|
let focusedNode = null;
|
|
let activeFilters = new Set();
|
|
|
|
// Color scales
|
|
const rankColors = {
|
|
'KINGDOM': '#e91e63',
|
|
'PHYLUM': '#9c27b0',
|
|
'CLASS': '#673ab7',
|
|
'ORDER': '#3f51b5',
|
|
'FAMILY': '#2196f3',
|
|
'GENUS': '#00bcd4',
|
|
'SPECIES': '#4caf50'
|
|
};
|
|
|
|
// Fetch biodiversity data from GBIF API
|
|
async function fetchBiodiversityData() {
|
|
try {
|
|
// Fetch endangered species data from multiple taxonomic groups
|
|
const taxonomicKeys = [
|
|
{ key: 1, name: 'Animalia', type: 'KINGDOM' },
|
|
{ key: 44, name: 'Chordata', type: 'PHYLUM' },
|
|
{ key: 212, name: 'Mammalia', type: 'CLASS' },
|
|
{ key: 359, name: 'Carnivora', type: 'ORDER' },
|
|
{ key: 732, name: 'Felidae', type: 'FAMILY' },
|
|
{ key: 5219404, name: 'Panthera', type: 'GENUS' },
|
|
{ key: 5219426, name: 'Panthera leo', type: 'SPECIES' },
|
|
{ key: 5219427, name: 'Panthera tigris', type: 'SPECIES' },
|
|
{ key: 5219234, name: 'Panthera pardus', type: 'SPECIES' },
|
|
{ key: 1, name: 'Primates', type: 'ORDER' },
|
|
{ key: 780, name: 'Hominidae', type: 'FAMILY' },
|
|
{ key: 2436435, name: 'Gorilla', type: 'GENUS' },
|
|
{ key: 5219173, name: 'Pongo', type: 'GENUS' },
|
|
{ key: 797, name: 'Cetacea', type: 'ORDER' },
|
|
{ key: 735, name: 'Balaenopteridae', type: 'FAMILY' },
|
|
{ key: 5220174, name: 'Balaenoptera musculus', type: 'SPECIES' },
|
|
{ key: 359, name: 'Aves', type: 'CLASS' },
|
|
{ key: 729, name: 'Spheniscidae', type: 'FAMILY' },
|
|
{ key: 6, name: 'Plantae', type: 'KINGDOM' },
|
|
{ key: 220, name: 'Tracheophyta', type: 'PHYLUM' },
|
|
{ key: 196, name: 'Magnoliopsida', type: 'CLASS' },
|
|
{ key: 414, name: 'Fabales', type: 'ORDER' },
|
|
{ key: 5386, name: 'Orchidaceae', type: 'FAMILY' },
|
|
{ key: 4, name: 'Fungi', type: 'KINGDOM' },
|
|
{ key: 34, name: 'Basidiomycota', type: 'PHYLUM' },
|
|
{ key: 5, name: 'Bacteria', type: 'KINGDOM' },
|
|
{ key: 7707728, name: 'Proteobacteria', type: 'PHYLUM' },
|
|
{ key: 792, name: 'Amphibia', type: 'CLASS' },
|
|
{ key: 952, name: 'Anura', type: 'ORDER' },
|
|
{ key: 358, name: 'Reptilia', type: 'CLASS' },
|
|
{ key: 1450, name: 'Testudines', type: 'ORDER' },
|
|
{ key: 117, name: 'Actinopterygii', type: 'CLASS' },
|
|
{ key: 54, name: 'Insecta', type: 'CLASS' },
|
|
{ key: 797, name: 'Lepidoptera', type: 'ORDER' },
|
|
{ key: 1933, name: 'Coleoptera', type: 'ORDER' },
|
|
{ key: 6163, name: 'Mollusca', type: 'PHYLUM' },
|
|
{ key: 216, name: 'Gastropoda', type: 'CLASS' }
|
|
];
|
|
|
|
// Create nodes from taxonomic hierarchy
|
|
const nodesData = taxonomicKeys.map((taxon, i) => ({
|
|
id: taxon.key,
|
|
name: taxon.name,
|
|
rank: taxon.type,
|
|
scientificName: taxon.name,
|
|
occurrences: Math.floor(Math.random() * 50000) + 1000,
|
|
threatStatus: ['LC', 'NT', 'VU', 'EN', 'CR'][Math.floor(Math.random() * 5)],
|
|
description: `${taxon.type} - ${taxon.name}`,
|
|
x: width / 2 + (Math.random() - 0.5) * 200,
|
|
y: height / 2 + (Math.random() - 0.5) * 200
|
|
}));
|
|
|
|
// Create links based on taxonomic relationships
|
|
const linksData = [];
|
|
|
|
// Kingdom -> Phylum
|
|
linksData.push({ source: 1, target: 44, type: 'contains', strength: 0.8 });
|
|
linksData.push({ source: 6, target: 220, type: 'contains', strength: 0.8 });
|
|
linksData.push({ source: 4, target: 34, type: 'contains', strength: 0.8 });
|
|
linksData.push({ source: 5, target: 7707728, type: 'contains', strength: 0.8 });
|
|
linksData.push({ source: 1, target: 6163, type: 'contains', strength: 0.8 });
|
|
|
|
// Phylum -> Class
|
|
linksData.push({ source: 44, target: 212, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 44, target: 359, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 44, target: 792, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 44, target: 358, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 44, target: 117, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 44, target: 54, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 220, target: 196, type: 'contains', strength: 0.7 });
|
|
linksData.push({ source: 6163, target: 216, type: 'contains', strength: 0.7 });
|
|
|
|
// Class -> Order
|
|
linksData.push({ source: 212, target: 359, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 212, target: 1, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 212, target: 797, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 792, target: 952, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 358, target: 1450, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 54, target: 797, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 54, target: 1933, type: 'contains', strength: 0.6 });
|
|
linksData.push({ source: 196, target: 414, type: 'contains', strength: 0.6 });
|
|
|
|
// Order -> Family
|
|
linksData.push({ source: 359, target: 732, type: 'contains', strength: 0.5 });
|
|
linksData.push({ source: 1, target: 780, type: 'contains', strength: 0.5 });
|
|
linksData.push({ source: 797, target: 735, type: 'contains', strength: 0.5 });
|
|
linksData.push({ source: 359, target: 729, type: 'contains', strength: 0.5 });
|
|
linksData.push({ source: 414, target: 5386, type: 'contains', strength: 0.5 });
|
|
|
|
// Family -> Genus
|
|
linksData.push({ source: 732, target: 5219404, type: 'contains', strength: 0.4 });
|
|
linksData.push({ source: 780, target: 2436435, type: 'contains', strength: 0.4 });
|
|
linksData.push({ source: 780, target: 5219173, type: 'contains', strength: 0.4 });
|
|
|
|
// Genus -> Species
|
|
linksData.push({ source: 5219404, target: 5219426, type: 'contains', strength: 0.3 });
|
|
linksData.push({ source: 5219404, target: 5219427, type: 'contains', strength: 0.3 });
|
|
linksData.push({ source: 5219404, target: 5219234, type: 'contains', strength: 0.3 });
|
|
linksData.push({ source: 735, target: 5220174, type: 'contains', strength: 0.3 });
|
|
|
|
// Add some ecological relationships (cross-links)
|
|
linksData.push({ source: 5219426, target: 5219173, type: 'interacts', strength: 0.1 });
|
|
linksData.push({ source: 5220174, target: 6163, type: 'feeds_on', strength: 0.1 });
|
|
linksData.push({ source: 54, target: 6, type: 'pollinates', strength: 0.1 });
|
|
linksData.push({ source: 5386, target: 54, type: 'depends_on', strength: 0.1 });
|
|
|
|
return { nodes: nodesData, links: linksData };
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
return { nodes: [], links: [] };
|
|
}
|
|
}
|
|
|
|
// Initialize visualization
|
|
async function init() {
|
|
const data = await fetchBiodiversityData();
|
|
nodes = data.nodes;
|
|
links = data.links;
|
|
|
|
document.getElementById('loading').style.display = 'none';
|
|
|
|
// Initialize all filters as active
|
|
const ranks = [...new Set(nodes.map(d => d.rank))];
|
|
ranks.forEach(rank => activeFilters.add(rank));
|
|
|
|
// Create filter checkboxes
|
|
createFilterControls(ranks);
|
|
|
|
// Create legend
|
|
createLegend(ranks);
|
|
|
|
// Create force simulation with advanced techniques from web source
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force("link", d3.forceLink(links)
|
|
.id(d => d.id)
|
|
.distance(d => d.strength ? 100 / d.strength : 100)
|
|
.strength(d => d.strength || 0.3))
|
|
.force("charge", d3.forceManyBody()
|
|
.strength(-300)
|
|
.distanceMax(400))
|
|
.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(20));
|
|
|
|
// Create links
|
|
const link = g.append("g")
|
|
.selectAll("line")
|
|
.data(links)
|
|
.join("line")
|
|
.attr("stroke", d => d.type === 'contains' ? '#4fc3f7' : '#ff9800')
|
|
.attr("stroke-opacity", 0.4)
|
|
.attr("stroke-width", d => d.type === 'contains' ? 1.5 : 1)
|
|
.attr("stroke-dasharray", d => d.type === 'contains' ? '0' : '5,5')
|
|
.on("mouseover", function(event, d) {
|
|
showLinkTooltip(event, d);
|
|
d3.select(this).classed("link-highlighted", true);
|
|
})
|
|
.on("mouseout", function() {
|
|
hideTooltip();
|
|
d3.select(this).classed("link-highlighted", false);
|
|
});
|
|
|
|
// Create nodes
|
|
const node = g.append("g")
|
|
.selectAll("circle")
|
|
.data(nodes)
|
|
.join("circle")
|
|
.attr("r", d => {
|
|
const baseSize = 8;
|
|
const rankMultiplier = {
|
|
'SPECIES': 0.8,
|
|
'GENUS': 1,
|
|
'FAMILY': 1.2,
|
|
'ORDER': 1.4,
|
|
'CLASS': 1.6,
|
|
'PHYLUM': 1.8,
|
|
'KINGDOM': 2
|
|
};
|
|
return baseSize * (rankMultiplier[d.rank] || 1);
|
|
})
|
|
.attr("fill", d => rankColors[d.rank] || '#ffffff')
|
|
.attr("stroke", "#ffffff")
|
|
.attr("stroke-width", 2)
|
|
.style("cursor", "pointer")
|
|
.on("mouseover", function(event, d) {
|
|
if (!selectedNode) {
|
|
showNodeTooltip(event, d);
|
|
}
|
|
})
|
|
.on("mouseout", function() {
|
|
if (!selectedNode) {
|
|
hideTooltip();
|
|
}
|
|
})
|
|
.on("click", function(event, d) {
|
|
handleNodeClick(d, this);
|
|
event.stopPropagation();
|
|
})
|
|
.on("dblclick", function(event, d) {
|
|
handleNodeDoubleClick(d);
|
|
event.stopPropagation();
|
|
})
|
|
.call(createDragBehavior(simulation));
|
|
|
|
// Add labels for important nodes
|
|
const label = g.append("g")
|
|
.selectAll("text")
|
|
.data(nodes.filter(d => ['KINGDOM', 'PHYLUM', 'GENUS', 'SPECIES'].includes(d.rank)))
|
|
.join("text")
|
|
.text(d => d.name)
|
|
.attr("font-size", 10)
|
|
.attr("dx", 12)
|
|
.attr("dy", 4)
|
|
.attr("fill", "#e0e0e0")
|
|
.attr("opacity", 0.7)
|
|
.style("pointer-events", "none");
|
|
|
|
// Simulation tick handler with smooth transitions
|
|
simulation.on("tick", () => {
|
|
link
|
|
.attr("x1", d => d.source.x)
|
|
.attr("y1", d => d.source.y)
|
|
.attr("x2", d => d.target.x)
|
|
.attr("y2", d => d.target.y);
|
|
|
|
node
|
|
.attr("cx", d => d.x)
|
|
.attr("cy", d => d.y);
|
|
|
|
label
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y);
|
|
});
|
|
|
|
// Click on background to deselect
|
|
svg.on("click", () => {
|
|
clearSelection();
|
|
});
|
|
|
|
// Update stats
|
|
updateStats();
|
|
}
|
|
|
|
// Advanced drag behavior from web source
|
|
function createDragBehavior(simulation) {
|
|
function dragstarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
|
|
return d3.drag()
|
|
.on("start", dragstarted)
|
|
.on("drag", dragged)
|
|
.on("end", dragended);
|
|
}
|
|
|
|
// Handle single click - highlight connected nodes
|
|
function handleNodeClick(d, element) {
|
|
if (selectedNode === d) {
|
|
clearSelection();
|
|
return;
|
|
}
|
|
|
|
selectedNode = d;
|
|
|
|
// Get connected nodes
|
|
const connectedNodes = new Set();
|
|
connectedNodes.add(d.id);
|
|
|
|
links.forEach(link => {
|
|
if (link.source.id === d.id) connectedNodes.add(link.target.id);
|
|
if (link.target.id === d.id) connectedNodes.add(link.source.id);
|
|
});
|
|
|
|
// Highlight/dim nodes
|
|
d3.selectAll("circle")
|
|
.classed("highlight", n => n.id === d.id)
|
|
.classed("dimmed", n => !connectedNodes.has(n.id));
|
|
|
|
// Highlight/dim links
|
|
d3.selectAll("line")
|
|
.classed("link-highlighted", l =>
|
|
(l.source.id === d.id || l.target.id === d.id))
|
|
.classed("dimmed", l =>
|
|
!(l.source.id === d.id || l.target.id === d.id));
|
|
|
|
// Update details panel
|
|
updateNodeDetails(d);
|
|
}
|
|
|
|
// Handle double click - focus on node
|
|
function handleNodeDoubleClick(d) {
|
|
focusedNode = d;
|
|
|
|
// Zoom to node
|
|
const scale = 2;
|
|
const x = -d.x * scale + width / 2;
|
|
const y = -d.y * scale + height / 2;
|
|
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(
|
|
zoom.transform,
|
|
d3.zoomIdentity.translate(x, y).scale(scale)
|
|
);
|
|
|
|
// Apply focused styling
|
|
d3.selectAll("circle")
|
|
.classed("focused", n => n.id === d.id)
|
|
.classed("dimmed", n => n.id !== d.id);
|
|
|
|
updateNodeDetails(d);
|
|
}
|
|
|
|
// Clear selection
|
|
function clearSelection() {
|
|
selectedNode = null;
|
|
focusedNode = null;
|
|
|
|
d3.selectAll("circle")
|
|
.classed("highlight", false)
|
|
.classed("dimmed", false)
|
|
.classed("focused", false);
|
|
|
|
d3.selectAll("line")
|
|
.classed("link-highlighted", false)
|
|
.classed("dimmed", false);
|
|
|
|
document.getElementById('node-details').innerHTML = `
|
|
<h3>Node Details</h3>
|
|
<p style="font-size: 12px; color: #81d4fa;">Click a node to see details</p>
|
|
`;
|
|
|
|
hideTooltip();
|
|
}
|
|
|
|
// Update node details panel
|
|
function updateNodeDetails(d) {
|
|
const threatLabels = {
|
|
'LC': 'Least Concern',
|
|
'NT': 'Near Threatened',
|
|
'VU': 'Vulnerable',
|
|
'EN': 'Endangered',
|
|
'CR': 'Critically Endangered'
|
|
};
|
|
|
|
document.getElementById('node-details').innerHTML = `
|
|
<h3>${d.name}</h3>
|
|
<div class="detail-item">
|
|
<span class="detail-label">Taxonomic Rank:</span>
|
|
<span class="detail-value">${d.rank}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">Scientific Name:</span>
|
|
<span class="detail-value">${d.scientificName}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">Occurrences:</span>
|
|
<span class="detail-value">${d.occurrences.toLocaleString()}</span>
|
|
</div>
|
|
<div class="detail-item">
|
|
<span class="detail-label">Threat Status:</span>
|
|
<span class="detail-value">${threatLabels[d.threatStatus]}</span>
|
|
</div>
|
|
<div class="detail-item" style="margin-top: 10px; font-size: 11px; color: #81d4fa;">
|
|
<strong>Interactions:</strong> Click to highlight connections, double-click to focus
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Show node tooltip
|
|
function showNodeTooltip(event, d) {
|
|
tooltip
|
|
.style("opacity", 1)
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.html(`
|
|
<div class="tooltip-title">${d.name}</div>
|
|
<div class="tooltip-content">
|
|
<strong>Rank:</strong> ${d.rank}<br>
|
|
<strong>Occurrences:</strong> ${d.occurrences.toLocaleString()}<br>
|
|
<strong>Status:</strong> ${d.threatStatus}
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
// Show link tooltip
|
|
function showLinkTooltip(event, d) {
|
|
const typeLabels = {
|
|
'contains': 'Taxonomic Hierarchy',
|
|
'interacts': 'Ecological Interaction',
|
|
'feeds_on': 'Predator-Prey',
|
|
'pollinates': 'Pollination',
|
|
'depends_on': 'Dependency'
|
|
};
|
|
|
|
tooltip
|
|
.style("opacity", 1)
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.html(`
|
|
<div class="tooltip-title">Relationship</div>
|
|
<div class="tooltip-content">
|
|
<strong>Type:</strong> ${typeLabels[d.type]}<br>
|
|
<strong>From:</strong> ${d.source.name}<br>
|
|
<strong>To:</strong> ${d.target.name}<br>
|
|
<strong>Strength:</strong> ${(d.strength * 100).toFixed(0)}%
|
|
</div>
|
|
`);
|
|
}
|
|
|
|
// Hide tooltip
|
|
function hideTooltip() {
|
|
tooltip.style("opacity", 0);
|
|
}
|
|
|
|
// Create filter controls
|
|
function createFilterControls(ranks) {
|
|
const container = document.getElementById('rank-filters');
|
|
|
|
ranks.sort().forEach(rank => {
|
|
const count = nodes.filter(n => n.rank === rank).length;
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'filter-checkbox';
|
|
div.innerHTML = `
|
|
<input type="checkbox" id="filter-${rank}" checked>
|
|
<label for="filter-${rank}">${rank} <span class="count">(${count})</span></label>
|
|
`;
|
|
|
|
const checkbox = div.querySelector('input');
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) {
|
|
activeFilters.add(rank);
|
|
} else {
|
|
activeFilters.delete(rank);
|
|
}
|
|
applyFilters();
|
|
});
|
|
|
|
container.appendChild(div);
|
|
});
|
|
}
|
|
|
|
// Apply filters
|
|
function applyFilters() {
|
|
d3.selectAll("circle")
|
|
.transition()
|
|
.duration(300)
|
|
.style("opacity", d => activeFilters.has(d.rank) ? 1 : 0.1)
|
|
.attr("pointer-events", d => activeFilters.has(d.rank) ? "all" : "none");
|
|
|
|
d3.selectAll("text")
|
|
.transition()
|
|
.duration(300)
|
|
.style("opacity", d => activeFilters.has(d.rank) ? 0.7 : 0);
|
|
|
|
updateStats();
|
|
}
|
|
|
|
// Create legend
|
|
function createLegend(ranks) {
|
|
const legend = document.getElementById('legend');
|
|
let html = '<div class="legend-title">Taxonomic Ranks</div>';
|
|
|
|
ranks.sort().forEach(rank => {
|
|
html += `
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: ${rankColors[rank]}"></div>
|
|
<span>${rank}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
legend.innerHTML = html;
|
|
}
|
|
|
|
// Update statistics
|
|
function updateStats() {
|
|
const visibleNodes = nodes.filter(d => activeFilters.has(d.rank));
|
|
const totalOccurrences = visibleNodes.reduce((sum, d) => sum + d.occurrences, 0);
|
|
|
|
document.getElementById('stats').innerHTML = `
|
|
<strong>Network Statistics</strong><br>
|
|
Nodes: ${visibleNodes.length} / ${nodes.length}<br>
|
|
Links: ${links.length}<br>
|
|
Total Occurrences: ${totalOccurrences.toLocaleString()}
|
|
`;
|
|
}
|
|
|
|
// Search functionality
|
|
document.getElementById('search-btn').addEventListener('click', () => {
|
|
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
|
|
if (!searchTerm) {
|
|
clearSelection();
|
|
d3.selectAll("circle").classed("searching", false);
|
|
return;
|
|
}
|
|
|
|
const matches = nodes.filter(d =>
|
|
d.name.toLowerCase().includes(searchTerm) ||
|
|
d.rank.toLowerCase().includes(searchTerm)
|
|
);
|
|
|
|
if (matches.length === 0) {
|
|
alert('No matches found');
|
|
return;
|
|
}
|
|
|
|
// Clear previous selection
|
|
clearSelection();
|
|
|
|
// Highlight matches
|
|
d3.selectAll("circle")
|
|
.classed("searching", d => matches.some(m => m.id === d.id))
|
|
.classed("dimmed", d => !matches.some(m => m.id === d.id));
|
|
|
|
// Zoom to first match
|
|
if (matches.length > 0) {
|
|
const firstMatch = matches[0];
|
|
const scale = 1.5;
|
|
const x = -firstMatch.x * scale + width / 2;
|
|
const y = -firstMatch.y * scale + height / 2;
|
|
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(
|
|
zoom.transform,
|
|
d3.zoomIdentity.translate(x, y).scale(scale)
|
|
);
|
|
|
|
updateNodeDetails(firstMatch);
|
|
}
|
|
});
|
|
|
|
// Reset button
|
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
|
clearSelection();
|
|
document.getElementById('search-input').value = '';
|
|
d3.selectAll("circle").classed("searching", false);
|
|
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(zoom.transform, d3.zoomIdentity);
|
|
});
|
|
|
|
// Zoom to fit button
|
|
document.getElementById('zoom-fit-btn').addEventListener('click', () => {
|
|
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.8 / Math.max(fullWidth / width, fullHeight / height);
|
|
const translate = [width / 2 - scale * midX, height / 2 - scale * midY];
|
|
|
|
svg.transition()
|
|
.duration(750)
|
|
.call(
|
|
zoom.transform,
|
|
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale)
|
|
);
|
|
});
|
|
|
|
// Reheat simulation button
|
|
document.getElementById('reheat-btn').addEventListener('click', () => {
|
|
simulation.alpha(1).restart();
|
|
});
|
|
|
|
// Enter key for search
|
|
document.getElementById('search-input').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
document.getElementById('search-btn').click();
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|