summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/svgwriter.go154
-rw-r--r--internal/flamegraph/svgwriter_js.go75
-rw-r--r--internal/flamegraph/svgwriter_jscode.go77
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(
+ "&", "&amp;",
+ "<", "&lt;",
+ ">", "&gt;",
+ `"`, "&quot;",
+ "'", "&apos;",
+ )
+ 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);
+`