|
| 1 | +// anyplot.ai |
| 2 | +// circlepacking-basic: Circle Packing Chart |
| 3 | +// Library: highcharts 12.6.0 | JavaScript 22.23.2 |
| 4 | +// Quality: pending | Created: 2026-09-02 |
| 5 | +//# anyplot-orientation: square |
| 6 | + |
| 7 | +// Highcharts' packed-bubble / circle-packing series lives in the |
| 8 | +// highcharts-more add-on module, which is not vendored here — only the core |
| 9 | +// bundle (with its SVGRenderer) is loaded. So the hierarchy below is packed |
| 10 | +// with a small deterministic relaxation algorithm (index-seeded, no RNG) and |
| 11 | +// drawn natively with `chart.renderer`: a root ring, one ring per directory, |
| 12 | +// and solid circles for the files inside. No other charting library is used. |
| 13 | + |
| 14 | +const t = window.ANYPLOT_TOKENS; |
| 15 | + |
| 16 | +// --- Data: a small repository's directory sizes (KB), 2 levels deep ---------- |
| 17 | +const CATEGORIES = [ |
| 18 | + { |
| 19 | + id: "src", |
| 20 | + label: "src/", |
| 21 | + children: [ |
| 22 | + { id: "src-components", label: "components/", value: 480 }, |
| 23 | + { id: "src-api", label: "api.js", value: 340 }, |
| 24 | + { id: "src-store", label: "store.js", value: 210 }, |
| 25 | + { id: "src-utils", label: "utils.js", value: 120 }, |
| 26 | + { id: "src-hooks", label: "hooks.js", value: 95 }, |
| 27 | + { id: "src-styles", label: "styles.css", value: 70 }, |
| 28 | + { id: "src-types", label: "types.d.ts", value: 55 }, |
| 29 | + ], |
| 30 | + }, |
| 31 | + { |
| 32 | + id: "tests", |
| 33 | + label: "tests/", |
| 34 | + children: [ |
| 35 | + { id: "tests-unit", label: "unit/", value: 200 }, |
| 36 | + { id: "tests-integration", label: "integration/", value: 150 }, |
| 37 | + { id: "tests-e2e", label: "e2e/", value: 90 }, |
| 38 | + { id: "tests-fixtures", label: "fixtures/", value: 60 }, |
| 39 | + { id: "tests-mocks", label: "mocks/", value: 45 }, |
| 40 | + ], |
| 41 | + }, |
| 42 | + { |
| 43 | + id: "docs", |
| 44 | + label: "docs/", |
| 45 | + children: [ |
| 46 | + { id: "docs-api", label: "api-reference.md", value: 130 }, |
| 47 | + { id: "docs-guide", label: "guide.md", value: 85 }, |
| 48 | + { id: "docs-changelog", label: "changelog.md", value: 50 }, |
| 49 | + { id: "docs-readme", label: "readme.md", value: 45 }, |
| 50 | + ], |
| 51 | + }, |
| 52 | + { |
| 53 | + id: "assets", |
| 54 | + label: "assets/", |
| 55 | + children: [ |
| 56 | + { id: "assets-images", label: "images/", value: 380 }, |
| 57 | + { id: "assets-fonts", label: "fonts/", value: 190 }, |
| 58 | + { id: "assets-videos", label: "videos/", value: 140 }, |
| 59 | + { id: "assets-icons", label: "icons/", value: 95 }, |
| 60 | + { id: "assets-logo", label: "logo.svg", value: 40 }, |
| 61 | + ], |
| 62 | + }, |
| 63 | + { |
| 64 | + id: "build", |
| 65 | + label: "build/", |
| 66 | + children: [ |
| 67 | + { id: "build-bundle", label: "bundle.js", value: 600 }, |
| 68 | + { id: "build-vendor", label: "vendor.js", value: 420 }, |
| 69 | + { id: "build-maps", label: "sourcemaps/", value: 210 }, |
| 70 | + { id: "build-manifest", label: "manifest.json", value: 45 }, |
| 71 | + ], |
| 72 | + }, |
| 73 | +]; |
| 74 | + |
| 75 | +// Directories are abstract categories, so the Imprint palette is used in |
| 76 | +// canonical order — src = brand green (palette[0]). |
| 77 | +const categoryColor = (i) => t.palette[i % t.palette.length]; |
| 78 | + |
| 79 | +const formatSize = (kb) => (kb >= 1000 ? `${(kb / 1000).toFixed(1)} MB` : `${kb} KB`); |
| 80 | + |
| 81 | +function hexToRgba(hex, alpha) { |
| 82 | + const r = parseInt(hex.slice(1, 3), 16); |
| 83 | + const g = parseInt(hex.slice(3, 5), 16); |
| 84 | + const b = parseInt(hex.slice(5, 7), 16); |
| 85 | + return `rgba(${r}, ${g}, ${b}, ${alpha})`; |
| 86 | +} |
| 87 | + |
| 88 | +// Pick readable label ink by the fill's own luminance rather than the active |
| 89 | +// theme — a leaf circle's colour is the same in light and dark mode, so the |
| 90 | +// text riding on top needs the ink value with contrast to THAT colour, not to |
| 91 | +// the page. #1A1A17 / #F0EFE8 are exactly the light/dark INK tokens reused |
| 92 | +// for this purpose, never a new custom hex. |
| 93 | +function contrastInk(hex) { |
| 94 | + const r = parseInt(hex.slice(1, 3), 16) / 255; |
| 95 | + const g = parseInt(hex.slice(3, 5), 16) / 255; |
| 96 | + const b = parseInt(hex.slice(5, 7), 16) / 255; |
| 97 | + const lin = (v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); |
| 98 | + const luminance = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); |
| 99 | + return luminance > 0.42 ? "#1A1A17" : "#F0EFE8"; |
| 100 | +} |
| 101 | + |
| 102 | +// --- Circle packing: a small deterministic relaxation, applied per level ----- |
| 103 | +// Seed circles on a ring (index-based angle, no RNG), then repeatedly resolve |
| 104 | +// overlaps and pull toward the centroid until the group settles into a tight, |
| 105 | +// non-overlapping cluster. The same routine packs files within a directory |
| 106 | +// and directories within the repository — only the input items change. |
| 107 | +function packCircles(items, gap) { |
| 108 | + if (items.length === 0) return { placed: [], boundingRadius: 0 }; |
| 109 | + if (items.length === 1) { |
| 110 | + return { placed: [{ id: items[0].id, x: 0, y: 0, r: items[0].r }], boundingRadius: items[0].r }; |
| 111 | + } |
| 112 | + const n = items.length; |
| 113 | + const nodes = items.map((it, i) => { |
| 114 | + const theta = (i / n) * Math.PI * 2; |
| 115 | + const seedR = it.r * 1.6 + i * 4; |
| 116 | + return { id: it.id, r: it.r, x: seedR * Math.cos(theta), y: seedR * Math.sin(theta) }; |
| 117 | + }); |
| 118 | + for (let iter = 0; iter < 260; iter += 1) { |
| 119 | + nodes.forEach((a) => { |
| 120 | + a.x -= a.x * 0.02; |
| 121 | + a.y -= a.y * 0.02; |
| 122 | + }); |
| 123 | + for (let i = 0; i < n; i += 1) { |
| 124 | + for (let j = i + 1; j < n; j += 1) { |
| 125 | + const a = nodes[i]; |
| 126 | + const b = nodes[j]; |
| 127 | + let dx = b.x - a.x; |
| 128 | + let dy = b.y - a.y; |
| 129 | + let dist = Math.sqrt(dx * dx + dy * dy); |
| 130 | + const minDist = a.r + b.r + gap; |
| 131 | + if (dist < 1e-6) { |
| 132 | + dx = 0.01 * (i + 1); |
| 133 | + dy = 0.01 * (j + 1); |
| 134 | + dist = Math.sqrt(dx * dx + dy * dy); |
| 135 | + } |
| 136 | + if (dist < minDist) { |
| 137 | + const overlap = (minDist - dist) / 2; |
| 138 | + const ux = dx / dist; |
| 139 | + const uy = dy / dist; |
| 140 | + a.x -= ux * overlap; |
| 141 | + a.y -= uy * overlap; |
| 142 | + b.x += ux * overlap; |
| 143 | + b.y += uy * overlap; |
| 144 | + } |
| 145 | + } |
| 146 | + } |
| 147 | + } |
| 148 | + const minX = Math.min(...nodes.map((nd) => nd.x - nd.r)); |
| 149 | + const maxX = Math.max(...nodes.map((nd) => nd.x + nd.r)); |
| 150 | + const minY = Math.min(...nodes.map((nd) => nd.y - nd.r)); |
| 151 | + const maxY = Math.max(...nodes.map((nd) => nd.y + nd.r)); |
| 152 | + const ox = (minX + maxX) / 2; |
| 153 | + const oy = (minY + maxY) / 2; |
| 154 | + nodes.forEach((nd) => { |
| 155 | + nd.x -= ox; |
| 156 | + nd.y -= oy; |
| 157 | + }); |
| 158 | + let boundingRadius = 0; |
| 159 | + nodes.forEach((nd) => { |
| 160 | + const d = Math.sqrt(nd.x * nd.x + nd.y * nd.y) + nd.r; |
| 161 | + if (d > boundingRadius) boundingRadius = d; |
| 162 | + }); |
| 163 | + return { placed: nodes, boundingRadius }; |
| 164 | +} |
| 165 | + |
| 166 | +// --- Layout: leaves packed within each directory, directories at the root ---- |
| 167 | +// Circle area, not radius, encodes size: radius = sqrt(value) in abstract |
| 168 | +// units; everything is rescaled to pixels once the final plot area is known. |
| 169 | +const LEAF_GAP = 1.4; |
| 170 | +const RING_PADDING = 6.5; |
| 171 | +const CATEGORY_GAP = 7; |
| 172 | +const ROOT_PADDING = 7; |
| 173 | + |
| 174 | +const leafRadius = (value) => Math.sqrt(value); |
| 175 | + |
| 176 | +const categoryLayouts = CATEGORIES.map((cat) => { |
| 177 | + const items = cat.children.map((ch) => ({ id: ch.id, r: leafRadius(ch.value) })); |
| 178 | + const { placed, boundingRadius } = packCircles(items, LEAF_GAP); |
| 179 | + const totalValue = cat.children.reduce((s, ch) => s + ch.value, 0); |
| 180 | + const leaves = placed.map((p) => { |
| 181 | + const src = cat.children.find((ch) => ch.id === p.id); |
| 182 | + return { label: src.label, value: src.value, x: p.x, y: p.y, r: p.r }; |
| 183 | + }); |
| 184 | + return { id: cat.id, label: cat.label, r: boundingRadius + RING_PADDING, totalValue, leaves }; |
| 185 | +}); |
| 186 | + |
| 187 | +const { placed: groupPlaced, boundingRadius: groupsBoundingRadius } = packCircles( |
| 188 | + categoryLayouts.map((cl) => ({ id: cl.id, r: cl.r })), |
| 189 | + CATEGORY_GAP, |
| 190 | +); |
| 191 | +const ROOT_R = groupsBoundingRadius + ROOT_PADDING; |
| 192 | +const TOTAL_VALUE = categoryLayouts.reduce((s, cl) => s + cl.totalValue, 0); |
| 193 | + |
| 194 | +// --- Chart shell (no series — every circle is drawn with the renderer) ------- |
| 195 | +const chart = Highcharts.chart("container", { |
| 196 | + chart: { |
| 197 | + backgroundColor: "transparent", |
| 198 | + animation: false, |
| 199 | + style: { fontFamily: "inherit" }, |
| 200 | + marginTop: 100, |
| 201 | + marginBottom: 40, |
| 202 | + marginLeft: 40, |
| 203 | + marginRight: 40, |
| 204 | + }, |
| 205 | + credits: { enabled: false }, |
| 206 | + colors: t.palette, |
| 207 | + title: { |
| 208 | + text: "circlepacking-basic · javascript · highcharts · anyplot.ai", |
| 209 | + style: { color: t.ink, fontSize: "22px", fontWeight: "600" }, |
| 210 | + }, |
| 211 | + subtitle: { |
| 212 | + text: "Repository directory sizes — circle area = file/folder size (KB), colour = directory", |
| 213 | + style: { color: t.inkSoft, fontSize: "14px" }, |
| 214 | + }, |
| 215 | + xAxis: { visible: false }, |
| 216 | + yAxis: { visible: false }, |
| 217 | + legend: { enabled: false }, |
| 218 | + plotOptions: { series: { animation: false } }, |
| 219 | + series: [], |
| 220 | +}); |
| 221 | + |
| 222 | +const cx = chart.plotLeft + chart.plotWidth / 2; |
| 223 | +const cy = chart.plotTop + chart.plotHeight / 2; |
| 224 | +const radiusMax = Math.min(chart.plotWidth, chart.plotHeight) / 2; |
| 225 | +const finalScale = (radiusMax - 12) / ROOT_R; |
| 226 | +const px = (x) => cx + x * finalScale; |
| 227 | +const py = (y) => cy + y * finalScale; |
| 228 | +const pr = (r) => r * finalScale; |
| 229 | + |
| 230 | +function addTitle(el, text) { |
| 231 | + const title = document.createElementNS("http://www.w3.org/2000/svg", "title"); |
| 232 | + title.textContent = text; |
| 233 | + el.element.appendChild(title); |
| 234 | +} |
| 235 | + |
| 236 | +const g = chart.renderer.g("circle-packing").add(); |
| 237 | + |
| 238 | +const rootCircle = chart.renderer |
| 239 | + .circle(px(0), py(0), pr(ROOT_R)) |
| 240 | + .attr({ fill: "transparent", stroke: t.grid, "stroke-width": 1.5 }) |
| 241 | + .add(g); |
| 242 | +addTitle(rootCircle, `repository — ${formatSize(TOTAL_VALUE)} total`); |
| 243 | + |
| 244 | +categoryLayouts.forEach((cl, ci) => { |
| 245 | + const gp = groupPlaced.find((p) => p.id === cl.id); |
| 246 | + const color = categoryColor(ci); |
| 247 | + const ringX = px(gp.x); |
| 248 | + const ringY = py(gp.y); |
| 249 | + const ringR = pr(cl.r); |
| 250 | + |
| 251 | + const ring = chart.renderer |
| 252 | + .circle(ringX, ringY, ringR) |
| 253 | + .attr({ fill: hexToRgba(color, 0.1), stroke: color, "stroke-width": 2.5 }) |
| 254 | + .add(g); |
| 255 | + addTitle(ring, `${cl.label} — ${formatSize(cl.totalValue)} total`); |
| 256 | + |
| 257 | + cl.leaves.forEach((lf) => { |
| 258 | + const leafX = px(gp.x + lf.x); |
| 259 | + const leafY = py(gp.y + lf.y); |
| 260 | + const leafR = pr(lf.r); |
| 261 | + |
| 262 | + const leaf = chart.renderer |
| 263 | + .circle(leafX, leafY, leafR) |
| 264 | + .attr({ fill: color, stroke: t.pageBg, "stroke-width": 1.5 }) |
| 265 | + .add(g); |
| 266 | + addTitle(leaf, `${cl.label}${lf.label} — ${formatSize(lf.value)}`); |
| 267 | + |
| 268 | + if (leafR >= 24) { |
| 269 | + const fontSize = Math.max(9, Math.min(12, Math.round(leafR * 0.24))); |
| 270 | + const maxChars = Math.max(3, Math.floor((leafR * 1.7) / (fontSize * 0.58))); |
| 271 | + const text = lf.label.length > maxChars ? `${lf.label.slice(0, maxChars - 1)}…` : lf.label; |
| 272 | + chart.renderer |
| 273 | + .text(text, leafX, leafY + fontSize * 0.35) |
| 274 | + .attr({ align: "center" }) |
| 275 | + .css({ color: contrastInk(color), fontSize: `${fontSize}px`, fontWeight: "500" }) |
| 276 | + .add(g); |
| 277 | + } |
| 278 | + }); |
| 279 | + |
| 280 | + if (ringR >= 55) { |
| 281 | + const fontSize = Math.max(12, Math.min(16, Math.round(ringR * 0.1))); |
| 282 | + chart.renderer |
| 283 | + .text(`${cl.label} · ${formatSize(cl.totalValue)}`, ringX, ringY - ringR + fontSize + 6) |
| 284 | + .attr({ align: "center" }) |
| 285 | + .css({ color: t.ink, fontSize: `${fontSize}px`, fontWeight: "600" }) |
| 286 | + .add(g); |
| 287 | + } |
| 288 | +}); |
| 289 | + |
| 290 | +// Static-frame timing signal for the harness. |
| 291 | +window.__anyplotReady = true; |
0 commit comments