tessella 0.3.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +900 -0
- data/doc/README.md +74 -0
- data/doc/bitfield.md +123 -0
- data/doc/charts.md +1370 -0
- data/doc/check.rb +75 -0
- data/doc/cli.md +483 -0
- data/doc/composition.md +599 -0
- data/doc/getting-started.md +121 -0
- data/doc/glyphs.md +134 -0
- data/doc/images.md +325 -0
- data/doc/library.md +239 -0
- data/doc/lint.md +270 -0
- data/doc/meter.md +210 -0
- data/doc/rule.md +51 -0
- data/doc/seq.md +169 -0
- data/doc/sparkline.md +63 -0
- data/doc/table.md +347 -0
- data/doc/targets.md +222 -0
- data/doc/text.md +84 -0
- data/doc/tree.md +108 -0
- data/doc/wave.md +122 -0
- data/examples/README.md +103 -0
- data/examples/ages.txt +9 -0
- data/examples/bad.csv +3 -0
- data/examples/boxes.txt +3 -0
- data/examples/broken.txt +4 -0
- data/examples/calibration.rb +12 -0
- data/examples/cities.csv +4 -0
- data/examples/cjk.rb +13 -0
- data/examples/comment.rb +9 -0
- data/examples/comment.sh +13 -0
- data/examples/cpu.txt +60 -0
- data/examples/dashboard.sh +25 -0
- data/examples/datasheet.rb +16 -0
- data/examples/disc.pgm +0 -0
- data/examples/disks.sh +18 -0
- data/examples/disks.txt +5 -0
- data/examples/dose.txt +7 -0
- data/examples/drift.txt +4 -0
- data/examples/ethernet.txt +3 -0
- data/examples/figure.c +5 -0
- data/examples/fit.rb +16 -0
- data/examples/floors.rb +21 -0
- data/examples/gallery.sh +20 -0
- data/examples/gauges.sh +31 -0
- data/examples/gradient.rb +16 -0
- data/examples/growth.txt +6 -0
- data/examples/hosts.csv +4 -0
- data/examples/icmp.txt +3 -0
- data/examples/ipv4.txt +12 -0
- data/examples/kinds.csv +4 -0
- data/examples/langs.csv +4 -0
- data/examples/lint.rb +16 -0
- data/examples/load.rb +14 -0
- data/examples/login.txt +10 -0
- data/examples/neg.csv +4 -0
- data/examples/normal.txt +200 -0
- data/examples/note.txt +2 -0
- data/examples/notes-fixed.md +17 -0
- data/examples/notes.md +17 -0
- data/examples/ohlc.txt +7 -0
- data/examples/os.csv +10 -0
- data/examples/outline.txt +8 -0
- data/examples/plot.txt +5 -0
- data/examples/refusals.rb +17 -0
- data/examples/report.sh +21 -0
- data/examples/rfc.sh +22 -0
- data/examples/samples.txt +7 -0
- data/examples/shapes.pgm +0 -0
- data/examples/sizes.tsv +3 -0
- data/examples/spi.txt +6 -0
- data/examples/status.sh +23 -0
- data/examples/stem.txt +12 -0
- data/examples/table-drift.txt +4 -0
- data/examples/temps.txt +31 -0
- data/examples/trig.sh +13 -0
- data/examples/trig.txt +102 -0
- data/examples/week.txt +8 -0
- data/examples/wide.txt +2 -0
- data/exe/tessella +6 -0
- data/lib/tessella/axis.rb +71 -0
- data/lib/tessella/bitfield.rb +194 -0
- data/lib/tessella/block.rb +56 -0
- data/lib/tessella/borders.rb +85 -0
- data/lib/tessella/canvas/area.rb +155 -0
- data/lib/tessella/canvas/ascii.rb +120 -0
- data/lib/tessella/canvas/block.rb +40 -0
- data/lib/tessella/canvas/braille.rb +30 -0
- data/lib/tessella/canvas/clip.rb +49 -0
- data/lib/tessella/canvas/density.rb +51 -0
- data/lib/tessella/canvas/dot.rb +27 -0
- data/lib/tessella/canvas/eighths.rb +43 -0
- data/lib/tessella/canvas/half.rb +51 -0
- data/lib/tessella/canvas/sextant.rb +36 -0
- data/lib/tessella/canvas.rb +168 -0
- data/lib/tessella/cell.rb +10 -0
- data/lib/tessella/chart.rb +211 -0
- data/lib/tessella/check.rb +52 -0
- data/lib/tessella/cli/bitfield.rb +121 -0
- data/lib/tessella/cli/chart.rb +615 -0
- data/lib/tessella/cli/chart_candle.rb +36 -0
- data/lib/tessella/cli/chart_heatmap.rb +21 -0
- data/lib/tessella/cli/chart_help.rb +85 -0
- data/lib/tessella/cli/chart_pie.rb +38 -0
- data/lib/tessella/cli/compose.rb +156 -0
- data/lib/tessella/cli/compose_options.rb +259 -0
- data/lib/tessella/cli/glyphs.rb +97 -0
- data/lib/tessella/cli/image.rb +250 -0
- data/lib/tessella/cli/input.rb +44 -0
- data/lib/tessella/cli/iso_time.rb +28 -0
- data/lib/tessella/cli/lint.rb +102 -0
- data/lib/tessella/cli/lint_options.rb +99 -0
- data/lib/tessella/cli/main.rb +121 -0
- data/lib/tessella/cli/meter.rb +80 -0
- data/lib/tessella/cli/rule.rb +57 -0
- data/lib/tessella/cli/seq.rb +117 -0
- data/lib/tessella/cli/sparkline.rb +72 -0
- data/lib/tessella/cli/table.rb +167 -0
- data/lib/tessella/cli/text.rb +78 -0
- data/lib/tessella/cli/tree.rb +100 -0
- data/lib/tessella/cli/wave.rb +148 -0
- data/lib/tessella/cli.rb +36 -0
- data/lib/tessella/color.rb +106 -0
- data/lib/tessella/compose.rb +243 -0
- data/lib/tessella/compose_fill.rb +176 -0
- data/lib/tessella/compose_grid.rb +36 -0
- data/lib/tessella/compose_shadow.rb +41 -0
- data/lib/tessella/error.rb +13 -0
- data/lib/tessella/frame.rb +170 -0
- data/lib/tessella/glyphs.rb +106 -0
- data/lib/tessella/image.rb +454 -0
- data/lib/tessella/image_octant.rb +46 -0
- data/lib/tessella/image_petscii.rb +116 -0
- data/lib/tessella/kinds/bar.rb +344 -0
- data/lib/tessella/kinds/box.rb +213 -0
- data/lib/tessella/kinds/box_upright.rb +110 -0
- data/lib/tessella/kinds/candlestick.rb +164 -0
- data/lib/tessella/kinds/columns.rb +259 -0
- data/lib/tessella/kinds/date_axis.rb +102 -0
- data/lib/tessella/kinds/error_bars.rb +71 -0
- data/lib/tessella/kinds/heatmap.rb +130 -0
- data/lib/tessella/kinds/line.rb +173 -0
- data/lib/tessella/kinds/multibar.rb +98 -0
- data/lib/tessella/kinds/multibar_columns.rb +130 -0
- data/lib/tessella/kinds/multibar_fine.rb +45 -0
- data/lib/tessella/kinds/multibar_rows.rb +93 -0
- data/lib/tessella/kinds/pie.rb +123 -0
- data/lib/tessella/kinds/pie_rim.rb +78 -0
- data/lib/tessella/kinds/sideways.rb +62 -0
- data/lib/tessella/kinds/stem.rb +134 -0
- data/lib/tessella/kinds/threshold.rb +73 -0
- data/lib/tessella/legend.rb +31 -0
- data/lib/tessella/lint/alphabet.rb +56 -0
- data/lib/tessella/lint/drift.rb +82 -0
- data/lib/tessella/lint/fences.rb +51 -0
- data/lib/tessella/lint/geometry.rb +120 -0
- data/lib/tessella/lint/geometry_checks.rb +224 -0
- data/lib/tessella/lint/lines.rb +115 -0
- data/lib/tessella/lint/mixing.rb +61 -0
- data/lib/tessella/lint/runner.rb +73 -0
- data/lib/tessella/lint/shadows.rb +120 -0
- data/lib/tessella/lint/target_rows.rb +66 -0
- data/lib/tessella/lint.rb +158 -0
- data/lib/tessella/meter.rb +133 -0
- data/lib/tessella/meter_upright.rb +43 -0
- data/lib/tessella/png.rb +456 -0
- data/lib/tessella/raster.rb +92 -0
- data/lib/tessella/rmagick_reader.rb +52 -0
- data/lib/tessella/rule.rb +59 -0
- data/lib/tessella/seq.rb +522 -0
- data/lib/tessella/sparkline.rb +57 -0
- data/lib/tessella/stats.rb +199 -0
- data/lib/tessella/table.rb +547 -0
- data/lib/tessella/table_cells.rb +225 -0
- data/lib/tessella/target.rb +105 -0
- data/lib/tessella/text.rb +76 -0
- data/lib/tessella/text_forms.rb +150 -0
- data/lib/tessella/tree.rb +221 -0
- data/lib/tessella/version.rb +5 -0
- data/lib/tessella/wave.rb +353 -0
- data/lib/tessella/width.rb +122 -0
- data/lib/tessella/width_class.rb +77 -0
- data/lib/tessella/width_invisible.rb +66 -0
- data/lib/tessella/width_invisible_high.rb +60 -0
- data/lib/tessella.rb +94 -0
- metadata +226 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Building one bar's row of cells: the fill run, the optional
|
|
5
|
+
# eighths partial cell, the track run, the value label, padding.
|
|
6
|
+
# Kept apart from Bar so Bar's own class body stays under budget.
|
|
7
|
+
module BarCells
|
|
8
|
+
EIGHTHS = " ▏▎▍▌▋▊▉".chars.freeze
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cell(char, color = nil) = Cell.new(char: char, color: color)
|
|
13
|
+
|
|
14
|
+
def bar_fraction(value, max_val)
|
|
15
|
+
return 0.0 unless max_val.positive?
|
|
16
|
+
|
|
17
|
+
[@xscale.call(value), 0].max.fdiv(max_val)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def bar_glyphs(fraction, max_bar_width)
|
|
21
|
+
return [(fraction * max_bar_width).round, nil] unless @eighths
|
|
22
|
+
|
|
23
|
+
units = (fraction * max_bar_width * 8).round
|
|
24
|
+
full, rem = units.divmod(8)
|
|
25
|
+
[full, rem.zero? ? nil : EIGHTHS[rem]]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def bar_cells(full, partial, color)
|
|
29
|
+
cells = Array.new(full) { cell(@fill, color) }
|
|
30
|
+
cells << cell(partial, color) if partial
|
|
31
|
+
cells
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def middle_cells(full, partial, max_bar_width)
|
|
35
|
+
return [] unless @track
|
|
36
|
+
|
|
37
|
+
used = full + (partial ? 1 : 0)
|
|
38
|
+
remain = max_bar_width - used
|
|
39
|
+
remain.positive? ? Array.new(remain) { cell(@track) } : []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def label_cells(value)
|
|
43
|
+
Axis.label(value).each_char.map { |ch| cell(ch) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pad_cells(cells, ew)
|
|
47
|
+
pad = [ew - cells.length, 0].max
|
|
48
|
+
cells + Array.new(pad) { Cell.blank }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Bars over values of both signs: the bar area splits at a zero
|
|
53
|
+
# column into a negative zone and a positive one, each as wide as
|
|
54
|
+
# its share of the data's span; negative bars grow left from zero,
|
|
55
|
+
# positive ones right, and the value labels follow the whole area,
|
|
56
|
+
# so zero is one column on every row. Without a negative value Bar
|
|
57
|
+
# keeps unicode_plot's layout. A negative bar's fractional end, at
|
|
58
|
+
# its left, is a right half block (Unicode has no right eighths).
|
|
59
|
+
module SignedBars
|
|
60
|
+
RIGHT_HALF = "▐"
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def signed? = @bars.any? { |_, value, _| @xscale.call(value).negative? }
|
|
65
|
+
|
|
66
|
+
def signed_rows
|
|
67
|
+
values = @bars.map { |_, value, _| @xscale.call(value) }
|
|
68
|
+
max_len = signed_label_width
|
|
69
|
+
ew = effective_width(max_len)
|
|
70
|
+
zones = signed_zones(values, [ew - 2 - max_len, 1].max)
|
|
71
|
+
@bars.each_with_index.map do |(_, value, color), i|
|
|
72
|
+
signed_row(values[i], value, color, zones, ew)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# scaled places the bar, value (as given) labels it.
|
|
77
|
+
def signed_row(scaled, value, color, zones, ew)
|
|
78
|
+
cells = signed_cells(scaled, color, zones)
|
|
79
|
+
pad_cells(cells + [Cell.blank] + label_cells(value), ew)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Every label, not only the largest value's: "-12.5" can be the
|
|
83
|
+
# widest, and each row must end at the same column.
|
|
84
|
+
def signed_label_width
|
|
85
|
+
@bars.map { |_, value, _| Axis.label(value).length }.max
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def signed_zones(values, area)
|
|
89
|
+
neg = -values.min
|
|
90
|
+
pos = [values.max, 0].max
|
|
91
|
+
neg_w = (area * neg.fdiv(neg + pos)).round
|
|
92
|
+
[neg_w, area - neg_w, neg, pos]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def signed_cells(value, color, zones)
|
|
96
|
+
neg_w, pos_w, neg, pos = zones
|
|
97
|
+
return left_bar(-value.fdiv(neg), neg_w, color) + gap(pos_w) if
|
|
98
|
+
value.negative?
|
|
99
|
+
|
|
100
|
+
fraction = pos.positive? ? value.fdiv(pos) : 0.0
|
|
101
|
+
gap(neg_w) + right_bar(fraction, pos_w, color)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def left_bar(fraction, width, color)
|
|
105
|
+
full, half = left_glyphs(fraction, width)
|
|
106
|
+
gap(width - full - half) +
|
|
107
|
+
Array.new(half) { cell(RIGHT_HALF, color) } +
|
|
108
|
+
Array.new(full) { cell(@fill, color) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def left_glyphs(fraction, width)
|
|
112
|
+
return [(fraction * width).round, 0] unless @eighths
|
|
113
|
+
|
|
114
|
+
(fraction * width * 2).round.divmod(2)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def right_bar(fraction, width, color)
|
|
118
|
+
cells = bar_cells(*bar_glyphs(fraction, width), color)
|
|
119
|
+
cells + gap(width - cells.size)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def gap(count)
|
|
123
|
+
Array.new([count, 0].max) { @track ? cell(@track) : Cell.blank }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Horizontal bars for (label, value) pairs (DESIGN.md, "Chart and
|
|
128
|
+
# kinds"): rows of cells laid out directly, one per bar, not on a
|
|
129
|
+
# pixel canvas, handed to Frame with border :bar -- as
|
|
130
|
+
# unicode_plot's barplot frame. Chart lends option checking and
|
|
131
|
+
# ascii/colour handling; its canvas and draw_series go unused.
|
|
132
|
+
class Bar < Chart
|
|
133
|
+
include BarCells
|
|
134
|
+
include SignedBars
|
|
135
|
+
include BarLayouts
|
|
136
|
+
include ColumnBars
|
|
137
|
+
include DotRowBars
|
|
138
|
+
include MultiBars
|
|
139
|
+
include MultiBarRows
|
|
140
|
+
include MultiBarColumns
|
|
141
|
+
|
|
142
|
+
OPTIONS = (Chart::OPTIONS +
|
|
143
|
+
%i[fill eighths track orientation blocks stacked
|
|
144
|
+
series]).freeze
|
|
145
|
+
MIN_WIDTH = 10
|
|
146
|
+
DEFAULT_FILL = "█"
|
|
147
|
+
ASCII_FILL = "#"
|
|
148
|
+
DEFAULT_TRACK = "·"
|
|
149
|
+
ASCII_TRACK = "."
|
|
150
|
+
|
|
151
|
+
def initialize(**opts)
|
|
152
|
+
super
|
|
153
|
+
@fill = fill_option(opts)
|
|
154
|
+
@eighths = @ascii ? false : opts.fetch(:eighths, true)
|
|
155
|
+
@track = track_option(opts)
|
|
156
|
+
check_layout!(opts)
|
|
157
|
+
@bars = []
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# values: a number per label, or an Array per label, one value a
|
|
161
|
+
# series (MultiBars).
|
|
162
|
+
def add(labels, values, color: nil)
|
|
163
|
+
labels = Array(labels)
|
|
164
|
+
return add_multi(labels, Array(values)) if
|
|
165
|
+
Array(values).first.is_a?(Array)
|
|
166
|
+
|
|
167
|
+
bar_color = color || auto_color
|
|
168
|
+
values = Check.finite!(Array(values), "value")
|
|
169
|
+
Check.same_size!(labels, values, %w[labels values])
|
|
170
|
+
labels.zip(values).each do |label, value|
|
|
171
|
+
@bars << [label, value, bar_color]
|
|
172
|
+
end
|
|
173
|
+
self
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def default_border = :bar
|
|
179
|
+
def default_xlabel_basename = ""
|
|
180
|
+
|
|
181
|
+
def fill_option(opts)
|
|
182
|
+
fill = opts[:fill] || (@ascii ? ASCII_FILL : DEFAULT_FILL)
|
|
183
|
+
if fill.length != 1
|
|
184
|
+
raise ArgumentError,
|
|
185
|
+
"fill must be one character: #{fill.inspect}"
|
|
186
|
+
end
|
|
187
|
+
fill
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def track_option(opts)
|
|
191
|
+
return opts[:track] if opts.key?(:track)
|
|
192
|
+
|
|
193
|
+
@ascii ? ASCII_TRACK : DEFAULT_TRACK
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def frame
|
|
197
|
+
return Frame.new(**multi_frame_options) if multi?
|
|
198
|
+
|
|
199
|
+
Frame.new(**(classic? ? frame_options : layout_frame_options))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def frame_options
|
|
203
|
+
{ body: rows, border: @border, ascii: @ascii, color: @color_on,
|
|
204
|
+
title: @opts[:title], xlabel: xlabel, ylabel: @opts[:ylabel],
|
|
205
|
+
left: left_labels }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def xlabel
|
|
209
|
+
@opts[:xlabel] || auto_xlabel
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def auto_xlabel
|
|
213
|
+
xscale = @opts[:xscale]
|
|
214
|
+
base = default_xlabel_basename
|
|
215
|
+
return base.empty? ? nil : base unless xscale
|
|
216
|
+
|
|
217
|
+
"#{base} [#{xscale}]"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def left_labels
|
|
221
|
+
@bars.each_with_index.to_h { |(label, *), i| [i, label.to_s] }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def reference
|
|
225
|
+
return [0.0, 0] if @bars.empty?
|
|
226
|
+
|
|
227
|
+
transformed = @bars.map { |_, value, _| @xscale.call(value) }
|
|
228
|
+
max_val = transformed.max
|
|
229
|
+
idx = transformed.index(max_val)
|
|
230
|
+
[max_val, Axis.label(@bars[idx][1]).length]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def effective_width(max_len)
|
|
234
|
+
[width, max_len + 7, MIN_WIDTH].max
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def rows
|
|
238
|
+
return signed_rows if signed?
|
|
239
|
+
|
|
240
|
+
max_val, max_len = reference
|
|
241
|
+
ew = effective_width(max_len)
|
|
242
|
+
@bars.map do |_, value, color|
|
|
243
|
+
bar_row(value, color, max_val, max_len, ew)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def bar_row(value, color, max_val, max_len, ew)
|
|
248
|
+
max_bar_width = [ew - 2 - max_len, 1].max
|
|
249
|
+
fraction = bar_fraction(value, max_val)
|
|
250
|
+
full, partial = bar_glyphs(fraction, max_bar_width)
|
|
251
|
+
cells = bar_cells(full, partial, color)
|
|
252
|
+
cells += middle_cells(full, partial, max_bar_width)
|
|
253
|
+
cells << Cell.blank
|
|
254
|
+
cells += label_cells(value)
|
|
255
|
+
pad_cells(cells, ew)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Frequency bars over Stats.histogram's bins (DESIGN.md): the same
|
|
260
|
+
# engine as Bar, fed bin labels and counts instead of given ones.
|
|
261
|
+
class Histogram < Bar
|
|
262
|
+
def add(values, bins: nil, closed: :left, color: nil)
|
|
263
|
+
values = Check.finite!(Array(values), "value")
|
|
264
|
+
hist = Stats.histogram(values, bins: bins, closed: closed)
|
|
265
|
+
@pairs = edge_pairs(hist).first(hist.counts.size)
|
|
266
|
+
super(bin_labels(hist), hist.counts, color: color)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def default_xlabel_basename = "Frequency"
|
|
272
|
+
def column_value_label = "Frequency"
|
|
273
|
+
|
|
274
|
+
# Columns sit on a continuous axis: its two ends, not names.
|
|
275
|
+
def column_names(_slot)
|
|
276
|
+
{ bottom: [Axis.label(@pairs.first[0]),
|
|
277
|
+
Axis.label(@pairs.last[1])] }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def group_label(first, last)
|
|
281
|
+
"#{Axis.label(@pairs[first][0])}..#{Axis.label(@pairs[last][1])}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def bin_labels(hist)
|
|
285
|
+
pairs = edge_pairs(hist)
|
|
286
|
+
pad = label_padding(pairs.flatten)
|
|
287
|
+
lb, rb = hist.closed == :right ? ["(", "]"] : ["[", ")"]
|
|
288
|
+
pairs.first(hist.counts.size).map do |lo, hi|
|
|
289
|
+
"#{lb}#{pad_num(lo, pad)}, #{pad_num(hi, pad)}#{rb}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def edge_pairs(hist)
|
|
294
|
+
edges = hist.edges
|
|
295
|
+
step = edges.size > 1 ? edges[1] - edges[0] : 1.0
|
|
296
|
+
edges.map { |e| bin_pair(e, step) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def bin_pair(edge, step)
|
|
300
|
+
lo = round_edge(edge, step)
|
|
301
|
+
[lo, round_edge(lo + step, step)]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def round_edge(value, step)
|
|
305
|
+
digits = round_digits(step)
|
|
306
|
+
if value.negative?
|
|
307
|
+
(-(-value).round(digits)).to_f
|
|
308
|
+
else
|
|
309
|
+
value.round(digits).to_f
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def round_digits(step)
|
|
314
|
+
return 1 if step.zero?
|
|
315
|
+
|
|
316
|
+
v = -Math.log10(step)
|
|
317
|
+
(v == v.to_i ? v.ceil : v.floor) + 1
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def label_padding(values)
|
|
321
|
+
parts = values.map { |v| v.to_s.split(".", 2) }
|
|
322
|
+
[parts.map { |a| a[0].length }.max,
|
|
323
|
+
parts.map { |a| (a[1] || "").length }.max]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def pad_num(value, pad)
|
|
327
|
+
left, right = pad
|
|
328
|
+
whole, frac = value.to_s.split(".", 2)
|
|
329
|
+
(" " * (left - whole.length)) + value.to_s +
|
|
330
|
+
(" " * (right - frac.length))
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# (labels, values, **opts): a new Bar chart with one set of bars.
|
|
335
|
+
def self.bar(*, **)
|
|
336
|
+
Bar.new(**).add(*)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# (values, bins: nil, closed: :left, **opts): a histogram over
|
|
340
|
+
# Stats.histogram's bins.
|
|
341
|
+
def self.histogram(values, bins: nil, closed: :left, **)
|
|
342
|
+
Histogram.new(**).add(values, bins: bins, closed: closed)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# The five-glyph shapes for one box-and-whisker row, unicode and
|
|
5
|
+
# ascii, by its position within the three rows a box takes
|
|
6
|
+
# (DESIGN.md's box, as unicode_plot's boxplot draws it: a corner
|
|
7
|
+
# border with blank sides, found only here -- Frame has no such
|
|
8
|
+
# border type, so Box builds it into the body cells instead of
|
|
9
|
+
# asking Frame for one).
|
|
10
|
+
module BoxShapes
|
|
11
|
+
UNICODE_SHAPES = [
|
|
12
|
+
{ min: "╷", line: " ", fill: "─", lbox: "┌",
|
|
13
|
+
median: "┬", rbox: "┐", max: "╷" },
|
|
14
|
+
{ min: "├", line: "─", fill: " ", lbox: "┤",
|
|
15
|
+
median: "│", rbox: "├", max: "┤" },
|
|
16
|
+
{ min: "╵", line: " ", fill: "─", lbox: "└",
|
|
17
|
+
median: "┴", rbox: "┘", max: "╵" }
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# One glyph, one ASCII byte: a whisker meeting the box (├ ┤)
|
|
21
|
+
# and a whisker end cap (╷ ╵) or a vertical box side (│) all
|
|
22
|
+
# become "|" rather than colliding into each other's corner or
|
|
23
|
+
# line glyph; a corner or median end (┌ ┐ └ ┘ ┬ ┴) becomes "+";
|
|
24
|
+
# a horizontal line (─) becomes "-".
|
|
25
|
+
GLYPH_MAP = {
|
|
26
|
+
"┌" => "+", "┐" => "+", "└" => "+", "┘" => "+",
|
|
27
|
+
"┬" => "+", "┴" => "+",
|
|
28
|
+
"├" => "|", "┤" => "|",
|
|
29
|
+
"─" => "-", "│" => "|",
|
|
30
|
+
"╷" => "|", "╵" => "|"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
ASCII_SHAPES = UNICODE_SHAPES.map do |shape|
|
|
34
|
+
shape.transform_values { |ch| GLYPH_MAP.fetch(ch, ch) }
|
|
35
|
+
end.freeze
|
|
36
|
+
|
|
37
|
+
UNICODE_CORNERS = ["┌", "┐", "└", "┘"].freeze
|
|
38
|
+
ASCII_CORNERS = UNICODE_CORNERS.map { |ch| GLYPH_MAP.fetch(ch, ch) }
|
|
39
|
+
.freeze
|
|
40
|
+
|
|
41
|
+
CORNERS = { unicode: UNICODE_CORNERS, ascii: ASCII_CORNERS }.freeze
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def cell(char, color = nil) = Cell.new(char: char, color: color)
|
|
46
|
+
|
|
47
|
+
def corners = CORNERS[@ascii ? :ascii : :unicode]
|
|
48
|
+
|
|
49
|
+
def shapes = @ascii ? ASCII_SHAPES : UNICODE_SHAPES
|
|
50
|
+
|
|
51
|
+
def border_row(top, ew)
|
|
52
|
+
tl, tr, bl, br = corners
|
|
53
|
+
left, right = top ? [tl, tr] : [bl, br]
|
|
54
|
+
[cell(left)] + Array.new(ew) { cell(" ") } + [cell(right)]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def shape_row(cols, row, ew, color)
|
|
58
|
+
chars = Array.new(ew, " ")
|
|
59
|
+
paint_shape!(chars, cols, shapes[row])
|
|
60
|
+
[cell(" ")] + chars.map { |ch| cell(ch, color) } + [cell(" ")]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def paint_shape!(chars, cols, shape)
|
|
64
|
+
spans(cols, shape).each do |lo, hi, ch|
|
|
65
|
+
fill_span!(chars, lo, hi, ch)
|
|
66
|
+
end
|
|
67
|
+
place_marks!(chars, cols, shape)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def spans(cols, shape)
|
|
71
|
+
[[cols[0], cols[1] - 1, shape[:line]],
|
|
72
|
+
[cols[1], cols[2] - 1, shape[:fill]],
|
|
73
|
+
[cols[2], cols[3] - 1, shape[:fill]],
|
|
74
|
+
[cols[3], cols[4] - 1, shape[:line]]]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fill_span!(chars, lo, hi, ch)
|
|
78
|
+
(lo...hi).each { |i| chars[i] = ch }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def place_marks!(chars, cols, shape)
|
|
82
|
+
marks(shape).each_with_index { |ch, i| chars[cols[i] - 1] = ch }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def marks(shape)
|
|
86
|
+
[shape[:min], shape[:lbox], shape[:median], shape[:rbox],
|
|
87
|
+
shape[:max]]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One or several box-and-whisker plots over samples (DESIGN.md,
|
|
92
|
+
# "Chart and kinds"): three rows of cells per box, laid out
|
|
93
|
+
# directly -- no pixel canvas, no Frame border (see BoxShapes) --
|
|
94
|
+
# from Stats.percentiles. Chart lends option checking and
|
|
95
|
+
# ascii/colour handling; its canvas and draw_series go unused.
|
|
96
|
+
class Box < Chart
|
|
97
|
+
include BoxShapes
|
|
98
|
+
|
|
99
|
+
OPTIONS = (Chart::OPTIONS + %i[orientation]).freeze
|
|
100
|
+
MIN_WIDTH = 10
|
|
101
|
+
|
|
102
|
+
def initialize(**opts)
|
|
103
|
+
super
|
|
104
|
+
@boxes = []
|
|
105
|
+
@orientation = opts.fetch(:orientation, :horizontal)
|
|
106
|
+
return if BarLayouts::ORIENTATIONS.include?(@orientation)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "unknown orientation: #{@orientation.inspect}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add(names, samples, color: nil)
|
|
112
|
+
box_color = color || auto_color
|
|
113
|
+
names = Array(names)
|
|
114
|
+
Check.same_size!(names, Array(samples), %w[names samples])
|
|
115
|
+
names.zip(samples).each do |name, sample|
|
|
116
|
+
values = Array(sample).map(&@xscale)
|
|
117
|
+
raise ValueError, "sample #{name} is empty" if values.empty?
|
|
118
|
+
|
|
119
|
+
@boxes << [name.to_s, Check.finite!(values, "sample"),
|
|
120
|
+
box_color]
|
|
121
|
+
end
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def frame
|
|
128
|
+
Frame.new(**(upright? ? upright_options : frame_options))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def frame_options
|
|
132
|
+
{ body: rows, border: :none, ascii: @ascii, color: @color_on,
|
|
133
|
+
title: @opts[:title], xlabel: @opts[:xlabel],
|
|
134
|
+
ylabel: @opts[:ylabel], left: left_labels }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def left_labels
|
|
138
|
+
@boxes.each_with_index.to_h { |(name, *), i| [(i * 3) + 2, name] }
|
|
139
|
+
.reject { |_, v| v.to_s.empty? }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def effective_width = [width, MIN_WIDTH].max
|
|
143
|
+
|
|
144
|
+
def box_limits
|
|
145
|
+
values = @boxes.flat_map { |_, sample, _| sample }
|
|
146
|
+
Axis.limits(values, @opts[:xlim])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rows
|
|
150
|
+
lo, hi = box_limits
|
|
151
|
+
ew = effective_width
|
|
152
|
+
[border_row(true, ew), *box_rows(lo, hi, ew),
|
|
153
|
+
border_row(false, ew), bottom_row(lo, hi, ew)]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def box_rows(lo, hi, ew)
|
|
157
|
+
@boxes.flat_map do |_, sample, color|
|
|
158
|
+
box_shape(sample, lo, hi, ew, color)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def box_shape(sample, lo, hi, ew, color)
|
|
163
|
+
cols = columns(Stats.percentiles(sample, [0, 25, 50, 75, 100]),
|
|
164
|
+
lo, hi, ew)
|
|
165
|
+
(0..2).map { |row| shape_row(cols, row, ew, color) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def columns(values, lo, hi, ew)
|
|
169
|
+
values.map { |v| scale_column(v, lo, hi, ew) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def scale_column(v, lo, hi, ew)
|
|
173
|
+
pos = (v - lo).fdiv(hi - lo) * ew
|
|
174
|
+
pos.round(half: :even).clamp(1, ew).to_i
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def bottom_row(lo, hi, ew)
|
|
178
|
+
min_s = Axis.tick(lo)
|
|
179
|
+
max_s = Axis.tick(hi)
|
|
180
|
+
mid_s = Axis.tick((lo + hi) / 2.0)
|
|
181
|
+
bottom_text(min_s, mid_s, max_s, ew).each_char.map { |c| cell(c) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def bottom_text(min_s, mid_s, max_s, ew)
|
|
185
|
+
cnt1 = left_gap(ew, mid_s, min_s)
|
|
186
|
+
cnt2 = right_gap(ew, min_s, mid_s, max_s, cnt1)
|
|
187
|
+
text = min_s + pad(cnt1) + mid_s + pad(cnt2) + max_s
|
|
188
|
+
pad_to(text, ew + 2)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def left_gap(ew, mid_s, min_s)
|
|
192
|
+
((ew / 2.0) - (mid_s.length / 2.0) - min_s.length).round
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def right_gap(ew, min_s, mid_s, max_s, cnt1)
|
|
196
|
+
ew - max_s.length - min_s.length - mid_s.length + 2 - cnt1
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def pad(count) = count.positive? ? " " * count : ""
|
|
200
|
+
|
|
201
|
+
def pad_to(text, len)
|
|
202
|
+
return text[0, len] if text.length >= len
|
|
203
|
+
|
|
204
|
+
text + (" " * (len - text.length))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# (names, samples, **opts): a new Box chart; samples is an Array
|
|
209
|
+
# of Arrays, one per name.
|
|
210
|
+
def self.box(*, **)
|
|
211
|
+
Box.new(**).add(*)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# orientation: :vertical for Box: each box three columns wide, the
|
|
5
|
+
# whiskers running up and down, the value axis on the left (top,
|
|
6
|
+
# middle and bottom ticks), the names under the boxes. Rows from
|
|
7
|
+
# the top: a cap at the maximum, the whisker, the box from Q3 to
|
|
8
|
+
# Q1 with the median across it, the whisker, a cap at the minimum.
|
|
9
|
+
# When two of them land on one row, the later in that order wins,
|
|
10
|
+
# so the median always shows. Kept apart from Box so Box's own
|
|
11
|
+
# class body stays under budget.
|
|
12
|
+
module UprightBoxes
|
|
13
|
+
UPRIGHT = {
|
|
14
|
+
cap_top: "╶┬╴", cap_bottom: "╶┴╴", whisker: " │ ",
|
|
15
|
+
side: "│ │", q3: "┌┴┐", q1: "└┬┘", median: "├─┤"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
UPRIGHT_ASCII = {
|
|
19
|
+
cap_top: "-+-", cap_bottom: "-+-", whisker: " | ",
|
|
20
|
+
side: "| |", q3: "+-+", q1: "+-+", median: "|-|"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def upright? = @orientation == :vertical
|
|
26
|
+
|
|
27
|
+
def upright_options
|
|
28
|
+
lo, hi = upright_limits
|
|
29
|
+
slot = upright_slot
|
|
30
|
+
{ body: upright_body(lo, hi, slot), border: :box, ascii: @ascii,
|
|
31
|
+
color: @color_on, title: @opts[:title], xlabel: @opts[:xlabel],
|
|
32
|
+
ylabel: @opts[:ylabel], left: upright_ticks(lo, hi),
|
|
33
|
+
under: upright_names(slot) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def upright_limits
|
|
37
|
+
values = @boxes.flat_map { |_, sample, _| sample }
|
|
38
|
+
Axis.limits(values, @opts[:ylim])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def upright_height = @opts.fetch(:height, Chart::DEFAULT_HEIGHT)
|
|
42
|
+
|
|
43
|
+
# Three columns and a gap, wider when a name needs it.
|
|
44
|
+
def upright_slot
|
|
45
|
+
names = @boxes.map { |name, *| Width.of(name) }
|
|
46
|
+
[names.max.to_i + 1, 4].max
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def upright_body(lo, hi, slot)
|
|
50
|
+
rows = Array.new(upright_height) { [] }
|
|
51
|
+
@boxes.each do |_, sample, color|
|
|
52
|
+
glyphs = upright_column(sample, lo, hi)
|
|
53
|
+
pad = (slot - 3) / 2
|
|
54
|
+
rows.each_with_index do |row, r|
|
|
55
|
+
row.concat(upright_cells(glyphs[r], pad, slot, color))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rows
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def upright_cells(glyphs, pad, slot, color)
|
|
62
|
+
text = (" " * pad) + glyphs + (" " * (slot - pad - 3))
|
|
63
|
+
text.chars.map do |ch|
|
|
64
|
+
ch == " " ? Cell.blank : Cell.new(char: ch, color: color)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One three-character string per row, top first.
|
|
69
|
+
def upright_column(sample, lo, hi)
|
|
70
|
+
at = Stats.percentiles(sample, [100, 75, 50, 25, 0])
|
|
71
|
+
.map { upright_row(it, lo, hi) }
|
|
72
|
+
column = Array.new(upright_height, " ")
|
|
73
|
+
upright_marks(at).each do |(from, to), part|
|
|
74
|
+
(from..to).each { column[it] = upright_glyphs[part] }
|
|
75
|
+
end
|
|
76
|
+
column
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Row spans in paint order: whiskers, caps, box sides, the box
|
|
80
|
+
# edges, the median last.
|
|
81
|
+
def upright_marks(at)
|
|
82
|
+
max, q3, med, q1, min = at
|
|
83
|
+
[[[max, q3], :whisker], [[q1, min], :whisker],
|
|
84
|
+
[[max, max], :cap_top], [[min, min], :cap_bottom],
|
|
85
|
+
[[q3, q1], :side], [[q3, q3], :q3], [[q1, q1], :q1],
|
|
86
|
+
[[med, med], :median]]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def upright_glyphs = @ascii ? UPRIGHT_ASCII : UPRIGHT
|
|
90
|
+
|
|
91
|
+
def upright_row(value, lo, hi)
|
|
92
|
+
share = (hi - value).fdiv(hi - lo)
|
|
93
|
+
(share * (upright_height - 1)).round.clamp(0, upright_height - 1)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def upright_ticks(lo, hi)
|
|
97
|
+
last = upright_height - 1
|
|
98
|
+
{ 0 => Axis.tick(hi), (last / 2) => Axis.tick((lo + hi) / 2.0),
|
|
99
|
+
last => Axis.tick(lo) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def upright_names(slot)
|
|
103
|
+
@boxes.map { |name, *| Width.center(name, slot) }.join.rstrip
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Box
|
|
108
|
+
include UprightBoxes
|
|
109
|
+
end
|
|
110
|
+
end
|