Dev.to WebDev 🛠 Dev 👁 0 📖 9 min read

How I Built a 151-Node Knowledge Graph for Black Myth Using Astro — Without D3.js in the Browser

A build-time force simulator, server-rendered SVG, and vanilla JS interactivity for a dense mythology knowledge graph across 10 languages. Figure 1: The full 151-node, 1,213-edge knowledge graph rendered as static

How I Built a 151-Node Knowledge Graph for Black Myth Using Astro — Without D3.js in the Browser

A build-time force simulator, server-rendered SVG, and vanilla JS interactivity for a dense mythology knowledge graph across 10 languages.

Figure 1: The full 151-node, 1,213-edge knowledge graph rendered as static SVG with build-time force layout.

You're building a fan wiki for an upcoming AAA game. You have 151 mythological concepts — Sun Wukong, the Jade Emperor, the Eighteen Levels of Hell, Zhong Kui's ghost-quelling rituals — and 1,213 relationships connecting them. The question isn't whether to visualize this as a graph. The question is: how do you render 1,213 edges on a page without destroying performance?

The obvious answer is D3.js force simulation. I didn't use it. Here's why, and what I built instead.

The Problem: 151 Nodes × 1,213 Edges × 10 Languages

The site is blackmyth.game, a lore reference for Game Science's Black Myth series (Wukong + the upcoming Zhong Kui). The knowledge graph has three dimensions:

Category Nodes Description
JTTW Lore 124 Classic Journey to the West entities, artifacts, celestial bureaucracy
Game Adaptation 10 Game Science's reinterpretations and original lore
Zhong Kui Bridge 17 Exorcism folklore, underworld hierarchy, motif cross-references

Every node has a title and URL in 10 locales (en, zh-cn, zh-tw, ja, ko, vi, th, ru, pt-br, es, de). The graph needs to serve readers in Tokyo, São Paulo, and Moscow equally.

The hard constraints:

  • First paint must be instant. No "loading spinner while physics simulates."
  • Must work on mobile. No 10 MB D3 bundle on a 4G connection.
  • Every node is a link. The graph is navigation, not decoration.
  • Must support filtering, search, zoom, and ego-network exploration — readers need to find "Zhong Kui" in 151 nodes without scrolling.

The Architecture: Move Physics to Build Time

The core insight: force-directed layout is deterministic given the same input graph. There is zero reason to recompute it in every user's browser.

┌─────────────────────────────────────────────────────────┐
│  BUILD TIME (runs once, offline)                        │
│                                                         │
│  content-intelligence-data.json                         │
│    │ (151 nodes + 1,213 edges from the content pipeline)│
│    ▼                                                    │
│  generate-bestiary-graph-layout.mjs                     │
│    │ (custom force simulator, 300 iterations)           │
│    ▼                                                    │
│  bestiaryGraphLayout.json (14,637 lines)                │
│    │ (pre-computed x/y coordinates for every node)      │
│    ▼                                                    │
│  BestiaryRelationshipGraph.astro                        │
│    │ (Astro SSG: reads JSON → renders static SVG)       │
│    ▼                                                    │
│  Static HTML + inline SVG + ~200 lines vanilla JS       │
└─────────────────────────────────────────────────────────┘

The browser receives pre-rendered SVG with all 151 nodes and 1,213 edge lines already positioned. No physics. No layout computation. The JavaScript payload is ~200 lines of vanilla JS for interactivity — no framework, no D3, no virtual DOM.

The Custom Force Simulator (300 Lines of Node.js)

D3-force is great for exploratory work, but it's a black box when you need reproducible, build-time output. I wrote a minimal force simulator that runs in Node.js during the build step:

// scripts/generate-bestiary-graph-layout.mjs (simplified)

const ITERATIONS = 300;
const REPULSION = 18000;   // Coulomb repulsion between all node pairs
const ATTRACTION = 0.008;  // Spring attraction along edges
const GRAVITY = 0.035;     // Pull toward category cluster center
const DAMPING = 0.88;      // Velocity decay per tick

