mini_histogram 0.1.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f74ffb350c63f9f26ea90db6a41c8135c3b2e180de663a1747d649a47e8e2df4
4
- data.tar.gz: 3a69ae4f5a0f4d64d830f7a39b0b79f3ab011cae5cf7479328ee8a907b5f7820
3
+ metadata.gz: 18c06d87cb7f009ee27eac4b00937c9d1165c23505bb2556d70e9619b0c7ef29
4
+ data.tar.gz: 89bc2da7c674e546ed261a8be2682edd5c99ed778de846ee861008b1b9dd887a
5
5
  SHA512:
6
- metadata.gz: 477c617614b9ed9202dc2f42aa6c3571a44651173ffb7357c5650aefedaeb599b9a98b38daa107e9c2002f8c59700cc2e349ddb2ead5412e99c339d37f4f6691
7
- data.tar.gz: a6dc9302632ab19b9264c5787d31fe8387dc9130669ec0fe2ae7860bab6fbcb10fee9d712943aa00a3a41167873fee214df9c07c5af2101dc5914da5662f9472
6
+ metadata.gz: 93b3be0e00220e5a3d212cffbd5a0058bc03bbb1c94903b8489e10d495d621961130f35b90e880bd953181516c5395ea728948242ba33a54147f7e20ff815a65
7
+ data.tar.gz: eb3ebd7429bba8a38654c54f989e9a55fb6902b626032ee3da757c287deb8641daf3a2d38759478c8486a1fbdaff1bd51ff87e1f1690a9c92c800e4d44326757
@@ -1,4 +1,24 @@
1
- ## Master
1
+ ## HEAD
2
+
3
+ ## 0.3.1
4
+
5
+ - Add missing require for stringio (https://github.com/zombocom/mini_histogram/pull/7)
6
+
7
+ ## 0.3.0
8
+
9
+ - Generate dualing side-by-side histograms (https://github.com/zombocom/mini_histogram/pull/6)
10
+
11
+ ## 0.2.2
12
+
13
+ - Frozen string optimization in histogram/plot.rb (https://github.com/zombocom/mini_histogram/pull/5)
14
+
15
+ ## 0.2.1
16
+
17
+ - Added missing constant needed for plotting support (https://github.com/zombocom/mini_histogram/pull/4)
18
+
19
+ ## 0.2.0
20
+
21
+ - Experimental plotting support added (https://github.com/zombocom/mini_histogram/pull/3)
2
22
 
3
23
  ## 0.1.3
4
24
 
data/README.md CHANGED
@@ -38,6 +38,109 @@ puts histogram.weights
38
38
 
39
39
  This means that the `array` here had three items between 0.0 and 2.0, four items between 4.0 and 6.0 and three items between 10.0 and 12.0
40
40
 
41
+ ## Plotting [experimental]
42
+
43
+ You can plot!
44
+
45
+ ```ruby
46
+ require 'mini_histogram/plot'
47
+ array = 50.times.map { rand(11.2..11.6) }
48
+ histogram = MiniHistogram.new(array)
49
+ puts histogram.plot
50
+ ```
51
+
52
+ Will generate:
53
+
54
+ ```
55
+ ┌ ┐
56
+ [11.2 , 11.25) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 9
57
+ [11.25, 11.3 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 6
58
+ [11.3 , 11.35) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4
59
+ [11.35, 11.4 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4
60
+ [11.4 , 11.45) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 11
61
+ [11.45, 11.5 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 5
62
+ [11.5 , 11.55) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 7
63
+ [11.55, 11.6 ) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇ 4
64
+ └ ┘
65
+ Frequency
66
+ ```
67
+
68
+ Integrated plotting is an experimental currently, use with some caution. If you are on Ruby 2.4+ you can pass an instance of MiniHistogram to [unicode_plot.rb](https://github.com/red-data-tools/unicode_plot.rb):
69
+
70
+ ```ruby
71
+ array = 50.times.map { rand(11.2..11.6) }
72
+ histogram = MiniHistogram.new(array)
73
+ puts UnicodePlot.histogram(histogram)
74
+ ```
75
+
76
+ ## Plotting dualing histograms [experimental]
77
+
78
+ If you're plotting multiple histograms (first, please normalize the bucket sizes), second. It can be hard to compare them vertically. Here's an example:
79
+
80
+ ```
81
+ ┌ ┐
82
+ [11.2 , 11.28) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12
83
+ [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 22
84
+ [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30
85
+ [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17
86
+ [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13
87
+ [11.58, 11.66) ┤▇▇▇▇▇▇▇ 6
88
+ [11.65, 11.73) ┤ 0
89
+ [11.73, 11.81) ┤ 0
90
+ [11.8 , 11.88) ┤ 0
91
+ └ ┘
92
+ Frequency
93
+ ┌ ┐
94
+ [11.2 , 11.28) ┤▇▇▇▇ 3
95
+ [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 19
96
+ [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17
97
+ [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25
98
+ [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 15
99
+ [11.58, 11.66) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13
100
+ [11.65, 11.73) ┤▇▇▇▇ 3
101
+ [11.73, 11.81) ┤▇▇▇▇ 3
102
+ [11.8 , 11.88) ┤▇▇▇ 2
103
+ └ ┘
104
+ Frequency
105
+ ```
106
+
107
+ Here's the same data set plotted side-by-side:
108
+
109
+ ```
110
+ ┌ ┐ ┌ ┐
111
+ [11.2 , 11.28) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 12 [11.2 , 11.28) ┤▇▇▇▇ 3
112
+ [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 22 [11.28, 11.36) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 19
113
+ [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 30 [11.35, 11.43) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17
114
+ [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 17 [11.43, 11.51) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 25
115
+ [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13 [11.5 , 11.58) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 15
116
+ [11.58, 11.66) ┤▇▇▇▇▇▇▇ 6 [11.58, 11.66) ┤▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇ 13
117
+ [11.65, 11.73) ┤ 0 [11.65, 11.73) ┤▇▇▇▇ 3
118
+ [11.73, 11.81) ┤ 0 [11.73, 11.81) ┤▇▇▇▇ 3
119
+ [11.8 , 11.88) ┤ 0 [11.8 , 11.88) ┤▇▇▇ 2
120
+ └ ┘ └ ┘
121
+ Frequency Frequency
122
+ ```
123
+
124
+ This method might require more scrolling in the github issue, but makes it easier to compare two distributions. Here's how you plot dualing histograms:
125
+
126
+ ```ruby
127
+ require 'mini_histogram/plot'
128
+
129
+ a = MiniHistogram.new [11.205184, 11.223665, 11.228286, 11.23219, 11.233325, 11.234516, 11.245781, 11.248441, 11.250758, 11.255686, 11.265876, 11.26641, 11.279456, 11.281067, 11.284281, 11.287656, 11.289316, 11.289682, 11.292289, 11.294518, 11.296454, 11.299277, 11.305801, 11.306602, 11.309311, 11.318465, 11.318477, 11.322258, 11.328267, 11.334188, 11.339722, 11.340585, 11.346084, 11.346197, 11.351863, 11.35982, 11.362358, 11.364476, 11.365743, 11.368492, 11.368566, 11.36869, 11.37268, 11.374204, 11.374217, 11.374955, 11.376422, 11.377989, 11.383357, 11.383593, 11.385184, 11.394766, 11.395829, 11.398455, 11.399739, 11.401304, 11.411387, 11.411978, 11.413585, 11.413659, 11.418504, 11.419194, 11.419415, 11.421374, 11.4261, 11.427901, 11.429651, 11.434272, 11.435012, 11.440848, 11.447495, 11.456107, 11.457434, 11.467112, 11.471005, 11.473235, 11.485025, 11.485852, 11.488256, 11.488275, 11.499545, 11.509588, 11.51378, 11.51544, 11.520783, 11.52246, 11.522855, 11.5322, 11.533764, 11.544047, 11.552597, 11.558062, 11.567239, 11.569749, 11.575796, 11.588014, 11.614032, 11.615062, 11.618194, 11.635267]
130
+ b = MiniHistogram.new [11.233813, 11.240717, 11.254617, 11.282013, 11.290658, 11.303213, 11.305237, 11.305299, 11.306397, 11.313867, 11.31397, 11.314444, 11.318032, 11.328111, 11.330127, 11.333235, 11.33678, 11.337799, 11.343758, 11.347798, 11.347915, 11.349594, 11.358198, 11.358507, 11.3628, 11.366111, 11.374993, 11.378195, 11.38166, 11.384867, 11.385235, 11.395825, 11.404434, 11.406065, 11.406677, 11.410244, 11.414527, 11.421267, 11.424535, 11.427231, 11.427869, 11.428548, 11.432594, 11.433524, 11.434903, 11.437769, 11.439761, 11.443437, 11.443846, 11.451106, 11.458503, 11.462256, 11.462324, 11.464342, 11.464716, 11.46477, 11.465271, 11.466843, 11.468789, 11.475492, 11.488113, 11.489616, 11.493736, 11.496842, 11.502074, 11.511367, 11.512634, 11.515562, 11.525771, 11.531415, 11.535379, 11.53966, 11.540969, 11.541265, 11.541978, 11.545301, 11.545533, 11.545701, 11.572584, 11.578881, 11.580701, 11.580922, 11.588731, 11.594082, 11.595915, 11.613622, 11.619884, 11.632889, 11.64377, 11.645225, 11.647167, 11.648257, 11.667158, 11.670378, 11.681261, 11.734586, 11.747066, 11.792425, 11.808377, 11.812346]
131
+
132
+ dual_histogram = MiniHistogram.dual_plot do |x, y|
133
+ x.histogram = a
134
+ x.options = {}
135
+ y.histogram = b
136
+ y.options = {}
137
+ end
138
+ puts dual_histogram
139
+ ```
140
+
141
+
142
+ ## Alternatives
143
+
41
144
  Alternatives to this gem include https://github.com/mrkn/enumerable-statistics/. I needed this gem to be able to calculate a "shared" or "average" edge value as seen in this PR https://github.com/mrkn/enumerable-statistics/pull/23. So that I could add histograms to derailed benchmarks: https://github.com/schneems/derailed_benchmarks/pull/169. This gem provides a `MiniHistogram.set_average_edges!` method to help there. Also this gem does not require a native extension compilation (faster to install, but performance is slower), and this gem does not extend or monkeypatch an core classes.
42
145
 
43
146
  [MiniHistogram API Docs](https://rubydoc.info/github/zombocom/mini_histogram/master/MiniHistogram)
@@ -198,6 +198,10 @@ class MiniHistogram
198
198
  end
199
199
  alias :edge :edges
200
200
 
201
+ def plot
202
+ raise "You must `require 'mini_histogram/plot'` to get this feature"
203
+ end
204
+
201
205
  # Given an array of Histograms this function calcualtes
202
206
  # an average edge size along with the minimum and maximum
203
207
  # edge values. It then updates the edge value on all inputs
@@ -226,3 +230,4 @@ class MiniHistogram
226
230
  return array_of_histograms
227
231
  end
228
232
  end
233
+
@@ -0,0 +1,785 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require_relative '../mini_histogram' # allows people to require 'mini_histogram/plot' directly
5
+
6
+ # Plots the histogram in unicode characters
7
+ #
8
+ # Thanks to https://github.com/red-data-tools/unicode_plot.rb
9
+ # it could not be used because the dependency enumerable-statistics has a hard
10
+ # lock on a specific version of Ruby and this library needs to support older Rubies
11
+ #
12
+ # Example:
13
+ #
14
+ # require 'mini_histogram/plot'
15
+ # array = 50.times.map { rand(11.2..11.6) }
16
+ # histogram = MiniHistogram.new(array)
17
+ # puts histogram.plot => Generates a plot
18
+ #
19
+ class MiniHistogram
20
+
21
+ # This is an object that holds a histogram
22
+ # and it's corresponding plot options
23
+ #
24
+ # Example:
25
+ #
26
+ # x = PlotValue.new
27
+ # x.values = [1,2,3,4,5]
28
+ # x.options = {xlabel: "random"}
29
+ #
30
+ # x.plot # => Generates a histogram plot with these values and options
31
+ class PlotValue
32
+ attr_accessor :histogram, :options
33
+
34
+ def initialize
35
+ @histogram = nil
36
+ @options = {}
37
+ end
38
+
39
+ def plot
40
+ raise "@histogram cannot be empty set via `values=` or `histogram=` methods" if @histogram.nil?
41
+
42
+ @histogram.plot(**@options)
43
+ end
44
+
45
+ def values=(values)
46
+ @histogram = MiniHistogram.new(values)
47
+ end
48
+
49
+ def self.dual_plot(plot_a, plot_b)
50
+ a_lines = plot_a.to_s.lines
51
+ b_lines = plot_b.to_s.lines
52
+
53
+ max_length = a_lines.map(&:length).max
54
+
55
+ side_by_side = String.new("")
56
+ a_lines.each_index do |i|
57
+ side_by_side << a_lines[i].chomp.ljust(max_length) # Remove newline, ensure same length
58
+ side_by_side << b_lines[i]
59
+ end
60
+
61
+ return side_by_side
62
+ end
63
+ end
64
+ private_constant :PlotValue
65
+
66
+ def self.dual_plot
67
+ a = PlotValue.new
68
+ b = PlotValue.new
69
+
70
+ yield a, b
71
+
72
+ if b.options[:ylabel] == a.options[:ylabel]
73
+ b.options[:ylabel] = nil
74
+ end
75
+
76
+ MiniHistogram.set_average_edges!(a.histogram, b.histogram)
77
+ PlotValue.dual_plot(a.plot, b.plot)
78
+ end
79
+
80
+ def plot(
81
+ nbins: nil,
82
+ closed: :left,
83
+ symbol: "▇",
84
+ **kw)
85
+ hist = self.histogram(*[nbins].compact, closed: closed)
86
+ edge, counts = hist.edge, hist.weights
87
+ labels = []
88
+ bin_width = edge[1] - edge[0]
89
+ pad_left, pad_right = 0, 0
90
+ (0 ... edge.length).each do |i|
91
+ val1 = float_round_log10(edge[i], bin_width)
92
+ val2 = float_round_log10(val1 + bin_width, bin_width)
93
+ a1 = val1.to_s.split('.', 2).map(&:length)
94
+ a2 = val2.to_s.split('.', 2).map(&:length)
95
+ pad_left = [pad_left, a1[0], a2[0]].max
96
+ pad_right = [pad_right, a1[1], a2[1]].max
97
+ end
98
+ l_str = hist.closed == :right ? "(" : "["
99
+ r_str = hist.closed == :right ? "]" : ")"
100
+ counts.each_with_index do |n, i|
101
+ val1 = float_round_log10(edge[i], bin_width)
102
+ val2 = float_round_log10(val1 + bin_width, bin_width)
103
+ a1 = val1.to_s.split('.', 2).map(&:length)
104
+ a2 = val2.to_s.split('.', 2).map(&:length)
105
+ labels[i] = "\e[90m#{l_str}\e[0m" +
106
+ (" " * (pad_left - a1[0])) +
107
+ val1.to_s +
108
+ (" " * (pad_right - a1[1])) +
109
+ "\e[90m, \e[0m" +
110
+ (" " * (pad_left - a2[0])) +
111
+ val2.to_s +
112
+ (" " * (pad_right - a2[1])) +
113
+ "\e[90m#{r_str}\e[0m"
114
+ end
115
+ xscale = kw.delete(:xscale)
116
+ xlabel = kw.delete(:xlabel) || MiniUnicodePlot::ValueTransformer.transform_name(xscale, "Frequency")
117
+ barplot(labels, counts,
118
+ symbol: symbol,
119
+ xscale: xscale,
120
+ xlabel: xlabel,
121
+ **kw)
122
+ end
123
+
124
+ ## Begin copy/pasta from unicode_plot.rb with some slight modifications
125
+ private def barplot(
126
+ *args,
127
+ width: 40,
128
+ color: :green,
129
+ symbol: "■",
130
+ border: :barplot,
131
+ xscale: nil,
132
+ xlabel: nil,
133
+ data: nil,
134
+ **kw)
135
+ case args.length
136
+ when 0
137
+ data = Hash(data)
138
+ keys = data.keys.map(&:to_s)
139
+ heights = data.values
140
+ when 2
141
+ keys = Array(args[0])
142
+ heights = Array(args[1])
143
+ else
144
+ raise ArgumentError, "invalid arguments"
145
+ end
146
+
147
+ unless keys.length == heights.length
148
+ raise ArgumentError, "The given vectors must be of the same length"
149
+ end
150
+ unless heights.min >= 0
151
+ raise ArgumentError, "All values have to be positive. Negative bars are not supported."
152
+ end
153
+
154
+ xlabel ||= ValueTransformer.transform_name(xscale)
155
+ plot = MiniUnicodePlot::Barplot.new(heights, width, color, symbol, xscale,
156
+ border: border, xlabel: xlabel,
157
+ **kw)
158
+ keys.each_with_index do |key, i|
159
+ plot.annotate_row!(:l, i, key)
160
+ end
161
+
162
+ plot
163
+ end
164
+
165
+ private def float_round_log10(x, m)
166
+ if x == 0
167
+ 0.0
168
+ elsif x > 0
169
+ x.round(ceil_neg_log10(m) + 1).to_f
170
+ else
171
+ -(-x).round(ceil_neg_log10(m) + 1).to_f
172
+ end
173
+ end
174
+
175
+ private def ceil_neg_log10(x)
176
+ if roundable?(-Math.log10(x))
177
+ (-Math.log10(x)).ceil
178
+ else
179
+ (-Math.log10(x)).floor
180
+ end
181
+ end
182
+
183
+ INT64_MIN = -9223372036854775808
184
+ INT64_MAX = 9223372036854775807
185
+ private def roundable?(x)
186
+ x.to_i == x && INT64_MIN <= x && x < INT64_MAX
187
+ end
188
+
189
+ module MiniUnicodePlot
190
+ module ValueTransformer
191
+ PREDEFINED_TRANSFORM_FUNCTIONS = {
192
+ log: Math.method(:log),
193
+ ln: Math.method(:log),
194
+ log10: Math.method(:log10),
195
+ lg: Math.method(:log10),
196
+ log2: Math.method(:log2),
197
+ lb: Math.method(:log2),
198
+ }.freeze
199
+
200
+ def transform_values(func, values)
201
+ return values unless func
202
+
203
+ unless func.respond_to?(:call)
204
+ func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
205
+ unless func.respond_to?(:call)
206
+ raise ArgumentError, "func must be callable"
207
+ end
208
+ end
209
+
210
+ case values
211
+ when Numeric
212
+ func.(values)
213
+ else
214
+ values.map(&func)
215
+ end
216
+ end
217
+
218
+ module_function def transform_name(func, basename="")
219
+ return basename unless func
220
+ case func
221
+ when String, Symbol
222
+ name = func
223
+ when ->(f) { f.respond_to?(:name) }
224
+ name = func.name
225
+ else
226
+ name = "custom"
227
+ end
228
+ "#{basename} [#{name}]"
229
+ end
230
+ end
231
+
232
+
233
+ module BorderMaps
234
+ BORDER_SOLID = {
235
+ tl: "┌",
236
+ tr: "┐",
237
+ bl: "└",
238
+ br: "┘",
239
+ t: "─",
240
+ l: "│",
241
+ b: "─",
242
+ r: "│"
243
+ }.freeze
244
+
245
+ BORDER_CORNERS = {
246
+ tl: "┌",
247
+ tr: "┐",
248
+ bl: "└",
249
+ br: "┘",
250
+ t: " ",
251
+ l: " ",
252
+ b: " ",
253
+ r: " ",
254
+ }.freeze
255
+
256
+ BORDER_BARPLOT = {
257
+ tl: "┌",
258
+ tr: "┐",
259
+ bl: "└",
260
+ br: "┘",
261
+ t: " ",
262
+ l: "┤",
263
+ b: " ",
264
+ r: " ",
265
+ }.freeze
266
+ end
267
+
268
+ BORDER_MAP = {
269
+ solid: BorderMaps::BORDER_SOLID,
270
+ corners: BorderMaps::BORDER_CORNERS,
271
+ barplot: BorderMaps::BORDER_BARPLOT,
272
+ }.freeze
273
+
274
+ module StyledPrinter
275
+ TEXT_COLORS = {
276
+ black: "\033[30m",
277
+ red: "\033[31m",
278
+ green: "\033[32m",
279
+ yellow: "\033[33m",
280
+ blue: "\033[34m",
281
+ magenta: "\033[35m",
282
+ cyan: "\033[36m",
283
+ white: "\033[37m",
284
+ gray: "\033[90m",
285
+ light_black: "\033[90m",
286
+ light_red: "\033[91m",
287
+ light_green: "\033[92m",
288
+ light_yellow: "\033[93m",
289
+ light_blue: "\033[94m",
290
+ light_magenta: "\033[95m",
291
+ light_cyan: "\033[96m",
292
+ normal: "\033[0m",
293
+ default: "\033[39m",
294
+ bold: "\033[1m",
295
+ underline: "\033[4m",
296
+ blink: "\033[5m",
297
+ reverse: "\033[7m",
298
+ hidden: "\033[8m",
299
+ nothing: "",
300
+ }
301
+
302
+ 0.upto(255) do |i|
303
+ TEXT_COLORS[i] = "\033[38;5;#{i}m"
304
+ end
305
+
306
+ TEXT_COLORS.freeze
307
+
308
+ DISABLE_TEXT_STYLE = {
309
+ bold: "\033[22m",
310
+ underline: "\033[24m",
311
+ blink: "\033[25m",
312
+ reverse: "\033[27m",
313
+ hidden: "\033[28m",
314
+ normal: "",
315
+ default: "",
316
+ nothing: "",
317
+ }.freeze
318
+
319
+ COLOR_ENCODE = {
320
+ normal: 0b000,
321
+ blue: 0b001,
322
+ red: 0b010,
323
+ magenta: 0b011,
324
+ green: 0b100,
325
+ cyan: 0b101,
326
+ yellow: 0b110,
327
+ white: 0b111
328
+ }.freeze
329
+
330
+ COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
331
+
332
+ def print_styled(out, *args, bold: false, color: :normal)
333
+ return out.print(*args) unless color?(out)
334
+
335
+ str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
336
+ color = :nothing if bold && color == :bold
337
+ enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
338
+ (bold ? TEXT_COLORS[:bold] : "")
339
+ disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
340
+ DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
341
+ first = true
342
+ StringIO.open do |sio|
343
+ str.each_line do |line|
344
+ sio.puts unless first
345
+ first = false
346
+ continue if line.empty?
347
+ sio.print(enable_ansi, line, disable_ansi)
348
+ end
349
+ sio.close
350
+ out.print(sio.string)
351
+ end
352
+ end
353
+
354
+ def print_color(out, color, *args)
355
+ color = COLOR_DECODE[color]
356
+ print_styled(out, *args, color: color)
357
+ end
358
+
359
+ def color?(out)
360
+ (out && out.tty?) || false
361
+ end
362
+ end
363
+
364
+ module BorderPrinter
365
+ include StyledPrinter
366
+
367
+ def print_border_top(out, padding, length, border=:solid, color: :light_black)
368
+ return if border == :none
369
+ b = BORDER_MAP[border]
370
+ print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
371
+ end
372
+
373
+ def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
374
+ return if border == :none
375
+ b = BORDER_MAP[border]
376
+ print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
377
+ end
378
+ end
379
+
380
+ class Renderer
381
+ include BorderPrinter
382
+
383
+ def self.render(out, plot)
384
+ new(plot).render(out)
385
+ end
386
+
387
+ def initialize(plot)
388
+ @plot = plot
389
+ @out = nil
390
+ end
391
+
392
+ attr_reader :plot
393
+ attr_reader :out
394
+
395
+ def render(out)
396
+ @out = out
397
+ init_render
398
+
399
+ render_top
400
+ render_rows
401
+ render_bottom
402
+ end
403
+
404
+ private
405
+
406
+ def render_top
407
+ # plot the title and the top border
408
+ print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
409
+ puts if plot.title_given?
410
+
411
+ if plot.show_labels?
412
+ topleft_str = plot.decorations.fetch(:tl, "")
413
+ topleft_col = plot.colors_deco.fetch(:tl, :light_black)
414
+ topmid_str = plot.decorations.fetch(:t, "")
415
+ topmid_col = plot.colors_deco.fetch(:t, :light_black)
416
+ topright_str = plot.decorations.fetch(:tr, "")
417
+ topright_col = plot.colors_deco.fetch(:tr, :light_black)
418
+
419
+ if topleft_str != "" || topright_str != "" || topmid_str != ""
420
+ topleft_len = topleft_str.length
421
+ topmid_len = topmid_str.length
422
+ topright_len = topright_str.length
423
+ print_styled(out, @border_padding, topleft_str, color: topleft_col)
424
+ cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
425
+ pad = cnt > 0 ? " " * cnt : ""
426
+ print_styled(out, pad, topmid_str, color: topmid_col)
427
+ cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
428
+ pad = cnt > 0 ? " " * cnt : ""
429
+ print_styled(out, pad, topright_str, "\n", color: topright_col)
430
+ end
431
+ end
432
+
433
+ print_border_top(out, @border_padding, @border_length, plot.border)
434
+ print(" " * @max_len_r, @plot_padding, "\n")
435
+ end
436
+
437
+ # render all rows
438
+ def render_rows
439
+ (0 ... plot.n_rows).each {|row| render_row(row) }
440
+ end
441
+
442
+ def render_row(row)
443
+ # Current labels to left and right of the row and their length
444
+ left_str = plot.labels_left.fetch(row, "")
445
+ left_col = plot.colors_left.fetch(row, :light_black)
446
+ right_str = plot.labels_right.fetch(row, "")
447
+ right_col = plot.colors_right.fetch(row, :light_black)
448
+ left_len = nocolor_string(left_str).length
449
+ right_len = nocolor_string(right_str).length
450
+
451
+ unless color?(out)
452
+ left_str = nocolor_string(left_str)
453
+ right_str = nocolor_string(right_str)
454
+ end
455
+
456
+ # print left annotations
457
+ print(" " * plot.margin)
458
+ if plot.show_labels?
459
+ if row == @y_lab_row
460
+ # print ylabel
461
+ print_styled(out, plot.ylabel, color: :normal)
462
+ print(" " * (@max_len_l - plot.ylabel_length - left_len))
463
+ else
464
+ # print padding to fill ylabel length
465
+ print(" " * (@max_len_l - left_len))
466
+ end
467
+ # print the left annotation
468
+ print_styled(out, left_str, color: left_col)
469
+ end
470
+
471
+ # print left border
472
+ print_styled(out, @plot_padding, @b[:l], color: :light_black)
473
+
474
+ # print canvas row
475
+ plot.print_row(out, row)
476
+
477
+ #print right label and padding
478
+ print_styled(out, @b[:r], color: :light_black)
479
+ if plot.show_labels?
480
+ print(@plot_padding)
481
+ print_styled(out, right_str, color: right_col)
482
+ print(" " * (@max_len_r - right_len))
483
+ end
484
+ puts
485
+ end
486
+
487
+ def render_bottom
488
+ # draw bottom border and bottom labels
489
+ print_border_bottom(out, @border_padding, @border_length, plot.border)
490
+ print(" " * @max_len_r, @plot_padding)
491
+ if plot.show_labels?
492
+ botleft_str = plot.decorations.fetch(:bl, "")
493
+ botleft_col = plot.colors_deco.fetch(:bl, :light_black)
494
+ botmid_str = plot.decorations.fetch(:b, "")
495
+ botmid_col = plot.colors_deco.fetch(:b, :light_black)
496
+ botright_str = plot.decorations.fetch(:br, "")
497
+ botright_col = plot.colors_deco.fetch(:br, :light_black)
498
+
499
+ if botleft_str != "" || botright_str != "" || botmid_str != ""
500
+ puts
501
+ botleft_len = botleft_str.length
502
+ botmid_len = botmid_str.length
503
+ botright_len = botright_str.length
504
+ print_styled(out, @border_padding, botleft_str, color: botleft_col)
505
+ cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
506
+ pad = cnt > 0 ? " " * cnt : ""
507
+ print_styled(out, pad, botmid_str, color: botmid_col)
508
+ cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
509
+ pad = cnt > 0 ? " " * cnt : ""
510
+ print_styled(out, pad, botright_str, color: botright_col)
511
+ end
512
+
513
+ # abuse the print_title function to print the xlabel. maybe refactor this
514
+ puts if plot.xlabel_given?
515
+ print_title(@border_padding, plot.xlabel, p_width: @border_length)
516
+ end
517
+ end
518
+
519
+ def init_render
520
+ @b = BORDER_MAP[plot.border]
521
+ @border_length = plot.n_columns
522
+
523
+ # get length of largest strings to the left and right
524
+ @max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
525
+ plot.labels_left.each_value.map {|l| nocolor_string(l).length }.max :
526
+ 0
527
+ @max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
528
+ plot.labels_right.each_value.map {|l| nocolor_string(l).length }.max :
529
+ 0
530
+ if plot.show_labels? && plot.ylabel_given?
531
+ @max_len_l += plot.ylabel_length + 1
532
+ end
533
+
534
+ # offset where the plot (incl border) begins
535
+ @plot_offset = @max_len_l + plot.margin + plot.padding
536
+
537
+ # padding-string from left to border
538
+ @plot_padding = " " * plot.padding
539
+
540
+ # padding-string between labels and border
541
+ @border_padding = " " * @plot_offset
542
+
543
+ # compute position of ylabel
544
+ @y_lab_row = (plot.n_rows / 2.0).round - 1
545
+ end
546
+
547
+ def print_title(padding, title, p_width: 0, color: :normal)
548
+ return unless title && title != ""
549
+ offset = (p_width / 2.0 - title.length / 2.0).round
550
+ offset = [offset, 0].max
551
+ tpad = " " * offset
552
+ print_styled(out, padding, tpad, title, color: color)
553
+ end
554
+
555
+ def print(*args)
556
+ out.print(*args)
557
+ end
558
+
559
+ def puts(*args)
560
+ out.puts(*args)
561
+ end
562
+
563
+ def nocolor_string(str)
564
+ str.to_s.gsub(/\e\[[0-9]+m/, "")
565
+ end
566
+ end
567
+
568
+ class Plot
569
+ include StyledPrinter
570
+
571
+ DEFAULT_WIDTH = 40
572
+ DEFAULT_BORDER = :solid
573
+ DEFAULT_MARGIN = 3
574
+ DEFAULT_PADDING = 1
575
+
576
+ def initialize(title: nil,
577
+ xlabel: nil,
578
+ ylabel: nil,
579
+ border: DEFAULT_BORDER,
580
+ margin: DEFAULT_MARGIN,
581
+ padding: DEFAULT_PADDING,
582
+ labels: true)
583
+ @title = title
584
+ @xlabel = xlabel
585
+ @ylabel = ylabel
586
+ @border = border
587
+ @margin = check_margin(margin)
588
+ @padding = padding
589
+ @labels_left = {}
590
+ @colors_left = {}
591
+ @labels_right = {}
592
+ @colors_right = {}
593
+ @decorations = {}
594
+ @colors_deco = {}
595
+ @show_labels = labels
596
+ @auto_color = 0
597
+ end
598
+
599
+ attr_reader :title
600
+ attr_reader :xlabel
601
+ attr_reader :ylabel
602
+ attr_reader :border
603
+ attr_reader :margin
604
+ attr_reader :padding
605
+ attr_reader :labels_left
606
+ attr_reader :colors_left
607
+ attr_reader :labels_right
608
+ attr_reader :colors_right
609
+ attr_reader :decorations
610
+ attr_reader :colors_deco
611
+
612
+ def title_given?
613
+ title && title != ""
614
+ end
615
+
616
+ def xlabel_given?
617
+ xlabel && xlabel != ""
618
+ end
619
+
620
+ def ylabel_given?
621
+ ylabel && ylabel != ""
622
+ end
623
+
624
+ def ylabel_length
625
+ (ylabel && ylabel.length) || 0
626
+ end
627
+
628
+ def show_labels?
629
+ @show_labels
630
+ end
631
+
632
+ def annotate!(loc, value, color: :normal)
633
+ case loc
634
+ when :l
635
+ (0 ... n_rows).each do |row|
636
+ if @labels_left.fetch(row, "") == ""
637
+ @labels_left[row] = value
638
+ @colors_left[row] = color
639
+ break
640
+ end
641
+ end
642
+ when :r
643
+ (0 ... n_rows).each do |row|
644
+ if @labels_right.fetch(row, "") == ""
645
+ @labels_right[row] = value
646
+ @colors_right[row] = color
647
+ break
648
+ end
649
+ end
650
+ when :t, :b, :tl, :tr, :bl, :br
651
+ @decorations[loc] = value
652
+ @colors_deco[loc] = color
653
+ else
654
+ raise ArgumentError,
655
+ "unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)"
656
+ end
657
+ end
658
+
659
+ def annotate_row!(loc, row_index, value, color: :normal)
660
+ case loc
661
+ when :l
662
+ @labels_left[row_index] = value
663
+ @colors_left[row_index] = color
664
+ when :r
665
+ @labels_right[row_index] = value
666
+ @colors_right[row_index] = color
667
+ else
668
+ raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead"
669
+ end
670
+ end
671
+
672
+ def render(out)
673
+ Renderer.render(out, self)
674
+ end
675
+
676
+ COLOR_CYCLE = [
677
+ :green,
678
+ :blue,
679
+ :red,
680
+ :magenta,
681
+ :yellow,
682
+ :cyan
683
+ ].freeze
684
+
685
+ def next_color
686
+ COLOR_CYCLE[@auto_color]
687
+ ensure
688
+ @auto_color = (@auto_color + 1) % COLOR_CYCLE.length
689
+ end
690
+
691
+ def to_s
692
+ StringIO.open do |sio|
693
+ render(sio)
694
+ sio.close
695
+ sio.string
696
+ end
697
+ end
698
+
699
+ private def check_margin(margin)
700
+ if margin < 0
701
+ raise ArgumentError, "margin must be >= 0"
702
+ end
703
+ margin
704
+ end
705
+
706
+ private def check_row_index(row_index)
707
+ unless 0 <= row_index && row_index < n_rows
708
+ raise ArgumentError, "row_index out of bounds"
709
+ end
710
+ end
711
+ end
712
+
713
+ class Barplot < Plot
714
+ include ValueTransformer
715
+
716
+ MIN_WIDTH = 10
717
+ DEFAULT_COLOR = :green
718
+ DEFAULT_SYMBOL = "■"
719
+
720
+ def initialize(bars, width, color, symbol, transform, **kw)
721
+ if symbol.length > 1
722
+ raise ArgumentError, "symbol must be a single character"
723
+ end
724
+ @bars = bars
725
+ @symbol = symbol
726
+ @max_freq, i = find_max(transform_values(transform, bars))
727
+ @max_len = bars[i].to_s.length
728
+ @width = [width, max_len + 7, MIN_WIDTH].max
729
+ @color = color
730
+ @symbol = symbol
731
+ @transform = transform
732
+ super(**kw)
733
+ end
734
+
735
+ attr_reader :max_freq
736
+ attr_reader :max_len
737
+ attr_reader :width
738
+
739
+ def n_rows
740
+ @bars.length
741
+ end
742
+
743
+ def n_columns
744
+ @width
745
+ end
746
+
747
+ def add_row!(bars)
748
+ @bars.concat(bars)
749
+ @max_freq, i = find_max(transform_values(@transform, bars))
750
+ @max_len = @bars[i].to_s.length
751
+ end
752
+
753
+ def print_row(out, row_index)
754
+ check_row_index(row_index)
755
+ bar = @bars[row_index]
756
+ max_bar_width = [width - 2 - max_len, 1].max
757
+ val = transform_values(@transform, bar)
758
+ bar_len = max_freq > 0 ?
759
+ ([val, 0].max.fdiv(max_freq) * max_bar_width).round :
760
+ 0
761
+ bar_str = max_freq > 0 ? @symbol * bar_len : ""
762
+ bar_lbl = bar.to_s
763
+ print_styled(out, bar_str, color: @color)
764
+ print_styled(out, " ", bar_lbl, color: :normal)
765
+ pan_len = [max_bar_width + 1 + max_len - bar_len - bar_lbl.length, 0].max
766
+ pad = " " * pan_len.round
767
+ out.print(pad)
768
+ end
769
+
770
+ private def find_max(values)
771
+ i = j = 0
772
+ max = values[i]
773
+ while j < values.length
774
+ if values[j] > max
775
+ i, max = j, values[j]
776
+ end
777
+ j += 1
778
+ end
779
+ [max, i]
780
+ end
781
+ end
782
+ end
783
+ private_constant :MiniUnicodePlot
784
+ end
785
+
@@ -1,3 +1,3 @@
1
1
  class MiniHistogram
2
- VERSION = "0.1.3"
2
+ VERSION = "0.3.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mini_histogram
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - schneems
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2020-03-24 00:00:00.000000000 Z
11
+ date: 2020-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: m
@@ -58,6 +58,7 @@ files:
58
58
  - bin/console
59
59
  - bin/setup
60
60
  - lib/mini_histogram.rb
61
+ - lib/mini_histogram/plot.rb
61
62
  - lib/mini_histogram/version.rb
62
63
  - mini_histogram.gemspec
63
64
  homepage: https://github.com/zombocom/mini_histogram
@@ -65,7 +66,7 @@ licenses:
65
66
  - MIT
66
67
  metadata:
67
68
  homepage_uri: https://github.com/zombocom/mini_histogram
68
- post_install_message:
69
+ post_install_message:
69
70
  rdoc_options: []
70
71
  require_paths:
71
72
  - lib
@@ -81,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
82
  version: '0'
82
83
  requirements: []
83
84
  rubygems_version: 3.1.2
84
- signing_key:
85
+ signing_key:
85
86
  specification_version: 4
86
87
  summary: A small gem for building histograms out of Ruby arrays
87
88
  test_files: []