summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-27 21:44:18 +0200
committerPaul Buetow <paul@buetow.org>2026-02-27 21:44:18 +0200
commitf55212d9c02b87b2d6e15f62b2ce5b992b9d3045 (patch)
treeeaf868b17b9aec382ee37db31285bfd200beec2f /internal
parent0887c715150fcf391191a924491737bd58b8af9c (diff)
flamegraph: fill live viewport width and tighten reset hotkey
Diffstat (limited to 'internal')
-rw-r--r--internal/flamegraph/livehtml.go65
-rw-r--r--internal/flamegraph/livehtml_browser_test.go38
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go10
3 files changed, 93 insertions, 20 deletions
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 = `<!doctype html>
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 = `<!doctype html>
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 = `<!doctype html>
var availableHeight = viewportHeight - controlsHeight;
if (availableHeight <= 0) {
return {
+ width: viewportWidth,
frameHeight: fg.cfg.baseFrameHeight,
canvasHeight: defaultCanvasHeight
};
@@ -209,18 +219,31 @@ const liveHTML = `<!doctype html>
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 = `<!doctype html>
}
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 = `<!doctype html>
}
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('<text class="title" x="10" y="22">I/O Flame Graph (Live)</text>');
@@ -676,7 +713,7 @@ const liveHTML = `<!doctype html>
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");