mini_histogram 0.1.0 → 0.2.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: a1d07dadd88ba1bc23f7a24c225a8a7146a657f932122e4681843478ef61ec14
4
- data.tar.gz: fed4add367a52b8dc55f67389877ed6444413c10899c6cb1979af262aa279f7c
3
+ metadata.gz: 3dc9a1c55d5d10c4c56a27b12ce42b3fdc5d2853997152e79850c081a0e2d07f
4
+ data.tar.gz: 5d5ef7440f2148b3154df577e7997195ff48a0be0d8194a0771517d3fd3e33a7
5
5
  SHA512:
6
- metadata.gz: 407f81d19278447ed837bf983e7794a229eefeee71d5f68f1b1b20929e755057a5047296554c84ec0af6144f2340c6d08dd1770327aa1f28633b80ca01e5e0ef
7
- data.tar.gz: 989f6b2acc56666c9c20c9bb05944a828ed2cef0cab0fc8a54fd7aa7ebb272e2ce7bbee37ecf465d489021be07cc1777e249d54f1fb088c4ffdfc754c3dddf60
6
+ metadata.gz: 306b0ca28c902e98ac3edcdbb829e9287615b8f4be470132c48c8bb8b76481f3fd0470b29ef402c4c68f5d65af45c59632e1405e9f29fb355f785b32c7064a04
7
+ data.tar.gz: 1b088faab263d9efc61d0b61247a24b2a78ced9748960e8bf7b2e70f33c4a19a0334975ef5c5cdda8872e022d2055c1006c001b2ce72819144eea8f99ca464f7
@@ -0,0 +1,10 @@
1
+ name: Check Changelog
2
+ on: [pull_request]
3
+ jobs:
4
+ build:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v1
8
+ - name: Check that CHANGELOG is touched
9
+ run: |
10
+ cat $GITHUB_EVENT_PATH | jq .pull_request.title | grep -i '\[\(\(changelog skip\)\|\(ci skip\)\)\]' || git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md
data/.gitignore CHANGED
@@ -6,3 +6,5 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+
10
+ Gemfile.lock
@@ -7,4 +7,3 @@ rvm:
7
7
  - 2.5
8
8
  - 2.6
9
9
  - 2.7.0
