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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # The x labels under a candlestick chart: the first and last x, as
5
+ # dates when x holds times (xdate: or the span's format, as on a date
6
+ # axis). Kept apart so Candlestick stays under budget.
7
+ module CandleLabels
8
+ private
9
+
10
+ def x_label(value)
11
+ return Axis.label(value) unless DateAxis.time?(value)
12
+
13
+ Time.at(DateAxis.seconds(value)).utc.strftime(x_format)
14
+ end
15
+
16
+ def x_format
17
+ return @opts[:xdate] if @opts[:xdate].is_a?(String)
18
+
19
+ seconds = @x.map { DateAxis.seconds(it) }
20
+ span = seconds.max - seconds.min
21
+ DateAxis::SPANS.find { |least, _| span >= least }.last
22
+ end
23
+ end
24
+
25
+ # Candlesticks (DESIGN.md, "Chart and kinds"): an open/high/low/
26
+ # close series spread over width: (40 by default), each candle as
27
+ # wide as its share of the width allows, a blank column between
28
+ # candles -- a column each and no gap when they do not fit. The
29
+ # wick │ runs from low to high in the candle's middle column; the
30
+ # body from open to close fills the candle: █ for an up candle
31
+ # (close at or above open), ▒ for a down one, ─ when open and close
32
+ # share a row; under color: true up is green and down red. The
33
+ # value axis is on the left, the first and last x under the frame.
34
+ class Candlestick < Chart
35
+ include CandleLabels
36
+
37
+ OPTIONS = (Chart::OPTIONS - %i[canvas xscale xlim] +
38
+ %i[xdate]).freeze
39
+ PRICES = %i[open high low close].freeze
40
+ GLYPHS = { up: "█", down: "▒", flat: "─", wick: "│" }.freeze
41
+ ASCII_GLYPHS = { up: "#", down: "=", flat: "-", wick: "|" }.freeze
42
+
43
+ # x (1..n when nil), and the four prices as Arrays.
44
+ def add(x = nil, **prices)
45
+ values = price_values(prices)
46
+ check_candles!(values)
47
+ @x = Array(x || (1..values[0].size))
48
+ Check.same_size!(@x, values[0], %w[x prices])
49
+ @candles = values.transpose
50
+ self
51
+ end
52
+
53
+ private
54
+
55
+ def default_border = :box
56
+
57
+ def price_values(prices)
58
+ missing = PRICES - prices.keys
59
+ raise ArgumentError, "missing #{missing.join(", ")}" if
60
+ missing.any?
61
+
62
+ PRICES.map { Check.finite!(Array(prices[it]), it.to_s) }
63
+ end
64
+
65
+ def check_candles!(values)
66
+ same_length!(values)
67
+ bad = values.transpose.index do |o, h, l, c|
68
+ l > [o, c].min || h < [o, c].max
69
+ end
70
+ return unless bad
71
+
72
+ raise ValueError, "candle #{bad + 1}: low and high must hold " \
73
+ "open and close"
74
+ end
75
+
76
+ def same_length!(values)
77
+ sizes = values.map(&:size).uniq
78
+ return if sizes.size == 1 && sizes.first.positive?
79
+
80
+ raise ArgumentError, "open, high, low and close must be Arrays " \
81
+ "of one length"
82
+ end
83
+
84
+ def glyphs = @ascii ? ASCII_GLYPHS : GLYPHS
85
+
86
+ def limits = Axis.limits(@candles.flatten, @opts[:ylim])
87
+
88
+ def row_of(value, lo, hi)
89
+ share = (hi - value).fdiv(hi - lo)
90
+ (share * (height - 1)).round.clamp(0, height - 1)
91
+ end
92
+
93
+ def gap = (2 * @candles.size) - 1 <= width ? 1 : 0
94
+
95
+ # Columns a candle: its share of the width, less the gap.
96
+ def candle_width
97
+ [((width + gap) / @candles.size) - gap, 1].max
98
+ end
99
+
100
+ def body
101
+ lo, hi = limits
102
+ rows = Array.new(height) { [] }
103
+ @candles.each_with_index do |candle, i|
104
+ rows.each { it << Cell.blank } if i.positive? && gap == 1
105
+ column = candle_column(candle, lo, hi)
106
+ rows.each_with_index { |row, r| row.concat(widen(column[r])) }
107
+ end
108
+ rows
109
+ end
110
+
111
+ # A cell across the candle's width: a body fills it, a wick
112
+ # stays one column, in the middle.
113
+ def widen(cell)
114
+ cw = candle_width
115
+ return Array.new(cw) { cell } unless cell.char == glyphs[:wick]
116
+
117
+ Array.new(cw) { it == (cw - 1) / 2 ? cell : Cell.blank }
118
+ end
119
+
120
+ def candle_column(candle, lo, hi)
121
+ open, high, low, close = candle
122
+ up = close >= open
123
+ top, bottom = [open, close].map { row_of(it, lo, hi) }.minmax
124
+ color = up ? :green : :red
125
+ (0...height).map do |r|
126
+ candle_cell(r, [row_of(high, lo, hi), row_of(low, lo, hi)],
127
+ [top, bottom], [up, color])
128
+ end
129
+ end
130
+
131
+ def candle_cell(row, wick, body, kind)
132
+ up, color = kind
133
+ if row.between?(*body)
134
+ return Cell.new(char: body_glyph(body, up), color: color)
135
+ end
136
+ return Cell.blank unless row.between?(*wick)
137
+
138
+ Cell.new(char: glyphs[:wick], color: color)
139
+ end
140
+
141
+ def body_glyph(body, up)
142
+ return glyphs[:flat] if body[0] == body[1]
143
+
144
+ glyphs[up ? :up : :down]
145
+ end
146
+
147
+ def frame = Frame.new(**candle_options)
148
+
149
+ def candle_options
150
+ lo, hi = limits
151
+ { body: body, border: @border, ascii: @ascii, color: @color_on,
152
+ title: @opts[:title], xlabel: @opts[:xlabel],
153
+ ylabel: @opts[:ylabel],
154
+ left: { 0 => Axis.tick(hi), (height - 1) => Axis.tick(lo) },
155
+ bottom: [x_label(@x.first), x_label(@x.last)] }
156
+ end
157
+ end
158
+
159
+ # (x = nil, open:, high:, low:, close:, **opts): a new Candlestick.
160
+ def self.candlestick(x = nil, **opts)
161
+ prices = opts.slice(*Candlestick::PRICES)
162
+ Candlestick.new(**opts.except(*Candlestick::PRICES)).add(x, **prices)
163
+ end
164
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # The bar layouts besides unicode_plot's (DESIGN.md, "Chart and
5
+ # kinds"): orientation: :vertical draws columns, and blocks:
6
+ # :braille, :block, :sextant or :octant packs several bars into
7
+ # one character -- braille and octant four bars per text row (two
8
+ # columns per character when vertical), sextant three, block two.
9
+ # Values must be zero or more. Kept apart from Bar so Bar's own
10
+ # class body stays under budget.
11
+ module BarLayouts
12
+ BLOCKS = %i[eighths braille block quadrant half sextant octant].freeze
13
+ ORIENTATIONS = %i[horizontal vertical].freeze
14
+
15
+ private
16
+
17
+ # unicode_plot's own layout: horizontal bars of eighths.
18
+ def classic? = @orientation == :horizontal && @blocks == :eighths
19
+
20
+ def check_layout!(opts)
21
+ @orientation = pick!(opts, :orientation, ORIENTATIONS)
22
+ @blocks = pick!(opts, :blocks, BLOCKS)
23
+ upright_track!(opts)
24
+ return if @blocks == :eighths
25
+
26
+ if @ascii
27
+ raise ArgumentError, "blocks: #{@blocks} conflicts with " \
28
+ "ascii: true"
29
+ end
30
+ glyph_options!(opts)
31
+ end
32
+
33
+ # Columns draw no track: one given is refused, not ignored.
34
+ def upright_track!(opts)
35
+ return unless @orientation == :vertical && opts.key?(:track)
36
+
37
+ raise ArgumentError, "track: only with horizontal bars"
38
+ end
39
+
40
+ def pick!(opts, key, allowed)
41
+ value = opts.fetch(key, allowed.first)
42
+ return value if allowed.include?(value)
43
+
44
+ raise ArgumentError, "unknown #{key}: #{value.inspect}"
45
+ end
46
+
47
+ def glyph_options!(opts)
48
+ given = %i[fill eighths track] & opts.keys
49
+ return if given.empty?
50
+
51
+ raise ArgumentError, "#{given.join(", ")} only with blocks: " \
52
+ ":eighths, not :#{@blocks}"
53
+ end
54
+
55
+ # Each bar's value through xscale, refused below zero.
56
+ def layout_values
57
+ values = @bars.map { |_, value, _| @xscale.call(value) }
58
+ return values if values.all? { it >= 0 }
59
+
60
+ raise ArgumentError, "a #{@orientation} #{@blocks} bar chart " \
61
+ "needs values of 0 or more"
62
+ end
63
+
64
+ def layout_frame_options
65
+ raise ArgumentError, "no bars to draw" if @bars.empty?
66
+
67
+ values = layout_values
68
+ max = values.max.to_f
69
+ common = { ascii: @ascii, color: @color_on, title: @opts[:title] }
70
+ return common.merge(column_options(values, max)) if
71
+ @orientation == :vertical
72
+
73
+ common.merge(dot_row_options(values, max))
74
+ end
75
+
76
+ # The value axis's top: the largest bar's value, as given.
77
+ def top_label
78
+ transformed = @bars.map { |_, value, _| @xscale.call(value) }
79
+ Axis.label(@bars[transformed.index(transformed.max)][1])
80
+ end
81
+
82
+ def share(value, max) = max.positive? ? value / max : 0.0
83
+
84
+ # At least one step for a value above zero, so a small bar
85
+ # never draws as an empty one.
86
+ def pixels(value, max, span) = steps(share(value, max) * span, value)
87
+
88
+ def steps(exact, value)
89
+ value.positive? ? [exact.round, 1].max : exact.round
90
+ end
91
+ end
92
+
93
+ # orientation: :vertical -- columns rising from a floor, value
94
+ # axis on the left (0 to the largest value), names under the
95
+ # columns when each fits its slot, else the first and the last.
96
+ module ColumnBars
97
+ RISE = " ▁▂▃▄▅▆▇".chars.freeze
98
+
99
+ private
100
+
101
+ def column_options(values, max)
102
+ body, slot = column_body(values, max)
103
+ { body: body, border: :box, xlabel: @opts[:xlabel],
104
+ ylabel: @opts[:ylabel] || column_value_label,
105
+ left: { (body.size - 1) => "0", 0 => top_label },
106
+ **column_names(slot) }
107
+ end
108
+
109
+ def column_body(values, max)
110
+ return rise_columns(values, max) if @blocks == :eighths
111
+
112
+ dot_columns(values, max)
113
+ end
114
+
115
+ def column_height = @opts.fetch(:height, Chart::DEFAULT_HEIGHT)
116
+
117
+ # One text column per bar, eighths of a cell: " ▁▂▃▄▅▆▇█".
118
+ def rise_columns(values, max)
119
+ gap, bw = rise_geometry(values.size)
120
+ rows = Array.new(column_height) { [] }
121
+ values.each_with_index do |v, i|
122
+ blank_columns(rows, i.zero? ? 0 : gap)
123
+ append_column(rows, rise_units(share(v, max), v), bw,
124
+ color_of(i))
125
+ end
126
+ [rows, bw + gap]
127
+ end
128
+
129
+ def blank_columns(rows, count)
130
+ rows.each { it.concat(Array.new(count) { Cell.blank }) }
131
+ end
132
+
133
+ def color_of(index) = @bars[index][2]
134
+
135
+ # [gap, column width]: a blank column between bars when they
136
+ # fit with one, and the width shared out between them.
137
+ def rise_geometry(n)
138
+ gap = (2 * n) - 1 <= width ? 1 : 0
139
+ [gap, [((width + gap) / n) - gap, 1].max]
140
+ end
141
+
142
+ def rise_units(fraction, value)
143
+ return steps(fraction * column_height, value) * 8 unless @eighths
144
+
145
+ steps(fraction * column_height * 8, value)
146
+ end
147
+
148
+ def append_column(rows, units, bw, color, fill = @fill)
149
+ rows.each_with_index do |row, r|
150
+ level = (column_height - 1 - r) * 8
151
+ row.concat(Array.new(bw) do
152
+ rise_cell(units - level, color, fill)
153
+ end)
154
+ end
155
+ end
156
+
157
+ def rise_cell(eighths, color, fill = @fill)
158
+ return Cell.new(char: fill, color: color) if eighths >= 8
159
+ return Cell.blank unless eighths.positive?
160
+
161
+ Cell.new(char: RISE[eighths], color: color)
162
+ end
163
+
164
+ # One pixel column per bar on a braille or block canvas, so a
165
+ # character holds two columns; wider when the width allows,
166
+ # with a one-pixel gap between bars then.
167
+ def dot_columns(values, max)
168
+ canvas = dot_canvas(column_chars(values.size), column_height)
169
+ per = [canvas.pixel_width / values.size, 1].max
170
+ values.each_with_index do |v, i|
171
+ dot_column(canvas, i * per, per,
172
+ [pixels(v, max, canvas.pixel_height), color_of(i)])
173
+ end
174
+ [canvas.rows, slot_chars(per)]
175
+ end
176
+
177
+ # A bar per pixels wide at px, one pixel left as its gap.
178
+ def dot_column(canvas, px, per, bar)
179
+ height, color = bar
180
+ (per > 1 ? per - 1 : 1).times do |k|
181
+ canvas.column_bar(px + k, height, color: color)
182
+ end
183
+ end
184
+
185
+ def column_chars(n) = [width, (n + across - 1) / across].max
186
+
187
+ # Bars side by side in one character: 2, or 1 with halves.
188
+ def across = @blocks == :half ? 1 : 2
189
+
190
+ # A name's room under a dot column, when bars fill whole cells.
191
+ def slot_chars(per) = (per % across).zero? ? per / across : nil
192
+
193
+ def dot_canvas(cols, rows)
194
+ Canvas.build(canvas_name, width: cols, height: rows,
195
+ xlim: [0, 1], ylim: [0, 1])
196
+ end
197
+
198
+ # Half blocks run along the bar: "▄" up a column, "▌" across.
199
+ def canvas_name
200
+ return :half_wide if @blocks == :half && @orientation == :horizontal
201
+
202
+ @blocks
203
+ end
204
+
205
+ # Names under the columns when each fits its slot (a column's
206
+ # width and its gap), else the first and the last at the ends.
207
+ def column_names(slot)
208
+ names = column_labels
209
+ if slot && names.all? { Width.of(it) <= slot }
210
+ { under: names.map { Width.center(it, slot) }.join.rstrip }
211
+ else
212
+ { bottom: [names.first, names.last] }
213
+ end
214
+ end
215
+
216
+ def column_labels = @bars.map { |label, *| label.to_s }
217
+ def column_value_label = nil
218
+ end
219
+
220
+ # blocks: other than eighths, horizontal: one pixel row per bar, so
221
+ # a text row holds four bars (braille, octant), three (sextant) or
222
+ # two (block), each row
223
+ # named by the bars it holds; the value axis runs under the frame
224
+ # from 0 to the largest value.
225
+ module DotRowBars
226
+ private
227
+
228
+ def dot_row_options(values, max)
229
+ canvas = dot_canvas(width, dot_row_count(values.size))
230
+ values.each_with_index do |v, i|
231
+ canvas.row_bar(i, pixels(v, max, canvas.pixel_width),
232
+ color: color_of(i))
233
+ end
234
+ { body: canvas.rows, border: :box, xlabel: xlabel,
235
+ ylabel: @opts[:ylabel], left: dot_row_labels(canvas),
236
+ bottom: ["0", top_label] }
237
+ end
238
+
239
+ def dot_row_count(n) = (n + per_row - 1) / per_row
240
+
241
+ # Bars a text row holds: the canvas's pixel rows per character.
242
+ def per_row
243
+ { braille: 4, octant: 4, sextant: 3, half: 1 }.fetch(@blocks, 2)
244
+ end
245
+
246
+ def dot_row_labels(canvas)
247
+ (0...canvas.height).to_h do |r|
248
+ first = r * per_row
249
+ last = [first + per_row, @bars.size].min - 1
250
+ [r, group_label(first, last)]
251
+ end
252
+ end
253
+
254
+ def group_label(first, last)
255
+ names = column_labels
256
+ first == last ? names[first] : "#{names[first]}..#{names[last]}"
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A date axis for the plotted kinds (DESIGN.md, "Chart and kinds"):
5
+ # x values that are times -- a Time, or anything with to_time (a
6
+ # Date, a DateTime) -- are plotted as seconds and labelled as dates,
7
+ # in UTC so the output does not depend on the machine. xdate: names
8
+ # the strftime format; by default it follows the span: a day or more
9
+ # "%Y-%m-%d", an hour or more "%m-%d %H:%M", less "%H:%M:%S". xlim:
10
+ # may be times too; without it the axis spans the data exactly, as
11
+ # a rounded number of seconds means nothing. A Date counts from
12
+ # midnight UTC. A date axis takes no xscale:. Sideways (an
13
+ # orientation: :horizontal chart) the dates label the vertical axis.
14
+ module DateAxis
15
+ DAY = 86_400
16
+
17
+ # One time, or one repeated, spans a day either side: a zero
18
+ # span has no scale.
19
+ SPANS = [[86_400, "%Y-%m-%d"], [3600, "%m-%d %H:%M"],
20
+ [0, "%H:%M:%S"]].freeze
21
+
22
+ # A Time, or a Date/DateTime (whose to_time the date library adds).
23
+ def self.time?(value)
24
+ value.is_a?(Time) || value.respond_to?(:to_time)
25
+ end
26
+
27
+ def self.seconds(value)
28
+ return value unless time?(value)
29
+ return value.to_f if value.is_a?(Time)
30
+ return value.to_time.to_f if value.respond_to?(:hour)
31
+
32
+ Time.utc(value.year, value.month, value.day).to_f
33
+ end
34
+
35
+ def initialize(**opts)
36
+ limit = opts[:xlim]
37
+ if limit&.any? { DateAxis.time?(it) }
38
+ opts = opts.merge(xlim: limit.map { DateAxis.seconds(it) })
39
+ @xdate = true
40
+ end
41
+ super
42
+ end
43
+
44
+ def add(*args, **)
45
+ return super unless args.length == 2 && dated?(args[0])
46
+
47
+ date_axis!
48
+ super(args[0].map { DateAxis.seconds(it) }, args[1], **)
49
+ end
50
+
51
+ private
52
+
53
+ def dated?(xs) = xs&.any? { DateAxis.time?(it) }
54
+
55
+ # The date axis spans the data exactly unless xlim: is given.
56
+ def xlim = date_limits(:x, !@sideways) || super
57
+ def ylim = date_limits(:y, @sideways) || super
58
+
59
+ def date_limits(axis, dated)
60
+ return nil unless dated && date_axis? && !@opts[:xlim]
61
+
62
+ lo, hi = @series.flat_map(&axis).minmax
63
+ lo == hi ? [lo - DAY, hi + DAY] : [lo, hi]
64
+ end
65
+
66
+ def date_axis? = @xdate || @opts[:xdate]
67
+
68
+ def date_axis!
69
+ raise ArgumentError, "a date axis takes no xscale:" if
70
+ @opts[:xscale]
71
+
72
+ @xdate = true
73
+ end
74
+
75
+ def frame_options(canvas, xl, yl)
76
+ options = super
77
+ return options unless date_axis?
78
+
79
+ return options.merge(left: date_left(yl)) if @sideways
80
+
81
+ options.merge(bottom: [date_label(xl.first, xl),
82
+ date_label(xl.last, xl)])
83
+ end
84
+
85
+ def date_left(yl)
86
+ { 0 => date_label(yl.last, yl),
87
+ (height - 1) => date_label(yl.first, yl) }
88
+ end
89
+
90
+ def date_label(seconds, limits)
91
+ Time.at(seconds).utc.strftime(date_format(limits))
92
+ end
93
+
94
+ def date_format(limits)
95
+ format = @opts[:xdate]
96
+ return format if format.is_a?(String)
97
+
98
+ span = (limits.last - limits.first).abs
99
+ SPANS.find { |least, _| span >= least }.last
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Error bars for the plotted kinds (DESIGN.md, "Chart and kinds"):
5
+ # add's yerr: is an error a point -- the bar runs y - e .. y + e --
6
+ # or [lows, highs] for asymmetric ones. Bounds are taken in data
7
+ # units, then through yscale:. Each point gets a segment along the
8
+ # value axis (across, sideways), drawn after the series or its
9
+ # marks, and the axis limits take the bars in.
10
+ module ErrorBars
11
+ def add(*args, yerr: nil, **)
12
+ super(*args, **)
13
+ return self unless yerr
14
+
15
+ ys = Array(args.last)
16
+ (@errors ||= {})[@series.size - 1] = error_bounds(ys, yerr)
17
+ self
18
+ end
19
+
20
+ private
21
+
22
+ # [lows, highs] in plotted units.
23
+ def error_bounds(ys, yerr)
24
+ lo, hi = yerr.first.is_a?(Array) ? yerr : [yerr, yerr]
25
+ [lo, hi].each { error_size!(it, ys) }
26
+ lows = ys.zip(lo).map { |y, e| @yscale.call(y - e) }
27
+ highs = ys.zip(hi).map { |y, e| @yscale.call(y + e) }
28
+ [Check.finite!(lows, "y - yerr"), Check.finite!(highs, "y + yerr")]
29
+ end
30
+
31
+ def error_size!(errors, ys)
32
+ Check.finite!(Array(errors), "yerr")
33
+ Check.same_size!(Array(errors), ys, %w[yerr y])
34
+ return if errors.all? { !it.negative? }
35
+
36
+ raise ArgumentError, "yerr must be 0 or more"
37
+ end
38
+
39
+ def draw_or_mark(canvas, series)
40
+ super
41
+ bounds = @errors&.[](series_index(series))
42
+ draw_errors(canvas, series, bounds) if bounds
43
+ end
44
+
45
+ # The series by its data, not its identity: threshold: draws a
46
+ # recoloured copy (Series#with keeps the same x and y Arrays).
47
+ def series_index(series)
48
+ @series.index { it.x.equal?(series.x) && it.y.equal?(series.y) }
49
+ end
50
+
51
+ def draw_errors(canvas, series, bounds)
52
+ positions = @sideways ? series.y : series.x
53
+ positions.each_with_index do |pos, i|
54
+ lo = bounds[0][i]
55
+ hi = bounds[1][i]
56
+ if @sideways
57
+ canvas.line(lo, pos, hi, pos, color: series.color)
58
+ else
59
+ canvas.line(pos, lo, pos, hi, color: series.color)
60
+ end
61
+ end
62
+ end
63
+
64
+ def axis_values(axis)
65
+ values = super
66
+ return values unless @errors && axis == (@sideways ? :x : :y)
67
+
68
+ values + @errors.values.flatten
69
+ end
70
+ end
71
+ end