DEV Community

Cover image for Stop Running Force Simulations in Your Users' Browsers. I Mapped 4,420 Pages Without One.
bitcoin_devto
bitcoin_devto

Posted on

Stop Running Force Simulations in Your Users' Browsers. I Mapped 4,420 Pages Without One.

Written by Dex Calloway, resident contrarian at Bitcoin Institute, an archive of Bitcoin's primary sources.

Zooming from the whole map into the cypherpunk cluster until individual page titles appear

Hot take: if your graph only changes when you deploy, the force simulation has no business running on your reader's machine.

That GIF is our whole archive: 4,420 nodes and 11,769 links. It covers forum posts, private emails, mailing-list messages, the whitepaper, and the 508 people who show up in them. The layout was computed once, on the build machine. The browser downloads a JSON file of integer coordinates, about 161 KB over the wire, and draws it with WebGL. Nobody's laptop fan spins up to figure out where Satoshi goes.

D3's own force-directed graph example does the opposite. It calls d3.forceSimulation(nodes) in the page and redraws on every tick. That's a fine way to teach forces. It's a strange way to ship a static picture.

What's in the graph

Every record, article, person, and thread on the site gets its own page, and each of those pages is a node: 2,686 forum posts, 508 people, 403 private emails, 293 threads, 179 articles, 158 mailing-list messages, and a long tail of analyses, BIPs, tweets, and court documents.

The 11,769 edges come in four kinds:

  • Body links (3,825): one page's text links to another, including links the site adds automatically to known terms.
  • Related entries (632): an editor listed the page as related.
  • Thread membership (3,184): a post belongs to a thread.
  • Appearances (4,128): a person appears in a record.

When two kinds connect the same pair, only one edge survives, so the map doesn't draw the same line twice.

Step 1: find the neighborhoods (Louvain, minus the hubs)

Clusters come from the Louvain method: move each node to whichever neighboring cluster raises modularity the most, repeat until nothing moves, collapse each cluster into a single node, and run it again.

Run it naively on this data and you get one giant blob. Satoshi Nakamoto alone touches 1,331 pages. A node like that glues every neighborhood to every other one. So any node with more than 150 connections sits out the clustering (five nodes qualify), and the edge kinds get different weights:

const HUB_DEGREE = 150;
const EDGE_WEIGHT = [1, 1, 2, 0.4]; // body link, related, thread, appearance

for (const [a, b, k] of edges) {
  if (degree[a] > HUB_DEGREE || degree[b] > HUB_DEGREE) continue;
  const w = EDGE_WEIGHT[k];
  nbr[a].set(b, (nbr[a].get(b) ?? 0) + w);
  nbr[b].set(a, (nbr[b].get(a) ?? 0) + w);
}
Enter fullscreen mode Exit fullscreen mode

Thread membership counts double because a reply really does belong with its thread. Appearances count 0.4 because a person mentioned in fifty records shouldn't drag all fifty into one pile. Nodes left in clusters under 12 get moved into whichever big cluster they connect to most. The result: 63 clusters, every node assigned. The largest is "cypherpunk", with 754 nodes.

Step 2: lay it out twice

One flat simulation over 4,420 nodes produces a hairball. Two simulations, run one after the other, give you the nebula in the GIF above.

The first places the 63 clusters as circles. Each circle's radius is 14 * sqrt(size), and clusters are pulled together by the total weight of the edges between them:

forceSimulation(clusters)
  .force('link', forceLink(clusterLinks)
    .distance((l) => radius[l.source.i] + radius[l.target.i] + 40)
    .strength((l) => 0.05 + 0.5 * Math.sqrt(l.w / maxW)))
  .force('charge', forceManyBody().strength((d) => -30 * d.r))
  .force('collide', forceCollide((d) => d.r + 30).iterations(3))
  .stop()
  .tick(400);
Enter fullscreen mode Exit fullscreen mode

The second places every node. Each one starts near its cluster's center, spaced out by the golden angle, then 320 ticks of forces pull it into shape. The line that makes the picture readable is the link strength:

.strength((l) => LINK_STRENGTH[l.kind] * (l.same ? 1 : 0.04))
Enter fullscreen mode Exit fullscreen mode

An edge that crosses between clusters pulls at 4% of normal strength. The line still gets drawn, but it no longer drags the clusters into each other. Each node is also pulled toward its own cluster's center, except the hubs. Nothing pulls them toward a cluster, so their own edges decide where they land.

Then every coordinate is scaled into a ±1000 box and rounded to an integer. Those integers are what ships.

The full map: 63 clusters laid out as a nebula

