mini_histogram 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f74ffb350c63f9f26ea90db6a41c8135c3b2e180de663a1747d649a47e8e2df4
4
- data.tar.gz: 3a69ae4f5a0f4d64d830f7a39b0b79f3ab011cae5cf7479328ee8a907b5f7820
3
+ metadata.gz: 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: []