import {
CodeCovEntry,
codeCoverageData,
codeData,
flameGraphData,
FlameGraphNode,
} from "./data.ts";
function loadCodeCoverage(
text: string,
data: CodeCovEntry[],
container: HTMLPreElement,
tooltip: HTMLElement,
) {
const entries = data.toSorted((
a: CodeCovEntry,
b: CodeCovEntry,
) => b.index - a.index);
const charEntries: { [key: string]: CodeCovEntry } = {};
const elements: HTMLElement[] = [];
let line = 1;
let col = 1;
for (let index = 0; index < text.length; ++index) {
if (text[index] === "\n") {
col = 1;
line += 1;
const newlineSpan = document.createElement("span");
newlineSpan.innerText = "\n";
elements.push(newlineSpan);
continue;
}
const entry = entries.find((entry) => index >= entry.index);
if (!entry) {
throw new Error("unreachable");
}
charEntries[`${line}-${col}`] = entry;
const color = (ratio: number) =>
`rgba(${255 - 255 * ratio}, ${255 * ratio}, 125, 0.5)`;
const span = document.createElement("span");
span.style.backgroundColor = color(Math.min(entry.covers / 25, 1));
span.innerText = text[index];
span.dataset.covers = entry.covers.toString();
elements.push(span);
col += 1;
}
function positionInBox(
position: [number, number],
boundingRect: {
left: number;
top: number;
right: number;
bottom: number;
},
) {
const [x, y] = position;
const outside = x < boundingRect.left ||
x >= boundingRect.right || y < boundingRect.top ||
y >= boundingRect.bottom;
return !outside;
}
container.append(...elements);
document.addEventListener("mousemove", (event) => {
const [x, y] = [event.clientX, event.clientY];
const outerBox = container.getBoundingClientRect();
if (!positionInBox([x, y], outerBox)) {
tooltip.hidden = true;
return;
}
const element = elements.find((element) => {
if (typeof element === "string") {
return false;
}
if (!element.dataset.covers) {
return false;
}
const isIn = positionInBox([x, y], element.getBoundingClientRect());
return isIn;
});
if (!element) {
tooltip.hidden = true;
return;
}
const maybeCovers = element.dataset.covers;
if (!maybeCovers) {
throw new Error("unreachable");
}
const covers = parseInt(maybeCovers);
tooltip.hidden = false;
tooltip.style.left = `${event.clientX + 20}px`;
tooltip.style.top = `${event.clientY + 20}px`;
tooltip.innerText = `Ran ${covers} time${covers !== 1 ? "s" : ""}`;
});
}
type FlameGraphFnNames = { [key: number]: string };
function loadFlameGraph(
flameGraphData: FlameGraphNode,
fnNames: FlameGraphFnNames,
flameGraphDiv: HTMLDivElement,
) {
flameGraphDiv.innerHTML = `
`;
const canvas = document.querySelector(
"#flame-graph-canvas",
)!;
canvas.width = 1000;
canvas.height = 500;
const ctx = canvas.getContext("2d")!;
ctx.font = "16px monospace";
type Node = {
x: number;
y: number;
w: number;
h: number;
title: string;
percent: string;
};
const nodes: Node[] = [];
function calculateNodeRects(
node: FlameGraphNode,
depth: number,
totalAcc: FlameGraphNode["acc"],
offsetAcc: 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 = fnNames[node.fn];
const percent = `${(node.acc / totalAcc * 100).toFixed(1)}%`;
nodes.push({ x, y, w, h, title, percent });
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(child, depth + 1, totalAcc, newOffsetAcc);
newOffsetAcc += child.acc;
}
}
calculateNodeRects(flameGraphData, 0, flameGraphData.acc, 0);
for (const node of nodes) {
const { x, y, w, h, title } = node;
ctx.fillStyle = "rgb(255, 125, 0)";
ctx.fillRect(
x + 1,
y + 1,
w - 2,
h - 2,
);
ctx.fillStyle = "black";
ctx.fillText(
title,
(x + (w - 10) / 2 - ctx.measureText(title).width / 2) + 5,
y + 20,
);
}
const tooltip = document.getElementById("flame-graph-tooltip")!;
canvas.addEventListener("mousemove", (e) => {
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;
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.addEventListener("mouseleave", () => {
tooltip.hidden = true;
});
}
function main() {
type RenderFns = {
"source-code": () => void;
"code-coverage": () => void;
"flame-graph": () => void;
};
function countLines(code: string) {
let lines = 0;
for (const char of code) {
if (char === "\n") lines += 1;
}
return lines;
}
function createLineElement(code: string): HTMLPreElement {
const lines = countLines(code) + 1;
const maxLineWidth = lines.toString().length;
let text = "";
for (let i = 1; i < lines; ++i) {
const node = i.toString().padStart(maxLineWidth);
text += node;
text += "\n";
}
const lineElement = document.createElement("pre");
lineElement.classList.add("code-lines");
lineElement.innerText = text;
return lineElement;
}
const view = document.querySelector("#view")!;
const renderFunctions: RenderFns = {
"source-code": () => {
const container = document.createElement("div");
container.classList.add("code-container");
const lines = createLineElement(codeData());
const code = document.createElement("pre");
code.innerText = codeData();
container.append(lines, code);
view.replaceChildren(container);
},
"code-coverage": () => {
const container = document.createElement("div");
container.classList.add("code-container");
const tooltip = document.createElement("div");
tooltip.id = "covers-tooltip";
tooltip.hidden = true;
const code = document.createElement("pre");
loadCodeCoverage(codeData(), codeCoverageData(), code, tooltip);
const lines = createLineElement(codeData());
container.append(lines, code);
const view = document.querySelector("#view")!;
view.replaceChildren(container, tooltip);
},
"flame-graph": () => {
const container = document.createElement("div");
const view = document.querySelector("#view")!;
view.replaceChildren(container);
loadFlameGraph(flameGraphData(), {
0: "",
12: "add",
18: "main",
}, container);
},
};
const viewRadios: NodeListOf = document.querySelectorAll(
'input[name="views"]',
);
for (const input of viewRadios) {
input.addEventListener("input", (ev) => {
const target = ev.target as HTMLInputElement;
const value = target.value as keyof RenderFns;
renderFunctions[value]();
});
if (input.checked) {
const value = input.value as keyof RenderFns;
renderFunctions[value]();
}
}
}
main();