wefactow web

This commit is contained in:
Theis Pieter Hollebeek 2024-12-16 13:45:42 +01:00
parent f7cb946830
commit 10ebd654ee
5 changed files with 425 additions and 374 deletions

View File

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

View 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" : ""}`;
});
}

View 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);
}

View File

@ -1,316 +1,16 @@
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) {
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),
};
}
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" : ""}`;
});
}
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() {
type RenderFns = {
"source-code": () => void;
"code-coverage": () => void;
"flame-graph": () => void;
};
function countLines(code: string) {
let lines = 0; let lines = 0;
for (const char of code) { for (const char of code) {
if (char === "\n") lines += 1; if (char === "\n") lines += 1;
} }
return lines; return lines;
} }
function createLineElement(code: string): HTMLPreElement { function createLineElement(code: string): HTMLPreElement {
const lines = countLines(code) + 1; const lines = countLines(code) + 1;
const maxLineWidth = lines.toString().length; const maxLineWidth = lines.toString().length;
let text = ""; let text = "";
@ -323,13 +23,24 @@ async function main() {
lineElement.classList.add("code-lines"); lineElement.classList.add("code-lines");
lineElement.innerText = text; lineElement.innerText = text;
return lineElement; 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";
}
const codeData = await data.codeData(); function sourceCode(view: Element, codeData: string) {
const view = document.querySelector("#view")!;
const renderFunctions: RenderFns = {
"source-code": () => {
const outerContainer = document.createElement("div"); const outerContainer = document.createElement("div");
outerContainer.classList.add("code-container"); outerContainer.classList.add("code-container");
@ -344,8 +55,9 @@ async function main() {
innerContainer.append(lines, code); innerContainer.append(lines, code);
outerContainer.append(innerContainer); outerContainer.append(innerContainer);
view.replaceChildren(outerContainer); view.replaceChildren(outerContainer);
}, }
"code-coverage": async () => {
async function codeCoverage(view: Element, codeData: string) {
const codeCoverageData = await data.codeCoverageData(); const codeCoverageData = await data.codeCoverageData();
const outerContainer = document.createElement("div"); const outerContainer = document.createElement("div");
@ -354,6 +66,33 @@ async function main() {
const innerContainer = document.createElement("div"); const innerContainer = document.createElement("div");
innerContainer.classList.add("code-container-inner"); 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",
);
const [testGroup, testRadio] = createRadio("test-coverage", "Test");
const radios = document.createElement("div");
radios.append(perfGroup, testGroup);
radios.classList.add("coverage-radio");
const tooltip = document.createElement("div"); const tooltip = document.createElement("div");
tooltip.id = "covers-tooltip"; tooltip.id = "covers-tooltip";
tooltip.hidden = true; tooltip.hidden = true;
@ -368,9 +107,22 @@ async function main() {
const lines = createLineElement(codeData); const lines = createLineElement(codeData);
innerContainer.append(lines, code); innerContainer.append(lines, code);
outerContainer.append(innerContainer); outerContainer.append(innerContainer);
view.replaceChildren(outerContainer, tooltip, radios);
}
async function main() {
type RenderFns = {
"source-code": () => void;
"code-coverage": () => void;
"flame-graph": () => void;
};
const codeData = await data.codeData();
const view = document.querySelector("#view")!; const view = document.querySelector("#view")!;
view.replaceChildren(outerContainer, tooltip); const renderFunctions: RenderFns = {
}, "source-code": () => sourceCode(view, codeData),
"code-coverage": async () => await codeCoverage(view, codeData),
"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;

View File

@ -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;
} }