mini_histogram 0.1.3 → 0.2.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: f74ffb350c63f9f26ea90db6a41c8135c3b2e180de663a1747d649a47e8e2df4
4
- data.tar.gz: 3a69ae4f5a0f4d64d830f7a39b0b79f3ab011cae5cf7479328ee8a907b5f7820
3
+ metadata.gz: 4fb499951688de491b9efc5ad662e6e89375418fcbf2f21eaafd256e379a09e9
4
+ data.tar.gz: 26d28edadb37e4316e5145a52755efd6057f774129bac3d56ba05c2c34fe936d
5
5
  SHA512:
6
- metadata.gz: 477c617614b9ed9202dc2f42aa6c3571a44651173ffb7357c5650aefedaeb599b9a98b38daa107e9c2002f8c59700cc2e349ddb2ead5412e99c339d37f4f6691
7
- data.tar.gz: a6dc9302632ab19b9264c5787d31fe8387dc9130669ec0fe2ae7860bab6fbcb10fee9d712943aa00a3a41167873fee214df9c07c5af2101dc5914da5662f9472
6
+ metadata.gz: a3d59b816db8059d5855c1c1ec820344eb5c0558a09450567c6cc6460ff737d1ab8c4626d7d73ca55922063e4741f3cf039d75bd99fa1d2ae44706177deb5371
7
+ data.tar.gz: ec2ed27962422fc0a05fb2e0b1330ea85466d993a974a4f3484f43e3335c1347d7925db6979a1b2a95e958dc7b0d05fdbb859293b15f6e4740cca4fdf716bb8b
@@ -1,4 +1,8 @@
1
- ## Master
1
+ ## HEAD
2
+
3
+ ## 0.2.0
4
+
5
+ - Experimental plotting support added (https://github.com/zombocom/mini_histogram/pull/3)
2
6
 
3
7
  ## 0.1.3
4
8
 
data/README.md CHANGED
@@ -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)
@@ -198,6 +198,10 @@ class MiniHistogram
198
198
  end
199
199
  alias :edge :edges
200
200
 
201
+ def plot
202
+ raise "You must `require 'mini_histogram/plot'` to get this feature"
203
+ end
204
+
201
205
  # Given an array of Histograms this function calcualtes
202
206
  # an average edge size along with the minimum and maximum
203
207
  # edge values. It then updates the edge value on all inputs
@@ -226,3 +230,4 @@ class MiniHistogram
226
230
  return array_of_histograms
227
231
  end
228
232
  end
