ModsCode / Gallery / Designs / Sunburst chart
Sunburst chart
Repository size three folders deep as rings with even 2px gaps, names only where they fit, the total in the centre and one large file marked.


hooks/designs/sunburst.mjs
// sunburst: where a 412 MB repository's bytes sit, three folder levels deep, in candle's look. The
// rings come from d3-hierarchy's partition() and the wedges from d3-shape's arc() (both ISC, Mike
// Bostock). Each ring is one depth and takes one quiet ink step: the area step 0.25, then the track
// 0.14 and the faint area 0.08 (0.42 at the centre was tried and the inner ring outshouted candle, as
// streamgraph also found for stage-filling areas); a 2px gap on
// each ring's mid-radius, as donut measures it, splits neighbours and a 2px gap splits the rings. A
// name is drawn only when its whole box fits inside its wedge, so short wedges stay blank rather than
// clipped. The sizes add up: .git 168, assets 121, dist 58, vendor 31, src 19, tests 9, docs 6 = 412 MB.
// The one Claude orange wedge is assets/video/demo.mp4 at 52 MB, a binary that belongs outside git, and the
// list on the right carries every top-level size. Sample data only.
import { hierarchy, partition, arc } 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 CX = 144
const CY = 144
// Inner and outer radius of each depth; the hole holds the total
const RINGS = [null, [40, 84], [86, 114], [116, 134]]
const STEP = [0, 0.25, 0.14, 0.08]
const GAP = 2 // px between two wedges, along the ring's mid-radius
const LX = 292 // the list's left edge
const ROW = 20
const f = n => n.toFixed(1)
// Sizes in MB on disk; leaves carry size, folders add their children up
const REPO = {
name: 'repo',
children: [
{ name: '.git', children: [
{ name: 'objects', children: [{ name: 'pack', size: 151 }, { name: 'loose', size: 11 }] },
{ name: 'refs', size: 6 },
] },
{ name: 'assets', children: [
{ name: 'video', children: [{ name: 'demo.mp4', size: 52, accent: true }, { name: 'intro.webm', size: 22 }] },
{ name: 'images', children: [{ name: 'shots', size: 21 }, { name: 'og', size: 7 }, { name: 'icons', size: 5 }] },
{ name: 'fonts', children: [{ name: 'inter', size: 9 }, { name: 'mono', size: 5 }] },
] },
{ name: 'dist', children: [
{ name: 'site', children: [{ name: 'js', size: 21 }, { name: 'html', size: 11 }, { name: 'css', size: 5 }] },
{ name: 'worker', children: [{ name: 'bundle', size: 16 }, { name: 'maps', size: 5 }] },
] },
{ name: 'vendor', children: [{ name: 'd3', size: 12 }, { name: 'pdf', size: 11 }, { name: 'katex', size: 8 }] },
{ name: 'src', children: [{ name: 'designs', size: 8 }, { name: 'site', size: 6 }, { name: 'mods', size: 5 }] },
{ name: 'tests', children: [{ name: 'fixtures', size: 6 }, { name: 'snaps', size: 3 }] },
{ name: 'docs', size: 6 },
],
}
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>`
const rmid = d => (RINGS[d.depth][0] + RINGS[d.depth][1]) / 2
// A label of w x h centred on (x, y) relative to the centre and turned by rot fits when its corners
// and edge midpoints all sit inside the wedge with a little air; d3's angles run clockwise from 12
function fits(d, x, y, w, h, rot) {
const [r0, r1] = RINGS[d.depth]
const c = Math.cos(rot)
const s = Math.sin(rot)
for (const [dx, dy] of [[-1, -1], [0, -1], [1, -1], [-1, 1], [0, 1], [1, 1], [-1, 0], [1, 0]]) {
const lx = (dx * w) / 2
const ly = (dy * h) / 2
const px = x + lx * c - ly * s
const py = y + lx * s + ly * c
const r = Math.hypot(px, py)
let a = Math.atan2(px, -py)
if (a < 0) a += 2 * Math.PI
const air = (GAP + 2) / r
if (r < r0 + 1.5 || r > r1 - 1.5 || a < d.x0 + air || a > d.x1 - air) return false
}
return true
}
function draw() {
const root = hierarchy(REPO).sum(d => d.size || 0).sort((a, b) => b.value - a.value)
partition().size([2 * Math.PI, 1])(root)
const wedge = arc()
.startAngle(d => d.x0)
.endAngle(d => d.x1)
.innerRadius(d => RINGS[d.depth][0])
.outerRadius(d => RINGS[d.depth][1])
.padAngle(d => GAP / rmid(d))
.padRadius(rmid)
.digits(1)
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
const nodes = root.descendants().filter(d => d.depth > 0)
const shapes = []
const labels = []
for (const d of nodes) {
const fill = d.data.accent ? `fill="${CLAUDE}" fill-opacity="0.55"` : `fill="${INK}" fill-opacity="${STEP[d.depth]}"`
shapes.push(`<path d="${wedge(d)}" ${fill}/>`)
// Names run along the ring and turn over in the lower half so they never read upside down.
// About 5.6px a character at 10px with 10% slack; the box is the cap height plus a little.
const [x, y] = wedge.centroid(d)
const deg = ((d.x0 + d.x1) / 2) * (180 / Math.PI)
const rot = deg < 90 || deg > 270 ? deg : deg - 180
const w = d.data.name.length * 5.6 * 1.1
if (fits(d, x, y, w, 9, (rot * Math.PI) / 180)) {
labels.push(`<text transform="translate(${f(x)} ${f(y)}) rotate(${f(rot)})" y="3.5" text-anchor="middle" font-size="10" font-family="${FONT}" fill="${INK}">${d.data.name}</text>`)
}
}
parts.push(`<g transform="translate(${CX} ${CY})">${shapes.join('')}${labels.join('')}</g>`)
parts.push(text(CX, CY + 2, `${root.value} MB`, { size: 14, fill: INK, anchor: 'middle' }))
parts.push(text(CX, CY + 16, 'TOTAL', { anchor: 'middle' }))
// Every top-level size, in ring order from 12 o'clock, then the one file to notice
const top = root.children
const y0 = CY - ((top.length - 1) * ROW) / 2 - 22
parts.push(text(LX, y0 - 16, 'FOLDER'))
parts.push(text(W - 12, y0 - 16, 'MB', { anchor: 'end' }))
top.forEach((d, i) => {
const y = y0 + i * ROW
parts.push(text(LX, y + 4, d.data.name, { size: 10, fill: INK }))
parts.push(text(W - 12, y + 4, String(d.value), { size: 11, fill: INK, anchor: 'end' }))
})
const big = root.leaves().find(d => d.data.accent)
const by = y0 + top.length * ROW + 12
parts.push(`<line x1="${LX}" x2="${W - 12}" y1="${by - 14}" y2="${by - 14}" stroke="${INK}" stroke-opacity="0.08"/>`)
parts.push(text(LX, by, 'LARGEST FILE'))
parts.push(text(LX, by + 16, big.data.name, { size: 10, fill: CLAUDE }))
parts.push(text(W - 12, by + 16, String(big.value), { size: 11, fill: CLAUDE, anchor: 'end' }))
parts.push(text(12, H - 8, 'SAMPLE REPOSITORY SIZE, 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: 'sunburst',
title: 'Sunburst chart',
category: 'parts',
description: 'Repository size three folders deep as rings with even 2px gaps, names only where they fit, the total in the centre and one large file marked.',
element: ui => ui.Svg({ source: (source ??= draw()), alt: 'Sunburst of a repository by folder size over three levels, total in the centre, sample data', width: W, height: H }),
}
Imports d3-hierarchy 3.1.2 (ISC), d3-shape 3.2.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).