10
- before_install: gem install bundler -v 2.1.2
@@ -0,0 +1,25 @@
1
+ ## HEAD
2
+
3
+ ## 0.2.1
4
+
5
+ - Added missing constant needed for plotting support (https://github.com/zombocom/mini_histogram/pull/4)
6
+
7
+ ## 0.2.0
8
+
9
+ - Experimental plotting support added (https://github.com/zombocom/mini_histogram/pull/3)
10
+
11
+ ## 0.1.3
12
+
13
+ - Handle edge cases (https://github.com/zombocom/mini_histogram/pull/2)
14
+
15
+ ## 0.1.2
16
+
17
+ - Add `edge` as alias to `edges`
18
+
19
+ ## 0.1.1
20
+
21
+ - Fix multi histogram weights, with set_average_edges! method (https://github.com/zombocom/mini_histogram/pull/1)
22
+
23
+ ## 0.1.0
24
+
25
+ - First
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # MiniHistogram
1
+ # MiniHistogram [![Build Status](https://travis-ci.org/zombocom/mini_histogram.svg?branch=master)](https://travis-ci.org/zombocom/mini_histogram)
2
2
 
3
3
  What's a histogram and why should you care? First read [Lies, Damned Lies, and Averages: Perc50, Perc95 explained for Programmers](https://schneems.com/2020/03/17/lies-damned-lies-and-averages-perc50-perc95-explained-for-programmers/). This library lets you build histograms in pure Ruby.
4
4
 
@@ -38,6 +38,43 @@ 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
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
+ ## Alternatives
77
+
41
78
  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
79
 
43
80
  [MiniHistogram API Docs](https://rubydoc.info/github/zombocom/mini_histogram/master/MiniHistogram)
data/Rakefile CHANGED
@@ -19,8 +19,8 @@ task :bench do
19
19
 
20
20
  array = 1000.times.map { rand }
21
21
 
22
- edges = MiniHistogram.edges(array)
23
- my_weights = MiniHistogram.counts_from_edges(array, edges: edges)
22
+ histogram = MiniHistogram.new(array)
23
+ my_weights = histogram.weights
24
24
  puts array.histogram.weights == my_weights
25
25
  puts array.histogram.weights.inspect
26
26
  puts my_weights.inspect
@@ -29,8 +29,7 @@ task :bench do
29
29
  Benchmark.ips do |x|
30
30
  x.report("enumerable stats") { array.histogram }
31
31
  x.report("mini histogram ") {
32
- edges = MiniHistogram.edges(array)
33
- MiniHistogram.counts_from_edges(array, edges: edges)
32
+ MiniHistogram.new(array).weights
34
33
  }
35
34
  x.compare!
36
35
  end
@@ -1,5 +1,4 @@
1
1
  require "mini_histogram/version"
2
- require 'math'
3
2
 
4
3
  # A class for building histogram info
5
4
  #
@@ -20,13 +19,15 @@ require 'math'
20
19
  #
21
20
  class MiniHistogram
22
21
  class Error < StandardError; end
23
- attr_reader :array, :left_p
22
+ attr_reader :array, :left_p, :max
24
23
 
25
- def initialize(array, left_p: false, edges: nil)
24
+ def initialize(array, left_p: true, edges: nil)
26
25
  @array = array
27
26
  @left_p = left_p
28
27
  @edges = edges
29
28
  @weights = nil
29
+
30
+ @min, @max = array.minmax
30
31
  end
31
32
 
32
33
  def edges_min
@@ -37,14 +38,25 @@ class MiniHistogram
37
38
  edges.max
38
39
  end
39
40
 
41
+ def histogram(*_)
42
+ self
43
+ end
44
+
45
+ def closed
46
+ @left_p ? :left : :right
47
+ end
48
+
40
49
  # Sets the edge value to something new,
41
50
  # also clears any previously calculated values
42
- def set_edges(value)
43
- @edges = value
51
+ def update_values(edges:, max: )
52
+ @edges = edges
53
+ @max = max
44
54
  @weights = nil # clear memoized value
45
55
  end
46
56
 
47
57
  def bin_size
58
+ return 0 if edges.length <= 1
59
+
48
60
  edges[1] - edges[0]
49
61
  end
50
62
 
@@ -77,11 +89,12 @@ class MiniHistogram
77
89
  # 4 values between 4.0 and 6.0 and three values between 10.0 and 12.0
78
90
  def weights
79
91
  return @weights if @weights
92
+ return @weights = [] if array.empty?
80
93
 
81
94
  lo = edges.first
82
95
  step = edges[1] - edges[0]
83
96
 
84
- max_index = ((array.max - lo) / step).floor
97
+ max_index = ((@max - lo) / step).floor
85
98
  @weights = Array.new(max_index + 1, 0)
86
99
 
87
100
  array.each do |x|
@@ -109,16 +122,18 @@ class MiniHistogram
109
122
  def edges
110
123
  return @edges if @edges
111
124
 
112
- hi = array.max
113
- lo = array.min
125
+ return @edges = [0.0] if array.empty?
114
126
 
115
- nbins = sturges * 1.0
127
+ lo = @min
128
+ hi = @max
129
+
130
+ nbins = sturges.to_f
116
131
 
117
132
  if hi == lo
118
- start = hi
133
+ start = lo
119
134
  step = 1.0
120
135
  divisor = 1.0
121
- len = 1.0
136
+ len = 1
122
137
  else
123
138
  bw = (hi - lo) / nbins
124
139
  lbw = Math.log10(bw)
@@ -154,31 +169,37 @@ class MiniHistogram
154
169
  start = (lo * divisor).floor
155
170
  len = (hi * divisor - start).ceil
156
171
  end
172
+ end
157
173
 
158
- if left_p
159
- while (lo < start/divisor)
160
- start -= step
161
- end
162
-
163
- while (start + (len - 1)*step)/divisor <= hi
164
- len += 1
165
- end
166
- else
167
- while lo <= start/divisor
168
- start -= step
169
- end
170
- while (start + (len - 1)*step)/divisor < hi
171
- len += 1
172
- end
174
+ if left_p
175
+ while (lo < start/divisor)
176
+ start -= step
173
177
  end
174
178
 
175
- @edges = []
176
- len.next.times.each do
177
- @edges << start/divisor
178
- start += step
179
+ while (start + (len - 1)*step)/divisor <= hi
180
+ len += 1
181
+ end
182
+ else
183
+ while lo <= start/divisor
184
+ start -= step
185
+ end
186
+ while (start + (len - 1)*step)/divisor < hi
187
+ len += 1
179
188
  end
180
- return @edges
181
189
  end
190
+
191
+ @edges = []
192
+ len.times.each do
193
+ @edges << start/divisor
194
+ start += step
195
+ end
196
+
197
+ return @edges
198
+ end
199
+ alias :edge :edges
200
+
201
+ def plot
202
+ raise "You must `require 'mini_histogram/plot'` to get this feature"
182
203
  end
183
204
 
184
205
  # Given an array of Histograms this function calcualtes
@@ -194,6 +215,8 @@ class MiniHistogram
194
215
  steps = array_of_histograms.map(&:bin_size)
195
216
  avg_step_size = steps.inject(&:+).to_f / steps.length
196
217
 
218
+ max_value = array_of_histograms.map(&:max).max
219
+
197
220
  max_edge = array_of_histograms.map(&:edges_max).max
198
221
  min_edge = array_of_histograms.map(&:edges_min).min
199
222
 
@@ -202,8 +225,9 @@ class MiniHistogram
202
225
  average_edges << average_edges.last + avg_step_size
203
226
  end
204
227
 
205
- array_of_histograms.each {|h| h.set_edges(average_edges) }
228
+ array_of_histograms.each {|h| h.update_values(edges: average_edges, max: max_value) }
206
229
 
207
230
  return array_of_histograms
208
231
  end
209
232
  end
233
+
@@ -0,0 +1,716 @@
1
+ # Plots the histogram in unicode characters
2
+ #
3
+ # Thanks to https://github.com/red-data-tools/unicode_plot.rb
4
+ # it could not be used because the dependency enumerable-statistics has a hard
5
+ # lock on a specific version of Ruby and this library needs to support older Rubies
6
+ #
7
+ # Example:
8
+ #
9
+ # require 'mini_histogram/plot'
10
+ # array = 50.times.map { rand(11.2..11.6) }
11
+ # histogram = MiniHistogram.new(array)
12
+ # puts histogram.plot
13
+ #
14
+ class MiniHistogram
15
+ def plot(
16
+ nbins: nil,
17
+ closed: :left,
18
+ symbol: "▇",
19
+ **kw)
20
+ hist = self.histogram(*[nbins].compact, closed: closed)
21
+ edge, counts = hist.edge, hist.weights
22
+ labels = []
23
+ bin_width = edge[1] - edge[0]
24
+ pad_left, pad_right = 0, 0
25
+ (0 ... edge.length).each do |i|
26
+ val1 = float_round_log10(edge[i], bin_width)
27
+ val2 = float_round_log10(val1 + bin_width, bin_width)
28
+ a1 = val1.to_s.split('.', 2).map(&:length)
29
+ a2 = val2.to_s.split('.', 2).map(&:length)
30
+ pad_left = [pad_left, a1[0], a2[0]].max
31
+ pad_right = [pad_right, a1[1], a2[1]].max
32
+ end
33
+ l_str = hist.closed == :right ? "(" : "["
34
+ r_str = hist.closed == :right ? "]" : ")"
35
+ counts.each_with_index do |n, i|
36
+ val1 = float_round_log10(edge[i], bin_width)
37
+ val2 = float_round_log10(val1 + bin_width, bin_width)
38
+ a1 = val1.to_s.split('.', 2).map(&:length)
39
+ a2 = val2.to_s.split('.', 2).map(&:length)
40
+ labels[i] = "\e[90m#{l_str}\e[0m" +
41
+ (" " * (pad_left - a1[0])) +
42
+ val1.to_s +
43
+ (" " * (pad_right - a1[1])) +
44
+ "\e[90m, \e[0m" +
45
+ (" " * (pad_left - a2[0])) +
46
+ val2.to_s +
47
+ (" " * (pad_right - a2[1])) +
48
+ "\e[90m#{r_str}\e[0m"
49
+ end
50
+ xscale = kw.delete(:xscale)
51
+ xlabel = kw.delete(:xlabel) || ValueTransformer.transform_name(xscale, "Frequency")
52
+ barplot(labels, counts,
53
+ symbol: symbol,
54
+ xscale: xscale,
55
+ xlabel: xlabel,
56
+ **kw)
57
+ end
58
+
59
+ private def barplot(
60
+ *args,
61
+ width: 40,
62
+ color: :green,
63
+ symbol: "■",
64
+ border: :barplot,
65
+ xscale: nil,
66
+ xlabel: nil,
67
+ data: nil,
68
+ **kw)
69
+ case args.length
70
+ when 0
71
+ data = Hash(data)
72
+ keys = data.keys.map(&:to_s)
73
+ heights = data.values
74
+ when 2
75
+ keys = Array(args[0])
76
+ heights = Array(args[1])
77
+ else
78
+ raise ArgumentError, "invalid arguments"
79
+ end
80
+
81
+ unless keys.length == heights.length
82
+ raise ArgumentError, "The given vectors must be of the same length"
83
+ end
84
+ unless heights.min >= 0
85
+ raise ArgumentError, "All values have to be positive. Negative bars are not supported."
86
+ end
87
+
88
+ xlabel ||= ValueTransformer.transform_name(xscale)
89
+ plot = Barplot.new(heights, width, color, symbol, xscale,
90
+ border: border, xlabel: xlabel,
91
+ **kw)
92
+ keys.each_with_index do |key, i|
93
+ plot.annotate_row!(:l, i, key)
94
+ end
95
+
96
+ plot
97
+ end
98
+
99
+ private def float_round_log10(x, m)
100
+ if x == 0
101
+ 0.0
102
+ elsif x > 0
103
+ x.round(ceil_neg_log10(m) + 1).to_f
104
+ else
105
+ -(-x).round(ceil_neg_log10(m) + 1).to_f
106
+ end
107
+ end
108
+
109
+ private def ceil_neg_log10(x)
110
+ if roundable?(-Math.log10(x))
111
+ (-Math.log10(x)).ceil
112
+ else
113
+ (-Math.log10(x)).floor
114
+ end
115
+ end
116
+
117
+ INT64_MIN = -9223372036854775808
118
+ INT64_MAX = 9223372036854775807
119
+ private def roundable?(x)
120
+ x.to_i == x && INT64_MIN <= x && x < INT64_MAX
121
+ end
122
+
123
+ module ValueTransformer
124
+ PREDEFINED_TRANSFORM_FUNCTIONS = {
125
+ log: Math.method(:log),
126
+ ln: Math.method(:log),
127
+ log10: Math.method(:log10),
128
+ lg: Math.method(:log10),
129
+ log2: Math.method(:log2),
130
+ lb: Math.method(:log2),
131
+ }.freeze
132
+
133
+ def transform_values(func, values)
134
+ return values unless func
135
+
136
+ unless func.respond_to?(:call)
137
+ func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
138
+ unless func.respond_to?(:call)
139
+ raise ArgumentError, "func must be callable"
140
+ end
141
+ end
142
+
143
+ case values
144
+ when Numeric
145
+ func.(values)
146
+ else
147
+ values.map(&func)
148
+ end
149
+ end
150
+
151
+ module_function def transform_name(func, basename="")
152
+ return basename unless func
153
+ case func
154
+ when String, Symbol
155
+ name = func
156
+ when ->(f) { f.respond_to?(:name) }
157
+ name = func.name
158
+ else
159
+ name = "custom"
160
+ end
161
+ "#{basename} [#{name}]"
162
+ end
163
+ end
164
+
165
+
166
+ module BorderMaps
167
+ BORDER_SOLID = {
168
+ tl: "┌",
169
+ tr: "┐",
170
+ bl: "└",
171
+ br: "┘",
172
+ t: "─",
173
+ l: "│",
174
+ b: "─",
175
+ r: "│"
176
+ }.freeze
177
+
178
+ BORDER_CORNERS = {
179
+ tl: "┌",
180
+ tr: "┐",
181
+ bl: "└",
182
+ br: "┘",
183
+ t: " ",
184
+ l: " ",
185
+ b: " ",
186
+ r: " ",
187
+ }.freeze
188
+
189
+ BORDER_BARPLOT = {
190
+ tl: "┌",
191
+ tr: "┐",
192
+ bl: "└",
193
+ br: "┘",
194
+ t: " ",
195
+ l: "┤",
196
+ b: " ",
197
+ r: " ",
198
+ }.freeze
199
+ end
200
+
201
+ BORDER_MAP = {
202
+ solid: BorderMaps::BORDER_SOLID,
203
+ corners: BorderMaps::BORDER_CORNERS,
204
+ barplot: BorderMaps::BORDER_BARPLOT,
205
+ }.freeze
206
+
207
+ module StyledPrinter
208
+ TEXT_COLORS = {
209
+ black: "\033[30m",
210
+ red: "\033[31m",
211
+ green: "\033[32m",
212
+ yellow: "\033[33m",
213
+ blue: "\033[34m",
214
+ magenta: "\033[35m",
215
+ cyan: "\033[36m",
216
+ white: "\033[37m",
217
+ gray: "\033[90m",
218
+ light_black: "\033[90m",
219
+ light_red: "\033[91m",
220
+ light_green: "\033[92m",
221
+ light_yellow: "\033[93m",
222
+ light_blue: "\033[94m",
223
+ light_magenta: "\033[95m",
224
+ light_cyan: "\033[96m",
225
+ normal: "\033[0m",
226
+ default: "\033[39m",
227
+ bold: "\033[1m",
228
+ underline: "\033[4m",
229
+ blink: "\033[5m",
230
+ reverse: "\033[7m",
231
+ hidden: "\033[8m",
232
+ nothing: "",
233
+ }
234
+
235
+ 0.upto(255) do |i|
236
+ TEXT_COLORS[i] = "\033[38;5;#{i}m"
237
+ end
238
+
239
+ TEXT_COLORS.freeze
240
+
241
+ DISABLE_TEXT_STYLE = {
242
+ bold: "\033[22m",
243
+ underline: "\033[24m",
244
+ blink: "\033[25m",
245
+ reverse: "\033[27m",
246
+ hidden: "\033[28m",
247
+ normal: "",
248
+ default: "",
249
+ nothing: "",
250
+ }.freeze
251
+
252
+ COLOR_ENCODE = {
253
+ normal: 0b000,
254
+ blue: 0b001,
255
+ red: 0b010,
256
+ magenta: 0b011,
257
+ green: 0b100,
258
+ cyan: 0b101,
259
+ yellow: 0b110,
260
+ white: 0b111
261
+ }.freeze
262
+
263
+ COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
264
+
265
+ def print_styled(out, *args, bold: false, color: :normal)
266
+ return out.print(*args) unless color?(out)
267
+
268
+ str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
269
+ color = :nothing if bold && color == :bold
270
+ enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
271
+ (bold ? TEXT_COLORS[:bold] : "")
272
+ disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
273
+ DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
274
+ first = true
275
+ StringIO.open do |sio|
276
+ str.each_line do |line|
277
+ sio.puts unless first
278
+ first = false
279
+ continue if line.empty?
280
+ sio.print(enable_ansi, line, disable_ansi)
281
+ end
282
+ sio.close
283
+ out.print(sio.string)
284
+ end
285
+ end
286
+
287
+ def print_color(out, color, *args)
288
+ color = COLOR_DECODE[color]
289
+ print_styled(out, *args, color: color)
290
+ end
291
+
292
+ def color?(out)
293
+ (out && out.tty?) || false
294
+ end
295
+ end
296
+
297
+ module BorderPrinter
298
+ include StyledPrinter
299
+
300
+ def print_border_top(out, padding, length, border=:solid, color: :light_black)
301
+ return if border == :none
302
+ b = BORDER_MAP[border]
303
+ print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
304
+ end
305
+
306
+ def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
307
+ return if border == :none
308
+ b = BORDER_MAP[border]
309
+ print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
310
+ end
311
+ end
312
+
313
+ class Renderer
314
+ include BorderPrinter
315
+
316
+ def self.render(out, plot)
317
+ new(plot).render(out)
318
+ end
319
+
320
+ def initialize(plot)
321
+ @plot = plot
322
+ @out = nil
323
+ end
324
+
325
+ attr_reader :plot
326
+ attr_reader :out
327
+
328
+ def render(out)
329
+ @out = out
330
+ init_render
331
+
332
+ render_top
333
+ render_rows
334
+ render_bottom
335
+ end
336
+
337
+ private
338
+
339
+ def render_top
340
+ # plot the title and the top border
341
+ print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
342
+ puts if plot.title_given?
343
+
344
+ if plot.show_labels?
345
+ topleft_str = plot.decorations.fetch(:tl, "")
346
+ topleft_col = plot.colors_deco.fetch(:tl, :light_black)
347
+ topmid_str = plot.decorations.fetch(:t, "")
348
+ topmid_col = plot.colors_deco.fetch(:t, :light_black)
349
+ topright_str = plot.decorations.fetch(:tr, "")
350
+ topright_col = plot.colors_deco.fetch(:tr, :light_black)
351
+
352
+ if topleft_str != "" || topright_str != "" || topmid_str != ""
353
+ topleft_len = topleft_str.length
354
+ topmid_len = topmid_str.length
355
+ topright_len = topright_str.length
356
+ print_styled(out, @border_padding, topleft_str, color: topleft_col)
357
+ cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
358
+ pad = cnt > 0 ? " " * cnt : ""
359
+ print_styled(out, pad, topmid_str, color: topmid_col)
360
+ cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
361
+ pad = cnt > 0 ? " " * cnt : ""
362
+ print_styled(out, pad, topright_str, "\n", color: topright_col)
363
+ end
364
+ end
365
+
366
+ print_border_top(out, @border_padding, @border_length, plot.border)
367
+ print(" " * @max_len_r, @plot_padding, "\n")
368
+ end
369
+
370
+ # render all rows
371
+ def render_rows
372
+ (0 ... plot.n_rows).each {|row| render_row(row) }
373
+ end
374
+
375
+ def render_row(row)
376
+ # Current labels to left and right of the row and their length
377
+ left_str = plot.labels_left.fetch(row, "")
378
+ left_col = plot.colors_left.fetch(row, :light_black)
379
+ right_str = plot.labels_right.fetch(row, "")
380
+ right_col = plot.colors_right.fetch(row, :light_black)
381
+ left_len = nocolor_string(left_str).length
382
+ right_len = nocolor_string(right_str).length
383
+
384
+ unless color?(out)
385
+ left_str = nocolor_string(left_str)
386
+ right_str = nocolor_string(right_str)
387
+ end
388
+
389
+ # print left annotations
390
+ print(" " * plot.margin)
391
+ if plot.show_labels?
392
+ if row == @y_lab_row
393
+ # print ylabel
394
+ print_styled(out, plot.ylabel, color: :normal)
395
+ print(" " * (@max_len_l - plot.ylabel_length - left_len))
396
+ else
397
+ # print padding to fill ylabel length
398
+ print(" " * (@max_len_l - left_len))
399
+ end
400
+ # print the left annotation
401
+ print_styled(out, left_str, color: left_col)
402
+ end
403
+
404
+ # print left border
405
+ print_styled(out, @plot_padding, @b[:l], color: :light_black)
406
+
407
+ # print canvas row
408
+ plot.print_row(out, row)
409
+
410
+ #print right label and padding
411
+ print_styled(out, @b[:r], color: :light_black)
412
+ if plot.show_labels?
413
+ print(@plot_padding)
414
+ print_styled(out, right_str, color: right_col)
415
+ print(" " * (@max_len_r - right_len))
416
+ end
417
+ puts
418
+ end
419
+
420
+ def render_bottom
421
+ # draw bottom border and bottom labels
422
+ print_border_bottom(out, @border_padding, @border_length, plot.border)
423
+ print(" " * @max_len_r, @plot_padding)
424
+ if plot.show_labels?
425
+ botleft_str = plot.decorations.fetch(:bl, "")
426
+ botleft_col = plot.colors_deco.fetch(:bl, :light_black)
427
+ botmid_str = plot.decorations.fetch(:b, "")
428
+ botmid_col = plot.colors_deco.fetch(:b, :light_black)
429
+ botright_str = plot.decorations.fetch(:br, "")
430
+ botright_col = plot.colors_deco.fetch(:br, :light_black)
431
+
432
+ if botleft_str != "" || botright_str != "" || botmid_str != ""
433
+ puts
434
+ botleft_len = botleft_str.length
435
+ botmid_len = botmid_str.length
436
+ botright_len = botright_str.length
437
+ print_styled(out, @border_padding, botleft_str, color: botleft_col)
438
+ cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
439
+ pad = cnt > 0 ? " " * cnt : ""
440
+ print_styled(out, pad, botmid_str, color: botmid_col)
441
+ cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
442
+ pad = cnt > 0 ? " " * cnt : ""
443
+ print_styled(out, pad, botright_str, color: botright_col)
444
+ end
445
+
446
+ # abuse the print_title function to print the xlabel. maybe refactor this
447
+ puts if plot.xlabel_given?
448
+ print_title(@border_padding, plot.xlabel, p_width: @border_length)
449
+ end
450
+ end
451
+
452
+ def init_render
453
+ @b = BORDER_MAP[plot.border]
454
+ @border_length = plot.n_columns
455
+
456
+ # get length of largest strings to the left and right
457
+ @max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
458
+ plot.labels_left.each_value.map {|l| nocolor_string(l).length }.max :
459
+ 0
460
+ @max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
461
+ plot.labels_right.each_value.map {|l| nocolor_string(l).length }.max :
462
+ 0
463
+ if plot.show_labels? && plot.ylabel_given?
464
+ @max_len_l += plot.ylabel_length + 1
465
+ end
466
+
467
+ # offset where the plot (incl border) begins
468
+ @plot_offset = @max_len_l + plot.margin + plot.padding
469
+
470
+ # padding-string from left to border
471
+ @plot_padding = " " * plot.padding
472
+
473
+ # padding-string between labels and border
474
+ @border_padding = " " * @plot_offset
475
+
476
+ # compute position of ylabel
477
+ @y_lab_row = (plot.n_rows / 2.0).round - 1
478
+ end
479
+
480
+ def print_title(padding, title, p_width: 0, color: :normal)
481
+ return unless title && title != ""
482
+ offset = (p_width / 2.0 - title.length / 2.0).round
483
+ offset = [offset, 0].max
484
+ tpad = " " * offset
485
+ print_styled(out, padding, tpad, title, color: color)
486
+ end
487
+
488
+ def print(*args)
489
+ out.print(*args)
490
+ end
491
+
492
+ def puts(*args)
493
+ out.puts(*args)
494
+ end
495
+
496
+ def nocolor_string(str)
497
+ str.to_s.gsub(/\e\[[0-9]+m/, "")
498
+ end
499
+ end
500
+
501
+ class Plot
502
+ include StyledPrinter
503
+
504
+ DEFAULT_WIDTH = 40
505
+ DEFAULT_BORDER = :solid
506
+ DEFAULT_MARGIN = 3
507
+ DEFAULT_PADDING = 1
508
+
509
+ def initialize(title: nil,
510
+ xlabel: nil,
511
+ ylabel: nil,
512
+ border: DEFAULT_BORDER,
513
+ margin: DEFAULT_MARGIN,
514
+ padding: DEFAULT_PADDING,
515
+ labels: true)
516
+ @title = title
517
+ @xlabel = xlabel
518
+ @ylabel = ylabel
519
+ @border = border
520
+ @margin = check_margin(margin)
521
+ @padding = padding
522
+ @labels_left = {}
523
+ @colors_left = {}
524
+ @labels_right = {}
525
+ @colors_right = {}
526
+ @decorations = {}
527
+ @colors_deco = {}
528
+ @show_labels = labels
529
+ @auto_color = 0
530
+ end
531
+
532
+ attr_reader :title
533
+ attr_reader :xlabel
534
+ attr_reader :ylabel
535
+ attr_reader :border
536
+ attr_reader :margin
537
+ attr_reader :padding
538
+ attr_reader :labels_left
539
+ attr_reader :colors_left
540
+ attr_reader :labels_right
541
+ attr_reader :colors_right
542
+ attr_reader :decorations
543
+ attr_reader :colors_deco
544
+
545
+ def title_given?
546
+ title && title != ""
547
+ end
548
+
549
+ def xlabel_given?
550
+ xlabel && xlabel != ""
551
+ end
552
+
553
+ def ylabel_given?
554
+ ylabel && ylabel != ""
555
+ end
556
+
557
+ def ylabel_length
558
+ (ylabel && ylabel.length) || 0
559
+ end
560
+
561
+ def show_labels?
562
+ @show_labels
563
+ end
564
+
565
+ def annotate!(loc, value, color: :normal)
566
+ case loc
567
+ when :l
568
+ (0 ... n_rows).each do |row|
569
+ if @labels_left.fetch(row, "") == ""
570
+ @labels_left[row] = value
571
+ @colors_left[row] = color
572
+ break
573
+ end
574
+ end
575
+ when :r
576
+ (0 ... n_rows).each do |row|
577
+ if @labels_right.fetch(row, "") == ""
578
+ @labels_right[row] = value
579
+ @colors_right[row] = color
580
+ break
581
+ end
582
+ end
583
+ when :t, :b, :tl, :tr, :bl, :br
584
+ @decorations[loc] = value
585
+ @colors_deco[loc] = color
586
+ else
587
+ raise ArgumentError,
588
+ "unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)"
589
+ end
590
+ end
591
+
592
+ def annotate_row!(loc, row_index, value, color: :normal)
593
+ case loc
594
+ when :l
595
+ @labels_left[row_index] = value
596
+ @colors_left[row_index] = color
597
+ when :r
598
+ @labels_right[row_index] = value
599
+ @colors_right[row_index] = color
600
+ else
601
+ raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead"
602
+ end
603
+ end
604
+
605
+ def render(out)
606
+ Renderer.render(out, self)
607
+ end
608
+
609
+ COLOR_CYCLE = [
610
+ :green,
611
+ :blue,
612
+ :red,
613
+ :magenta,
614
+ :yellow,
615
+ :cyan
616
+ ].freeze
617
+
618
+ def next_color
619
+ COLOR_CYCLE[@auto_color]
620
+ ensure
621
+ @auto_color = (@auto_color + 1) % COLOR_CYCLE.length
622
+ end
623
+
624
+ def to_s
625
+ StringIO.open do |sio|
626
+ render(sio)
627
+ sio.close
628
+ sio.string
629
+ end
630
+ end
631
+
632
+ private def check_margin(margin)
633
+ if margin < 0
634
+ raise ArgumentError, "margin must be >= 0"
635
+ end
636
+ margin
637
+ end
638
+
639
+ private def check_row_index(row_index)
640
+ unless 0 <= row_index && row_index < n_rows
641
+ raise ArgumentError, "row_index out of bounds"
642
+ end
643
+ end
644
+ end
645
+
646
+ class Barplot < Plot
647
+ include ValueTransformer
648
+
649
+ MIN_WIDTH = 10
650
+ DEFAULT_COLOR = :green
651
+ DEFAULT_SYMBOL = "■"
652
+
653
+ def initialize(bars, width, color, symbol, transform, **kw)
654
+ if symbol.length > 1
655
+ raise ArgumentError, "symbol must be a single character"
656
+ end
657
+ @bars = bars
658
+ @symbol = symbol
659
+ @max_freq, i = find_max(transform_values(transform, bars))
660
+ @max_len = bars[i].to_s.length
661
+ @width = [width, max_len + 7, MIN_WIDTH].max
662
+ @color = color
663
+ @symbol = symbol
664
+ @transform = transform
665
+ super(**kw)
666
+ end
667
+
668
+ attr_reader :max_freq
669
+ attr_reader :max_len
670
+ attr_reader :width
671
+
672
+ def n_rows
673
+ @bars.length
674
+ end
675
+
676
+ def n_columns
677
+ @width
678
+ end
679
+
680
+ def add_row!(bars)
681
+ @bars.concat(bars)
682
+ @max_freq, i = find_max(transform_values(@transform, bars))
683
+ @max_len = @bars[i].to_s.length
684
+ end
685
+
686
+ def print_row(out, row_index)
687
+ check_row_index(row_index)
688
+ bar = @bars[row_index]
689
+ max_bar_width = [width - 2 - max_len, 1].max
690
+ val = transform_values(@transform, bar)
691
+ bar_len = max_freq > 0 ?
692
+ ([val, 0].max.fdiv(max_freq) * max_bar_width).round :
693
+ 0
694
+ bar_str = max_freq > 0 ? @symbol * bar_len : ""
695
+ bar_lbl = bar.to_s
696
+ print_styled(out, bar_str, color: @color)
697
+ print_styled(out, " ", bar_lbl, color: :normal)
698
+ pan_len = [max_bar_width + 1 + max_len - bar_len - bar_lbl.length, 0].max
699
+ pad = " " * pan_len.round
700
+ out.print(pad)
701
+ end
702
+
703
+ private def find_max(values)
704
+ i = j = 0
705
+ max = values[i]
706
+ while j < values.length
707
+ if values[j] > max
708
+ i, max = j, values[j]
709
+ end
710
+ j += 1
711
+ end
712
+ [max, i]
713
+ end
714
+ end
715
+ end
716
+
@@ -1,3 +1,3 @@
1
1
  class MiniHistogram
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
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.0
4
+ version: 0.2.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-21 00:00:00.000000000 Z
11
+ date: 2020-09-15 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:
@@ -32,17 +46,19 @@ executables: []
32
46
  extensions: []
33
47
  extra_rdoc_files: []
34
48
  files:
49
+ - ".github/workflows/check_changelog.yml"
35
50
  - ".gitignore"
36
51
  - ".travis.yml"
52
+ - CHANGELOG.md
37
53
  - CODE_OF_CONDUCT.md
38
54
  - Gemfile
39
- - Gemfile.lock
40
55
  - LICENSE.txt
41
56
  - README.md
42
57
  - Rakefile
43
58
  - bin/console
44
59
  - bin/setup
45
60
  - lib/mini_histogram.rb
61
+ - lib/mini_histogram/plot.rb
46
62
  - lib/mini_histogram/version.rb
47
63
  - mini_histogram.gemspec
48
64
  homepage: https://github.com/zombocom/mini_histogram
@@ -50,7 +66,7 @@ licenses:
50
66
  - MIT
51
67
  metadata:
52
68
  homepage_uri: https://github.com/zombocom/mini_histogram
53
- post_install_message:
69
+ post_install_message:
54
70
  rdoc_options: []
55
71
  require_paths:
56
72
  - lib
@@ -66,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
66
82
  version: '0'
67
83
  requirements: []
68
84
  rubygems_version: 3.1.2
69
- signing_key:
85
+ signing_key:
70
86
  specification_version: 4
71
87
  summary: A small gem for building histograms out of Ruby arrays
72
88
  test_files: []
@@ -1,26 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- mini_histogram (0.1.0)
5
-
6
- GEM
7
- remote: https://rubygems.org/
8
- specs:
9
- m (1.5.1)
10
- method_source (>= 0.6.7)
11
- rake (>= 0.9.2.2)
12
- method_source (0.9.2)
13
- minitest (5.14.0)
14
- rake (12.3.3)
15
-
16
- PLATFORMS
17
- ruby
18
-
19
- DEPENDENCIES
20
- m
21
- mini_histogram!
22
- minitest (~> 5.0)
23
- rake (~> 12.0)
24
-
25
- BUNDLED WITH
26
- 2.1.2