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 +4 -4
- data/CHANGELOG.md +5 -1
- data/README.md +37 -0
- data/lib/mini_histogram.rb +5 -0
- data/lib/mini_histogram/plot.rb +714 -0
- data/lib/mini_histogram/version.rb +1 -1
- metadata +6 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4fb499951688de491b9efc5ad662e6e89375418fcbf2f21eaafd256e379a09e9
|
4
|
+
data.tar.gz: 26d28edadb37e4316e5145a52755efd6057f774129bac3d56ba05c2c34fe936d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a3d59b816db8059d5855c1c1ec820344eb5c0558a09450567c6cc6460ff737d1ab8c4626d7d73ca55922063e4741f3cf039d75bd99fa1d2ae44706177deb5371
|
7
|
+
data.tar.gz: ec2ed27962422fc0a05fb2e0b1330ea85466d993a974a4f3484f43e3335c1347d7925db6979a1b2a95e958dc7b0d05fdbb859293b15f6e4740cca4fdf716bb8b
|
data/CHANGELOG.md
CHANGED
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)
|
data/lib/mini_histogram.rb
CHANGED
@@ -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
|
+
|
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.
|
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-
|
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: []
|