// Three cluster centers for visual separation
const CLUSTER_CENTERS = {
  "journey-to-the-west": { x: 560, y: 540 },   // Left
  "game-adaptation":     { x: 1360, y: 340 },  // Top right
  "zhong-kui-lore":      { x: 1360, y: 740 },  // Bottom right
};

for (let iter = 0; iter < ITERATIONS; iter++) {
  // 1. Repulsion: every node pushes every other node away
  for (let i = 0; i < nodes.length; i++) {
    for (let j = i + 1; j < nodes.length; j++) {
      let dx = nodes[j].x - nodes[i].x;
      let dy = nodes[j].y - nodes[i].y;
      let distSq = dx * dx + dy * dy + 1; // avoid division by zero
      let force = REPULSION / distSq;
      // Apply force in opposite directions
      nodes[i].vx -= (dx / Math.sqrt(distSq)) * force;
      nodes[i].vy -= (dy / Math.sqrt(distSq)) * force;
      nodes[j].vx += (dx / Math.sqrt(distSq)) * force;
      nodes[j].vy += (dy / Math.sqrt(distSq)) * force;
    }
  }

  // 2. Attraction: connected nodes pull toward each other
  for (const edge of edges) {
    let dx = edge.target.x - edge.source.x;
    let dy = edge.target.y - edge.source.y;
    let dist = Math.sqrt(dx * dx + dy * dy) || 1;
    let force = (dist - 120) * ATTRACTION; // rest length = 120px
    edge.source.vx += (dx / dist) * force;
    edge.source.vy += (dy / dist) * force;
    edge.target.vx -= (dx / dist) * force;
    edge.target.vy -= (dy / dist) * force;
  }

  // 3. Cluster gravity: pull nodes toward their category center
  for (const node of nodes) {
    const center = CLUSTER_CENTERS[node.category];
    node.vx += (center.x - node.x) * GRAVITY;
    node.vy += (center.y - node.y) * GRAVITY;
    node.vx *= DAMPING;
    node.vy *= DAMPING;
    node.x += node.vx;
    node.y += node.vy;
  }
}

Three forces, 300 iterations, ~500ms on a Mac Mini. The output is a 14,637-line JSON file with pre-computed x/y coordinates for every node and edge endpoint. This runs once per content update, not once per page view.

Why Three Cluster Centers?

Without cluster gravity, the three categories would mix into an undifferentiated hairball. With gravity, JTTW entities cluster on the left, game adaptations on the top right, and Zhong Kui folklore on the bottom right — creating a readable visual topology that mirrors the conceptual structure.

Server-Side Rendering: Astro + Static SVG

The BestiaryRelationshipGraph.astro component reads the pre-computed layout and renders it as inline SVG:

---
// BestiaryRelationshipGraph.astro (simplified)
import graphLayout from "@/data/bestiaryGraphLayout.json";

const { nodes, edges, viewBox } = graphLayout;

// Compute degree per node dynamically (edge count = connection strength)
const nodeDegrees = new Map();
edges.forEach(e => {
  nodeDegrees.set(e.source, (nodeDegrees.get(e.source) || 0) + 1);
  nodeDegrees.set(e.target, (nodeDegrees.get(e.target) || 0) + 1);
});

// Tier system: narrative importance from connection count
function computeTier(nodeId, degree) {
  if (degree >= 40) return 0;  // Universe anchor (gold glow, largest)
  if (degree >= 15) return 1;  // Chapter core
  if (degree >= 5)  return 2;  // Branch
  return 3;                     // Leaf (smallest, dimmest)
}
---

