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:

  1. Physics-based dragging - Nodes follow each other like they’re connected by springs
  2. Fullscreen content preview - Click a node, read the full post in a panel
  3. Real-time filtering - Toggle between posts, tags, and categories
  4. 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:

  1. Spatial hashing - Only calculate repulsion for nearby nodes
  2. Web Workers - Offload physics to a separate thread
  3. Reduce frame rate - 30fps is often enough
  4. 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

FeatureQuartzThis Implementation
Graph libraryD3.jsCytoscape.js
PhysicsStaticInteractive (springs)
Fullscreen previewNoYes
Filter controlsBasicPost/Tag/Category
Obsidian integrationNativeManual
ComplexityLowerHigher

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: