208 lines
6.9 KiB
TypeScript
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);
|
|
}
|