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