|
| 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