NxtSoftLabs
← All writing

Visualize a codebase graph on the web: one HTML file

August 12, 2026·6 min read

The live code-graph demo on our CGraph project page is a single self-contained HTML file — CGraph's own graph.html export, run on its own repository — embedded in a click-to-load, sandboxed iframe. There is no graph database behind it, no charting library fetched from a CDN, and no runtime computation: the ~1.2 MB file carries the graph data and the renderer inline, and the page it lives on ships zero extra JavaScript until a visitor asks to see it.

The graph.html viewer: 1,300 community-colored nodes of the CGraph repository on a dark canvas, with a sidebar listing search, node info, communities with their sizes, and a footer reading 1300 nodes, 1697 edges, 70 communities
The exact file this post is about: graph.html rendering CGraph's own repository — 1,300 nodes in 70 community-colored clusters, search and node details in the sidebar. Click to open it.

This post walks through the three pieces that make that work: the export format, the embed, and the provenance gate that keeps the demo honest.

The demo is the export, not a copy of it

Every cgraph --root . --out cgraph-out build writes a graph.html alongside graph.json — an interactive viewer meant to be double-clicked from disk. The demo serves that exact artifact, unmodified. What you pan and zoom on the project page is what any user gets locally, which is the point: the demo demonstrates the exporter, not a bespoke web app that flatters it.

The file on the page today is CGraph's graph of itself, and its numbers come straight from the build that produced it (more on that in the provenance section):

161
source files scanned
1,300
nodes
1,697
edges
1.2 MB
one HTML file, everything inline
The committed demo asset: cgraph run on the CGraph repository at commit dd80379. The exact byte count is 1,294,392.

One file: data and renderer inline

Self-contained means the graph JSON is embedded directly in a <script> block:

const graphData = {"nodes":[...],"edges":[...]};

Inlining a multi-megabyte JSON payload into HTML has one classic footgun: if any string in the data contains </script>, the document breaks (or worse, becomes an injection vector). The exporter escapes every </ sequence as <\/ while streaming the payload into the output buffer, so the embedded data can never close its own script tag — and it appends in place rather than materializing a second copy of the serialized graph.

The renderer is equally dependency-free. It draws to a single <canvas> with the 2D API — no SVG, no DOM node per graph node, no d3. At 1,300 nodes that choice is comfortable; a DOM-based viewer would be fighting layout and memory long before that.

Three details do the heavy lifting for legibility:

  • Layout. Nodes are positioned by a Fruchterman–Reingold-style force simulation that cools over animation frames. Initial positions use deterministic per-node jitter (a seeded hash, not Math.random), so the same graph lays out the same way on every load. And when the C++ engine has already stamped a precomputed igraph layout into each node's properties, the viewer adopts those coordinates verbatim and skips the browser simulation entirely — a large graph renders near-instantly instead of paying an O(N²) cool-down.
  • Communities. Nodes carry a properties.community, clusters are seeded around a ring so each starts in its own region, and each community gets a stable color from the Tableau 10 palette. The sidebar lists communities next to search, node details, and the node/edge counts.
  • A label budget. Only the 24 highest-degree nodes are labelled at rest. Everything else stays reachable through hover, selection, search, and zoom — so the overview reads as a map instead of a wall of text.

The viewer opens dark by default (deliberately — it's the tool's signature look, not the site's palette), with light mode behind an explicit toggle.

Zoomed into the graph: distinct community-colored clusters of nodes joined by edges, with labels on the highest-degree nodes and the communities list visible in the sidebar
Zoomed in, the same file: each community holds its color, edges stay legible, and the label budget spends itself on the hub nodes.

Embedding it without wrecking the page

A 1.2 MB iframe is a terrible thing to ship eagerly on a content page. The embed is a client-side facade component:

{loaded ? (
  <iframe
    src="/demos/cgraph/graph.html"
    sandbox="allow-scripts"
    loading="lazy"
    title="Interactive CGraph knowledge graph of the CGraph repository"
  />
) : (
  <button onClick={() => setLoaded(true)}>Load the interactive graph</button>
)}

Three properties fall out of this:

  • Nothing transfers until asked. The facade is a styled button showing the real counts ("161 files → 1,300 nodes · 1,697 edges") and an honest size warning. The viewer's ~1.2 MB moves only after a click.
  • Nothing shifts. The frame has a fixed 16:10 aspect ratio with a minimum height, so the facade-to-iframe swap causes zero layout shift.
  • Nothing escapes. The iframe is sandboxed to allow-scripts only — the viewer can run its canvas renderer but gets no same-origin access, no top-navigation, no forms.

One layout wrinkle worth stealing: the viewer switches to its desktop layout (canvas plus sidebar) only above 840px of width, so the figure breaks out of the ~820px article column to min(1100px, 100vw - 3rem). Embedding it at column width would permanently trap it in its mobile layout.

Provenance, and refusing to publish a viewer that phones home

A demo of real output is only worth something if it stays real. The asset is regenerated by a script that walks a fixed pipeline:

git clone --depth 1
fresh CGraph checkout
cgraph --root . --out
build the graph
grep gate
refuse external assets
publish
graph.html + provenance.json
scripts/regenerate-cgraph-demo.sh — the only path by which the demo asset changes.

It parses the build summary line for the counts, then writes a provenance.json next to the HTML:

{
  "repo": "https://github.com/taylor009/CGraph",
  "commit": "dd80379",
  "generatedAt": "2026-07-12",
  "cgraphVersion": "cgraph-native 0.1.0",
  "files": 161,
  "nodes": 1300,
  "edges": 1697
}

The demo component imports that file and renders it verbatim — the caption's commit link and the facade's counts are never hand-edited, so they cannot drift from what actually produced the artifact.

The script also enforces the self-containment claim instead of trusting it:

# The viewer must stay fully self-contained — refuse to ship one that phones out.
if grep -qE 'src="https?://|href="https?://[^"]*\.(js|css)' "$TMP/out/graph.html"; then
  echo "error: graph.html references external assets; refusing to publish" >&2
  exit 1
fi

If a future exporter change ever introduced a CDN script or external stylesheet, the regeneration fails loudly rather than quietly shipping a viewer that phones home.

Try it

Load the live graph on the CGraph project page and click around — every node is the real function, class, or file it claims to be. Then run it on your own repository:

cgraph --root . --out cgraph-out
open cgraph-out/graph.html

The same build also writes Obsidian, Cypher, and SVG exports if a web viewer isn't the shape you need — and if you want the graph queryable rather than viewable, start with feeding your codebase to Claude Code.