mirror of
https://git.sfja.dk/Mikkel/slige.git
synced 2025-01-18 19:36:32 +00:00
wefactow web
This commit is contained in:
parent
f7cb946830
commit
10ebd654ee
@ -28,7 +28,6 @@
|
|||||||
<main id="view">
|
<main id="view">
|
||||||
<div id="flame-graph"></div>
|
<div id="flame-graph"></div>
|
||||||
<pre id="code-coverage"></pre>
|
<pre id="code-coverage"></pre>
|
||||||
<div id="cover">Process is currently running</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
131
web/public/src/code_coverage.ts
Normal file
131
web/public/src/code_coverage.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import * as data from "./data.ts";
|
||||||
|
|
||||||
|
type Color = { r: number; g: number; b: number };
|
||||||
|
|
||||||
|
function lerp2(ratio: number, start: number, end: number) {
|
||||||
|
return (1 - ratio) * start + ratio * end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp3(ratio: number, start: number, middle: number, end: number) {
|
||||||
|
return (1 - ratio) * lerp2(ratio, start, middle) +
|
||||||
|
ratio * lerp2(ratio, middle, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorLerp(
|
||||||
|
ratio: number,
|
||||||
|
start: Color,
|
||||||
|
middle: Color,
|
||||||
|
end: Color,
|
||||||
|
): Color {
|
||||||
|
return {
|
||||||
|
r: lerp3(ratio, start.r, middle.r, end.r),
|
||||||
|
g: lerp3(ratio, start.g, middle.g, end.g),
|
||||||
|
b: lerp3(ratio, start.b, middle.b, end.b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCodeCoverage(
|
||||||
|
text: string,
|
||||||
|
input: data.CodeCovEntry[],
|
||||||
|
container: HTMLPreElement,
|
||||||
|
tooltip: HTMLElement,
|
||||||
|
) {
|
||||||
|
if (input.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = input.toSorted((
|
||||||
|
a: data.CodeCovEntry,
|
||||||
|
b: data.CodeCovEntry,
|
||||||
|
) => b.index - a.index);
|
||||||
|
const charEntries: { [key: string]: data.CodeCovEntry } = {};
|
||||||
|
const elements: HTMLElement[] = [];
|
||||||
|
let line = 1;
|
||||||
|
let col = 1;
|
||||||
|
const maxCovers = entries.map((v) => v.covers).reduce((acc, v) =>
|
||||||
|
acc > Math.log10(v) ? acc : Math.log10(v)
|
||||||
|
);
|
||||||
|
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 backgroundColor = (ratio: number) => {
|
||||||
|
const clr = colorLerp(ratio, { r: 42, g: 121, b: 82 }, {
|
||||||
|
r: 247,
|
||||||
|
g: 203,
|
||||||
|
b: 21,
|
||||||
|
}, {
|
||||||
|
r: 167,
|
||||||
|
g: 29,
|
||||||
|
b: 49,
|
||||||
|
});
|
||||||
|
return `rgb(${clr.r}, ${clr.g}, ${clr.b})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.style.backgroundColor = backgroundColor(
|
||||||
|
Math.log10(entry.covers) / maxCovers,
|
||||||
|
);
|
||||||
|
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" : ""}`;
|
||||||
|
});
|
||||||
|
}
|
165
web/public/src/flamegraph.ts
Normal file
165
web/public/src/flamegraph.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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 monospace";
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.font = fnNameFont;
|
||||||
|
|
||||||
|
type CalcNode = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
title: string;
|
||||||
|
percent: string;
|
||||||
|
fgNode: 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[]) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const { x, y, w, h } = node;
|
||||||
|
ctx.fillStyle = "rgb(255, 125, 0)";
|
||||||
|
ctx.fillRect(
|
||||||
|
x + 2,
|
||||||
|
y + 2,
|
||||||
|
w - 4,
|
||||||
|
h - 4,
|
||||||
|
);
|
||||||
|
const textCanvas = drawTextCanvas(node);
|
||||||
|
ctx.drawImage(textCanvas, x + 4, y);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tooltip.innerText = `${node.title} ${node.percent}`;
|
||||||
|
tooltip.style.left = `${e.clientX + 20}px`;
|
||||||
|
tooltip.style.top = `${e.clientY + 20}px`;
|
||||||
|
tooltip.hidden = false;
|
||||||
|
};
|
||||||
|
const mouseleaveEvent = () => {
|
||||||
|
tooltip.hidden = true;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
@ -1,298 +1,113 @@
|
|||||||
|
import { loadCodeCoverage } from "./code_coverage.ts";
|
||||||
import * as data from "./data.ts";
|
import * as data from "./data.ts";
|
||||||
import { FlameGraphNode } from "./data.ts";
|
import { loadFlameGraph } from "./flamegraph.ts";
|
||||||
|
|
||||||
type Color = { r: number; g: number; b: number };
|
function countLines(code: string) {
|
||||||
|
let lines = 0;
|
||||||
function lerp2(ratio: number, start: number, end: number) {
|
for (const char of code) {
|
||||||
return (1 - ratio) * start + ratio * end;
|
if (char === "\n") lines += 1;
|
||||||
}
|
|
||||||
|
|
||||||
function lerp3(ratio: number, start: number, middle: number, end: number) {
|
|
||||||
return (1 - ratio) * lerp2(ratio, start, middle) +
|
|
||||||
ratio * lerp2(ratio, middle, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorLerp(
|
|
||||||
ratio: number,
|
|
||||||
start: Color,
|
|
||||||
middle: Color,
|
|
||||||
end: Color,
|
|
||||||
): Color {
|
|
||||||
return {
|
|
||||||
r: lerp3(ratio, start.r, middle.r, end.r),
|
|
||||||
g: lerp3(ratio, start.g, middle.g, end.g),
|
|
||||||
b: lerp3(ratio, start.b, middle.b, end.b),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadCodeCoverage(
|
|
||||||
text: string,
|
|
||||||
input: data.CodeCovEntry[],
|
|
||||||
container: HTMLPreElement,
|
|
||||||
tooltip: HTMLElement,
|
|
||||||
) {
|
|
||||||
if (input.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const entries = input.toSorted((
|
return lines;
|
||||||
a: data.CodeCovEntry,
|
}
|
||||||
b: data.CodeCovEntry,
|
|
||||||
) => b.index - a.index);
|
function createLineElement(code: string): HTMLPreElement {
|
||||||
const charEntries: { [key: string]: data.CodeCovEntry } = {};
|
const lines = countLines(code) + 1;
|
||||||
const elements: HTMLElement[] = [];
|
const maxLineWidth = lines.toString().length;
|
||||||
let line = 1;
|
let text = "";
|
||||||
let col = 1;
|
for (let i = 1; i < lines; ++i) {
|
||||||
const maxCovers = entries.map((v) => v.covers).reduce((acc, v) =>
|
const node = i.toString().padStart(maxLineWidth);
|
||||||
acc > Math.log10(v) ? acc : Math.log10(v)
|
text += node;
|
||||||
|
text += "\n";
|
||||||
|
}
|
||||||
|
const lineElement = document.createElement("pre");
|
||||||
|
lineElement.classList.add("code-lines");
|
||||||
|
lineElement.innerText = text;
|
||||||
|
return lineElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus(): Promise<"running" | "done"> {
|
||||||
|
const status = await data.status();
|
||||||
|
|
||||||
|
if (status.running) {
|
||||||
|
return "running";
|
||||||
|
}
|
||||||
|
const statusHtml = document.querySelector<HTMLSpanElement>(
|
||||||
|
"#status",
|
||||||
|
)!;
|
||||||
|
statusHtml.innerText = "Done";
|
||||||
|
document.body.classList.remove("status-waiting");
|
||||||
|
document.body.classList.add("status-done");
|
||||||
|
return "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCode(view: Element, codeData: string) {
|
||||||
|
const outerContainer = document.createElement("div");
|
||||||
|
outerContainer.classList.add("code-container");
|
||||||
|
|
||||||
|
const innerContainer = document.createElement("div");
|
||||||
|
innerContainer.classList.add("code-container-inner");
|
||||||
|
|
||||||
|
const lines = createLineElement(codeData);
|
||||||
|
const code = document.createElement("pre");
|
||||||
|
|
||||||
|
code.classList.add("code-source");
|
||||||
|
code.innerText = codeData;
|
||||||
|
innerContainer.append(lines, code);
|
||||||
|
outerContainer.append(innerContainer);
|
||||||
|
view.replaceChildren(outerContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function codeCoverage(view: Element, codeData: string) {
|
||||||
|
const codeCoverageData = await data.codeCoverageData();
|
||||||
|
|
||||||
|
const outerContainer = document.createElement("div");
|
||||||
|
outerContainer.classList.add("code-container");
|
||||||
|
|
||||||
|
const innerContainer = document.createElement("div");
|
||||||
|
innerContainer.classList.add("code-container-inner");
|
||||||
|
|
||||||
|
function createRadio(
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
): [HTMLDivElement, HTMLInputElement] {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.htmlFor = id;
|
||||||
|
label.innerText = content;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.id = id;
|
||||||
|
input.name = "coverage-radio";
|
||||||
|
input.type = "radio";
|
||||||
|
input.hidden = true;
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.classList.add("coverage-radio-group");
|
||||||
|
container.append(input, label);
|
||||||
|
return [container, input];
|
||||||
|
}
|
||||||
|
const [perfGroup, perfInput] = createRadio(
|
||||||
|
"performance-coverage",
|
||||||
|
"Performance",
|
||||||
);
|
);
|
||||||
for (let index = 0; index < text.length; ++index) {
|
const [testGroup, testRadio] = createRadio("test-coverage", "Test");
|
||||||
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 backgroundColor = (ratio: number) => {
|
const radios = document.createElement("div");
|
||||||
const clr = colorLerp(ratio, { r: 42, g: 121, b: 82 }, {
|
radios.append(perfGroup, testGroup);
|
||||||
r: 247,
|
radios.classList.add("coverage-radio");
|
||||||
g: 203,
|
|
||||||
b: 21,
|
|
||||||
}, {
|
|
||||||
r: 167,
|
|
||||||
g: 29,
|
|
||||||
b: 49,
|
|
||||||
});
|
|
||||||
return `rgb(${clr.r}, ${clr.g}, ${clr.b})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const span = document.createElement("span");
|
const tooltip = document.createElement("div");
|
||||||
span.style.backgroundColor = backgroundColor(
|
tooltip.id = "covers-tooltip";
|
||||||
Math.log10(entry.covers) / maxCovers,
|
tooltip.hidden = true;
|
||||||
);
|
const code = document.createElement("pre");
|
||||||
span.innerText = text[index];
|
code.classList.add("code-source");
|
||||||
span.dataset.covers = entry.covers.toString();
|
loadCodeCoverage(
|
||||||
elements.push(span);
|
codeData,
|
||||||
col += 1;
|
codeCoverageData,
|
||||||
}
|
code,
|
||||||
function positionInBox(
|
tooltip,
|
||||||
position: [number, number],
|
);
|
||||||
boundingRect: {
|
const lines = createLineElement(codeData);
|
||||||
left: number;
|
innerContainer.append(lines, code);
|
||||||
top: number;
|
outerContainer.append(innerContainer);
|
||||||
right: number;
|
view.replaceChildren(outerContainer, tooltip, radios);
|
||||||
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" : ""}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 monospace";
|
|
||||||
|
|
||||||
const ctx = canvas.getContext("2d")!;
|
|
||||||
ctx.font = fnNameFont;
|
|
||||||
|
|
||||||
type CalcNode = {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
title: string;
|
|
||||||
percent: string;
|
|
||||||
fgNode: 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[]) {
|
|
||||||
for (const node of nodes) {
|
|
||||||
const { x, y, w, h } = node;
|
|
||||||
ctx.fillStyle = "rgb(255, 125, 0)";
|
|
||||||
ctx.fillRect(
|
|
||||||
x + 2,
|
|
||||||
y + 2,
|
|
||||||
w - 4,
|
|
||||||
h - 4,
|
|
||||||
);
|
|
||||||
const textCanvas = drawTextCanvas(node);
|
|
||||||
ctx.drawImage(textCanvas, x + 4, y);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tooltip.innerText = `${node.title} ${node.percent}`;
|
|
||||||
tooltip.style.left = `${e.clientX + 20}px`;
|
|
||||||
tooltip.style.top = `${e.clientY + 20}px`;
|
|
||||||
tooltip.hidden = false;
|
|
||||||
};
|
|
||||||
const mouseleaveEvent = () => {
|
|
||||||
tooltip.hidden = true;
|
|
||||||
};
|
|
||||||
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;
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@ -302,75 +117,12 @@ async function main() {
|
|||||||
"flame-graph": () => 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 codeData = await data.codeData();
|
const codeData = await data.codeData();
|
||||||
|
|
||||||
const view = document.querySelector("#view")!;
|
const view = document.querySelector("#view")!;
|
||||||
const renderFunctions: RenderFns = {
|
const renderFunctions: RenderFns = {
|
||||||
"source-code": () => {
|
"source-code": () => sourceCode(view, codeData),
|
||||||
const outerContainer = document.createElement("div");
|
"code-coverage": async () => await codeCoverage(view, codeData),
|
||||||
outerContainer.classList.add("code-container");
|
|
||||||
|
|
||||||
const innerContainer = document.createElement("div");
|
|
||||||
innerContainer.classList.add("code-container-inner");
|
|
||||||
|
|
||||||
const lines = createLineElement(codeData);
|
|
||||||
const code = document.createElement("pre");
|
|
||||||
|
|
||||||
code.classList.add("code-source");
|
|
||||||
code.innerText = codeData;
|
|
||||||
innerContainer.append(lines, code);
|
|
||||||
outerContainer.append(innerContainer);
|
|
||||||
view.replaceChildren(outerContainer);
|
|
||||||
},
|
|
||||||
"code-coverage": async () => {
|
|
||||||
const codeCoverageData = await data.codeCoverageData();
|
|
||||||
|
|
||||||
const outerContainer = document.createElement("div");
|
|
||||||
outerContainer.classList.add("code-container");
|
|
||||||
|
|
||||||
const innerContainer = document.createElement("div");
|
|
||||||
innerContainer.classList.add("code-container-inner");
|
|
||||||
|
|
||||||
const tooltip = document.createElement("div");
|
|
||||||
tooltip.id = "covers-tooltip";
|
|
||||||
tooltip.hidden = true;
|
|
||||||
const code = document.createElement("pre");
|
|
||||||
code.classList.add("code-source");
|
|
||||||
loadCodeCoverage(
|
|
||||||
codeData,
|
|
||||||
codeCoverageData,
|
|
||||||
code,
|
|
||||||
tooltip,
|
|
||||||
);
|
|
||||||
const lines = createLineElement(codeData);
|
|
||||||
innerContainer.append(lines, code);
|
|
||||||
outerContainer.append(innerContainer);
|
|
||||||
const view = document.querySelector("#view")!;
|
|
||||||
view.replaceChildren(outerContainer, tooltip);
|
|
||||||
},
|
|
||||||
"flame-graph": async () => {
|
"flame-graph": async () => {
|
||||||
const flameGraphData = await data.flameGraphData();
|
const flameGraphData = await data.flameGraphData();
|
||||||
const flameGraphFnNames = await data.flameGraphFnNames();
|
const flameGraphFnNames = await data.flameGraphFnNames();
|
||||||
@ -399,21 +151,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkStatus(): Promise<"running" | "done"> {
|
|
||||||
const status = await data.status();
|
|
||||||
|
|
||||||
if (status.running) {
|
|
||||||
return "running";
|
|
||||||
}
|
|
||||||
const statusHtml = document.querySelector<HTMLSpanElement>(
|
|
||||||
"#status",
|
|
||||||
)!;
|
|
||||||
statusHtml.innerText = "Done";
|
|
||||||
document.body.classList.remove("status-waiting");
|
|
||||||
document.body.classList.add("status-done");
|
|
||||||
return "done";
|
|
||||||
}
|
|
||||||
|
|
||||||
checkStatus().then((status) => {
|
checkStatus().then((status) => {
|
||||||
if (status == "done") {
|
if (status == "done") {
|
||||||
return;
|
return;
|
||||||
|
@ -24,10 +24,6 @@ body {
|
|||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.status-error {
|
|
||||||
--code-status: #ff595e;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.status-waiting {
|
body.status-waiting {
|
||||||
--code-status: #e3b23c;
|
--code-status: #e3b23c;
|
||||||
}
|
}
|
||||||
@ -159,6 +155,29 @@ main #cover {
|
|||||||
color: #eee;
|
color: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.coverage-radio {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-radio-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-radio-group label {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid var(--code-status);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverage-radio-group input:checked ~ label {
|
||||||
|
background-color: var(--code-status);
|
||||||
|
}
|
||||||
|
|
||||||
#flame-graph {
|
#flame-graph {
|
||||||
width: min-content;
|
width: min-content;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user