<svg viewBox={`0 0 ${viewBox.width} ${viewBox.height}`}
     class="h-full w-full" style="background:#080806;">
  <!-- Background grid + radial glow -->
  <rect width={viewBox.width} height={viewBox.height} fill="url(#graphGrid)"/>

  <!-- Edge layer: all 1,213 lines pre-positioned -->
  <g class="edges-layer">
    {edges.map(edge => (
      <line x1={edge.x1} y1={edge.y1} x2={edge.x2} y2={edge.y2}
            data-source={edge.source} data-target={edge.target}
            stroke="rgba(255,255,255,0.12)" stroke-width="0.6"/>
    ))}
  </g>

  <!-- Node layer: 151 clickable nodes with tier-based sizing -->
  <g class="nodes-layer">
    {nodes.map(node => {
      const tier = computeTier(node.id, nodeDegrees.get(node.id) || 0);
      const r = tier === 0 ? 16 : tier === 1 ? 11 : tier === 2 ? 7 : 5;
      return (
        <a href={node.urls[locale]}>
          <circle cx={node.x} cy={node.y} r={r}
                  fill={tier === 0 ? "#C3A15A" : "#4a4a58"}
                  filter={tier === 0 ? "url(#tier0Glow)" : undefined}/>
          {tier <= 1 && (
            <text x={node.x} y={node.y + r + 12}
                  text-anchor="middle" fill="#E8E0D2" font-size="10">
              {node.titles[locale]}
            </text>
          )}
        </a>
      );
    })}
  </g>
</svg>

Key decisions in the rendering layer:

  • Tier 0 "universe anchors" get a gold SVG glow filter — Sun Wukong, Zhong Kui, The Destined One, Yanluo Wang. These are the 8 nodes every reader looks for first.
  • Labels only on Tier 0-1 nodes — avoids text collision on the 120+ leaf nodes while keeping the important ones instantly readable.
  • Every node is an <a href> — the graph is a navigation surface, not a standalone viz. Click any node → go to its deep-dive lore article.
  • Three node shapes — circles (standard entities), diamonds (game adaptations), triangles (Zhong Kui bridge concepts). Shape = category at a glance.

Interactive Layer: ~200 Lines of Vanilla JS

With layout done at build time, the browser JavaScript only handles three things:

1. Category Filtering (Portal Cards)

Three clickable cards at the top let readers focus on one dimension:

function applyCategoryFilter(category) {
  nodes.forEach(node => {
    const el = node;
    el.style.opacity = node.dataset.category === category ? "1" : "0.08";
  });

  edges.forEach(edge => {
    const srcCat = edge.dataset.sourceCat;
    const tgtCat = edge.dataset.targetCat;
    const el = edge;

    if (srcCat === category && tgtCat === category) {
      el.style.opacity = "0.55";   // intra-cluster: prominent
    } else if (srcCat === category || tgtCat === category) {
      el.style.opacity = "0.12";   // cross-cluster: visible but dim
    } else {
      el.style.opacity = "0";      // unrelated: hidden
    }
  });
}

This turns a hairball of 1,213 edges into a readable sub-graph in one click — no re-layout needed because positions are pre-computed.

2. Ego-Network Hover

Hover any node → spotlight its 1-degree neighborhood:

nodes.forEach(node => {
  node.addEventListener("mouseenter", () => {
    const targetId = node.dataset.id;
    const connectedIds = new Set([targetId]);

    edges.forEach(edge => {
      if (edge.dataset.source === targetId || edge.dataset.target === targetId) {
        connectedIds.add(edge.dataset.source);
        connectedIds.add(edge.dataset.target);
        edge.style.opacity = "0.85";
      } else {
        edge.style.opacity = "0.03";
      }
    });

    nodes.forEach(n => {
      n.style.opacity = connectedIds.has(n.dataset.id) ? "1" : "0.08";
    });
  });

  node.addEventListener("mouseleave", () => resetToCurrentFilter());
});

This is the interaction readers use most — hover "Zhong Kui" → instantly see his 40+ connections to the underworld court, ghost attendants, door gods, and Wukong's comparative mythology. The tooltip updates live: 🎯 Zhong Kui · 42 connections (钟馗宇宙与民俗).

