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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A heatmap (DESIGN.md, "Chart and kinds"): a matrix of values as a
5
+ # grid of cells, each cell_width: columns wide (2 by default, near
6
+ # square in a terminal), shaded by value -- along a ramp (" ░▒▓█",
7
+ # ASCII " .:-=+*#%@") without colour, or as █ coloured along a
8
+ # colormap under color: true (a viridis-like blend by default;
9
+ # colormap: takes gradient stops as Color does). Rows are named by
10
+ # ylabels: on the left, columns by xlabels: under the frame when
11
+ # they fit, and a key right of the frame gives the range. min: and
12
+ # max: fix the range, so maps drawn apart share one scale. The
13
+ # matrix sets the size, so width: and height: are refused.
14
+ class Heatmap < Chart
15
+ OPTIONS = (Chart::OPTIONS - %i[width height xlim ylim canvas xscale] +
16
+ %i[xlabels ylabels cell_width colormap min max]).freeze
17
+ RAMP = " ░▒▓█"
18
+ ASCII_RAMP = " .:-=+*#%@"
19
+ VIRIDIS = %w[#440154 #3b528b #21918c #5ec962 #fde725].freeze
20
+
21
+ def add(matrix)
22
+ rows = Array(matrix).map { Check.finite!(Array(it), "value") }
23
+ if rows.empty? || rows.map(&:size).uniq.size != 1 || rows[0].empty?
24
+ raise ArgumentError, "a heatmap needs rows of one length"
25
+ end
26
+ @matrix = rows
27
+ check_colormap!
28
+ self
29
+ end
30
+
31
+ private
32
+
33
+ def default_border = :box
34
+
35
+ def cell_width
36
+ Check.positive_integer!(@opts.fetch(:cell_width, 2), "cell_width")
37
+ end
38
+
39
+ def ramp = @ascii ? ASCII_RAMP : RAMP
40
+ def colormap = @opts.fetch(:colormap, VIRIDIS)
41
+
42
+ def check_colormap!
43
+ Color::Gradient.check!(colormap) if @opts.key?(:colormap)
44
+ end
45
+
46
+ def range
47
+ values = @matrix.flatten
48
+ lo = @opts.fetch(:min, values.min)
49
+ hi = @opts.fetch(:max, values.max)
50
+ Check.finite!([lo, hi], "min/max")
51
+ raise ArgumentError, "min #{lo} is above max #{hi}" if lo > hi
52
+
53
+ [lo, hi]
54
+ end
55
+
56
+ # 0..1 for v within the range; the middle when it is flat.
57
+ def share(v, lo, hi)
58
+ hi == lo ? 0.5 : (v.clamp(lo, hi) - lo).fdiv(hi - lo)
59
+ end
60
+
61
+ def heat_cell(v, lo, hi)
62
+ s = share(v, lo, hi)
63
+ if @color_on
64
+ color = Color::Gradient.at(colormap, (s * 255).round, 256)
65
+ return Cell.new(char: @ascii ? "#" : "█", color: color)
66
+ end
67
+ Cell.new(char: ramp[(s * (ramp.length - 1)).round], color: nil)
68
+ end
69
+
70
+ def body
71
+ lo, hi = range
72
+ @matrix.map do |row|
73
+ row.flat_map do |v|
74
+ Array.new(cell_width) { heat_cell(v, lo, hi) }
75
+ end
76
+ end
77
+ end
78
+
79
+ def frame
80
+ Frame.new(**heat_options)
81
+ end
82
+
83
+ def heat_options
84
+ { body: body, border: @border, ascii: @ascii, color: @color_on,
85
+ title: @opts[:title], xlabel: @opts[:xlabel],
86
+ ylabel: @opts[:ylabel], left: row_names, right: key,
87
+ **column_names }
88
+ end
89
+
90
+ def row_names
91
+ Array(@opts[:ylabels]).each_with_index.to_h { |n, i| [i, n.to_s] }
92
+ end
93
+
94
+ # The key: the top of the range beside the first row, the bottom
95
+ # beside the last, each after its own shade.
96
+ # A one-row map shows the range as lo..hi instead.
97
+ def key
98
+ lo, hi = range
99
+ last = @matrix.size - 1
100
+ return { 0 => "#{Axis.label(lo)}..#{Axis.label(hi)}" } if
101
+ last.zero?
102
+
103
+ { 0 => "#{key_glyph(hi, lo, hi)} #{Axis.label(hi)}",
104
+ last => "#{key_glyph(lo, lo, hi)} #{Axis.label(lo)}" }
105
+ end
106
+
107
+ # The key's shade; words under colour, as the key itself is not
108
+ # coloured.
109
+ def key_glyph(v, lo, hi)
110
+ return v == hi ? "max" : "min" if @color_on
111
+
112
+ heat_cell(v, lo, hi).char
113
+ end
114
+
115
+ def column_names
116
+ names = Array(@opts[:xlabels]).map(&:to_s)
117
+ return {} if names.empty?
118
+ return { bottom: [names.first, names.last] } unless
119
+ names.all? { Width.of(it) < cell_width }
120
+
121
+ { under: names.map { Width.center(it, cell_width) }.join.rstrip }
122
+ end
123
+ end
124
+
125
+ # (matrix, xlabels: nil, ylabels: nil, cell_width: 2, colormap: nil,
126
+ # min: nil, max: nil, **opts): a new Heatmap.
127
+ def self.heatmap(matrix, **)
128
+ Heatmap.new(**).add(matrix)
129
+ end
130
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # A connected path through (x, y) points (DESIGN.md, "Chart and
5
+ # kinds"): the x = 0 / y = 0 axis lines draw when in range, as
6
+ # unicode_plot's lineplot does. area: true fills under the path
7
+ # instead, from the canvas floor up, btop-style; under color: true
8
+ # its cells take gradient:'s colours by height, bottom band first.
9
+ class Line < Chart
10
+ include Sideways
11
+ include DateAxis
12
+ include ErrorBars
13
+ include Threshold
14
+
15
+ OPTIONS = (Chart::OPTIONS +
16
+ %i[area gradient orientation yscale xdate threshold
17
+ above below]).freeze
18
+ GRADIENT = %i[green yellow red].freeze
19
+
20
+ def initialize(**opts)
21
+ super
22
+ check_area!(opts)
23
+ end
24
+
25
+ private
26
+
27
+ # An area is measured from zero: the value axis takes in 0
28
+ # unless its limits are given.
29
+ def zero_floor? = @opts[:area]
30
+ def fills? = @opts[:area]
31
+
32
+ def draw_series(canvas, series)
33
+ return canvas.lines(series.x, series.y, color: series.color) unless
34
+ @opts[:area]
35
+
36
+ canvas.area(series.x, series.y, color: area_color(series),
37
+ across: @sideways)
38
+ end
39
+
40
+ # The gradient as a cell row -> colour map (row 0 the top), or
41
+ # the series' own colour when gradient: false.
42
+ def area_color(series)
43
+ stops = @opts.fetch(:gradient, GRADIENT)
44
+ return series.color unless stops
45
+
46
+ return ->(col) { Color::Gradient.at(stops, col, width) } if
47
+ @sideways
48
+
49
+ rows = height
50
+ ->(row) { Color::Gradient.at(stops, rows - 1 - row, rows) }
51
+ end
52
+
53
+ def check_area!(opts)
54
+ stops = opts[:gradient]
55
+ return if stops.nil? || stops == false
56
+
57
+ raise ArgumentError, "gradient: needs area: true" unless opts[:area]
58
+
59
+ Color::Gradient.check!(stops)
60
+ end
61
+ end
62
+
63
+ # Unconnected points (DESIGN.md): the same axis lines as Line, with
64
+ # no path drawn between points.
65
+ class Scatter < Chart
66
+ include Sideways
67
+ include DateAxis
68
+ include ErrorBars
69
+ include Threshold
70
+
71
+ OPTIONS = (Chart::OPTIONS + %i[orientation yscale xdate threshold
72
+ above below]).freeze
73
+
74
+ private
75
+
76
+ def draw_series(canvas, series)
77
+ canvas.points(series.x, series.y, color: series.color)
78
+ end
79
+ end
80
+
81
+ # A step path through (x, y): style :post holds the previous y
82
+ # until the new x, then steps to the new y; :pre steps to the new y
83
+ # first, at the previous x -- as unicode_plot's stairs does.
84
+ class Stairs < Line
85
+ STYLES = %i[post pre].freeze
86
+
87
+ def initialize(style: :post, **)
88
+ raise ArgumentError, "unknown style: #{style.inspect}" unless
89
+ STYLES.include?(style)
90
+
91
+ @style = style
92
+ super(**)
93
+ end
94
+
95
+ # A marked series marks the points given, not the step corners.
96
+ def add(*args, name: nil, color: nil, mark: nil)
97
+ return super if mark
98
+
99
+ x, y = args.length == 1 ? [nil, *args] : args
100
+ xs = Array(x || (1..y.length))
101
+ sx, sy = step_coords(xs, Array(y))
102
+ super(sx, sy, name: name, color: color)
103
+ end
104
+
105
+ private
106
+
107
+ def step_coords(x, y)
108
+ @style == :post ? post_steps(x, y) : pre_steps(x, y)
109
+ end
110
+
111
+ def post_steps(x, y)
112
+ xs = [x.first]
113
+ ys = [y.first]
114
+ (1...x.size).each do |i|
115
+ xs << x[i] << x[i]
116
+ ys << y[i - 1] << y[i]
117
+ end
118
+ [xs, ys]
119
+ end
120
+
121
+ def pre_steps(x, y)
122
+ xs = [x.first]
123
+ ys = [y.first]
124
+ (1...x.size).each do |i|
125
+ xs << x[i - 1] << x[i]
126
+ ys << y[i] << y[i]
127
+ end
128
+ [xs, ys]
129
+ end
130
+ end
131
+
132
+ # Hit-count shading over (x, y) points (DESIGN.md): always the
133
+ # :density canvas -- matching unicode_plot's own densityplot
134
+ # default, no axis lines.
135
+ class Density < Scatter
136
+ private
137
+
138
+ def canvas_type = :density
139
+ def draw_grid? = false
140
+ end
141
+
142
+ # (x = nil, y, yerr: nil, **opts): a new Line chart with one series.
143
+ def self.line(*, name: nil, mark: nil, yerr: nil, **)
144
+ add_series(Line.new(**), *, name: name, mark: mark, yerr: yerr)
145
+ end
146
+
147
+ # (x = nil, y, yerr: nil, **opts): a new Scatter chart with one
148
+ # series.
149
+ def self.scatter(*, name: nil, mark: nil, yerr: nil, **)
150
+ add_series(Scatter.new(**), *, name: name, mark: mark, yerr: yerr)
151
+ end
152
+
153
+ # (x = nil, y, style: :post, **opts): a new Stairs chart, style:
154
+ # :pre or :post.
155
+ def self.stairs(*, style: :post, name: nil, mark: nil, **)
156
+ add_series(Stairs.new(style: style, **), *, name: name,
157
+ mark: mark)
158
+ end
159
+
160
+ # (x = nil, y, **opts): a new Density chart with one series.
161
+ def self.density(*, name: nil, mark: nil, **)
162
+ add_series(Density.new(**), *, name: name, mark: mark)
163
+ end
164
+
165
+ # name: and mark: apply to the first series, as add's do to the
166
+ # others
167
+ def self.add_series(chart, *, name: nil, mark: nil, yerr: nil)
168
+ extra = yerr ? { yerr: yerr } : {}
169
+ chart.add(*, name: name, mark: mark, **extra)
170
+ chart
171
+ end
172
+ private_class_method :add_series
173
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Bars of several series (DESIGN.md, "Chart and kinds"): Bar#add
5
+ # given an Array per label -- one value a series -- draws them
6
+ # grouped, a bar per series under each label with a blank row
7
+ # between labels, or stacked: true, the series end to end in one bar
8
+ # labelled with its total. Series are told apart by fill glyph
9
+ # (Legend::FILLS, █ ▓ ▒ ░ ... ASCII # = + @ ..., so at most 12) and,
10
+ # under color: true, by colour; the legend sits right of the frame,
11
+ # or wrapped under it, named by series: (1..n otherwise). Values
12
+ # must be 0 or more; the braille, block and half layouts refuse
13
+ # several series. Kept apart so Bar stays under budget.
14
+ module MultiBars
15
+ include MultiBarFine
16
+
17
+ FILLS = Legend::FILLS
18
+ ASCII_FILLS = Legend::ASCII_FILLS
19
+
20
+ private
21
+
22
+ def multi? = !@groups.nil?
23
+
24
+ def add_multi(labels, rows)
25
+ raise ArgumentError, "xscale: takes one series" if @opts[:xscale]
26
+
27
+ Check.same_size!(labels, rows, %w[labels groups])
28
+ @groups ||= []
29
+ rows.each_with_index do |values, i|
30
+ vals = Check.finite!(Array(values), "value")
31
+ check_series_size!(vals)
32
+ Legend.fills!(vals.size, "series")
33
+ @groups << [labels[i], vals]
34
+ end
35
+ self
36
+ end
37
+
38
+ def check_series_size!(vals)
39
+ n = @groups.first&.last&.size || vals.size
40
+ return if vals.size == n && n.positive?
41
+
42
+ raise ArgumentError, "every label needs #{n} values, one a " \
43
+ "series: got #{vals.size}"
44
+ end
45
+
46
+ def series_count = @groups.first.last.size
47
+
48
+ def series_fill(i) = (@ascii ? ASCII_FILLS : FILLS)[i]
49
+
50
+ # Eighths (▏..▉, ▁..▇) are solid: they end only a █ series, so a
51
+ # ▓ or ▒ bar is never closed by what looks like another series.
52
+ def eighths_for?(fill) = @eighths && fill == FILLS.first
53
+ def series_color(i) = Color::NAMES[i % Color::NAMES.size]
54
+
55
+ def series_names
56
+ names = Array(@opts[:series]).map(&:to_s)
57
+ return names if names.size == series_count
58
+
59
+ (1..series_count).map(&:to_s)
60
+ end
61
+
62
+ def legend_entries
63
+ series_names.each_with_index.map do |name, i|
64
+ "#{series_fill(i)} #{name}"
65
+ end
66
+ end
67
+
68
+ # The legend beside the frame, an entry a row, or under it,
69
+ # wrapped to the frame's width, when there are more series
70
+ # than rows.
71
+ def legend_options(rows)
72
+ entries = legend_entries
73
+ if entries.size <= rows
74
+ return { right: entries.each_with_index.to_h { |e, i| [i, e] } }
75
+ end
76
+
77
+ { under: Legend.wrap(entries, width) }
78
+ end
79
+
80
+ def multi_values!
81
+ values = @groups.flat_map(&:last)
82
+ return if values.all? { it >= 0 }
83
+
84
+ raise ArgumentError, "bars of several series need values of " \
85
+ "0 or more"
86
+ end
87
+
88
+ def multi_frame_options
89
+ multi_values!
90
+ unless @blocks == :eighths
91
+ raise ArgumentError, "blocks: #{@blocks} draws one series"
92
+ end
93
+ return multi_column_options if @orientation == :vertical
94
+
95
+ multi_row_options
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Columns of several series (MultiBars): side by side in each
5
+ # label's slot, or stacked into one column; the value axis runs
6
+ # from 0 to the largest value, or total.
7
+ module MultiBarColumns
8
+ private
9
+
10
+ def multi_column_options
11
+ body, slot, top = multi_columns
12
+ { body: body, border: :box, ascii: @ascii, color: @color_on,
13
+ title: @opts[:title],
14
+ ylabel: @opts[:ylabel] || column_value_label,
15
+ left: { (body.size - 1) => "0", 0 => Axis.label(top) },
16
+ **column_captions(body.size, slot) }
17
+ end
18
+
19
+ # The names under the columns and the legend: beside the frame,
20
+ # or, when it has more entries than rows and the names already
21
+ # hold the line under it, on the lines after the x label.
22
+ def column_captions(rows, slot)
23
+ names = group_names(slot)
24
+ legend = legend_options(rows)
25
+ xlabel = @opts[:xlabel]
26
+ if legend[:under] && names[:under]
27
+ xlabel = [xlabel, legend.delete(:under)].compact.join("\n")
28
+ end
29
+ { xlabel: xlabel, **legend, **names }
30
+ end
31
+
32
+ def multi_columns
33
+ @opts[:stacked] ? stacked_columns : grouped_columns
34
+ end
35
+
36
+ # [gap, column width]: a blank column between groups when the
37
+ # width allows, the rest shared between all the columns.
38
+ def group_geometry
39
+ g = @groups.size
40
+ n = series_count
41
+ gap = (g * n) + g - 1 <= width ? 1 : 0
42
+ [gap, [(width - ((g - 1) * gap)) / (g * n), 1].max]
43
+ end
44
+
45
+ def grouped_columns
46
+ gap, cw = group_geometry
47
+ max = @groups.flat_map(&:last).max
48
+ rows = Array.new(column_height) { [] }
49
+ @groups.each_with_index do |(_, vals), gi|
50
+ blank_columns(rows, gi.zero? ? 0 : gap)
51
+ vals.each_with_index do |v, i|
52
+ grouped_column(rows, v, i, [max, cw])
53
+ end
54
+ end
55
+ [rows, (series_count * cw) + gap, max]
56
+ end
57
+
58
+ def grouped_column(rows, value, index, scale)
59
+ max, cw = scale
60
+ fill = series_fill(index)
61
+ units = column_units(share(value, max.to_f), value, fill)
62
+ append_column(rows, units, cw, series_color(index), fill)
63
+ end
64
+
65
+ # Eighths of a cell for a █ series, whole cells for the others.
66
+ def column_units(fraction, value, fill)
67
+ units = rise_units(fraction, value)
68
+ eighths_for?(fill) ? units : ((units + 4) / 8) * 8
69
+ end
70
+
71
+ def stacked_columns
72
+ gap, cw = rise_geometry(@groups.size)
73
+ max = @groups.map { it.last.sum }.max
74
+ rows = Array.new(column_height) { [] }
75
+ @groups.each_with_index do |(_, vals), gi|
76
+ blank_columns(rows, gi.zero? ? 0 : gap)
77
+ stacked_column(rows, vals, max.to_f, cw)
78
+ end
79
+ [rows, cw + gap, max]
80
+ end
81
+
82
+ def stacked_column(rows, vals, max, cw)
83
+ heights = stack_heights(vals, max)
84
+ rows.each_with_index do |row, r|
85
+ level = (column_height - 1 - r) * 8
86
+ glyph = stack_cell(heights, level, vals.size)
87
+ row.concat(Array.new(cw) { glyph })
88
+ end
89
+ end
90
+
91
+ # [total, the cumulative top of each series], in eighths.
92
+ def stack_heights(vals, max)
93
+ ends = stack_ends(vals, max)
94
+ return [ends.last, ends] if fine?
95
+
96
+ top = series_fill(vals.size - 1)
97
+ [column_units(share(vals.sum, max), vals.sum, top), ends]
98
+ end
99
+
100
+ def stack_ends(vals, max)
101
+ vals.each_index.map do |i|
102
+ rise_units(share(vals[0..i].sum, max), vals[0..i].sum)
103
+ end
104
+ end
105
+
106
+ # Whole cells take the series at their middle; the top cell's
107
+ # eighths take the last series' colour.
108
+ def stack_cell(heights, level, count)
109
+ total, ends = heights
110
+ return Cell.blank unless total > level
111
+ return fine_cell(ends, level, ColumnBars::RISE) if fine?
112
+
113
+ if total < level + 8
114
+ return Cell.new(char: ColumnBars::RISE[total - level],
115
+ color: series_color(count - 1))
116
+ end
117
+
118
+ s = ends.index { level + 4 < it } || (count - 1)
119
+ Cell.new(char: series_fill(s), color: series_color(s))
120
+ end
121
+
122
+ def group_names(slot)
123
+ names = @groups.map { it.first.to_s }
124
+ return { bottom: [names.first, names.last] } unless
125
+ names.all? { Width.of(it) <= slot }
126
+
127
+ { under: names.map { Width.center(it, slot) }.join.rstrip }
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Stacked bars under color: true, to the eighth: where one series
5
+ # ends inside a cell and the next goes on, the cell is an eighths
6
+ # glyph (▏..▉ across, ▁..▇ up) inked in the first series' colour on
7
+ # the next one's background, so the boundary falls between whole
8
+ # cells no more. Without colour the two halves of such a cell could
9
+ # not be told apart, so the boundary stays on a whole cell there.
10
+ module MultiBarFine
11
+ private
12
+
13
+ def fine? = @color_on && @eighths
14
+
15
+ # A horizontal stacked row: cells to the total's last eighth.
16
+ def fine_row(vals, scale)
17
+ max, area, ew = scale
18
+ ends = fine_ends(vals, max, area)
19
+ full, rem = ends.last.divmod(8)
20
+ cells = (0...(full + (rem.zero? ? 0 : 1))).map do |col|
21
+ fine_cell(ends, col * 8, Bar::EIGHTHS)
22
+ end
23
+ finish_row(cells, [full, rem.zero? ? nil : true, area],
24
+ vals.sum, ew)
25
+ end
26
+
27
+ # Each series' cumulative end, in eighths of a cell.
28
+ def fine_ends(vals, max, area)
29
+ vals.each_index.map do |i|
30
+ (share(vals[0..i].sum, max) * area * 8).round
31
+ end
32
+ end
33
+
34
+ # The cell from eighth lo: the series covering lo, whole when it
35
+ # covers the cell, else split with the series after it.
36
+ def fine_cell(ends, low, glyphs)
37
+ a = ends.index { it > low }
38
+ return cell(series_fill(a), series_color(a)) if ends[a] >= low + 8
39
+
40
+ b = ends.index { it > ends[a] }
41
+ colors = b ? [series_color(a), series_color(b)] : series_color(a)
42
+ cell(glyphs[ends[a] - low], colors)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Horizontal bars of several series (MultiBars): grouped rows, a
5
+ # blank row between labels, or stacked rows labelled with totals.
6
+ module MultiBarRows
7
+ private
8
+
9
+ def multi_row_options
10
+ ew = effective_width(multi_labels.map(&:length).max)
11
+ area = [ew - 2 - multi_labels.map(&:length).max, 1].max
12
+ rows, left = multi_rows(area, ew)
13
+ { body: rows, border: @border, ascii: @ascii, color: @color_on,
14
+ title: @opts[:title], xlabel: xlabel, ylabel: @opts[:ylabel],
15
+ left: left, **legend_options(rows.size) }
16
+ end
17
+
18
+ def multi_rows(area, ew)
19
+ @opts[:stacked] ? stacked_rows(area, ew) : grouped_rows(area, ew)
20
+ end
21
+
22
+ # The value labels a bar ends with: each value, or each total.
23
+ def multi_labels
24
+ return @groups.map { Axis.label(it.last.sum) } if @opts[:stacked]
25
+
26
+ @groups.flat_map { |_, vals| vals.map { Axis.label(it) } }
27
+ end
28
+
29
+ def grouped_rows(area, ew)
30
+ scale = [@groups.flat_map(&:last).max.to_f, area, ew]
31
+ rows = []
32
+ left = {}
33
+ @groups.each_with_index do |(label, vals), g|
34
+ rows << pad_cells([], ew) if g.positive?
35
+ left[rows.size] = label.to_s
36
+ vals.each_with_index { |v, i| rows << series_row(v, i, scale) }
37
+ end
38
+ [rows, left]
39
+ end
40
+
41
+ def series_row(value, index, scale)
42
+ max, area, ew = scale
43
+ full, partial = series_glyphs(share(value, max), area, index)
44
+ color = series_color(index)
45
+ cells = Array.new(full) { cell(series_fill(index), color) }
46
+ cells << cell(partial, color) if partial
47
+ finish_row(cells, [full, partial, area], value, ew)
48
+ end
49
+
50
+ # The track after the bar, a blank, the value, padding.
51
+ def finish_row(cells, bar, value, ew)
52
+ cells += middle_cells(*bar)
53
+ pad_cells(cells + [Cell.blank] + label_cells(value), ew)
54
+ end
55
+
56
+ def series_glyphs(fraction, area, index)
57
+ return bar_glyphs(fraction, area) if
58
+ eighths_for?(series_fill(index))
59
+
60
+ [(fraction * area).round, nil]
61
+ end
62
+
63
+ def stacked_rows(area, ew)
64
+ scale = [@groups.map { it.last.sum }.max.to_f, area, ew]
65
+ rows = @groups.map do |_, vals|
66
+ fine? ? fine_row(vals, scale) : stacked_row(vals, scale)
67
+ end
68
+ [rows, @groups.each_with_index.to_h { |(l, _), i| [i, l.to_s] }]
69
+ end
70
+
71
+ # Each series runs to the cell its cumulative share reaches; the
72
+ # total's eighths end the bar in the last series' colour.
73
+ def stacked_row(vals, scale)
74
+ max, area, ew = scale
75
+ last = vals.size - 1
76
+ full, partial = series_glyphs(share(vals.sum, max), area, last)
77
+ ends = cumulative_ends(vals, max, area, full)
78
+ cells = (0...full).map { segment_cell(ends, it) }
79
+ cells << cell(partial, series_color(last)) if partial
80
+ finish_row(cells, [full, partial, area], vals.sum, ew)
81
+ end
82
+
83
+ def cumulative_ends(vals, max, area, full)
84
+ sums = vals.each_index.map { vals[0..it].sum }
85
+ sums.map { (share(it, max) * area).round.clamp(0, full) }
86
+ end
87
+
88
+ def segment_cell(ends, col)
89
+ s = ends.index { col < it } || (ends.size - 1)
90
+ cell(series_fill(s), series_color(s))
91
+ end
92
+ end
93
+ end