diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-03 13:03:37 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-03 13:03:37 +0200 |
| commit | d80acf0c92ad4b436c23ac881ec24485297a80d8 (patch) | |
| tree | e03bd7699d8e698585a820ec4416a1a5e010683f /internal/flamegraph | |
| parent | f92382c20193a5366d15c7347dcc8ed2743f3b85 (diff) | |
Extract renderer-agnostic flamegraph layout
Diffstat (limited to 'internal/flamegraph')
| -rw-r--r-- | internal/flamegraph/layout.go | 78 | ||||
| -rw-r--r-- | internal/flamegraph/layout_test.go | 77 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter.go | 40 |
3 files changed, 160 insertions, 35 deletions
diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go new file mode 100644 index 0000000..c319800 --- /dev/null +++ b/internal/flamegraph/layout.go @@ -0,0 +1,78 @@ +package flamegraph + +import "fmt" + +// FrameLayout captures renderer-agnostic flamegraph geometry for a single frame. +// +// The layout is reusable by non-SVG renderers (for example SDL or WASM UIs) so +// they can render the same hierarchy without depending on SVG internals. +type FrameLayout struct { + Name string + Title string + Fill string + X float64 + Y float64 + Width float64 + Height float64 + Depth int + Total uint64 + Percent float64 +} + +func sanitizeSVGConfig(cfg SVGConfig) SVGConfig { + if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 { + return defaultSVGConfig() + } + if cfg.Title == "" { + cfg.Title = defaultSVGConfig().Title + } + return cfg +} + +func canvasHeightFor(cfg SVGConfig, t *trie) int { + return cfg.FrameHeight*(t.maxDepth+1) + 80 +} + +// BuildFrameLayout builds renderer-agnostic frame coordinates from a flamegraph trie. +func BuildFrameLayout(t *trie, cfg SVGConfig) []FrameLayout { + if t == nil || t.root == nil || t.root.total == 0 { + return nil + } + cfg = sanitizeSVGConfig(cfg) + canvasHeight := canvasHeightFor(cfg, t) + out := make([]FrameLayout, 0, len(t.root.children)) + collectFrameLayout(&out, t.root, t.root.total, cfg, 0, 0, canvasHeight, true) + return out +} + +func collectFrameLayout(out *[]FrameLayout, node *trieNode, rootTotal uint64, + cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) { + + if !isRoot { + w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal)) + if w < cfg.MinWidthPx { + return + } + y := float64(canvasHeight - (depth+1)*cfg.FrameHeight) + pct := 100 * float64(node.total) / float64(rootTotal) + *out = append(*out, FrameLayout{ + Name: node.name, + Title: fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct), + Fill: frameColor(node.name), + X: x, + Y: y, + Width: w, + Height: float64(cfg.FrameHeight - 1), + Depth: depth, + Total: node.total, + Percent: pct, + }) + } + + cursor := x + for _, child := range node.children { + cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal)) + collectFrameLayout(out, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false) + cursor += cw + } +} diff --git a/internal/flamegraph/layout_test.go b/internal/flamegraph/layout_test.go new file mode 100644 index 0000000..8fa7398 --- /dev/null +++ b/internal/flamegraph/layout_test.go @@ -0,0 +1,77 @@ +package flamegraph + +import ( + "math" + "testing" +) + +func almostEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-6 +} + +func TestBuildFrameLayoutBasicGeometry(t *testing.T) { + tr := newTrie() + tr.add([]string{"A"}, 4) + tr.add([]string{"B"}, 1) + tr.computeTotals() + + cfg := defaultSVGConfig() + cfg.Width = 100 + cfg.FrameHeight = 10 + cfg.FontSize = 10 + cfg.MinWidthPx = 1 + + frames := BuildFrameLayout(tr, cfg) + if len(frames) != 2 { + t.Fatalf("frames len = %d, want 2", len(frames)) + } + + a := frames[0] + if a.Name != "A" { + t.Fatalf("first frame name = %q, want %q", a.Name, "A") + } + if !almostEqual(a.X, 0) { + t.Fatalf("A x = %f, want 0", a.X) + } + if !almostEqual(a.Width, 80) { + t.Fatalf("A width = %f, want 80", a.Width) + } + if !almostEqual(a.Percent, 80) { + t.Fatalf("A percent = %f, want 80", a.Percent) + } + if a.Depth != 1 { + t.Fatalf("A depth = %d, want 1", a.Depth) + } + + b := frames[1] + if b.Name != "B" { + t.Fatalf("second frame name = %q, want %q", b.Name, "B") + } + if !almostEqual(b.X, 80) { + t.Fatalf("B x = %f, want 80", b.X) + } + if !almostEqual(b.Width, 20) { + t.Fatalf("B width = %f, want 20", b.Width) + } +} + +func TestBuildFrameLayoutSkipsFramesBelowMinWidth(t *testing.T) { + tr := newTrie() + tr.add([]string{"A"}, 999) + tr.add([]string{"B"}, 1) + tr.computeTotals() + + cfg := defaultSVGConfig() + cfg.Width = 100 + cfg.FrameHeight = 10 + cfg.FontSize = 10 + cfg.MinWidthPx = 1 + + frames := BuildFrameLayout(tr, cfg) + if len(frames) != 1 { + t.Fatalf("frames len = %d, want 1", len(frames)) + } + if frames[0].Name != "A" { + t.Fatalf("remaining frame name = %q, want %q", frames[0].Name, "A") + } +} diff --git a/internal/flamegraph/svgwriter.go b/internal/flamegraph/svgwriter.go index f8b70b3..7fd699e 100644 --- a/internal/flamegraph/svgwriter.go +++ b/internal/flamegraph/svgwriter.go @@ -58,20 +58,16 @@ func DefaultSVGConfig() SVGConfig { // for zoom, search, and highlighting, and is designed to be served directly to // a browser (for example via ServeSVG) without any external assets. func WriteSVG(w io.Writer, t *trie, cfg SVGConfig) error { - if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 { - cfg = defaultSVGConfig() - } - if cfg.Title == "" { - cfg.Title = defaultSVGConfig().Title - } + cfg = sanitizeSVGConfig(cfg) - canvasHeight := cfg.FrameHeight*(t.maxDepth+1) + 80 + canvasHeight := canvasHeightFor(cfg, t) bw := bufio.NewWriter(w) if err := writeSVGHeader(bw, cfg, canvasHeight); err != nil { return err } - if t.root.total > 0 { - if err := renderFrames(bw, t.root, t.root.total, cfg, 0, 0, canvasHeight, true); err != nil { + for _, frame := range BuildFrameLayout(t, cfg) { + if err := writeFrame(bw, frame.Name, frame.Title, frame.Fill, + frame.X, frame.Y, frame.Width, frame.Height, frame.Depth, cfg.FontSize); err != nil { return err } } @@ -108,32 +104,6 @@ func writeSVGFooter(bw *bufio.Writer) error { return err } -func renderFrames(bw *bufio.Writer, node *trieNode, rootTotal uint64, cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) error { - if !isRoot { - w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal)) - if w < cfg.MinWidthPx { - return nil - } - y := float64(canvasHeight - (depth+1)*cfg.FrameHeight) - fill := frameColor(node.name) - pct := 100 * float64(node.total) / float64(rootTotal) - title := fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct) - if err := writeFrame(bw, node.name, title, fill, x, y, w, float64(cfg.FrameHeight-1), depth, cfg.FontSize); err != nil { - return err - } - } - - cursor := x - for _, child := range node.children { - cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal)) - if err := renderFrames(bw, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false); err != nil { - return err - } - cursor += cw - } - return nil -} - func writeFrame(bw *bufio.Writer, name, title, fill string, x, y, w, h float64, depth, fontSize int) error { textStyle := "" labelStyle := "" |
