ModsCode / Gallery / Designs / Circle packing
Circle packing
Installed packages packed inside their groups, each circle sized by disk space, with names inside where they fit and group totals on the outlines.


hooks/designs/circle-pack.mjs
// circle-pack: what a Claude Code mod's node_modules holds, 23 packages in six groups, in candle's
// look. The circles come from d3-hierarchy's pack() (ISC, Mike Bostock; Wang et al.'s front-chain
// packing), so a circle's area is its size on disk. Groups are 1px outlines at 18% ink, the card
// outline step, and packages are 25% ink, the area step, so the leaves carry the weight and the groups
// only gather them. Each group's name and total sit in a gap left in its outline, like a fieldset
// legend, so they need no room inside: at the top, or at the bottom when another group presses on the
// top (on a tangent point a label read as either circle's), and the leaves keep 3 units of padding so
// the label clears them. A package name is drawn only when it fits across its
// circle, and its size only when the circle is tall enough for a second line. The sizes add up: cloud 55,
// lang 33, test 24, build 24, runtime 18, lint 15 = 169 MB. Sample data only.
import { hierarchy, pack } from '../vendor/d3.mjs'
const W = 380
const H = 340
const STAGE = '#FAF9F5'
const INK = '#141413'
const MUTED = '#73726C'
const FONT = "'Inter Tight','Segoe UI',system-ui,sans-serif"
const PLOT = { left: 12, top: 16, right: 368, bottom: 314 } // the groups are fitted to this box
const f = n => n.toFixed(1)
// Installed MB per package
const DEPS = {
name: 'node_modules',
children: [
{ name: 'CLOUD', children: [{ name: 'workerd', size: 30 }, { name: 'wrangler', size: 14 }, { name: 'miniflare', size: 7 }, { name: 'undici', size: 4 }] },
{ name: 'LANG', children: [{ name: 'typescript', size: 26 }, { name: 'tsx', size: 5 }, { name: '@types/node', size: 2 }] },
{ name: 'TEST', children: [{ name: 'vitest', size: 9 }, { name: 'playwright', size: 8 }, { name: 'jsdom', size: 4 }, { name: 'msw', size: 3 }] },
{ name: 'BUILD', children: [{ name: 'esbuild', size: 11 }, { name: 'vite', size: 6 }, { name: 'rollup', size: 5 }, { name: 'postcss', size: 2 }] },
{ name: 'RUNTIME', children: [{ name: 'react-dom', size: 5 }, { name: 'sdk', size: 4 }, { name: 'zod', size: 3 }, { name: 'katex', size: 3 }, { name: 'd3', size: 2 }, { name: 'hono', size: 1 }] },
{ name: 'LINT', children: [{ name: 'oxlint', size: 8 }, { name: 'eslint', size: 7 }] },
],
}
const text = (x, y, s, { size = 9, fill = MUTED, anchor = 'start' } = {}) =>
`<text x="${f(x)}" y="${f(y)}"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`} font-size="${size}" font-family="${FONT}" fill="${fill}">${s}</text>`
function draw() {
const root = hierarchy(DEPS).sum(d => d.size || 0).sort((a, b) => b.value - a.value)
pack().size([300, 300]).padding(d => (d.depth === 0 ? 6 : 3))(root)
// pack() lays the groups out a little taller than wide, so they are turned a quarter (x and y swap,
// which keeps every circle where it touches) and their own bounding box, not the root circle, is
// fitted to the wide plot: the leaves come out about 18% larger, enough to name the big ones
root.each(d => { const x = d.x; d.x = d.y; d.y = x })
const gs = root.children
const bx0 = Math.min(...gs.map(g => g.x - g.r))
const bx1 = Math.max(...gs.map(g => g.x + g.r))
const by0 = Math.min(...gs.map(g => g.y - g.r))
const by1 = Math.max(...gs.map(g => g.y + g.r))
const k = Math.min((PLOT.right - PLOT.left) / (bx1 - bx0), (PLOT.bottom - PLOT.top) / (by1 - by0))
const ox = (PLOT.left + PLOT.right) / 2 - ((bx0 + bx1) / 2) * k
const oy = (PLOT.top + PLOT.bottom) / 2 - ((by0 + by1) / 2) * k
root.each(d => { d.x = ox + d.x * k; d.y = oy + d.y * k; d.r *= k })
const X = d => d.x
const Y = d => d.y
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
const legends = []
// A group's label goes on its outline where no other group presses against it: the top, or the
// bottom when a neighbour sits on the top. The clearance is sampled across the label's box.
const clear = (g, y, w) => {
let min = Infinity
for (const o of root.children) if (o !== g) for (const dx of [-w / 2, 0, w / 2]) for (const dy of [-5, 5]) min = Math.min(min, Math.hypot(g.x + dx - o.x, y + dy - o.y) - o.r)
return min
}
for (const g of root.children) {
const label = `${g.data.name} ${g.value}`
const w = label.length * 5 * 1.1
const top = clear(g, Y(g) - g.r, w)
const bottom = clear(g, Y(g) + g.r, w)
const th = top >= 4 || top >= bottom ? -Math.PI / 2 : Math.PI / 2
// The outline is one arc that stops 3px short of each side of the label, so the gap cuts only the
// line; a stage-coloured patch was tried first and it also shaved the top leaf and a neighbour's outline
const a = Math.asin(Math.min(1, (w / 2 + 3) / g.r))
const [x0, y0] = [X(g) + g.r * Math.cos(th + a), Y(g) + g.r * Math.sin(th + a)]
const [x1, y1] = [X(g) + g.r * Math.cos(th - a), Y(g) + g.r * Math.sin(th - a)]
parts.push(`<path d="M${f(x0)} ${f(y0)}A${f(g.r)} ${f(g.r)} 0 1 1 ${f(x1)} ${f(y1)}" fill="none" stroke="${INK}" stroke-opacity="0.18"/>`)
// Centred on the line but 1.5px outward, which keeps the caps clear of the leaf under them
legends.push(text(X(g), Y(g) + Math.sin(th) * (g.r + 1.5) + 3, label, { anchor: 'middle' }))
for (const d of g.children) {
parts.push(`<circle cx="${f(X(d))}" cy="${f(Y(d))}" r="${f(d.r)}" fill="${INK}" fill-opacity="0.25"/>`)
// About 5.6px a character at 10px with 10% slack, and 2px of air each side
const nameW = d.data.name.length * 5.6 * 1.1
if (2 * d.r < nameW + 4) continue
const two = d.r >= 15
parts.push(text(X(d), Y(d) + (two ? -1 : 3.5), d.data.name, { size: 10, fill: INK, anchor: 'middle' }))
if (two) parts.push(text(X(d), Y(d) + 10, `${d.value} MB`, { anchor: 'middle' }))
}
}
parts.push(...legends)
// The total sits on the caption line, across from the caption, as in icicle
const total = `${root.value} MB`
parts.push(text(W - 12, H - 8, total, { size: 13, fill: INK, anchor: 'end' }))
parts.push(text(W - 12 - total.length * 7.2 - 6, H - 8, 'TOTAL', { anchor: 'end' }))
parts.push(text(12, H - 8, 'SAMPLE NODE_MODULES, MB'))
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: 'circle-pack',
title: 'Circle packing',
category: 'parts',
description: 'Installed packages packed inside their groups, each circle sized by disk space, with names inside where they fit and group totals on the outlines.',
element: ui => ui.Svg({ source: (source ??= draw()), alt: 'Circle packing of installed packages grouped by purpose and sized by disk space, sample data', width: W, height: H }),
}
Imports d3-hierarchy 3.1.2 (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).