ModsCode / Gallery / Designs / Sankey diagram
Sankey diagram
Where tokens go: each kind of token flows into the tools that spent it, link width by volume, with the largest flow in Claude orange.


hooks/designs/sankey.mjs
// sankey: where a day's tokens go, from four kinds of token (input, cache read, cache write, output) to
// the five things the model spent them on (Read, Edit, Bash, Search, Reply). Nine nodes and fourteen
// links in thousands of tokens: cache read is the big source (1,840K of 2,660K), as it is in a long
// Claude Code session, and its flow into Read (820K, re-reading files) is the one Claude orange flow, the thing
// to notice. The layout is d3-sankey (BSD-3-Clause, Mike Bostock) with its sankeyLinkHorizontal path;
// node order is fixed (nodeSort null) so the kinds keep their reading order. Links are strokes as wide
// as their value at the 14% track ink, so where they cross they build up instead of hiding each other
// (at 25% the 520K cache read to Edit link was one bright slab beside candle); nodes are 6px bars at
// candle's 55% body. The label columns sit outside the link area on both sides, the name at
// 10px and the value under it at 9px, so no text lies over a flow. Sample data only.
import { sankey, sankeyLinkHorizontal } from '../vendor/d3.mjs'
const W = 380
const H = 260
const STAGE = '#FAF9F5'
const INK = '#141413'
const MUTED = '#73726C'
const CLAUDE = '#D77757'
const FONT = "'Inter Tight','Segoe UI',system-ui,sans-serif"
const PLOT = { left: 88, right: 318, top: 32, bottom: 226 } // the label columns sit outside these edges
const f = n => n.toFixed(1)
// Token kinds first, then tools; the order here is the order on the stage
const NODES = ['Input', 'Cache read', 'Cache write', 'Output', 'Read', 'Edit', 'Bash', 'Search', 'Reply']
// [from, to, thousands of tokens]
const LINKS = [
['Input', 'Read', 90], ['Input', 'Search', 70], ['Input', 'Bash', 60], ['Input', 'Edit', 40],
['Cache read', 'Read', 820], ['Cache read', 'Edit', 520], ['Cache read', 'Search', 260], ['Cache read', 'Reply', 240],
['Cache write', 'Read', 210], ['Cache write', 'Bash', 130], ['Cache write', 'Edit', 80],
['Output', 'Edit', 70], ['Output', 'Reply', 50], ['Output', 'Bash', 20],
]
const NOTICE = ['Cache read', 'Read'] // the one Claude orange flow
// The alt names the nodes from the data, so it stays true when the sample changes
const KINDS = NODES.filter(n => LINKS.some(l => l[0] === n))
const TOOLS = NODES.filter(n => !KINDS.includes(n))
const list = a => (a.length > 1 ? `${a.slice(0, -1).join(', ')} and ${a.at(-1)}` : a.join(''))
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
const text = (x, y, s, { size = 9, fill = MUTED, anchor = 'start', font = FONT } = {}) =>
`<text x="${f(x)}" y="${f(y)}"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`} font-size="${size}" font-family="${font}" fill="${fill}">${esc(s)}</text>`
const kTokens = v => (v >= 1000 ? `${(v / 1000).toFixed(2)}M` : `${Math.round(v)}K`)
function draw() {
const graph = sankey()
.nodeId(d => d.name)
.nodeWidth(6)
.nodePadding(16)
.nodeSort(null)
.extent([[PLOT.left, PLOT.top], [PLOT.right, PLOT.bottom]])({
nodes: NODES.map(name => ({ name })),
links: LINKS.map(([source, target, value]) => ({ source, target, value })),
})
const link = sankeyLinkHorizontal()
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
parts.push(text(PLOT.left - 6, 18, 'TOKEN KIND', { anchor: 'end' }))
parts.push(text(PLOT.right + 6, 18, 'TOOL'))
const isNotice = l => l.source.name === NOTICE[0] && l.target.name === NOTICE[1]
// The Claude orange flow goes last, so it lies on top of the ink flows it crosses
for (const l of [...graph.links].sort((a, b) => isNotice(a) - isNotice(b))) {
const look = isNotice(l) ? `stroke="${CLAUDE}" stroke-opacity="0.55"` : `stroke="${INK}" stroke-opacity="0.14"`
parts.push(`<path d="${link.digits(1)(l)}" fill="none" ${look} stroke-width="${f(Math.max(1, l.width))}"/>`)
}
for (const n of graph.nodes) {
const h = Math.max(1, n.y1 - n.y0)
parts.push(`<rect x="${f(n.x0)}" y="${f(n.y0)}" width="${f(n.x1 - n.x0)}" height="${f(h)}" fill="${INK}" fill-opacity="0.55"/>`)
const mid = (n.y0 + n.y1) / 2
const left = n.depth === 0
const x = left ? n.x0 - 6 : n.x1 + 6
const anchor = left ? 'end' : 'start'
parts.push(text(x, mid - 1, n.name, { size: 10, fill: INK, anchor }))
parts.push(text(x, mid + 10, kTokens(n.value), { anchor }))
}
// The Claude orange flow carries its own value where it is still level, just after it leaves its source
const hot = graph.links.find(isNotice)
parts.push(text(hot.source.x1 + 8, hot.y0 + 3, kTokens(hot.value), { fill: INK, size: 10 }))
const total = graph.nodes.filter(n => n.depth === 0).reduce((s, n) => s + n.value, 0)
parts.push(text(W - 12, H - 8, `TOTAL ${kTokens(total)}`, { anchor: 'end' }))
parts.push(text(12, H - 8, 'SAMPLE TOKENS BY KIND AND TOOL'))
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: 'sankey',
title: 'Sankey diagram',
category: 'flow',
description: 'Where tokens go: each kind of token flows into the tools that spent it, link width by volume, with the largest flow in Claude orange.',
element: ui => ui.Svg({ source: (source ??= draw()), alt: `Sankey diagram of tokens flowing from ${list(KINDS.map(k => k.toLowerCase()))} into ${list(TOOLS)}, sample data`, width: W, height: H }),
}
Imports d3-sankey 0.12.3 (BSD-3-Clause), 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).