diff options
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 121 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 63 | ||||
| -rw-r--r-- | internal/tui/dashboard/icicle.go | 88 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 91 |
4 files changed, 214 insertions, 149 deletions
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go index 96ebea1..eec75a5 100644 --- a/internal/tui/dashboard/bubbles.go +++ b/internal/tui/dashboard/bubbles.go @@ -156,6 +156,9 @@ func (c *bubbleChart) SetDarkMode(isDark bool) { c.isDark = isDark } +// SetData recomputes bubble targets from data and merges them with existing +// animation state so that live updates animate smoothly. Returns true when +// at least one node has motion and a Tick should be scheduled. func (c *bubbleChart) SetData(data []bubbleDatum) bool { targets := buildBubbleTargets(data, c.Metric(), c.width, c.height) @@ -169,6 +172,23 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool { existing[node.ID] = node } + c.nodes = c.mergeTargetNodes(targets, existing) + if len(c.nodes) == 0 { + c.selected = 0 + c.animating = false + return false + } + c.selected = c.selectIndexByID(selectedID) + c.animating = c.hasMotion() + if c.animating { + c.Tick(0) + } + return c.animating +} + +// mergeTargetNodes converts target positions into live nodes, carrying over +// spring velocities and drift state from existing nodes where available. +func (c *bubbleChart) mergeTargetNodes(targets []bubbleNode, existing map[string]bubbleNode) []bubbleNode { next := make([]bubbleNode, 0, len(targets)) for _, target := range targets { node := bubbleNode{ @@ -188,25 +208,7 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool { ySpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping), } if prev, ok := existing[target.ID]; ok { - node.radius = prev.radius - node.x = prev.x - node.y = prev.y - node.velocityRadius = prev.velocityRadius - node.velocityX = prev.velocityX - node.velocityY = prev.velocityY - node.driftPhase = prev.driftPhase - node.driftSpeed = prev.driftSpeed - node.driftAmpX = prev.driftAmpX - node.driftAmpY = prev.driftAmpY - // New metrics or topology can otherwise produce stale springs. - if node.radius == 0 { - node.radius = target.targetRadius - } - if node.driftSpeed == 0 { - c.initNodeDrift(&node) - } else { - c.updateNodeDriftAmplitude(&node) - } + c.inheritPrevNodeState(&node, prev, target) } else { node.radius = target.targetRadius node.x = target.targetX @@ -216,18 +218,31 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool { node.applyDrift(c.driftTime, c.width, c.height) next = append(next, node) } - c.nodes = next - if len(c.nodes) == 0 { - c.selected = 0 - c.animating = false - return false - } - c.selected = c.selectIndexByID(selectedID) - c.animating = c.hasMotion() - if c.animating { - c.Tick(0) + return next +} + +// inheritPrevNodeState copies physics and drift state from a previous node +// into node so that the transition animates rather than snapping. +func (c *bubbleChart) inheritPrevNodeState(node *bubbleNode, prev bubbleNode, target bubbleNode) { + node.radius = prev.radius + node.x = prev.x + node.y = prev.y + node.velocityRadius = prev.velocityRadius + node.velocityX = prev.velocityX + node.velocityY = prev.velocityY + node.driftPhase = prev.driftPhase + node.driftSpeed = prev.driftSpeed + node.driftAmpX = prev.driftAmpX + node.driftAmpY = prev.driftAmpY + // New metrics or topology can otherwise produce stale springs. + if node.radius == 0 { + node.radius = target.targetRadius + } + if node.driftSpeed == 0 { + c.initNodeDrift(node) + } else { + c.updateNodeDriftAmplitude(node) } - return c.animating } func (c *bubbleChart) selectIndexByID(id string) int { @@ -641,10 +656,10 @@ func renderBubbleRow(cells []bubbleCell, palette []color.Color) string { return b.String() } +// buildBubbleTargets computes initial target positions and radii for each +// bubble, then runs a short relaxation pass to reduce overlap. Returns nil +// when there is nothing to render. func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height int) []bubbleNode { - if len(data) == 0 { - return nil - } if width <= 0 { width = 80 } @@ -655,6 +670,20 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i if chartHeight < 4 { chartHeight = 4 } + + filtered := filterAndSortBubbleData(data, metric) + if len(filtered) == 0 { + return nil + } + + targets := placeBubbleNodes(filtered, metric, width, chartHeight) + relaxTargets(targets, width, chartHeight) + return targets +} + +// filterAndSortBubbleData removes datums without an ID, sorts by descending +// metric value (ties broken by label), and caps the result to bubbleMaxItems. +func filterAndSortBubbleData(data []bubbleDatum, metric bubbleMetric) []bubbleDatum { filtered := make([]bubbleDatum, 0, len(data)) for _, datum := range data { if datum.ID == "" { @@ -662,9 +691,6 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i } filtered = append(filtered, datum) } - if len(filtered) == 0 { - return nil - } slices.SortFunc(filtered, func(a, b bubbleDatum) int { va := bubbleValue(a, metric) vb := bubbleValue(b, metric) @@ -676,34 +702,40 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i if len(filtered) > bubbleMaxItems { filtered = filtered[:bubbleMaxItems] } + return filtered +} + +// placeBubbleNodes converts sorted bubble data into node structs with target +// positions arranged in a golden-angle spiral around the chart centre. +func placeBubbleNodes(filtered []bubbleDatum, metric bubbleMetric, width, chartHeight int) []bubbleNode { maxValue := uint64(0) for _, datum := range filtered { - value := bubbleValue(datum, metric) - if value > maxValue { - maxValue = value + if v := bubbleValue(datum, metric); v > maxValue { + maxValue = v } } if maxValue == 0 { maxValue = 1 } + minRadius := 1.7 maxRadius := math.Min(float64(width)/6.0, float64(chartHeight)/2.6) if maxRadius < 2.4 { maxRadius = 2.4 } - targets := make([]bubbleNode, 0, len(filtered)) + cx := float64(width-1) / 2.0 cy := float64(chartHeight-1) / 2.0 goldenAngle := math.Pi * (3.0 - math.Sqrt(5.0)) spacingBase := maxRadius * 0.95 + + targets := make([]bubbleNode, 0, len(filtered)) for idx, datum := range filtered { value := bubbleValue(datum, metric) ratio := math.Sqrt(float64(value) / float64(maxValue)) targetRadius := minRadius + ratio*(maxRadius-minRadius) distance := spacingBase * math.Sqrt(float64(idx)+0.6) angle := float64(idx) * goldenAngle - targetX := cx + math.Cos(angle)*distance - targetY := cy + math.Sin(angle)*distance*0.68 targets = append(targets, bubbleNode{ ID: datum.ID, Label: datum.Label, @@ -713,11 +745,10 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i Duration: datum.Duration, Value: value, targetRadius: targetRadius, - targetX: targetX, - targetY: targetY, + targetX: cx + math.Cos(angle)*distance, + targetY: cy + math.Sin(angle)*distance*0.68, }) } - relaxTargets(targets, width, chartHeight) return targets } diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 28f5b2b..80b6309 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -47,43 +47,20 @@ func renderLatencyGapsTab(snap *statsengine.Snapshot, width, height int) string return strings.Join([]string{lat, gap}, "\n") } +// renderHistogram renders a histogram snapshot as a bar chart panel. func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, height int) string { buckets := hist.Buckets() if len(buckets) == 0 { return common.PanelStyle.Render(title + ": no data") } - if width <= 0 { width = 80 } panelW := panelWidth(width) panelInner := panelInnerWidth(width) - if height > 0 { - maxRows := height - 3 - if maxRows < 1 { - maxRows = 1 - } - if len(buckets) > maxRows { - buckets = buckets[:maxRows] - } - } - - maxCount := uint64(0) - labelWidth := 0 - countWidth := len(strconv.FormatUint(hist.Total, 10)) - for _, bucket := range buckets { - if bucket.Count > maxCount { - maxCount = bucket.Count - } - if len(bucket.Label) > labelWidth { - labelWidth = len(bucket.Label) - } - if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth { - countWidth = digits - } - } - + buckets = clampHistogramBuckets(buckets, height) + maxCount, labelWidth, countWidth := histogramMetrics(hist, buckets) barWidth := panelInner - labelWidth - countWidth - 6 if barWidth < 8 { barWidth = 8 @@ -96,10 +73,42 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he lines = append(lines, fmt.Sprintf("%-*s | %-*s %*d", labelWidth, bucket.Label, barWidth, bar, countWidth, bucket.Count)) } lines = append(lines, "Scale: █▓▒░") - return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n")) } +// clampHistogramBuckets trims the bucket slice to fit within the available rows. +func clampHistogramBuckets(buckets []statsengine.HistogramBucketSnapshot, height int) []statsengine.HistogramBucketSnapshot { + if height <= 0 { + return buckets + } + maxRows := height - 3 + if maxRows < 1 { + maxRows = 1 + } + if len(buckets) > maxRows { + return buckets[:maxRows] + } + return buckets +} + +// histogramMetrics computes the maximum count, maximum label width, and maximum +// count-digit width needed to align the histogram columns. +func histogramMetrics(hist statsengine.HistogramSnapshot, buckets []statsengine.HistogramBucketSnapshot) (maxCount uint64, labelWidth, countWidth int) { + countWidth = len(strconv.FormatUint(hist.Total, 10)) + for _, bucket := range buckets { + if bucket.Count > maxCount { + maxCount = bucket.Count + } + if len(bucket.Label) > labelWidth { + labelWidth = len(bucket.Label) + } + if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth { + countWidth = digits + } + } + return maxCount, labelWidth, countWidth +} + func renderHistogramBar(count, maxCount uint64, width int) string { if count == 0 || maxCount == 0 || width <= 0 { return "" diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go index 560bb2a..019100c 100644 --- a/internal/tui/dashboard/icicle.go +++ b/internal/tui/dashboard/icicle.go @@ -28,6 +28,7 @@ type icicleTile struct { colorSlot int } +// renderFilesIcicle renders the icicle chart for directory-based file stats. func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string { if snap == nil { return "Files icicle: waiting for stats..." @@ -39,28 +40,44 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub height = 18 } header := fmt.Sprintf("Files icicle | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric)) - dirs := aggregateFilesByDir(snap.Files()) - if len(dirs) == 0 { + + tiles, ok := buildIcicleTiles(snap, width, height, metric) + if !ok { return header + "\nFiles icicle: no directory data\nsel: none" } + if len(tiles) == 0 { + return header + "\nFiles icicle: no visible tiles\nsel: none" + } + return renderIcicleGrid(header, tiles, width, height, metric, selected, isDark) +} +// buildIcicleTiles constructs the icicle tile layout from the snapshot's file data. +// Returns (nil, false) when there is no data to display. +func buildIcicleTiles(snap *statsengine.Snapshot, width, height int, metric bubbleMetric) ([]icicleTile, bool) { + dirs := aggregateFilesByDir(snap.Files()) + if len(dirs) == 0 { + return nil, false + } root := buildIcicleTree(dirs) children := sortedIcicleChildren(root, metric) if len(children) == 0 { - return header + "\nFiles icicle: no directory data\nsel: none" + return nil, false } - chartHeight := height - 2 if chartHeight < 4 { chartHeight = 4 } - tiles := make([]icicleTile, 0, 64) layoutIcicle(children, 0, width, 0, chartHeight, 0, metric, &tiles) - if len(tiles) == 0 { - return header + "\nFiles icicle: no visible tiles\nsel: none" - } + return tiles, true +} +// renderIcicleGrid fills a 2-D grid with icicle tiles and assembles the final string. +func renderIcicleGrid(header string, tiles []icicleTile, width, height int, metric bubbleMetric, selected int, isDark bool) string { + chartHeight := height - 2 + if chartHeight < 4 { + chartHeight = 4 + } selected = clampOffset(selected, len(tiles)) grid := make([][]treemapCell, chartHeight) for row := 0; row < chartHeight; row++ { @@ -71,7 +88,6 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub } fillIcicleGrid(grid, tiles, selected) palette := treemapPalette(isDark) - lines := make([]string, 0, chartHeight+2) lines = append(lines, padOrTrim(header, width)) for _, row := range grid { @@ -184,14 +200,13 @@ func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode { return out } +// layoutIcicle recursively lays out icicle chart tiles for one depth level, +// distributing the available width among nodes proportional to their metric values. func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, metric bubbleMetric, out *[]icicleTile) { if len(nodes) == 0 || width <= 0 || depth >= maxDepth { return } - total := uint64(0) - for _, node := range nodes { - total += icicleValue(node, metric) - } + total := icicleNodeTotal(nodes, metric) if total == 0 { return } @@ -201,17 +216,7 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, cursor := x for idx, node := range nodes { value := icicleValue(node, metric) - tileWidth := remainingWidth - if idx < len(nodes)-1 { - tileWidth = int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue))) - minRemaining := len(nodes) - idx - 1 - if tileWidth < 1 { - tileWidth = 1 - } - if tileWidth > remainingWidth-minRemaining { - tileWidth = remainingWidth - minRemaining - } - } + tileWidth := icicleTileWidth(idx, len(nodes), value, remainingWidth, remainingValue) if tileWidth <= 0 { continue } @@ -219,17 +224,10 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, if depth == 0 { colorSlot = idx } - *out = append(*out, icicleTile{ - node: node, - depth: depth, - x: cursor, - w: tileWidth, - colorSlot: colorSlot, - }) + *out = append(*out, icicleTile{node: node, depth: depth, x: cursor, w: tileWidth, colorSlot: colorSlot}) if depth+1 < maxDepth { layoutIcicle(sortedIcicleChildren(node, metric), cursor, tileWidth, depth+1, maxDepth, colorSlot, metric, out) } - cursor += tileWidth remainingWidth -= tileWidth remainingValue -= value @@ -239,6 +237,32 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, } } +// icicleNodeTotal sums the metric values of all nodes in the slice. +func icicleNodeTotal(nodes []*icicleNode, metric bubbleMetric) uint64 { + total := uint64(0) + for _, node := range nodes { + total += icicleValue(node, metric) + } + return total +} + +// icicleTileWidth computes the pixel width to allocate to the node at idx. +// The last node gets the full remaining width to avoid rounding gaps. +func icicleTileWidth(idx, total int, value uint64, remainingWidth int, remainingValue uint64) int { + if idx == total-1 { + return remainingWidth + } + tileWidth := int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue))) + minRemaining := total - idx - 1 + if tileWidth < 1 { + tileWidth = 1 + } + if tileWidth > remainingWidth-minRemaining { + tileWidth = remainingWidth - minRemaining + } + return tileWidth +} + func fillIcicleGrid(grid [][]treemapCell, tiles []icicleTile, selected int) { height := len(grid) if height == 0 { diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go index 4d5486a..cc9f44d 100644 --- a/internal/tui/dashboard/treemap.go +++ b/internal/tui/dashboard/treemap.go @@ -251,70 +251,74 @@ func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallT return tiles } +// layoutSyscallTreemapInto recursively partitions items into tiles using a +// binary split strategy. Items are bisected near the median value and placed +// into the left/right (vertical split) or top/bottom (horizontal split) halves. func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex int, out *[]syscallTreemapTile) { if len(items) == 0 || w <= 0 || h <= 0 { return } - if len(items) == 1 { + + total := sumTreemapValues(items) + if len(items) == 1 || total == 0 { + // Degenerate case: single item or all-zero values — fill the whole rect. *out = append(*out, syscallTreemapTile{ - item: items[0], - index: baseIndex, - x: x, - y: y, - w: w, - h: h, + item: items[0], index: baseIndex, + x: x, y: y, w: w, h: h, }) return } + splitAt := findTreemapSplitIndex(items, total) + first, second := items[:splitAt], items[splitAt:] + firstTotal := sumTreemapValues(first) + + if chooseSplitVertical(w, h) { + layoutTreemapVertical(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out) + } else { + layoutTreemapHorizontal(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out) + } +} + +// sumTreemapValues returns the sum of Value fields across items. +func sumTreemapValues(items []syscallTreemapItem) uint64 { total := uint64(0) for _, item := range items { total += item.Value } - if total == 0 { - *out = append(*out, syscallTreemapTile{ - item: items[0], - index: baseIndex, - x: x, - y: y, - w: w, - h: h, - }) - return - } - - splitAt := findTreemapSplitIndex(items, total) - first := items[:splitAt] - second := items[splitAt:] - firstTotal := uint64(0) - for _, item := range first { - firstTotal += item.Value - } + return total +} +// chooseSplitVertical returns true when the rectangle should be split along +// the vertical axis (left/right), using aspect-ratio heuristics. +func chooseSplitVertical(w, h int) bool { splitVertical := w >= h if splitVertical && w <= 1 { - splitVertical = false + return false } if !splitVertical && h <= 1 { - splitVertical = true + return true } + return splitVertical +} - if splitVertical { - w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total))) - if w1 < 1 { - w1 = 1 - } - if w1 >= w { - w1 = w - 1 - } - if w1 <= 0 { - w1 = 1 - } - layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out) - layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out) - return +// layoutTreemapVertical splits the items into left (first) and right (second) +// columns proportional to their value totals and recurses into each column. +func layoutTreemapVertical(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) { + w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total))) + if w1 < 1 { + w1 = 1 + } + if w1 >= w { + w1 = w - 1 } + layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out) + layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out) +} +// layoutTreemapHorizontal splits items into top (first) and bottom (second) +// rows proportional to their value totals and recurses into each row. +func layoutTreemapHorizontal(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) { h1 := int(math.Round(float64(h) * float64(firstTotal) / float64(total))) if h1 < 1 { h1 = 1 @@ -322,9 +326,6 @@ func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex if h1 >= h { h1 = h - 1 } - if h1 <= 0 { - h1 = 1 - } layoutSyscallTreemapInto(first, x, y, w, h1, baseIndex, out) layoutSyscallTreemapInto(second, x, y+h1, w, h-h1, baseIndex+splitAt, out) } |
