ModsCode / Gallery / Designs / Force-directed graph
Force-directed graph
Import graph of a small repo laid out by forces: dot size by number of links, the hub file in Claude orange, the busiest files named.


hooks/designs/force-graph.mjs
// force-graph: the import graph of a small TypeScript repo, 30 files and 44 import links, laid out by
// d3-force (ISC, Mike Bostock): link, many-body, collide and two gentle x/y pulls, run as 300 ticks after
// .stop() so no timer is involved and its own seeded random source gives the same layout every time.
// The y pull is stronger than the x pull, so the cloud comes out wide like the stage, and the settled
// extent is then scaled into the plot. The graph itself is grown from a seeded walk (20261035): each new
// file imports one earlier file, picked in proportion to the square of (its links + 1), then fifteen
// extra imports join a file picked the same way to a random one; that rich-get-richer rule is what gives
// real import graphs their one big hub (plain proportional picking was tried and gave a flat mesh whose
// top file had 9 links). Names go to files by degree, so the most linked file is types.ts (13 links, the
// one Claude orange node). Dot radius grows with the square root of degree from candle's 2px dot, and a size key
// at the top right reads it back; links are 1px at 25% ink. The five most linked files carry their name
// in the mono face and their link count, each placed on whichever of eight sides clears every dot and
// earlier label and has the fewest links running through it. The graph is too dense for a side with
// none (a strict no-crossing pass found none for any of the five), so the labels get a 3px stage halo
// and a passing link stops short of the letters instead of striking them. Sample data only.
import { forceSimulation, forceLink, forceManyBody, forceCollide, forceX, forceY } from '../vendor/d3.mjs'
const W = 380
const H = 300
const STAGE = '#FAF9F5'
const INK = '#141413'
const MUTED = '#73726C'
const CLAUDE = '#D77757'
const FONT = "'Inter Tight','Segoe UI',system-ui,sans-serif"
const MONO = "ui-monospace,'Cascadia Mono',Consolas,monospace"
const PLOT = { left: 24, right: 356, top: 34, bottom: 262 } // the band above holds the size key
const FILES = 30
const EXTRA = 15 // imports added after the tree, so 29 + 15 = 44 links
const LABELS = 5
// From most to least linked: shared types and helpers sit at the centre of a repo, entry points at the edge
const NAMES = [
'types.ts', 'utils.ts', 'config.ts', 'client.ts', 'store.ts', 'logger.ts', 'session.ts', 'tokens.ts',
'hooks.ts', 'router.ts', 'format.ts', 'cache.ts', 'errors.ts', 'schema.ts', 'auth.ts', 'pane.ts',
'diff.ts', 'git.ts', 'agent.ts', 'tools.ts', 'prompt.ts', 'stream.ts', 'retry.ts', 'usage.ts',
'theme.ts', 'keys.ts', 'paths.ts', 'env.ts', 'cli.ts', 'index.ts',
]
const f = n => n.toFixed(1)
function rng(seed) {
return () => (seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff) / 0x7fffffff
}
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
// halo: a 3px stage-coloured edge painted under the glyphs, so a link that has to pass a label stops
// short of its letters instead of striking through them
const text = (x, y, s, { size = 9, fill = MUTED, anchor = 'start', font = FONT, halo = false } = {}) =>
`<text x="${f(x)}" y="${f(y)}"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`} font-size="${size}" font-family="${font}" fill="${fill}"${halo ? ` stroke="${STAGE}" stroke-width="3" stroke-linejoin="round" paint-order="stroke"` : ''}>${esc(s)}</text>`
const radius = deg => 2 + 1.3 * Math.sqrt(deg - 1)
// Preferential attachment, then extra links between distinct, unlinked pairs
function sample() {
const rnd = rng(20261035)
const deg = Array(FILES).fill(0)
const links = []
const has = new Set()
const add = (a, b) => {
links.push([a, b])
has.add(`${Math.min(a, b)}-${Math.max(a, b)}`)
deg[a]++
deg[b]++
}
const pick = n => {
const weight = j => (deg[j] + 1) ** 2
let total = 0
for (let j = 0; j < n; j++) total += weight(j)
let r = rnd() * total
for (let j = 0; j < n; j++) if ((r -= weight(j)) < 0) return j
return n - 1
}
for (let i = 1; i < FILES; i++) add(i, pick(i))
while (links.length < FILES - 1 + EXTRA) {
const a = pick(FILES)
const b = Math.floor(rnd() * FILES)
if (a !== b && !has.has(`${Math.min(a, b)}-${Math.max(a, b)}`)) add(a, b)
}
// Names by degree: the most linked file takes the first name
const rank = deg.map((d, i) => i).sort((p, q) => deg[q] - deg[p] || p - q)
const nodes = deg.map(d => ({ deg: d, r: radius(d) }))
rank.forEach((i, k) => { nodes[i].name = NAMES[k]; nodes[i].rank = k })
return { nodes, links: links.map(([source, target]) => ({ source, target })) }
}
function draw() {
const { nodes, links } = sample()
forceSimulation(nodes)
.randomSource(rng(20261036))
.force('link', forceLink(links).distance(20))
.force('charge', forceManyBody().strength(-46))
.force('collide', forceCollide(n => n.r + 3))
.force('x', forceX(0).strength(0.05))
.force('y', forceY(0).strength(0.075))
.stop()
.tick(300)
// Fit the settled cloud into the plot with one scale for both axes, centred
const x0 = Math.min(...nodes.map(n => n.x - n.r))
const x1 = Math.max(...nodes.map(n => n.x + n.r))
const y0 = Math.min(...nodes.map(n => n.y - n.r))
const y1 = Math.max(...nodes.map(n => n.y + n.r))
const k = Math.min((PLOT.right - PLOT.left) / (x1 - x0), (PLOT.bottom - PLOT.top) / (y1 - y0))
const ox = (PLOT.left + PLOT.right) / 2 - (k * (x0 + x1)) / 2
const oy = (PLOT.top + PLOT.bottom) / 2 - (k * (y0 + y1)) / 2
for (const n of nodes) {
n.px = ox + k * n.x
n.py = oy + k * n.y
}
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
const d = links.map(l => `M${f(l.source.px)} ${f(l.source.py)}L${f(l.target.px)} ${f(l.target.py)}`).join('')
parts.push(`<path d="${d}" fill="none" stroke="${INK}" stroke-opacity="0.25"/>`)
for (const n of [...nodes].sort((a, b) => a.deg - b.deg)) {
const look = n.rank === 0 ? `fill="${CLAUDE}" fill-opacity="0.9"` : `fill="${INK}" fill-opacity="0.55"`
parts.push(`<circle cx="${f(n.px)}" cy="${f(n.py)}" r="${f(n.r)}" ${look}/>`)
}
// Size key: three dots read back the radius rule, right-aligned at W - 12
const KEY = [1, 5, 10]
let kx = W - 12
for (const v of [...KEY].reverse()) {
const label = String(v)
parts.push(text(kx, 20, label, { anchor: 'end' }))
kx -= label.length * 5 + 3 + radius(v)
parts.push(`<circle cx="${f(kx)}" cy="17" r="${f(radius(v))}" fill="${INK}" fill-opacity="0.55"/>`)
kx -= radius(v) + 10
}
parts.push(text(kx + 4, 20, 'LINKS', { anchor: 'end' }))
// Labels: name (mono 10px, about 6px a character) then degree (9px); the first clear side wins
const boxes = []
const hitsDot = (b, self) => nodes.some(n => n !== self && n.px + n.r > b.x0 && n.px - n.r < b.x1 && n.py + n.r > b.y0 && n.py - n.r < b.y1)
const hitsBox = b => boxes.some(o => o.x1 > b.x0 && o.x0 < b.x1 && o.y1 > b.y0 && o.y0 < b.y1)
// Liang-Barsky clip: does the link's segment enter the box at all
const crosses = (b, l) => {
const x = l.source.px
const y = l.source.py
const dx = l.target.px - x
const dy = l.target.py - y
let t0 = 0
let t1 = 1
for (const [p, q] of [[-dx, x - b.x0], [dx, b.x1 - x], [-dy, y - b.y0], [dy, b.y1 - y]]) {
if (p === 0) { if (q < 0) return false; continue }
const t = q / p
if (p < 0) t0 = Math.max(t0, t)
else t1 = Math.min(t1, t)
if (t0 > t1) return false
}
return true
}
const linksIn = b => links.filter(l => crosses(b, l)).length
for (const n of [...nodes].sort((a, b) => a.rank - b.rank).slice(0, LABELS)) {
const wName = n.name.length * 6
const w = wName + 4 + String(n.deg).length * 5
const g = n.r * 0.7 + 3 // a diagonal side starts this far off the centre on each axis
const sides = [
[n.px + n.r + 4, n.py + 3.5], // right
[n.px - n.r - 4 - w, n.py + 3.5], // left
[n.px - w / 2, n.py - n.r - 5], // above
[n.px - w / 2, n.py + n.r + 12], // below
[n.px + g, n.py - g], // up right
[n.px + g, n.py + g + 8], // down right
[n.px - g - w, n.py - g], // up left
[n.px - g - w, n.py + g + 8], // down left
]
// Of the sides clear of the stage edge, every dot and earlier labels, the one fewest links run
// through wins (the earlier side on a tie); the graph is too dense for a side with none
let best = null
for (const [lx, ly] of sides) {
const b = { x0: lx - 2, x1: lx + w * 1.1 + 2, y0: ly - 9, y1: ly + 3 } // 10% slack on the width
if (b.x0 < 8 || b.x1 > W - 8 || b.y0 < 28 || b.y1 > H - 22) continue
if (hitsDot(b, n) || hitsBox(b)) continue
const cut = linksIn(b)
if (!best || cut < best.cut) best = { lx, ly, b, cut }
}
if (!best) continue
boxes.push(best.b)
parts.push(text(best.lx, best.ly, n.name, { size: 10, fill: n.rank === 0 ? CLAUDE : INK, font: MONO, halo: true }))
parts.push(text(best.lx + wName + 4, best.ly, n.deg, { halo: true }))
}
parts.push(text(W - 12, H - 8, `${FILES} FILES, ${links.length} IMPORTS`, { anchor: 'end' }))
parts.push(text(12, H - 8, 'SAMPLE IMPORT GRAPH'))
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join('')}</svg>`
}
let source // drawn on first use and kept: the pane loads every design, so none pays at load time
export default {
id: 'force-graph',
title: 'Force-directed graph',
category: 'flow',
description: 'Import graph of a small repo laid out by forces: dot size by number of links, the hub file in Claude orange, the busiest files named.',
element: ui => ui.Svg({ source: (source ??= draw()), alt: `Force-directed import graph of ${FILES} files and ${FILES - 1 + EXTRA} links, with the most linked file as the Claude orange hub, sample data`, width: W, height: H }),
}
Imports d3-force 3.0.0 (ISC), bundled with its licence in look's hooks/vendor/.
To use it in a mod: copy the file to your mod's hooks/designs/ and return its element(ui) from a pane's ui.render. How a pane is built.
Code: PolyForm Noncommercial License 1.0.0: noncommercial use only. Required Notice: Copyright 2026 Haruki Nakada (https://modscode.com).