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