1939 lines
63 KiB
Plaintext
1939 lines
63 KiB
Plaintext
---
|
||
// 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>
|