From f55212d9c02b87b2d6e15f62b2ce5b992b9d3045 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 27 Feb 2026 21:44:18 +0200 Subject: flamegraph: fill live viewport width and tighten reset hotkey --- internal/flamegraph/livehtml.go | 65 +++++++++++++++++++----- internal/flamegraph/livehtml_browser_test.go | 38 ++++++++++++-- internal/flamegraph/livehtml_interaction_test.go | 10 +++- 3 files changed, 93 insertions(+), 20 deletions(-) (limited to 'internal') diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go index 2db5137..e136a18 100644 --- a/internal/flamegraph/livehtml.go +++ b/internal/flamegraph/livehtml.go @@ -139,6 +139,7 @@ const liveHTML = ` resetZoomBtn: document.getElementById('btn-reset-zoom'), resetBaselineBtn: document.getElementById('btn-reset-baseline'), cfg: { + baseWidth: 1200, baseFrameHeight: 16, width: 1200, frameHeight: 16, @@ -181,9 +182,17 @@ const liveHTML = ` function fgViewportLayout(maxDepth) { var rows = Math.max(maxDepth + 1, 1); var defaultCanvasHeight = fgDefaultCanvasHeight(maxDepth); + var viewportWidth = Number(window.innerWidth || 0); + if (viewportWidth <= 0 && document && document.documentElement) { + viewportWidth = Number(document.documentElement.clientWidth || 0); + } + if (viewportWidth <= 0) { + viewportWidth = fg.cfg.baseWidth; + } var viewportHeight = Number(window.innerHeight || 0); if (viewportHeight <= 0) { return { + width: viewportWidth, frameHeight: fg.cfg.baseFrameHeight, canvasHeight: defaultCanvasHeight }; @@ -198,6 +207,7 @@ const liveHTML = ` var availableHeight = viewportHeight - controlsHeight; if (availableHeight <= 0) { return { + width: viewportWidth, frameHeight: fg.cfg.baseFrameHeight, canvasHeight: defaultCanvasHeight }; @@ -209,18 +219,31 @@ const liveHTML = ` frameHeight = fg.cfg.baseFrameHeight; } return { + width: viewportWidth, frameHeight: frameHeight, canvasHeight: canvasHeight }; } - function fgBuildFrames(node, rootTotal, x, depth, canvasHeight, isRoot, out, path) { - if (!node || rootTotal <= 0) { + function fgVisibleChildrenTotal(node) { + var children = Array.isArray(node && node.c) ? node.c : []; + var total = 0; + for (var i = 0; i < children.length; i++) { + total += Number(children[i].t || 0); + } + if (total > 0) { + return total; + } + return Number(node && node.t || 0); + } + + function fgBuildFrames(node, rootTotal, x, width, depth, canvasHeight, isRoot, out, path) { + if (!node || rootTotal <= 0 || width <= 0) { return; } var currentPath = path || ''; if (!isRoot) { - var w = fg.cfg.width * (Number(node.t || 0) / Number(rootTotal)); + var w = width; if (w < fg.cfg.minWidthPx) { return; } @@ -244,10 +267,18 @@ const liveHTML = ` } var cursor = x; var children = Array.isArray(node.c) ? node.c : []; + var childrenTotal = fgVisibleChildrenTotal(node); + if (childrenTotal <= 0) { + return; + } for (var i = 0; i < children.length; i++) { var child = children[i]; - var childWidth = fg.cfg.width * (Number(child.t || 0) / Number(rootTotal)); - fgBuildFrames(child, rootTotal, cursor, depth + 1, canvasHeight, false, out, currentPath); + var childTotal = Number(child.t || 0); + if (childTotal <= 0) { + continue; + } + var childWidth = width * (childTotal / childrenTotal); + fgBuildFrames(child, rootTotal, cursor, childWidth, depth + 1, canvasHeight, false, out, currentPath); cursor += childWidth; } } @@ -564,22 +595,28 @@ const liveHTML = ` } function fgRender(treeData) { + var maxDepth = fgMaxDepth(treeData, 0); + var layout = fgViewportLayout(maxDepth); + fg.cfg.width = layout.width; + fg.cfg.frameHeight = layout.frameHeight; + if (!treeData || Number(treeData.t || 0) <= 0) { - var emptyLayout = fgViewportLayout(0); - fg.cfg.frameHeight = emptyLayout.frameHeight; - fg.svg.style.height = String(emptyLayout.canvasHeight) + 'px'; + fg.svg.style.height = String(layout.canvasHeight) + 'px'; + fg.svg.setAttribute('viewBox', '0 0 ' + fg.cfg.width + ' ' + layout.canvasHeight); + fg.svg.setAttribute('preserveAspectRatio', 'xMinYMin meet'); fg.frames = []; fg.svg.innerHTML = ''; fgSetStatus(''); return; } - var rootTotal = Number(treeData.t || 0); - var maxDepth = fgMaxDepth(treeData, 0); - var layout = fgViewportLayout(maxDepth); - fg.cfg.frameHeight = layout.frameHeight; + + var rootTotal = fgVisibleChildrenTotal(treeData); + if (rootTotal <= 0) { + rootTotal = Number(treeData.t || 0); + } var canvasHeight = layout.canvasHeight; var frames = []; - fgBuildFrames(treeData, rootTotal, 0, 0, canvasHeight, true, frames, ''); + fgBuildFrames(treeData, rootTotal, 0, fg.cfg.width, 0, canvasHeight, true, frames, ''); var parts = []; parts.push('I/O Flame Graph (Live)'); @@ -676,7 +713,7 @@ const liveHTML = ` fgSearch(); return; } - if (ev.key === 'r' || ev.key === 'R') { + if (ev.key === 'r') { ev.preventDefault(); fgResetBaseline(); return; diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go index c362ddf..4105453 100644 --- a/internal/flamegraph/livehtml_browser_test.go +++ b/internal/flamegraph/livehtml_browser_test.go @@ -25,6 +25,7 @@ type liveJSResult struct { ViewBox string `json:"viewBox"` TallViewBox string `json:"tallViewBox"` TallHeight string `json:"tallHeight"` + PrunedMaxEnd float64 `json:"prunedMaxEnd"` SingleCount int `json:"singleCount"` DeepMaxDepth int `json:"deepMaxDepth"` WideFrameCount int `json:"wideFrameCount"` @@ -77,12 +78,15 @@ func TestLiveHTMLJSRenderingParity(t *testing.T) { if got.ViewBox != "0 0 1200 128" { t.Fatalf("viewBox = %q, want %q", got.ViewBox, "0 0 1200 128") } - if got.TallViewBox != "0 0 1200 844" { - t.Fatalf("tall viewBox = %q, want %q", got.TallViewBox, "0 0 1200 844") + if got.TallViewBox != "0 0 1600 844" { + t.Fatalf("tall viewBox = %q, want %q", got.TallViewBox, "0 0 1600 844") } if got.TallHeight != "844px" { t.Fatalf("tall style height = %q, want %q", got.TallHeight, "844px") } + if diff(got.PrunedMaxEnd, 1600) > 0.01 { + t.Fatalf("pruned max end = %f, want 1600", got.PrunedMaxEnd) + } if got.SingleCount != 1 { t.Fatalf("single-frame case count = %d, want 1", got.SingleCount) @@ -186,7 +190,7 @@ const knownTree = { const maxDepth = fgMaxDepth(knownTree, 0); const canvasHeight = (liveFlamegraphState.cfg.frameHeight * (maxDepth + 1)) + 80; const knownFramesRaw = []; -fgBuildFrames(knownTree, knownTree.t, 0, 0, canvasHeight, true, knownFramesRaw, ""); +fgBuildFrames(knownTree, knownTree.t, 0, 1200, 0, canvasHeight, true, knownFramesRaw, ""); const knownFrames = knownFramesRaw.map((f) => ({ name: f.name, x: Number(f.x.toFixed(3)), @@ -200,6 +204,7 @@ fgRender(knownTree); const svgHTML = elements["flamegraph"].innerHTML; const viewBox = elements["flamegraph"].attrs["viewBox"] || ""; +window.innerWidth = 1600; window.innerHeight = 900; fgRender(knownTree); const tallViewBox = elements["flamegraph"].attrs["viewBox"] || ""; @@ -208,7 +213,7 @@ const tallHeight = elements["flamegraph"].style.height || ""; const singleTree = { n: "", v: 0, t: 1, c: [{ n: "only", v: 1, t: 1 }] }; const singleFrames = []; const singleCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(singleTree, 0) + 1)) + 80; -fgBuildFrames(singleTree, singleTree.t, 0, 0, singleCanvas, true, singleFrames, ""); +fgBuildFrames(singleTree, singleTree.t, 0, 1200, 0, singleCanvas, true, singleFrames, ""); let deepTree = { n: "", v: 0, t: 1, c: [] }; let cursor = deepTree; @@ -226,7 +231,29 @@ for (let i = 0; i < 1000; i++) { const wideTree = { n: "", v: 0, t: 1000, c: wideChildren }; const wideCanvas = (liveFlamegraphState.cfg.frameHeight * (fgMaxDepth(wideTree, 0) + 1)) + 80; const wideFrames = []; -fgBuildFrames(wideTree, wideTree.t, 0, 0, wideCanvas, true, wideFrames, ""); +fgBuildFrames(wideTree, wideTree.t, 0, 1200, 0, wideCanvas, true, wideFrames, ""); + +const prunedTree = { + n: "", + v: 0, + t: 100, + c: [ + { n: "A", v: 0, t: 60 }, + { n: "B", v: 0, t: 20 } + ] +}; +fgRender(prunedTree); +const prunedHTML = elements["flamegraph"].innerHTML; +const prunedMatches = prunedHTML.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/g) || []; +let prunedMaxEnd = 0; +for (const m of prunedMatches) { + const parts = m.match(/data-x=\"([0-9.]+)\" data-w=\"([0-9.]+)\"/); + if (!parts) continue; + const end = Number(parts[1]) + Number(parts[2]); + if (end > prunedMaxEnd) { + prunedMaxEnd = end; + } +} console.log(JSON.stringify({ colors, @@ -235,6 +262,7 @@ console.log(JSON.stringify({ viewBox, tallViewBox, tallHeight, + prunedMaxEnd, singleCount: singleFrames.length, deepMaxDepth, wideFrameCount: wideFrames.length, diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go index 59aaef9..675b03f 100644 --- a/internal/flamegraph/livehtml_interaction_test.go +++ b/internal/flamegraph/livehtml_interaction_test.go @@ -34,6 +34,7 @@ type pauseKeyboardResult struct { type resetBaselineResult struct { HotkeyPrevented bool `json:"hotkeyPrevented"` + ShiftHotkeyIgnored bool `json:"shiftHotkeyIgnored"` HotkeyResetApplied bool `json:"hotkeyResetApplied"` ButtonResetApplied bool `json:"buttonResetApplied"` ResetCallsValid bool `json:"resetCallsValid"` @@ -335,6 +336,8 @@ fetch = function(url, opts) { }; const hotkeyPrevented = keyEvent("r", "KeyR"); +const shiftHotkeyPrevented = keyEvent("R", "KeyR"); +const shiftHotkeyIgnored = !shiftHotkeyPrevented && resetCalls.length === 1; setTimeout(function() { const hotkeyResetApplied = fg.zoomRange === null && fg.searchQuery === "" && fg.frames.length === 0; @@ -354,6 +357,7 @@ setTimeout(function() { console.log(JSON.stringify({ hotkeyPrevented, + shiftHotkeyIgnored, hotkeyResetApplied, buttonResetApplied, resetCallsValid @@ -371,6 +375,9 @@ setTimeout(function() { if !got.HotkeyPrevented { t.Fatalf("expected reset hotkey to prevent default browser handling") } + if !got.ShiftHotkeyIgnored { + t.Fatalf("expected uppercase 'R' to be ignored for baseline reset") + } if !got.HotkeyResetApplied { t.Fatalf("expected 'r' hotkey to reset baseline and clear UI state") } @@ -401,6 +408,7 @@ function makeElement(id) { classList: { toggle: function(){}, add: function(){}, remove: function(){} }, listeners: {}, addEventListener: function(event, cb) { this.listeners[event] = cb; }, + getBoundingClientRect: function() { return { height: id === "controls" ? 56 : 0 }; }, setAttribute: function(k, v) { this.attrs[k] = String(v); }, getAttribute: function(k) { return this.attrs[k] || ""; }, querySelectorAll: function() { return []; }, @@ -469,7 +477,7 @@ function makeFrame(name, path, depth, x, w) { } const elements = {}; -["flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { +["controls", "flamegraph", "status", "btn-pause", "btn-search", "btn-reset-search", "btn-undo-zoom", "btn-reset-zoom", "btn-reset-baseline"].forEach((id) => { elements[id] = makeElement(id); }); elements["body"] = makeElement("body"); -- cgit v1.2.3