argobox-portfolio/src/components/KnowledgeGraph.astro

1110 lines
35 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
// src/components/KnowledgeGraph.astro
// Enhanced interactive visualization of content connections using Cytoscape.js
export interface GraphNode {
id: string;
label: string;
type: 'post' | 'tag' | 'category'; // Node types to distinguish posts from tags
category?: string;
tags?: string[];
url?: string; // URL for linking
}
export interface GraphEdge {
source: string;
target: string;
type: 'post-tag' | 'post-category' | 'post-post'; // Edge types
strength?: number;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
interface Props {
graphData: GraphData;
height?: string; // e.g., '500px'
initialFilter?: string; // Optional initial filter
}
const { graphData, height = "50vh", initialFilter = "all" } = Astro.props;
// Generate colors based on node types
const nodeTypeColors = {
'post': '#3B82F6', // Blue for posts
'tag': '#10B981', // Green for tags
'category': '#8B5CF6' // Purple for categories
};
// Generate predefined colors for categories
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'
};
// Calculate node sizes
const nodeSizes = {};
const minSize = 15; const maxSize = 35;
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;
// Make tags slightly smaller than posts by default
const baseSize = node.type === 'post' ? minSize : minSize * 0.8;
const normalizedSize = maxDegree === 0 ? 0.5 : degree / maxDegree;
nodeSizes[node.id] = baseSize + normalizedSize * (maxSize - minSize);
});
// Count node types for legend
const nodeTypeCounts = {
post: graphData.nodes.filter(node => node.type === 'post').length,
tag: graphData.nodes.filter(node => node.type === 'tag').length,
category: graphData.nodes.filter(node => node.type === 'category').length
};
---
<!-- 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-container-wrapper" style={`--graph-height: ${height};`}>
<!-- Graph Instructions -->
<div class="graph-instructions">
<details class="instructions-details">
<summary class="instructions-summary">How to use the Knowledge Graph</summary>
<div class="instructions-content">
<p>This Knowledge Graph visualizes connections between blog content:</p>
<ul>
<li><span class="node-example post-node"></span> <strong>Posts</strong> - Blog articles (circle nodes)</li>
<li><span class="node-example tag-node"></span> <strong>Tags</strong> - Content topics (diamond nodes)</li>
</ul>
<p>Interactions:</p>
<ul>
<li>Click a node to see its connections and details</li>
<li>Click a tag node to filter posts by that tag</li>
<li>Click a post node to highlight that specific post</li>
<li>Use the filter buttons below to focus on specific topics</li>
<li>Use mouse wheel to zoom in/out and drag to pan</li>
<li>Click an empty area to reset the view</li>
</ul>
</div>
</details>
</div>
<!-- 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-type" class="node-type">
<span class="type-value">Type</span>
</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="_self" rel="noopener noreferrer">View Content</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</button>
<button class="graph-filter" data-filter="posts" style="--filter-color: #3B82F6;">Posts ({nodeTypeCounts.post})</button>
<button class="graph-filter" data-filter="tags" style="--filter-color: #10B981;">Tags ({nodeTypeCounts.tag})</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>
</div>
<script define:vars={{ graphData, nodeTypeColors, nodeSizes, predefinedColors, initialFilter }}>
// 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 = [];
// Add nodes with appropriate styling based on type
graphData.nodes.forEach(node => {
let nodeColor;
if (node.type === 'post') {
// Posts get category color if available
nodeColor = node.category && predefinedColors[node.category]
? predefinedColors[node.category]
: nodeTypeColors['post'];
} else {
// Tags and categories get their type color
nodeColor = nodeTypeColors[node.type];
}
elements.push({
data: {
id: node.id,
label: node.label,
type: node.type,
category: node.category || '',
tags: node.tags || [],
size: nodeSizes[node.id] || 25,
color: nodeColor,
url: node.url || '#'
}
});
});
// Add edges
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,
type: edge.type || 'post-tag',
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: [
// Base node styles
{ 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
}},
// Post node specific styles
{ selector: 'node[type="post"]', style: {
'shape': 'ellipse',
'border-width': '2px'
}},
// Tag node specific styles
{ selector: 'node[type="tag"]', style: {
'shape': 'diamond',
'border-width': '1px',
'border-color': '#10B981',
'border-opacity': 0.9
}},
// Category node specific styles
{ selector: 'node[type="category"]', style: {
'shape': 'hexagon',
'border-width': '1px'
}},
// Edge styles
{ selector: 'edge', style: {
'width': 'mapData(weight, 1, 10, 1, 3)',
'line-color': 'rgba(226, 232, 240, 0.2)',
'curve-style': 'bezier',
'opacity': 0.6,
'z-index': 1
}},
// Post-tag edge specific styles
{ selector: 'edge[type="post-tag"]', style: {
'line-color': 'rgba(16, 185, 129, 0.4)',
'line-style': 'dashed'
}},
// Post-post edge specific styles
{ selector: 'edge[type="post-post"]', style: {
'line-color': 'rgba(59, 130, 246, 0.4)',
'line-style': 'solid'
}},
// Selection and hover styles
{ 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 } }
],
// Update layout for better visualization of post-tag connections
layout: {
name: 'cose',
idealEdgeLength: 80,
nodeOverlap: 20,
refresh: 20,
fit: true,
padding: 30,
randomize: false,
componentSpacing: 100,
nodeRepulsion: 450000,
edgeElasticity: 100,
nestingFactor: 5,
gravity: 80,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0
},
zoom: 1, minZoom: 0.1, maxZoom: 3, 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) {
// Set node title
document.getElementById('node-title').textContent = nodeData.label;
// Set node type
const nodeTypeEl = document.getElementById('node-type').querySelector('.type-value') ||
document.getElementById('node-type');
nodeTypeEl.textContent = nodeData.type.charAt(0).toUpperCase() + nodeData.type.slice(1);
nodeTypeEl.className = `type-value ${nodeData.type}-type`;
// Set category if it's a post
const categorySection = document.getElementById('node-category');
if (nodeData.type === 'post' && nodeData.category) {
categorySection.style.display = 'block';
const categoryEl = categorySection.querySelector('.category-value');
categoryEl.textContent = nodeData.category;
// Use category colors
const catColor = predefinedColors[nodeData.category] || 'var(--text-secondary)';
categoryEl.style.backgroundColor = `${catColor}33`; // Add alpha
categoryEl.style.color = catColor;
} else {
categorySection.style.display = 'none';
}
// Set tags if it's a post
const tagsSection = document.getElementById('node-tags');
const tagsContainer = tagsSection.querySelector('.tags-container');
if (nodeData.type === 'post' && nodeData.tags && nodeData.tags.length > 0) {
tagsSection.style.display = 'block';
tagsContainer.innerHTML = '';
nodeData.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = tag;
tagEl.addEventListener('click', () => {
// Find the tag node and trigger its selection
const tagNode = cy.getElementById(`tag-${tag}`);
if (tagNode.length > 0) {
tagNode.trigger('tap');
}
// Try to find and click tag filter button
try {
const tagFilterBtn = Array.from(
document.querySelectorAll('.tag-filter-btn')
).find(btn => btn.dataset.tag === tag);
if (tagFilterBtn) {
tagFilterBtn.click();
}
} catch (e) {
console.log('Tag filter button not found');
}
});
tagsContainer.appendChild(tagEl);
});
} else {
tagsSection.style.display = 'none';
}
// Set connections
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>';
}
// Set link text and URL based on node type
const nodeLink = document.getElementById('node-link');
if (nodeData.type === 'post') {
nodeLink.textContent = 'Read Article';
} else if (nodeData.type === 'tag') {
nodeLink.textContent = 'Browse Tag';
} else {
nodeLink.textContent = 'View Content';
}
nodeLink.href = nodeData.url;
nodeDetailsEl.classList.add('active');
}
// Highlight the node and its connections, fade everything else
cy.elements().removeClass('highlighted').removeClass('faded');
node.addClass('highlighted');
node.neighborhood().addClass('highlighted');
cy.elements().difference(node.neighborhood().union(node)).addClass('faded');
// If it's a tag node, try to trigger the corresponding tag filter
if (nodeData.type === 'tag') {
const tagName = nodeData.label;
try {
// Find and click the corresponding tag filter button
const tagFilterBtn = Array.from(
document.querySelectorAll('.tag-filter-btn')
).find(btn => btn.dataset.tag === tagName);
if (tagFilterBtn) {
tagFilterBtn.click();
}
// Scroll to the blog section
const blogSection = document.querySelector('.blog-posts-section');
if (blogSection) {
blogSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} catch (e) {
console.log('Tag filter button not found or blog section not found');
}
}
});
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');
});
}
// Filter graph by node type
const filterButtons = document.querySelectorAll('.graph-filter');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const filter = button.dataset.filter;
if (filter === 'all') {
cy.elements().removeClass('faded highlighted filtered');
} else if (filter === 'posts') {
cy.elements().addClass('faded').removeClass('highlighted filtered');
const postNodes = cy.nodes().filter(node => node.data('type') === 'post');
postNodes.removeClass('faded').addClass('filtered');
} else if (filter === 'tags') {
cy.elements().addClass('faded').removeClass('highlighted filtered');
const tagNodes = cy.nodes().filter(node => node.data('type') === 'tag');
tagNodes.removeClass('faded').addClass('filtered');
}
});
});
// 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();
}
});
// Apply initial filter if specified
if (initialFilter && initialFilter !== 'all') {
const filterButton = document.querySelector(`.graph-filter[data-filter="${initialFilter}"]`);
if (filterButton) {
setTimeout(() => filterButton.click(), 500);
}
}
// Connect search input if it exists
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', e => {
const term = e.target.value.toLowerCase();
if (!term) {
// Reset graph view if search is cleared
cy.elements().removeClass('highlighted faded filtered');
const allFilterBtn = document.querySelector('.graph-filter[data-filter="all"]');
if (allFilterBtn) allFilterBtn.click();
return;
}
// Reset previous filters
cy.elements().addClass('faded').removeClass('highlighted filtered');
// Find nodes that match the search term
const matchingNodes = cy.nodes().filter(node => {
const label = node.data('label').toLowerCase();
return label.includes(term);
});
if (matchingNodes.length) {
matchingNodes.removeClass('faded').addClass('highlighted');
matchingNodes.connectedEdges().removeClass('faded');
}
});
}
// Connect tag filter buttons if they exist
const tagFilterButtons = document.querySelectorAll('.tag-filter-btn');
if (tagFilterButtons.length) {
tagFilterButtons.forEach(btn => {
btn.addEventListener('click', () => {
const tagName = btn.dataset.tag;
if (tagName === 'all') {
// Reset graph view if "All" is selected
cy.elements().removeClass('highlighted faded filtered');
const allFilterBtn = document.querySelector('.graph-filter[data-filter="all"]');
if (allFilterBtn) allFilterBtn.click();
return;
}
// Find the tag node
const tagNode = cy.nodes().filter(node =>
node.data('type') === 'tag' &&
node.data('label') === tagName
);
if (tagNode.length) {
// Highlight this tag and connected posts in the graph
cy.elements().addClass('faded').removeClass('highlighted filtered');
tagNode.removeClass('faded').addClass('highlighted');
// Get connected posts
const connectedPosts = tagNode.neighborhood('node[type="post"]');
connectedPosts.removeClass('faded').addClass('filtered');
// Highlight connecting edges
tagNode.connectedEdges().removeClass('faded').addClass('highlighted');
}
});
});
}
// Dispatch graphReady event for external listeners
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>
/* Graph Container Styles */
.graph-container-wrapper {
position: relative;
width: 100%;
height: var(--graph-height, 50vh);
min-height: 400px;
max-height: 800px;
margin-bottom: 2rem;
}
.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;
}
/* Loading Animation */
.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;
border-radius: 12px;
}
.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 Panel */
.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: 1rem;
}
.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-type {
margin-bottom: 1rem;
}
.type-value {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.post-type {
background-color: rgba(59, 130, 246, 0.15);
color: #3B82F6;
}
.tag-type {
background-color: rgba(16, 185, 129, 0.15);
color: #10B981;
}
.category-type {
background-color: rgba(139, 92, 246, 0.15);
color: #8B5CF6;
}
.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(16, 185, 129, 0.1);
color: #10B981;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.tag:hover {
background: rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.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 */
.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 Instructions */
.graph-instructions {
margin-bottom: 1rem;
width: 100%;
}
.instructions-details {
background: rgba(15, 23, 42, 0.3);
border-radius: 8px;
border: 1px solid var(--border-primary);
overflow: hidden;
transition: all 0.3s ease;
}
.instructions-details[open] {
padding-bottom: 1rem;
}
.instructions-summary {
padding: 0.75rem 1rem;
cursor: pointer;
color: var(--text-primary);
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.instructions-summary::before {
content: "";
font-size: 1rem;
}
.instructions-content {
padding: 0 1rem;
color: var(--text-secondary);
}
.instructions-content ul {
padding-left: 1.5rem;
margin: 0.5rem 0 1rem;
}
.node-example {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 0.25rem;
vertical-align: middle;
}
.post-node {
background-color: #3B82F6;
border-radius: 50%;
}
.tag-node {
background-color: #10B981;
transform: rotate(45deg);
}
/* Media Queries */
@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-instructions {
margin-bottom: 0.5rem;
}
.graph-controls {
bottom: 10px;
}
.graph-filters {
padding: 0.5rem;
}
.graph-filter {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
}