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,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A pie, or with donut: true a ring (DESIGN.md, "Chart and kinds"):
|
|
5
|
+
# labels and values as slices clockwise from the top, drawn on
|
|
6
|
+
# character cells twice as wide as tall, so the disc reads round in
|
|
7
|
+
# a terminal. Slices are told apart by fill -- █ ▓ ▒ ░ ╳ ╱ ╲ ∴ ● ○ ■
|
|
8
|
+
# ·, one width class, or # = + @ * : . o % & ~ x in ASCII, so at
|
|
9
|
+
# most 12 slices -- and, under color: true, by colour. The legend
|
|
10
|
+
# right of the disc gives each slice's share, or under it, wrapped
|
|
11
|
+
# to 40 columns or the disc's width, when the slices outnumber the
|
|
12
|
+
# rows. height: (12 by default) sets the size, so width: is
|
|
13
|
+
# refused. Values must be 0 or more, and not all 0. rim: :smooth
|
|
14
|
+
# draws the edge to the shape (PieRim).
|
|
15
|
+
class Pie < Chart
|
|
16
|
+
include PieRim
|
|
17
|
+
|
|
18
|
+
OPTIONS = (Chart::OPTIONS - %i[width xlim ylim canvas xscale] +
|
|
19
|
+
%i[donut rim]).freeze
|
|
20
|
+
HOLE = 0.5
|
|
21
|
+
LEGEND = 40
|
|
22
|
+
|
|
23
|
+
def add(labels, values)
|
|
24
|
+
@labels = Array(labels).map(&:to_s)
|
|
25
|
+
@values = Check.finite!(Array(values), "value")
|
|
26
|
+
Check.same_size!(@labels, @values, %w[labels values])
|
|
27
|
+
values!
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def values!
|
|
34
|
+
unless @values.all? { it >= 0 } && @values.sum.positive?
|
|
35
|
+
raise ValueError, "pie values must be 0 or more, not all 0"
|
|
36
|
+
end
|
|
37
|
+
Legend.fills!(@values.size, "slices")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def default_border = :none
|
|
41
|
+
def height = @opts.fetch(:height, 12)
|
|
42
|
+
def fill(i) = (@ascii ? Legend::ASCII_FILLS : Legend::FILLS)[i]
|
|
43
|
+
def slice_color(i) = Color::NAMES[i % Color::NAMES.size]
|
|
44
|
+
|
|
45
|
+
# Where each slice ends, as a share of the turn.
|
|
46
|
+
def ends
|
|
47
|
+
total = @values.sum.to_f
|
|
48
|
+
@values.each_index.map { @values[0..it].sum / total }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def body
|
|
52
|
+
w = height * 2
|
|
53
|
+
stops = ends
|
|
54
|
+
(0...height).map do |y|
|
|
55
|
+
(0...w).map { |x| disc_cell(x, y, w, stops) }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def disc_cell(x, y, w, stops)
|
|
60
|
+
dx = offset(x, w)
|
|
61
|
+
dy = offset(y, height)
|
|
62
|
+
i = slice_at(turn(dx, dy), stops)
|
|
63
|
+
cell = rim? ? rim_cell(x, y, w, i) : whole_cell(dx, dy)
|
|
64
|
+
cell || Cell.new(char: fill(i), color: slice_color(i))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A cell in or out by its centre: blank, or nil for its fill.
|
|
68
|
+
def whole_cell(dx, dy)
|
|
69
|
+
r = Math.hypot(dx, dy)
|
|
70
|
+
Cell.blank if r > 1 || (@opts[:donut] && r < HOLE)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A cell's centre from the disc's, in radii.
|
|
74
|
+
def offset(cell, cells) = (cell + 0.5 - (cells / 2.0)) / (cells / 2.0)
|
|
75
|
+
|
|
76
|
+
# 0 at the top, clockwise, as a share of the turn.
|
|
77
|
+
def turn(dx, dy) = (Math.atan2(dx, -dy) / (2 * Math::PI)) % 1.0
|
|
78
|
+
|
|
79
|
+
def slice_at(share, stops)
|
|
80
|
+
stops.index { share < it } || (stops.size - 1)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def frame
|
|
84
|
+
Frame.new(body: body, border: @border, ascii: @ascii,
|
|
85
|
+
color: @color_on, title: @opts[:title],
|
|
86
|
+
xlabel: @opts[:xlabel], ylabel: @opts[:ylabel],
|
|
87
|
+
**legend)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The legend beside the disc, centred, or under it, wrapped,
|
|
91
|
+
# when there are more slices than rows.
|
|
92
|
+
def legend
|
|
93
|
+
rows = legend_entries
|
|
94
|
+
return { under: Legend.wrap(rows, [height * 2, LEGEND].max) } if
|
|
95
|
+
rows.size > height
|
|
96
|
+
|
|
97
|
+
first = (height - rows.size) / 2
|
|
98
|
+
{ right: rows.each_with_index.to_h { |text, i| [first + i, text] } }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def legend_entries
|
|
102
|
+
percents = shares
|
|
103
|
+
@labels.each_with_index.map do |label, i|
|
|
104
|
+
"#{fill(i)} #{label} #{percents[i]} %"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whole percents summing to 100: floors, the rest handed out by
|
|
109
|
+
# largest remainder.
|
|
110
|
+
def shares
|
|
111
|
+
exact = @values.map { it * 100.0 / @values.sum }
|
|
112
|
+
whole = exact.map(&:floor)
|
|
113
|
+
order = exact.each_index.sort_by { whole[it] - exact[it] }
|
|
114
|
+
order.first(100 - whole.sum).each { whole[it] += 1 }
|
|
115
|
+
whole
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# (labels, values, donut: false, **opts): a new Pie.
|
|
120
|
+
def self.pie(labels, values, **)
|
|
121
|
+
Pie.new(**).add(labels, values)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A pie's edge drawn to the shape (rim: :smooth or :basic): a cell
|
|
5
|
+
# the disc (or a donut's hole) only partly covers takes the glyph
|
|
6
|
+
# whose shape best fits that coverage, sampled as 4x8 -- ◢ ◣ ◤ ◥,
|
|
7
|
+
# halves, quadrants, quarter and eighth blocks, Image's petscii
|
|
8
|
+
# shapes -- in its slice's colour. The glyph is solid ink, so
|
|
9
|
+
# without colour the rim reads as an outline rather than as its
|
|
10
|
+
# slice's fill. :smooth adds Legacy Computing's 🮊 🮂 🮅, which
|
|
11
|
+
# older fonts lack; :basic keeps to Block Elements and Geometric
|
|
12
|
+
# Shapes. rim: :cells, the default, fills whole cells.
|
|
13
|
+
module PieRim
|
|
14
|
+
RIMS = %i[cells smooth basic].freeze
|
|
15
|
+
BASIC = %w[█ ▀ ▄ ▌ ▐ ▘ ▝ ▖ ▗ ▚ ▞ ▙ ▛ ▜ ▟ ▏ ▕ ▔ ▁ ▊ ▂ ▆
|
|
16
|
+
◢ ◣ ◤ ◥].freeze
|
|
17
|
+
LEGACY = %W[\u{1FB8A} \u{1FB82} \u{1FB85}].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(**opts)
|
|
20
|
+
super
|
|
21
|
+
rim!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rim = Borders.name(@opts.fetch(:rim, :cells))
|
|
27
|
+
def rim? = rim != :cells
|
|
28
|
+
|
|
29
|
+
def rim!
|
|
30
|
+
unless RIMS.include?(rim)
|
|
31
|
+
raise ArgumentError, "rim must be one of #{RIMS.join(", ")}: " \
|
|
32
|
+
"#{@opts[:rim].inspect}"
|
|
33
|
+
end
|
|
34
|
+
raise ArgumentError, "rim: #{rim} needs Unicode" if rim? && @ascii
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The cell as the rim draws it: blank, a shape, or nil when the
|
|
38
|
+
# disc covers it whole (its slice's fill then).
|
|
39
|
+
def rim_cell(x, y, w, slice)
|
|
40
|
+
cover = coverage(x, y, w)
|
|
41
|
+
return Cell.blank if cover.sum.zero?
|
|
42
|
+
return nil if cover.sum == cover.size
|
|
43
|
+
|
|
44
|
+
Cell.new(char: rim_glyph(cover), color: slice_color(slice))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rim_glyph(cover)
|
|
48
|
+
rim_shapes.min_by do |_, ink|
|
|
49
|
+
ink.each_with_index.sum { |v, k| (v - cover[k])**2 }
|
|
50
|
+
end.first
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def rim_shapes
|
|
54
|
+
@rim_shapes ||= Image::PETSCII.slice(
|
|
55
|
+
*BASIC, *(rim == :smooth ? LEGACY : [])
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 1.0 or 0.0 for each of the cell's 4x8 samples, row by row.
|
|
60
|
+
def coverage(x, y, w)
|
|
61
|
+
sw, sh = Image::PETSCII_SUB
|
|
62
|
+
(0...sh).flat_map do |sy|
|
|
63
|
+
(0...sw).map do |sx|
|
|
64
|
+
disc_ink(sample(x, sx, sw, w), sample(y, sy, sh, height))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def sample(cell, sub, subs, cells)
|
|
70
|
+
((cell + ((sub + 0.5) / subs)) - (cells / 2.0)) / (cells / 2.0)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def disc_ink(dx, dy)
|
|
74
|
+
r = Math.hypot(dx, dy)
|
|
75
|
+
r <= 1 && !(@opts[:donut] && r < Pie::HOLE) ? 1.0 : 0.0
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# orientation: for the plotted kinds (line, scatter, stairs,
|
|
5
|
+
# density): :vertical, the default, puts values up the vertical
|
|
6
|
+
# axis; :horizontal swaps the axes, values along the horizontal one
|
|
7
|
+
# and x running up. Each series is stored swapped, so everything
|
|
8
|
+
# drawing it works unchanged; xlim:, ylim:, xlabel: and ylabel:
|
|
9
|
+
# keep their meaning and follow their axis. A kind with a floor at
|
|
10
|
+
# zero (an area) names it with zero_floor?, and the value axis
|
|
11
|
+
# takes in 0 whichever way it runs.
|
|
12
|
+
module Sideways
|
|
13
|
+
ORIENTATIONS = %i[vertical horizontal].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(**opts)
|
|
16
|
+
super
|
|
17
|
+
orientation = opts.fetch(:orientation, :vertical)
|
|
18
|
+
unless ORIENTATIONS.include?(orientation)
|
|
19
|
+
raise ArgumentError,
|
|
20
|
+
"unknown orientation: #{orientation.inspect}"
|
|
21
|
+
end
|
|
22
|
+
@sideways = orientation == :horizontal
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(*, **)
|
|
26
|
+
super
|
|
27
|
+
return self unless @sideways
|
|
28
|
+
|
|
29
|
+
last = @series.pop
|
|
30
|
+
@series << last.with(x: last.y, y: last.x)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def zero_floor? = false
|
|
37
|
+
|
|
38
|
+
def xlim = Axis.limits(axis_values(:x), @opts[limit_key(:x)])
|
|
39
|
+
def ylim = Axis.limits(axis_values(:y), @opts[limit_key(:y)])
|
|
40
|
+
|
|
41
|
+
# The option limiting a canvas axis: its own, or the other's
|
|
42
|
+
# when the axes are swapped.
|
|
43
|
+
def limit_key(axis)
|
|
44
|
+
return :"#{axis}lim" unless @sideways
|
|
45
|
+
|
|
46
|
+
axis == :x ? :ylim : :xlim
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def axis_values(axis)
|
|
50
|
+
values = @series.flat_map(&axis)
|
|
51
|
+
value_axis = @sideways ? :x : :y
|
|
52
|
+
zero_floor? && axis == value_axis ? [0, *values] : values
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def frame_options(*)
|
|
56
|
+
options = super
|
|
57
|
+
return options unless @sideways
|
|
58
|
+
|
|
59
|
+
options.merge(xlabel: @opts[:ylabel], ylabel: @opts[:xlabel])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# Stem-and-leaf plot (DESIGN.md, "Chart and kinds"): one sample, or
|
|
5
|
+
# two back to back, laid out as unicode_plot's stemplot does --
|
|
6
|
+
# stems beside a divider, leaves beside that, a key line below.
|
|
7
|
+
# Text laid out by its own rules, not a pixel canvas: Chart lends
|
|
8
|
+
# option checking and ascii/colour handling only; its canvas,
|
|
9
|
+
# frame and draw_series go unused.
|
|
10
|
+
class Stem < Chart
|
|
11
|
+
DEFAULT_SCALE = 10
|
|
12
|
+
MAX_STEMS = 1000
|
|
13
|
+
DIVIDER = "|"
|
|
14
|
+
|
|
15
|
+
def initialize(values, other = nil, scale: nil, **)
|
|
16
|
+
super(**)
|
|
17
|
+
@scale = Check.positive!(scale || DEFAULT_SCALE, "scale")
|
|
18
|
+
@left = leaves_by_stem(values)
|
|
19
|
+
@right = other && leaves_by_stem(other)
|
|
20
|
+
check_stem_count!([@left, @right].compact.flat_map(&:keys).uniq)
|
|
21
|
+
@left_color = @color_on ? auto_color : nil
|
|
22
|
+
@right_color = @color_on && @right ? auto_color : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_s
|
|
26
|
+
body = @right ? back_to_back_lines : single_lines
|
|
27
|
+
"#{(body + key_lines).map(&:rstrip).join("\n")}\n"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def numeric!(value, what)
|
|
33
|
+
return value if value.is_a?(Numeric)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "non-numeric #{what}: #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def leaves_by_stem(values)
|
|
39
|
+
Check.finite!(Array(values), "value").each_with_object({}) do |v, h|
|
|
40
|
+
stem, leaf = stem_and_leaf(v)
|
|
41
|
+
(h[stem] ||= []) << leaf
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stem_and_leaf(value)
|
|
46
|
+
value = numeric!(value, "value")
|
|
47
|
+
# Rounded first: 0.3 / 0.1 is 2.9999999999999996, and
|
|
48
|
+
# truncating that would file 0.3 under leaf 2.
|
|
49
|
+
scaled = value.to_f.fdiv(@scale / 10.0).round(9)
|
|
50
|
+
whole = (scaled / 10).to_i
|
|
51
|
+
frac = (scaled - (whole * 10)).to_i
|
|
52
|
+
sign = value.negative? ? "-" : ""
|
|
53
|
+
["#{sign}#{whole.abs}", frac.abs.to_s]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stems_for(*hashes)
|
|
57
|
+
keys = hashes.compact.flat_map(&:keys).uniq
|
|
58
|
+
neg, pos = keys.partition { it.start_with?("-") }
|
|
59
|
+
negative_range(neg) + positive_range(pos)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One line per stem from the lowest to the highest, empty ones
|
|
63
|
+
# included: past MAX_STEMS that is a wall of text, not a chart.
|
|
64
|
+
def check_stem_count!(keys)
|
|
65
|
+
return if keys.empty?
|
|
66
|
+
|
|
67
|
+
lo, hi = keys.map { it.start_with?("-") ? -it.to_i.abs : it.to_i }
|
|
68
|
+
.minmax
|
|
69
|
+
count = hi - lo + 1 + (keys.any? { it.start_with?("-") } ? 1 : 0)
|
|
70
|
+
return if count <= MAX_STEMS
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{count} stems at scale " \
|
|
73
|
+
"#{Axis.label(@scale)}: raise the scale"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def negative_range(keys)
|
|
77
|
+
return [] if keys.empty?
|
|
78
|
+
|
|
79
|
+
lo, hi = keys.map { it.to_i.abs }.minmax
|
|
80
|
+
(lo..hi).to_a.reverse.map { "-#{it}" }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def positive_range(keys)
|
|
84
|
+
return [] if keys.empty?
|
|
85
|
+
|
|
86
|
+
lo, hi = keys.map(&:to_i).minmax
|
|
87
|
+
(lo..hi).to_a.map(&:to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def single_lines
|
|
91
|
+
stems = stems_for(@left)
|
|
92
|
+
width = stems.map(&:length).max || 0
|
|
93
|
+
stems.map { single_line(it, width) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def single_line(stem, width)
|
|
97
|
+
leaves = Color.wrap((@left[stem] || []).sort.join, @left_color)
|
|
98
|
+
"#{stem.rjust(width)} #{DIVIDER} #{leaves}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def back_to_back_lines
|
|
102
|
+
stems = stems_for(@left, @right)
|
|
103
|
+
width = stems.map(&:length).max || 0
|
|
104
|
+
left_width = @left.values.map(&:length).max || 0
|
|
105
|
+
stems.map { back_to_back_line(it, width, left_width) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def back_to_back_line(stem, width, left_width)
|
|
109
|
+
left = Color.wrap(left_leaves(stem, left_width), @left_color)
|
|
110
|
+
right = Color.wrap((@right[stem] || []).sort.join, @right_color)
|
|
111
|
+
mid = stem.rjust(width + 1).ljust(width + 2)
|
|
112
|
+
"#{left} #{DIVIDER}#{mid}#{DIVIDER} #{right}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def left_leaves(stem, width)
|
|
116
|
+
(@left[stem] || []).sort.join.reverse.rjust(width)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def key_lines = [key_line, decimal_line]
|
|
120
|
+
def key_line = "Key: 1#{DIVIDER}0 = #{Axis.label(@scale)}"
|
|
121
|
+
|
|
122
|
+
def decimal_line
|
|
123
|
+
tens = Math.log10(@scale).truncate
|
|
124
|
+
side = tens.negative? ? "left" : "right"
|
|
125
|
+
"The decimal is #{tens.abs} digit(s) to the #{side} of #{DIVIDER}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# (values, other = nil, scale: nil, **opts): a stem-and-leaf plot,
|
|
130
|
+
# or -- given other -- one back to back with it.
|
|
131
|
+
def self.stem(*, **)
|
|
132
|
+
Stem.new(*, **)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A threshold for the plotted kinds (DESIGN.md, "Chart and kinds"):
|
|
5
|
+
# threshold: N draws a dashed reference line at N across the plot
|
|
6
|
+
# -- down it, sideways -- visible without colour; under color:
|
|
7
|
+
# true the cells beyond it take above: (red by default) and the
|
|
8
|
+
# rest below: (the series' own colour unless given), as asciigraph
|
|
9
|
+
# colours above and below a limit. The value axis takes N in.
|
|
10
|
+
module Threshold
|
|
11
|
+
def initialize(**opts)
|
|
12
|
+
super
|
|
13
|
+
limit = opts[:threshold]
|
|
14
|
+
return if limit.nil?
|
|
15
|
+
|
|
16
|
+
Check.finite!([limit], "threshold")
|
|
17
|
+
%i[above below].each do |key|
|
|
18
|
+
next unless opts.key?(key)
|
|
19
|
+
next if Color.valid?(opts[key])
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, "unknown colour: #{opts[key].inspect}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def threshold = @opts[:threshold]
|
|
28
|
+
|
|
29
|
+
def build_canvas(xl, yl)
|
|
30
|
+
canvas = super
|
|
31
|
+
threshold_line(canvas, xl, yl) if threshold
|
|
32
|
+
canvas
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A dot a two pixels along the line, so it reads as a rule.
|
|
36
|
+
def threshold_line(canvas, xl, yl)
|
|
37
|
+
if @sideways
|
|
38
|
+
canvas.dashed(threshold, yl.first, threshold, yl.last)
|
|
39
|
+
else
|
|
40
|
+
canvas.dashed(xl.first, threshold, xl.last, threshold)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def draw_or_mark(canvas, series)
|
|
45
|
+
return super unless threshold
|
|
46
|
+
|
|
47
|
+
super(canvas, series.with(color: split_color(series.color, canvas)))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The colour of a cell: above: beyond the threshold, below:
|
|
51
|
+
# (or the series' colour) before it.
|
|
52
|
+
def split_color(own, canvas)
|
|
53
|
+
above = @opts.fetch(:above, :red)
|
|
54
|
+
below = @opts.fetch(:below, own)
|
|
55
|
+
lambda do |row, col|
|
|
56
|
+
beyond?(canvas, row, col) ? above : below
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def beyond?(canvas, row, col)
|
|
61
|
+
return canvas.cell_column_value(col) > threshold if @sideways
|
|
62
|
+
|
|
63
|
+
canvas.cell_row_value(row) > threshold
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def axis_values(axis)
|
|
67
|
+
values = super
|
|
68
|
+
return values unless threshold && axis == (@sideways ? :x : :y)
|
|
69
|
+
|
|
70
|
+
values + [threshold]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# What tells series apart where colour may be off -- a fill each,
|
|
5
|
+
# one width class, so at most 12 series (pie slices, bar series) --
|
|
6
|
+
# and a legend set under a frame: its entries two blanks apart,
|
|
7
|
+
# wrapped into lines no wider than the frame's body, or than the
|
|
8
|
+
# widest entry, which is never split.
|
|
9
|
+
module Legend
|
|
10
|
+
FILLS = %w[█ ▓ ▒ ░ ╳ ╱ ╲ ∴ ● ○ ■ ·].freeze
|
|
11
|
+
ASCII_FILLS = %w[# = + @ * : . o % & ~ x].freeze
|
|
12
|
+
|
|
13
|
+
def self.fills!(count, what)
|
|
14
|
+
return if count <= FILLS.size
|
|
15
|
+
|
|
16
|
+
raise ArgumentError, "at most #{FILLS.size} #{what}, one a fill"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.wrap(entries, width)
|
|
20
|
+
limit = [width, *entries.map { Width.of(it) }].max
|
|
21
|
+
entries.each_with_object([]) do |entry, lines|
|
|
22
|
+
last = lines.last
|
|
23
|
+
if last && Width.of(last) + 2 + Width.of(entry) <= limit
|
|
24
|
+
last << " " << entry
|
|
25
|
+
else
|
|
26
|
+
lines << +entry
|
|
27
|
+
end
|
|
28
|
+
end.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
module Lint
|
|
5
|
+
# What a text is made of (Tessella.alphabet), so the character
|
|
6
|
+
# policy is decided by measurement before a figure is drawn:
|
|
7
|
+
# "ascii", or "utf-8" and each glyph class present with its
|
|
8
|
+
# count, box-drawing first, and up to five unclassified glyphs
|
|
9
|
+
# by code point. Invalid bytes are a class of their own, not
|
|
10
|
+
# scrubbed away: a Latin-1 file is not ASCII either.
|
|
11
|
+
module Alphabet
|
|
12
|
+
CLASSES = {
|
|
13
|
+
"box-drawing" => 0x2500..0x257F,
|
|
14
|
+
"blocks" => 0x2580..0x259F,
|
|
15
|
+
"geometric" => 0x25A0..0x25FF,
|
|
16
|
+
"arrows" => 0x2190..0x21FF,
|
|
17
|
+
"braille" => 0x2800..0x28FF
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.of(text)
|
|
21
|
+
seen = Hash.new(0)
|
|
22
|
+
other = {}
|
|
23
|
+
text.each_char { tally(it, seen, other) }
|
|
24
|
+
return "ascii" if seen.empty? && other.empty?
|
|
25
|
+
|
|
26
|
+
"utf-8 #{parts(seen, other).join(" ")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.tally(ch, seen, other)
|
|
30
|
+
return seen["invalid-utf-8"] += 1 unless ch.valid_encoding?
|
|
31
|
+
return if ch.ord <= 0x7F
|
|
32
|
+
|
|
33
|
+
name = classify(ch.ord)
|
|
34
|
+
name ? seen[name] += 1 : other[ch.ord] = ch
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.classify(code)
|
|
38
|
+
CLASSES.each { |name, range| return name if range.cover?(code) }
|
|
39
|
+
return "wide" if Lint.wide?(code)
|
|
40
|
+
return "invisible" if Lint.invisible?(code)
|
|
41
|
+
|
|
42
|
+
"emoji-risk" if Lint.emoji_risk?(code)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.parts(seen, other)
|
|
46
|
+
parts = seen.map { |name, n| "#{name}(#{n})" }
|
|
47
|
+
return parts if other.empty?
|
|
48
|
+
|
|
49
|
+
sample = other.first(5).map do |code, ch|
|
|
50
|
+
format("U+%<code>04X %<ch>s", code: code, ch: ch)
|
|
51
|
+
end
|
|
52
|
+
parts << "other(#{other.size}: #{sample.join(", ")})"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
module Lint
|
|
5
|
+
# A vertical anchor (a lifeline, a box edge) one column off on a
|
|
6
|
+
# row or two. Per figure, the rows carrying a vertical at each
|
|
7
|
+
# display column are counted: a column used by few rows, beside
|
|
8
|
+
# one used by many, with no row using both, is a drifted anchor
|
|
9
|
+
# rather than a second one. Boxes of different widths share no
|
|
10
|
+
# such neighbour, and a double line (││) has rows using both.
|
|
11
|
+
module Drift
|
|
12
|
+
MIN_ROWS = 4
|
|
13
|
+
RATIO = 3
|
|
14
|
+
|
|
15
|
+
# A Markdown pipe table's separator row: such a paragraph is
|
|
16
|
+
# not a figure, its pipes need not line up in the source.
|
|
17
|
+
MD_TABLE_SEP = /\A\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*\z/
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def check_anchor_drift(sec)
|
|
22
|
+
group = []
|
|
23
|
+
sec.lines.each_with_index do |line, i|
|
|
24
|
+
if line.strip.empty?
|
|
25
|
+
flag_anchor_drift(sec, group)
|
|
26
|
+
group = []
|
|
27
|
+
else
|
|
28
|
+
group << [i, line]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
flag_anchor_drift(sec, group)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def flag_anchor_drift(sec, group)
|
|
35
|
+
return unless drift_figure?(group)
|
|
36
|
+
|
|
37
|
+
rows = vertical_rows(group)
|
|
38
|
+
return if rows.length < MIN_ROWS
|
|
39
|
+
|
|
40
|
+
count = rows.flat_map(&:last).tally
|
|
41
|
+
count.default = 0
|
|
42
|
+
count.keys.sort.each do |c|
|
|
43
|
+
anchor = drift_anchor(rows, count, c)
|
|
44
|
+
report_drift(sec, rows, count, [c, anchor]) if anchor
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# [index, its distinct vertical columns] per row that has any.
|
|
49
|
+
def vertical_rows(group)
|
|
50
|
+
group.map { |i, line| [i, vertical_columns(line).uniq] }
|
|
51
|
+
.reject { |_, cols| cols.empty? }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def drift_figure?(group)
|
|
55
|
+
group.any? do |_, line|
|
|
56
|
+
bare = line.gsub(INLINE_CODE, "")
|
|
57
|
+
bare.match?(BOX_CHAR) || Mixing.ascii_structural?(bare)
|
|
58
|
+
end && group.none? { |_, line| line.match?(MD_TABLE_SEP) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def drift_anchor(rows, count, col)
|
|
62
|
+
[col - 1, col + 1].find do |d|
|
|
63
|
+
count[d] >= MIN_ROWS && count[d] >= RATIO * count[col] &&
|
|
64
|
+
rows.none? do |_, cols|
|
|
65
|
+
cols.include?(col) && cols.include?(d)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def report_drift(sec, rows, count, pair)
|
|
71
|
+
col, anchor = pair
|
|
72
|
+
bad = rows.select { |_, cols| cols.include?(col) }
|
|
73
|
+
.map { |i, _| sec.first + i }
|
|
74
|
+
report(sec, bad.first - sec.first,
|
|
75
|
+
"vertical at column #{col} on #{bad.length} row(s) " \
|
|
76
|
+
"(#{bad.join(",")}) beside the figure's anchor at " \
|
|
77
|
+
"column #{anchor} (#{count[anchor]} rows) -- one " \
|
|
78
|
+
"column off?")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
module Lint
|
|
5
|
+
# fences: true -- each fenced block of a Markdown text linted on
|
|
6
|
+
# its own, with the text's line numbers, since a whole document
|
|
7
|
+
# would read its prose and tables as art. A fence opens with
|
|
8
|
+
# three or more backticks or tildes (indented at most three
|
|
9
|
+
# spaces) and closes with a run of the same glyph at least as
|
|
10
|
+
# long; an unclosed fence runs to the end. An allow-mixed
|
|
11
|
+
# marker on the last non-blank line before a fence exempts
|
|
12
|
+
# that block from the mixing check.
|
|
13
|
+
module Fences
|
|
14
|
+
OPEN = /\A {0,3}(`{3,}|~{3,})/
|
|
15
|
+
|
|
16
|
+
def self.sections(lines)
|
|
17
|
+
out = []
|
|
18
|
+
i = 0
|
|
19
|
+
while i < lines.length
|
|
20
|
+
fence = lines[i][OPEN, 1]
|
|
21
|
+
i += 1
|
|
22
|
+
next unless fence
|
|
23
|
+
|
|
24
|
+
out << section(lines, i, closing(lines, i, fence))
|
|
25
|
+
i = out.last.first + out.last.lines.length
|
|
26
|
+
end
|
|
27
|
+
out
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The index of the closing fence, or lines.length.
|
|
31
|
+
def self.closing(lines, from, fence)
|
|
32
|
+
glyph = Regexp.escape(fence[0])
|
|
33
|
+
close = /\A {0,3}#{glyph}{#{fence.length},}\s*\z/
|
|
34
|
+
(from...lines.length).find { lines[it].match?(close) } ||
|
|
35
|
+
lines.length
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.section(lines, from, stop)
|
|
39
|
+
Section.new(lines: lines[from...stop], first: from + 1,
|
|
40
|
+
exempt: marked?(lines, from - 1))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.marked?(lines, fence_at)
|
|
44
|
+
before = lines[0...fence_at].reverse.find do |line|
|
|
45
|
+
!line.strip.empty?
|
|
46
|
+
end
|
|
47
|
+
before&.match?(MIXED_MARKER) || false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|