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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # 2x2 sub-pixels per cell, packed into the Unicode quadrant
6
+ # block characters (Block Elements / Symbols for Legacy
7
+ # Computing): each of the four Unicode glyphs for a quadrant
8
+ # combination is the only character that means that exact
9
+ # combination, so this table is fixed by the Unicode names,
10
+ # not by any library.
11
+ class Block < Canvas
12
+ TOP_LEFT = 8
13
+ TOP_RIGHT = 4
14
+ BOTTOM_LEFT = 2
15
+ BOTTOM_RIGHT = 1
16
+
17
+ BITS = [
18
+ [TOP_LEFT, BOTTOM_LEFT],
19
+ [TOP_RIGHT, BOTTOM_RIGHT]
20
+ ].freeze
21
+
22
+ GLYPHS = [
23
+ " ", "▗", "▖", "▄", "▝", "▐", "▞", "▟",
24
+ "▘", "▚", "▌", "▙", "▀", "▜", "▛", "█"
25
+ ].freeze
26
+
27
+ private
28
+
29
+ def x_pixel_per_char = 2
30
+ def y_pixel_per_char = 2
31
+
32
+ def encode(code, sub_x, sub_y) = code | BITS[sub_x][sub_y]
33
+ def decode(code) = GLYPHS[code]
34
+ end
35
+
36
+ register(:block, Block)
37
+ # The image module's name for the same 2x2 quadrant glyphs.
38
+ register(:quadrant, Block)
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # 2x4 sub-pixels per cell, packed into the Unicode Braille
6
+ # Patterns block: dot n sets bit 2**(n-1) of the base U+2800,
7
+ # dots 1/2/3/7 in the left column (top to bottom), 4/5/6/8 in
8
+ # the right -- the Unicode standard's own dot layout, so this
9
+ # table is fixed by the codepoint scheme, not by any library.
10
+ class Braille < Canvas
11
+ BITS = [
12
+ [0x01, 0x02, 0x04, 0x40],
13
+ [0x08, 0x10, 0x20, 0x80]
14
+ ].freeze
15
+
16
+ private
17
+
18
+ def x_pixel_per_char = 2
19
+ def y_pixel_per_char = 4
20
+
21
+ def encode(code, sub_x, sub_y) = code | BITS[sub_x][sub_y]
22
+
23
+ def decode(code)
24
+ code.zero? ? " " : [0x2800 | code].pack("U")
25
+ end
26
+ end
27
+
28
+ register(:braille, Braille)
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # A segment far longer than the canvas (a spike past ylim:)
6
+ # steps only where it crosses the canvas: stepping it all, one
7
+ # rejected pixel at a time, took minutes for a value a million
8
+ # times the limits. Shorter segments keep the running sum, and
9
+ # so unicode_plot's exact pixels.
10
+ module Clip
11
+ private
12
+
13
+ def long?(steps) = steps > 2 * (pixel_width + pixel_height)
14
+
15
+ def clipped_line(from, inc, steps, color)
16
+ first, last = on_canvas(from, inc, steps)
17
+ first.upto(last) do |i|
18
+ draw_pixel((from[0] + (inc[0] * i)).floor,
19
+ (from[1] + (inc[1] * i)).floor, color)
20
+ end
21
+ end
22
+
23
+ # The steps [first, last] at which both coordinates are on
24
+ # the canvas, a pixel of margin either side.
25
+ def on_canvas(from, inc, steps)
26
+ x = span(from[0], inc[0], pixel_width)
27
+ y = span(from[1], inc[1], pixel_height)
28
+ within([x[0], y[0]].max, [x[1], y[1]].min, steps)
29
+ end
30
+
31
+ def within(first, last, steps)
32
+ [first.clamp(1, steps.ceil), last.clamp(0, steps.floor)]
33
+ end
34
+
35
+ def span(start, inc, size)
36
+ return flat_span(start, size) if inc.zero?
37
+
38
+ a, b = [(-1 - start) / inc, (size + 1 - start) / inc].minmax
39
+ [a.floor, b.ceil]
40
+ end
41
+
42
+ def flat_span(start, size)
43
+ start.between?(-1, size + 1) ? [0, Float::INFINITY] : [1, 0]
44
+ end
45
+ end
46
+
47
+ include Clip
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # 1x2 sub-pixels per cell; the glyph is chosen by hit count
6
+ # relative to the densest cell on the canvas, not by position
7
+ # (DESIGN.md, Canvas). ascii: true swaps the default shade
8
+ # ramp for an ASCII one (brief rules).
9
+ class Density < Canvas
10
+ SIGNS = [" ", "░", "▒", "▓", "█"].freeze
11
+ ASCII_SIGNS = [" ", ".", ":", "+", "#"].freeze
12
+
13
+ def initialize(width, height, xlim:, ylim:, ascii: false)
14
+ @signs = ascii ? ASCII_SIGNS : SIGNS
15
+ @max_density = 1
16
+ super(width, height, xlim: xlim, ylim: ylim)
17
+ end
18
+
19
+ private
20
+
21
+ def x_pixel_per_char = 1
22
+ def y_pixel_per_char = 2
23
+
24
+ # The oracle maps a pixel to its cell by floor(float
25
+ # round-trip), not exact integer division, and this can
26
+ # round a whole pixel short at some widths/heights (e.g.
27
+ # 13.fdiv(23) * 23 == 12.999999999999998, D1). Matched
28
+ # here verbatim for byte parity rather than "fixed", since
29
+ # the oracle's own output is what rendering must match.
30
+ def cell_index(px, py)
31
+ cx = (px.fdiv(pixel_width) * width).floor
32
+ cy = (py.fdiv(pixel_height) * height).floor
33
+ (cy * width) + cx
34
+ end
35
+
36
+ def encode(code, _sub_x, _sub_y)
37
+ count = code + 1
38
+ @max_density = count if count > @max_density
39
+ count
40
+ end
41
+
42
+ def decode(code)
43
+ return " " if code.zero?
44
+ scale = (@signs.size - 1).fdiv(@max_density)
45
+ @signs[(code * scale).round]
46
+ end
47
+ end
48
+
49
+ register(:density, Density)
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # 1x2 sub-pixels per cell: the top pixel alone draws a raised
6
+ # mark, the bottom alone a mark on the baseline, both a colon.
7
+ class Dot < Canvas
8
+ TOP = 2
9
+ BOTTOM = 1
10
+
11
+ GLYPHS = [" ", ".", "'", ":"].freeze
12
+
13
+ private
14
+
15
+ def x_pixel_per_char = 1
16
+ def y_pixel_per_char = 2
17
+
18
+ def encode(code, _sub_x, sub_y)
19
+ code | (sub_y.zero? ? TOP : BOTTOM)
20
+ end
21
+
22
+ def decode(code) = GLYPHS[code]
23
+ end
24
+
25
+ register(:dot, Dot)
26
+ end
27
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # Eighth blocks, 1x8 sub-pixels per cell, for fills only (an
6
+ # area): a cell reads as filled from its bottom up to its
7
+ # highest pixel -- " ▁▂▃▄▅▆▇█" -- one pixel column per
8
+ # character, eight height steps. A pixel alone in a cell (a
9
+ # line, a dot) would read as a bar under it, so the charts take
10
+ # this canvas only where they fill.
11
+ class Eighths < Canvas
12
+ GLYPHS = " ▁▂▃▄▅▆▇█".chars.freeze
13
+
14
+ private
15
+
16
+ def x_pixel_per_char = 1
17
+ def y_pixel_per_char = 8
18
+
19
+ def encode(code, _sub_x, sub_y) = code | (1 << (7 - sub_y))
20
+
21
+ # The highest pixel set, as a height in eighths.
22
+ def decode(code) = GLYPHS[code.bit_length]
23
+ end
24
+
25
+ # Eighth blocks across, 8x1: a cell reads as filled from its
26
+ # left edge out to its rightmost pixel -- " ▏▎▍▌▋▊▉█" -- for an
27
+ # area filled sideways.
28
+ class EighthsWide < Canvas
29
+ GLYPHS = " ▏▎▍▌▋▊▉█".chars.freeze
30
+
31
+ private
32
+
33
+ def x_pixel_per_char = 8
34
+ def y_pixel_per_char = 1
35
+
36
+ def encode(code, sub_x, _sub_y) = code | (1 << sub_x)
37
+ def decode(code) = GLYPHS[code.bit_length]
38
+ end
39
+
40
+ register(:eighths, Eighths)
41
+ register(:eighths_wide, EighthsWide)
42
+ end
43
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # Half blocks, 1x2 sub-pixels per cell: the upper half, the
6
+ # lower, or both -- "▀", "▄", "█" -- one pixel column per
7
+ # character, two rows. A column chart's columns rise by half a
8
+ # cell; an area fills by halves.
9
+ class Half < Canvas
10
+ TOP = 1
11
+ BOTTOM = 2
12
+
13
+ GLYPHS = [" ", "▀", "▄", "█"].freeze
14
+
15
+ private
16
+
17
+ def x_pixel_per_char = 1
18
+ def y_pixel_per_char = 2
19
+
20
+ def encode(code, _sub_x, sub_y)
21
+ code | (sub_y.zero? ? TOP : BOTTOM)
22
+ end
23
+
24
+ def decode(code) = GLYPHS[code]
25
+ end
26
+
27
+ # Half blocks across, 2x1 sub-pixels per cell: the left half,
28
+ # the right, or both -- "▌", "▐", "█" -- so a horizontal bar
29
+ # grows by half a cell, one bar per text row.
30
+ class HalfWide < Canvas
31
+ LEFT = 1
32
+ RIGHT = 2
33
+
34
+ GLYPHS = [" ", "▌", "▐", "█"].freeze
35
+
36
+ private
37
+
38
+ def x_pixel_per_char = 2
39
+ def y_pixel_per_char = 1
40
+
41
+ def encode(code, sub_x, _sub_y)
42
+ code | (sub_x.zero? ? LEFT : RIGHT)
43
+ end
44
+
45
+ def decode(code) = GLYPHS[code]
46
+ end
47
+
48
+ register(:half, Half)
49
+ register(:half_wide, HalfWide)
50
+ end
51
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ class Canvas
5
+ # Teletext sextants, 2x3 sub-pixels per cell, solid where
6
+ # braille is dotted: Unicode 13's Symbols for Legacy Computing
7
+ # (U+1FB00..1FB3B, with ▌ ▐ █ for the halves and the whole),
8
+ # Image::SEXTANT's table. Bit (row * 2 + column), top left 1.
9
+ class Sextant < Canvas
10
+ private
11
+
12
+ def x_pixel_per_char = 2
13
+ def y_pixel_per_char = 3
14
+
15
+ def encode(code, sub_x, sub_y) = code | (1 << ((sub_y * 2) + sub_x))
16
+ def decode(code) = Image::SEXTANT[code]
17
+ end
18
+
19
+ # Octants, 2x4 sub-pixels per cell: braille's resolution,
20
+ # solid -- Unicode 16 (U+1CD00..1CDE5 and older glyphs for the
21
+ # patterns that had one), Image::OCTANT's table, so only the
22
+ # newest terminals draw them.
23
+ class Octant < Canvas
24
+ private
25
+
26
+ def x_pixel_per_char = 2
27
+ def y_pixel_per_char = 4
28
+
29
+ def encode(code, sub_x, sub_y) = code | (1 << ((sub_y * 2) + sub_x))
30
+ def decode(code) = Image::OCTANT[code]
31
+ end
32
+
33
+ register(:sextant, Sextant)
34
+ register(:octant, Octant)
35
+ end
36
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # Glyphs written over a canvas's cells, whatever those cells draw:
5
+ # text (annotate) and point marks (mark). Kept apart from Canvas so
6
+ # Canvas's own class body stays under budget.
7
+ module Overlay
8
+ # char in the cell holding (x, y), over whatever that cell
9
+ # draws; outside the limits, nothing.
10
+ def mark(x, y, char, color: nil)
11
+ return unless x.between?(@xlo, @xhi) && y.between?(@ylo, @yhi)
12
+
13
+ px, py = to_pixel(x, y).map(&:floor)
14
+ px = clip(px, pixel_width)
15
+ py = clip(py, pixel_height)
16
+ @overlay[cell_index(px, py)] =
17
+ Cell.new(char: char, color: cell_color(color, px, py))
18
+ end
19
+
20
+ def annotate(col, row, text, color: nil)
21
+ text.each_char.with_index do |ch, i|
22
+ next unless (col + i).between?(0, width - 1) &&
23
+ row.between?(0, height - 1)
24
+ @overlay[(row * width) + col + i] =
25
+ Cell.new(char: ch, color: color)
26
+ end
27
+ end
28
+ end
29
+
30
+ # A grid of character cells a chart draws data into: data
31
+ # coordinates map onto pixels, pixel (0, 0) is the bottom-left, and
32
+ # sub-pixels pack into one Cell per character (DESIGN.md, Canvas).
33
+ # Concrete sub-pixel layouts live in canvas/*.rb, each registering
34
+ # itself under its type with Canvas.register.
35
+ class Canvas
36
+ include Overlay
37
+
38
+ def self.types = (@types ||= {})
39
+ def self.register(type, klass) = (types[type] = klass)
40
+
41
+ # type, width:, height:, xlim:, ylim: and a type's own extras
42
+ # (density's ascii:). A missing required keyword raises
43
+ # ArgumentError naming it, as Ruby's own "missing keyword:
44
+ # :width" does, rather than failing deeper in a subclass.
45
+ def self.build(type, **opts)
46
+ klass = types.fetch(type) do
47
+ raise Error, "unknown canvas type: #{type.inspect}"
48
+ end
49
+ missing = %i[width height xlim ylim] - opts.keys
50
+ unless missing.empty?
51
+ s = missing.size > 1 ? "s" : ""
52
+ names = missing.map { ":#{it}" }.join(", ")
53
+ raise ArgumentError, "missing keyword#{s}: #{names}"
54
+ end
55
+ klass.new(opts.delete(:width), opts.delete(:height), **opts)
56
+ end
57
+
58
+ attr_reader :width, :height, :pixel_width, :pixel_height
59
+
60
+ def initialize(width, height, xlim:, ylim:)
61
+ @width = width
62
+ @height = height
63
+ @pixel_width = width * x_pixel_per_char
64
+ @pixel_height = height * y_pixel_per_char
65
+ @xlo, @xhi = xlim
66
+ @ylo, @yhi = ylim
67
+ @grid = Array.new(width * height, blank_code)
68
+ @colors = Array.new(width * height)
69
+ @overlay = Array.new(width * height)
70
+ end
71
+
72
+ def point(x, y, color: nil)
73
+ return unless x.between?(@xlo, @xhi) && y.between?(@ylo, @yhi)
74
+ draw_pixel(*to_pixel(x, y).map(&:floor), color)
75
+ end
76
+
77
+ def points(xs, ys, color: nil)
78
+ xs.zip(ys).each { |x, y| point(x, y, color: color) }
79
+ end
80
+
81
+ def line(x0, y0, x1, y1, color: nil)
82
+ return if out?(x0, x1, @xlo, @xhi) || out?(y0, y1, @ylo, @yhi)
83
+ draw_line(x0, y0, x1, y1, color)
84
+ end
85
+
86
+ def lines(xs, ys, color: nil)
87
+ (0...(xs.size - 1)).each do |i|
88
+ line(xs[i], ys[i], xs[i + 1], ys[i + 1], color: color)
89
+ end
90
+ end
91
+
92
+ def rows
93
+ Array.new(height) { |r| Array.new(width) { |c| cell_at(r, c) } }
94
+ end
95
+
96
+ private
97
+
98
+ def blank_code = 0
99
+
100
+ def out?(a, b, lo, hi) = (a < lo && b < lo) || (a > hi && b > hi)
101
+
102
+ def to_pixel(x, y)
103
+ fx = (x - @xlo).fdiv(@xhi - @xlo) * pixel_width
104
+ fy = pixel_height - ((y - @ylo).fdiv(@yhi - @ylo) * pixel_height)
105
+ [fx, fy]
106
+ end
107
+
108
+ def draw_line(x0, y0, x1, y1, color)
109
+ from = to_pixel(x0, y0)
110
+ to = to_pixel(x1, y1)
111
+ draw_pixel(from[0].floor, from[1].floor, color)
112
+ dda(from, to, color)
113
+ end
114
+
115
+ def dda(from, to, color)
116
+ dx = to[0] - from[0]
117
+ dy = to[1] - from[1]
118
+ steps = [dx.abs, dy.abs].max
119
+ return if steps.zero?
120
+
121
+ inc = [dx.fdiv(steps), dy.fdiv(steps)]
122
+ line = long?(steps) ? :clipped_line : :step_line
123
+ send(line, from, inc, steps, color)
124
+ end
125
+
126
+ # A running sum, as unicode_plot (from + inc * i rounds apart).
127
+ def step_line(from, inc, steps, color)
128
+ x, y = from
129
+ 1.upto(steps) do
130
+ draw_pixel((x += inc[0]).floor, (y += inc[1]).floor, color)
131
+ end
132
+ end
133
+
134
+ # color: a name, or a callable of the cell's row and column.
135
+ def draw_pixel(px, py, color)
136
+ return unless px.between?(0, pixel_width) &&
137
+ py.between?(0, pixel_height)
138
+ x = clip(px, pixel_width)
139
+ y = clip(py, pixel_height)
140
+ set_sub_pixel(x, y, cell_color(color, x, y))
141
+ end
142
+
143
+ def cell_color(color, px, py)
144
+ return color unless color.respond_to?(:call) && color.arity == 2
145
+
146
+ color.call(py / y_pixel_per_char, px / x_pixel_per_char)
147
+ end
148
+
149
+ def clip(v, max) = v == max ? v - 1 : v
150
+
151
+ def set_sub_pixel(px, py, color)
152
+ i = cell_index(px, py)
153
+ @grid[i] = encode(@grid[i], px % x_pixel_per_char,
154
+ py % y_pixel_per_char)
155
+ @colors[i] = color unless color.nil?
156
+ end
157
+
158
+ # Overridden by density.rb, D1.
159
+ def cell_index(px, py)
160
+ ((py / y_pixel_per_char) * width) + (px / x_pixel_per_char)
161
+ end
162
+
163
+ def cell_at(row, col)
164
+ i = (row * width) + col
165
+ @overlay[i] || Cell.new(char: decode(@grid[i]), color: @colors[i])
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tessella
4
+ # One character cell of a chart body: its glyph, and the colour name
5
+ # it is drawn in (nil: none). Canvases produce rows of these; the
6
+ # frame renders them (DESIGN.md).
7
+ Cell = Data.define(:char, :color) do
8
+ def self.blank = new(char: " ", color: nil)
9
+ end
10
+ end