Building an Interactive Knowledge Graph for a Static Site
Digital gardens are having a moment.
Obsidian’s local-first approach combined with tools like Quartz have made publishing interconnected notes accessible to everyone. One feature defines this space: the knowledge graph - a visual map of how ideas connect.
I wanted one for my blog. But I wanted it to go further than static visualization. I wanted:
- Physics-based dragging - Nodes follow each other like they’re connected by springs
- Fullscreen content preview - Click a node, read the full post in a panel
- Real-time filtering - Toggle between posts, tags, and categories
- Responsive performance - Works on mobile without killing the battery
This is how I built it with Cytoscape.js and Astro.
The Result
Before diving into code, here’s what we’re building:
Features:
- Graph visualization of all blog posts and their tag relationships
- Drag any node and watch connected nodes follow with spring physics
- Click a node to see metadata; in fullscreen mode, see the full post content
- Filter by posts, tags, or categories
- Zoom, pan, and navigate naturally
The tech stack:
- Astro - Static site generator with partial hydration
- Cytoscape.js - Graph theory library for visualization
- Custom physics engine - Spring forces for Obsidian-like interaction
Part 1: Data Structure
Every graph needs nodes and edges. In a blog context:
interface GraphNode {
id: string; // Unique identifier
label: string; // Display name
type: 'post' | 'tag' | 'category';
url?: string; // Link to content
category?: string; // For color coding
tags?: string[]; // For posts
content?: string; // For fullscreen preview
}
interface GraphEdge {
source: string; // Source node ID
target: string; // Target node ID
type: 'post-tag' | 'post-post';
strength?: number; // Edge weight
}
Generating Graph Data from Content
At build time, we traverse all posts and extract relationships:
---
// In KnowledgeGraph.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('posts', ({ data }) => !data.draft);
// Build nodes
const nodes = [];
const edges = [];
const seenTags = new Set();
posts.forEach(post => {
// Add post node
nodes.push({
id: `post-${post.slug}`,
label: post.data.title,
type: 'post',
url: `/posts/${post.slug}`,
category: post.data.category,
tags: post.data.tags || []
});
// Add tag nodes and edges
post.data.tags?.forEach(tag => {
const tagId = `tag-${tag}`;
if (!seenTags.has(tag)) {
nodes.push({
id: tagId,
label: tag,
type: 'tag'
});
seenTags.add(tag);
}
edges.push({
source: `post-${post.slug}`,
target: tagId,
type: 'post-tag'
});
});
});
const graphData = { nodes, edges };
---
Part 2: Basic Cytoscape Setup
Cytoscape.js handles the heavy lifting of graph rendering:
const cy = cytoscape({
container: document.getElementById('knowledge-graph'),
elements: elements, // Nodes and edges
style: [
// Node styling
{
selector: 'node',
style: {
'background-color': 'data(color)',
'label': 'data(label)',
'width': 'data(size)',
'height': 'data(size)',
'font-size': '10px',
'color': '#E2E8F0'
}
},
// Post nodes are circles
{
selector: 'node[type="post"]',
style: { 'shape': 'ellipse' }
},
// Tag nodes are diamonds
{
selector: 'node[type="tag"]',
style: { 'shape': 'diamond' }
},
// Edge styling
{
selector: 'edge',
style: {
'line-color': 'rgba(226, 232, 240, 0.2)',
'curve-style': 'bezier'
}
}
],
// Initial layout
layout: {
name: 'cose',
nodeRepulsion: 500000,
gravity: 80,
numIter: 1000
}
});
The cose layout (Compound Spring Embedder) positions nodes using force-directed simulation. But it only runs once at initialization. For Obsidian-like interaction, we need continuous physics.
Part 3: The Physics Engine
Here’s where it gets interesting. We need nodes to respond to dragging in real-time, with connected nodes following along like they’re attached by springs.
Physics Configuration
const physicsConfig = {
springLength: 120, // Ideal distance between connected nodes
springStrength: 0.04, // How strongly springs pull
repulsion: 800, // How strongly nodes push apart
damping: 0.85, // Friction (0-1)
gravity: 0.02, // Pull toward center
maxVelocity: 15, // Speed cap
minMovement: 0.5 // Stop when movement is below this
};
Force Calculation
Each frame, we calculate forces on each node:
function calculatePhysics() {
const nodes = cy.nodes();
const edges = cy.edges();
const forces = new Map();
// Initialize forces
nodes.forEach(node => {
forces.set(node.id(), { fx: 0, fy: 0 });
});
// REPULSION: Nodes push each other apart
nodes.forEach(nodeA => {
const posA = nodeA.position();
nodes.forEach(nodeB => {
if (nodeA.id() === nodeB.id()) return;
const posB = nodeB.position();
const dx = posA.x - posB.x;
const dy = posA.y - posB.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// Inverse square law
const repulsionForce = physicsConfig.repulsion / (distance * distance);
const forceX = (dx / distance) * repulsionForce;
const forceY = (dy / distance) * repulsionForce;
forces.get(nodeA.id()).fx += forceX;
forces.get(nodeA.id()).fy += forceY;
});
});
// SPRINGS: Connected nodes attract
edges.forEach(edge => {
const source = edge.source();
const target = edge.target();
const sourcePos = source.position();
const targetPos = target.position();
const dx = targetPos.x - sourcePos.x;
const dy = targetPos.y - sourcePos.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
// Hooke's law: F = k * displacement
const displacement = distance - physicsConfig.springLength;
const springForce = displacement * physicsConfig.springStrength;
const forceX = (dx / distance) * springForce;
const forceY = (dy / distance) * springForce;
forces.get(source.id()).fx += forceX;
forces.get(source.id()).fy += forceY;
forces.get(target.id()).fx -= forceX;
forces.get(target.id()).fy -= forceY;
});
// GRAVITY: Pull toward center
const center = { x: cy.width() / 2, y: cy.height() / 2 };
nodes.forEach(node => {
const pos = node.position();
const force = forces.get(node.id());
force.fx += (center.x - pos.x) * physicsConfig.gravity;
force.fy += (center.y - pos.y) * physicsConfig.gravity;
});
return forces;
}
The Animation Loop
We use requestAnimationFrame for smooth 60fps updates:
const nodeVelocities = new Map();
let physicsRunning = false;
function updatePhysics() {
if (!physicsRunning) return;
const forces = calculatePhysics();
let totalMovement = 0;
cy.nodes().forEach(node => {
// Skip the node being dragged
if (draggedNode && node.id() === draggedNode.id()) return;
const velocity = nodeVelocities.get(node.id());
const force = forces.get(node.id());
// Update velocity
velocity.vx = (velocity.vx + force.fx) * physicsConfig.damping;
velocity.vy = (velocity.vy + force.fy) * physicsConfig.damping;
// Cap velocity
const speed = Math.sqrt(velocity.vx ** 2 + velocity.vy ** 2);
if (speed > physicsConfig.maxVelocity) {
velocity.vx = (velocity.vx / speed) * physicsConfig.maxVelocity;
velocity.vy = (velocity.vy / speed) * physicsConfig.maxVelocity;
}
// Update position
const pos = node.position();
node.position({
x: pos.x + velocity.vx,
y: pos.y + velocity.vy
});
totalMovement += Math.abs(velocity.vx) + Math.abs(velocity.vy);
});
// Continue if still moving
if (totalMovement > physicsConfig.minMovement || draggedNode) {
requestAnimationFrame(updatePhysics);
} else {
physicsRunning = false;
}
}
Part 4: Drag Handlers
The magic happens in the drag events:
let draggedNode = null;
// When drag starts
cy.on('grab', 'node', function(e) {
draggedNode = e.target;
// Highlight connected nodes
draggedNode.addClass('highlighted');
draggedNode.neighborhood().addClass('highlighted');
// Start physics if not running
startPhysics();
});
// When drag ends
cy.on('free', 'node', function(e) {
draggedNode = null;
// Small random velocity for natural settling
const vel = nodeVelocities.get(e.target.id());
vel.vx = (Math.random() - 0.5) * 2;
vel.vy = (Math.random() - 0.5) * 2;
startPhysics();
});
The key insight: while dragging, the dragged node is excluded from physics updates. Its position is controlled by the mouse. But other nodes still feel its spring forces, so they follow along.
Part 5: Fullscreen Content Preview
In fullscreen mode, clicking a post node shows the full article in a side panel:
cy.on('tap', 'node', function(e) {
const node = e.target;
if (node.data('type') === 'post') {
// Show content in panel
document.getElementById('full-post-title').textContent = node.data('label');
document.getElementById('full-post-container').innerHTML = node.data('content');
document.getElementById('full-post-content').classList.add('active');
}
});
The content is embedded in node data at build time (as HTML), so it’s available instantly without fetching.
Part 6: Performance Considerations
For Large Graphs
If you have hundreds of posts, the O(n²) repulsion calculation becomes expensive. Solutions:
- Spatial hashing - Only calculate repulsion for nearby nodes
- Web Workers - Offload physics to a separate thread
- Reduce frame rate - 30fps is often enough
- Limit visible nodes - Filter by date or category
For Mobile
Touch devices need special handling:
// Disable physics on mobile to save battery
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
if (isMobile) {
physicsConfig.maxVelocity = 8;
physicsConfig.damping = 0.95; // More friction = faster settling
}
The Complete Component
The full implementation is ~600 lines including:
- Physics engine
- Drag handlers
- Fullscreen mode
- Content preview panel
- Filter buttons (all/posts/tags)
- Zoom controls
- Loading animation
- Responsive styling
It runs entirely client-side after an initial SSR from Astro.
Comparison: This vs Quartz
| Feature | Quartz | This Implementation |
|---|---|---|
| Graph library | D3.js | Cytoscape.js |
| Physics | Static | Interactive (springs) |
| Fullscreen preview | No | Yes |
| Filter controls | Basic | Post/Tag/Category |
| Obsidian integration | Native | Manual |
| Complexity | Lower | Higher |
Quartz is better if you want Obsidian integration out of the box. This approach is better if you want custom behavior and don’t mind building it.
Try It Yourself
You can see the graph in action on my blog page. Drag nodes around, watch them spring back into place, and click to explore content.
The full source is in my site repo. The key file is src/components/KnowledgeGraph.astro.
Building custom visualizations is time-consuming, but the result is something truly yours. If you have questions about the implementation, reach out.
Related: