laforceit-blog/src/components/KnowledgeGraph.astro

2052 lines
62 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
content?: string; // Add content property for post full content
}
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};`}>
<!-- Instructions overlay - move to be positioned absolutely over the graph -->
<div class="graph-instructions overlay">
<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>
<li>In fullscreen mode, click a post to view its full content</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>
<!-- Fullscreen Toggle Button -->
<button id="fullscreen-toggle" class="fullscreen-toggle" aria-label="Toggle fullscreen">
<svg id="fullscreen-enter-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
<svg id="fullscreen-exit-icon" class="hidden" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 14 10 14 10 20"></polyline>
<polyline points="20 10 14 10 14 4"></polyline>
<line x1="14" y1="10" x2="21" y2="3"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
<!-- Full Post Content Panel -->
<div id="full-post-content" class="full-post-content">
<div class="full-post-header">
<h2 id="full-post-title" class="full-post-title">Post Title</h2>
<button id="close-full-post" class="close-button" aria-label="Close post content">
<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 class="full-post-metadata">
<div class="metadata-row">
<span class="metadata-label">Category:</span>
<div id="full-post-category" class="full-post-category"></div>
</div>
<div class="metadata-row">
<span class="metadata-label">Tags:</span>
<div id="full-post-tags" class="full-post-tags"></div>
</div>
</div>
<div id="full-post-container" class="full-post-container">
<div class="full-post-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<p>Select a post node to view its content</p>
</div>
</div>
<div class="full-post-footer">
<a href="#" id="full-post-link" class="full-post-link" target="_self" rel="noopener noreferrer">
Read Full Article
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</a>
</div>
</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');
const graphWrapper = document.querySelector('.graph-container-wrapper');
const fullscreenToggle = document.getElementById('fullscreen-toggle');
const fullscreenEnterIcon = document.getElementById('fullscreen-enter-icon');
const fullscreenExitIcon = document.getElementById('fullscreen-exit-icon');
const fullPostContent = document.getElementById('full-post-content');
const closeFullPostBtn = document.getElementById('close-full-post');
const fullPostTitle = document.getElementById('full-post-title');
const fullPostContainer = document.getElementById('full-post-container');
const fullPostCategory = document.getElementById('full-post-category');
const fullPostTags = document.getElementById('full-post-tags');
const fullPostLink = document.getElementById('full-post-link');
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 = [];
// State variables
let isFullscreen = false;
// 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 || '#',
content: node.content || '' // Include content data for post nodes
}
});
});
// 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);
}
// --- Fullscreen Toggle Functionality ---
if (fullscreenToggle) {
fullscreenToggle.addEventListener('click', toggleFullscreen);
}
// Toggle fullscreen function
function toggleFullscreen() {
isFullscreen = !isFullscreen;
if (isFullscreen) {
// Enable fullscreen mode
graphWrapper.classList.add('fullscreen');
fullscreenEnterIcon.classList.add('hidden');
fullscreenExitIcon.classList.remove('hidden');
// Hide the node details panel if it's visible
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
// In fullscreen, we adjust the cytoscape layout to fit
setTimeout(() => {
cy.resize();
cy.fit(null, 30);
}, 300); // Wait for transition to complete
} else {
// Disable fullscreen mode
graphWrapper.classList.remove('fullscreen');
fullscreenEnterIcon.classList.remove('hidden');
fullscreenExitIcon.classList.add('hidden');
// Hide the full post content panel
if (fullPostContent) {
fullPostContent.classList.remove('active');
}
// Reset the cytoscape layout
setTimeout(() => {
cy.resize();
cy.fit(null, 30);
}, 300); // Wait for transition to complete
}
}
// --- 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';
});
// Node click handler - dispatches to appropriate function based on fullscreen mode
cy.on('tap', 'node', function(e) {
const node = e.target;
if (isFullscreen) {
handleNodeClickFullscreen(node);
} else {
handleNodeClickNormal(node);
}
// Common highlight functionality for both modes
cy.elements().removeClass('highlighted').removeClass('faded');
node.addClass('highlighted');
node.neighborhood().addClass('highlighted');
cy.elements().difference(node.neighborhood().union(node)).addClass('faded');
});
// Handle node click in normal mode - shows metadata in sidebar
function handleNodeClickNormal(node) {
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');
}
// If it's a tag node, try to trigger the corresponding tag filter
if (nodeData.type === 'tag') {
const tagName = nodeData.label.replace(/^#/, ''); // Remove # prefix if present
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');
}
}
}
// Handle node click in fullscreen mode - shows full content in side panel
function handleNodeClickFullscreen(node) {
const nodeData = node.data();
console.log('Node clicked in fullscreen mode:', nodeData); // Debug log
// Only show content for post nodes
if (nodeData.type === 'post') {
if (fullPostContent) {
// Set post title
fullPostTitle.textContent = nodeData.label;
// Set post category if available
if (nodeData.category) {
const catColor = predefinedColors[nodeData.category] || 'var(--accent-primary)';
fullPostCategory.textContent = nodeData.category;
fullPostCategory.style.backgroundColor = `${catColor}33`;
fullPostCategory.style.color = catColor;
fullPostCategory.parentElement.style.display = 'flex';
} else {
fullPostCategory.parentElement.style.display = 'none';
}
// Set post tags if available
if (nodeData.tags && nodeData.tags.length > 0) {
fullPostTags.innerHTML = '';
nodeData.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'post-tag';
tagEl.textContent = tag;
fullPostTags.appendChild(tagEl);
});
fullPostTags.parentElement.style.display = 'flex';
} else {
fullPostTags.parentElement.style.display = 'none';
}
// Check if content is available
console.log('Content available:', !!nodeData.content); // Debug log
// Display the content or a placeholder
if (nodeData.content) {
fullPostContainer.innerHTML = `
<article class="post-content">
${nodeData.content}
</article>
`;
} else {
// Try to fetch the content if not available
console.log('Attempting to fetch content from:', nodeData.url);
fullPostContainer.innerHTML = `
<div class="loading-content">
<div class="spinner"></div>
<p>Loading content...</p>
</div>
`;
// Try to load the content from the post URL
fetch(nodeData.url)
.then(response => response.text())
.then(html => {
// Extract the post content
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const articleContent = doc.querySelector('article') ||
doc.querySelector('.content') ||
doc.querySelector('main');
if (articleContent) {
fullPostContainer.innerHTML = `
<article class="post-content">
${articleContent.innerHTML}
</article>
`;
} else {
// If we can't extract content, show the placeholder
showContentPlaceholder("Could not load content. Click 'Read Full Article' to view on the blog.");
}
})
.catch(error => {
console.error('Error fetching content:', error);
showContentPlaceholder("Error loading content. Click 'Read Full Article' to view on the blog.");
});
}
// Set the article link
fullPostLink.href = nodeData.url || '#';
// Make the content panel visible
fullPostContent.classList.add('active');
fullPostContent.style.display = 'flex';
}
} else {
// Hide the content panel for non-post nodes
if (fullPostContent) {
fullPostContent.classList.remove('active');
fullPostContent.style.display = 'none';
}
}
}
// Helper function to show content placeholder
function showContentPlaceholder() {
fullPostContainer.innerHTML = `
<div class="full-post-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
<p>Full content preview not available.</p>
</div>
`;
}
// Helper function to fetch post content if not provided
// async function fetchPostContent(url) {
// if (!url || url === '#') return null;
// try {
// const response = await fetch(url);
// if (!response.ok) return null;
// const html = await response.text();
// const parser = new DOMParser();
// const doc = parser.parseFromString(html, 'text/html');
// const articleContent = doc.querySelector('article') || doc.querySelector('.content') || doc.querySelector('main');
// return articleContent ? articleContent.innerHTML : null;
// } catch (error) {
// console.error('Error fetching post content:', error);
// return null;
// }
// }
// Modify background click handler to account for fullscreen mode
cy.on('tap', function(e) {
if (e.target === cy) {
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
if (isFullscreen && fullPostContent) {
fullPostContent.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');
});
}
// Close full post panel button
if (closeFullPostBtn) {
closeFullPostBtn.addEventListener('click', () => {
if (fullPostContent) {
fullPostContent.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"]');
// Add event listener to prevent redirect in fullscreen mode
if (fullPostLink) {
fullPostLink.addEventListener('click', (e) => {
if (isFullscreen) {
// If in fullscreen, prevent default behavior to keep the user in the graph view
e.preventDefault();
// Instead, display a message to exit fullscreen to visit the full article
const message = document.createElement('div');
message.className = 'fullscreen-message';
message.textContent = 'Exit fullscreen to visit the full article page';
message.style.position = 'absolute';
message.style.bottom = '70px'; // Adjust as needed
message.style.left = '50%';
message.style.transform = 'translateX(-50%)';
message.style.background = 'rgba(0, 0, 0, 0.75)';
message.style.color = 'white';
message.style.padding = '8px 16px';
message.style.borderRadius = '4px';
message.style.zIndex = '1000';
message.style.transition = 'opacity 0.3s ease';
fullPostContent.appendChild(message);
// Remove the message after 3 seconds
setTimeout(() => {
message.style.opacity = '0';
setTimeout(() => {
message.remove();
}, 300);
}, 3000);
}
});
}
// Listen for ESC key to exit fullscreen
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isFullscreen) {
toggleFullscreen();
}
});
// Update window resize handler to preserve layout in fullscreen mode
window.addEventListener('resize', () => {
if (cy) {
cy.resize();
if (!isFullscreen) {
cy.fit(null, 30);
}
}
});
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 } }));
// Add event listener to prevent redirect in fullscreen mode
if (fullPostLink) {
fullPostLink.addEventListener('click', (e) => {
if (isFullscreen) {
// If in fullscreen, prevent default behavior to keep the user in the graph view
e.preventDefault();
// Instead, display a message to exit fullscreen to visit the full article
const message = document.createElement('div');
message.className = 'fullscreen-message';
message.textContent = 'Exit fullscreen to visit the full article page';
message.style.position = 'absolute';
message.style.bottom = '70px'; // Adjust as needed
message.style.left = '50%';
message.style.transform = 'translateX(-50%)';
message.style.background = 'rgba(0, 0, 0, 0.75)';
message.style.color = 'white';
message.style.padding = '8px 16px';
message.style.borderRadius = '4px';
message.style.zIndex = '1000';
message.style.transition = 'opacity 0.3s ease';
fullPostContent.appendChild(message);
// Remove the message after 3 seconds
setTimeout(() => {
message.style.opacity = '0';
setTimeout(() => {
message.remove();
}, 300);
}, 3000);
}
});
}
// Listen for ESC key to exit fullscreen
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isFullscreen) {
toggleFullscreen();
}
});
// Update window resize handler to preserve layout in fullscreen mode
window.addEventListener('resize', () => {
if (cy) {
cy.resize();
if (!isFullscreen) {
cy.fit(null, 30);
}
}
});
}
// Initialize graph on DOMContentLoaded or if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeGraph);
} else {
initializeGraph();
}
</script>
<style>
/* Updated Graph Container Styles */
.graph-container-wrapper {
position: relative;
width: 100%;
height: var(--graph-height, 50vh);
min-height: 400px;
max-height: 800px;
margin-bottom: 2rem;
transition: all 0.5s cubic-bezier(0.19, 1, 0.22, 1);
}
/* Improved Fullscreen mode styles */
.graph-container-wrapper.fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-height: 100vh;
z-index: 9999;
margin: 0;
padding: 0;
background: var(--bg-primary);
backdrop-filter: blur(10px);
display: flex;
border-radius: 0;
overflow: hidden;
}
.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);
transition: all 0.3s ease;
}
/* Adjust graph container in fullscreen mode */
.graph-container-wrapper.fullscreen .graph-container {
position: relative;
width: 60%;
height: 100%;
border-radius: 0;
margin: 0;
border: none;
border-right: 1px solid var(--border-primary);
}
#knowledge-graph {
width: 100%;
height: 100%;
z-index: 1;
}
/* Overlay instructions - position over the graph instead of taking up space */
.graph-instructions.overlay {
position: absolute;
top: 20px;
left: 20px;
width: auto;
max-width: 350px;
z-index: 5;
margin: 0;
}
.graph-container-wrapper.fullscreen .graph-instructions.overlay {
top: 80px;
}
.instructions-details {
background: rgba(15, 23, 42, 0.75);
border-radius: 8px;
border: 1px solid var(--border-primary);
overflow: hidden;
transition: all 0.3s ease;
backdrop-filter: blur(5px);
}
.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);
}
/* Fullscreen Toggle Button */
.fullscreen-toggle {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 8px;
background: rgba(30, 41, 59, 0.7);
border: 1px solid var(--border-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 6;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.fullscreen-toggle:hover {
background: rgba(30, 41, 59, 0.9);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.graph-container-wrapper.fullscreen .fullscreen-toggle {
top: 20px;
right: 20px;
}
/* Hide one of the fullscreen icons */
.hidden {
display: none;
}
/* Full Post Content Panel - Improved styling */
.full-post-content {
position: absolute;
top: 0;
right: 0;
width: 0;
height: 100%;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.19, 1, 0.22, 1);
background: var(--bg-secondary);
border-radius: 12px;
opacity: 0;
z-index: 4;
border: 1px solid var(--card-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
visibility: hidden;
display: none;
}
/* Full post panel in fullscreen mode */
.graph-container-wrapper.fullscreen .full-post-content {
position: relative;
width: 40%;
height: 100%;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
opacity: 1;
visibility: visible;
border: none;
border-radius: 0;
box-shadow: none;
}
.graph-container-wrapper.fullscreen .full-post-content.active {
display: flex;
}
.full-post-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(15, 23, 42, 0.8);
}
.full-post-title {
font-size: 1.5rem;
margin: 0;
color: var(--text-primary);
font-weight: 600;
line-height: 1.3;
}
.full-post-metadata {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(15, 23, 42, 0.6);
}
.metadata-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.metadata-label {
color: var(--text-secondary);
font-size: 0.85rem;
font-family: var(--font-mono);
}
.full-post-category {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
font-family: var(--font-mono);
}
.full-post-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.post-tag {
background: rgba(16, 185, 129, 0.1);
color: #10B981;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-family: var(--font-mono);
transition: all 0.2s ease;
}
.post-tag:hover {
background: rgba(16, 185, 129, 0.2);
transform: translateY(-2px);
}
.full-post-container {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
color: var(--text-primary);
background: var(--bg-secondary);
}
.full-post-container::-webkit-scrollbar {
width: 8px;
}
.full-post-container::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.5);
border-radius: 4px;
}
.full-post-container::-webkit-scrollbar-thumb {
background: rgba(226, 232, 240, 0.1);
border-radius: 4px;
}
.full-post-container::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.2);
}
.full-post-placeholder {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
text-align: center;
padding: 2rem;
}
.full-post-placeholder svg {
margin-bottom: 1rem;
opacity: 0.5;
}
.post-content {
line-height: 1.7;
font-size: 1rem;
}
.post-content h1, .post-content h2, .post-content h3,
.post-content h4, .post-content h5, .post-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
color: var(--text-primary);
font-weight: 600;
}
.post-content p {
margin-bottom: 1.25rem;
}
.post-content a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px dotted var(--accent-primary);
transition: all 0.2s ease;
}
.post-content a:hover {
color: var(--accent-secondary);
border-bottom-style: solid;
}
.post-content pre {
background: rgba(15, 23, 42, 0.6);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-primary);
}
.post-content code {
font-family: var(--font-mono);
font-size: 0.9rem;
}
.post-content img {
max-width: 100%;
height: auto;
margin: 1.5rem 0;
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.post-content blockquote {
border-left: 4px solid var(--accent-primary);
padding-left: 1rem;
margin-left: 0;
margin-right: 0;
font-style: italic;
color: var(--text-secondary);
}
.post-content ul, .post-content ol {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.post-content li {
margin-bottom: 0.5rem;
}
.full-post-footer {
padding: 1rem;
background: rgba(15, 23, 42, 0.7);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.full-post-link {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
border-radius: 6px;
}
.full-post-link:hover {
background: linear-gradient(90deg, var(--accent-secondary), var(--accent-primary));
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
}
/* Media Queries for Fullscreen */
@media screen and (max-width: 992px) {
/* Adjust fullscreen layout for smaller screens */
.graph-container-wrapper.fullscreen {
flex-direction: column;
}
.graph-container-wrapper.fullscreen .graph-container {
width: 100%;
height: 50%;
margin-right: 0;
}
.graph-container-wrapper.fullscreen .full-post-content {
width: 100%;
height: 50%;
}
}
@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.overlay {
top: 15px;
left: 15px;
max-width: 300px;
}
.graph-controls {
bottom: 10px;
}
.graph-filters {
padding: 0.5rem;
}
.graph-filter {
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
}
.fullscreen-toggle {
top: 15px;
right: 15px;
width: 36px;
height: 36px;
}
.graph-container-wrapper.fullscreen .graph-container {
height: 40%;
}
.graph-container-wrapper.fullscreen .full-post-content {
height: 60%;
}
.full-post-header {
padding: 1rem;
}
.full-post-title {
font-size: 1.25rem;
}
.full-post-metadata {
padding: 0.75rem 1rem;
}
.full-post-container {
padding: 1rem;
}
}
@media screen and (max-width: 480px) {
.graph-container-wrapper.fullscreen {
padding: 0;
}
.graph-container-wrapper.fullscreen .graph-container {
height: 35%;
}
.graph-container-wrapper.fullscreen .full-post-content {
height: 65%;
}
.full-post-title {
font-size: 1.1rem;
}
.graph-instructions.overlay {
max-width: 250px;
}
}
/* Graph Container Styles */
/* .graph-container-wrapper is defined earlier */
/* .graph-container is defined earlier */
/* #knowledge-graph is defined earlier */
/* 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 are defined earlier as overlay */
/* Media Queries are defined earlier */
/* Styles for rendered post content */
.post-content {
line-height: 1.7;
font-size: 1rem;
overflow-wrap: break-word;
word-wrap: break-word;
}
.post-content * {
max-width: 100%;
}
.post-content h1, .post-content h2, .post-content h3,
.post-content h4, .post-content h5, .post-content h6 {
margin-top: 1.5rem;
margin-bottom: 1rem;
color: var(--text-primary);
font-weight: 600;
line-height: 1.3;
}
.post-content h1 { font-size: 1.875rem; }
.post-content h2 { font-size: 1.5rem; }
.post-content h3 { font-size: 1.25rem; }
.post-content h4 { font-size: 1.125rem; }
.post-content h5, .post-content h6 { font-size: 1rem; }
.post-content p {
margin-bottom: 1.25rem;
}
.post-content a {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px dotted var(--accent-primary);
transition: all 0.2s ease;
}
.post-content a:hover {
color: var(--accent-secondary);
border-bottom-style: solid;
}
.post-content pre {
background: rgba(15, 23, 42, 0.6);
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-primary);
}
.post-content code {
font-family: var(--font-mono);
font-size: 0.9rem;
background: rgba(15, 23, 42, 0.4);
padding: 0.2em 0.4em;
border-radius: 3px;
}
.post-content pre code {
background: transparent;
padding: 0;
}
.post-content img {
max-width: 100%;
height: auto;
margin: 1.5rem auto;
border-radius: 6px;
display: block;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.post-content blockquote {
border-left: 4px solid var(--accent-primary);
padding-left: 1rem;
margin-left: 0;
margin-right: 0;
font-style: italic;
color: var(--text-secondary);
}
.post-content ul, .post-content ol {
padding-left: 1.5rem;
margin-bottom: 1.25rem;
}
.post-content li {
margin-bottom: 0.5rem;
}
.post-content table {
width: 100%;
border-collapse: collapse;
margin: 1.5rem 0;
overflow-x: auto;
display: block;
}
.post-content table th,
.post-content table td {
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.5rem;
}
.post-content table th {
background: rgba(30, 41, 59, 0.5);
font-weight: 600;
}
.post-content table tr:nth-child(even) {
background: rgba(30, 41, 59, 0.3);
}
.post-content iframe {
max-width: 100%;
margin: 1.5rem 0;
}
/* Make iframes responsive */
.post-content .video-container {
position: relative;
padding-bottom: 56.25%; /* 16:9 */
height: 0;
overflow: hidden;
margin: 1.5rem 0;
}
.post-content .video-container iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Loading indicator styles */
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(59, 130, 246, 0.3);
border-radius: 50%;
border-top-color: var(--accent-primary, #3b82f6);
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>