laforceit-blog/src/components/KnowledgeGraph.astro

1939 lines
63 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};`}>
<!-- Physics Toggle Button -->
<button id="toggle-physics" class="physics-toggle" aria-label="Physics settings">
<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"><circle cx="12" cy="12" r="10"></circle><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path><path d="M2 12h20"></path></svg>
</button>
<!-- Physics Controls Panel -->
<div id="physics-panel" class="physics-controls">
<div class="physics-control-group">
<label class="physics-label">Node Repulsion: <span id="repulsion-value">9000</span></label>
<input type="range" id="node-repulsion" class="physics-slider" min="1000" max="20000" step="1000" value="9000">
</div>
<div class="physics-control-group">
<label class="physics-label">Edge Elasticity: <span id="elasticity-value">50</span></label>
<input type="range" id="edge-elasticity" class="physics-slider" min="10" max="100" step="5" value="50">
</div>
<div class="physics-control-group">
<label class="physics-label">Gravity: <span id="gravity-value">10</span></label>
<input type="range" id="gravity" class="physics-slider" min="0" max="50" step="5" value="10">
</div>
<button id="apply-physics" class="physics-button">Apply Settings</button>
<button id="reset-physics" class="physics-button">Reset to Defaults</button>
</div>
<!-- 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>
<button id="fullscreen-graph" class="graph-action" aria-label="Fullscreen">
<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="M8 3H5a2 2 0 0 0-2 2v3"></path><path d="M21 8V5a2 2 0 0 0-2-2h-3"></path><path d="M3 16v3a2 2 0 0 0 2 2h3"></path><path d="M16 21h3a2 2 0 0 0 2-2v-3"></path></svg>
</button>
</div>
</div>
<!-- Fullscreen Mode Split View -->
<div id="fullscreen-container" class="fullscreen-container">
<div class="fullscreen-header">
<h2>Knowledge Graph Explorer</h2>
<div class="fullscreen-controls">
<button id="fullscreen-close" class="fullscreen-btn" aria-label="Close Fullscreen">
<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"><path d="M15 3h6v6"></path><path d="M9 21H3v-6"></path><path d="M21 3l-7 7"></path><path d="M3 21l7-7"></path></svg>
Exit Fullscreen
</button>
</div>
</div>
<div class="fullscreen-content">
<div id="fullscreen-graph" class="fullscreen-graph-container"></div>
<div id="fullscreen-article" class="fullscreen-article-container">
<div class="article-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="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg>
<p>Select a post in the graph to view its content here</p>
</div>
<div id="article-content" class="article-content"></div>
</div>
</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 } },
// Styles for dragging effects
{ selector: 'node.grabbed', style: {
'border-width': '3px',
'border-color': '#FFFFFF',
'border-opacity': 1
}}
],
// Update layout for better visualization of post-tag connections
// Replace the existing layout configuration with this improved one
layout: {
name: 'cose',
idealEdgeLength: 75, // Increased from previous value
nodeOverlap: 30, // Increased to prevent overlap
refresh: 20,
fit: true,
padding: 30,
randomize: false,
componentSpacing: 60,
nodeRepulsion: 1000000, // Significantly increased repulsion
edgeElasticity: 150, // Increased elasticity
nestingFactor: 7, // Adjusted nesting
gravity: 30, // Reduced gravity for more spread
numIter: 2000, // Increased iterations for better settling
initialTemp: 250, // Higher initial temperature
coolingFactor: 0.95, // Slower cooling for better layout
minTemp: 1.0,
animate: true,
animationDuration: 800 // Longer animation
},
zoom: 1, minZoom: 0.1, maxZoom: 3, zoomingEnabled: true, userZoomingEnabled: true, panningEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false,
});
// Add improved Obsidian-like dragging behavior
cy.on('drag', 'node', function(evt){
const node = evt.target;
node.lock(); // Lock node during drag to prevent physics interference
// Get immediate neighbors (directly connected nodes)
const directNeighbors = node.neighborhood('node');
// Apply a gentle pull to immediate neighbors
if (directNeighbors.length > 0) {
directNeighbors.forEach(neighbor => {
// Don't move nodes that are being manually dragged
if (!neighbor.grabbed()) {
// Calculate distance and direction
const dx = node.position('x') - neighbor.position('x');
const dy = node.position('y') - neighbor.position('y');
const distance = Math.sqrt(dx*dx + dy*dy);
// Apply gentle force (stronger for closer nodes)
const pullFactor = Math.min(0.05, 10/distance);
neighbor.position({
x: neighbor.position('x') + dx * pullFactor,
y: neighbor.position('y') + dy * pullFactor
});
}
});
}
});
cy.on('dragfree', 'node', function(evt){
const node = evt.target;
setTimeout(() => {
node.unlock(); // Unlock after drag complete
}, 50);
});
// Create physics controls object to store settings
const physicsSettings = {
nodeRepulsion: 9000,
edgeElasticity: 50,
gravity: 10,
animate: true
};
// Function to update physics settings
function updatePhysics(settings) {
cy.layout({
name: 'cose',
idealEdgeLength: 100,
nodeOverlap: 30,
refresh: 20,
fit: false, // Don't fit to viewport to maintain current view
padding: 40,
randomize: false,
componentSpacing: 120,
nodeRepulsion: settings.nodeRepulsion,
edgeElasticity: settings.edgeElasticity,
nestingFactor: 5,
gravity: settings.gravity,
numIter: 1000,
initialTemp: 200,
coolingFactor: 0.95,
minTemp: 1.0,
animate: settings.animate
}).run();
}
// Expose physics settings to global scope for external controls
window.graphPhysics = {
settings: physicsSettings,
update: updatePhysics
};
// 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));
// Reset button functionality
document.getElementById('reset-graph')?.addEventListener('click', () => {
// Reset zoom and position
cy.fit(null, 30);
// Reset all filters and highlighting
cy.elements().removeClass('faded highlighted filtered');
// Reset active filter buttons
const allFilterButton = document.querySelector('.graph-filter[data-filter="all"]');
if (allFilterButton) allFilterButton.click();
// Close node details panel if open
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
// Reset any selected nodes
cy.$(':selected').unselect();
});
// 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 } }));
// Fullscreen mode functionality
const fullscreenBtn = document.getElementById('fullscreen-graph');
const fullscreenContainer = document.getElementById('fullscreen-container');
const fullscreenClose = document.getElementById('fullscreen-close');
const fullscreenGraphContainer = document.getElementById('fullscreen-graph');
const fullscreenArticle = document.getElementById('fullscreen-article');
const articleContent = document.getElementById('article-content');
let fullscreenCy = null;
if (fullscreenBtn && fullscreenContainer) {
fullscreenBtn.addEventListener('click', () => {
// Show fullscreen container
fullscreenContainer.classList.add('active');
document.body.classList.add('graph-fullscreen-mode');
// Create a clone of the graph in fullscreen mode if it doesn't exist
if (!fullscreenCy) {
// Small delay to ensure container is visible first
setTimeout(() => {
// Clone the Cytoscape graph
fullscreenCy = cytoscape({
container: fullscreenGraphContainer,
elements: cy.json().elements,
style: cy.style().json(),
layout: { name: 'preset' }, // Use preset layout to maintain positions
zoom: cy.zoom(),
pan: cy.pan()
});
// Add same event listeners to the fullscreen graph
fullscreenCy.on('tap', 'node', function(e) {
const node = e.target;
const nodeData = node.data();
// Highlight the node and its connections
fullscreenCy.elements().removeClass('highlighted').addClass('faded');
node.removeClass('faded').addClass('highlighted');
node.neighborhood().removeClass('faded').addClass('highlighted');
// If it's a post node, load the content
if (nodeData.type === 'post' && nodeData.url && nodeData.url !== '#') {
// Show loading state
articleContent.innerHTML = '<div class="article-loading">Loading article...</div>';
// Remove placeholder if exists
const placeholder = fullscreenArticle.querySelector('.article-placeholder');
if (placeholder) placeholder.classList.add('hidden');
// Show content container
articleContent.classList.remove('hidden');
// Fetch and display article content
fetch(nodeData.url)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the article content - adjust selector to match your blog structure
const articleElement = doc.querySelector('article') ||
doc.querySelector('.blog-post-content') ||
doc.querySelector('main');
if (articleElement) {
// Create header for the article
const articleHeader = document.createElement('div');
articleHeader.className = 'article-header';
articleHeader.innerHTML = `
<h1>${nodeData.label}</h1>
<a href="${nodeData.url}" class="article-link" target="_blank">
<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>
View Full Article
</a>
`;
// Set content
articleContent.innerHTML = '';
articleContent.appendChild(articleHeader);
articleContent.appendChild(articleElement);
} else {
articleContent.innerHTML = `
<div class="article-error">
<h2>${nodeData.label}</h2>
<p>Unable to load article content directly.</p>
<a href="${nodeData.url}" class="article-link" target="_blank">
Open Article in New Tab
</a>
</div>
`;
}
})
.catch(error => {
console.error('Error loading article:', error);
articleContent.innerHTML = `
<div class="article-error">
<h2>Error Loading Content</h2>
<p>There was a problem loading the article content.</p>
<a href="${nodeData.url}" class="article-link" target="_blank">
Open Article in New Tab
</a>
</div>
`;
});
} else if (nodeData.type === 'tag') {
// For tag nodes, show related posts
const connectedPosts = node.neighborhood('node[type="post"]');
// Hide placeholder if exists
const placeholder = fullscreenArticle.querySelector('.article-placeholder');
if (placeholder) placeholder.classList.add('hidden');
// Show content container
articleContent.classList.remove('hidden');
articleContent.innerHTML = `
<div class="tag-posts">
<h2>Posts tagged with: ${nodeData.label}</h2>
<ul class="related-posts-list">
${connectedPosts.length === 0
? '<li>No posts found with this tag</li>'
: connectedPosts.map(post => {
const postData = post.data();
return `
<li>
<a href="${postData.url}" data-id="${postData.id}" class="post-link">
${postData.label}
</a>
</li>
`;
}).join('')
}
</ul>
</div>
`;
// Add click handlers for the post links
setTimeout(() => {
document.querySelectorAll('.post-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const postId = link.dataset.id;
const postNode = fullscreenCy.getElementById(postId);
if (postNode.length > 0) {
postNode.trigger('tap');
}
});
});
}, 100);
}
});
fullscreenCy.on('tap', function(e) {
if (e.target === fullscreenCy) {
fullscreenCy.elements().removeClass('highlighted faded');
}
});
// Add drag behavior
fullscreenCy.on('drag', 'node', function(evt){
const node = evt.target;
node.lock();
// Get immediate neighbors
const directNeighbors = node.neighborhood('node');
// Apply gentle pull to immediate neighbors
if (directNeighbors.length > 0) {
directNeighbors.forEach(neighbor => {
if (!neighbor.grabbed()) {
const dx = node.position('x') - neighbor.position('x');
const dy = node.position('y') - neighbor.position('y');
const distance = Math.sqrt(dx*dx + dy*dy);
const pullFactor = Math.min(0.05, 10/distance);
neighbor.position({
x: neighbor.position('x') + dx * pullFactor,
y: neighbor.position('y') + dy * pullFactor
});
}
});
}
});
fullscreenCy.on('dragfree', 'node', function(evt){
const node = evt.target;
setTimeout(() => {
node.unlock();
}, 50);
});
}, 100);
} else {
// If fullscreenCy already exists, update its elements and layout
fullscreenCy.json({ elements: cy.json().elements });
fullscreenCy.layout({ name: 'preset' }).run();
fullscreenCy.zoom(cy.zoom());
fullscreenCy.pan(cy.pan());
}
});
// Close fullscreen mode
if (fullscreenClose) {
fullscreenClose.addEventListener('click', () => {
fullscreenContainer.classList.remove('active');
document.body.classList.remove('graph-fullscreen-mode');
// Hide any loaded content
if (articleContent) {
articleContent.innerHTML = '';
articleContent.classList.add('hidden');
}
// Show placeholder again
const placeholder = fullscreenArticle.querySelector('.article-placeholder');
if (placeholder) placeholder.classList.remove('hidden');
// If the main graph exists, sync positions from fullscreen graph
if (cy && fullscreenCy) {
// Transfer node positions from fullscreen to main graph
fullscreenCy.nodes().forEach(node => {
const mainNode = cy.getElementById(node.id());
if (mainNode.length > 0) {
mainNode.position(node.position());
}
});
}
});
}
}
}
// 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: 4; /* Increase z-index to ensure nodes appear above instructions */
}
/* 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%;
position: relative;
z-index: 5; /* Higher than graph for visibility but lower than open instructions */
}
.instructions-details {
background: rgba(15, 23, 42, 0.9); /* More opaque background */
border-radius: 8px;
border: 1px solid var(--border-primary);
overflow: hidden;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
position: relative; /* Changed from absolute */
width: 100%;
}
.instructions-details[open] {
padding-bottom: 1rem;
z-index: 20; /* Very high z-index when open */
}
.instructions-details:not([open]) {
background: rgba(15, 23, 42, 0.8);
border: 1px solid var(--border-primary);
backdrop-filter: blur(5px);
}
.instructions-summary {
padding: 0.75rem 1rem;
cursor: pointer;
color: var(--text-primary);
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
z-index: 3;
}
.instructions-summary::before {
content: "";
font-size: 1rem;
}
.instructions-content {
padding: 0.5rem 1rem 1rem;
color: var(--text-primary);
line-height: 1.5;
max-width: 800px;
position: relative;
z-index: 3;
}
.instructions-content p {
margin-bottom: 0.5rem;
}
.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;
}
}
/* Fullscreen Mode Styles */
.fullscreen-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--bg-primary, #0f172a);
z-index: 9999;
display: flex;
flex-direction: column;
visibility: hidden;
opacity: 0;
transition: all 0.3s ease;
color: var(--text-primary, #e2e8f0);
}
.fullscreen-container.active {
visibility: visible;
opacity: 1;
}
.fullscreen-header {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 1.5rem;
background: var(--bg-secondary, #1e293b);
border-bottom: 1px solid var(--border-primary, #334155);
}
.fullscreen-header h2 {
font-size: 1.2rem;
margin: 0;
color: var(--text-primary, #e2e8f0);
}
.fullscreen-controls {
display: flex;
gap: 1rem;
}
.fullscreen-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(226, 232, 240, 0.05);
color: var(--text-primary, #e2e8f0);
border: 1px solid var(--border-primary, #334155);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.fullscreen-btn:hover {
background: rgba(226, 232, 240, 0.1);
border-color: var(--accent-primary, #38bdf8);
}
.fullscreen-content {
flex: 1;
display: flex;
overflow: hidden;
}
.fullscreen-graph-container {
width: 60%;
height: 100%;
background: var(--bg-primary, #0f172a);
border-right: 1px solid var(--border-primary, #334155);
position: relative;
}
.fullscreen-article-container {
width: 40%;
height: 100%;
overflow-y: auto;
padding: 0;
background: var(--bg-secondary, #1e293b);
}
.article-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary, #94a3b8);
text-align: center;
padding: 2rem;
}
.article-placeholder svg {
opacity: 0.4;
margin-bottom: 1rem;
}
.article-placeholder p {
font-size: 1.1rem;
max-width: 300px;
}
.article-content {
padding: 2rem;
}
.article-content.hidden {
display: none;
}
.article-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-primary, #334155);
}
.article-header h1 {
font-size: 1.8rem;
margin: 0 0 1rem 0;
line-height: 1.3;
color: var(--text-primary, #e2e8f0);
}
.article-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--accent-primary, #38bdf8);
text-decoration: none;
font-size: 0.9rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--accent-primary, #38bdf8);
border-radius: 6px;
transition: all 0.2s ease;
}
.article-link:hover {
background: rgba(56, 189, 248, 0.1);
transform: translateY(-2px);
}
.article-loading, .article-error {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #94a3b8);
}
.tag-posts {
padding: 2rem;
}
.tag-posts h2 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
color: var(--text-primary, #e2e8f0);
}
.related-posts-list {
list-style: none;
padding: 0;
margin: 0;
}
.related-posts-list li {
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-secondary, #1f2937);
}
.related-posts-list .post-link {
color: var(--accent-primary, #38bdf8);
text-decoration: none;
font-size: 1rem;
transition: all 0.2s ease;
display: block;
padding: 0.5rem;
border-radius: 4px;
}
.related-posts-list .post-link:hover {
background: rgba(56, 189, 248, 0.05);
color: var(--accent-secondary, #0ea5e9);
transform: translateX(4px);
}
.hidden {
display: none !important;
}
body.graph-fullscreen-mode {
overflow: hidden;
}
/* Physics Controls Styles */
.physics-toggle {
position: absolute;
top: 20px;
right: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 15;
transition: all 0.2s ease;
}
.physics-toggle:hover {
transform: rotate(20deg);
background: var(--bg-tertiary);
}
.physics-controls {
position: absolute;
top: 70px;
right: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 1rem;
width: 250px;
z-index: 20;
box-shadow: 0 5px 15px 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);
display: none;
}
.physics-controls.active {
transform: translateX(0);
opacity: 1;
display: block;
}
.physics-control-group {
margin-bottom: 1rem;
}
.physics-label {
display: block;
font-size: 0.8rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-family: var(--font-mono);
}
.physics-slider {
width: 100%;
height: 4px;
background: rgba(226, 232, 240, 0.1);
border-radius: 2px;
outline: none;
appearance: none;
}
.physics-slider::-webkit-slider-thumb {
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
border: none;
}
.physics-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
border: none;
}
.physics-button {
background: rgba(226, 232, 240, 0.05);
color: var(--text-primary);
border: 1px solid var(--border-primary);
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
width: 100%;
margin-top: 0.5rem;
transition: all 0.2s ease;
}
.physics-button:hover {
background: rgba(226, 232, 240, 0.1);
border-color: var(--accent-primary);
}
/* Media Queries for Fullscreen Mode */
@media screen and (max-width: 900px) {
.fullscreen-content {
flex-direction: column;
}
.fullscreen-graph-container,
.fullscreen-article-container {
width: 100%;
height: 50%;
}
.fullscreen-graph-container {
border-right: none;
border-bottom: 1px solid var(--border-primary);
}
.article-content {
padding: 1.5rem;
}
}
@media screen and (max-width: 768px) {
// ... existing media queries ...
.fullscreen-header {
padding: 0 1rem;
}
.fullscreen-header h2 {
font-size: 1rem;
}
.fullscreen-btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
}
.article-header h1 {
font-size: 1.5rem;
}
.physics-controls {
width: 200px;
right: 50px;
}
}
</style>
<script>
// Add Physics Controls UI interaction
document.addEventListener('DOMContentLoaded', () => {
const physicsToggle = document.getElementById('toggle-physics');
const physicsPanel = document.getElementById('physics-panel');
if (physicsToggle && physicsPanel) {
// Toggle physics panel visibility
physicsToggle.addEventListener('click', () => {
physicsPanel.classList.toggle('active');
});
// Update value displays as sliders change
document.getElementById('node-repulsion')?.addEventListener('input', (e) => {
document.getElementById('repulsion-value').textContent = e.target.value;
});
document.getElementById('edge-elasticity')?.addEventListener('input', (e) => {
document.getElementById('elasticity-value').textContent = e.target.value;
});
document.getElementById('gravity')?.addEventListener('input', (e) => {
document.getElementById('gravity-value').textContent = e.target.value;
});
// Apply physics settings when button is clicked
document.getElementById('apply-physics')?.addEventListener('click', () => {
if (window.graphPhysics) {
const newSettings = {
nodeRepulsion: parseInt(document.getElementById('node-repulsion').value),
edgeElasticity: parseInt(document.getElementById('edge-elasticity').value),
gravity: parseInt(document.getElementById('gravity').value),
animate: true
};
// Update settings object
Object.assign(window.graphPhysics.settings, newSettings);
// Apply settings
window.graphPhysics.update(newSettings);
// Close panel after applying
physicsPanel.classList.remove('active');
}
});
// Reset physics to defaults
document.getElementById('reset-physics')?.addEventListener('click', () => {
if (window.graphPhysics) {
const defaultSettings = {
nodeRepulsion: 9000,
edgeElasticity: 50,
gravity: 10,
animate: true
};
// Update UI
document.getElementById('node-repulsion').value = defaultSettings.nodeRepulsion;
document.getElementById('repulsion-value').textContent = defaultSettings.nodeRepulsion;
document.getElementById('edge-elasticity').value = defaultSettings.edgeElasticity;
document.getElementById('elasticity-value').textContent = defaultSettings.edgeElasticity;
document.getElementById('gravity').value = defaultSettings.gravity;
document.getElementById('gravity-value').textContent = defaultSettings.gravity;
// Update settings object
Object.assign(window.graphPhysics.settings, defaultSettings);
// Apply settings
window.graphPhysics.update(defaultSettings);
}
});
// Click outside to close physics panel
document.addEventListener('click', (e) => {
if (!physicsPanel.contains(e.target) && e.target !== physicsToggle) {
physicsPanel.classList.remove('active');
}
});
}
});
</script>