Skip to content

Commit bd26c5c

Browse files
feat(highcharts): implement circlepacking-basic
1 parent 9e683ff commit bd26c5c

1 file changed

Lines changed: 291 additions & 0 deletions

File tree

  • plots/circlepacking-basic/implementations/javascript
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
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

Comments
 (0)