diff options
Diffstat (limited to 'internal/flamegraph')
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, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - 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) + '...'; |
