summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/dashboard')
-rw-r--r--internal/tui/dashboard/bubbles.go121
-rw-r--r--internal/tui/dashboard/histogram.go63
-rw-r--r--internal/tui/dashboard/icicle.go88
-rw-r--r--internal/tui/dashboard/treemap.go91
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)
}