summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 13:03:37 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 13:03:37 +0200
commitd80acf0c92ad4b436c23ac881ec24485297a80d8 (patch)
treee03bd7699d8e698585a820ec4416a1a5e010683f /internal
parentf92382c20193a5366d15c7347dcc8ed2743f3b85 (diff)
Extract renderer-agnostic flamegraph layout
Diffstat (limited to 'internal')
-rw-r--r--internal/flamegraph/layout.go78
-rw-r--r--internal/flamegraph/layout_test.go77
-rw-r--r--internal/flamegraph/svgwriter.go40
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 := ""