slige-mirror/web/public/src/flamegraph.ts

208 lines
6.9 KiB
TypeScript

import * as data from "./data.ts";
export function loadFlameGraph(
flameGraphData: data.FlameGraphNode,
fnNames: data.FlameGraphFnNames,
flameGraphDiv: HTMLDivElement,
) {
flameGraphDiv.innerHTML = `
<div id="fg-background">
<div id="canvas-div">
<canvas id="flame-graph-canvas"></canvas>
<span id="flame-graph-tooltip" hidden></span>
</div>
</div>
<div id="toolbar">
<button id="flame-graph-reset">Reset</button>
</div>
`;
const canvas = document.querySelector<HTMLCanvasElement>(
"#flame-graph-canvas",
)!;
const resetButton = document.querySelector<HTMLButtonElement>(
"#flame-graph-reset",
)!;
canvas.width = 1000;
canvas.height = 500;
const fnNameFont = "600 14px 'Roboto Mono'";
const ctx = canvas.getContext("2d")!;
ctx.font = fnNameFont;
type CalcNode = {
x: number;
y: number;
w: number;
h: number;
title: string;
percent: string;
fgNode: data.FlameGraphNode;
};
function calculateNodeRects(
nodes: CalcNode[],
node: data.FlameGraphNode,
depth: number,
totalAcc: data.FlameGraphNode["acc"],
offsetAcc: data.FlameGraphNode["acc"],
) {
const x = (offsetAcc / totalAcc) * canvas.width;
const y = canvas.height - 30 * depth - 30;
const w = ((node.acc + 1) / totalAcc) * canvas.width;
const h = 30;
const title = node.fn == 0
? "<program>"
: fnNames[node.fn] ?? "<unknown>";
const percent = `${(node.acc / totalAcc * 100).toFixed(1)}%`;
nodes.push({ x, y, w, h, title, percent, fgNode: node });
const totalChildrenAcc = node.children.reduce(
(acc, child) => acc + child.acc,
0,
);
let newOffsetAcc = offsetAcc + (node.acc - totalChildrenAcc) / 2;
for (const child of node.children) {
calculateNodeRects(nodes, child, depth + 1, totalAcc, newOffsetAcc);
newOffsetAcc += child.acc;
}
}
function drawTextCanvas(node: CalcNode): HTMLCanvasElement {
const { w, h, title } = node;
const textCanvas = document.createElement("canvas");
textCanvas.width = Math.max(w - 8, 1);
textCanvas.height = h;
const textCtx = textCanvas.getContext("2d")!;
textCtx.font = fnNameFont;
textCtx.fillStyle = "black";
textCtx.fillText(
title,
((w - 10) / 2 - ctx.measureText(title).width / 2) + 5 - 4,
20,
);
return textCanvas;
}
function renderNodes(nodes: CalcNode[], hoverFnId?: number) {
for (const node of nodes) {
const { x, y, w, h } = node;
ctx.fillStyle = "rgb(227, 178, 60)";
ctx.fillRect(x + 2, y + 2, w - 4, h - 4);
const textCanvas = drawTextCanvas(node);
ctx.drawImage(textCanvas, x + 4, y);
const edgePadding = 4;
const edgeWidth = 8;
const leftGradient = ctx.createLinearGradient(
x + 2 + edgePadding,
0,
x + 2 + edgeWidth,
0,
);
leftGradient.addColorStop(1, "rgba(227, 178, 60, 0.0)");
leftGradient.addColorStop(0, "rgba(227, 178, 60, 1.0)");
ctx.fillStyle = leftGradient;
ctx.fillRect(x + 2, y + 2, Math.min(edgeWidth, (w - 4) / 2), h - 4);
const rightGradient = ctx.createLinearGradient(
x + w - 2 - edgeWidth,
0,
x + w - 2 - edgePadding,
0,
);
rightGradient.addColorStop(0, "rgba(227, 178, 60, 0.0)");
rightGradient.addColorStop(1, "rgba(227, 178, 60, 1.0)");
ctx.fillStyle = rightGradient;
ctx.fillRect(
x + w - 2 - Math.min(edgeWidth, (w - 4) / 2),
y + 2,
Math.min(edgeWidth, (w - 4) / 2),
h - 4,
);
if (hoverFnId === node.fgNode.fn) {
ctx.strokeStyle = canvas.style.backgroundColor;
ctx.lineWidth = 1;
ctx.strokeRect(x + 2, y + 2, w - 4, h - 4);
}
}
const tooltip = document.getElementById("flame-graph-tooltip")!;
const mousemoveEvent = (e: MouseEvent) => {
const x = e.offsetX;
const y = e.offsetY;
const node = nodes.find((node) =>
x >= node.x && x < node.x + node.w && y >= node.y &&
y < node.y + node.h
);
if (!node) {
tooltip.hidden = true;
canvas.style.cursor = "default";
return;
}
tooltip.innerText = `${node.title} ${node.percent}`;
tooltip.style.left = `${e.clientX + 20}px`;
tooltip.style.top = `${e.clientY + 20}px`;
tooltip.hidden = false;
canvas.style.cursor = "pointer";
canvas.removeEventListener("mousemove", mousemoveEvent);
canvas.removeEventListener("mouseleave", mouseleaveEvent);
canvas.removeEventListener("mousedown", mousedownEvent);
ctx.clearRect(0, 0, canvas.width, canvas.height);
renderNodes(nodes, node.fgNode.fn);
};
const mouseleaveEvent = () => {
tooltip.hidden = true;
canvas.style.cursor = "default";
};
const mousedownEvent = (e: MouseEvent) => {
const x = e.offsetX;
const y = e.offsetY;
const node = nodes.find((node) =>
x >= node.x && x < node.x + node.w && y >= node.y &&
y < node.y + node.h
);
if (!node) {
return;
}
tooltip.hidden = true;
canvas.style.cursor = "default";
const newNodes: CalcNode[] = [];
calculateNodeRects(
newNodes,
node.fgNode,
0,
node.fgNode.acc,
0,
);
canvas.removeEventListener("mousemove", mousemoveEvent);
canvas.removeEventListener("mouseleave", mouseleaveEvent);
canvas.removeEventListener("mousedown", mousedownEvent);
ctx.clearRect(0, 0, canvas.width, canvas.height);
renderNodes(newNodes);
};
canvas.addEventListener("mousemove", mousemoveEvent);
canvas.addEventListener("mouseleave", mouseleaveEvent);
canvas.addEventListener("mousedown", mousedownEvent);
}
resetButton.addEventListener("click", () => {
const nodes: CalcNode[] = [];
calculateNodeRects(nodes, flameGraphData, 0, flameGraphData.acc, 0);
renderNodes(nodes);
});
const nodes: CalcNode[] = [];
calculateNodeRects(nodes, flameGraphData, 0, flameGraphData.acc, 0);
renderNodes(nodes);
}