argobox-portfolio/src/components/KnowledgeGraph.astro

435 lines
24 KiB
Plaintext

---
// src/components/KnowledgeGraph.astro
// Interactive visualization of content connections using Cytoscape.js
export interface GraphNode {
id: string;
label: string;
category?: string;
tags?: string[];
url?: string; // Added URL for linking
}
export interface GraphEdge {
source: string;
target: string;
strength?: number;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
interface Props {
graphData: GraphData;
height?: string; // e.g., '600px'
}
const { graphData, height = "60vh" } = Astro.props;
// Generate colors based on categories for nodes
const uniqueCategories = [...new Set(graphData.nodes.map(node => node.category || 'Uncategorized'))];
const categoryColors = {};
const predefinedColors = {
'Kubernetes': '#326CE5', 'Docker': '#2496ED', 'DevOps': '#FF6F61',
'Homelab': '#06B6D4', 'Networking': '#9333EA', 'Infrastructure': '#10B981',
'Automation': '#F59E0B', 'Security': '#EF4444', 'Monitoring': '#6366F1',
'Storage': '#8B5CF6', 'Obsidian': '#7C3AED', 'Tutorial': '#3B82F6',
'Uncategorized': '#A0AEC0'
};
uniqueCategories.forEach((category, index) => {
if (predefinedColors[category]) {
categoryColors[category] = predefinedColors[category];
} else {
const hue = (index * 137.5) % 360;
categoryColors[category] = `hsl(${hue}, 70%, 60%)`;
}
});
// Calculate node sizes
const nodeSizes = {};
const minSize = 20; const maxSize = 40;
const degreeMap = new Map();
graphData.nodes.forEach(node => degreeMap.set(node.id, 0));
graphData.edges.forEach(edge => {
degreeMap.set(edge.source, (degreeMap.get(edge.source) || 0) + 1);
degreeMap.set(edge.target, (degreeMap.get(edge.target) || 0) + 1);
});
const maxDegree = Math.max(...Array.from(degreeMap.values()), 1);
graphData.nodes.forEach(node => {
const degree = degreeMap.get(node.id) || 0;
const normalizedSize = maxDegree === 0 ? 0.5 : degree / maxDegree;
nodeSizes[node.id] = minSize + normalizedSize * (maxSize - minSize);
});
---
<!-- Include Cytoscape via CDN with is:inline to ensure it loads before the script runs -->
<script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js" is:inline></script>
<div class="graph-wrapper" style={`--graph-height: ${height};`}>
<!-- Loading Animation -->
<div id="graph-loading" class="graph-loading">
<div class="loading-spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<div class="loading-text">Initializing Knowledge Graph...</div>
</div>
<!-- Cytoscape Container -->
<div id="knowledge-graph" class="graph-container"></div>
<!-- Node Details Panel -->
<div id="node-details" class="node-details">
<div class="node-details-header">
<h3 id="node-title" class="node-title">Node Title</h3>
<button id="close-details" class="close-button" aria-label="Close details">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div id="node-category" class="node-category">
<span class="category-label">Category:</span>
<span class="category-value">Category Name</span>
</div>
<div id="node-tags" class="node-tags">
<span class="tags-label">Tags:</span>
<div class="tags-container">
<!-- Tags populated by JS -->
</div>
</div>
<div id="node-connections" class="node-connections">
<span class="connections-label">Connections:</span>
<ul class="connections-list">
<!-- Connections populated by JS -->
</ul>
</div>
<a href="#" id="node-link" class="node-link" target="_blank" rel="noopener noreferrer">Read Article</a>
</div>
<!-- Graph Controls -->
<div class="graph-controls">
<div class="graph-filters">
<button class="graph-filter active" data-filter="all" style="--filter-color: var(--accent-primary);">All Topics</button>
{uniqueCategories.map(category => (
<button class="graph-filter" data-filter={category} style={`--filter-color: ${categoryColors[category]};`}>{category}</button>
))}
</div>
<div class="graph-actions">
<button id="zoom-in" class="graph-action" aria-label="Zoom In">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button id="zoom-out" class="graph-action" aria-label="Zoom Out">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button id="reset-graph" class="graph-action" aria-label="Reset View">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v6h6"></path><path d="M21 12A9 9 0 0 0 6 5.3L3 8"></path><path d="M21 22v-6h-6"></path><path d="M3 12a9 9 0 0 0 15 6.7l3-2.7"></path></svg>
</button>
</div>
</div>
<!-- Graph Legend -->
<details class="graph-legend">
<summary class="legend-title">Legend</summary>
<div class="legend-items">
{uniqueCategories.map(category => (
<div class="legend-item" data-category={category}>
<span class="legend-color" style={`background-color: ${categoryColors[category]};`}></span>
<span class="legend-label">{category}</span>
</div>
))}
</div>
</details>
</div>
<script define:vars={{ graphData, categoryColors, nodeSizes }}>
// Initialize the graph when the DOM is ready
function initializeGraph() {
// Check if Cytoscape is loaded
if (typeof cytoscape === 'undefined') {
console.error("Cytoscape library not loaded. Make sure it's included via the script tag.");
const loadingEl = document.getElementById('graph-loading');
if(loadingEl) loadingEl.innerHTML = "<p>Error: Cytoscape library not loaded.</p>";
return;
}
const loadingEl = document.getElementById('graph-loading');
const nodeDetailsEl = document.getElementById('node-details');
const closeDetailsBtn = document.getElementById('close-details');
const graphContainer = document.getElementById('knowledge-graph');
if (!graphContainer) {
console.error("Knowledge graph container not found!");
return;
}
if (!graphData || !graphData.nodes) {
console.error("Graph data is missing or invalid.");
if(loadingEl) loadingEl.innerHTML = "<p>Error loading graph data.</p>";
return;
}
// Format data for Cytoscape
const elements = [];
graphData.nodes.forEach(node => {
elements.push({
data: {
id: node.id,
label: node.label,
category: node.category || 'Uncategorized',
tags: node.tags || [],
size: nodeSizes[node.id] || 25,
color: categoryColors[node.category || 'Uncategorized'] || '#A0AEC0',
url: `/posts/${node.id}/`
}
});
});
graphData.edges.forEach((edge, index) => {
if (graphData.nodes.some(n => n.id === edge.source) && graphData.nodes.some(n => n.id === edge.target)) {
elements.push({
data: { id: `e${index}`, source: edge.source, target: edge.target, weight: edge.strength || 1 }
});
} else {
console.warn(`Skipping edge e${index} due to missing node: ${edge.source} -> ${edge.target}`);
}
});
// Initialize Cytoscape
const cy = cytoscape({
container: graphContainer,
elements: elements,
style: [
{ selector: 'node', style: { 'background-color': 'data(color)', 'label': 'data(label)', 'width': 'data(size)', 'height': 'data(size)', 'font-size': '10px', 'color': '#E2E8F0', 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': '7px', 'text-background-opacity': 0.7, 'text-background-color': '#0F1219', 'text-background-padding': '3px', 'text-background-shape': 'roundrectangle', 'text-max-width': '120px', 'text-wrap': 'ellipsis', 'text-overflow-wrap': 'anywhere', 'border-width': '2px', 'border-color': '#0F1219', 'border-opacity': 0.8, 'z-index': 10, 'text-outline-width': 1, 'text-outline-color': '#000', 'text-outline-opacity': 0.5 } },
{ selector: 'edge', style: { 'width': 'mapData(weight, 1, 10, 1, 4)', 'line-color': 'rgba(226, 232, 240, 0.2)', 'curve-style': 'bezier', 'opacity': 0.6, 'z-index': 1 } },
{ selector: '.highlighted', style: { 'background-color': 'data(color)', 'border-color': '#FFFFFF', 'border-width': '3px', 'color': '#FFFFFF', 'text-background-opacity': 0.9, 'opacity': 1, 'z-index': 20 } },
{ selector: '.filtered', style: { 'background-color': 'data(color)', 'border-color': '#FFFFFF', 'border-width': '2px', 'color': '#FFFFFF', 'text-background-opacity': 0.8, 'opacity': 0.8, 'z-index': 15 } },
{ selector: '.faded', style: { 'opacity': 0.15, 'text-opacity': 0.3, 'background-opacity': 0.3, 'z-index': 1 } },
{ selector: 'node:selected', style: { 'border-width': '4px', 'border-color': '#FFFFFF', 'border-opacity': 1, 'background-color': 'data(color)', 'text-opacity': 1, 'color': '#FFFFFF', 'z-index': 30 } },
{ selector: 'edge:selected', style: { 'width': 'mapData(weight, 1, 10, 2, 6)', 'line-color': '#FFFFFF', 'opacity': 1, 'z-index': 30 } }
],
layout: { name: 'cose', idealEdgeLength: 100, nodeOverlap: 20, refresh: 20, fit: true, padding: 30, randomize: false, componentSpacing: 100, nodeRepulsion: 400000, edgeElasticity: 100, nestingFactor: 5, gravity: 80, numIter: 1000, initialTemp: 200, coolingFactor: 0.95, minTemp: 1.0 },
zoom: 1, minZoom: 0.1, maxZoom: 4, zoomingEnabled: true, userZoomingEnabled: true, panningEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false,
});
// Hide loading screen
if (loadingEl) {
setTimeout(() => { loadingEl.classList.add('hidden'); }, 500);
}
// --- Interactions ---
let hoverTimeout;
cy.on('mouseover', 'node', function(e) {
const node = e.target;
clearTimeout(hoverTimeout);
node.addClass('highlighted');
node.connectedEdges().addClass('highlighted');
graphContainer.style.cursor = 'pointer';
});
cy.on('mouseout', 'node', function(e) {
const node = e.target;
hoverTimeout = setTimeout(() => {
if (!node.selected()) {
node.removeClass('highlighted');
node.connectedEdges().removeClass('highlighted');
}
}, 100);
graphContainer.style.cursor = 'default';
});
cy.on('tap', 'node', function(e) {
const node = e.target;
const nodeData = node.data();
if (nodeDetailsEl) {
document.getElementById('node-title').textContent = nodeData.label;
const categoryEl = document.getElementById('node-category').querySelector('.category-value');
categoryEl.textContent = nodeData.category;
// Use categoryColors map correctly
const catColor = categoryColors[nodeData.category] || 'var(--text-secondary)';
categoryEl.style.backgroundColor = `${catColor}33`; // Add alpha
categoryEl.style.color = catColor;
const tagsContainer = document.getElementById('node-tags').querySelector('.tags-container');
tagsContainer.innerHTML = '';
if (nodeData.tags && nodeData.tags.length > 0) {
nodeData.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = tag;
tagsContainer.appendChild(tagEl);
});
} else {
tagsContainer.innerHTML = '<span class="no-tags">No tags</span>';
}
const connectionsList = document.getElementById('node-connections').querySelector('.connections-list');
connectionsList.innerHTML = '';
const connectedNodes = node.neighborhood('node');
if (connectedNodes.length > 0) {
connectedNodes.forEach(connectedNode => {
const connectedData = connectedNode.data();
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
link.textContent = connectedData.label;
link.dataset.id = connectedData.id;
link.addEventListener('click', (evt) => {
evt.preventDefault();
cy.$(':selected').unselect();
const targetNode = cy.getElementById(connectedData.id);
if (targetNode) {
targetNode.select();
cy.animate({ center: { eles: targetNode }, zoom: cy.zoom() }, { duration: 300 });
targetNode.trigger('tap');
}
});
listItem.appendChild(link);
connectionsList.appendChild(listItem);
});
} else {
connectionsList.innerHTML = '<li>No connections</li>';
}
document.getElementById('node-link').href = nodeData.url;
nodeDetailsEl.classList.add('active');
}
cy.elements().removeClass('highlighted').removeClass('faded');
node.addClass('highlighted');
node.neighborhood().addClass('highlighted');
cy.elements().difference(node.neighborhood().union(node)).addClass('faded');
});
cy.on('tap', function(e) {
if (e.target === cy) {
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
cy.elements().removeClass('selected highlighted faded');
}
});
if (closeDetailsBtn) {
closeDetailsBtn.addEventListener('click', () => {
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
cy.$(':selected').unselect();
cy.elements().removeClass('highlighted faded');
});
}
// Category filtering
const filterButtons = document.querySelectorAll('.graph-filter');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const category = button.dataset.filter;
if (category === 'all') {
cy.elements().removeClass('faded highlighted filtered');
} else {
cy.elements().addClass('faded').removeClass('highlighted filtered');
const selectedNodes = cy.nodes().filter(node => node.data('category') === category);
selectedNodes.removeClass('faded').addClass('filtered');
selectedNodes.connectedEdges().removeClass('faded');
}
});
});
// Legend item interactions
const legendItems = document.querySelectorAll('.legend-item');
legendItems.forEach(item => {
item.addEventListener('click', () => {
const category = item.dataset.category;
const filterButton = document.querySelector(`.graph-filter[data-filter="${category}"]`);
if (filterButton) filterButton.click();
});
});
// Zoom controls
document.getElementById('zoom-in')?.addEventListener('click', () => cy.zoom(cy.zoom() * 1.2));
document.getElementById('zoom-out')?.addEventListener('click', () => cy.zoom(cy.zoom() / 1.2));
document.getElementById('reset-graph')?.addEventListener('click', () => {
cy.fit(null, 30);
cy.elements().removeClass('faded highlighted filtered');
const allFilterButton = document.querySelector('.graph-filter[data-filter="all"]');
if (allFilterButton) allFilterButton.click();
});
// Add mouse wheel zoom controls
cy.on('zoom', function() {
if (cy.zoom() > 1.5) {
cy.style().selector('node').style({ 'text-max-width': '150px', 'font-size': '12px' }).update();
} else {
cy.style().selector('node').style({ 'text-max-width': '120px', 'font-size': '10px' }).update();
}
});
// Dispatch graphReady event
document.dispatchEvent(new CustomEvent('graphReady', { detail: { cy } }));
}
// Initialize graph on DOMContentLoaded or if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeGraph);
} else {
initializeGraph();
}
</script>
<style>
/* Styles from user snippet */
.graph-wrapper { position: relative; width: 100%; height: var(--graph-height, 60vh); min-height: 500px; max-height: 800px; }
.graph-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: 12px; overflow: hidden; border: 1px solid var(--card-border); background: rgba(15, 23, 42, 0.2); backdrop-filter: blur(5px); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); }
#knowledge-graph { width: 100%; height: 100%; z-index: 1; }
.graph-loading { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.7); z-index: 10; transition: opacity 0.5s ease, visibility 0.5s ease; }
.graph-loading.hidden { opacity: 0; visibility: hidden; }
.loading-spinner { position: relative; width: 80px; height: 80px; margin-bottom: 1rem; }
.spinner-ring { position: absolute; width: 100%; height: 100%; border-radius: 50%; border: 3px solid transparent; border-top-color: var(--accent-primary); animation: spin 1.5s linear infinite; }
.spinner-ring:nth-child(2) { width: calc(100% - 15px); height: calc(100% - 15px); top: 7.5px; left: 7.5px; border-top-color: var(--accent-secondary); animation-duration: 2s; animation-direction: reverse; }
.spinner-ring:nth-child(3) { width: calc(100% - 30px); height: calc(100% - 30px); top: 15px; left: 15px; border-top-color: var(--accent-tertiary); animation-duration: 2.5s; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-text { color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; letter-spacing: 0.05em; }
.node-details { position: absolute; top: 20px; right: 20px; width: 300px; background: var(--bg-secondary); border: 1px solid var(--card-border); border-radius: 10px; padding: 1.5rem; z-index: 5; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); transform: translateX(120%); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); opacity: 0; backdrop-filter: blur(10px); }
.node-details.active { transform: translateX(0); opacity: 1; }
.node-details-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.25rem; }
.node-title { font-size: 1.2rem; margin: 0; color: var(--text-primary); font-weight: 600; line-height: 1.3; }
.close-button { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 5px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; }
.close-button:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.1); }
.node-category, .node-tags, .node-connections { margin-bottom: 1.25rem; }
.category-label, .tags-label, .connections-label { display: block; color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.5rem; font-family: var(--font-mono); }
.category-value { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.85rem; font-family: var(--font-mono); }
.tags-container { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag { background: rgba(226, 232, 240, 0.05); color: var(--text-secondary); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-family: var(--font-mono); }
.no-tags { color: var(--text-tertiary); font-style: italic; font-size: 0.8rem; }
.connections-list { padding-left: 0; list-style: none; margin: 0; max-height: 150px; overflow-y: auto; }
.connections-list li { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem; }
.connections-list a { color: var(--accent-primary); text-decoration: none; transition: color 0.2s ease; }
.connections-list a:hover { color: var(--accent-secondary); text-decoration: underline; }
.node-link { display: block; background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); color: var(--bg-primary); font-weight: 500; padding: 0.6rem 1.25rem; border-radius: 6px; text-decoration: none; text-align: center; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(6, 182, 212, 0.2); margin-top: 1rem; }
.node-link:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(6, 182, 212, 0.3); }
.graph-controls { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 1rem; z-index: 5; }
.graph-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; padding: 0.75rem; background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(10px); border-radius: 30px; border: 1px solid var(--border-primary); }
.graph-filter { background: rgba(226, 232, 240, 0.05); color: var(--text-secondary); border: 1px solid var(--border-secondary); padding: 0.4rem 0.8rem; border-radius: 30px; font-size: 0.8rem; cursor: pointer; transition: all 0.2s ease; font-family: var(--font-mono); position: relative; overflow: hidden; }
.graph-filter::before { content: ''; position: absolute; top: 0; left: 0; width: 0; height: 100%; background: var(--filter-color, 'transparent'); opacity: 0.15; transition: width 0.3s ease; }
.graph-filter:hover::before { width: 100%; }
.graph-filter:hover { color: var(--text-primary); border-color: var(--filter-color, rgba(56, 189, 248, 0.4)); transform: translateY(-2px); }
.graph-filter.active { background-color: var(--filter-color, var(--accent-primary)); color: var(--bg-primary); border-color: var(--filter-color, var(--accent-primary)); font-weight: 600; }
.graph-filter.active::before { width: 100%; opacity: 0.2; }
.graph-actions { display: flex; justify-content: center; gap: 0.75rem; }
.graph-action { width: 36px; height: 36px; border-radius: 50%; background: var(--bg-secondary); border: 1px solid var(--border-primary); color: var(--text-secondary); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; }
.graph-action:hover { color: var(--text-primary); background: var(--bg-tertiary); transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); }
.graph-legend { position: absolute; top: 20px; left: 20px; background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 10px; padding: 1rem; z-index: 5; max-width: 200px; backdrop-filter: blur(10px); }
.legend-title { font-size: 0.9rem; color: var(--text-primary); margin-bottom: 0.75rem; font-weight: 600; font-family: var(--font-mono); cursor: pointer; }
.legend-items { display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; }
.graph-legend:not([open]) .legend-items { display: none; }
.legend-item { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; transition: all 0.2s ease; padding: 0.25rem 0.5rem; border-radius: 4px; }
.legend-item:hover { background: rgba(255, 255, 255, 0.05); }
.legend-color { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.legend-label { font-size: 0.8rem; color: var(--text-secondary); }
@media screen and (max-width: 768px) {
.node-details { width: 85%; max-width: 300px; left: 50%; right: auto; transform: translate(-50%, 120%); bottom: 20px; top: auto; }
.node-details.active { transform: translate(-50%, 0); }
.graph-legend { transform: translateX(-120%); transition: transform 0.3s ease; max-height: calc(100% - 40px); overflow-y: auto; }
.graph-legend[open] { transform: translateX(0); }
.graph-controls { bottom: 10px; }
}