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
data/lib/tessella/seq.rb
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# UML-style sequence diagrams (this module matches the tessella
|
|
5
|
+
# skill's scripts/seq.rb byte for byte). One statement per line:
|
|
6
|
+
# "participant NAME", "A -> B: text" (call), "A --> B: text"
|
|
7
|
+
# (reply, dashed), "A ->> B: text" (async, a double arrowhead), a
|
|
8
|
+
# self call "A -> A: text" (a loop right of A), and "note over
|
|
9
|
+
# A[,B]: text". A name is any run of Unicode letters/digits, "_",
|
|
10
|
+
# "." or "-"; under ascii: true such a name must be ASCII.
|
|
11
|
+
# Columns are fixed by first appearance (or an explicit
|
|
12
|
+
# "participant" line) and widen just enough for every label and
|
|
13
|
+
# note that touches them -- never truncating. Every refusal (an
|
|
14
|
+
# unrecognised statement, a non-ASCII name under ascii: true, no
|
|
15
|
+
# statements or no participants in the input) is an ArgumentError
|
|
16
|
+
# naming the line ("line N: ..."; line 0 when there is no line).
|
|
17
|
+
module Seq
|
|
18
|
+
GAP = 2
|
|
19
|
+
LOOP_WIDTH = 3
|
|
20
|
+
|
|
21
|
+
# A name may hold "-" but not end with one, so "A-->B" is A
|
|
22
|
+
# replying to B, and "A-->>B", no arrow, is refused.
|
|
23
|
+
NAME = '[\p{L}\p{N}_.](?:[\p{L}\p{N}_.-]*[\p{L}\p{N}_.])?'
|
|
24
|
+
KIND = { "->" => :call, "-->" => :reply, "->>" => :async }.freeze
|
|
25
|
+
PART_RE = /\Aparticipant\s+(#{NAME})\s*\z/
|
|
26
|
+
ARROW_RE = /\A(#{NAME})\s*(->>|-->|->)\s*(#{NAME})\s*:\s*(.*)\z/
|
|
27
|
+
NOTE_RE = /\Anote\s+over\s+(#{NAME})(?:\s*,\s*(#{NAME}))?
|
|
28
|
+
\s*:\s*(.*)\z/x
|
|
29
|
+
|
|
30
|
+
UNI_G = { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
31
|
+
td: "┬", tu: "┴", dash: "┄" }.freeze
|
|
32
|
+
ASC_G = { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+",
|
|
33
|
+
td: "+", tu: "+", dash: "-" }.freeze
|
|
34
|
+
|
|
35
|
+
def self.bad!(lineno, msg)
|
|
36
|
+
raise ArgumentError, "line #{lineno}: #{msg}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.style_for(ascii)
|
|
40
|
+
(ascii ? ASC_G : UNI_G).merge(ascii: ascii)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The whole pipeline: parse, check names, number messages,
|
|
44
|
+
# fix the participant order, lay out columns, draw.
|
|
45
|
+
def self.build(text, ascii:, numbered:, footer:)
|
|
46
|
+
statements = parse(text)
|
|
47
|
+
check_ascii_names!(statements, ascii)
|
|
48
|
+
assign_numbers!(statements, numbered)
|
|
49
|
+
participants = gather_participants(statements)
|
|
50
|
+
bad!(0, "no participants in the input") if participants.empty?
|
|
51
|
+
lay = layout(participants, statements)
|
|
52
|
+
style = style_for(ascii)
|
|
53
|
+
Block.new(render(statements, lay, participants, style, footer))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Parsing: one statement per non-blank, non-comment line, each
|
|
58
|
+
# tagged with its 1-based line number; a line matching none of
|
|
59
|
+
# the three shapes is refused by name, and so is empty input.
|
|
60
|
+
module Seq
|
|
61
|
+
def self.match_statement(line)
|
|
62
|
+
if (m = PART_RE.match(line))
|
|
63
|
+
{ type: :participant, name: m[1] }
|
|
64
|
+
elsif (m = ARROW_RE.match(line))
|
|
65
|
+
{ type: :message, kind: KIND[m[2]], from: m[1], to: m[3],
|
|
66
|
+
text: m[4].rstrip }
|
|
67
|
+
elsif (m = NOTE_RE.match(line))
|
|
68
|
+
{ type: :note, names: [m[1], m[2]].compact.uniq,
|
|
69
|
+
text: m[3].rstrip }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.parse_line(raw, lineno)
|
|
74
|
+
line = raw.strip
|
|
75
|
+
return nil if line.empty? || line.start_with?("#")
|
|
76
|
+
|
|
77
|
+
stmt = match_statement(line)
|
|
78
|
+
bad!(lineno, line) unless stmt
|
|
79
|
+
stmt.merge(line: lineno)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.parse(text)
|
|
83
|
+
statements = text.split("\n", -1).each_with_index
|
|
84
|
+
.map { |raw, i| parse_line(raw, i + 1) }.compact
|
|
85
|
+
bad!(0, "no statements in the input") if statements.empty?
|
|
86
|
+
statements
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.assign_numbers!(statements, numbered)
|
|
90
|
+
n = 0
|
|
91
|
+
statements.each do |s|
|
|
92
|
+
next unless s[:type] == :message
|
|
93
|
+
|
|
94
|
+
n += 1
|
|
95
|
+
s[:number] = numbered ? n : nil
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.label_text(stmt)
|
|
100
|
+
return stmt[:text] unless stmt[:number]
|
|
101
|
+
|
|
102
|
+
"#{stmt[:number]}. #{stmt[:text]}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.names_in(stmt)
|
|
106
|
+
case stmt[:type]
|
|
107
|
+
when :participant then [stmt[:name]]
|
|
108
|
+
when :message then [stmt[:from], stmt[:to]]
|
|
109
|
+
when :note then stmt[:names]
|
|
110
|
+
else []
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.ascii_name?(name) = name.each_char.all? { it.ord < 128 }
|
|
115
|
+
|
|
116
|
+
# Under ascii: true names, labels and notes are ASCII -- but
|
|
117
|
+
# under :cjk, where ascii: only means the structure.
|
|
118
|
+
def self.check_ascii_names!(statements, ascii)
|
|
119
|
+
return unless ascii && !Target.text_free?
|
|
120
|
+
|
|
121
|
+
statements.each { check_ascii_statement!(it) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.check_ascii_statement!(stmt)
|
|
125
|
+
name = names_in(stmt).find { !ascii_name?(it) }
|
|
126
|
+
line = stmt[:line]
|
|
127
|
+
bad!(line, "participant #{name.inspect} is not ASCII") if name
|
|
128
|
+
text = stmt[:text]
|
|
129
|
+
bad!(line, "text #{text.inspect} is not ASCII") if
|
|
130
|
+
text && !ascii_name?(text)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.gather_participants(stmts)
|
|
134
|
+
seen = {}
|
|
135
|
+
order = []
|
|
136
|
+
stmts.each do |s|
|
|
137
|
+
names_in(s).each do |n|
|
|
138
|
+
next if seen[n]
|
|
139
|
+
|
|
140
|
+
seen[n] = true
|
|
141
|
+
order << n
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
order
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Column layout, part 1: a participant's box (inner width and
|
|
149
|
+
# centre offset), the left-to-right positions gaps and margin
|
|
150
|
+
# produce, and the two primitives ("how much room is free to one
|
|
151
|
+
# side of a column") the growers below share.
|
|
152
|
+
module Seq
|
|
153
|
+
def self.measure_box(text)
|
|
154
|
+
inner = Width.of(text) + 2
|
|
155
|
+
inner += 1 if inner.even?
|
|
156
|
+
{ inner: inner, width: inner + 2, offset: (inner + 1) / 2 }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.positions_for(boxes, gaps, left_margin)
|
|
160
|
+
cursor = left_margin
|
|
161
|
+
boxes.each_with_index.map do |box, i|
|
|
162
|
+
left = cursor
|
|
163
|
+
cursor += box[:width] + gaps[i]
|
|
164
|
+
{ left: left, center: left + box[:offset], width: box[:width] }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.total_width(positions, gaps)
|
|
169
|
+
last = positions.last
|
|
170
|
+
last[:left] + last[:width] + gaps.last
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def self.right_room(boxes, pos, idx, trailing_gap)
|
|
174
|
+
return trailing_gap if idx == boxes.size - 1
|
|
175
|
+
|
|
176
|
+
pos[idx + 1][:center] - pos[idx][:center] - 1
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.left_room(pos, idx)
|
|
180
|
+
return pos[0][:center] if idx.zero?
|
|
181
|
+
|
|
182
|
+
pos[idx][:center] - pos[idx - 1][:center] - 1
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Column layout, part 2: state is { gaps:, margin:, boxes: },
|
|
187
|
+
# one Array entry per participant; ensure_width! grows the gap
|
|
188
|
+
# between two columns, grow_left!/grow_right! grow the margin or
|
|
189
|
+
# a neighbouring gap, and widen_for! dispatches a statement to
|
|
190
|
+
# whichever of those its shape (a message, a self call, a note
|
|
191
|
+
# over one or two participants) needs.
|
|
192
|
+
module Seq
|
|
193
|
+
def self.ensure_width!(state, lo, hi, required, pad)
|
|
194
|
+
pos = positions_for(state[:boxes], state[:gaps], state[:margin])
|
|
195
|
+
deficit = required - (pos[hi][:center] - pos[lo][:center] + pad)
|
|
196
|
+
state[:gaps][lo] += deficit if deficit.positive?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.grow_left!(state, idx, amount)
|
|
200
|
+
return unless amount.positive?
|
|
201
|
+
|
|
202
|
+
if idx.zero?
|
|
203
|
+
state[:margin] += amount
|
|
204
|
+
else
|
|
205
|
+
state[:gaps][idx - 1] += amount
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.grow_right!(state, idx, needed)
|
|
210
|
+
boxes = state[:boxes]
|
|
211
|
+
pos = positions_for(boxes, state[:gaps], state[:margin])
|
|
212
|
+
amount = needed - right_room(boxes, pos, idx, state[:gaps][idx])
|
|
213
|
+
state[:gaps][idx] += amount if amount.positive?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def self.ensure_centered!(state, idx, note_box)
|
|
217
|
+
half = note_box[:offset] + 1
|
|
218
|
+
pos = positions_for(state[:boxes], state[:gaps], state[:margin])
|
|
219
|
+
grow_left!(state, idx, half - left_room(pos, idx))
|
|
220
|
+
grow_right!(state, idx, half)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def self.self_loop_need(label) = LOOP_WIDTH + 3 + Width.of(label)
|
|
224
|
+
|
|
225
|
+
def self.widen_message!(state, participants, stmt)
|
|
226
|
+
i = participants.index(stmt[:from])
|
|
227
|
+
j = participants.index(stmt[:to])
|
|
228
|
+
label = label_text(stmt)
|
|
229
|
+
return grow_right!(state, i, self_loop_need(label)) if i == j
|
|
230
|
+
|
|
231
|
+
lo, hi = [i, j].minmax
|
|
232
|
+
required = Width.of(label) + 2
|
|
233
|
+
ensure_width!(state, lo, hi, required, -1)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.widen_note!(state, participants, stmt)
|
|
237
|
+
idx = stmt[:names].map { participants.index(it) }.sort
|
|
238
|
+
if idx.size == 1
|
|
239
|
+
ensure_centered!(state, idx.first, measure_box(stmt[:text]))
|
|
240
|
+
else
|
|
241
|
+
required = Width.of(stmt[:text]) + 2
|
|
242
|
+
ensure_width!(state, idx.first, idx.last, required, 1)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.widen_for!(state, participants, stmt)
|
|
247
|
+
case stmt[:type]
|
|
248
|
+
when :message then widen_message!(state, participants, stmt)
|
|
249
|
+
when :note then widen_note!(state, participants, stmt)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def self.layout(participants, statements)
|
|
254
|
+
boxes = participants.map { measure_box(it) }
|
|
255
|
+
state = { gaps: Array.new(participants.size, GAP), margin: 0,
|
|
256
|
+
boxes: boxes }
|
|
257
|
+
statements.each { |s| widen_for!(state, participants, s) }
|
|
258
|
+
pos = positions_for(boxes, state[:gaps], state[:margin])
|
|
259
|
+
{ positions: pos, boxes: boxes,
|
|
260
|
+
width: total_width(pos, state[:gaps]) }
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Drawing primitives on one row of characters: a blank row,
|
|
265
|
+
# placing text (a double-width glyph takes two columns, so the
|
|
266
|
+
# cell after it holds ""), a dashed or solid run, and a
|
|
267
|
+
# three-line box (top, centred text, bottom) with an optional
|
|
268
|
+
# tee in its top or bottom edge.
|
|
269
|
+
module Seq
|
|
270
|
+
def self.blank_row(width) = Array.new(width, " ")
|
|
271
|
+
|
|
272
|
+
def self.place!(row, col, text)
|
|
273
|
+
text.each_char do |ch|
|
|
274
|
+
w = Width.of(ch)
|
|
275
|
+
row[col] = ch
|
|
276
|
+
row[col + 1] = "" if w == 2
|
|
277
|
+
col += w
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.dash_line(inner, fill, tee, offset)
|
|
282
|
+
s = fill * inner
|
|
283
|
+
s[offset - 1] = tee if tee
|
|
284
|
+
s
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def self.box_rows(box, text, style, tee_top: nil, tee_bot: nil)
|
|
288
|
+
tl, tr, bl, br, h, v = style.values_at(:tl, :tr, :bl, :br, :h, :v)
|
|
289
|
+
inner = box[:inner]
|
|
290
|
+
offset = box[:offset]
|
|
291
|
+
top = tl + dash_line(inner, h, tee_top, offset) + tr
|
|
292
|
+
mid = v + Width.center(text, inner) + v
|
|
293
|
+
bot = bl + dash_line(inner, h, tee_bot, offset) + br
|
|
294
|
+
[top, mid, bot]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Full-size heads (not emoji-capable, though a font lacking
|
|
298
|
+
# them gives them a fallback width on the web); async a double
|
|
299
|
+
# head, as ->> writes it.
|
|
300
|
+
HEADS = { call: { right: "►", left: "◄" },
|
|
301
|
+
async: { right: "►►", left: "◄◄" } }.freeze
|
|
302
|
+
ASCII_HEADS = { call: { right: ">", left: "<" },
|
|
303
|
+
async: { right: ">>", left: "<<" } }.freeze
|
|
304
|
+
|
|
305
|
+
def self.arrowhead(kind, dir, style)
|
|
306
|
+
heads = style[:ascii] ? ASCII_HEADS : HEADS
|
|
307
|
+
heads[kind == :async ? :async : :call][dir]
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def self.alt_dashes(n) = (0...n).map { |i| i.even? ? "-" : " " }.join
|
|
311
|
+
|
|
312
|
+
def self.dash_run(n, dashed, style)
|
|
313
|
+
return "" if n <= 0
|
|
314
|
+
return alt_dashes(n) if style[:ascii] && dashed
|
|
315
|
+
|
|
316
|
+
(dashed ? style[:dash] : style[:h]) * n
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Row builders, part 1: the header/footer boxes and plain
|
|
321
|
+
# arrows. A lifeline row has the vertical bar at every
|
|
322
|
+
# participant's centre column; an arrow row adds its head and a
|
|
323
|
+
# dashed or solid run between two columns.
|
|
324
|
+
module Seq
|
|
325
|
+
def self.place_box!(rows, left, box)
|
|
326
|
+
box.each_with_index { |s, r| place!(rows[r], left, s) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def self.participant_lines(lay, participants, style, tee_top:,
|
|
330
|
+
tee_bot:)
|
|
331
|
+
positions, boxes, width = lay.values_at(:positions, :boxes,
|
|
332
|
+
:width)
|
|
333
|
+
rows = Array.new(3) { blank_row(width) }
|
|
334
|
+
participants.each_with_index do |name, i|
|
|
335
|
+
br = box_rows(boxes[i], name, style,
|
|
336
|
+
tee_top: tee_top, tee_bot: tee_bot)
|
|
337
|
+
place_box!(rows, positions[i][:left], br)
|
|
338
|
+
end
|
|
339
|
+
rows.map(&:join)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def self.lifeline_row(lay, style)
|
|
343
|
+
row = blank_row(lay[:width])
|
|
344
|
+
lay[:positions].each { |p| place!(row, p[:center], style[:v]) }
|
|
345
|
+
row
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def self.blank_line(lay, style) = lifeline_row(lay, style).join
|
|
349
|
+
|
|
350
|
+
def self.fill_arrow_row(row, spec, style, dashed)
|
|
351
|
+
lo, hi, kind, dir = spec.values_at(:lo, :hi, :kind, :dir)
|
|
352
|
+
gap = dash_run(hi - lo - 2, dashed, style)
|
|
353
|
+
head = arrowhead(kind, dir, style)
|
|
354
|
+
if dir == :right
|
|
355
|
+
place!(row, lo + 1, gap)
|
|
356
|
+
place!(row, hi - Width.of(head), head)
|
|
357
|
+
else
|
|
358
|
+
place!(row, lo + 2, gap)
|
|
359
|
+
place!(row, lo + 1, head)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def self.arrow_row(lay, spec, style)
|
|
364
|
+
row = lifeline_row(lay, style)
|
|
365
|
+
fill_arrow_row(row, spec, style, spec[:kind] == :reply)
|
|
366
|
+
row
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Only the label's own glyphs overwrite the lifeline row --
|
|
370
|
+
# not the padding either side of it -- so a lifeline the
|
|
371
|
+
# message happens to span still shows through the label row.
|
|
372
|
+
def self.centered_start(interior, text_width)
|
|
373
|
+
(interior - text_width) / 2
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def self.arrow_rows(lay, spec, label, style)
|
|
377
|
+
lo, hi = spec.values_at(:lo, :hi)
|
|
378
|
+
lr = lifeline_row(lay, style)
|
|
379
|
+
interior = hi - lo - 1
|
|
380
|
+
start = lo + 1 + centered_start(interior, Width.of(label))
|
|
381
|
+
place!(lr, start, label)
|
|
382
|
+
[lr.join, arrow_row(lay, spec, style).join]
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Row builders, part 2: a self call loops to the right of its
|
|
387
|
+
# own lifeline; a note is a small box over one participant's
|
|
388
|
+
# centre column, or spanning the columns of two.
|
|
389
|
+
module Seq
|
|
390
|
+
def self.self_loop_top(lay, style, kind, col)
|
|
391
|
+
row = lifeline_row(lay, style)
|
|
392
|
+
place!(row, col + 1, dash_run(LOOP_WIDTH - 1, kind == :reply,
|
|
393
|
+
style))
|
|
394
|
+
place!(row, col + LOOP_WIDTH, style[:tr])
|
|
395
|
+
row
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def self.self_loop_mid(lay, style, label, col)
|
|
399
|
+
row = lifeline_row(lay, style)
|
|
400
|
+
place!(row, col + LOOP_WIDTH, style[:v])
|
|
401
|
+
place!(row, col + LOOP_WIDTH + 2, label)
|
|
402
|
+
row
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def self.self_loop_bot(lay, style, kind, col)
|
|
406
|
+
row = lifeline_row(lay, style)
|
|
407
|
+
place!(row, col + 2, dash_run(LOOP_WIDTH - 2, kind == :reply,
|
|
408
|
+
style))
|
|
409
|
+
place!(row, col + 1, arrowhead(kind, :left, style))
|
|
410
|
+
place!(row, col + LOOP_WIDTH, style[:br])
|
|
411
|
+
row
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def self.self_loop_rows(lay, style, kind, label, col)
|
|
415
|
+
[self_loop_top(lay, style, kind, col),
|
|
416
|
+
self_loop_mid(lay, style, label, col),
|
|
417
|
+
self_loop_bot(lay, style, kind, col)].map(&:join)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def self.single_note(idx, positions, note_box)
|
|
421
|
+
{ left: positions[idx][:center] - note_box[:offset],
|
|
422
|
+
box: note_box }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def self.span_note(lo, hi, positions)
|
|
426
|
+
width = positions[hi][:center] - positions[lo][:center] + 1
|
|
427
|
+
{ left: positions[lo][:center],
|
|
428
|
+
box: { inner: width - 2, width: width } }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def self.note_spot(idx, positions, text)
|
|
432
|
+
return single_note(idx.first, positions, measure_box(text)) if
|
|
433
|
+
idx.size == 1
|
|
434
|
+
|
|
435
|
+
span_note(idx.first, idx.last, positions)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def self.overlay_rows(lay, style, rows, left)
|
|
439
|
+
canvas = rows.map { lifeline_row(lay, style) }
|
|
440
|
+
rows.each_with_index { |s, r| place!(canvas[r], left, s) }
|
|
441
|
+
canvas.map(&:join)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def self.note_rows(stmt, lay, participants, style)
|
|
445
|
+
idx = stmt[:names].map { participants.index(it) }.sort
|
|
446
|
+
spot = note_spot(idx, lay[:positions], stmt[:text])
|
|
447
|
+
rows = box_rows(spot[:box], stmt[:text], style)
|
|
448
|
+
overlay_rows(lay, style, rows, spot[:left])
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Dispatches a statement to its rows, and assembles the whole
|
|
453
|
+
# diagram: header boxes, a blank lifeline row and the statement's
|
|
454
|
+
# own rows for each statement that draws anything (a bare
|
|
455
|
+
# "participant" declaration draws nothing, and gets no spacer
|
|
456
|
+
# row either), a trailing blank row, and the footer boxes when
|
|
457
|
+
# asked for.
|
|
458
|
+
module Seq
|
|
459
|
+
def self.arrow_statement_rows(lay, idx_pair, kind, label, style)
|
|
460
|
+
i, j = idx_pair
|
|
461
|
+
lo, hi = idx_pair.minmax
|
|
462
|
+
dir = i < j ? :right : :left
|
|
463
|
+
pos = lay[:positions]
|
|
464
|
+
spec = { lo: pos[lo][:center], hi: pos[hi][:center],
|
|
465
|
+
kind: kind, dir: dir }
|
|
466
|
+
arrow_rows(lay, spec, label, style)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def self.message_statement_rows(stmt, lay, participants, style)
|
|
470
|
+
i = participants.index(stmt[:from])
|
|
471
|
+
j = participants.index(stmt[:to])
|
|
472
|
+
label = label_text(stmt)
|
|
473
|
+
if i == j
|
|
474
|
+
center = lay[:positions][i][:center]
|
|
475
|
+
return self_loop_rows(lay, style, stmt[:kind], label, center)
|
|
476
|
+
end
|
|
477
|
+
arrow_statement_rows(lay, [i, j], stmt[:kind], label, style)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def self.statement_rows(stmt, lay, participants, style)
|
|
481
|
+
case stmt[:type]
|
|
482
|
+
when :message
|
|
483
|
+
message_statement_rows(stmt, lay, participants, style)
|
|
484
|
+
when :note
|
|
485
|
+
note_rows(stmt, lay, participants, style)
|
|
486
|
+
else
|
|
487
|
+
[]
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def self.append_footer(lines, lay, participants, style, footer)
|
|
492
|
+
return lines unless footer
|
|
493
|
+
|
|
494
|
+
footer_style = { tee_top: style[:tu], tee_bot: nil }
|
|
495
|
+
lines.concat(participant_lines(lay, participants, style,
|
|
496
|
+
**footer_style))
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def self.append_statement(lines, s, lay, participants, style)
|
|
500
|
+
rows = statement_rows(s, lay, participants, style)
|
|
501
|
+
return if rows.empty?
|
|
502
|
+
|
|
503
|
+
lines << blank_line(lay, style)
|
|
504
|
+
lines.concat(rows)
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def self.render(statements, lay, participants, style, footer)
|
|
508
|
+
header_style = { tee_top: nil, tee_bot: style[:td] }
|
|
509
|
+
lines = participant_lines(lay, participants, style,
|
|
510
|
+
**header_style)
|
|
511
|
+
statements.each do |s|
|
|
512
|
+
append_statement(lines, s, lay, participants, style)
|
|
513
|
+
end
|
|
514
|
+
lines << blank_line(lay, style)
|
|
515
|
+
append_footer(lines, lay, participants, style, footer)
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def self.seq(text, ascii: Target.ascii?, numbered: false, footer: false)
|
|
520
|
+
Seq.build(text, ascii: ascii, numbered: numbered, footer: footer)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tessella
|
|
4
|
+
# A sparkline (DESIGN.md, "Meters and rules"): one line, one cell a
|
|
5
|
+
# value, eight steps from the lowest to the highest -- "▁▂▃▅▇" -- or
|
|
6
|
+
# eight ASCII levels under ascii: true. width: averages the values
|
|
7
|
+
# into that many cells (or repeats them to fill it); min: and max:
|
|
8
|
+
# fix the range, so lines drawn apart share one scale.
|
|
9
|
+
module Sparkline
|
|
10
|
+
RISE = "▁▂▃▄▅▆▇█"
|
|
11
|
+
ASCII = "_.-:=+*#"
|
|
12
|
+
|
|
13
|
+
def self.build(values, width: nil, min: nil, max: nil,
|
|
14
|
+
ascii: Target.ascii?)
|
|
15
|
+
vals = Check.finite!(Array(values), "value")
|
|
16
|
+
raise ArgumentError, "a sparkline needs values" if vals.empty?
|
|
17
|
+
|
|
18
|
+
vals = resample(vals, Check.positive_integer!(width, "width")) if
|
|
19
|
+
width
|
|
20
|
+
glyphs = ascii ? ASCII : RISE
|
|
21
|
+
lo, hi = range(vals, min, max)
|
|
22
|
+
steps = glyphs.size
|
|
23
|
+
Block.new([vals.map { glyphs[level(it, lo, hi, steps)] }.join])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.range(vals, min, max)
|
|
27
|
+
lo = min || vals.min
|
|
28
|
+
hi = max || vals.max
|
|
29
|
+
Check.finite!([lo, hi], "min/max")
|
|
30
|
+
raise ArgumentError, "min #{lo} is above max #{hi}" if lo > hi
|
|
31
|
+
|
|
32
|
+
[lo, hi]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The step for v: the middle one when every value is the same.
|
|
36
|
+
def self.level(v, lo, hi, steps)
|
|
37
|
+
return steps / 2 if hi == lo
|
|
38
|
+
|
|
39
|
+
(v.clamp(lo, hi) - lo).fdiv(hi - lo).*(steps - 1).round
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# width cells: the mean of the values falling in each, or the
|
|
43
|
+
# nearest value when there are fewer values than cells.
|
|
44
|
+
def self.resample(vals, width)
|
|
45
|
+
n = vals.size
|
|
46
|
+
(0...width).map do |i|
|
|
47
|
+
lo = i * n / width
|
|
48
|
+
hi = [((i + 1) * n / width), lo + 1].max
|
|
49
|
+
bucket = vals[lo...hi]
|
|
50
|
+
bucket.sum.fdiv(bucket.size)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# values, width: nil, min: nil, max: nil, ascii: false.
|
|
56
|
+
def self.sparkline(values, **) = Sparkline.build(values, **)
|
|
57
|
+
end
|