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.
Files changed (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. 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