summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard/histogram.go
blob: 80b63098d55cb06b11a9f87a66f8327b56edc0b7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package dashboard

import (
	"fmt"
	"math"
	"strconv"
	"strings"

	"ior/internal/statsengine"
	common "ior/internal/tui/common"
)

func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string {
	if snap == nil {
		return common.PanelStyle.Render("Latency: waiting for stats...")
	}

	panelW := panelWidth(width)
	panelInner := panelInnerWidth(width)
	hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height)
	spark := common.PanelStyle.Width(panelW).Render(
		renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner),
	)
	return strings.Join([]string{hist, spark}, "\n")
}

func renderGapsTab(snap *statsengine.Snapshot, width, height int) string {
	if snap == nil {
		return common.PanelStyle.Render("Gaps: waiting for stats...")
	}

	panelW := panelWidth(width)
	panelInner := panelInnerWidth(width)
	hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height)
	spark := common.PanelStyle.Width(panelW).Render(
		renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner),
	)
	return strings.Join([]string{hist, spark}, "\n")
}

func renderLatencyGapsTab(snap *statsengine.Snapshot, width, height int) string {
	if snap == nil {
		return common.PanelStyle.Render("Latency+Gaps: waiting for stats...")
	}
	lat := renderLatencyTab(snap, width, height)
	gap := renderGapsTab(snap, width, height)
	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)

	buckets = clampHistogramBuckets(buckets, height)
	maxCount, labelWidth, countWidth := histogramMetrics(hist, buckets)
	barWidth := panelInner - labelWidth - countWidth - 6
	if barWidth < 8 {
		barWidth = 8
	}

	lines := make([]string, 0, len(buckets)+2)
	lines = append(lines, fmt.Sprintf("%s (total=%d)", title, hist.Total))
	for _, bucket := range buckets {
		bar := renderHistogramBar(bucket.Count, maxCount, barWidth)
		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 ""
	}

	ratio := float64(count) / float64(maxCount)
	length := int(math.Round(ratio * float64(width)))
	if length < 1 {
		length = 1
	}
	if length > width {
		length = width
	}

	char := '░'
	switch {
	case ratio >= 0.75:
		char = '█'
	case ratio >= 0.5:
		char = '▓'
	case ratio >= 0.25:
		char = '▒'
	}

	return strings.Repeat(string(char), length)
}