diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 20:18:53 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 20:18:53 +0200 |
| commit | 81735bb46a75dce67a06e383f0703871e23b29d4 (patch) | |
| tree | d8c002b5f9908774f73637f0ce27de2c4e0fdfbe | |
| parent | b8e683e41364fcfd9edda05c4e35a8af3a21835d (diff) | |
flamegraph: add native svg renderer and embedded js
| -rw-r--r-- | internal/flamegraph/svgwriter.go | 154 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter_js.go | 75 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter_jscode.go | 77 |
3 files changed, 306 insertions, 0 deletions
diff --git a/internal/flamegraph/svgwriter.go b/internal/flamegraph/svgwriter.go new file mode 100644 index 0000000..26e203e --- /dev/null +++ b/internal/flamegraph/svgwriter.go @@ -0,0 +1,154 @@ +package flamegraph + +import ( + "bufio" + "fmt" + "hash/fnv" + "io" + "strings" +) + +type SVGConfig struct { + Title string + Width int + FrameHeight int + FontSize int + MinWidthPx float64 +} + +func defaultSVGConfig() SVGConfig { + return SVGConfig{ + Title: "I/O Flame Graph", + Width: 1200, + FrameHeight: 16, + FontSize: 12, + MinWidthPx: 1.0, + } +} + +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 + } + + canvasHeight := cfg.FrameHeight*(t.maxDepth+1) + 80 + 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 { + return err + } + } + if err := writeSVGFooter(bw); err != nil { + return err + } + return bw.Flush() +} + +func writeSVGHeader(bw *bufio.Writer, cfg SVGConfig, height int) error { + _, err := fmt.Fprintf(bw, `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`+"\n", + cfg.Width, height, cfg.Width, height) + if err != nil { + return err + } + _, err = fmt.Fprintf(bw, "<style><![CDATA[%s]]></style>\n", flamegraphCSS(cfg)) + if err != nil { + return err + } + _, err = fmt.Fprintf(bw, "<script><![CDATA[%s]]></script>\n", flamegraphJS) + if err != nil { + return err + } + _, err = fmt.Fprintf(bw, `<text class="title" x="10" y="22">%s</text>`+"\n", svgEscape(cfg.Title)) + if err != nil { + return err + } + _, err = fmt.Fprintf(bw, `<g class="controls"><text x="10" y="42" onclick="fgSearch()">Search</text><text x="80" y="42" onclick="fgResetSearch()">Reset Search</text><text x="190" y="42" onclick="fgResetZoom()">Reset Zoom</text><text id="fg-info" x="320" y="42"></text></g>`+"\n") + return err +} + +func writeSVGFooter(bw *bufio.Writer) error { + _, err := fmt.Fprintln(bw, "</svg>") + 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 { + _, err := fmt.Fprintf(bw, `<g class="frame" data-name="%s" data-x="%.3f" data-w="%.3f" data-depth="%d" data-base-fill="%s">`+"\n", + svgEscape(name), x, w, depth, fill) + if err != nil { + return err + } + if _, err = fmt.Fprintf(bw, `<title>%s</title><rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" fill="%s"/>`+"\n", + svgEscape(title), x, y, w, h, fill); err != nil { + return err + } + if w > float64(fontSize*2) { + _, err = fmt.Fprintf(bw, `<text x="%.3f" y="%.3f">%s</text>`+"\n", x+3, y+float64(fontSize), svgEscape(name)) + if err != nil { + return err + } + } + _, err = fmt.Fprintln(bw, "</g>") + return err +} + +func frameColor(name string) string { + hasher := fnv.New32a() + _, _ = hasher.Write([]byte(name)) + h := hasher.Sum32() + r := 200 + int(h%35) + g := 80 + int((h>>8)%120) + b := 40 + int((h>>16)%90) + return fmt.Sprintf("rgb(%d,%d,%d)", r, g, b) +} + +func flamegraphCSS(cfg SVGConfig) string { + return fmt.Sprintf(` +.title { font-size: %dpx; font-family: monospace; } +.controls text { font-size: %dpx; font-family: monospace; cursor: pointer; fill: #444; } +.frame text { font-size: %dpx; font-family: monospace; pointer-events: none; fill: #111; } +.frame rect { stroke: rgba(0,0,0,0.18); stroke-width: 0.5; } +`, cfg.FontSize+2, cfg.FontSize, cfg.FontSize-1) +} + +func svgEscape(s string) string { + replacer := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return replacer.Replace(s) +} diff --git a/internal/flamegraph/svgwriter_js.go b/internal/flamegraph/svgwriter_js.go new file mode 100644 index 0000000..7b9183f --- /dev/null +++ b/internal/flamegraph/svgwriter_js.go @@ -0,0 +1,75 @@ +package flamegraph + +const flamegraphJS = ` +const fg = { + frames: [], + info: null, + matchColor: "rgb(220, 30, 70)", +}; + +function fgInit() { + fg.frames = Array.from(document.querySelectorAll("g.frame")); + fg.info = document.getElementById("fg-info"); + fg.frames.forEach((frame) => { + frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget)); + frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); + }); + document.addEventListener("dblclick", () => fgResetZoom()); +} + +function fgHover(frame) { + if (!fg.info) return; + const title = frame.querySelector("title"); + fg.info.textContent = title ? title.textContent : ""; +} + +function fgZoom(frame) { + const x = Number(frame.dataset.x || "0"); + const w = Number(frame.dataset.w || "0"); + if (w <= 0) return; + const end = x + w; + fg.frames.forEach((other) => { + const ox = Number(other.dataset.x || "0"); + const ow = Number(other.dataset.w || "0"); + const sameBand = Number(other.dataset.depth || "0") >= Number(frame.dataset.depth || "0"); + if (sameBand && ox >= x && ox + ow <= end) { + other.style.display = ""; + } else { + other.style.display = "none"; + } + }); +} + +function fgResetZoom() { + fg.frames.forEach((frame) => { + frame.style.display = ""; + }); +} + +function fgSearch() { + const needle = prompt("Search frames (substring):", ""); + if (needle === null) return; + const q = needle.trim().toLowerCase(); + fg.frames.forEach((frame) => { + const rect = frame.querySelector("rect"); + const base = frame.dataset.baseFill || ""; + const name = (frame.dataset.name || "").toLowerCase(); + if (!rect) return; + if (q !== "" && name.includes(q)) { + rect.style.fill = fg.matchColor; + } else { + rect.style.fill = base; + } + }); +} + +function fgResetSearch() { + fg.frames.forEach((frame) => { + const rect = frame.querySelector("rect"); + if (!rect) return; + rect.style.fill = frame.dataset.baseFill || ""; + }); +} + +window.addEventListener("DOMContentLoaded", fgInit); +` diff --git a/internal/flamegraph/svgwriter_jscode.go b/internal/flamegraph/svgwriter_jscode.go new file mode 100644 index 0000000..52f8818 --- /dev/null +++ b/internal/flamegraph/svgwriter_jscode.go @@ -0,0 +1,77 @@ +//go:build !js + +package flamegraph + +const flamegraphJS = ` +const fg = { + frames: [], + info: null, + matchColor: "rgb(220, 30, 70)", +}; + +function fgInit() { + fg.frames = Array.from(document.querySelectorAll("g.frame")); + fg.info = document.getElementById("fg-info"); + fg.frames.forEach((frame) => { + frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget)); + frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); + }); + document.addEventListener("dblclick", () => fgResetZoom()); +} + +function fgHover(frame) { + if (!fg.info) return; + const title = frame.querySelector("title"); + fg.info.textContent = title ? title.textContent : ""; +} + +function fgZoom(frame) { + const x = Number(frame.dataset.x || "0"); + const w = Number(frame.dataset.w || "0"); + if (w <= 0) return; + const end = x + w; + fg.frames.forEach((other) => { + const ox = Number(other.dataset.x || "0"); + const ow = Number(other.dataset.w || "0"); + const sameBand = Number(other.dataset.depth || "0") >= Number(frame.dataset.depth || "0"); + if (sameBand && ox >= x && ox + ow <= end) { + other.style.display = ""; + } else { + other.style.display = "none"; + } + }); +} + +function fgResetZoom() { + fg.frames.forEach((frame) => { + frame.style.display = ""; + }); +} + +function fgSearch() { + const needle = prompt("Search frames (substring):", ""); + if (needle === null) return; + const q = needle.trim().toLowerCase(); + fg.frames.forEach((frame) => { + const rect = frame.querySelector("rect"); + const base = frame.dataset.baseFill || ""; + const name = (frame.dataset.name || "").toLowerCase(); + if (!rect) return; + if (q !== "" && name.includes(q)) { + rect.style.fill = fg.matchColor; + } else { + rect.style.fill = base; + } + }); +} + +function fgResetSearch() { + fg.frames.forEach((frame) => { + const rect = frame.querySelector("rect"); + if (!rect) return; + rect.style.fill = frame.dataset.baseFill || ""; + }); +} + +window.addEventListener("DOMContentLoaded", fgInit); +` |