3. Search with Autocomplete

A text input with an HTML <datalist> of all 151 node titles. Type "who is" → autocomplete suggests "Who Is Zhong Kui", "Who Is Sun Wukong", etc. Selecting a result triggers the ego-network spotlight.

<input type="text" id="graph-concept-search"
       list="graph-concepts-list"
       placeholder="Search specific concept or entity..." />
<datalist id="graph-concepts-list">
  {nodes.map(node => <option value={node.titles[locale]} />)}
</datalist>

Bonus: Zoom and Fullscreen

Ctrl+scroll zoom (0.3×–3×) and a fullscreen toggle — handled by manipulating the SVG <g transform="scale(...)"> and the Fullscreen API. Combined total: ~50 lines.

The Data Pipeline: Content Intelligence → Graph

The graph isn't hand-curated. It's generated from the site's content in three build steps:

  1. generate-content-graph.mjs — parses all 108+ lore articles in src/data/blog/, extracts internal links between articles, and outputs a raw content graph.
  2. merge-gsc-graph.mjs — merges Google Search Console data (clicks, impressions, average position) into the graph, producing content-intelligence-data.json — a 66,627-line file with 153 nodes and 1,225 edges. This is the single source of truth for the graph topology.
  3. generate-bestiary-graph-layout.mjs — reads the intelligence data, applies category mapping from conceptCategoryTags.ts, runs the custom force simulator, and outputs bestiaryGraphLayout.json (14,637 lines, 151 nodes, 1,213 edges after dedup).

When a new lore article is published, re-running the pipeline updates the graph. The entire flow — data extraction → GSC enrichment → layout → render — is deterministic and reproducible.

Why Not D3.js?

Concern D3-force in browser Our build-time approach
First paint ~2-5s of blank canvas while physics simulates Instant — pre-rendered SVG in HTML
Bundle size ~250 KB (d3-force + d3-selection + d3-zoom) ~5 KB vanilla JS
Mobile Force simulation jank on low-end CPUs Static SVG, GPU-composited
SEO Canvas-based graphs are invisible to crawlers Inline SVG — every node label is indexable text
Reproducibility Slightly different layout per run Deterministic output, same layout every time
10 locales Need to re-run layout per locale or add data All locale titles baked into the JSON, Astro picks at build

The trade-off: layout updates require a build step. For a content site that rebuilds on every push anyway, this is zero additional cost.

Results

The graph page loads with:

  • 151 nodes across 3 mythological dimensions
  • 1,213 edges representing cross-article citations and motif links
  • 10 languages per node
  • < 5 KB of JavaScript for all interactivity
  • Instant first paint — the SVG is in the initial HTML payload
  • GPU-composited scrolling and zoom — SVG transforms are hardware-accelerated

The live graph is at blackmyth.game/en/bestiary/. The full source — data model, layout generator, and Astro component — is at github.com/rambo586/blackmyth-concept-graph.

What I'd Do Differently

  1. WebWorker for layout regeneration. The current Node.js script is fine for CI, but if I wanted in-browser relayout (e.g., after the user drags a node), I'd move the force simulator to a Web Worker to avoid blocking the main thread.

  2. Canvas fallback for 500+ nodes. SVG hits a rendering bottleneck around ~500 nodes with DOM-based hit testing. For larger graphs, a Canvas renderer with a quadtree for mouse hit detection would scale better.

  3. Transition animations. Adding transition: opacity 300ms to node/edge CSS made the category filter feel polished. I'd add the same to the zoom transform for smoother scaling.

  4. Edge bundling. With 1,213 edges, many overlap. Edge bundling (grouping edges that share direction) would reduce visual noise without losing topology information.

The knowledge graph and its build pipeline are part of the blackmyth.game open-source project. Full source at the GitHub repository.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.