1271 lines
44 KiB
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>
|