mini_histogram 0.1.2 → 0.3.0

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: 387d46563e24fde087f05153983b93b8209b5b83f8291515baa9458acd55f696
4
- data.tar.gz: d74fafa98d169baf6de9cd66f46d8a25f514ac7d4f40a3ac9ea9029feeb9c2d9
3
+ metadata.gz: b95e08050cb7942fba011d3c18bcd493064695bd15206826752a9432ba147f0a
4
+ data.tar.gz: cb225c6fcc1d5da019f1037ba7559c89b6957b98e3571d13cdeb780795d06a4d
5
5
  SHA512:
6
- metadata.gz: e8a23b6ebdb80c2a06679d6ef5fa7183ee4d2e7a6e4bf7b8caf46cbd335398b30af8dfb8dd7fd8970ea6fe73e35650297595db57381223744d3b7b787efb4b0a
7
- data.tar.gz: 2c8fafef6d1bed5fa60b7e5ebb46698ad3305ac110fb3414d33502a9e4b0f44cfd9fbc9f83eef60eddbb6d9815c0dd9c2541703ab2fba92041e5881a6cf9fb3f
6
+ metadata.gz: a33e73068d9f3c7c85db911a392cf9728eccecbbd6c744fd33a2aa42c2d0218b63819f9b6e8627fc1f68b33adb9551bceaa191d6f6a1dbace7b0380aadedcd36
7
+ data.tar.gz: e45c116668523722f82c41644874e21ec5ad39986c0222e3bc34edc343badb9ccf2478b7384ab99caeb7c36aafe4745cd1ac07edf46f034865f00b2ac11cca04
@@ -1,4 +1,24 @@
1
- ## Master
1
+ ## HEAD
2
+
3
+ ## 0.3.0
4
+
5
+ - Generate dualing side-by-side histograms (https://github.com/zombocom/mini_histogram/pull/6)
6
+
7
+ ## 0.2.2
8
+
9
+ - Frozen string optimization in histogram/plot.rb (https://github.com/zombocom/mini_histogram/pull/5)
10
+
11
+ ## 0.2.1
12
+
13
+ - Added missing constant needed for plotting support (https://github.com/zombocom/mini_histogram/pull/4)
14
+
15
+ ## 0.2.0
16
+
17
+ - Experimental plotting support added (https://github.com/zombocom/mini_histogram/pull/3)
18
+
19
+ ## 0.1.3
20
+
21
+ - Handle edge cases (https://github.com/zombocom/mini_histogram/pull/2)
2
22
 
3
23
  ## 0.1.2
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
+ ```
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)
@@ -21,12 +21,13 @@ class MiniHistogram
21
21
  class Error < StandardError; end
22
22
  attr_reader :array, :left_p, :max
23
23
 
24
- def initialize(array, left_p: false, edges: nil)
24
+ def initialize(array, left_p: true, edges: nil)
25
25
  @array = array
26
26
  @left_p = left_p
27
27
  @edges = edges
28
28
  @weights = nil
29
- @max = array.max
29
+
30
+ @min, @max = array.minmax
30
31
  end
31
32
 
32
33
  def edges_min
@@ -42,7 +43,7 @@ class MiniHistogram
42
43
  end
43
44
 
44
45
  def closed
45
- :left
46
+ @left_p ? :left : :right
46
47
  end
47
48
 
48
49
  # Sets the edge value to something new,
@@ -54,6 +55,8 @@ class MiniHistogram
54
55
  end
55
56
 
56
57
  def bin_size
58
+ return 0 if edges.length <= 1
59
+
57
60
  edges[1] - edges[0]
58
61
  end
59
62
 
@@ -86,6 +89,7 @@ class MiniHistogram
86
89
  # 4 values between 4.0 and 6.0 and three values between 10.0 and 12.0
87
90
  def weights
88
91
  return @weights if @weights
92
+ return @weights = [] if array.empty?
89
93
 
90
94
  lo = edges.first
91
95
  step = edges[1] - edges[0]
@@ -118,16 +122,18 @@ class MiniHistogram
118
122
  def edges
119
123
  return @edges if @edges
120
124
 
121
- hi = array.max
122
- lo = array.min
125
+ return @edges = [0.0] if array.empty?
126
+
127
+ lo = @min
128
+ hi = @max
123
129
 
124
- nbins = sturges * 1.0
130
+ nbins = sturges.to_f
125
131
 
126
132
  if hi == lo
127
- start = hi
133
+ start = lo
128
134
  step = 1.0
129
135
  divisor = 1.0
130
- len = 1.0
136
+ len = 1
131
137
  else
132
138
  bw = (hi - lo) / nbins
133
139
  lbw = Math.log10(bw)
@@ -163,34 +169,39 @@ class MiniHistogram
163
169
  start = (lo * divisor).floor
164
170
  len = (hi * divisor - start).ceil
165
171
  end
172
+ end
166
173
 
167
- if left_p
168
- while (lo < start/divisor)
169
- start -= step
170
- end
171
-
172
- while (start + (len - 1)*step)/divisor <= hi
173
- len += 1
174
- end
175
- else
176
- while lo <= start/divisor
177
- start -= step
178
- end
179
- while (start + (len - 1)*step)/divisor < hi
180
- len += 1
181
- end
174
+ if left_p
175
+ while (lo < start/divisor)
176
+ start -= step
182
177
  end
183
178
 
184
- @edges = []
185
- len.next.times.each do
186
- @edges << start/divisor
187
- start += step
179
+ while (start + (len - 1)*step)/divisor <= hi
180
+ len += 1
188
181
  end
189
- return @edges
182
+ else
183
+ while lo <= start/divisor
184
+ start -= step
185
+ end
186
+ while (start + (len - 1)*step)/divisor < hi
187
+ len += 1
188
+ end
189
+ end
190
+
191
+ @edges = []
192
+ len.times.each do
193
+ @edges << start/divisor
194
+ start += step
190
195
  end
196
+
197
+ return @edges
191
198
  end
192
199
  alias :edge :edges
193
200
 
201
+ def plot
202
+ raise "You must `require 'mini_histogram/plot'` to get this feature"
203
+ end
204
+
194
205
  # Given an array of Histograms this function calcualtes
195
206
  # an average edge size along with the minimum and maximum
196
207
  # edge values. It then updates the edge value on all inputs
@@ -219,3 +230,4 @@ class MiniHistogram
219
230
  return array_of_histograms
220
231
  end
221
232
  end
233
+
@@ -0,0 +1,782 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Plots the histogram in unicode characters
4
+ #
5
+ # Thanks to https://github.com/red-data-tools/unicode_plot.rb
6
+ # it could not be used because the dependency enumerable-statistics has a hard
7
+ # lock on a specific version of Ruby and this library needs to support older Rubies
8
+ #
9
+ # Example:
10
+ #
11
+ # require 'mini_histogram/plot'
12
+ # array = 50.times.map { rand(11.2..11.6) }
13
+ # histogram = MiniHistogram.new(array)
14
+ # puts histogram.plot => Generates a plot
15
+ #
16
+ class MiniHistogram
17
+
18
+ # This is an object that holds a histogram
19
+ # and it's corresponding plot options
20
+ #
21
+ # Example:
22
+ #
23
+ # x = PlotValue.new
24
+ # x.values = [1,2,3,4,5]
25
+ # x.options = {xlabel: "random"}
26
+ #
27
+ # x.plot # => Generates a histogram plot with these values and options
28
+ class PlotValue
29
+ attr_accessor :histogram, :options
30
+
31
+ def initialize
32
+ @histogram = nil
33
+ @options = {}
34
+ end
35
+
36
+ def plot
37
+ raise "@histogram cannot be empty set via `values=` or `histogram=` methods" if @histogram.nil?
38
+
39
+ @histogram.plot(**@options)
40
+ end
41
+
42
+ def values=(values)
43
+ @histogram = MiniHistogram.new(values)
44
+ end
45
+
46
+ def self.dual_plot(plot_a, plot_b)
47
+ a_lines = plot_a.to_s.lines
48
+ b_lines = plot_b.to_s.lines
49
+
50
+ max_length = a_lines.map(&:length).max
51
+
52
+ side_by_side = String.new("")
53
+ a_lines.each_index do |i|
54
+ side_by_side << a_lines[i].chomp.ljust(max_length) # Remove newline, ensure same length
55
+ side_by_side << b_lines[i]
56
+ end
57
+
58
+ return side_by_side
59
+ end
60
+ end
61
+ private_constant :PlotValue
62
+
63
+ def self.dual_plot
64
+ a = PlotValue.new
65
+ b = PlotValue.new
66
+
67
+ yield a, b
68
+
69
+ if b.options[:ylabel] == a.options[:ylabel]
70
+ b.options[:ylabel] = nil
71
+ end
72
+
73
+ MiniHistogram.set_average_edges!(a.histogram, b.histogram)
74
+ PlotValue.dual_plot(a.plot, b.plot)
75
+ end
76
+
77
+ def plot(
78
+ nbins: nil,
79
+ closed: :left,
80
+ symbol: "▇",
81
+ **kw)
82
+ hist = self.histogram(*[nbins].compact, closed: closed)
83
+ edge, counts = hist.edge, hist.weights
84
+ labels = []
85
+ bin_width = edge[1] - edge[0]
86
+ pad_left, pad_right = 0, 0
87
+ (0 ... edge.length).each do |i|
88
+ val1 = float_round_log10(edge[i], bin_width)
89
+ val2 = float_round_log10(val1 + bin_width, bin_width)
90
+ a1 = val1.to_s.split('.', 2).map(&:length)
91
+ a2 = val2.to_s.split('.', 2).map(&:length)
92
+ pad_left = [pad_left, a1[0], a2[0]].max
93
+ pad_right = [pad_right, a1[1], a2[1]].max
94
+ end
95
+ l_str = hist.closed == :right ? "(" : "["
96
+ r_str = hist.closed == :right ? "]" : ")"
97
+ counts.each_with_index do |n, i|
98
+ val1 = float_round_log10(edge[i], bin_width)
99
+ val2 = float_round_log10(val1 + bin_width, bin_width)
100
+ a1 = val1.to_s.split('.', 2).map(&:length)
101
+ a2 = val2.to_s.split('.', 2).map(&:length)
102
+ labels[i] = "\e[90m#{l_str}\e[0m" +
103
+ (" " * (pad_left - a1[0])) +
104
+ val1.to_s +
105
+ (" " * (pad_right - a1[1])) +
106
+ "\e[90m, \e[0m" +
107
+ (" " * (pad_left - a2[0])) +
108
+ val2.to_s +
109
+ (" " * (pad_right - a2[1])) +
110
+ "\e[90m#{r_str}\e[0m"
111
+ end
112
+ xscale = kw.delete(:xscale)
113
+ xlabel = kw.delete(:xlabel) || MiniUnicodePlot::ValueTransformer.transform_name(xscale, "Frequency")
114
+ barplot(labels, counts,
115
+ symbol: symbol,
116
+ xscale: xscale,
117
+ xlabel: xlabel,
118
+ **kw)
119
+ end
120
+
121
+ ## Begin copy/pasta from unicode_plot.rb with some slight modifications
122
+ private def barplot(
123
+ *args,
124
+ width: 40,
125
+ color: :green,
126
+ symbol: "■",
127
+ border: :barplot,
128
+ xscale: nil,
129
+ xlabel: nil,
130
+ data: nil,
131
+ **kw)
132
+ case args.length
133
+ when 0
134
+ data = Hash(data)
135
+ keys = data.keys.map(&:to_s)
136
+ heights = data.values
137
+ when 2
138
+ keys = Array(args[0])
139
+ heights = Array(args[1])
140
+ else
141
+ raise ArgumentError, "invalid arguments"
142
+ end
143
+
144
+ unless keys.length == heights.length
145
+ raise ArgumentError, "The given vectors must be of the same length"
146
+ end
147
+ unless heights.min >= 0
148
+ raise ArgumentError, "All values have to be positive. Negative bars are not supported."
149
+ end
150
+
151
+ xlabel ||= ValueTransformer.transform_name(xscale)
152
+ plot = MiniUnicodePlot::Barplot.new(heights, width, color, symbol, xscale,
153
+ border: border, xlabel: xlabel,
154
+ **kw)
155
+ keys.each_with_index do |key, i|
156
+ plot.annotate_row!(:l, i, key)
157
+ end
158
+
159
+ plot
160
+ end
161
+
162
+ private def float_round_log10(x, m)
163
+ if x == 0
164
+ 0.0
165
+ elsif x > 0
166
+ x.round(ceil_neg_log10(m) + 1).to_f
167
+ else
168
+ -(-x).round(ceil_neg_log10(m) + 1).to_f
169
+ end
170
+ end
171
+
172
+ private def ceil_neg_log10(x)
173
+ if roundable?(-Math.log10(x))
174
+ (-Math.log10(x)).ceil
175
+ else
176
+ (-Math.log10(x)).floor
177
+ end
178
+ end
179
+
180
+ INT64_MIN = -9223372036854775808
181
+ INT64_MAX = 9223372036854775807
182
+ private def roundable?(x)
183
+ x.to_i == x && INT64_MIN <= x && x < INT64_MAX
184
+ end
185
+
186
+ module MiniUnicodePlot
187
+ module ValueTransformer
188
+ PREDEFINED_TRANSFORM_FUNCTIONS = {
189
+ log: Math.method(:log),
190
+ ln: Math.method(:log),
191
+ log10: Math.method(:log10),
192
+ lg: Math.method(:log10),
193
+ log2: Math.method(:log2),
194
+ lb: Math.method(:log2),
195
+ }.freeze
196
+
197
+ def transform_values(func, values)
198
+ return values unless func
199
+
200
+ unless func.respond_to?(:call)
201
+ func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
202
+ unless func.respond_to?(:call)
203
+ raise ArgumentError, "func must be callable"
204
+ end
205
+ end
206
+
207
+ case values
208
+ when Numeric
209
+ func.(values)
210
+ else
211
+ values.map(&func)
212
+ end
213
+ end
214
+
215
+ module_function def transform_name(func, basename="")
216
+ return basename unless func
217
+ case func
218
+ when String, Symbol
219
+ name = func
220
+ when ->(f) { f.respond_to?(:name) }
221
+ name = func.name
222
+ else
223
+ name = "custom"
224
+ end
225
+ "#{basename} [#{name}]"
226
+ end
227
+ end
228
+
229
+
230
+ module BorderMaps
231
+ BORDER_SOLID = {
232
+ tl: "┌",
233
+ tr: "┐",
234
+ bl: "└",
235
+ br: "┘",
236
+ t: "─",
237
+ l: "│",
238
+ b: "─",
239
+ r: "│"
240
+ }.freeze
241
+
242
+ BORDER_CORNERS = {
243
+ tl: "┌",
244
+ tr: "┐",
245
+ bl: "└",
246
+ br: "┘",
247
+ t: " ",
248
+ l: " ",
249
+ b: " ",
250
+ r: " ",
251
+ }.freeze
252
+
253
+ BORDER_BARPLOT = {
254
+ tl: "┌",
255
+ tr: "┐",
256
+ bl: "└",
257
+ br: "┘",
258
+ t: " ",
259
+ l: "┤",
260
+ b: " ",
261
+ r: " ",
262
+ }.freeze
263
+ end
264
+
265
+ BORDER_MAP = {
266
+ solid: BorderMaps::BORDER_SOLID,
267
+ corners: BorderMaps::BORDER_CORNERS,
268
+ barplot: BorderMaps::BORDER_BARPLOT,
269
+ }.freeze
270
+
271
+ module StyledPrinter
272
+ TEXT_COLORS = {
273
+ black: "\033[30m",
274
+ red: "\033[31m",
275
+ green: "\033[32m",
276
+ yellow: "\033[33m",
277
+ blue: "\033[34m",
278
+ magenta: "\033[35m",
279
+ cyan: "\033[36m",
280
+ white: "\033[37m",
281
+ gray: "\033[90m",
282
+ light_black: "\033[90m",
283
+ light_red: "\033[91m",
284
+ light_green: "\033[92m",
285
+ light_yellow: "\033[93m",
286
+ light_blue: "\033[94m",
287
+ light_magenta: "\033[95m",
288
+ light_cyan: "\033[96m",
289
+ normal: "\033[0m",
290
+ default: "\033[39m",
291
+ bold: "\033[1m",
292
+ underline: "\033[4m",
293
+ blink: "\033[5m",
294
+ reverse: "\033[7m",
295
+ hidden: "\033[8m",
296
+ nothing: "",
297
+ }
298
+
299
+ 0.upto(255) do |i|
300
+ TEXT_COLORS[i] = "\033[38;5;#{i}m"
301
+ end
302
+
303
+ TEXT_COLORS.freeze
304
+
305
+ DISABLE_TEXT_STYLE = {
306
+ bold: "\033[22m",
307
+ underline: "\033[24m",
308
+ blink: "\033[25m",
309
+ reverse: "\033[27m",
310
+ hidden: "\033[28m",
311
+ normal: "",
312
+ default: "",
313
+ nothing: "",
314
+ }.freeze
315
+
316
+ COLOR_ENCODE = {
317
+ normal: 0b000,
318
+ blue: 0b001,
319
+ red: 0b010,
320
+ magenta: 0b011,
321
+ green: 0b100,
322
+ cyan: 0b101,
323
+ yellow: 0b110,
324
+ white: 0b111
325
+ }.freeze
326
+
327
+ COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
328
+
329
+ def print_styled(out, *args, bold: false, color: :normal)
330
+ return out.print(*args) unless color?(out)
331
+
332
+ str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
333
+ color = :nothing if bold && color == :bold
334
+ enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
335
+ (bold ? TEXT_COLORS[:bold] : "")
336
+ disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
337
+ DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
338
+ first = true
339
+ StringIO.open do |sio|
340
+ str.each_line do |line|
341
+ sio.puts unless first
342
+ first = false
343
+ continue if line.empty?
344
+ sio.print(enable_ansi, line, disable_ansi)
345
+ end
346
+ sio.close
347
+ out.print(sio.string)
348
+ end
349
+ end
350
+
351
+ def print_color(out, color, *args)
352
+ color = COLOR_DECODE[color]
353
+ print_styled(out, *args, color: color)
354
+ end
355
+
356
+ def color?(out)
357
+ (out && out.tty?) || false
358
+ end
359
+ end
360
+
361
+ module BorderPrinter
362
+ include StyledPrinter
363
+
364
+ def print_border_top(out, padding, length, border=:solid, color: :light_black)
365
+ return if border == :none
366
+ b = BORDER_MAP[border]
367
+ print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
368
+ end
369
+
370
+ def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
371
+ return if border == :none
372
+ b = BORDER_MAP[border]
373
+ print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
374
+ end
375
+ end
376
+
377
+ class Renderer
378
+ include BorderPrinter
379
+
380
+ def self.render(out, plot)
381
+ new(plot).render(out)
382
+ end
383
+
384
+ def initialize(plot)
385
+ @plot = plot
386
+ @out = nil
387
+ end
388
+
389
+ attr_reader :plot
390
+ attr_reader :out
391
+
392
+ def render(out)
393
+ @out = out
394
+ init_render
395
+
396
+ render_top
397
+ render_rows
398
+ render_bottom
399
+ end
400
+
401
+ private
402
+
403
+ def render_top
404
+ # plot the title and the top border
405
+ print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
406
+ puts if plot.title_given?
407
+
408
+ if plot.show_labels?
409
+ topleft_str = plot.decorations.fetch(:tl, "")
410
+ topleft_col = plot.colors_deco.fetch(:tl, :light_black)
411
+ topmid_str = plot.decorations.fetch(:t, "")
412
+ topmid_col = plot.colors_deco.fetch(:t, :light_black)
413
+ topright_str = plot.decorations.fetch(:tr, "")
414
+ topright_col = plot.colors_deco.fetch(:tr, :light_black)
415
+
416
+ if topleft_str != "" || topright_str != "" || topmid_str != ""
417
+ topleft_len = topleft_str.length
418
+ topmid_len = topmid_str.length
419
+ topright_len = topright_str.length
420
+ print_styled(out, @border_padding, topleft_str, color: topleft_col)
421
+ cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
422
+ pad = cnt > 0 ? " " * cnt : ""
423
+ print_styled(out, pad, topmid_str, color: topmid_col)
424
+ cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
425
+ pad = cnt > 0 ? " " * cnt : ""
426
+ print_styled(out, pad, topright_str, "\n", color: topright_col)
427
+ end
428
+ end
429
+
430
+ print_border_top(out, @border_padding, @border_length, plot.border)
431
+ print(" " * @max_len_r, @plot_padding, "\n")
432
+ end
433
+
434
+ # render all rows
435
+ def render_rows
436
+ (0 ... plot.n_rows).each {|row| render_row(row) }
437
+ end
438
+
439
+ def render_row(row)
440
+ # Current labels to left and right of the row and their length
441
+ left_str = plot.labels_left.fetch(row, "")
442
+ left_col = plot.colors_left.fetch(row, :light_black)
443
+ right_str = plot.labels_right.fetch(row, "")
444
+ right_col = plot.colors_right.fetch(row, :light_black)
445
+ left_len = nocolor_string(left_str).length
446
+ right_len = nocolor_string(right_str).length
447
+
448
+ unless color?(out)
449
+ left_str = nocolor_string(left_str)
450
+ right_str = nocolor_string(right_str)
451
+ end
452
+
453
+ # print left annotations
454
+ print(" " * plot.margin)
455
+ if plot.show_labels?
456
+ if row == @y_lab_row
457
+ # print ylabel
458
+ print_styled(out, plot.ylabel, color: :normal)
459
+ print(" " * (@max_len_l - plot.ylabel_length - left_len))
460
+ else
461
+ # print padding to fill ylabel length
462
+ print(" " * (@max_len_l - left_len))
463
+ end
464
+ # print the left annotation
465
+ print_styled(out, left_str, color: left_col)
466
+ end
467
+
468
+ # print left border
469
+ print_styled(out, @plot_padding, @b[:l], color: :light_black)
470
+
471
+ # print canvas row
472
+ plot.print_row(out, row)
473
+
474
+ #print right label and padding
475
+ print_styled(out, @b[:r], color: :light_black)
476
+ if plot.show_labels?
477
+ print(@plot_padding)
478
+ print_styled(out, right_str, color: right_col)
479
+ print(" " * (@max_len_r - right_len))
480
+ end
481
+ puts
482
+ end
483
+
484
+ def render_bottom
485
+ # draw bottom border and bottom labels
486
+ print_border_bottom(out, @border_padding, @border_length, plot.border)
487
+ print(" " * @max_len_r, @plot_padding)
488
+ if plot.show_labels?
489
+ botleft_str = plot.decorations.fetch(:bl, "")
490
+ botleft_col = plot.colors_deco.fetch(:bl, :light_black)
491
+ botmid_str = plot.decorations.fetch(:b, "")
492
+ botmid_col = plot.colors_deco.fetch(:b, :light_black)
493
+ botright_str = plot.decorations.fetch(:br, "")
494
+ botright_col = plot.colors_deco.fetch(:br, :light_black)
495
+
496
+ if botleft_str != "" || botright_str != "" || botmid_str != ""
497
+ puts
498
+ botleft_len = botleft_str.length
499
+ botmid_len = botmid_str.length
500
+ botright_len = botright_str.length
501
+ print_styled(out, @border_padding, botleft_str, color: botleft_col)
502
+ cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
503
+ pad = cnt > 0 ? " " * cnt : ""
504
+ print_styled(out, pad, botmid_str, color: botmid_col)
505
+ cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
506
+ pad = cnt > 0 ? " " * cnt : ""
507
+ print_styled(out, pad, botright_str, color: botright_col)
508
+ end
509
+
510
+ # abuse the print_title function to print the xlabel. maybe refactor this
511
+ puts if plot.xlabel_given?
512
+ print_title(@border_padding, plot.xlabel, p_width: @border_length)
513
+ end
514
+ end
515
+
516
+ def init_render
517
+ @b = BORDER_MAP[plot.border]
518
+ @border_length = plot.n_columns
519
+
520
+ # get length of largest strings to the left and right
521
+ @max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
522
+ plot.labels_left.each_value.map {|l| nocolor_string(l).length }.max :
523
+ 0
524
+ @max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
525
+ plot.labels_right.each_value.map {|l| nocolor_string(l).length }.max :
526
+ 0
527
+ if plot.show_labels? && plot.ylabel_given?
528
+ @max_len_l += plot.ylabel_length + 1
529
+ end
530
+
531
+ # offset where the plot (incl border) begins
532
+ @plot_offset = @max_len_l + plot.margin + plot.padding
533
+
534
+ # padding-string from left to border
535
+ @plot_padding = " " * plot.padding
536
+
537
+ # padding-string between labels and border
538
+ @border_padding = " " * @plot_offset
539
+
540
+ # compute position of ylabel
541
+ @y_lab_row = (plot.n_rows / 2.0).round - 1
542
+ end
543
+
544
+ def print_title(padding, title, p_width: 0, color: :normal)
545
+ return unless title && title != ""
546
+ offset = (p_width / 2.0 - title.length / 2.0).round
547
+ offset = [offset, 0].max
548
+ tpad = " " * offset
549
+ print_styled(out, padding, tpad, title, color: color)
550
+ end
551
+
552
+ def print(*args)
553
+ out.print(*args)
554
+ end
555
+
556
+ def puts(*args)
557
+ out.puts(*args)
558
+ end
559
+
560
+ def nocolor_string(str)
561
+ str.to_s.gsub(/\e\[[0-9]+m/, "")
562
+ end
563
+ end
564
+
565
+ class Plot
566
+ include StyledPrinter
567
+
568
+ DEFAULT_WIDTH = 40
569
+ DEFAULT_BORDER = :solid
570
+ DEFAULT_MARGIN = 3
571
+ DEFAULT_PADDING = 1
572
+
573
+ def initialize(title: nil,
574
+ xlabel: nil,
575
+ ylabel: nil,
576
+ border: DEFAULT_BORDER,
577
+ margin: DEFAULT_MARGIN,
578
+ padding: DEFAULT_PADDING,
579
+ labels: true)
580
+ @title = title
581
+ @xlabel = xlabel
582
+ @ylabel = ylabel
583
+ @border = border
584
+ @margin = check_margin(margin)
585
+ @padding = padding
586
+ @labels_left = {}
587
+ @colors_left = {}
588
+ @labels_right = {}
589
+ @colors_right = {}
590
+ @decorations = {}
591
+ @colors_deco = {}
592
+ @show_labels = labels
593
+ @auto_color = 0
594
+ end
595
+
596
+ attr_reader :title
597
+ attr_reader :xlabel
598
+ attr_reader :ylabel
599
+ attr_reader :border
600
+ attr_reader :margin
601
+ attr_reader :padding
602
+ attr_reader :labels_left
603
+ attr_reader :colors_left
604
+ attr_reader :labels_right
605
+ attr_reader :colors_right
606
+ attr_reader :decorations
607
+ attr_reader :colors_deco
608
+
609
+ def title_given?
610
+ title && title != ""
611
+ end
612
+
613
+ def xlabel_given?
614
+ xlabel && xlabel != ""
615
+ end
616
+
617
+ def ylabel_given?
618
+ ylabel && ylabel != ""
619
+ end
620
+
621
+ def ylabel_length
622
+ (ylabel && ylabel.length) || 0
623
+ end
624
+
625
+ def show_labels?
626
+ @show_labels
627
+ end
628
+
629
+ def annotate!(loc, value, color: :normal)
630
+ case loc
631
+ when :l
632
+ (0 ... n_rows).each do |row|
633
+ if @labels_left.fetch(row, "") == ""
634
+ @labels_left[row] = value
635
+ @colors_left[row] = color
636
+ break
637
+ end
638
+ end
639
+ when :r
640
+ (0 ... n_rows).each do |row|
641
+ if @labels_right.fetch(row, "") == ""
642
+ @labels_right[row] = value
643
+ @colors_right[row] = color
644
+ break
645
+ end
646
+ end
647
+ when :t, :b, :tl, :tr, :bl, :br
648
+ @decorations[loc] = value
649
+ @colors_deco[loc] = color
650
+ else
651
+ raise ArgumentError,
652
+ "unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)"
653
+ end
654
+ end
655
+
656
+ def annotate_row!(loc, row_index, value, color: :normal)
657
+ case loc
658
+ when :l
659
+ @labels_left[row_index] = value
660
+ @colors_left[row_index] = color
661
+ when :r
662
+ @labels_right[row_index] = value
663
+ @colors_right[row_index] = color
664
+ else
665
+ raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead"
666
+ end
667
+ end
668
+
669
+ def render(out)
670
+ Renderer.render(out, self)
671
+ end
672
+
673
+ COLOR_CYCLE = [
674
+ :green,
675
+ :blue,
676
+ :red,
677
+ :magenta,
678
+ :yellow,
679
+ :cyan
680
+ ].freeze
681
+
682
+ def next_color
683
+ COLOR_CYCLE[@auto_color]
684
+ ensure
685
+ @auto_color = (@auto_color + 1) % COLOR_CYCLE.length
686
+ end
687
+
688
+ def to_s
689
+ StringIO.open do |sio|
690
+ render(sio)
691
+ sio.close
692
+ sio.string
693
+ end
694
+ end
695
+
696
+ private def check_margin(margin)
697
+ if margin < 0
698
+ raise ArgumentError, "margin must be >= 0"
699
+ end
700
+ margin
701
+ end
702
+
703
+ private def check_row_index(row_index)
704
+ unless 0 <= row_index && row_index < n_rows
705
+ raise ArgumentError, "row_index out of bounds"
706
+ end
707
+ end
708
+ end
709
+
710
+ class Barplot < Plot
711
+ include ValueTransformer
712
+
713
+ MIN_WIDTH = 10
714
+ DEFAULT_COLOR = :green
715
+ DEFAULT_SYMBOL = "■"
716
+
717
+ def initialize(bars, width, color, symbol, transform, **kw)
718
+ if symbol.length > 1
719
+ raise ArgumentError, "symbol must be a single character"
720
+ end
721
+ @bars = bars
722
+ @symbol = symbol
723
+ @max_freq, i = find_max(transform_values(transform, bars))
724
+ @max_len = bars[i].to_s.length
725
+ @width = [width, max_len + 7, MIN_WIDTH].max
726
+ @color = color
727
+ @symbol = symbol
728
+ @transform = transform
729
+ super(**kw)
730
+ end
731
+
732
+ attr_reader :max_freq
733
+ attr_reader :max_len
734
+ attr_reader :width
735
+
736
+ def n_rows
737
+ @bars.length
738
+ end
739
+
740
+ def n_columns
741
+ @width
742
+ end
743
+
744
+ def add_row!(bars)
745
+ @bars.concat(bars)
746
+ @max_freq, i = find_max(transform_values(@transform, bars))
747
+ @max_len = @bars[i].to_s.length
748
+ end
749
+
750
+ def print_row(out, row_index)
751
+ check_row_index(row_index)
752
+ bar = @bars[row_index]
753
+ max_bar_width = [width - 2 - max_len, 1].max
754
+ val = transform_values(@transform, bar)
755
+ bar_len = max_freq > 0 ?
756
+ ([val, 0].max.fdiv(max_freq) * max_bar_width).round :
757
+ 0
758
+ bar_str = max_freq > 0 ? @symbol * bar_len : ""
759
+ bar_lbl = bar.to_s
760
+ print_styled(out, bar_str, color: @color)
761
+ print_styled(out, " ", bar_lbl, color: :normal)
762
+ pan_len = [max_bar_width + 1 + max_len - bar_len - bar_lbl.length, 0].max
763
+ pad = " " * pan_len.round
764
+ out.print(pad)
765
+ end
766
+
767
+ private def find_max(values)
768
+ i = j = 0
769
+ max = values[i]
770
+ while j < values.length
771
+ if values[j] > max
772
+ i, max = j, values[j]
773
+ end
774
+ j += 1
775
+ end
776
+ [max, i]
777
+ end
778
+ end
779
+ end
780
+ private_constant :MiniUnicodePlot
781
+ end
782
+
@@ -1,3 +1,3 @@
1
1
  class MiniHistogram
2
- VERSION = "0.1.2"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -26,4 +26,7 @@ Gem::Specification.new do |spec|
26
26
  spec.require_paths = ["lib"]
27
27
 
28
28
  spec.add_development_dependency "m"
29
+ # Used for comparison testing, but only supports Ruby 2.4+
30
+ # spec.add_development_dependency "enumerable-statistics"
31
+ spec.add_development_dependency "benchmark-ips"
29
32
  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.2
4
+ version: 0.3.0
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-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: m
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: benchmark-ips
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  description: It makes histograms out of Ruby data. How cool is that!? Pretty cool
28
42
  if you ask me.
29
43
  email:
@@ -44,6 +58,7 @@ files:
44
58
  - bin/console
45
59
  - bin/setup
46
60
  - lib/mini_histogram.rb
61
+ - lib/mini_histogram/plot.rb
47
62
  - lib/mini_histogram/version.rb
48
63
  - mini_histogram.gemspec
49
64
  homepage: https://github.com/zombocom/mini_histogram
@@ -51,7 +66,7 @@ licenses:
51
66
  - MIT
52
67
  metadata:
53
68
  homepage_uri: https://github.com/zombocom/mini_histogram
54
- post_install_message:
69
+ post_install_message:
55
70
  rdoc_options: []
56
71
  require_paths:
57
72
  - lib
@@ -67,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
82
  version: '0'
68
83
  requirements: []
69
84
  rubygems_version: 3.1.2
70
- signing_key:
85
+ signing_key:
71
86
  specification_version: 4
72
87
  summary: A small gem for building histograms out of Ruby arrays
73
88
  test_files: []