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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Axis extremes and number labels for a chart's scale (DESIGN.md).
|
|
5
|
+
module Axis
|
|
6
|
+
INT64_RANGE =
|
|
7
|
+
(-9_223_372_036_854_775_808..9_223_372_036_854_775_807)
|
|
8
|
+
|
|
9
|
+
# lim [a, b] given and not [0, 0]: returned as is; otherwise the
|
|
10
|
+
# data's extent widened to round values exactly as unicode_plot
|
|
11
|
+
# widens it (its axis labels are the oracle).
|
|
12
|
+
def self.limits(values, lim = nil)
|
|
13
|
+
lim ||= [0, 0]
|
|
14
|
+
auto = lim == [0, 0]
|
|
15
|
+
mi, ma = (auto ? values : lim).minmax.map(&:to_f)
|
|
16
|
+
mi, ma = widen(mi) if mi == ma
|
|
17
|
+
auto ? narrow(mi, ma) : [mi, ma]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Integer as is; Float in the shortest round-tripping form, no
|
|
21
|
+
# exponent for |x| in 1e-4...1e15 (Ruby's own Float#to_s already
|
|
22
|
+
# holds to that range, and keeps a Float's digits where an
|
|
23
|
+
# Integer input has none).
|
|
24
|
+
def self.label(number) = number.to_s
|
|
25
|
+
|
|
26
|
+
# An axis extreme: limits are Floats, and a whole one prints
|
|
27
|
+
# without its ".0" (10, not 10.0), as unicode_plot prints them;
|
|
28
|
+
# any other value as label does. For value labels, use label.
|
|
29
|
+
def self.tick(number)
|
|
30
|
+
number = number.to_i if number.is_a?(Float) && roundable?(number)
|
|
31
|
+
label(number)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# +-1, as unicode_plot widens a constant; from about 1e16 up a
|
|
35
|
+
# Float cannot hold mi +- 1, so the step grows by tens until it
|
|
36
|
+
# changes mi (unicode_plot crashes there instead).
|
|
37
|
+
def self.widen(mi)
|
|
38
|
+
d = 1.0
|
|
39
|
+
d *= 10 while mi + d == mi || mi - d == mi
|
|
40
|
+
[mi - d, mi + d]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.narrow(mi, ma)
|
|
44
|
+
diff = ma - mi
|
|
45
|
+
lo = round_subtick(mi, diff, :down)
|
|
46
|
+
hi = round_subtick(ma, diff, :up)
|
|
47
|
+
[lo.to_f, hi.to_f]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.round_subtick(x, m, dir)
|
|
51
|
+
return 0.0 if x.zero?
|
|
52
|
+
|
|
53
|
+
digits = neg_log10_digits(m)
|
|
54
|
+
up = dir == :up
|
|
55
|
+
if x.positive?
|
|
56
|
+
up ? x.ceil(digits) : x.floor(digits)
|
|
57
|
+
else
|
|
58
|
+
up ? -(-x).floor(digits) : -(-x).ceil(digits)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.neg_log10_digits(m)
|
|
63
|
+
v = -Math.log10(m)
|
|
64
|
+
roundable?(v) ? v.ceil + 1 : v.floor + 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.roundable?(x)
|
|
68
|
+
x.finite? && INT64_RANGE.cover?(x) && x.to_i == x
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Bitfield (ported byte for byte from the ascii-art skill's
|
|
5
|
+
# scripts/bitfield.rb): an RFC-style packet / bit-field diagram --
|
|
6
|
+
# a two-row bit ruler, "+-+" tick rows, and each field's name
|
|
7
|
+
# centred in its own cell. Pure ASCII always; RFC 2360's own
|
|
8
|
+
# convention, so there is no Unicode mode (no "ascii:" option).
|
|
9
|
+
#
|
|
10
|
+
# `fields` is either the script's "name:bits" text (# comments and
|
|
11
|
+
# blank lines skipped) or an Array of [name, bits] pairs. A field
|
|
12
|
+
# wider than the bits left in its row continues on the next
|
|
13
|
+
# row(s), the name repeated (or truncated under abbrev:) in each
|
|
14
|
+
# one, as RFC 791 draws its Options field.
|
|
15
|
+
module Bitfield
|
|
16
|
+
WORD_SIZES = [8, 16, 32, 64].freeze
|
|
17
|
+
FIELD_RE = /\A\s*([^:]+?)\s*:\s*(\d+)\s*\z/
|
|
18
|
+
|
|
19
|
+
Field = Struct.new(:name, :bits, :line)
|
|
20
|
+
RowState = Struct.new(:cursor, :row, :rows)
|
|
21
|
+
|
|
22
|
+
def self.block(fields, word:, octets:, ruler:, abbrev:)
|
|
23
|
+
word!(word)
|
|
24
|
+
rows = layout_rows(ascii_names!(fields_for(fields)), word, abbrev)
|
|
25
|
+
show_ruler = ruler && !octets
|
|
26
|
+
Block.new(render(rows, word, show_ruler, octets))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.word!(word)
|
|
30
|
+
return word if WORD_SIZES.include?(word)
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "word must be 8, 16, 32 or 64: " \
|
|
33
|
+
"#{word.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The script's own text format, or the fields already given.
|
|
37
|
+
# A bit-field is drawn in ASCII only, so its names must be.
|
|
38
|
+
def self.ascii_names!(fields)
|
|
39
|
+
fields.each_with_index do |f, i|
|
|
40
|
+
next if f.name.ascii_only?
|
|
41
|
+
|
|
42
|
+
where = f.line ? "line #{f.line}" : "field #{i + 1}"
|
|
43
|
+
raise ArgumentError, "#{where}: name #{f.name.inspect} is " \
|
|
44
|
+
"non-ASCII (a bit-field is ASCII)"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.fields_for(fields)
|
|
49
|
+
return parse(fields) if fields.is_a?(String)
|
|
50
|
+
|
|
51
|
+
fields.each_with_index.map { |(name, bits), i| pair(name, bits, i) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.pair(name, bits, index)
|
|
55
|
+
unless bits.is_a?(Integer) && bits.positive?
|
|
56
|
+
raise ArgumentError, "field #{index + 1} (#{name.inspect}): " \
|
|
57
|
+
"bits must be a positive Integer: " \
|
|
58
|
+
"#{bits.inspect}"
|
|
59
|
+
end
|
|
60
|
+
Field.new(name.to_s, bits, nil)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Parsing the "name:bits" text format: one field per line, "#"
|
|
65
|
+
# comments and blank lines skipped.
|
|
66
|
+
module Bitfield
|
|
67
|
+
def self.parse(text)
|
|
68
|
+
fields = text.each_line.with_index(1).filter_map do |raw, lineno|
|
|
69
|
+
line = raw.chomp
|
|
70
|
+
next if line.strip.empty? || line.lstrip.start_with?("#")
|
|
71
|
+
|
|
72
|
+
parse_line(line, lineno)
|
|
73
|
+
end
|
|
74
|
+
raise ArgumentError, "no fields given" if fields.empty?
|
|
75
|
+
|
|
76
|
+
fields
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.parse_line(line, lineno)
|
|
80
|
+
m = FIELD_RE.match(line)
|
|
81
|
+
unless m
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"line #{lineno}: #{line.inspect} not name:bits"
|
|
84
|
+
end
|
|
85
|
+
Field.new(m[1], bits_for(m[2], line, lineno), lineno)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.bits_for(digits, line, lineno)
|
|
89
|
+
bits = digits.to_i
|
|
90
|
+
return bits unless bits.zero?
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "line #{lineno}: #{line.inspect} has 0 bits"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Laying fields into rows of word: bits each, a field that outgrows
|
|
97
|
+
# its row continuing on the next (RowState tracks the row being
|
|
98
|
+
# filled, and the rows closed so far).
|
|
99
|
+
module Bitfield
|
|
100
|
+
# A name that already fits returns as-is; one too wide is bad
|
|
101
|
+
# input unless abbrev truncates it to width - 1 characters plus
|
|
102
|
+
# a dot.
|
|
103
|
+
def self.fit_label(name, width, abbrev)
|
|
104
|
+
return name if name.length <= width
|
|
105
|
+
unless abbrev
|
|
106
|
+
raise ArgumentError, "name #{name.inspect} needs " \
|
|
107
|
+
"#{name.length} columns, this cell " \
|
|
108
|
+
"only has #{width}"
|
|
109
|
+
end
|
|
110
|
+
"#{name[0, width - 1]}."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.close_row(state, word)
|
|
114
|
+
return unless state.cursor == word
|
|
115
|
+
|
|
116
|
+
state.rows << state.row
|
|
117
|
+
state.row = []
|
|
118
|
+
state.cursor = 0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.place_field(field, word, abbrev, state)
|
|
122
|
+
remaining = field.bits
|
|
123
|
+
until remaining.zero?
|
|
124
|
+
take = [remaining, word - state.cursor].min
|
|
125
|
+
label = fit_label(field.name, (2 * take) - 1, abbrev)
|
|
126
|
+
state.row << { bits: take, label: label }
|
|
127
|
+
state.cursor += take
|
|
128
|
+
remaining -= take
|
|
129
|
+
close_row(state, word)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.layout_rows(fields, word, abbrev)
|
|
134
|
+
state = RowState.new(0, [], [])
|
|
135
|
+
fields.each { place_field(it, word, abbrev, state) }
|
|
136
|
+
state.rows << state.row unless state.row.empty?
|
|
137
|
+
state.rows
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.row_bits(row) = row.sum { it[:bits] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Rendering rows into text lines: the bit ruler, a tick line per
|
|
144
|
+
# row (and a closing one), and each row's content line.
|
|
145
|
+
module Bitfield
|
|
146
|
+
def self.tick_line(bits, step)
|
|
147
|
+
positions = (0..bits).step(step).to_a
|
|
148
|
+
positions << bits if positions.last != bits
|
|
149
|
+
line = +"+"
|
|
150
|
+
positions.each_cons(2) do |p0, p1|
|
|
151
|
+
line << ("-" * ((2 * (p1 - p0)) - 1)) << "+"
|
|
152
|
+
end
|
|
153
|
+
line
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def self.ruler_lines(word)
|
|
157
|
+
tens = +""
|
|
158
|
+
units = +""
|
|
159
|
+
word.times do |i|
|
|
160
|
+
tens << " " << ((i % 10).zero? ? (i / 10).to_s : " ")
|
|
161
|
+
units << " " << (i % 10).to_s
|
|
162
|
+
end
|
|
163
|
+
[tens.rstrip, units.rstrip]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.content_line(segments)
|
|
167
|
+
line = +"|"
|
|
168
|
+
segments.each do |seg|
|
|
169
|
+
line << Width.center(seg[:label], (2 * seg[:bits]) - 1) << "|"
|
|
170
|
+
end
|
|
171
|
+
line
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A tick line between two rows spans the wider of them, so a
|
|
175
|
+
# short last row does not leave the row above it open.
|
|
176
|
+
def self.render(rows, word, show_ruler, octets)
|
|
177
|
+
step = octets ? 8 : 1
|
|
178
|
+
lines = show_ruler ? ruler_lines(word) : []
|
|
179
|
+
above = 0
|
|
180
|
+
rows.each do |row|
|
|
181
|
+
lines << tick_line([above, row_bits(row)].max, step)
|
|
182
|
+
lines << content_line(row)
|
|
183
|
+
above = row_bits(row)
|
|
184
|
+
end
|
|
185
|
+
lines << tick_line(above, step)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def self.bitfield(fields, word: 32, octets: false, ruler: true,
|
|
190
|
+
abbrev: false)
|
|
191
|
+
Bitfield.block(fields, word: word, octets: octets, ruler: ruler,
|
|
192
|
+
abbrev: abbrev)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A finished figure as lines of text: what every module returns and
|
|
5
|
+
# what composition takes (DESIGN.md, "Blocks and composition"). A
|
|
6
|
+
# block is never rewritten, only padded on the right; its trailing
|
|
7
|
+
# blank lines are dropped, a tab is refused (its width is up to the
|
|
8
|
+
# viewer), and so is a block with no line left.
|
|
9
|
+
class Block
|
|
10
|
+
attr_reader :lines
|
|
11
|
+
|
|
12
|
+
# A Block, a String, or anything whose to_s is the figure (a
|
|
13
|
+
# chart): the composition functions take any of them.
|
|
14
|
+
def self.from(figure)
|
|
15
|
+
figure.is_a?(Block) ? figure : new(figure.to_s.lines(chomp: true))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(lines)
|
|
19
|
+
lines = lines.map(&:to_s)
|
|
20
|
+
lines.pop while lines.last&.strip&.empty?
|
|
21
|
+
raise ArgumentError, "empty block" if lines.empty?
|
|
22
|
+
|
|
23
|
+
check_tabs!(lines)
|
|
24
|
+
@lines = lines.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def width = @lines.map { Width.of(it) }.max
|
|
29
|
+
def height = @lines.size
|
|
30
|
+
|
|
31
|
+
# Every line without its trailing blanks, each ended by "\n";
|
|
32
|
+
# under target :web, emoji-capable glyphs kept as text (Target).
|
|
33
|
+
def to_s = Target.adapt(@lines.map { "#{it.rstrip}\n" }.join)
|
|
34
|
+
|
|
35
|
+
def ascii? = @lines.all?(&:ascii_only?)
|
|
36
|
+
|
|
37
|
+
# The first line over max columns raises, naming its width.
|
|
38
|
+
def check_width!(max)
|
|
39
|
+
@lines.each_with_index do |line, i|
|
|
40
|
+
w = Width.of(line.rstrip)
|
|
41
|
+
next if w <= max
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "output is #{w} columns on line " \
|
|
44
|
+
"#{i + 1} (max-width #{max})"
|
|
45
|
+
end
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def check_tabs!(lines)
|
|
52
|
+
i = lines.index { it.include?("\t") }
|
|
53
|
+
raise ArgumentError, "line #{i + 1}: tab character" if i
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# The border glyph sets every framed figure draws with (a frame, a
|
|
5
|
+
# table): corners, a horizontal and a vertical run. Under ascii:
|
|
6
|
+
# true the set is "ascii" from the first byte; asking for another
|
|
7
|
+
# one with it is refused rather than silently overridden.
|
|
8
|
+
#
|
|
9
|
+
# Besides the named sets, a custom one: a Set, or a String of six
|
|
10
|
+
# glyphs in Set order (tl tr bl br h v), or of eleven for a table,
|
|
11
|
+
# whose five more are its tees (top_t bot_t sep_l sep_t sep_r:
|
|
12
|
+
# down from the top edge, up from the bottom one, and the left
|
|
13
|
+
# tee, cross and right tee of a separator row). Each glyph is one
|
|
14
|
+
# visible column; under ascii: true each must be ASCII.
|
|
15
|
+
module Borders
|
|
16
|
+
Set = Data.define(:tl, :tr, :bl, :br, :h, :v)
|
|
17
|
+
|
|
18
|
+
STYLES = {
|
|
19
|
+
light: Set.new("┌", "┐", "└", "┘", "─", "│"),
|
|
20
|
+
heavy: Set.new("┏", "┓", "┗", "┛", "━", "┃"),
|
|
21
|
+
double: Set.new("╔", "╗", "╚", "╝", "═", "║"),
|
|
22
|
+
rounded: Set.new("╭", "╮", "╰", "╯", "─", "│"),
|
|
23
|
+
dashed: Set.new("┌", "┐", "└", "┘", "┄", "┆"),
|
|
24
|
+
dashed_fine: Set.new("┌", "┐", "└", "┘", "┈", "┊"),
|
|
25
|
+
heavy_dashed: Set.new("┏", "┓", "┗", "┛", "┅", "┇"),
|
|
26
|
+
heavy_dashed_fine: Set.new("┏", "┓", "┗", "┛", "┉", "┋"),
|
|
27
|
+
double_h: Set.new("╒", "╕", "╘", "╛", "═", "│"),
|
|
28
|
+
double_v: Set.new("╓", "╖", "╙", "╜", "─", "║"),
|
|
29
|
+
ascii: Set.new("+", "+", "+", "+", "-", "|")
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
TEE_KEYS = %i[top_t bot_t sep_l sep_t sep_r].freeze
|
|
33
|
+
|
|
34
|
+
def self.fetch(style, ascii: false)
|
|
35
|
+
return custom(style, ascii: ascii).first if custom?(style)
|
|
36
|
+
|
|
37
|
+
name = Borders.name(style || (ascii ? :ascii : :light))
|
|
38
|
+
if ascii && name != :ascii
|
|
39
|
+
raise ArgumentError, "style #{name} conflicts with ascii: true"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
STYLES.fetch(name) do
|
|
43
|
+
raise ArgumentError, "unknown style: #{name.inspect}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A style's name as a Symbol: "double-h" and :double_h alike.
|
|
48
|
+
def self.name(style) = style.to_s.tr("-", "_").to_sym
|
|
49
|
+
|
|
50
|
+
# A Set, or a String that names no style: a glyph set.
|
|
51
|
+
def self.custom?(style)
|
|
52
|
+
style.is_a?(Set) ||
|
|
53
|
+
(style.is_a?(String) && !STYLES.key?(name(style)) &&
|
|
54
|
+
!style.match?(/\A[a-z_-]+\z/))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# [Set, tees Hash or nil] for a custom style, checked.
|
|
58
|
+
def self.custom(style, ascii: false)
|
|
59
|
+
glyphs = style.is_a?(Set) ? style.to_h.values : style.chars
|
|
60
|
+
unless [6, 11].include?(glyphs.size)
|
|
61
|
+
raise ArgumentError, "a border set is 6 glyphs (tl tr bl br " \
|
|
62
|
+
"h v), or 11 with a table's tees: " \
|
|
63
|
+
"#{style.inspect}"
|
|
64
|
+
end
|
|
65
|
+
glyphs.each { check_glyph!(it, ascii) }
|
|
66
|
+
[Set.new(*glyphs.first(6)), tees(glyphs)]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.tees(glyphs)
|
|
70
|
+
glyphs.size == 11 ? TEE_KEYS.zip(glyphs.last(5)).to_h : nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.check_glyph!(glyph, ascii)
|
|
74
|
+
unless glyph.is_a?(String) && glyph.length == 1 &&
|
|
75
|
+
Width.of(glyph) == 1 && glyph.match?(/\A[[:graph:]]\z/)
|
|
76
|
+
raise ArgumentError, "a border glyph is one visible " \
|
|
77
|
+
"column: #{glyph.inspect}"
|
|
78
|
+
end
|
|
79
|
+
return unless ascii && !glyph.ascii_only?
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "border glyph #{glyph} conflicts with " \
|
|
82
|
+
"ascii: true"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
class Canvas
|
|
5
|
+
# An area under a curve (a line chart's area: true), as btop
|
|
6
|
+
# draws its graphs: every pixel column the series spans is set
|
|
7
|
+
# from the canvas floor up to the curve, so a braille cell holds
|
|
8
|
+
# two independent bars of 0..4 dots and a block cell two of
|
|
9
|
+
# 0..2. A column's height is the curve's value at its centre,
|
|
10
|
+
# or the highest point falling inside it, so a peak narrower
|
|
11
|
+
# than a column is not lost.
|
|
12
|
+
module Area
|
|
13
|
+
# color: one colour name for every cell, or a callable
|
|
14
|
+
# taking a cell row (0 at the top) and returning one.
|
|
15
|
+
# across: true fills each pixel row instead, from the left
|
|
16
|
+
# edge out to the curve (x the value, y the position), the
|
|
17
|
+
# callable then taking a cell column.
|
|
18
|
+
def area(xs, ys, color: nil, across: false)
|
|
19
|
+
return area_across(xs, ys, color) if across
|
|
20
|
+
|
|
21
|
+
(0...pixel_width).each do |px|
|
|
22
|
+
value = column_value(xs, ys, px)
|
|
23
|
+
fill_column(px, column_top(value), color) if value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The value at the middle of a cell row (0 the top) or column.
|
|
28
|
+
def cell_row_value(row)
|
|
29
|
+
@yhi - ((row + 0.5) * (@yhi - @ylo) / height)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cell_column_value(col)
|
|
33
|
+
@xlo + ((col + 0.5) * (@xhi - @xlo) / width)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A dashed line in data coordinates: every other pixel set.
|
|
37
|
+
def dashed(x0, y0, x1, y1, color: nil)
|
|
38
|
+
from = to_pixel(x0, y0).map(&:floor)
|
|
39
|
+
to = to_pixel(x1, y1).map(&:floor)
|
|
40
|
+
steps = from.zip(to).map { |a, b| (b - a).abs }.max
|
|
41
|
+
(0..steps).step(2) do |i|
|
|
42
|
+
t = steps.zero? ? 0 : i.fdiv(steps)
|
|
43
|
+
draw_pixel(*between(from, to, t), color)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The pixel t (0..1) of the way from one pixel to another.
|
|
48
|
+
def between(from, to, t)
|
|
49
|
+
from.zip(to).map { |a, b| (a + ((b - a) * t)).round }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A bar along pixel row py (0 the top), length pixels from
|
|
53
|
+
# the left edge: a horizontal bar chart's bar on a canvas.
|
|
54
|
+
def row_bar(py, length, color: nil)
|
|
55
|
+
length.clamp(0, pixel_width).times do |px|
|
|
56
|
+
set_sub_pixel(px, py, color)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A bar up pixel column px, height pixels from the floor: a
|
|
61
|
+
# column chart's column on a canvas.
|
|
62
|
+
def column_bar(px, height, color: nil)
|
|
63
|
+
((pixel_height - height.clamp(0, pixel_height))...pixel_height)
|
|
64
|
+
.each { set_sub_pixel(px, it, color) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def area_across(xs, ys, color)
|
|
70
|
+
(0...pixel_height).each do |py|
|
|
71
|
+
value = row_value(xs, ys, py)
|
|
72
|
+
next unless value
|
|
73
|
+
|
|
74
|
+
share = (value - @xlo).fdiv(@xhi - @xlo).clamp(0.0, 1.0)
|
|
75
|
+
(share * pixel_width).round.times do |px|
|
|
76
|
+
set_sub_pixel(px, py, shade_across(color, px, py))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The value at a pixel row: the curve at its centre (x as a
|
|
82
|
+
# function of y), or the highest x of a point inside it.
|
|
83
|
+
def row_value(xs, ys, py)
|
|
84
|
+
lo, hi = row_span(py)
|
|
85
|
+
inside = ys.each_index.select { ys[it] > lo && ys[it] <= hi }
|
|
86
|
+
[*inside.map { xs[it] }, curve_at(ys, xs, (lo + hi) / 2.0)]
|
|
87
|
+
.compact.max
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def row_span(py)
|
|
91
|
+
step = (@yhi - @ylo).fdiv(pixel_height)
|
|
92
|
+
hi = @yhi - (py * step)
|
|
93
|
+
[hi - step, hi]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A callable of (row, col), as threshold: gives, or of the
|
|
97
|
+
# one coordinate a gradient runs along.
|
|
98
|
+
def shade_across(color, px, py)
|
|
99
|
+
return color unless color.respond_to?(:call)
|
|
100
|
+
return cell_color(color, px, py) if color.arity == 2
|
|
101
|
+
|
|
102
|
+
color.call(px / x_pixel_per_char)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def column_value(xs, ys, px)
|
|
106
|
+
lo, hi = column_span(px)
|
|
107
|
+
inside = xs.each_index.select { xs[it] >= lo && xs[it] < hi }
|
|
108
|
+
[*inside.map { ys[it] }, curve_at(xs, ys, (lo + hi) / 2.0)]
|
|
109
|
+
.compact.max
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def column_span(px)
|
|
113
|
+
step = (@xhi - @xlo).fdiv(pixel_width)
|
|
114
|
+
[@xlo + (px * step), @xlo + ((px + 1) * step)]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The highest y any segment of the path takes at x, or nil
|
|
118
|
+
# where the path does not reach.
|
|
119
|
+
def curve_at(xs, ys, x)
|
|
120
|
+
(0...(xs.size - 1)).filter_map do |i|
|
|
121
|
+
segment_at(xs[i], ys[i], xs[i + 1], ys[i + 1], x)
|
|
122
|
+
end.max
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def segment_at(x0, y0, x1, y1, x)
|
|
126
|
+
return nil unless x.between?(*[x0, x1].minmax)
|
|
127
|
+
return [y0, y1].max if x0 == x1
|
|
128
|
+
|
|
129
|
+
y0 + ((y1 - y0) * (x - x0).fdiv(x1 - x0))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The first pixel row filled: the column's height in pixels,
|
|
133
|
+
# rounded, counted down from the top.
|
|
134
|
+
def column_top(value)
|
|
135
|
+
share = (value - @ylo).fdiv(@yhi - @ylo).clamp(0.0, 1.0)
|
|
136
|
+
pixel_height - (share * pixel_height).round
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def fill_column(px, top, color)
|
|
140
|
+
(top...pixel_height).each do |py|
|
|
141
|
+
set_sub_pixel(px, py, shade(color, px, py))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def shade(color, px, py)
|
|
146
|
+
return color unless color.respond_to?(:call)
|
|
147
|
+
return cell_color(color, px, py) if color.arity == 2
|
|
148
|
+
|
|
149
|
+
color.call(py / y_pixel_per_char)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
include Area
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
class Canvas
|
|
5
|
+
# 3x3 sub-pixels per cell, drawn with ASCII only. There is no
|
|
6
|
+
# Unicode or ASCII standard for "this 3x3 dot pattern means
|
|
7
|
+
# this character" the way there is for braille dots or block
|
|
8
|
+
# quadrants, so the glyph follows an original rule, by where
|
|
9
|
+
# the set sub-pixels lie (D2): one populated row reads as a
|
|
10
|
+
# mark along that row (top: ` left, ' centre or right, " for
|
|
11
|
+
# two or more; middle: -; bottom: . one, _ for two or more);
|
|
12
|
+
# a full middle row with pixels elsewhere is still - unless
|
|
13
|
+
# some column is also full, which (with a full row) is a
|
|
14
|
+
# crossing; a column two- or three-deep with nothing in the
|
|
15
|
+
# other columns is |; a dominant diagonal is / or \; a
|
|
16
|
+
# crossing (both diagonals, or a full row and a full column)
|
|
17
|
+
# is +; anything else falls back to whichever of - or | its
|
|
18
|
+
# coverage resembles more. # overrides all of the above once
|
|
19
|
+
# 7 or more of the 9 sub-pixels are set: by then a full row
|
|
20
|
+
# and a full column always coexist (pigeonhole: 3 rows summing
|
|
21
|
+
# to >= 7 force one to be full, and likewise for columns), so
|
|
22
|
+
# "dense" and "a crossing" are never really distinct cases --
|
|
23
|
+
# density is checked first and reads as solid ink, not a plus
|
|
24
|
+
# sign. bits index as sy * SIZE + sx (top row first).
|
|
25
|
+
class Ascii < Canvas
|
|
26
|
+
SIZE = 3
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def x_pixel_per_char = SIZE
|
|
31
|
+
def y_pixel_per_char = SIZE
|
|
32
|
+
|
|
33
|
+
def encode(code, sub_x, sub_y)
|
|
34
|
+
code | (1 << ((sub_y * SIZE) + sub_x))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def decode(code)
|
|
38
|
+
return " " if code.zero?
|
|
39
|
+
glyph_for(code)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def glyph_for(code)
|
|
43
|
+
rows = row_counts(code)
|
|
44
|
+
cols = col_counts(code)
|
|
45
|
+
row_only_glyph(code, rows) ||
|
|
46
|
+
full_middle_glyph(rows, cols) || column_glyph(cols) ||
|
|
47
|
+
dense_glyph(code) ||
|
|
48
|
+
diag_or_cross_glyph(code, rows, cols) ||
|
|
49
|
+
fallback_glyph(rows, cols)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dense_glyph(code)
|
|
53
|
+
"#" if (0...9).count { code[it] == 1 } >= 7
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def row_counts(code)
|
|
57
|
+
(0..2).map { |r| (0..2).count { code[(r * SIZE) + it] == 1 } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def col_counts(code)
|
|
61
|
+
(0..2).map { |c| (0..2).count { code[(it * SIZE) + c] == 1 } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def only_row?(rows, idx)
|
|
65
|
+
others = (0..2).to_a - [idx]
|
|
66
|
+
rows[idx].positive? && others.all? { rows[it].zero? }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def row_only_glyph(code, rows)
|
|
70
|
+
return top_row_glyph(code) if only_row?(rows, 0)
|
|
71
|
+
return bottom_row_glyph(code) if only_row?(rows, 2)
|
|
72
|
+
"-" if only_row?(rows, 1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def top_row_glyph(code)
|
|
76
|
+
set = (0..2).select { code[it] == 1 }
|
|
77
|
+
return "\"" if set.size >= 2
|
|
78
|
+
set.first.zero? ? "`" : "'"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def bottom_row_glyph(code)
|
|
82
|
+
(0..2).count { code[6 + it] == 1 } >= 2 ? "_" : "."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def full_middle_glyph(rows, cols)
|
|
86
|
+
return nil unless rows[1] == 3
|
|
87
|
+
"-" unless cols.any? { it == 3 }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def column_glyph(cols)
|
|
91
|
+
idx = (0..2).find { cols[it] >= 2 }
|
|
92
|
+
return nil unless idx
|
|
93
|
+
"|" if (0..2).all? { it == idx || cols[it].zero? }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def diagonal_counts(code)
|
|
97
|
+
[[6, 4, 2].count { code[it] == 1 },
|
|
98
|
+
[0, 4, 8].count { code[it] == 1 }]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def crossing?(rising, falling, rows, cols)
|
|
102
|
+
(rising >= 2 && falling >= 2) ||
|
|
103
|
+
(rows.any? { it == 3 } && cols.any? { it == 3 })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def diag_or_cross_glyph(code, rows, cols)
|
|
107
|
+
rising, falling = diagonal_counts(code)
|
|
108
|
+
return "+" if crossing?(rising, falling, rows, cols)
|
|
109
|
+
return "/" if rising >= 2 && rising > falling
|
|
110
|
+
"\\" if falling >= 2 && falling > rising
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fallback_glyph(rows, cols)
|
|
114
|
+
rows.max >= cols.max ? "-" : "|"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
register(:ascii, Ascii)
|
|
119
|
+
end
|
|
120
|
+
end
|