diff --git a/web/public/index.html b/web/public/index.html index 25d1473..89abb0c 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -28,7 +28,6 @@

-                    
Process is currently running
diff --git a/web/public/src/code_coverage.ts b/web/public/src/code_coverage.ts new file mode 100644 index 0000000..c14862f --- /dev/null +++ b/web/public/src/code_coverage.ts @@ -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" : ""}`; + }); +} diff --git a/web/public/src/flamegraph.ts b/web/public/src/flamegraph.ts new file mode 100644 index 0000000..4655e90 --- /dev/null +++ b/web/public/src/flamegraph.ts @@ -0,0 +1,165 @@ +import * as data from "./data.ts"; + +export function loadFlameGraph( + flameGraphData: data.FlameGraphNode, + fnNames: data.FlameGraphFnNames, + flameGraphDiv: HTMLDivElement, +) { + flameGraphDiv.innerHTML = ` +
+
+ + +
+
+
+ +
+ `; + + const canvas = document.querySelector( + "#flame-graph-canvas", + )!; + const resetButton = document.querySelector( + "#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 + ? "" + : fnNames[node.fn] ?? ""; + 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); +} diff --git a/web/public/src/index.ts b/web/public/src/index.ts index dcc252d..eab7f27 100644 --- a/web/public/src/index.ts +++ b/web/public/src/index.ts @@ -1,298 +1,113 @@ +import { loadCodeCoverage } from "./code_coverage.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 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; +function countLines(code: string) { + let lines = 0; + for (const char of code) { + if (char === "\n") lines += 1; } - 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) + 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; +} + +async function checkStatus(): Promise<"running" | "done"> { + const status = await data.status(); + + if (status.running) { + return "running"; + } + const statusHtml = document.querySelector( + "#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) { - 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 [testGroup, testRadio] = createRadio("test-coverage", "Test"); - 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 radios = document.createElement("div"); + radios.append(perfGroup, testGroup); + radios.classList.add("coverage-radio"); - 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 = ` -
-
- - -
-
-
- -
- `; - - const canvas = document.querySelector( - "#flame-graph-canvas", - )!; - const resetButton = document.querySelector( - "#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 - ? "" - : fnNames[node.fn] ?? ""; - 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); + 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); + view.replaceChildren(outerContainer, tooltip, radios); } async function main() { @@ -302,75 +117,12 @@ async function main() { "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 view = document.querySelector("#view")!; const renderFunctions: RenderFns = { - "source-code": () => { - 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); - }, - "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); - }, + "source-code": () => sourceCode(view, codeData), + "code-coverage": async () => await codeCoverage(view, codeData), "flame-graph": async () => { const flameGraphData = await data.flameGraphData(); 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( - "#status", - )!; - statusHtml.innerText = "Done"; - document.body.classList.remove("status-waiting"); - document.body.classList.add("status-done"); - return "done"; - } - checkStatus().then((status) => { if (status == "done") { return; diff --git a/web/public/style.css b/web/public/style.css index 1a2e852..924de14 100644 --- a/web/public/style.css +++ b/web/public/style.css @@ -24,10 +24,6 @@ body { color: var(--white); } -body.status-error { - --code-status: #ff595e; -} - body.status-waiting { --code-status: #e3b23c; } @@ -159,6 +155,29 @@ main #cover { 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 { width: min-content; }