Skip to content

Commit 724af4f

Browse files
feat(chartjs): implement flamegraph-basic (#8514)
## Implementation: `flamegraph-basic` - javascript/chartjs Implements the **javascript/chartjs** version of `flamegraph-basic`. **File:** `plots/flamegraph-basic/implementations/javascript/chartjs.js` **Parent Issue:** #4665 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/anyplot/actions/runs/27165720177)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com>
1 parent cfbe2d2 commit 724af4f

2 files changed

Lines changed: 542 additions & 0 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// anyplot.ai
2+
// flamegraph-basic: Flame Graph for Performance Profiling
3+
// Library: chartjs 4.4.7 | JavaScript 22.22.3
4+
// Quality: 93/100 | Created: 2026-06-08
5+
//# anyplot-orientation: landscape
6+
7+
const t = window.ANYPLOT_TOKENS;
8+
9+
// --- Data: simulated CPU profile of a web API request handler --------------
10+
// Tree of call stacks. `self` = samples spent in the function itself (not in
11+
// its children). A node's *total* = self + sum(children.total) and becomes the
12+
// width of its flame-graph bar. Children sit above the parent and fill it
13+
// left-to-right; any leftover width at depth+1 is the parent's `self` time.
14+
const profile = {
15+
name: "main", self: 1, children: [
16+
{ name: "runServer", self: 2, children: [
17+
{ name: "handleRequest", self: 4, children: [
18+
{ name: "parseHeaders", self: 3, children: [
19+
{ name: "decodeUtf8", self: 12, children: [] },
20+
{ name: "lowercaseKeys", self: 8, children: [] },
21+
{ name: "splitCookies", self: 6, children: [] },
22+
] },
23+
{ name: "routeRequest", self: 2, children: [
24+
{ name: "authMiddleware", self: 3, children: [
25+
{ name: "verifyToken", self: 4, children: [
26+
{ name: "parseJwt", self: 8, children: [] },
27+
{ name: "validateSig", self: 14, children: [] },
28+
] },
29+
{ name: "loadUser", self: 3, children: [
30+
{ name: "cacheGet", self: 4, children: [] },
31+
{ name: "dbFetch", self: 22, children: [] },
32+
] },
33+
] },
34+
{ name: "rateLimitCheck", self: 2, children: [
35+
{ name: "bucketLookup", self: 6, children: [] },
36+
{ name: "bucketUpdate", self: 8, children: [] },
37+
] },
38+
{ name: "dispatchHandler", self: 4, children: [
39+
{ name: "queryDB", self: 5, children: [
40+
{ name: "openConn", self: 10, children: [] },
41+
{ name: "executeQuery", self: 92, children: [] },
42+
{ name: "parseRows", self: 18, children: [] },
43+
{ name: "closeConn", self: 6, children: [] },
44+
] },
45+
{ name: "renderTemplate", self: 3, children: [
46+
{ name: "loadTemplate", self: 10, children: [] },
47+
{ name: "compileTemplate", self: 28, children: [] },
48+
{ name: "renderHTML", self: 24, children: [] },
49+
{ name: "escapeHTML", self: 12, children: [] },
50+
] },
51+
{ name: "serialize", self: 4, children: [
52+
{ name: "jsonStringify", self: 16, children: [] },
53+
{ name: "gzipCompress", self: 22, children: [] },
54+
] },
55+
] },
56+
] },
57+
{ name: "writeResponse", self: 3, children: [
58+
{ name: "setHeaders", self: 6, children: [] },
59+
{ name: "flushBuffer", self: 14, children: [] },
60+
] },
61+
] },
62+
{ name: "gcMinor", self: 6, children: [
63+
{ name: "markRefs", self: 10, children: [] },
64+
{ name: "sweepHeap", self: 14, children: [] },
65+
] },
66+
{ name: "logRequest", self: 2, children: [
67+
{ name: "formatLog", self: 4, children: [] },
68+
{ name: "writeLog", self: 8, children: [] },
69+
] },
70+
] },
71+
{ name: "backgroundJobs", self: 2, children: [
72+
{ name: "cronTick", self: 3, children: [
73+
{ name: "scanJobs", self: 6, children: [] },
74+
{ name: "claimJob", self: 4, children: [] },
75+
] },
76+
{ name: "workerLoop", self: 3, children: [
77+
{ name: "fetchJob", self: 8, children: [] },
78+
{ name: "runJob", self: 4, children: [
79+
{ name: "emailSend", self: 18, children: [] },
80+
{ name: "imageResize", self: 3, children: [
81+
{ name: "decodeImg", self: 12, children: [] },
82+
{ name: "resampleImg", self: 26, children: [] },
83+
{ name: "encodeImg", self: 14, children: [] },
84+
] },
85+
{ name: "dataExport", self: 18, children: [] },
86+
] },
87+
] },
88+
] },
89+
],
90+
};
91+
92+
// Flatten the tree into (depth, start, end, total, self) frames via DFS.
93+
const frames = [];
94+
let maxDepth = 0;
95+
function visit(node, depth, x) {
96+
if (depth > maxDepth) maxDepth = depth;
97+
let childX = x;
98+
let kidsTotal = 0;
99+
for (const child of node.children) {
100+
const ct = visit(child, depth + 1, childX);
101+
childX += ct;
102+
kidsTotal += ct;
103+
}
104+
const total = node.self + kidsTotal;
105+
frames.push({ name: node.name, depth, start: x, end: x + total, total, self: node.self });
106+
return total;
107+
}
108+
const totalSamples = visit(profile, 0, 0);
109+
110+
// --- Warm palette tiered by self-samples (hotness encoding) ----------------
111+
// Imprint's three warm anchors map to a self-time tier so the eye lands on
112+
// the actual hotspot (the matte-red frame) instead of color being decorative.
113+
// Dark text rides on the lighter amber/ochre tiers; light text on matte red.
114+
// Thresholds: low ≤10 (cool amber), medium ≤24 (ochre), high >24 (matte red).
115+
function colorFor(self) {
116+
if (self > 24) return { fill: "#AE3030", text: "#FFFDF6" };
117+
if (self > 10) return { fill: "#BD8233", text: "#1A1A17" };
118+
return { fill: "#DDCC77", text: "#1A1A17" };
119+
}
120+
121+
// --- Mount -----------------------------------------------------------------
122+
const canvas = document.createElement("canvas");
123+
document.getElementById("container").appendChild(canvas);
124+
125+
// --- Custom flame-graph renderer ------------------------------------------
126+
// Chart.js core has no flame-graph type, so we run a `type: 'bar'` chart for
127+
// its axes / title chrome and draw the per-frame rectangles ourselves in an
128+
// `afterDatasetsDraw` plugin. y-pixel positions are computed from chartArea
129+
// rather than the category scale so reverse / band alignment stay exact.
130+
const flamePlugin = {
131+
id: "flamegraph",
132+
afterDatasetsDraw(chart) {
133+
const { ctx, chartArea, scales: { x } } = chart;
134+
const rows = maxDepth + 1;
135+
const bandPx = (chartArea.bottom - chartArea.top) / rows;
136+
const gap = 2;
137+
const barH = Math.max(2, bandPx - gap);
138+
139+
ctx.save();
140+
ctx.font = "600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
141+
ctx.textBaseline = "middle";
142+
143+
for (const f of frames) {
144+
const xLeft = x.getPixelForValue(f.start);
145+
const xRight = x.getPixelForValue(f.end);
146+
// depth 0 at the bottom band; depth N at the top.
147+
const yCenter = chartArea.bottom - (f.depth + 0.5) * bandPx;
148+
const top = yCenter - barH / 2;
149+
const w = Math.max(1, xRight - xLeft - 1);
150+
151+
const { fill, text } = colorFor(f.self);
152+
ctx.fillStyle = fill;
153+
ctx.fillRect(xLeft, top, w, barH);
154+
// pageBg-coloured hairline separates adjacent siblings on both themes.
155+
ctx.strokeStyle = t.pageBg;
156+
ctx.lineWidth = 1;
157+
ctx.strokeRect(xLeft + 0.5, top + 0.5, w - 1, barH - 1);
158+
159+
if (w >= 60) {
160+
ctx.fillStyle = text;
161+
let label = f.name;
162+
const maxText = w - 12;
163+
if (ctx.measureText(label).width > maxText) {
164+
while (label.length > 2 && ctx.measureText(label + "…").width > maxText) {
165+
label = label.slice(0, -1);
166+
}
167+
label = label + "…";
168+
}
169+
ctx.fillText(label, xLeft + 6, yCenter);
170+
}
171+
}
172+
ctx.restore();
173+
},
174+
};
175+
176+
// --- Chart -----------------------------------------------------------------
177+
const depthLabels = Array.from({ length: maxDepth + 1 }, (_, i) => String(i));
178+
179+
new Chart(canvas, {
180+
type: "bar",
181+
data: {
182+
labels: depthLabels,
183+
datasets: [{
184+
label: "Stack frames",
185+
data: depthLabels.map(() => 0), // placeholder; real bars are drawn by the plugin
186+
backgroundColor: "rgba(0,0,0,0)",
187+
borderColor: "rgba(0,0,0,0)",
188+
}],
189+
},
190+
options: {
191+
indexAxis: "y",
192+
responsive: true,
193+
maintainAspectRatio: false,
194+
animation: false,
195+
layout: { padding: { left: 8, right: 18, top: 4, bottom: 8 } },
196+
plugins: {
197+
title: {
198+
display: true,
199+
text: "flamegraph-basic · javascript · chartjs · anyplot.ai",
200+
color: t.ink,
201+
font: { size: 22, weight: "600" },
202+
padding: { top: 4, bottom: 6 },
203+
},
204+
subtitle: {
205+
display: true,
206+
text: "Simulated CPU profile · bar width = samples · color = self-time tier · horizontal order is arbitrary, not temporal",
207+
color: t.inkSoft,
208+
font: { size: 14, style: "italic" },
209+
padding: { bottom: 16 },
210+
},
211+
legend: { display: false },
212+
tooltip: { enabled: false },
213+
},
214+
scales: {
215+
x: {
216+
type: "linear",
217+
min: 0,
218+
max: totalSamples,
219+
title: {
220+
display: true,
221+
text: "Samples",
222+
color: t.ink,
223+
font: { size: 14 },
224+
padding: { top: 8 },
225+
},
226+
ticks: { color: t.inkSoft, font: { size: 12 } },
227+
grid: { color: t.grid, drawTicks: false },
228+
border: { color: t.grid },
229+
},
230+
y: {
231+
type: "category",
232+
labels: depthLabels,
233+
reverse: true, // depth 0 (root) at the bottom
234+
title: {
235+
display: true,
236+
text: "Call stack depth (root → leaf)",
237+
color: t.ink,
238+
font: { size: 14 },
239+
},
240+
// Hide numeric tick labels — visible bar stacking + axis title already
241+
// convey depth; numeric ticks would just add chrome noise.
242+
ticks: { display: false },
243+
grid: { display: false, drawTicks: false },
244+
border: { color: t.grid },
245+
},
246+
},
247+
},
248+
plugins: [flamePlugin],
249+
});

0 commit comments

Comments
 (0)