summaryrefslogtreecommitdiff
path: root/internal/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/flamegraph')
-rw-r--r--internal/flamegraph/counter.go13
-rw-r--r--internal/flamegraph/doc.go2
-rw-r--r--internal/flamegraph/iordata.go90
-rw-r--r--internal/flamegraph/iordata_test.go57
-rw-r--r--internal/flamegraph/iordatacollector.go64
-rw-r--r--internal/flamegraph/layout.go78
-rw-r--r--internal/flamegraph/layout_test.go77
-rw-r--r--internal/flamegraph/livehtml.go841
-rw-r--r--internal/flamegraph/livehtml_browser_test.go314
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go615
-rw-r--r--internal/flamegraph/liveserver.go314
-rw-r--r--internal/flamegraph/liveserver_open_test.go179
-rw-r--r--internal/flamegraph/liveserver_test.go380
-rw-r--r--internal/flamegraph/livetrie.go155
-rw-r--r--internal/flamegraph/livetrie_test.go187
-rw-r--r--internal/flamegraph/nativejson.go86
-rw-r--r--internal/flamegraph/nativejson_test.go75
-rw-r--r--internal/flamegraph/nativesvg.go97
-rw-r--r--internal/flamegraph/nativesvg_test.go60
-rw-r--r--internal/flamegraph/svgwriter.go151
-rw-r--r--internal/flamegraph/svgwriter_js.go212
-rw-r--r--internal/flamegraph/svgwriter_jscode.go214
-rw-r--r--internal/flamegraph/svgwriter_test.go112
-rw-r--r--internal/flamegraph/testfixture.go120
-rw-r--r--internal/flamegraph/trie.go15
-rw-r--r--internal/flamegraph/trie_insert.go22
-rw-r--r--internal/flamegraph/webserver.go199
-rw-r--r--internal/flamegraph/webserver_autoreload_test.go37
-rw-r--r--internal/flamegraph/webserver_timeout_test.go43
-rw-r--r--internal/flamegraph/worker.go33
30 files changed, 539 insertions, 4303 deletions
diff --git a/internal/flamegraph/counter.go b/internal/flamegraph/counter.go
index ae727d4..441db68 100644
--- a/internal/flamegraph/counter.go
+++ b/internal/flamegraph/counter.go
@@ -10,6 +10,7 @@ import (
// - Duration is the syscall runtime on the same thread.
// - DurationToPrev is the inter-syscall gap on the same thread and is attributed
// to the current node; there is no separate "idle" pseudo-node.
+//
// Bytes is only populated for read/write/transfer syscalls.
type Counter struct {
Count uint64
@@ -27,17 +28,17 @@ func (c Counter) add(other Counter) Counter {
return c
}
-func (c Counter) ValueByName(name string) uint64 {
+func (c Counter) ValueByName(name string) (uint64, error) {
switch name {
case "count":
- return c.Count
+ return c.Count, nil
case "duration":
- return c.Duration
+ return c.Duration, nil
case "durationToPrev":
- return c.DurationToPrev
+ return c.DurationToPrev, nil
case "bytes":
- return c.Bytes
+ return c.Bytes, nil
default:
- panic(fmt.Sprintln("No", name, "in count record"))
+ return 0, fmt.Errorf("unknown counter field %q", name)
}
}
diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go
new file mode 100644
index 0000000..02429d3
--- /dev/null
+++ b/internal/flamegraph/doc.go
@@ -0,0 +1,2 @@
+// Package flamegraph provides TUI flamegraph aggregation primitives.
+package flamegraph
diff --git a/internal/flamegraph/iordata.go b/internal/flamegraph/iordata.go
index 61a65a9..4a562e3 100644
--- a/internal/flamegraph/iordata.go
+++ b/internal/flamegraph/iordata.go
@@ -3,16 +3,17 @@ package flamegraph
import (
"bytes"
"encoding/gob"
+ "errors"
"fmt"
- "io"
- "ior/internal/event"
- "ior/internal/file"
- "ior/internal/types"
"iter"
"os"
"strings"
"time"
+ "ior/internal/event"
+ "ior/internal/file"
+ "ior/internal/types"
+
// Is there a zstd library part of Go 1.25
"github.com/DataDog/zstd"
)
@@ -23,7 +24,8 @@ type commType = string
type pidType = uint32
type tidType = uint32
type flagsType = file.Flags
-type pathMap map[pathType]map[traceIdType]map[commType]map[pidType]map[tidType]map[flagsType]Counter
+
+var hostnameFn = os.Hostname
type recordKey struct {
Path pathType
@@ -97,10 +99,10 @@ func (iod iorData) merge(other iorData) iorData {
return iod
}
-func (iod iorData) serializeToFile(flamegraphName string) error {
- hostname, err := os.Hostname()
+func (iod iorData) serializeToFile(flamegraphName string) (retErr error) {
+ hostname, err := hostnameFn()
if err != nil {
- panic(err)
+ return fmt.Errorf("get hostname: %w", err)
}
if flamegraphName == "" {
flamegraphName = "default"
@@ -113,22 +115,33 @@ func (iod iorData) serializeToFile(flamegraphName string) error {
file, err := os.Create(tmpFilename)
if err != nil {
- return err
+ return fmt.Errorf("create temp file %s: %w", tmpFilename, err)
}
- defer file.Close()
+ defer func() {
+ if err := file.Close(); err != nil {
+ retErr = errors.Join(retErr, fmt.Errorf("close temp file %s: %w", tmpFilename, err))
+ }
+ }()
encoder := zstd.NewWriter(file)
- defer encoder.Close()
+ defer func() {
+ if err := encoder.Close(); err != nil {
+ retErr = errors.Join(retErr, fmt.Errorf("close zstd writer for %s: %w", tmpFilename, err))
+ }
+ }()
gobEncoder := gob.NewEncoder(encoder)
if err := gobEncoder.Encode(iod.records); err != nil {
- return err
+ return fmt.Errorf("encode ior records: %w", err)
}
if err := encoder.Flush(); err != nil {
- return err
+ return fmt.Errorf("flush ior records: %w", err)
}
- return os.Rename(tmpFilename, filename)
+ if err := os.Rename(tmpFilename, filename); err != nil {
+ return fmt.Errorf("rename %s to %s: %w", tmpFilename, filename, err)
+ }
+ return nil
}
func (iod *iorData) loadFromFile(filename string) error {
@@ -142,23 +155,14 @@ func (iod *iorData) loadFromFile(filename string) error {
defer decoder.Close()
var records map[recordKey]Counter
- if err := gob.NewDecoder(decoder).Decode(&records); err == nil && len(records) > 0 {
- iod.records = records
- return nil
- }
-
- // Fallback path for legacy payloads and empty-map ambiguity.
- if _, err := file.Seek(0, io.SeekStart); err != nil {
+ if err := gob.NewDecoder(decoder).Decode(&records); err != nil {
return err
}
- decoder = zstd.NewReader(file)
- defer decoder.Close()
-
- var buffer bytes.Buffer
- if _, err = io.Copy(&buffer, decoder); err != nil {
- return err
+ if records == nil {
+ records = make(map[recordKey]Counter)
}
- return iod.deserialize(&buffer)
+ iod.records = records
+ return nil
}
func (iod iorData) serialize() ([]byte, error) {
@@ -169,36 +173,14 @@ func (iod iorData) serialize() ([]byte, error) {
}
func (iod *iorData) deserialize(buf *bytes.Buffer) error {
- raw := append([]byte(nil), buf.Bytes()...)
- dec := gob.NewDecoder(bytes.NewReader(raw))
var records map[recordKey]Counter
- if err := dec.Decode(&records); err == nil && len(records) > 0 {
- iod.records = records
- return nil
- }
-
- var legacy pathMap
- if err := gob.NewDecoder(bytes.NewReader(raw)).Decode(&legacy); err != nil {
+ if err := gob.NewDecoder(bytes.NewReader(buf.Bytes())).Decode(&records); err != nil {
return err
}
-
- iod.records = make(map[recordKey]Counter)
- for path, traceIDMap := range legacy {
- for traceID, commMap := range traceIDMap {
- for comm, pidMap := range commMap {
- for pid, tidMap := range pidMap {
- for tid, flagsMap := range tidMap {
- for f, cnt := range flagsMap {
- iod.add(path, traceID, comm, pid, tid, f, cnt)
- }
- }
- }
- }
- }
- }
- if len(iod.records) == 0 && records != nil {
- iod.records = records
+ if records == nil {
+ records = make(map[recordKey]Counter)
}
+ iod.records = records
return nil
}
diff --git a/internal/flamegraph/iordata_test.go b/internal/flamegraph/iordata_test.go
index 54f1ed5..ee07a90 100644
--- a/internal/flamegraph/iordata_test.go
+++ b/internal/flamegraph/iordata_test.go
@@ -2,9 +2,12 @@ package flamegraph
import (
"bytes"
- "ior/internal/types"
+ "errors"
+ "strings"
"syscall"
"testing"
+
+ "ior/internal/types"
)
func counterAt(iod iorData, path pathType, traceID traceIdType, comm commType, pid pidType, tid tidType, flags flagsType) (Counter, bool) {
@@ -167,16 +170,36 @@ func TestStringByNameValidFields(t *testing.T) {
}
}
-func TestCounterValueByNamePanic(t *testing.T) {
+func TestCounterValueByNameUnknownField(t *testing.T) {
c := Counter{Count: 1, Duration: 100, DurationToPrev: 10, Bytes: 64}
- defer func() {
- if r := recover(); r == nil {
- t.Error("Expected panic for unknown counter name, got none")
- }
- }()
+ _, err := c.ValueByName("nonexistent")
+ if err == nil {
+ t.Error("Expected error for unknown counter name, got nil")
+ }
+}
- c.ValueByName("nonexistent")
+func TestCounterValueByNameValidFields(t *testing.T) {
+ c := Counter{Count: 1, Duration: 100, DurationToPrev: 10, Bytes: 64}
+
+ tests := map[string]uint64{
+ "count": c.Count,
+ "duration": c.Duration,
+ "durationToPrev": c.DurationToPrev,
+ "bytes": c.Bytes,
+ }
+
+ for field, want := range tests {
+ t.Run(field, func(t *testing.T) {
+ got, err := c.ValueByName(field)
+ if err != nil {
+ t.Fatalf("Expected no error for field %q, got %v", field, err)
+ }
+ if got != want {
+ t.Fatalf("Expected %d for field %q, got %d", want, field, got)
+ }
+ })
+ }
}
func TestMergeEmpty(t *testing.T) {
@@ -287,6 +310,24 @@ func TestDeserializeInvalidData(t *testing.T) {
}
}
+func TestSerializeToFileHostnameErrorReturnsError(t *testing.T) {
+ origHostnameFn := hostnameFn
+ t.Cleanup(func() { hostnameFn = origHostnameFn })
+
+ hostnameFn = func() (string, error) {
+ return "", errors.New("hostname unavailable")
+ }
+
+ iod := newIorData()
+ err := iod.serializeToFile("test")
+ if err == nil {
+ t.Fatal("Expected error when hostname lookup fails, got nil")
+ }
+ if !strings.Contains(err.Error(), "get hostname") {
+ t.Fatalf("Expected get hostname context, got %v", err)
+ }
+}
+
func bothArraysHaveSameElements(a, b []string) bool {
if len(a) != len(b) {
return false
diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go
deleted file mode 100644
index 9e92b63..0000000
--- a/internal/flamegraph/iordatacollector.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package flamegraph
-
-import (
- "context"
- "fmt"
- "ior/internal/event"
- "runtime"
- "sync"
-)
-
-type IorDataCollector struct {
- flamegraphName string
- Ch chan *event.Pair
- Done chan error
- workers []worker
-}
-
-func New(flamegraphName ...string) IorDataCollector {
- name := "default"
- if len(flamegraphName) > 0 && flamegraphName[0] != "" {
- name = flamegraphName[0]
- }
-
- f := IorDataCollector{
- flamegraphName: name,
- Ch: make(chan *event.Pair, 4096),
- Done: make(chan error, 1),
- }
- numWorkers := runtime.NumCPU() / 4
- if numWorkers == 0 {
- numWorkers = 1
- }
- for range numWorkers {
- f.workers = append(f.workers, newWorker())
- }
- return f
-}
-
-func (f IorDataCollector) Start(ctx context.Context) {
- go func() {
- defer close(f.Done)
- var wg sync.WaitGroup
- wg.Add(len(f.workers))
-
- for i, worker := range f.workers {
- fmt.Println("Starting flamegraph worker", i)
- go worker.run(ctx, &wg, f.Ch)
- }
- wg.Wait()
-
- iod := f.workers[0].iod
- if len(f.workers) > 1 {
- for i, w := range f.workers[1:] {
- iod = iod.merge(w.iod)
- fmt.Println("Worker", i+1, "merged")
- }
- }
- if err := iod.serializeToFile(f.flamegraphName); err != nil {
- f.Done <- err
- return
- }
- f.Done <- nil
- }()
-}
diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go
deleted file mode 100644
index c319800..0000000
--- a/internal/flamegraph/layout.go
+++ /dev/null
@@ -1,78 +0,0 @@
-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
deleted file mode 100644
index 8fa7398..0000000
--- a/internal/flamegraph/layout_test.go
+++ /dev/null
@@ -1,77 +0,0 @@
-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/livehtml.go b/internal/flamegraph/livehtml.go
deleted file mode 100644
index 90a6d3d..0000000
--- a/internal/flamegraph/livehtml.go
+++ /dev/null
@@ -1,841 +0,0 @@
-package flamegraph
-
-const liveHTML = `<!doctype html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>I/O Flame Graph (Live)</title>
- <style>
- :root {
- --fg-bg: #f6f1ea;
- --fg-panel: #fbf7f1;
- --fg-border: #d8cdc0;
- --fg-text: #232323;
- --fg-muted: #5f5f5f;
- --fg-accent: #7b2d1f;
- --fg-btn: #efe2d2;
- --fg-btn-hover: #e6d5c1;
- --fg-paused: #b02222;
- }
-
- * { box-sizing: border-box; }
-
- body {
- margin: 0;
- min-height: 100vh;
- background: linear-gradient(180deg, #f8f2ea 0%, #f2e9dc 100%);
- color: var(--fg-text);
- font-family: monospace;
- }
-
- #controls {
- position: sticky;
- top: 0;
- z-index: 1;
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
- padding: 10px 12px;
- background: var(--fg-panel);
- border-bottom: 1px solid var(--fg-border);
- }
-
- #controls button {
- border: 1px solid var(--fg-border);
- background: var(--fg-btn);
- color: var(--fg-text);
- font: inherit;
- font-size: 12px;
- line-height: 1.2;
- padding: 6px 10px;
- cursor: pointer;
- }
-
- #controls button:hover {
- background: var(--fg-btn-hover);
- }
-
- #controls .order-toggle {
- min-width: 220px;
- text-align: left;
- }
-
- #status {
- margin-left: 8px;
- font-size: 12px;
- color: var(--fg-muted);
- }
-
- .paused #status {
- color: var(--fg-paused);
- font-weight: 700;
- letter-spacing: 0.03em;
- text-transform: uppercase;
- }
-
- #flamegraph {
- display: block;
- width: 100%;
- height: calc(100vh - 56px);
- min-height: calc(100vh - 56px);
- background: transparent;
- }
-
- .title {
- font-size: 14px;
- font-family: monospace;
- }
-
- .controls text {
- font-size: 12px;
- font-family: monospace;
- cursor: pointer;
- fill: #444;
- }
-
- .frame text {
- font-size: 11px;
- font-family: monospace;
- pointer-events: none;
- fill: #111;
- }
-
- .frame rect {
- stroke: rgba(0, 0, 0, 0.18);
- stroke-width: 0.5;
- }
- </style>
-</head>
-<body>
- <div id="controls">
- <button id="btn-pause" type="button">Pause</button>
- <button id="btn-search" type="button">Search</button>
- <button id="btn-reset-search" type="button">Reset Search</button>
- <button id="btn-undo-zoom" type="button">Undo Zoom</button>
- <button id="btn-reset-zoom" type="button">Reset Zoom</button>
- <button id="btn-reset-baseline" type="button">Reset Baseline</button>
- <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > path > tracepoint</button>
- <span id="status">LIVE</span>
- </div>
-
- <svg id="flamegraph" xmlns="http://www.w3.org/2000/svg"></svg>
-
- <script>
- (function () {
- var fg = {
- paused: false,
- resetting: false,
- lastTreeData: null,
- pendingData: null,
- searchQuery: '',
- zoomStack: [],
- zoomRange: null,
- frames: [],
- rootWidth: 0,
- matchColor: 'rgb(220,30,70)',
- eventSource: null,
- svg: document.getElementById('flamegraph'),
- status: document.getElementById('status'),
- pauseBtn: document.getElementById('btn-pause'),
- searchBtn: document.getElementById('btn-search'),
- resetSearchBtn: document.getElementById('btn-reset-search'),
- undoZoomBtn: document.getElementById('btn-undo-zoom'),
- resetZoomBtn: document.getElementById('btn-reset-zoom'),
- resetBaselineBtn: document.getElementById('btn-reset-baseline'),
- toggleOrderBtn: document.getElementById('btn-toggle-order'),
- orderPresets: [
- 'comm,path,tracepoint',
- 'path,tracepoint,comm',
- 'tracepoint,comm,path',
- 'pid,path,tracepoint'
- ],
- orderIndex: 0,
- cfg: {
- baseWidth: 1200,
- baseFrameHeight: 16,
- width: 1200,
- frameHeight: 16,
- fontSize: 12,
- minWidthPx: 1.0
- }
- };
-
- function fgFrameColor(name) {
- var bytes = new TextEncoder().encode(name || '');
- var h = 2166136261 >>> 0;
- for (var i = 0; i < bytes.length; i++) {
- h ^= bytes[i];
- h = Math.imul(h, 16777619) >>> 0;
- }
- var r = 200 + (h % 35);
- var g = 80 + ((h >>> 8) % 120);
- var b = 40 + ((h >>> 16) % 90);
- return 'rgb(' + r + ',' + g + ',' + b + ')';
- }
-
- function fgMaxDepth(node, depth) {
- if (!node || !Array.isArray(node.c) || node.c.length === 0) {
- return depth;
- }
- var maxDepth = depth;
- for (var i = 0; i < node.c.length; i++) {
- var childDepth = fgMaxDepth(node.c[i], depth + 1);
- if (childDepth > maxDepth) {
- maxDepth = childDepth;
- }
- }
- return maxDepth;
- }
-
- function fgDefaultCanvasHeight(maxDepth) {
- return (fg.cfg.baseFrameHeight * (maxDepth + 1)) + 80;
- }
-
- function fgViewportLayout(maxDepth) {
- var rows = Math.max(maxDepth + 1, 1);
- var defaultCanvasHeight = fgDefaultCanvasHeight(maxDepth);
- var viewportWidth = Number(window.innerWidth || 0);
- if (viewportWidth <= 0 && document && document.documentElement) {
- viewportWidth = Number(document.documentElement.clientWidth || 0);
- }
- if (viewportWidth <= 0) {
- viewportWidth = fg.cfg.baseWidth;
- }
- var viewportHeight = Number(window.innerHeight || 0);
- if (viewportHeight <= 0) {
- return {
- width: viewportWidth,
- frameHeight: fg.cfg.baseFrameHeight,
- canvasHeight: defaultCanvasHeight
- };
- }
-
- var controls = document.getElementById('controls');
- var controlsHeight = 56;
- if (controls && typeof controls.getBoundingClientRect === 'function') {
- controlsHeight = Number(controls.getBoundingClientRect().height || controlsHeight);
- }
-
- var availableHeight = viewportHeight - controlsHeight;
- if (availableHeight <= 0) {
- return {
- width: viewportWidth,
- frameHeight: fg.cfg.baseFrameHeight,
- canvasHeight: defaultCanvasHeight
- };
- }
-
- var canvasHeight = Math.max(defaultCanvasHeight, availableHeight);
- var frameHeight = (canvasHeight - 80) / rows;
- if (frameHeight < fg.cfg.baseFrameHeight) {
- frameHeight = fg.cfg.baseFrameHeight;
- }
- return {
- width: viewportWidth,
- frameHeight: frameHeight,
- canvasHeight: canvasHeight
- };
- }
-
- function fgVisibleChildrenTotal(node) {
- var children = Array.isArray(node && node.c) ? node.c : [];
- var total = 0;
- for (var i = 0; i < children.length; i++) {
- total += Number(children[i].t || 0);
- }
- if (total > 0) {
- return total;
- }
- return Number(node && node.t || 0);
- }
-
- function fgBuildFrames(node, rootTotal, x, width, depth, canvasHeight, isRoot, out, path) {
- if (!node || rootTotal <= 0 || width <= 0) {
- return;
- }
- var currentPath = path || '';
- if (!isRoot) {
- var w = width;
- if (w < fg.cfg.minWidthPx) {
- return;
- }
- var name = node.n || '';
- currentPath = currentPath ? (currentPath + '\u001f' + name) : name;
- var y = canvasHeight - ((depth + 1) * fg.cfg.frameHeight);
- var total = Number(node.t || 0);
- var pct = 100 * total / Number(rootTotal);
- out.push({
- name: name,
- path: currentPath,
- x: x,
- y: y,
- w: w,
- h: fg.cfg.frameHeight - 1,
- depth: depth,
- total: total,
- pct: pct,
- fill: fgFrameColor(name)
- });
- }
- var cursor = x;
- var children = Array.isArray(node.c) ? node.c : [];
- var childrenTotal = fgVisibleChildrenTotal(node);
- if (childrenTotal <= 0) {
- return;
- }
- for (var i = 0; i < children.length; i++) {
- var child = children[i];
- var childTotal = Number(child.t || 0);
- if (childTotal <= 0) {
- continue;
- }
- var childWidth = width * (childTotal / childrenTotal);
- fgBuildFrames(child, rootTotal, cursor, childWidth, depth + 1, canvasHeight, false, out, currentPath);
- cursor += childWidth;
- }
- }
-
- function fgEscape(value) {
- return String(value || '')
- .replace(/&/g, '&amp;')
- .replace(/</g, '&lt;')
- .replace(/>/g, '&gt;')
- .replace(/"/g, '&quot;')
- .replace(/'/g, '&apos;');
- }
-
- function fgSetStatus(suffix) {
- var prefix = fg.paused ? 'PAUSED' : 'LIVE';
- fg.status.textContent = suffix ? (prefix + ' | ' + suffix) : prefix;
- }
-
- function fgOrderLabel(csv) {
- return String(csv || '').split(',').join(' > ');
- }
-
- function fgOrderFields(csv) {
- return String(csv || '').split(',').filter(function (s) { return s; });
- }
-
- function fgSetOrderIndexByCSV(csv) {
- for (var i = 0; i < fg.orderPresets.length; i++) {
- if (fg.orderPresets[i] === csv) {
- fg.orderIndex = i;
- return;
- }
- }
- }
-
- function fgUpdateOrderButton() {
- fg.toggleOrderBtn.textContent = 'Order: ' + fgOrderLabel(fg.orderPresets[fg.orderIndex] || '');
- }
-
- function fgHover(frame) {
- var title = frame.querySelector('title');
- fgSetStatus(title ? title.textContent : '');
- }
-
- function fgDetectRootWidth() {
- var maxEnd = 0;
- for (var i = 0; i < fg.frames.length; i++) {
- var x = Number(fg.frames[i].dataset.x || '0');
- var w = Number(fg.frames[i].dataset.w || '0');
- if (x + w > maxEnd) {
- maxEnd = x + w;
- }
- }
- return maxEnd;
- }
-
- function fgSnapshotOriginalGeometry(frame) {
- var rect = frame.querySelector('rect');
- var 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 fgFitLabel(text, width) {
- var full = text.dataset.full || text.textContent || '';
- var 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) + '...';