mini_histogram 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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