diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 21:15:20 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 21:15:20 +0200 |
| commit | 93d587a6f5ae453907de3d5556866b60bac405cb (patch) | |
| tree | e177a5758b486e75fc66552fb0874b95bb145726 /internal/flamegraph | |
| parent | 8361fd22d45e4fbf6b24309aaa1b6d49d9010759 (diff) | |
flamegraph: improve interactive zoom and serve svg over embedded http
Diffstat (limited to 'internal/flamegraph')
| -rw-r--r-- | internal/flamegraph/nativesvg.go | 11 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter.go | 16 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter_js.go | 155 | ||||
| -rw-r--r-- | internal/flamegraph/svgwriter_jscode.go | 155 | ||||
| -rw-r--r-- | internal/flamegraph/tool.go | 4 | ||||
| -rw-r--r-- | internal/flamegraph/webserver.go | 108 |
6 files changed, 421 insertions, 28 deletions
diff --git a/internal/flamegraph/nativesvg.go b/internal/flamegraph/nativesvg.go index 2c76a7d..de0364b 100644 --- a/internal/flamegraph/nativesvg.go +++ b/internal/flamegraph/nativesvg.go @@ -23,7 +23,7 @@ func NewNativeSVG(fields []string, countField string) NativeSVG { } } -func (n NativeSVG) WriteSVGFromFile(iorDataFile string) error { +func (n NativeSVG) WriteSVGFromFile(iorDataFile string) (string, error) { outFile := fmt.Sprintf("%s.%s-by-%s.svg", strings.TrimSuffix(iorDataFile, ".ior.zst"), strings.Join(n.fields, ":"), @@ -32,16 +32,19 @@ func (n NativeSVG) WriteSVGFromFile(iorDataFile string) error { iod, err := newIorDataFromFile(iorDataFile) if err != nil { - return fmt.Errorf("read ior data: %w", err) + return outFile, fmt.Errorf("read ior data: %w", err) } fd, err := os.Create(outFile) if err != nil { - return fmt.Errorf("create output %s: %w", outFile, err) + return outFile, fmt.Errorf("create output %s: %w", outFile, err) } defer fd.Close() - return n.WriteSVGFromIter(iod.iter(), fd) + if err := n.WriteSVGFromIter(iod.iter(), fd); err != nil { + return outFile, err + } + return outFile, nil } func (n NativeSVG) WriteSVGFromIter(records iter.Seq[IterRecord], w io.Writer) error { diff --git a/internal/flamegraph/svgwriter.go b/internal/flamegraph/svgwriter.go index 26e203e..5ab8e50 100644 --- a/internal/flamegraph/svgwriter.go +++ b/internal/flamegraph/svgwriter.go @@ -68,7 +68,7 @@ func writeSVGHeader(bw *bufio.Writer, cfg SVGConfig, height int) error { 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") + _, 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="fgUndoZoom()">Undo Zoom</text><text x="280" y="42" onclick="fgResetZoom()">Reset Zoom</text><text id="fg-info" x="390" y="42"></text></g>`+"\n") return err } @@ -113,11 +113,14 @@ func writeFrame(bw *bufio.Writer, name, title, fill string, x, y, w, h float64, 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 - } + labelStyle := "" + if w <= float64(fontSize*2) { + labelStyle = ` style="display:none"` + } + _, err = fmt.Fprintf(bw, `<text x="%.3f" y="%.3f"%s>%s</text>`+"\n", + x+3, y+float64(fontSize), labelStyle, svgEscape(name)) + if err != nil { + return err } _, err = fmt.Fprintln(bw, "</g>") return err @@ -139,6 +142,7 @@ func flamegraphCSS(cfg SVGConfig) string { .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; } +.title, .controls text, .frame text { user-select: none; -webkit-user-select: none; } `, cfg.FontSize+2, cfg.FontSize, cfg.FontSize-1) } diff --git a/internal/flamegraph/svgwriter_js.go b/internal/flamegraph/svgwriter_js.go index 7b9183f..bf8bfd2 100644 --- a/internal/flamegraph/svgwriter_js.go +++ b/internal/flamegraph/svgwriter_js.go @@ -5,16 +5,33 @@ const fg = { frames: [], info: null, matchColor: "rgb(220, 30, 70)", + zoomStack: [], + zoomRange: null, + rootWidth: 0, }; function fgInit() { fg.frames = Array.from(document.querySelectorAll("g.frame")); fg.info = document.getElementById("fg-info"); + fg.rootWidth = fgDetectRootWidth(); fg.frames.forEach((frame) => { - frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget)); + fgSnapshotOriginalGeometry(frame); + frame.addEventListener("click", (ev) => { + if (ev.detail > 1) return; + ev.stopPropagation(); + fgZoom(ev.currentTarget); + }); + frame.addEventListener("dblclick", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + fgResetZoom(); + }); frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); }); - document.addEventListener("dblclick", () => fgResetZoom()); + document.addEventListener("dblclick", (ev) => { + ev.preventDefault(); + fgResetZoom(); + }); } function fgHover(frame) { @@ -24,15 +41,42 @@ function fgHover(frame) { } function fgZoom(frame) { - const x = Number(frame.dataset.x || "0"); - const w = Number(frame.dataset.w || "0"); + const x = fgOriginalX(frame); + const w = fgOriginalW(frame); if (w <= 0) return; - const end = x + w; + if (fg.zoomRange) { + fg.zoomStack.push(fg.zoomRange); + } + fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") }; + fgApplyZoom(); +} + +function fgApplyZoom() { + if (!fg.zoomRange) { + fg.frames.forEach((frame) => { + frame.style.display = ""; + }); + return; + } + const x = fg.zoomRange.x; + const end = x + fg.zoomRange.w; + const width = fg.zoomRange.w; + const minDepth = fg.zoomRange.depth; + const eps = 1e-6; + const scale = fg.rootWidth / width; 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) { + const ox = fgOriginalX(other); + const ow = fgOriginalW(other); + const depth = Number(other.dataset.depth || "0"); + const inSelectedRange = ox >= x-eps && ox+ow <= end+eps; + const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps; + + if (isAncestor || (depth >= minDepth && inSelectedRange)) { + if (isAncestor) { + fgSetFrameGeometry(other, 0, fg.rootWidth); + } else { + fgSetFrameGeometry(other, (ox-x)*scale, ow*scale); + } other.style.display = ""; } else { other.style.display = "none"; @@ -40,8 +84,20 @@ function fgZoom(frame) { }); } +function fgUndoZoom() { + if (fg.zoomStack.length === 0) { + fgResetZoom(); + return; + } + fg.zoomRange = fg.zoomStack.pop(); + fgApplyZoom(); +} + function fgResetZoom() { + fg.zoomStack = []; + fg.zoomRange = null; fg.frames.forEach((frame) => { + fgRestoreFrameGeometry(frame); frame.style.display = ""; }); } @@ -71,5 +127,86 @@ function fgResetSearch() { }); } +function fgDetectRootWidth() { + let maxEnd = 0; + fg.frames.forEach((frame) => { + const x = Number(frame.dataset.x || "0"); + const w = Number(frame.dataset.w || "0"); + maxEnd = Math.max(maxEnd, x + w); + }); + return maxEnd; +} + +function fgSnapshotOriginalGeometry(frame) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + frame.dataset.ox = frame.dataset.x || "0"; + frame.dataset.ow = frame.dataset.w || "0"; + if (rect) { + rect.dataset.ox = rect.getAttribute("x") || "0"; + rect.dataset.ow = rect.getAttribute("width") || "0"; + } + if (text) { + text.dataset.ox = text.getAttribute("x") || "0"; + text.dataset.hidden = text.style.display === "none" ? "1" : "0"; + text.dataset.full = text.textContent || frame.dataset.name || ""; + } +} + +function fgOriginalX(frame) { + return Number(frame.dataset.ox || frame.dataset.x || "0"); +} + +function fgOriginalW(frame) { + return Number(frame.dataset.ow || frame.dataset.w || "0"); +} + +function fgSetFrameGeometry(frame, x, w) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + if (rect) { + rect.setAttribute("x", String(x)); + rect.setAttribute("width", String(w)); + } + if (text) { + text.setAttribute("x", String(x + 3)); + fgFitLabel(text, w); + } +} + +function fgRestoreFrameGeometry(frame) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + if (rect) { + rect.setAttribute("x", rect.dataset.ox || "0"); + rect.setAttribute("width", rect.dataset.ow || "0"); + } + if (text) { + text.setAttribute("x", text.dataset.ox || "0"); + if (text.dataset.hidden === "1") { + text.style.display = "none"; + text.textContent = text.dataset.full || ""; + } else { + fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0")); + } + } +} + +function fgFitLabel(text, width) { + const full = text.dataset.full || text.textContent || ""; + const maxChars = Math.floor((width - 6) / 7); + if (maxChars < 3) { + text.style.display = "none"; + text.textContent = full; + return; + } + text.style.display = ""; + if (full.length <= maxChars) { + text.textContent = full; + return; + } + text.textContent = full.slice(0, maxChars - 1) + "…"; +} + window.addEventListener("DOMContentLoaded", fgInit); ` diff --git a/internal/flamegraph/svgwriter_jscode.go b/internal/flamegraph/svgwriter_jscode.go index 52f8818..3ac00fd 100644 --- a/internal/flamegraph/svgwriter_jscode.go +++ b/internal/flamegraph/svgwriter_jscode.go @@ -7,16 +7,33 @@ const fg = { frames: [], info: null, matchColor: "rgb(220, 30, 70)", + zoomStack: [], + zoomRange: null, + rootWidth: 0, }; function fgInit() { fg.frames = Array.from(document.querySelectorAll("g.frame")); fg.info = document.getElementById("fg-info"); + fg.rootWidth = fgDetectRootWidth(); fg.frames.forEach((frame) => { - frame.addEventListener("click", (ev) => fgZoom(ev.currentTarget)); + fgSnapshotOriginalGeometry(frame); + frame.addEventListener("click", (ev) => { + if (ev.detail > 1) return; + ev.stopPropagation(); + fgZoom(ev.currentTarget); + }); + frame.addEventListener("dblclick", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + fgResetZoom(); + }); frame.addEventListener("mouseenter", (ev) => fgHover(ev.currentTarget)); }); - document.addEventListener("dblclick", () => fgResetZoom()); + document.addEventListener("dblclick", (ev) => { + ev.preventDefault(); + fgResetZoom(); + }); } function fgHover(frame) { @@ -26,15 +43,42 @@ function fgHover(frame) { } function fgZoom(frame) { - const x = Number(frame.dataset.x || "0"); - const w = Number(frame.dataset.w || "0"); + const x = fgOriginalX(frame); + const w = fgOriginalW(frame); if (w <= 0) return; - const end = x + w; + if (fg.zoomRange) { + fg.zoomStack.push(fg.zoomRange); + } + fg.zoomRange = { x: x, w: w, depth: Number(frame.dataset.depth || "0") }; + fgApplyZoom(); +} + +function fgApplyZoom() { + if (!fg.zoomRange) { + fg.frames.forEach((frame) => { + frame.style.display = ""; + }); + return; + } + const x = fg.zoomRange.x; + const end = x + fg.zoomRange.w; + const width = fg.zoomRange.w; + const minDepth = fg.zoomRange.depth; + const eps = 1e-6; + const scale = fg.rootWidth / width; 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) { + const ox = fgOriginalX(other); + const ow = fgOriginalW(other); + const depth = Number(other.dataset.depth || "0"); + const inSelectedRange = ox >= x-eps && ox+ow <= end+eps; + const isAncestor = depth < minDepth && ox <= x+eps && ox+ow >= end-eps; + + if (isAncestor || (depth >= minDepth && inSelectedRange)) { + if (isAncestor) { + fgSetFrameGeometry(other, 0, fg.rootWidth); + } else { + fgSetFrameGeometry(other, (ox-x)*scale, ow*scale); + } other.style.display = ""; } else { other.style.display = "none"; @@ -42,8 +86,20 @@ function fgZoom(frame) { }); } +function fgUndoZoom() { + if (fg.zoomStack.length === 0) { + fgResetZoom(); + return; + } + fg.zoomRange = fg.zoomStack.pop(); + fgApplyZoom(); +} + function fgResetZoom() { + fg.zoomStack = []; + fg.zoomRange = null; fg.frames.forEach((frame) => { + fgRestoreFrameGeometry(frame); frame.style.display = ""; }); } @@ -73,5 +129,86 @@ function fgResetSearch() { }); } +function fgDetectRootWidth() { + let maxEnd = 0; + fg.frames.forEach((frame) => { + const x = Number(frame.dataset.x || "0"); + const w = Number(frame.dataset.w || "0"); + maxEnd = Math.max(maxEnd, x + w); + }); + return maxEnd; +} + +function fgSnapshotOriginalGeometry(frame) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + frame.dataset.ox = frame.dataset.x || "0"; + frame.dataset.ow = frame.dataset.w || "0"; + if (rect) { + rect.dataset.ox = rect.getAttribute("x") || "0"; + rect.dataset.ow = rect.getAttribute("width") || "0"; + } + if (text) { + text.dataset.ox = text.getAttribute("x") || "0"; + text.dataset.hidden = text.style.display === "none" ? "1" : "0"; + text.dataset.full = text.textContent || frame.dataset.name || ""; + } +} + +function fgOriginalX(frame) { + return Number(frame.dataset.ox || frame.dataset.x || "0"); +} + +function fgOriginalW(frame) { + return Number(frame.dataset.ow || frame.dataset.w || "0"); +} + +function fgSetFrameGeometry(frame, x, w) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + if (rect) { + rect.setAttribute("x", String(x)); + rect.setAttribute("width", String(w)); + } + if (text) { + text.setAttribute("x", String(x + 3)); + fgFitLabel(text, w); + } +} + +function fgRestoreFrameGeometry(frame) { + const rect = frame.querySelector("rect"); + const text = frame.querySelector("text"); + if (rect) { + rect.setAttribute("x", rect.dataset.ox || "0"); + rect.setAttribute("width", rect.dataset.ow || "0"); + } + if (text) { + text.setAttribute("x", text.dataset.ox || "0"); + if (text.dataset.hidden === "1") { + text.style.display = "none"; + text.textContent = text.dataset.full || ""; + } else { + fgFitLabel(text, Number(rect ? (rect.dataset.ow || "0") : "0")); + } + } +} + +function fgFitLabel(text, width) { + const full = text.dataset.full || text.textContent || ""; + const maxChars = Math.floor((width - 6) / 7); + if (maxChars < 3) { + text.style.display = "none"; + text.textContent = full; + return; + } + text.style.display = ""; + if (full.length <= maxChars) { + text.textContent = full; + return; + } + text.textContent = full.slice(0, maxChars - 1) + "…"; +} + window.addEventListener("DOMContentLoaded", fgInit); ` diff --git a/internal/flamegraph/tool.go b/internal/flamegraph/tool.go index 3719b0a..a83c44f 100644 --- a/internal/flamegraph/tool.go +++ b/internal/flamegraph/tool.go @@ -69,6 +69,10 @@ func (t Tool) WriteSVG() error { return nil } +func (t Tool) OutFile() string { + return t.outFile +} + func decompress(compressedFile string) (string, error) { decompressedFile := strings.TrimSuffix(compressedFile, ".zst") diff --git a/internal/flamegraph/webserver.go b/internal/flamegraph/webserver.go new file mode 100644 index 0000000..7925011 --- /dev/null +++ b/internal/flamegraph/webserver.go @@ -0,0 +1,108 @@ +package flamegraph + +import ( + "context" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" +) + +func ServeSVG(svgFile string) error { + absPath, err := filepath.Abs(svgFile) + if err != nil { + return fmt.Errorf("resolve svg path: %w", err) + } + urlPath := buildURLPath(absPath) + srv := &http.Server{Handler: buildSVGHandler(absPath, urlPath)} + + listener, err := listenRandomPort() + if err != nil { + return err + } + defer listener.Close() + + hostname, port := serverHostPort(listener) + printServerURL(hostname, port, urlPath) + + errCh := make(chan error, 1) + go func() { + errCh <- srv.Serve(listener) + }() + + return waitForStop(srv, errCh) +} + +func buildURLPath(absPath string) string { + urlPath := filepath.ToSlash(absPath) + if !strings.HasPrefix(urlPath, "/") { + return "/" + urlPath + } + return urlPath +} + +func buildSVGHandler(absPath, urlPath string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, urlPath, http.StatusFound) + }) + mux.HandleFunc(urlPath, func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, absPath) + }) + return mux +} + +func listenRandomPort() (net.Listener, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, fmt.Errorf("start web server: %w", err) + } + return listener, nil +} + +func serverHostPort(listener net.Listener) (string, int) { + hostname, err := os.Hostname() + if err != nil { + hostname = "localhost" + } + port := listener.Addr().(*net.TCPAddr).Port + return hostname, port +} + +func printServerURL(hostname string, port int, urlPath string) { + fmt.Printf("Flamegraph available at http://%s:%d%s\n", hostname, port, urlPath) + fmt.Println("Press Ctrl+C to stop the web server.") +} + +func waitForStop(srv *http.Server, errCh <-chan error) error { + stopCh := make(chan os.Signal, 1) + signal.Notify(stopCh, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(stopCh) + + select { + case sig := <-stopCh: + _ = sig + case serveErr := <-errCh: + if serveErr != nil && serveErr != http.ErrServerClosed { + return serveErr + } + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + return fmt.Errorf("shutdown web server: %w", err) + } + + serveErr := <-errCh + if serveErr != nil && serveErr != http.ErrServerClosed { + return serveErr + } + return nil +} |
