tessella 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +900 -0
  4. data/doc/README.md +74 -0
  5. data/doc/bitfield.md +123 -0
  6. data/doc/charts.md +1370 -0
  7. data/doc/check.rb +75 -0
  8. data/doc/cli.md +483 -0
  9. data/doc/composition.md +599 -0
  10. data/doc/getting-started.md +121 -0
  11. data/doc/glyphs.md +134 -0
  12. data/doc/images.md +325 -0
  13. data/doc/library.md +239 -0
  14. data/doc/lint.md +270 -0
  15. data/doc/meter.md +210 -0
  16. data/doc/rule.md +51 -0
  17. data/doc/seq.md +169 -0
  18. data/doc/sparkline.md +63 -0
  19. data/doc/table.md +347 -0
  20. data/doc/targets.md +222 -0
  21. data/doc/text.md +84 -0
  22. data/doc/tree.md +108 -0
  23. data/doc/wave.md +122 -0
  24. data/examples/README.md +103 -0
  25. data/examples/ages.txt +9 -0
  26. data/examples/bad.csv +3 -0
  27. data/examples/boxes.txt +3 -0
  28. data/examples/broken.txt +4 -0
  29. data/examples/calibration.rb +12 -0
  30. data/examples/cities.csv +4 -0
  31. data/examples/cjk.rb +13 -0
  32. data/examples/comment.rb +9 -0
  33. data/examples/comment.sh +13 -0
  34. data/examples/cpu.txt +60 -0
  35. data/examples/dashboard.sh +25 -0
  36. data/examples/datasheet.rb +16 -0
  37. data/examples/disc.pgm +0 -0
  38. data/examples/disks.sh +18 -0
  39. data/examples/disks.txt +5 -0
  40. data/examples/dose.txt +7 -0
  41. data/examples/drift.txt +4 -0
  42. data/examples/ethernet.txt +3 -0
  43. data/examples/figure.c +5 -0
  44. data/examples/fit.rb +16 -0
  45. data/examples/floors.rb +21 -0
  46. data/examples/gallery.sh +20 -0
  47. data/examples/gauges.sh +31 -0
  48. data/examples/gradient.rb +16 -0
  49. data/examples/growth.txt +6 -0
  50. data/examples/hosts.csv +4 -0
  51. data/examples/icmp.txt +3 -0
  52. data/examples/ipv4.txt +12 -0
  53. data/examples/kinds.csv +4 -0
  54. data/examples/langs.csv +4 -0
  55. data/examples/lint.rb +16 -0
  56. data/examples/load.rb +14 -0
  57. data/examples/login.txt +10 -0
  58. data/examples/neg.csv +4 -0
  59. data/examples/normal.txt +200 -0
  60. data/examples/note.txt +2 -0
  61. data/examples/notes-fixed.md +17 -0
  62. data/examples/notes.md +17 -0
  63. data/examples/ohlc.txt +7 -0
  64. data/examples/os.csv +10 -0
  65. data/examples/outline.txt +8 -0
  66. data/examples/plot.txt +5 -0
  67. data/examples/refusals.rb +17 -0
  68. data/examples/report.sh +21 -0
  69. data/examples/rfc.sh +22 -0
  70. data/examples/samples.txt +7 -0
  71. data/examples/shapes.pgm +0 -0
  72. data/examples/sizes.tsv +3 -0
  73. data/examples/spi.txt +6 -0
  74. data/examples/status.sh +23 -0
  75. data/examples/stem.txt +12 -0
  76. data/examples/table-drift.txt +4 -0
  77. data/examples/temps.txt +31 -0
  78. data/examples/trig.sh +13 -0
  79. data/examples/trig.txt +102 -0
  80. data/examples/week.txt +8 -0
  81. data/examples/wide.txt +2 -0
  82. data/exe/tessella +6 -0
  83. data/lib/tessella/axis.rb +71 -0
  84. data/lib/tessella/bitfield.rb +194 -0
  85. data/lib/tessella/block.rb +56 -0
  86. data/lib/tessella/borders.rb +85 -0
  87. data/lib/tessella/canvas/area.rb +155 -0
  88. data/lib/tessella/canvas/ascii.rb +120 -0
  89. data/lib/tessella/canvas/block.rb +40 -0
  90. data/lib/tessella/canvas/braille.rb +30 -0
  91. data/lib/tessella/canvas/clip.rb +49 -0
  92. data/lib/tessella/canvas/density.rb +51 -0
  93. data/lib/tessella/canvas/dot.rb +27 -0
  94. data/lib/tessella/canvas/eighths.rb +43 -0
  95. data/lib/tessella/canvas/half.rb +51 -0
  96. data/lib/tessella/canvas/sextant.rb +36 -0
  97. data/lib/tessella/canvas.rb +168 -0
  98. data/lib/tessella/cell.rb +10 -0
  99. data/lib/tessella/chart.rb +211 -0
  100. data/lib/tessella/check.rb +52 -0
  101. data/lib/tessella/cli/bitfield.rb +121 -0
  102. data/lib/tessella/cli/chart.rb +615 -0
  103. data/lib/tessella/cli/chart_candle.rb +36 -0
  104. data/lib/tessella/cli/chart_heatmap.rb +21 -0
  105. data/lib/tessella/cli/chart_help.rb +85 -0
  106. data/lib/tessella/cli/chart_pie.rb +38 -0
  107. data/lib/tessella/cli/compose.rb +156 -0
  108. data/lib/tessella/cli/compose_options.rb +259 -0
  109. data/lib/tessella/cli/glyphs.rb +97 -0
  110. data/lib/tessella/cli/image.rb +250 -0
  111. data/lib/tessella/cli/input.rb +44 -0
  112. data/lib/tessella/cli/iso_time.rb +28 -0
  113. data/lib/tessella/cli/lint.rb +102 -0
  114. data/lib/tessella/cli/lint_options.rb +99 -0
  115. data/lib/tessella/cli/main.rb +121 -0
  116. data/lib/tessella/cli/meter.rb +80 -0
  117. data/lib/tessella/cli/rule.rb +57 -0
  118. data/lib/tessella/cli/seq.rb +117 -0
  119. data/lib/tessella/cli/sparkline.rb +72 -0
  120. data/lib/tessella/cli/table.rb +167 -0
  121. data/lib/tessella/cli/text.rb +78 -0
  122. data/lib/tessella/cli/tree.rb +100 -0
  123. data/lib/tessella/cli/wave.rb +148 -0
  124. data/lib/tessella/cli.rb +36 -0
  125. data/lib/tessella/color.rb +106 -0
  126. data/lib/tessella/compose.rb +243 -0
  127. data/lib/tessella/compose_fill.rb +176 -0
  128. data/lib/tessella/compose_grid.rb +36 -0
  129. data/lib/tessella/compose_shadow.rb +41 -0
  130. data/lib/tessella/error.rb +13 -0
  131. data/lib/tessella/frame.rb +170 -0
  132. data/lib/tessella/glyphs.rb +106 -0
  133. data/lib/tessella/image.rb +454 -0
  134. data/lib/tessella/image_octant.rb +46 -0
  135. data/lib/tessella/image_petscii.rb +116 -0
  136. data/lib/tessella/kinds/bar.rb +344 -0
  137. data/lib/tessella/kinds/box.rb +213 -0
  138. data/lib/tessella/kinds/box_upright.rb +110 -0
  139. data/lib/tessella/kinds/candlestick.rb +164 -0
  140. data/lib/tessella/kinds/columns.rb +259 -0
  141. data/lib/tessella/kinds/date_axis.rb +102 -0
  142. data/lib/tessella/kinds/error_bars.rb +71 -0
  143. data/lib/tessella/kinds/heatmap.rb +130 -0
  144. data/lib/tessella/kinds/line.rb +173 -0
  145. data/lib/tessella/kinds/multibar.rb +98 -0
  146. data/lib/tessella/kinds/multibar_columns.rb +130 -0
  147. data/lib/tessella/kinds/multibar_fine.rb +45 -0
  148. data/lib/tessella/kinds/multibar_rows.rb +93 -0
  149. data/lib/tessella/kinds/pie.rb +123 -0
  150. data/lib/tessella/kinds/pie_rim.rb +78 -0
  151. data/lib/tessella/kinds/sideways.rb +62 -0
  152. data/lib/tessella/kinds/stem.rb +134 -0
  153. data/lib/tessella/kinds/threshold.rb +73 -0
  154. data/lib/tessella/legend.rb +31 -0
  155. data/lib/tessella/lint/alphabet.rb +56 -0
  156. data/lib/tessella/lint/drift.rb +82 -0
  157. data/lib/tessella/lint/fences.rb +51 -0
  158. data/lib/tessella/lint/geometry.rb +120 -0
  159. data/lib/tessella/lint/geometry_checks.rb +224 -0
  160. data/lib/tessella/lint/lines.rb +115 -0
  161. data/lib/tessella/lint/mixing.rb +61 -0
  162. data/lib/tessella/lint/runner.rb +73 -0
  163. data/lib/tessella/lint/shadows.rb +120 -0
  164. data/lib/tessella/lint/target_rows.rb +66 -0
  165. data/lib/tessella/lint.rb +158 -0
  166. data/lib/tessella/meter.rb +133 -0
  167. data/lib/tessella/meter_upright.rb +43 -0
  168. data/lib/tessella/png.rb +456 -0
  169. data/lib/tessella/raster.rb +92 -0
  170. data/lib/tessella/rmagick_reader.rb +52 -0
  171. data/lib/tessella/rule.rb +59 -0
  172. data/lib/tessella/seq.rb +522 -0
  173. data/lib/tessella/sparkline.rb +57 -0
  174. data/lib/tessella/stats.rb +199 -0
  175. data/lib/tessella/table.rb +547 -0
  176. data/lib/tessella/table_cells.rb +225 -0
  177. data/lib/tessella/target.rb +105 -0
  178. data/lib/tessella/text.rb +76 -0
  179. data/lib/tessella/text_forms.rb +150 -0
  180. data/lib/tessella/tree.rb +221 -0
  181. data/lib/tessella/version.rb +5 -0
  182. data/lib/tessella/wave.rb +353 -0
  183. data/lib/tessella/width.rb +122 -0
  184. data/lib/tessella/width_class.rb +77 -0
  185. data/lib/tessella/width_invisible.rb +66 -0
  186. data/lib/tessella/width_invisible_high.rb +60 -0
  187. data/lib/tessella.rb +94 -0
  188. metadata +226 -0
@@ -0,0 +1,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