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,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
|