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