mini_histogram 0.1.3 → 0.3.1

Sign up to get free protection for your applications and to get access to all the features.
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: []