infinite-agents-public/sdg_viz/sdg_viz_13.html

1271 lines
44 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 Visualization 13 - Advanced Filtering & Exploration</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a0000 0%, #330000 50%, #660000 100%);
color: #fff;
overflow: hidden;
}
#container {
width: 100vw;
height: 100vh;
display: flex;
}
#left-panel {
width: 280px;
background: rgba(0, 0, 0, 0.85);
border-right: 2px solid rgba(220, 53, 69, 0.5);
display: flex;
flex-direction: column;
overflow-y: auto;
}
#visualization {
flex: 1;
position: relative;
}
#right-panel {
width: 300px;
background: rgba(0, 0, 0, 0.85);
border-left: 2px solid rgba(13, 110, 253, 0.5);
overflow-y: auto;
padding: 20px;
}
canvas {
display: block;
cursor: grab;
}
canvas:active {
cursor: grabbing;
}
/* FILTER PANEL */
.filter-section {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.filter-section h3 {
font-size: 13px;
color: #dc3545;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.filter-section h3::before {
content: '';
width: 3px;
height: 14px;
background: #dc3545;
}
/* SEARCH BOX */
.search-container {
position: relative;
margin-bottom: 10px;
}
#search-box {
width: 100%;
padding: 10px 35px 10px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(220, 53, 69, 0.3);
border-radius: 6px;
color: #fff;
font-size: 13px;
transition: all 0.3s;
}
#search-box:focus {
outline: none;
border-color: #dc3545;
background: rgba(255, 255, 255, 0.08);
}
#search-box::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.search-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #dc3545;
pointer-events: none;
}
#search-results {
font-size: 11px;
color: #aaa;
margin-top: 8px;
}
#search-results.has-results {
color: #dc3545;
font-weight: bold;
}
/* TOPIC FILTERS */
.topic-filters {
max-height: 300px;
overflow-y: auto;
}
.filter-checkbox {
display: flex;
align-items: center;
padding: 8px 0;
cursor: pointer;
transition: all 0.2s;
}
.filter-checkbox:hover {
background: rgba(220, 53, 69, 0.1);
padding-left: 5px;
}
.filter-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
margin-right: 10px;
cursor: pointer;
accent-color: #dc3545;
}
.filter-checkbox .color-dot {
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
}
.filter-checkbox label {
font-size: 12px;
cursor: pointer;
flex: 1;
}
.filter-checkbox .count {
font-size: 10px;
color: #666;
background: rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 3px;
}
/* DATA SOURCE FILTERS */
.source-filter-group {
margin-bottom: 15px;
}
.source-filter-group h4 {
font-size: 11px;
color: #aaa;
margin-bottom: 8px;
text-transform: uppercase;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
padding: 5px 10px;
background: rgba(13, 110, 253, 0.2);
border: 1px solid rgba(13, 110, 253, 0.4);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.filter-chip:hover {
background: rgba(13, 110, 253, 0.3);
border-color: rgba(13, 110, 253, 0.6);
}
.filter-chip.active {
background: #0d6efd;
border-color: #0d6efd;
color: #fff;
font-weight: bold;
}
/* CONNECTION STRENGTH SLIDER */
.slider-container {
margin-top: 15px;
}
.slider-container label {
display: block;
font-size: 12px;
color: #aaa;
margin-bottom: 8px;
}
.slider-container input[type="range"] {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
outline: none;
}
.slider-container input[type="range"]::-webkit-slider-thumb {
width: 14px;
height: 14px;
background: #dc3545;
cursor: pointer;
border-radius: 50%;
}
.slider-value {
font-size: 11px;
color: #dc3545;
font-weight: bold;
}
/* RESET BUTTON */
.reset-section {
padding: 15px 20px;
background: rgba(220, 53, 69, 0.1);
border-top: 2px solid rgba(220, 53, 69, 0.3);
}
#reset-filters {
width: 100%;
padding: 10px;
background: rgba(220, 53, 69, 0.3);
border: 1px solid #dc3545;
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-weight: bold;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#reset-filters:hover {
background: #dc3545;
transform: translateY(-1px);
}
#reset-filters:active {
transform: translateY(0);
}
/* ACTIVE FILTERS STATUS */
#filter-status {
padding: 12px 20px;
background: rgba(220, 53, 69, 0.15);
border-bottom: 1px solid rgba(220, 53, 69, 0.3);
font-size: 11px;
color: #aaa;
}
#filter-status.active {
color: #dc3545;
font-weight: bold;
}
#filter-status .count {
color: #fff;
background: #dc3545;
padding: 2px 6px;
border-radius: 3px;
margin-left: 5px;
}
/* RIGHT PANEL - DETAILS */
#right-panel h3 {
font-size: 14px;
color: #0d6efd;
margin-bottom: 15px;
text-transform: uppercase;
letter-spacing: 1px;
}
.detail-section {
margin-bottom: 20px;
padding: 15px;
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
border-left: 3px solid #0d6efd;
}
.detail-section h4 {
font-size: 12px;
color: #0d6efd;
margin-bottom: 10px;
}
.detail-item {
font-size: 11px;
color: #ccc;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
}
.detail-item .label {
color: #888;
}
.detail-item .value {
color: #fff;
font-weight: bold;
}
.connection-list {
max-height: 200px;
overflow-y: auto;
margin-top: 10px;
}
.connection-item {
padding: 8px;
background: rgba(13, 110, 253, 0.1);
border-left: 2px solid #0d6efd;
margin-bottom: 6px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.connection-item:hover {
background: rgba(13, 110, 253, 0.2);
padding-left: 12px;
}
.connection-item .name {
color: #fff;
font-weight: bold;
}
.connection-item .type {
color: #888;
font-size: 10px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #666;
font-size: 12px;
font-style: italic;
}
/* TOOLTIP */
.info-tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.95);
padding: 12px 16px;
border-radius: 6px;
border: 1px solid #dc3545;
pointer-events: none;
z-index: 1000;
display: none;
font-size: 12px;
max-width: 250px;
}
.info-tooltip.visible {
display: block;
}
.info-tooltip .title {
font-weight: bold;
color: #dc3545;
margin-bottom: 6px;
}
.info-tooltip .details {
color: #ccc;
font-size: 11px;
}
/* SCROLLBAR STYLING */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-thumb {
background: rgba(220, 53, 69, 0.5);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(220, 53, 69, 0.7);
}
</style>
</head>
<body>
<div id="container">
<!-- LEFT PANEL: FILTERS -->
<div id="left-panel">
<div id="filter-status">
No filters active
</div>
<!-- SEARCH -->
<div class="filter-section">
<h3>Search</h3>
<div class="search-container">
<input type="text" id="search-box" placeholder="Search topics or sources...">
<span class="search-icon">🔍</span>
</div>
<div id="search-results">Type to search...</div>
</div>
<!-- TOPIC FILTERS -->
<div class="filter-section">
<h3>SDG Topics</h3>
<div class="topic-filters" id="topic-filters"></div>
</div>
<!-- DATA SOURCE FILTERS -->
<div class="filter-section">
<h3>Data Sources</h3>
<div class="source-filter-group">
<h4>API Type</h4>
<div class="filter-chips" id="api-type-filters"></div>
</div>
<div class="source-filter-group">
<h4>Data Format</h4>
<div class="filter-chips" id="format-filters"></div>
</div>
<div class="source-filter-group">
<h4>Update Frequency</h4>
<div class="filter-chips" id="frequency-filters"></div>
</div>
</div>
<!-- CONNECTION STRENGTH -->
<div class="filter-section">
<h3>Connection Strength</h3>
<div class="slider-container">
<label>
Minimum Strength: <span class="slider-value" id="strength-value">0</span>
</label>
<input type="range" id="strength-slider" min="0" max="10" value="0" step="1">
</div>
</div>
<!-- RESET -->
<div class="reset-section">
<button id="reset-filters">🔄 Reset All Filters</button>
</div>
</div>
<!-- CENTER: VISUALIZATION -->
<div id="visualization">
<canvas id="canvas"></canvas>
<div class="info-tooltip" id="tooltip">
<div class="title"></div>
<div class="details"></div>
</div>
</div>
<!-- RIGHT PANEL: DETAILS -->
<div id="right-panel">
<h3>Selection Details</h3>
<div id="details-content">
<div class="empty-state">
Click on a topic or data source to explore connections
</div>
</div>
</div>
</div>
<script>
// ============================================================================
// CONFIGURATION & DATA
// ============================================================================
const SDG_CATEGORIES = [
{ id: 1, name: "No Poverty", color: "#E5243B" },
{ id: 2, name: "Zero Hunger", color: "#DDA63A" },
{ id: 3, name: "Good Health", color: "#4C9F38" },
{ id: 4, name: "Quality Education", color: "#C5192D" },
{ id: 5, name: "Gender Equality", color: "#FF3A21" },
{ id: 6, name: "Clean Water", color: "#26BDE2" },
{ id: 7, name: "Affordable Energy", color: "#FCC30B" },
{ id: 8, name: "Decent Work", color: "#A21942" },
{ id: 9, name: "Industry Innovation", color: "#FD6925" },
{ id: 10, name: "Reduced Inequalities", color: "#DD1367" },
{ id: 11, name: "Sustainable Cities", color: "#FD9D24" },
{ id: 12, name: "Responsible Consumption", color: "#BF8B2E" },
{ id: 13, name: "Climate Action", color: "#3F7E44" },
{ id: 14, name: "Life Below Water", color: "#0A97D9" },
{ id: 15, name: "Life on Land", color: "#56C02B" },
{ id: 16, name: "Peace Justice", color: "#00689D" },
{ id: 17, name: "Partnerships", color: "#19486A" }
];
const API_TYPES = ['REST', 'GraphQL', 'SOAP', 'WebSocket'];
const DATA_FORMATS = ['JSON', 'CSV', 'XML', 'Parquet'];
const UPDATE_FREQUENCIES = ['Real-time', 'Daily', 'Weekly', 'Monthly'];
// ============================================================================
// STATE MANAGEMENT
// ============================================================================
let state = {
// Data
topics: [],
sources: [],
links: [],
// Visual
transform: d3.zoomIdentity,
selectedNode: null,
hoveredNode: null,
// Filters
filters: {
search: '',
selectedTopics: new Set(),
apiTypes: new Set(),
dataFormats: new Set(),
updateFrequencies: new Set(),
minStrength: 0
},
// Simulation
simulation: null
};
// ============================================================================
// DATA GENERATION
// ============================================================================
function generateBipartiteNetwork() {
const topics = [];
const sources = [];
const links = [];
// Generate topic nodes (left side)
SDG_CATEGORIES.forEach((sdg, i) => {
topics.push({
id: `topic_${sdg.id}`,
name: sdg.name,
type: 'topic',
sdgId: sdg.id,
color: sdg.color,
x: 300,
y: (i + 1) * (window.innerHeight / (SDG_CATEGORIES.length + 1))
});
});
// Generate data source nodes (right side)
const sourcesPerTopic = 3;
let sourceId = 0;
SDG_CATEGORIES.forEach((sdg, topicIdx) => {
for (let i = 0; i < sourcesPerTopic; i++) {
const apiType = API_TYPES[Math.floor(Math.random() * API_TYPES.length)];
const format = DATA_FORMATS[Math.floor(Math.random() * DATA_FORMATS.length)];
const frequency = UPDATE_FREQUENCIES[Math.floor(Math.random() * UPDATE_FREQUENCIES.length)];
sources.push({
id: `source_${sourceId}`,
name: `${sdg.name} Data ${i + 1}`,
type: 'source',
apiType: apiType,
dataFormat: format,
updateFrequency: frequency,
primaryTopic: sdg.id,
x: window.innerWidth - 400,
y: Math.random() * window.innerHeight
});
sourceId++;
}
});
// Create primary connections
sources.forEach(source => {
const strength = Math.floor(Math.random() * 8) + 3; // 3-10
links.push({
source: `topic_${source.primaryTopic}`,
target: source.id,
strength: strength,
primary: true
});
});
// Add some cross-connections
sources.forEach(source => {
if (Math.random() < 0.4) {
const randomTopic = topics[Math.floor(Math.random() * topics.length)];
if (randomTopic.sdgId !== source.primaryTopic) {
const strength = Math.floor(Math.random() * 5) + 1; // 1-5
links.push({
source: randomTopic.id,
target: source.id,
strength: strength,
primary: false
});
}
}
});
return { topics, sources, links };
}
// ============================================================================
// CANVAS SETUP
// ============================================================================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const vizContainer = document.getElementById('visualization');
function resizeCanvas() {
canvas.width = vizContainer.clientWidth;
canvas.height = vizContainer.clientHeight;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// ============================================================================
// FILTERING LOGIC
// ============================================================================
function getFilteredData() {
const { search, selectedTopics, apiTypes, dataFormats, updateFrequencies, minStrength } = state.filters;
// Filter topics
let filteredTopics = state.topics.filter(topic => {
// Search filter
if (search && !topic.name.toLowerCase().includes(search.toLowerCase())) {
return false;
}
// Topic filter
if (selectedTopics.size > 0 && !selectedTopics.has(topic.sdgId)) {
return false;
}
return true;
});
// Filter sources
let filteredSources = state.sources.filter(source => {
// Search filter
if (search && !source.name.toLowerCase().includes(search.toLowerCase())) {
return false;
}
// API type filter
if (apiTypes.size > 0 && !apiTypes.has(source.apiType)) {
return false;
}
// Data format filter
if (dataFormats.size > 0 && !dataFormats.has(source.dataFormat)) {
return false;
}
// Update frequency filter
if (updateFrequencies.size > 0 && !updateFrequencies.has(source.updateFrequency)) {
return false;
}
return true;
});
// Get IDs for quick lookup
const filteredTopicIds = new Set(filteredTopics.map(t => t.id));
const filteredSourceIds = new Set(filteredSources.map(s => s.id));
// Filter links based on filtered nodes and strength
let filteredLinks = state.links.filter(link => {
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
return filteredTopicIds.has(sourceId) &&
filteredSourceIds.has(targetId) &&
link.strength >= minStrength;
});
// Further filter nodes that have no connections
const connectedTopicIds = new Set();
const connectedSourceIds = new Set();
filteredLinks.forEach(link => {
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
connectedTopicIds.add(sourceId);
connectedSourceIds.add(targetId);
});
filteredTopics = filteredTopics.filter(t => connectedTopicIds.has(t.id));
filteredSources = filteredSources.filter(s => connectedSourceIds.has(s.id));
return { topics: filteredTopics, sources: filteredSources, links: filteredLinks };
}
function updateFilterStatus() {
const { search, selectedTopics, apiTypes, dataFormats, updateFrequencies, minStrength } = state.filters;
const activeCount = (search ? 1 : 0) +
selectedTopics.size +
apiTypes.size +
dataFormats.size +
updateFrequencies.size +
(minStrength > 0 ? 1 : 0);
const statusEl = document.getElementById('filter-status');
if (activeCount > 0) {
statusEl.innerHTML = `Active filters: <span class="count">${activeCount}</span>`;
statusEl.classList.add('active');
} else {
statusEl.textContent = 'No filters active';
statusEl.classList.remove('active');
}
const filtered = getFilteredData();
document.getElementById('search-results').innerHTML =
filtered.topics.length + filtered.sources.length > 0
? `<span class="has-results">${filtered.topics.length} topics, ${filtered.sources.length} sources</span>`
: 'No results';
}
function applyFilters() {
updateFilterStatus();
render();
}
// ============================================================================
// RENDERING
// ============================================================================
function render() {
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
ctx.save();
// Apply transform
ctx.translate(state.transform.x, state.transform.y);
ctx.scale(state.transform.k, state.transform.k);
const filtered = getFilteredData();
// Draw links
ctx.globalAlpha = 0.3;
filtered.links.forEach(link => {
const source = typeof link.source === 'object' ? link.source :
state.topics.find(t => t.id === link.source) ||
state.sources.find(s => s.id === link.source);
const target = typeof link.target === 'object' ? link.target :
state.topics.find(t => t.id === link.target) ||
state.sources.find(s => s.id === link.target);
if (source && target) {
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
// Highlight selected connections
if (state.selectedNode &&
(source.id === state.selectedNode.id || target.id === state.selectedNode.id)) {
ctx.strokeStyle = link.primary ? '#dc3545' : '#0d6efd';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
} else {
ctx.strokeStyle = link.primary ? '#dc3545' : '#0d6efd';
ctx.lineWidth = Math.max(0.5, link.strength / 5);
ctx.globalAlpha = 0.2;
}
ctx.stroke();
}
});
ctx.globalAlpha = 1.0;
// Draw topic nodes (red - left side)
filtered.topics.forEach(topic => {
const isSelected = state.selectedNode && state.selectedNode.id === topic.id;
const isHovered = state.hoveredNode && state.hoveredNode.id === topic.id;
const radius = isSelected ? 12 : (isHovered ? 10 : 8);
ctx.beginPath();
ctx.arc(topic.x, topic.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = topic.color;
ctx.fill();
if (isSelected || isHovered) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
}
// Label
if (state.transform.k > 0.7) {
ctx.fillStyle = '#fff';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(topic.name, topic.x - radius - 5, topic.y + 4);
}
});
// Draw source nodes (blue - right side)
filtered.sources.forEach(source => {
const isSelected = state.selectedNode && state.selectedNode.id === source.id;
const isHovered = state.hoveredNode && state.hoveredNode.id === source.id;
const radius = isSelected ? 10 : (isHovered ? 8 : 6);
ctx.beginPath();
ctx.arc(source.x, source.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = '#0d6efd';
ctx.fill();
if (isSelected || isHovered) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
}
// Label
if (state.transform.k > 0.9) {
ctx.fillStyle = '#fff';
ctx.font = '10px sans-serif';
ctx.textAlign = 'left';
ctx.fillText(source.name, source.x + radius + 5, source.y + 3);
}
});
ctx.restore();
}
// ============================================================================
// INTERACTION HANDLERS
// ============================================================================
// Zoom and pan
const zoom = d3.zoom()
.scaleExtent([0.3, 3])
.on('zoom', (event) => {
state.transform = event.transform;
render();
});
d3.select(canvas).call(zoom);
// Drag
const drag = d3.drag()
.subject(dragsubject)
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended);
d3.select(canvas).call(drag);
function dragsubject(event) {
const [mx, my] = state.transform.invert([event.x, event.y]);
const filtered = getFilteredData();
const allNodes = [...filtered.topics, ...filtered.sources];
for (let node of allNodes) {
const dx = node.x - mx;
const dy = node.y - my;
const radius = node.type === 'topic' ? 12 : 10;
if (dx * dx + dy * dy < radius * radius) {
return node;
}
}
return null;
}
function dragstarted(event) {
if (event.subject) {
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
}
function dragged(event) {
if (event.subject) {
const [x, y] = state.transform.invert([event.x, event.y]);
event.subject.fx = x;
event.subject.fy = y;
event.subject.x = x;
event.subject.y = y;
render();
}
}
function dragended(event) {
if (event.subject) {
event.subject.fx = null;
event.subject.fy = null;
}
}
// Click to select
canvas.addEventListener('click', (event) => {
const [mx, my] = state.transform.invert([event.offsetX, event.offsetY]);
const filtered = getFilteredData();
const allNodes = [...filtered.topics, ...filtered.sources];
let clicked = null;
for (let node of allNodes) {
const dx = node.x - mx;
const dy = node.y - my;
const radius = node.type === 'topic' ? 12 : 10;
if (dx * dx + dy * dy < radius * radius) {
clicked = node;
break;
}
}
state.selectedNode = clicked;
updateDetailsPanel();
render();
});
// Hover tooltip
const tooltip = document.getElementById('tooltip');
canvas.addEventListener('mousemove', (event) => {
const [mx, my] = state.transform.invert([event.offsetX, event.offsetY]);
const filtered = getFilteredData();
const allNodes = [...filtered.topics, ...filtered.sources];
let hovered = null;
for (let node of allNodes) {
const dx = node.x - mx;
const dy = node.y - my;
const radius = node.type === 'topic' ? 12 : 10;
if (dx * dx + dy * dy < radius * radius) {
hovered = node;
break;
}
}
state.hoveredNode = hovered;
if (hovered) {
const title = hovered.name;
const details = hovered.type === 'topic'
? `SDG ${hovered.sdgId} • Topic`
: `${hovered.apiType}${hovered.dataFormat}${hovered.updateFrequency}`;
tooltip.querySelector('.title').textContent = title;
tooltip.querySelector('.details').textContent = details;
tooltip.style.left = (event.pageX + 15) + 'px';
tooltip.style.top = (event.pageY + 15) + 'px';
tooltip.classList.add('visible');
} else {
tooltip.classList.remove('visible');
}
render();
});
canvas.addEventListener('mouseleave', () => {
tooltip.classList.remove('visible');
state.hoveredNode = null;
render();
});
// ============================================================================
// FILTER UI INITIALIZATION
// ============================================================================
function initTopicFilters() {
const container = document.getElementById('topic-filters');
SDG_CATEGORIES.forEach(sdg => {
const count = state.topics.filter(t => t.sdgId === sdg.id).length;
const div = document.createElement('div');
div.className = 'filter-checkbox';
div.innerHTML = `
<input type="checkbox" id="topic-${sdg.id}" value="${sdg.id}">
<div class="color-dot" style="background: ${sdg.color}"></div>
<label for="topic-${sdg.id}">${sdg.name}</label>
<span class="count">${count}</span>
`;
const checkbox = div.querySelector('input');
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
state.filters.selectedTopics.add(sdg.id);
} else {
state.filters.selectedTopics.delete(sdg.id);
}
applyFilters();
});
container.appendChild(div);
});
}
function initSourceFilters() {
// API Types
const apiContainer = document.getElementById('api-type-filters');
API_TYPES.forEach(type => {
const chip = document.createElement('div');
chip.className = 'filter-chip';
chip.textContent = type;
chip.addEventListener('click', () => {
if (state.filters.apiTypes.has(type)) {
state.filters.apiTypes.delete(type);
chip.classList.remove('active');
} else {
state.filters.apiTypes.add(type);
chip.classList.add('active');
}
applyFilters();
});
apiContainer.appendChild(chip);
});
// Data Formats
const formatContainer = document.getElementById('format-filters');
DATA_FORMATS.forEach(format => {
const chip = document.createElement('div');
chip.className = 'filter-chip';
chip.textContent = format;
chip.addEventListener('click', () => {
if (state.filters.dataFormats.has(format)) {
state.filters.dataFormats.delete(format);
chip.classList.remove('active');
} else {
state.filters.dataFormats.add(format);
chip.classList.add('active');
}
applyFilters();
});
formatContainer.appendChild(chip);
});
// Update Frequencies
const freqContainer = document.getElementById('frequency-filters');
UPDATE_FREQUENCIES.forEach(freq => {
const chip = document.createElement('div');
chip.className = 'filter-chip';
chip.textContent = freq;
chip.addEventListener('click', () => {
if (state.filters.updateFrequencies.has(freq)) {
state.filters.updateFrequencies.delete(freq);
chip.classList.remove('active');
} else {
state.filters.updateFrequencies.add(freq);
chip.classList.add('active');
}
applyFilters();
});
freqContainer.appendChild(chip);
});
}
// ============================================================================
// FILTER CONTROLS
// ============================================================================
// Search
document.getElementById('search-box').addEventListener('input', (e) => {
state.filters.search = e.target.value;
applyFilters();
});
// Connection strength slider
document.getElementById('strength-slider').addEventListener('input', (e) => {
const value = parseInt(e.target.value);
state.filters.minStrength = value;
document.getElementById('strength-value').textContent = value;
applyFilters();
});
// Reset filters
document.getElementById('reset-filters').addEventListener('click', () => {
// Clear all filters
state.filters = {
search: '',
selectedTopics: new Set(),
apiTypes: new Set(),
dataFormats: new Set(),
updateFrequencies: new Set(),
minStrength: 0
};
// Reset UI
document.getElementById('search-box').value = '';
document.getElementById('strength-slider').value = 0;
document.getElementById('strength-value').textContent = '0';
document.querySelectorAll('.filter-checkbox input').forEach(cb => cb.checked = false);
document.querySelectorAll('.filter-chip').forEach(chip => chip.classList.remove('active'));
applyFilters();
});
// ============================================================================
// DETAILS PANEL
// ============================================================================
function updateDetailsPanel() {
const container = document.getElementById('details-content');
if (!state.selectedNode) {
container.innerHTML = '<div class="empty-state">Click on a topic or data source to explore connections</div>';
return;
}
const node = state.selectedNode;
if (node.type === 'topic') {
// Get connected sources
const connections = state.links
.filter(link => {
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
return sourceId === node.id;
})
.map(link => {
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
const source = state.sources.find(s => s.id === targetId);
return { ...source, strength: link.strength, primary: link.primary };
})
.sort((a, b) => b.strength - a.strength);
container.innerHTML = `
<div class="detail-section">
<h4>${node.name}</h4>
<div class="detail-item">
<span class="label">Type:</span>
<span class="value">SDG Topic</span>
</div>
<div class="detail-item">
<span class="label">SDG Number:</span>
<span class="value">${node.sdgId}</span>
</div>
<div class="detail-item">
<span class="label">Connected Sources:</span>
<span class="value">${connections.length}</span>
</div>
</div>
<div class="detail-section">
<h4>Data Sources (${connections.length})</h4>
<div class="connection-list">
${connections.map(conn => `
<div class="connection-item">
<div class="name">${conn.name}</div>
<div class="type">${conn.apiType}${conn.dataFormat} • Strength: ${conn.strength}</div>
</div>
`).join('')}
</div>
</div>
`;
} else {
// Get connected topics
const connections = state.links
.filter(link => {
const targetId = typeof link.target === 'object' ? link.target.id : link.target;
return targetId === node.id;
})
.map(link => {
const sourceId = typeof link.source === 'object' ? link.source.id : link.source;
const topic = state.topics.find(t => t.id === sourceId);
return { ...topic, strength: link.strength, primary: link.primary };
})
.sort((a, b) => b.strength - a.strength);
container.innerHTML = `
<div class="detail-section">
<h4>${node.name}</h4>
<div class="detail-item">
<span class="label">Type:</span>
<span class="value">Data Source</span>
</div>
<div class="detail-item">
<span class="label">API Type:</span>
<span class="value">${node.apiType}</span>
</div>
<div class="detail-item">
<span class="label">Data Format:</span>
<span class="value">${node.dataFormat}</span>
</div>
<div class="detail-item">
<span class="label">Update Frequency:</span>
<span class="value">${node.updateFrequency}</span>
</div>
<div class="detail-item">
<span class="label">Connected Topics:</span>
<span class="value">${connections.length}</span>
</div>
</div>
<div class="detail-section">
<h4>Related Topics (${connections.length})</h4>
<div class="connection-list">
${connections.map(conn => `
<div class="connection-item">
<div class="name">${conn.name}</div>
<div class="type">SDG ${conn.sdgId} • Strength: ${conn.strength}${conn.primary ? ' (Primary)' : ''}</div>
</div>
`).join('')}
</div>
</div>
`;
}
}
// ============================================================================
// INITIALIZATION
// ============================================================================
function init() {
// Generate data
const network = generateBipartiteNetwork();
state.topics = network.topics;
state.sources = network.sources;
state.links = network.links;
// Initialize UI
initTopicFilters();
initSourceFilters();
updateFilterStatus();
// Initial render
render();
}
// Start the visualization
init();
console.log('SDG Visualization 13 - Advanced Filtering & Exploration');
console.log('Web Learning: Filter state management from Observable D3 Sortable Bar Chart');
console.log('Features: Topic filters, source filters, search, connection strength, click to explore');
</script>
</body>
</html>