import * as data from "./data.ts"; import { FlameGraphNode } 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), }; } 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 = `
`; 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); } async 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 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 () => { if (await data.status().then((r) => r.running)) { return; } 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 () => { if (await data.status().then((r) => r.running)) { return; } const flameGraphData = await data.flameGraphData(); const flameGraphFnNames = await data.flameGraphFnNames(); const container = document.createElement("div"); container.classList.add("flame-graph"); container.id = "flame-graph"; const view = document.querySelector("#view")!; view.replaceChildren(container); loadFlameGraph(flameGraphData, flameGraphFnNames, 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](); } } 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; } const interval = setInterval(async () => { const status = await checkStatus(); if (status == "done") { clearInterval(interval); } }, 500); }); } main();