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,615 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # The command line (DESIGN.md, "Command line"): parses argv, reads
5
+ # a file or stdin, calls one Tessella.<kind>, and prints. Split
6
+ # into the small classes and modules below (each kept under the
7
+ # ruby skill's size budget); every reopening of CLI adds one.
8
+ class ChartCLI
9
+ # The family of refusals CLI.run turns into an exit status
10
+ # rather than letting bubble past it. UsageError (exit 2) is a
11
+ # bad invocation; DataError (exit 1) is input that cannot
12
+ # become a chart.
13
+ class Error < StandardError; end
14
+ class UsageError < Error; end
15
+ class DataError < Error; end
16
+
17
+ # Raised by --help and --version to short-circuit parsing with
18
+ # a message and a status already decided.
19
+ class Halt < StandardError
20
+ attr_reader :status, :text
21
+
22
+ def initialize(status, text)
23
+ @status = status
24
+ @text = text
25
+ super(text)
26
+ end
27
+ end
28
+
29
+ BANNER = "Usage: tessella chart [options] [file]"
30
+
31
+ # argv is read, never mutated; the kind's one Tessella.<kind>
32
+ # call draws the chart, printed to stdout. Returns the exit
33
+ # status: 0 drawn, 1 bad input, 2 usage.
34
+ def self.run(argv, stdin:, stdout:, stderr:)
35
+ draw(argv, stdin, stdout)
36
+ rescue Halt => e
37
+ CLI.halt(e, stdout, stderr)
38
+ rescue DataError, Tessella::ValueError => e
39
+ data_error(e, stderr)
40
+ rescue OptionParser::ParseError, UsageError, ArgumentError => e
41
+ usage_error(e, stderr)
42
+ end
43
+
44
+ def self.draw(argv, stdin, stdout)
45
+ opts, path = Options.parse(argv.dup)
46
+ KindOptions.check!(opts)
47
+ rows = Reader.rows(path, stdin)
48
+ Reader.check_ascii!(rows) if opts[:ascii]
49
+ chart = Kinds.build(opts, rows)
50
+ stdout.print(chart.to_s)
51
+ 0
52
+ end
53
+
54
+ def self.usage_error(error, stderr)
55
+ stderr.puts("tessella chart: #{error.message}")
56
+ stderr.puts(BANNER)
57
+ 2
58
+ end
59
+
60
+ def self.data_error(error, stderr)
61
+ stderr.puts("tessella chart: #{error.message}")
62
+ 1
63
+ end
64
+ end
65
+
66
+ # Integer-or-Float parsing that keeps a number's own kind
67
+ # (DESIGN.md: "A number keeps its kind"), shared by option values
68
+ # and data fields. nil when neither parses.
69
+ class ChartCLI
70
+ module Num
71
+ def self.parse(raw)
72
+ Integer(raw, 10, exception: false) ||
73
+ Float(raw, exception: false)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Flags particular to some kinds (the axes' scales and dates, the
79
+ # histogram's bins, the heatmap's cells), kept apart so Options
80
+ # stays under budget.
81
+ class ChartCLI
82
+ module KindFlags
83
+ private
84
+
85
+ # --xdate's optional FMT took the word after it: a word with
86
+ # no % in it is no strftime format but the file, given back.
87
+ def unformat!(argv)
88
+ format = @opts[:xdate]
89
+ return unless format.is_a?(String) && !format.include?("%")
90
+
91
+ argv.unshift(format)
92
+ @opts[:xdate] = true
93
+ end
94
+
95
+ def add_axes
96
+ @parser.on("--yscale C") { |v| @opts[:yscale] = v.to_sym }
97
+ @parser.on("--xdate [FMT]") { |v| @opts[:xdate] = v || true }
98
+ @parser.on("--yerr") { @opts[:yerr] = true }
99
+ add_threshold
100
+ end
101
+
102
+ def add_threshold
103
+ @parser.on("--threshold N") { @opts[:threshold] = num(it) }
104
+ @parser.on("--above C") { @opts[:above] = Color.parse(it) }
105
+ @parser.on("--below C") { @opts[:below] = Color.parse(it) }
106
+ end
107
+
108
+ def add_histogram_specific
109
+ @parser.on("--bins N", Integer) { |v| @opts[:bins] = v }
110
+ @parser.on("--closed C") { |v| @opts[:closed] = v.to_sym }
111
+ end
112
+
113
+ def add_layout
114
+ @parser.on("--orientation O", %w[horizontal vertical]) do |v|
115
+ @opts[:orientation] = v.to_sym
116
+ end
117
+ blocks = %w[eighths braille block quadrant half sextant octant]
118
+ @parser.on("--blocks B", blocks) do |v|
119
+ @opts[:blocks] = v.to_sym
120
+ end
121
+ end
122
+
123
+ def add_heatmap
124
+ @parser.on("--cell-width N", Integer) do |n|
125
+ @opts[:cell_width] = n
126
+ end
127
+ @parser.on("--colormap C,C", Array) do |v|
128
+ @opts[:colormap] = v.map { Color.parse(it) }
129
+ end
130
+ @parser.on("--min A") { @opts[:min] = num(it) }
131
+ @parser.on("--max B") { @opts[:max] = num(it) }
132
+ add_pie
133
+ end
134
+
135
+ def add_pie
136
+ @parser.on("--donut") { @opts[:donut] = true }
137
+ @parser.on("--rim R", %w[cells smooth basic]) do |v|
138
+ @opts[:rim] = v.to_sym
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ # Parses argv into the options CLI.run acts on: :kind plus every
145
+ # key ChartOpts and KindOptions know about. Raises Halt for
146
+ # --help/--version, or lets OptionParser::ParseError escape for a
147
+ # bad flag, missing argument, or disallowed value.
148
+ class ChartCLI
149
+ class Options
150
+ include KindFlags
151
+
152
+ KINDS = %w[line scatter stairs density bar histogram box
153
+ stem heatmap candlestick pie].freeze
154
+
155
+ def self.parse(argv)
156
+ new.parse(argv)
157
+ end
158
+
159
+ def initialize
160
+ @opts = { kind: :line }
161
+ @parser = OptionParser.new
162
+ @parser.banner = Help::BANNER
163
+ build
164
+ Help.describe!(@parser)
165
+ end
166
+
167
+ def parse(argv)
168
+ @parser.parse!(argv)
169
+ unformat!(argv)
170
+ raise ChartCLI::UsageError, "one file argument at most" if
171
+ argv.size > 1
172
+
173
+ [@opts, argv.first]
174
+ end
175
+
176
+ private
177
+
178
+ def build
179
+ add_kind_and_dims
180
+ add_labels
181
+ add_ranges
182
+ add_render
183
+ add_kind_specific
184
+ add_bar_specific
185
+ add_meta
186
+ end
187
+
188
+ def add_kind_and_dims
189
+ @parser.on("--kind KIND", KINDS) { |v| @opts[:kind] = v.to_sym }
190
+ @parser.on("--width N", Integer) { |v| @opts[:width] = v }
191
+ @parser.on("--height N", Integer) { |v| @opts[:height] = v }
192
+ end
193
+
194
+ def add_labels
195
+ @parser.on("--title S") { |v| @opts[:title] = v }
196
+ @parser.on("--xlabel S") { |v| @opts[:xlabel] = v }
197
+ @parser.on("--ylabel S") { |v| @opts[:ylabel] = v }
198
+ end
199
+
200
+ def add_ranges
201
+ @parser.on("--xlim A,B", Array) { |v| @opts[:xlim] = range(v) }
202
+ @parser.on("--ylim A,B", Array) { |v| @opts[:ylim] = range(v) }
203
+ end
204
+
205
+ def add_render
206
+ @parser.on("--canvas C") { |v| @opts[:canvas] = v.to_sym }
207
+ @parser.on("--[no-]ascii") { @opts[:ascii] = it }
208
+ @parser.on("--color") { @opts[:color] = true }
209
+ end
210
+
211
+ def add_kind_specific
212
+ add_histogram_specific
213
+ add_area
214
+ @parser.on("--style C") { |v| @opts[:style] = v.to_sym }
215
+ @parser.on("--scale N") { |v| @opts[:scale] = num(v) }
216
+ @parser.on("--xscale C") { |v| @opts[:xscale] = v.to_sym }
217
+ add_axes
218
+ end
219
+
220
+ def add_area
221
+ @parser.on("--area") { @opts[:area] = true }
222
+ @parser.on("--gradient C,C", Array) do |v|
223
+ @opts[:gradient] =
224
+ v == ["none"] ? false : v.map { Color.parse(it) }
225
+ end
226
+ end
227
+
228
+ def add_bar_specific
229
+ add_heatmap
230
+ add_layout
231
+ @parser.on("--stacked") { @opts[:stacked] = true }
232
+ @parser.on("--fill G") { |v| @opts[:fill] = v }
233
+ @parser.on("--no-eighths") { @opts[:eighths] = false }
234
+ @parser.on("--track G") do |v|
235
+ @opts[:track] = v == "none" ? nil : v
236
+ end
237
+ end
238
+
239
+ def add_meta
240
+ @parser.on("--help") { raise ChartCLI::Halt.new(0, @parser.to_s) }
241
+ @parser.on("--version") do
242
+ raise ChartCLI::Halt.new(0, "#{Tessella::VERSION}\n")
243
+ end
244
+ end
245
+
246
+ def range(values)
247
+ return values.map { num(it) } if values.size == 2
248
+
249
+ raise OptionParser::InvalidArgument, values.join(",")
250
+ end
251
+
252
+ def num(raw)
253
+ Num.parse(raw) || (raise OptionParser::InvalidArgument, raw)
254
+ end
255
+ end
256
+ end
257
+
258
+ # One numeric column out of parsed rows, naming the row and the
259
+ # field in any refusal (DESIGN.md's CLI input rules: "the message
260
+ # naming the row number and the field").
261
+ class ChartCLI
262
+ module Columns
263
+ def self.numeric(rows, index, name)
264
+ rows.each_with_index.map { |row, i| field(row, index, name, i) }
265
+ end
266
+
267
+ # The x column under --xdate: ISO 8601 times, a bad one named.
268
+ def self.times(rows, index)
269
+ rows.each_with_index.map do |row, i|
270
+ IsoTime.parse(row[index].to_s) or
271
+ raise DataError, "row #{i + 1}: x is not an ISO 8601 " \
272
+ "date: #{row[index].inspect}"
273
+ end
274
+ end
275
+
276
+ def self.field(row, index, name, row_no)
277
+ raw = row[index]
278
+ raise DataError, "row #{row_no + 1} has no #{name}" if
279
+ raw.nil?
280
+
281
+ Num.parse(raw) || bad_number(raw, name, row_no)
282
+ end
283
+
284
+ def self.bad_number(raw, name, row_no)
285
+ raise DataError,
286
+ "row #{row_no + 1}: #{name} is not a number: " \
287
+ "#{raw.inspect}"
288
+ end
289
+ end
290
+ end
291
+
292
+ # Splits a header row from the data (DESIGN.md: "a first row that
293
+ # is not all numbers names the columns"; bar looks only at the
294
+ # value field).
295
+ class ChartCLI
296
+ module Header
297
+ def self.split(rows, bar_like: false, dates: false)
298
+ return [nil, rows] unless
299
+ header?(rows.first, bar_like, dates: dates)
300
+
301
+ [rows.first, rest(rows)]
302
+ end
303
+
304
+ def self.rest(rows)
305
+ data = rows.drop(1)
306
+ raise DataError, "no data after the header" if data.empty?
307
+
308
+ data
309
+ end
310
+
311
+ # Under --xdate a date in the first field is data too.
312
+ def self.header?(row, bar_like, dates: false)
313
+ return !numeric?(row[1]) if bar_like
314
+
315
+ first = dates && IsoTime.parse(row.first.to_s)
316
+ !row.each_with_index.all? do |field, i|
317
+ numeric?(field) || (i.zero? && first)
318
+ end
319
+ end
320
+
321
+ def self.numeric?(field) = !field.nil? && !Num.parse(field).nil?
322
+ end
323
+ end
324
+
325
+ # The subset of parsed options forwarded to Tessella.<kind>: the
326
+ # options common to every kind, plus the ones its own kind takes
327
+ # (DESIGN.md's "Command line" and "Chart and kinds").
328
+ class ChartCLI
329
+ module ChartOpts
330
+ COMMON = %i[title xlabel ylabel width height xlim ylim canvas
331
+ ascii color xscale].freeze
332
+
333
+ EXTRA = {
334
+ line: %i[area gradient orientation yscale xdate yerr
335
+ threshold above below],
336
+ scatter: %i[orientation yscale xdate yerr threshold above
337
+ below],
338
+ density: %i[orientation yscale xdate],
339
+ stairs: %i[style area gradient orientation yscale xdate
340
+ threshold above below],
341
+ bar: %i[fill eighths track orientation blocks stacked],
342
+ histogram: %i[fill eighths track bins closed orientation
343
+ blocks],
344
+ box: %i[orientation],
345
+ heatmap: %i[cell_width colormap min max],
346
+ candlestick: %i[xdate],
347
+ pie: %i[donut rim],
348
+ stem: %i[scale]
349
+ }.freeze
350
+
351
+ # The chart's own options; --yerr goes to its series instead.
352
+ def self.for(opts)
353
+ opts.slice(*(COMMON + EXTRA.fetch(opts[:kind], []) - %i[yerr]))
354
+ end
355
+ end
356
+ end
357
+
358
+ # An option a kind does not take is a usage error naming both
359
+ # (DESIGN.md's "Command line"): --style, --scale, --bins,
360
+ # --closed, and bar's own --fill/--no-eighths/--track only fit
361
+ # certain kinds.
362
+ class ChartCLI
363
+ module KindOptions
364
+ FLAG = { style: "--style", scale: "--scale", bins: "--bins",
365
+ closed: "--closed", fill: "--fill", area: "--area",
366
+ gradient: "--gradient", orientation: "--orientation",
367
+ blocks: "--blocks", yscale: "--yscale",
368
+ stacked: "--stacked", cell_width: "--cell-width",
369
+ xdate: "--xdate", yerr: "--yerr",
370
+ threshold: "--threshold", above: "--above",
371
+ below: "--below",
372
+ colormap: "--colormap", min: "--min", max: "--max",
373
+ eighths: "--no-eighths", track: "--track",
374
+ donut: "--donut", rim: "--rim" }.freeze
375
+
376
+ def self.check!(opts)
377
+ allowed = ChartOpts::EXTRA.fetch(opts[:kind], [])
378
+ (FLAG.keys - allowed).each { refuse!(opts, it, opts[:kind]) }
379
+ end
380
+
381
+ def self.refuse!(opts, key, kind)
382
+ return unless opts.key?(key)
383
+
384
+ raise UsageError,
385
+ "#{FLAG[key]} is not valid with --kind #{kind}"
386
+ end
387
+ end
388
+ end
389
+
390
+ # Lines from a file or stdin into fields: CSV, tabs or runs of
391
+ # spaces; blank lines and # comments skipped (DESIGN.md's CLI
392
+ # input rules, as the ascii-art skill's plot.rb helper reads
393
+ # them too). A line holding a comma splits on commas only, one
394
+ # holding a tab on tabs only, so a label may hold spaces; only a
395
+ # line with neither splits on runs of spaces.
396
+ class ChartCLI
397
+ class Reader
398
+ def self.fields(line)
399
+ return line.split(/\s*,\s*/) if line.include?(",")
400
+ return line.split(/\s*\t\s*/) if line.include?("\t")
401
+
402
+ line.split
403
+ end
404
+
405
+ def self.rows(path, stdin)
406
+ rows = CLI.read_text(path, stdin, DataError).each_line
407
+ .filter_map { line(it) }
408
+ raise DataError, "no data" if rows.empty?
409
+
410
+ rows
411
+ end
412
+
413
+ # A non-ASCII label under --ascii is bad input, named by
414
+ # its row (a non-ASCII --title or label is usage).
415
+ def self.check_ascii!(rows)
416
+ return if Target.text_free?
417
+
418
+ i = rows.index { |row| !row.join.ascii_only? } or return
419
+
420
+ raise DataError, "row #{i + 1} is non-ASCII under --ascii"
421
+ end
422
+
423
+ def self.line(raw)
424
+ stripped = raw.strip
425
+ return nil if stripped.empty? || stripped.start_with?("#")
426
+
427
+ fields(stripped)
428
+ end
429
+ end
430
+ end
431
+
432
+ # Dispatches to the one builder for opts[:kind] (DESIGN.md's CLI
433
+ # input rules: the column shape differs by kind).
434
+ class ChartCLI
435
+ module Kinds
436
+ SERIES = %i[line scatter stairs density].freeze
437
+ BARLIKE = %i[bar histogram].freeze
438
+ OTHERS = { box: :BoxKind, heatmap: :HeatKind,
439
+ candlestick: :CandleKind, pie: :PieKind }.freeze
440
+
441
+ def self.build(opts, rows)
442
+ kind = opts[:kind]
443
+ return SeriesKind.build(kind, opts, rows) if
444
+ SERIES.include?(kind)
445
+ return BarKind.build(kind, opts, rows) if
446
+ BARLIKE.include?(kind)
447
+
448
+ other = OTHERS.fetch(kind, :StemKind)
449
+ ChartCLI.const_get(other).build(opts, rows)
450
+ end
451
+ end
452
+ end
453
+
454
+ # line, scatter, stairs, density: one column is y against 1..n,
455
+ # two are x y, more are x and one series per further column
456
+ # (DESIGN.md's CLI input rules). The one Tessella.<kind> call
457
+ # below makes the first series, named by its header field, and
458
+ # chart.add the rest, each named the same way.
459
+ class ChartCLI
460
+ class SeriesKind
461
+ def self.build(kind, opts, rows)
462
+ header, data = Header.split(rows, dates: opts[:xdate])
463
+ ncols = (header || data.first).size
464
+ columns = (0...ncols).map do |i|
465
+ column(data, header, i, ncols, opts[:xdate])
466
+ end
467
+ chart = first_call(kind, opts, columns, header)
468
+ add_rest(chart, columns, header)
469
+ chart
470
+ end
471
+
472
+ def self.column(data, header, i, ncols, xdate)
473
+ return Columns.times(data, i) if xdate && i.zero? && ncols > 1
474
+
475
+ Columns.numeric(data, i, field_name(header, i, ncols))
476
+ end
477
+
478
+ def self.field_name(header, i, ncols)
479
+ return "y" if ncols == 1
480
+ return "x" if i.zero?
481
+
482
+ header ? header[i] : "y#{i}"
483
+ end
484
+
485
+ def self.first_call(kind, opts, columns, header)
486
+ args = columns.size == 1 ? [columns[0]] : columns[0, 2]
487
+ name = header && header[columns.size == 1 ? 0 : 1]
488
+ Tessella.public_send(kind, *args, name: name,
489
+ **error_opts(opts, columns),
490
+ **ChartOpts.for(opts))
491
+ end
492
+
493
+ # --yerr: x, y and the error, three columns exactly.
494
+ def self.error_opts(opts, columns)
495
+ return {} unless opts[:yerr]
496
+ unless columns.size == 3
497
+ raise DataError, "--yerr reads x, y and the error: " \
498
+ "#{columns.size} columns"
499
+ end
500
+ { yerr: errors!(columns.pop) }
501
+ end
502
+
503
+ # A negative error is a bad value in the input: its row named.
504
+ def self.errors!(errors)
505
+ i = errors.index(&:negative?)
506
+ return errors unless i
507
+
508
+ raise DataError, "row #{i + 1}: the error is negative: " \
509
+ "#{Axis.label(errors[i])}"
510
+ end
511
+
512
+ def self.add_rest(chart, columns, header)
513
+ columns[2..].to_a.each_with_index do |y, j|
514
+ chart.add(columns[0], y, name: header && header[j + 2])
515
+ end
516
+ end
517
+ end
518
+ end
519
+
520
+ # bar: label value; histogram: one column of samples, over
521
+ # Stats.histogram's bins (DESIGN.md's CLI input rules).
522
+ class ChartCLI
523
+ # More series (or slices) than fills is input that cannot be
524
+ # drawn: exit 1, as Legend.fills! would raise.
525
+ def self.fills!(count, what)
526
+ Legend.fills!(count, what)
527
+ rescue ArgumentError => e
528
+ raise DataError, e.message
529
+ end
530
+
531
+ class BarKind
532
+ def self.build(kind, opts, rows)
533
+ header, data = Header.split(rows, bar_like: kind == :bar)
534
+ return histogram(opts, data) unless kind == :bar
535
+
536
+ bar(opts, data, header)
537
+ end
538
+
539
+ # label value, or label and a value a series (named by the
540
+ # header), grouped or --stacked.
541
+ def self.bar(opts, data, header)
542
+ labels = data.map { it[0] }
543
+ ncols = same_width!(data)
544
+ ChartCLI.fills!(ncols - 1, "series")
545
+ if ncols == 2
546
+ return Tessella.bar(labels,
547
+ Columns.numeric(data, 1, "value"),
548
+ **ChartOpts.for(opts))
549
+ end
550
+ Tessella.bar(labels, series_values(data, ncols),
551
+ series: header&.drop(1), **ChartOpts.for(opts))
552
+ end
553
+
554
+ def self.series_values(data, ncols)
555
+ columns = (1...ncols).map do |i|
556
+ Columns.numeric(data, i, "value #{i}")
557
+ end
558
+ columns.transpose
559
+ end
560
+
561
+ # Every row as wide as the first: a short or long one is
562
+ # refused rather than read in part.
563
+ def self.same_width!(data)
564
+ ncols = data.first.size
565
+ i = data.index { it.size != ncols }
566
+ return ncols unless i
567
+
568
+ raise DataError, "row #{i + 1} has #{data[i].size} columns, " \
569
+ "row 1 has #{ncols}"
570
+ end
571
+
572
+ def self.histogram(opts, data)
573
+ samples = Columns.numeric(data, 0, "sample")
574
+ Tessella.histogram(samples, **ChartOpts.for(opts))
575
+ end
576
+ end
577
+ end
578
+
579
+ # box: one or more columns, one box per column, named by the
580
+ # header or 1..n (DESIGN.md's CLI input rules).
581
+ class ChartCLI
582
+ class BoxKind
583
+ def self.build(opts, rows)
584
+ header, data = Header.split(rows)
585
+ ncols = (header || data.first).size
586
+ names = header || (1..ncols).map(&:to_s)
587
+ columns = (0...ncols).map do |i|
588
+ Columns.numeric(data, i, names[i])
589
+ end
590
+ Tessella.box(names, columns, **ChartOpts.for(opts))
591
+ end
592
+ end
593
+ end
594
+
595
+ # stem: one column, or two for the back-to-back form (DESIGN.md's
596
+ # CLI input rules).
597
+ class ChartCLI
598
+ class StemKind
599
+ def self.build(opts, rows)
600
+ header, data = Header.split(rows)
601
+ ncols = (header || data.first).size
602
+ check_ncols!(ncols)
603
+ values = Columns.numeric(data, 0, "value")
604
+ other = ncols == 2 ? Columns.numeric(data, 1, "other") : nil
605
+ Tessella.stem(values, other, **ChartOpts.for(opts))
606
+ end
607
+
608
+ def self.check_ncols!(ncols)
609
+ return if ncols <= 2
610
+
611
+ raise DataError, "stem takes one or two columns, got #{ncols}"
612
+ end
613
+ end
614
+ end
615
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # candlestick: x open high low close, a row a candle; x an ISO 8601
5
+ # date under --xdate (DESIGN.md's CLI input rules).
6
+ class ChartCLI
7
+ class CandleKind
8
+ FIELDS = %w[open high low close].freeze
9
+
10
+ def self.build(opts, rows)
11
+ _header, data = Header.split(rows, bar_like: true)
12
+ five!(data)
13
+ x = opts[:xdate] ? Columns.times(data, 0) : x_values(data)
14
+ prices = FIELDS.each_with_index.to_h do |name, i|
15
+ [name.to_sym, Columns.numeric(data, i + 1, name)]
16
+ end
17
+ Tessella.candlestick(x, **prices, **ChartOpts.for(opts))
18
+ end
19
+
20
+ def self.five!(data)
21
+ i = data.index { it.size != 5 }
22
+ return unless i
23
+
24
+ raise DataError, "row #{i + 1} has #{data[i].size} columns: " \
25
+ "candlestick reads x open high low close"
26
+ end
27
+
28
+ # x as numbers when every one is, else as given (labels).
29
+ def self.x_values(data)
30
+ xs = data.map(&:first)
31
+ numbers = xs.map { Num.parse(it) }
32
+ numbers.all? ? numbers : xs
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # heatmap: a row a label then its values; a first row whose values
5
+ # are not numbers names the columns (DESIGN.md's CLI input rules).
6
+ # Every row as wide as the first.
7
+ class ChartCLI
8
+ class HeatKind
9
+ def self.build(opts, rows)
10
+ header, data = Header.split(rows, bar_like: true)
11
+ ncols = BarKind.same_width!(data)
12
+ values = (1...ncols).map do |i|
13
+ Columns.numeric(data, i, "value #{i}")
14
+ end
15
+ Tessella.heatmap(values.transpose, ylabels: data.map(&:first),
16
+ xlabels: header&.drop(1),
17
+ **ChartOpts.for(opts))
18
+ end
19
+ end
20
+ end
21
+ end