The build computes two more layouts, without any physics. "Timeline" spreads nodes by year, in one band per record type. "Rings around Satoshi" places each node on a ring by how many hops it is from him. Switching views just interpolates between precomputed coordinate sets for 1.4 seconds.

The same nodes rearranged as a timeline

The same nodes on rings by hop count from Satoshi Nakamoto

Satoshi is one hop from 1,331 pages, two hops from 1,155 more, and three hops from 1,640. Every one of the 4,420 nodes is reachable within six.

Step 3: make it glow without blowing out

The nodes are WebGL points. The fragment shader gives each one a sharp core and a soft halo:

vec2 d = gl_PointCoord * 2.0 - 1.0; float r = dot(d, d);
if (r > 1.0) discard;
float core = exp(-r * 22.0); float halo = exp(-r * 4.5) * 0.45;
vec3 c = (vColor * (halo + core * 0.9) + vec3(core * 0.5)) * uGain;
Enter fullscreen mode Exit fullscreen mode

The points are combined with screen blending instead of additive blending:

gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_COLOR, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
Enter fullscreen mode Exit fullscreen mode

With additive blending, a cluster of 754 overlapping halos burns to pure white. Screen blending keeps getting brighter as halos stack but never quite hits the ceiling, so dense clusters still show structure. The further you zoom out, the lower the shader's brightness multiplier (uGain) goes, so the densest clusters don't wash out.

Step 4: let the zoom level decide what text you see

Text goes on a 2D canvas layered over the WebGL canvas, and the zoom factor z (1 = whole map fits) decides how much of it appears:

const maxComms = Math.round(14 * Math.pow(z, 1.4));  // cluster names shown
const fade = Math.max(0, Math.min(1, (6 - z) / 3));   // names fade from z=3, gone by z=6
if (z > 2.4) { /* page titles, most-connected first, max 60 (25 on touch) */ }
Enter fullscreen mode Exit fullscreen mode

Zoomed out, you get at most the 14 biggest cluster names. Zoom in and smaller clusters get named. Past 2.4×, the most-connected page titles start appearing, and by 6× the cluster names are gone. Every label remembers its bounding box, and any label that would overlap one already drawn is skipped.

Zoomed out: only the largest cluster names

Closer in: more cluster names, and the first page titles

Closer: cluster names give way to page titles

Step 5: draw nothing

If you remember one snippet from this post, make it this one:

const invalidate = () => {
  dirty = true;
  if (!raf) raf = requestAnimationFrame(frame);
};
Enter fullscreen mode Exit fullscreen mode

No render loop. A frame gets requested only when something changed: a pan, a zoom, a selection, a view switch. When the map is sitting still, it costs nothing.

Selecting Satoshi Nakamoto lights up his 1,331 connections

Two nodes have more than 300 connections. Each one's edges appear only when you select it. Otherwise those edges would paint a starburst over everything else.

The numbers

Measured on the live page in Chromium (Metal backend) on an Apple M1 Max, 1280×800 at 2× pixel ratio. I haven't measured phones.

  • Graph JSON: 791,204 bytes raw, about 161 KB transferred with Brotli. Fetched in 57 ms.
  • Map ready 298 ms after navigation started.
  • Frames drawn during 2 seconds of doing nothing: 0.
  • One 1.4-second view switch: 169 frames, about 120 per second. Median frame cost 0.9–1.1 ms, worst 4.3 ms, across six switches.

No server. No worker. No simulation in the page.

"But users want to drag nodes around"

Then you need live physics, and you should run it. That's a different product. This map exists to be read: zoom, pan, click a page, follow the links. Nobody reading a map of Bitcoin's paper trail needs to fling the Satoshi node across the screen.

"But the layout goes stale when the data changes"

It changes when we deploy, and every deploy rebuilds the layout. Adding a page costs one build, not a slower page for every reader. If your graph changes per request or per user, sure, compute it at runtime. If it only changes when you push, you already have the perfect place to compute it.

"Build-time layout won't scale"

At 4,420 nodes and 11,769 edges, the build pays for the simulation once. Every reader after that pays for a 161 KB download and nothing more. The trade gets better with every reader.

The October 31, 2008 whitepaper selected, with its 12 connected pages

The whitepaper as posted on October 31, 2008 connects to only 12 pages. Satoshi connects to 1,331. Open the map, search for the whitepaper, and see which of those 12 you've never read.

If your graph doesn't change between deploys and you're still shipping d3.forceSimulation to every visitor, you're making a thousand laptops solve the same equation you could have solved once.

Tell me why I'm wrong.

Top comments (0)