233
+
@@ -0,0 +1,714 @@
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
+ private def roundable?(x)
118
+ x.to_i == x && INT64_MIN <= x && x < INT64_MAX
119
+ end
120
+
121
+ module ValueTransformer
122
+ PREDEFINED_TRANSFORM_FUNCTIONS = {
123
+ log: Math.method(:log),
124
+ ln: Math.method(:log),
125
+ log10: Math.method(:log10),
126
+ lg: Math.method(:log10),
127
+ log2: Math.method(:log2),
128
+ lb: Math.method(:log2),
129
+ }.freeze
130
+
131
+ def transform_values(func, values)
132
+ return values unless func
133
+
134
+ unless func.respond_to?(:call)
135
+ func = PREDEFINED_TRANSFORM_FUNCTIONS[func]
136
+ unless func.respond_to?(:call)
137
+ raise ArgumentError, "func must be callable"
138
+ end
139
+ end
140
+
141
+ case values
142
+ when Numeric
143
+ func.(values)
144
+ else
145
+ values.map(&func)
146
+ end
147
+ end
148
+
149
+ module_function def transform_name(func, basename="")
150
+ return basename unless func
151
+ case func
152
+ when String, Symbol
153
+ name = func
154
+ when ->(f) { f.respond_to?(:name) }
155
+ name = func.name
156
+ else
157
+ name = "custom"
158
+ end
159
+ "#{basename} [#{name}]"
160
+ end
161
+ end
162
+
163
+
164
+ module BorderMaps
165
+ BORDER_SOLID = {
166
+ tl: "┌",
167
+ tr: "┐",
168
+ bl: "└",
169
+ br: "┘",
170
+ t: "─",
171
+ l: "│",
172
+ b: "─",
173
+ r: "│"
174
+ }.freeze
175
+
176
+ BORDER_CORNERS = {
177
+ tl: "┌",
178
+ tr: "┐",
179
+ bl: "└",
180
+ br: "┘",
181
+ t: " ",
182
+ l: " ",
183
+ b: " ",
184
+ r: " ",
185
+ }.freeze
186
+
187
+ BORDER_BARPLOT = {
188
+ tl: "┌",
189
+ tr: "┐",
190
+ bl: "└",
191
+ br: "┘",
192
+ t: " ",
193
+ l: "┤",
194
+ b: " ",
195
+ r: " ",
196
+ }.freeze
197
+ end
198
+
199
+ BORDER_MAP = {
200
+ solid: BorderMaps::BORDER_SOLID,
201
+ corners: BorderMaps::BORDER_CORNERS,
202
+ barplot: BorderMaps::BORDER_BARPLOT,
203
+ }.freeze
204
+
205
+ module StyledPrinter
206
+ TEXT_COLORS = {
207
+ black: "\033[30m",
208
+ red: "\033[31m",
209
+ green: "\033[32m",
210
+ yellow: "\033[33m",
211
+ blue: "\033[34m",
212
+ magenta: "\033[35m",
213
+ cyan: "\033[36m",
214
+ white: "\033[37m",
215
+ gray: "\033[90m",
216
+ light_black: "\033[90m",
217
+ light_red: "\033[91m",
218
+ light_green: "\033[92m",
219
+ light_yellow: "\033[93m",
220
+ light_blue: "\033[94m",
221
+ light_magenta: "\033[95m",
222
+ light_cyan: "\033[96m",
223
+ normal: "\033[0m",
224
+ default: "\033[39m",
225
+ bold: "\033[1m",
226
+ underline: "\033[4m",
227
+ blink: "\033[5m",
228
+ reverse: "\033[7m",
229
+ hidden: "\033[8m",
230
+ nothing: "",
231
+ }
232
+
233
+ 0.upto(255) do |i|
234
+ TEXT_COLORS[i] = "\033[38;5;#{i}m"
235
+ end
236
+
237
+ TEXT_COLORS.freeze
238
+
239
+ DISABLE_TEXT_STYLE = {
240
+ bold: "\033[22m",
241
+ underline: "\033[24m",
242
+ blink: "\033[25m",
243
+ reverse: "\033[27m",
244
+ hidden: "\033[28m",
245
+ normal: "",
246
+ default: "",
247
+ nothing: "",
248
+ }.freeze
249
+
250
+ COLOR_ENCODE = {
251
+ normal: 0b000,
252
+ blue: 0b001,
253
+ red: 0b010,
254
+ magenta: 0b011,
255
+ green: 0b100,
256
+ cyan: 0b101,
257
+ yellow: 0b110,
258
+ white: 0b111
259
+ }.freeze
260
+
261
+ COLOR_DECODE = COLOR_ENCODE.map {|k, v| [v, k] }.to_h.freeze
262
+
263
+ def print_styled(out, *args, bold: false, color: :normal)
264
+ return out.print(*args) unless color?(out)
265
+
266
+ str = StringIO.open {|sio| sio.print(*args); sio.close; sio.string }
267
+ color = :nothing if bold && color == :bold
268
+ enable_ansi = TEXT_COLORS.fetch(color, TEXT_COLORS[:default]) +
269
+ (bold ? TEXT_COLORS[:bold] : "")
270
+ disable_ansi = (bold ? DISABLE_TEXT_STYLE[:bold] : "") +
271
+ DISABLE_TEXT_STYLE.fetch(color, TEXT_COLORS[:default])
272
+ first = true
273
+ StringIO.open do |sio|
274
+ str.each_line do |line|
275
+ sio.puts unless first
276
+ first = false
277
+ continue if line.empty?
278
+ sio.print(enable_ansi, line, disable_ansi)
279
+ end
280
+ sio.close
281
+ out.print(sio.string)
282
+ end
283
+ end
284
+
285
+ def print_color(out, color, *args)
286
+ color = COLOR_DECODE[color]
287
+ print_styled(out, *args, color: color)
288
+ end
289
+
290
+ def color?(out)
291
+ (out && out.tty?) || false
292
+ end
293
+ end
294
+
295
+ module BorderPrinter
296
+ include StyledPrinter
297
+
298
+ def print_border_top(out, padding, length, border=:solid, color: :light_black)
299
+ return if border == :none
300
+ b = BORDER_MAP[border]
301
+ print_styled(out, padding, b[:tl], b[:t] * length, b[:tr], color: color)
302
+ end
303
+
304
+ def print_border_bottom(out, padding, length, border=:solid, color: :light_black)
305
+ return if border == :none
306
+ b = BORDER_MAP[border]
307
+ print_styled(out, padding, b[:bl], b[:b] * length, b[:br], color: color)
308
+ end
309
+ end
310
+
311
+ class Renderer
312
+ include BorderPrinter
313
+
314
+ def self.render(out, plot)
315
+ new(plot).render(out)
316
+ end
317
+
318
+ def initialize(plot)
319
+ @plot = plot
320
+ @out = nil
321
+ end
322
+
323
+ attr_reader :plot
324
+ attr_reader :out
325
+
326
+ def render(out)
327
+ @out = out
328
+ init_render
329
+
330
+ render_top
331
+ render_rows
332
+ render_bottom
333
+ end
334
+
335
+ private
336
+
337
+ def render_top
338
+ # plot the title and the top border
339
+ print_title(@border_padding, plot.title, p_width: @border_length, color: :bold)
340
+ puts if plot.title_given?
341
+
342
+ if plot.show_labels?
343
+ topleft_str = plot.decorations.fetch(:tl, "")
344
+ topleft_col = plot.colors_deco.fetch(:tl, :light_black)
345
+ topmid_str = plot.decorations.fetch(:t, "")
346
+ topmid_col = plot.colors_deco.fetch(:t, :light_black)
347
+ topright_str = plot.decorations.fetch(:tr, "")
348
+ topright_col = plot.colors_deco.fetch(:tr, :light_black)
349
+
350
+ if topleft_str != "" || topright_str != "" || topmid_str != ""
351
+ topleft_len = topleft_str.length
352
+ topmid_len = topmid_str.length
353
+ topright_len = topright_str.length
354
+ print_styled(out, @border_padding, topleft_str, color: topleft_col)
355
+ cnt = (@border_length / 2.0 - topmid_len / 2.0 - topleft_len).round
356
+ pad = cnt > 0 ? " " * cnt : ""
357
+ print_styled(out, pad, topmid_str, color: topmid_col)
358
+ cnt = @border_length - topright_len - topleft_len - topmid_len + 2 - cnt
359
+ pad = cnt > 0 ? " " * cnt : ""
360
+ print_styled(out, pad, topright_str, "\n", color: topright_col)
361
+ end
362
+ end
363
+
364
+ print_border_top(out, @border_padding, @border_length, plot.border)
365
+ print(" " * @max_len_r, @plot_padding, "\n")
366
+ end
367
+
368
+ # render all rows
369
+ def render_rows
370
+ (0 ... plot.n_rows).each {|row| render_row(row) }
371
+ end
372
+
373
+ def render_row(row)
374
+ # Current labels to left and right of the row and their length
375
+ left_str = plot.labels_left.fetch(row, "")
376
+ left_col = plot.colors_left.fetch(row, :light_black)
377
+ right_str = plot.labels_right.fetch(row, "")
378
+ right_col = plot.colors_right.fetch(row, :light_black)
379
+ left_len = nocolor_string(left_str).length
380
+ right_len = nocolor_string(right_str).length
381
+
382
+ unless color?(out)
383
+ left_str = nocolor_string(left_str)
384
+ right_str = nocolor_string(right_str)
385
+ end
386
+
387
+ # print left annotations
388
+ print(" " * plot.margin)
389
+ if plot.show_labels?
390
+ if row == @y_lab_row
391
+ # print ylabel
392
+ print_styled(out, plot.ylabel, color: :normal)
393
+ print(" " * (@max_len_l - plot.ylabel_length - left_len))
394
+ else
395
+ # print padding to fill ylabel length
396
+ print(" " * (@max_len_l - left_len))
397
+ end
398
+ # print the left annotation
399
+ print_styled(out, left_str, color: left_col)
400
+ end
401
+
402
+ # print left border
403
+ print_styled(out, @plot_padding, @b[:l], color: :light_black)
404
+
405
+ # print canvas row
406
+ plot.print_row(out, row)
407
+
408
+ #print right label and padding
409
+ print_styled(out, @b[:r], color: :light_black)
410
+ if plot.show_labels?
411
+ print(@plot_padding)
412
+ print_styled(out, right_str, color: right_col)
413
+ print(" " * (@max_len_r - right_len))
414
+ end
415
+ puts
416
+ end
417
+
418
+ def render_bottom
419
+ # draw bottom border and bottom labels
420
+ print_border_bottom(out, @border_padding, @border_length, plot.border)
421
+ print(" " * @max_len_r, @plot_padding)
422
+ if plot.show_labels?
423
+ botleft_str = plot.decorations.fetch(:bl, "")
424
+ botleft_col = plot.colors_deco.fetch(:bl, :light_black)
425
+ botmid_str = plot.decorations.fetch(:b, "")
426
+ botmid_col = plot.colors_deco.fetch(:b, :light_black)
427
+ botright_str = plot.decorations.fetch(:br, "")
428
+ botright_col = plot.colors_deco.fetch(:br, :light_black)
429
+
430
+ if botleft_str != "" || botright_str != "" || botmid_str != ""
431
+ puts
432
+ botleft_len = botleft_str.length
433
+ botmid_len = botmid_str.length
434
+ botright_len = botright_str.length
435
+ print_styled(out, @border_padding, botleft_str, color: botleft_col)
436
+ cnt = (@border_length / 2.0 - botmid_len / 2.0 - botleft_len).round
437
+ pad = cnt > 0 ? " " * cnt : ""
438
+ print_styled(out, pad, botmid_str, color: botmid_col)
439
+ cnt = @border_length - botright_len - botleft_len - botmid_len + 2 - cnt
440
+ pad = cnt > 0 ? " " * cnt : ""
441
+ print_styled(out, pad, botright_str, color: botright_col)
442
+ end
443
+
444
+ # abuse the print_title function to print the xlabel. maybe refactor this
445
+ puts if plot.xlabel_given?
446
+ print_title(@border_padding, plot.xlabel, p_width: @border_length)
447
+ end
448
+ end
449
+
450
+ def init_render
451
+ @b = BORDER_MAP[plot.border]
452
+ @border_length = plot.n_columns
453
+
454
+ # get length of largest strings to the left and right
455
+ @max_len_l = plot.show_labels? && !plot.labels_left.empty? ?
456
+ plot.labels_left.each_value.map {|l| nocolor_string(l).length }.max :
457
+ 0
458
+ @max_len_r = plot.show_labels? && !plot.labels_right.empty? ?
459
+ plot.labels_right.each_value.map {|l| nocolor_string(l).length }.max :
460
+ 0
461
+ if plot.show_labels? && plot.ylabel_given?
462
+ @max_len_l += plot.ylabel_length + 1
463
+ end
464
+
465
+ # offset where the plot (incl border) begins
466
+ @plot_offset = @max_len_l + plot.margin + plot.padding
467
+
468
+ # padding-string from left to border
469
+ @plot_padding = " " * plot.padding
470
+
471
+ # padding-string between labels and border
472
+ @border_padding = " " * @plot_offset
473
+
474
+ # compute position of ylabel
475
+ @y_lab_row = (plot.n_rows / 2.0).round - 1
476
+ end
477
+
478
+ def print_title(padding, title, p_width: 0, color: :normal)
479
+ return unless title && title != ""
480
+ offset = (p_width / 2.0 - title.length / 2.0).round
481
+ offset = [offset, 0].max
482
+ tpad = " " * offset
483
+ print_styled(out, padding, tpad, title, color: color)
484
+ end
485
+
486
+ def print(*args)
487
+ out.print(*args)
488
+ end
489
+
490
+ def puts(*args)
491
+ out.puts(*args)
492
+ end
493
+
494
+ def nocolor_string(str)
495
+ str.to_s.gsub(/\e\[[0-9]+m/, "")
496
+ end
497
+ end
498
+
499
+ class Plot
500
+ include StyledPrinter
501
+
502
+ DEFAULT_WIDTH = 40
503
+ DEFAULT_BORDER = :solid
504
+ DEFAULT_MARGIN = 3
505
+ DEFAULT_PADDING = 1
506
+
507
+ def initialize(title: nil,
508
+ xlabel: nil,
509
+ ylabel: nil,
510
+ border: DEFAULT_BORDER,
511
+ margin: DEFAULT_MARGIN,
512
+ padding: DEFAULT_PADDING,
513
+ labels: true)
514
+ @title = title
515
+ @xlabel = xlabel
516
+ @ylabel = ylabel
517
+ @border = border
518
+ @margin = check_margin(margin)
519
+ @padding = padding
520
+ @labels_left = {}
521
+ @colors_left = {}
522
+ @labels_right = {}
523
+ @colors_right = {}
524
+ @decorations = {}
525
+ @colors_deco = {}
526
+ @show_labels = labels
527
+ @auto_color = 0
528
+ end
529
+
530
+ attr_reader :title
531
+ attr_reader :xlabel
532
+ attr_reader :ylabel
533
+ attr_reader :border
534
+ attr_reader :margin
535
+ attr_reader :padding
536
+ attr_reader :labels_left
537
+ attr_reader :colors_left
538
+ attr_reader :labels_right
539
+ attr_reader :colors_right
540
+ attr_reader :decorations
541
+ attr_reader :colors_deco
542
+
543
+ def title_given?
544
+ title && title != ""
545
+ end
546
+
547
+ def xlabel_given?
548
+ xlabel && xlabel != ""
549
+ end
550
+
551
+ def ylabel_given?
552
+ ylabel && ylabel != ""
553
+ end
554
+
555
+ def ylabel_length
556
+ (ylabel && ylabel.length) || 0
557
+ end
558
+
559
+ def show_labels?
560
+ @show_labels
561
+ end
562
+
563
+ def annotate!(loc, value, color: :normal)
564
+ case loc
565
+ when :l
566
+ (0 ... n_rows).each do |row|
567
+ if @labels_left.fetch(row, "") == ""
568
+ @labels_left[row] = value
569
+ @colors_left[row] = color
570
+ break
571
+ end
572
+ end
573
+ when :r
574
+ (0 ... n_rows).each do |row|
575
+ if @labels_right.fetch(row, "") == ""
576
+ @labels_right[row] = value
577
+ @colors_right[row] = color
578
+ break
579
+ end
580
+ end
581
+ when :t, :b, :tl, :tr, :bl, :br
582
+ @decorations[loc] = value
583
+ @colors_deco[loc] = color
584
+ else
585
+ raise ArgumentError,
586
+ "unknown location to annotate (#{loc.inspect} for :t, :b, :l, :r, :tl, :tr, :bl, or :br)"
587
+ end
588
+ end
589
+
590
+ def annotate_row!(loc, row_index, value, color: :normal)
591
+ case loc
592
+ when :l
593
+ @labels_left[row_index] = value
594
+ @colors_left[row_index] = color
595
+ when :r
596
+ @labels_right[row_index] = value
597
+ @colors_right[row_index] = color
598
+ else
599
+ raise ArgumentError, "unknown location `#{loc}`, try :l or :r instead"
600
+ end
601
+ end
602
+
603
+ def render(out)
604
+ Renderer.render(out, self)
605
+ end
606
+
607
+ COLOR_CYCLE = [
608
+ :green,
609
+ :blue,
610
+ :red,
611
+ :magenta,
612
+ :yellow,
613
+ :cyan
614
+ ].freeze
615
+
616
+ def next_color
617
+ COLOR_CYCLE[@auto_color]
618
+ ensure
619
+ @auto_color = (@auto_color + 1) % COLOR_CYCLE.length
620
+ end
621
+
622
+ def to_s
623
+ StringIO.open do |sio|
624
+ render(sio)
625
+ sio.close
626
+ sio.string
627
+ end
628
+ end
629
+
630
+ private def check_margin(margin)
631
+ if margin < 0
632
+ raise ArgumentError, "margin must be >= 0"
633
+ end
634
+ margin
635
+ end
636
+
637
+ private def check_row_index(row_index)
638
+ unless 0 <= row_index && row_index < n_rows
639
+ raise ArgumentError, "row_index out of bounds"
640
+ end
641
+ end
642
+ end
643
+
644
+ class Barplot < Plot
645
+ include ValueTransformer
646
+
647
+ MIN_WIDTH = 10
648
+ DEFAULT_COLOR = :green
649
+ DEFAULT_SYMBOL = "■"
650
+
651
+ def initialize(bars, width, color, symbol, transform, **kw)
652
+ if symbol.length > 1
653
+ raise ArgumentError, "symbol must be a single character"
654
+ end
655
+ @bars = bars
656
+ @symbol = symbol
657
+ @max_freq, i = find_max(transform_values(transform, bars))
658
+ @max_len = bars[i].to_s.length
659
+ @width = [width, max_len + 7, MIN_WIDTH].max
660
+ @color = color
661
+ @symbol = symbol
662
+ @transform = transform
663
+ super(**kw)
664
+ end
665
+
666
+ attr_reader :max_freq
667
+ attr_reader :max_len
668
+ attr_reader :width
669
+
670
+ def n_rows
671
+ @bars.length
672
+ end
673
+
674
+ def n_columns
675
+ @width
676
+ end
677
+
678
+ def add_row!(bars)
679
+ @bars.concat(bars)
680
+ @max_freq, i = find_max(transform_values(@transform, bars))
681
+ @max_len = @bars[i].to_s.length
682
+ end
683
+
684
+ def print_row(out, row_index)
685
+ check_row_index(row_index)
686
+ bar = @bars[row_index]
687
+ max_bar_width = [width - 2 - max_len, 1].max
688
+ val = transform_values(@transform, bar)
689
+ bar_len = max_freq > 0 ?
690
+ ([val, 0].max.fdiv(max_freq) * max_bar_width).round :
691
+ 0
692
+ bar_str = max_freq > 0 ? @symbol * bar_len : ""
693
+ bar_lbl = bar.to_s
694
+ print_styled(out, bar_str, color: @color)
695
+ print_styled(out, " ", bar_lbl, color: :normal)
696
+ pan_len = [max_bar_width + 1 + max_len - bar_len - bar_lbl.length, 0].max
697
+ pad = " " * pan_len.round
698
+ out.print(pad)
699
+ end
700
+
701
+ private def find_max(values)
702
+ i = j = 0
703
+ max = values[i]
704
+ while j < values.length
705
+ if values[j] > max
706
+ i, max = j, values[j]
707
+ end
708
+ j += 1
709
+ end
710
+ [max, i]
711
+ end
712
+ end
713
+ end
714
+
@@ -1,3 +1,3 @@
1
1
  class MiniHistogram
2
- VERSION = "0.1.3"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mini_histogram
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.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-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: m
@@ -58,6 +58,7 @@ files:
58
58
  - bin/console
59
59
  - bin/setup
60
60
  - lib/mini_histogram.rb
61
+ - lib/mini_histogram/plot.rb
61
62
  - lib/mini_histogram/version.rb
62
63
  - mini_histogram.gemspec
63
64
  homepage: https://github.com/zombocom/mini_histogram
@@ -65,7 +66,7 @@ licenses:
65
66
  - MIT
66
67
  metadata:
67
68
  homepage_uri: https://github.com/zombocom/mini_histogram
68
- post_install_message:
69
+ post_install_message:
69
70
  rdoc_options: []
70
71
  require_paths:
71
72
  - lib
@@ -81,7 +82,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
82
  version: '0'
82
83
  requirements: []
83
84
  rubygems_version: 3.1.2
84
- signing_key:
85
+ signing_key:
85
86
  specification_version: 4
86
87
  summary: A small gem for building histograms out of Ruby arrays
87
88
  test_files: []