merminal 0.1.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +85 -0
  5. data/docs/adr/000-template.md +24 -0
  6. data/docs/adr/001-runtime.md +19 -0
  7. data/docs/adr/002-scene.md +18 -0
  8. data/docs/adr/003-layout.md +20 -0
  9. data/docs/adr/004-parser.md +19 -0
  10. data/docs/adr/005-public-api.md +23 -0
  11. data/docs/adr/README.md +10 -0
  12. data/docs/syntax/additional.md +58 -0
  13. data/docs/syntax/class.md +3 -0
  14. data/docs/syntax/er.md +3 -0
  15. data/docs/syntax/flowchart.md +16 -0
  16. data/docs/syntax/gantt.md +3 -0
  17. data/docs/syntax/mindmap.md +3 -0
  18. data/docs/syntax/pie.md +3 -0
  19. data/docs/syntax/sequence.md +14 -0
  20. data/docs/syntax/state.md +3 -0
  21. data/docs/syntax/timeline.md +3 -0
  22. data/docs/syntax/xychart.md +3 -0
  23. data/exe/merminal +7 -0
  24. data/lib/merminal/cli.rb +94 -0
  25. data/lib/merminal/diagrams/additional.rb +257 -0
  26. data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
  27. data/lib/merminal/diagrams/base.rb +65 -0
  28. data/lib/merminal/diagrams/gantt.rb +109 -0
  29. data/lib/merminal/diagrams/mindmap.rb +94 -0
  30. data/lib/merminal/diagrams/pie.rb +64 -0
  31. data/lib/merminal/diagrams/sequence.rb +186 -0
  32. data/lib/merminal/diagrams/structure.rb +316 -0
  33. data/lib/merminal/diagrams/timeline.rb +60 -0
  34. data/lib/merminal/diagrams/xychart.rb +152 -0
  35. data/lib/merminal/flowchart/layout.rb +734 -0
  36. data/lib/merminal/flowchart.rb +279 -0
  37. data/lib/merminal/markdown.rb +37 -0
  38. data/lib/merminal/output.rb +93 -0
  39. data/lib/merminal/raster/box_drawing_table.rb +6 -0
  40. data/lib/merminal/raster.rb +175 -0
  41. data/lib/merminal/scene.rb +37 -0
  42. data/lib/merminal/shareable.rb +9 -0
  43. data/lib/merminal/source.rb +78 -0
  44. data/lib/merminal/text/east_asian_width_table.rb +8 -0
  45. data/lib/merminal/text.rb +110 -0
  46. data/lib/merminal/version.rb +5 -0
  47. data/lib/merminal.rb +122 -0
  48. data/sig/merminal.rbs +59 -0
  49. metadata +88 -0
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Mutable construction of graphs shared by structural diagram parsers.
5
+ class Structure
6
+ attr_reader :nodes, :edges, :subgraphs, :styles
7
+
8
+ def initialize
9
+ @nodes = {}
10
+ @edges = []
11
+ @subgraphs = []
12
+ @styles = {}
13
+ end
14
+
15
+ def node(id, label: id, shape: :rectangle, line: 1)
16
+ previous = @nodes[id]
17
+ @nodes[id] = Flowchart::Node.new(id: id, label: label || previous&.label || id,
18
+ shape: shape || previous&.shape || :rectangle, classes: [], source_pos: [line, 1])
19
+ end
20
+
21
+ def edge(from, to, label: nil, stroke: :light, marker: :arrow, line: 1)
22
+ node(from) unless @nodes.key?(from)
23
+ node(to) unless @nodes.key?(to)
24
+ @edges << Flowchart::Edge.new(id: @edges.length, from: from, to: to, label: label, stroke: stroke,
25
+ start_marker: nil, end_marker: marker, minlen: 1, source_pos: [line, 1])
26
+ end
27
+
28
+ def diagram(direction = :TB)
29
+ Flowchart::Diagram.new(direction: direction, nodes: @nodes.values.freeze, edges: @edges.freeze,
30
+ subgraphs: @subgraphs.freeze, styles: @styles.freeze)
31
+ end
32
+ end
33
+
34
+ def self.endpoint_labels(scene, ast, key)
35
+ edge_lines = scene.items.grep(Scene::Polyline).select { |item| item.role == :edge }
36
+ labels = []
37
+ ast.edges.zip(edge_lines).each do |edge, line|
38
+ values = ast.styles["#{key}:#{edge.id}"]
39
+ next unless values && line
40
+
41
+ values.zip([line.points.first, line.points.last]).each_with_index do |(value, point), index|
42
+ next unless value && !value.empty?
43
+
44
+ x, y = point
45
+ if ast.direction == :LR || ast.direction == :RL
46
+ x += index.zero? ? 1 : -Text.width(value) - 1
47
+ y -= 1
48
+ else
49
+ x += index.zero? ? -Text.width(value) - 1 : 2
50
+ y += index.zero? ? 1 : -1
51
+ end
52
+ labels << Scene::Text.new(x: [x, 0].max, y: [y, 0].max, string: value, role: :edge_label,
53
+ layer: :label, emphasis: nil)
54
+ end
55
+ end
56
+ return scene if labels.empty?
57
+
58
+ width = [scene.width, labels.map { |item| item.x + Text.width(item.string) }.max].max
59
+ height = [scene.height, labels.map { |item| item.y + 1 }.max].max
60
+ Scene.new(width: width, height: height, items: (scene.items + labels).freeze)
61
+ end
62
+
63
+ # State transitions, choices and composite state frames.
64
+ module State
65
+ def self.diagram_type = :state
66
+ def self.keywords = %w[stateDiagram stateDiagram-v2]
67
+
68
+ def self.parse(source)
69
+ graph = Structure.new
70
+ findings = []
71
+ direction = :TB
72
+ stack = []
73
+ notes = []
74
+ open_note = nil
75
+ source.lines.drop(1).each_with_index do |line, index|
76
+ statement = line.strip
77
+ if open_note
78
+ if statement == "end note"
79
+ notes << [open_note[0], open_note[1], open_note[2].join("\n")]
80
+ open_note = nil
81
+ else
82
+ open_note[2] << statement
83
+ end
84
+ next
85
+ end
86
+ case statement
87
+ when /\Adirection\s+(TB|TD|BT|LR|RL)\z/
88
+ direction = Regexp.last_match(1).to_sym
89
+ when /\Astate\s+"([^"]+)"\s+as\s+(\w+)\z/
90
+ id = Regexp.last_match(2)
91
+ graph.node(id, label: Regexp.last_match(1), line: index + 2)
92
+ graph.subgraphs.each { |group| group.node_ids << id if stack.include?(group.id) }
93
+ when /\Astate\s+(\w+)\s*\{\z/
94
+ id = Regexp.last_match(1)
95
+ graph.subgraphs << Flowchart::Subgraph.new(id: id, label: id, node_ids: [], parent: stack.last)
96
+ stack << id
97
+ when /\Astate\s+(\w+)\s+<<(choice|fork|join)>>\z/
98
+ id = Regexp.last_match(1)
99
+ shape = Regexp.last_match(2) == "choice" ? :decision : Regexp.last_match(2).to_sym
100
+ graph.node(id, label: shape == :decision ? id : "", shape: shape)
101
+ graph.subgraphs.each { |group| group.node_ids << id if stack.include?(group.id) }
102
+ when "}"
103
+ stack.pop || findings << Merminal::Diagrams.finding("unexpected state end", source, index + 1)
104
+ when /\A(\[\*\]|[\w.-]+)\s*-->\s*(\[\*\]|[\w.-]+)(?:\s*:\s*(.+))?\z/
105
+ from, to, label = Regexp.last_match.captures
106
+ scope = stack.empty? ? "" : ":#{stack.join(':')}"
107
+ from = "__start#{scope}" if from == "[*]"
108
+ to = "__end#{scope}" if to == "[*]"
109
+ graph.node(from, label: "●", shape: :circle) if from.start_with?("__start")
110
+ graph.node(to, label: "◉", shape: :circle) if to.start_with?("__end")
111
+ graph.edge(from, to, label: label, line: index + 2)
112
+ graph.subgraphs.each { |group| group.node_ids.concat([from, to]) if stack.include?(group.id) }
113
+ when /\Anote\s+(left|right)\s+of\s+([\w.-]+)\s*:\s*(.+)\z/i
114
+ notes << [Regexp.last_match(1).downcase, Regexp.last_match(2), Regexp.last_match(3)]
115
+ when /\Anote\s+(left|right)\s+of\s+([\w.-]+)\z/i
116
+ open_note = [Regexp.last_match(1).downcase, Regexp.last_match(2), []]
117
+ when "end note"
118
+ findings << Merminal::Diagrams.finding("unexpected end note", source, index + 1)
119
+ when ""
120
+ next
121
+ else
122
+ findings << Merminal::Diagrams.finding("unrecognized state statement", source, index + 1)
123
+ end
124
+ end
125
+ findings << Merminal::Diagrams.finding("unclosed state note", source, source.lines.length - 1) if open_note
126
+ graph.styles["notes"] = notes unless notes.empty?
127
+ group_ids = graph.subgraphs.map(&:id)
128
+ graph.edges.each do |edge|
129
+ from = group_ids.include?(edge.from) ? edge.from : nil
130
+ to = group_ids.include?(edge.to) ? edge.to : nil
131
+ graph.styles["group_edge:#{edge.id}"] = [from, to] if from || to
132
+ end
133
+ graph.subgraphs.each do |group|
134
+ representative = group.node_ids.first
135
+ next unless representative
136
+
137
+ graph.edges.map! do |edge|
138
+ edge.with(from: edge.from == group.id ? representative : edge.from,
139
+ to: edge.to == group.id ? representative : edge.to)
140
+ end
141
+ graph.nodes.delete(group.id) unless group.node_ids.include?(group.id)
142
+ end
143
+ notes.each do |_, id, _|
144
+ findings << Merminal::Diagrams.finding("unknown state note target #{id}", source, source.lines.length - 1) unless graph.nodes.key?(id)
145
+ end
146
+ findings << Merminal::Diagrams.finding("unclosed composite state", source, source.lines.length - 1) unless stack.empty?
147
+ [graph.diagram(direction), findings]
148
+ end
149
+
150
+ def self.layout(ast, **options)
151
+ scene = Flowchart.layout(ast, **options)
152
+ notes = ast.styles["notes"]
153
+ return scene unless notes && !notes.empty?
154
+
155
+ visible_nodes = ast.nodes.reject { |node| %i[fork join].include?(node.shape) }
156
+ boxes = visible_nodes.map(&:id).zip(scene.items.grep(Scene::Box).select { |item| item.role == :node_border }).to_h
157
+ left_width = notes.select { |side, _, _| side == "left" }.map do |_, _, content|
158
+ content.split("\n").map { |line| Text.width(line) }.max.to_i + 4
159
+ end.max.to_i
160
+ shift = left_width.positive? ? left_width + 3 : 0
161
+ items = scene.items.map { |item| Scene.translate(item, dx: shift) }
162
+ width = scene.width + shift
163
+ height = scene.height
164
+ bottoms = { "left" => 0, "right" => 0 }
165
+ notes.each do |side, id, content|
166
+ target = boxes[id]&.rect
167
+ next unless target
168
+
169
+ lines = content.split("\n")
170
+ note_width = lines.map { |line| Text.width(line) }.max.to_i + 4
171
+ note_height = lines.length + 2
172
+ x = side == "left" ? 0 : scene.width + shift + 2
173
+ y = [target.y, bottoms[side]].max
174
+ bottoms[side] = y + note_height + 1
175
+ items << Scene::Box.new(rect: Scene::Rect.new(x: x, y: y, width: note_width, height: note_height),
176
+ stroke: Scene::LIGHT, corners: :sharp, role: :container_border, layer: :container)
177
+ lines.each_with_index do |line, index|
178
+ items << Scene::Text.new(x: x + 2, y: y + 1 + index, string: line, role: :node_text, layer: :label, emphasis: nil)
179
+ end
180
+ center_y = target.y + target.height / 2
181
+ points = side == "left" ? [[x + note_width - 1, y + note_height / 2], [target.x + shift, center_y]] :
182
+ [[target.x + shift + target.width - 1, center_y], [x, y + note_height / 2]]
183
+ bend = (points.first[0] + points.last[0]) / 2
184
+ items << Scene::Polyline.new(points: [points.first, [bend, points.first[1]], [bend, points.last[1]], points.last],
185
+ stroke: Scene::LIGHT, role: :edge, layer: :edge)
186
+ width = [width, x + note_width].max
187
+ height = [height, y + note_height].max
188
+ end
189
+ Scene.new(width: width, height: height, items: items.freeze)
190
+ end
191
+ end
192
+
193
+ # Class diagrams with compartmented boxes and relation endpoints.
194
+ module ClassDiagram
195
+ def self.diagram_type = :class
196
+ def self.keywords = %w[classDiagram]
197
+
198
+ def self.parse(source)
199
+ graph = Structure.new
200
+ findings = []
201
+ bodies = Hash.new { |hash, key| hash[key] = [] }
202
+ annotations = {}
203
+ current = nil
204
+ source.lines.drop(1).each_with_index do |line, index|
205
+ statement = line.strip
206
+ if current
207
+ if statement == "}"
208
+ current = nil
209
+ else
210
+ bodies[current] << statement unless statement.empty?
211
+ end
212
+ next
213
+ end
214
+ case statement
215
+ when /\Aclass\s+([\w.-]+)\s*\{\z/
216
+ current = Regexp.last_match(1)
217
+ graph.node(current)
218
+ when /\A(?:class\s+)?([\w.-]+)\s+<<([\w.-]+)>>\z/
219
+ id, annotation = Regexp.last_match.captures
220
+ graph.node(id)
221
+ annotations[id] = annotation
222
+ when /\Aclass\s+([\w.-]+)\z/
223
+ graph.node(Regexp.last_match(1))
224
+ when /\A([\w.-]+)\s*:\s*(.+)\z/
225
+ bodies[Regexp.last_match(1)] << Regexp.last_match(2)
226
+ graph.node(Regexp.last_match(1))
227
+ when /\A([\w.-]+)(?:\s+"([^"]+)")?\s+(<\|--|--\|>|\*--|--\*|o--|--o|\.\.\|>|<\|\.\.|\.\.>|<\.\.|-->|<--|--|\.\.)(?:\s+"([^"]+)")?\s+([\w.-]+)(?:\s*:\s*(.+))?\z/
228
+ left, left_mult, relation, right_mult, right, label = Regexp.last_match.captures
229
+ marker = relation.include?("|") ? :triangle : relation.include?("*") ? :diamond : relation.include?("o") ? :open_diamond : relation.include?(">") || relation.include?("<") ? :arrow : nil
230
+ from, to = relation.start_with?("<") || relation.start_with?("*") || relation.start_with?("o") ? [right, left] : [left, right]
231
+ graph.edge(from, to, label: label, stroke: relation.include?(".") ? :dotted : :light, marker: marker, line: index + 2)
232
+ graph.styles["mult:#{graph.edges.length - 1}"] = from == left ? [left_mult, right_mult] : [right_mult, left_mult]
233
+ when ""
234
+ next
235
+ else
236
+ findings << Merminal::Diagrams.finding("unrecognized class statement", source, index + 1)
237
+ end
238
+ end
239
+ findings << Merminal::Diagrams.finding("unclosed class body", source, source.lines.length - 1) if current
240
+ graph.nodes.each do |id, node|
241
+ body = bodies[id]
242
+ attributes, operations = body.partition { |item| !item.include?("(") }
243
+ name = annotations[id] ? "<<#{annotations[id]}>>\n#{id}" : node.label
244
+ graph.node(id, label: ([name, ""] + (attributes.empty? ? [" "] : attributes) +
245
+ [""] + (operations.empty? ? [" "] : operations)).join("\n"), shape: :rectangle)
246
+ end
247
+ [graph.diagram, findings]
248
+ end
249
+
250
+ def self.layout(ast, **options)
251
+ scene = Flowchart.layout(ast, **options)
252
+ boxes = scene.items.grep(Scene::Box).select { |item| item.role == :node_border }
253
+ dividers = ast.nodes.zip(boxes).flat_map do |node, box|
254
+ rect = box.rect
255
+ rows = node.label.split("\n", -1).flat_map do |line|
256
+ line.empty? ? [true] : Text.wrap(line, options.fetch(:max_label_width, 24),
257
+ ambiguous_width: options.fetch(:ambiguous_width, 1)).map { false }
258
+ end
259
+ rows.each_with_index.filter_map do |divider, index|
260
+ next unless divider
261
+
262
+ y = rect.y + index + 1
263
+ Scene::Polyline.new(points: [[rect.x, y], [rect.x + rect.width - 1, y]],
264
+ stroke: Scene::LIGHT, role: :node_border, layer: :node)
265
+ end
266
+ end
267
+ Merminal::Diagrams.endpoint_labels(scene.with(items: (scene.items + dividers).freeze), ast, "mult")
268
+ end
269
+ end
270
+
271
+ # Entity relationship diagrams with attribute tables.
272
+ module ER
273
+ CARDINALITY = { "||" => "1", "o|" => "0..1", "|o" => "0..1", "|{" => "1..*", "}|" => "1..*",
274
+ "o{" => "0..*", "}o" => "0..*" }.freeze
275
+ def self.diagram_type = :er
276
+ def self.keywords = %w[erDiagram]
277
+
278
+ def self.parse(source)
279
+ graph = Structure.new
280
+ findings = []
281
+ current = nil
282
+ attributes = Hash.new { |hash, key| hash[key] = [] }
283
+ source.lines.drop(1).each_with_index do |line, index|
284
+ statement = line.strip
285
+ if current
286
+ if statement == "}"
287
+ current = nil
288
+ elsif !statement.empty?
289
+ attributes[current] << statement.gsub(/\s+/, " ")
290
+ end
291
+ next
292
+ end
293
+ case statement
294
+ when /\A([\w.-]+)\s*\{\z/
295
+ current = Regexp.last_match(1)
296
+ graph.node(current)
297
+ when /\A([\w.-]+)\s+([|o{}]{2})(--|\.\.)([|o{}]{2})\s+([\w.-]+)\s*:\s*(.*)\z/
298
+ from, left, stroke, right, to, text = Regexp.last_match.captures
299
+ graph.edge(from, to, label: text, stroke: stroke == ".." ? :dotted : :light, marker: nil, line: index + 2)
300
+ graph.styles["card:#{graph.edges.length - 1}"] = [CARDINALITY[left] || left, CARDINALITY[right] || right]
301
+ when ""
302
+ next
303
+ else
304
+ findings << Merminal::Diagrams.finding("unrecognized ER statement", source, index + 1)
305
+ end
306
+ end
307
+ findings << Merminal::Diagrams.finding("unclosed entity body", source, source.lines.length - 1) if current
308
+ attributes.each { |id, rows| graph.node(id, label: ([id, ""] + rows).join("\n")) }
309
+ [graph.diagram, findings]
310
+ end
311
+
312
+ def self.layout(ast, **options)
313
+ Merminal::Diagrams.endpoint_labels(ClassDiagram.layout(ast, **options), ast, "card")
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Ordered timeline rendered as a vertical list.
5
+ module Timeline
6
+ Event = Data.define(:period, :text, :section)
7
+ Diagram = Data.define(:title, :events)
8
+ def self.diagram_type = :timeline
9
+ def self.keywords = %w[timeline]
10
+
11
+ def self.parse(source)
12
+ title = source.title
13
+ section = nil
14
+ events = []
15
+ findings = []
16
+ period = nil
17
+ source.lines.drop(1).each_with_index do |line, index|
18
+ case line.strip
19
+ when /\Atitle\s+(.+)\z/
20
+ title = Regexp.last_match(1)
21
+ when /\Asection\s+(.+)\z/
22
+ section = Regexp.last_match(1)
23
+ when /\A([^:]+?)\s*:\s*(.+)\z/
24
+ period = Regexp.last_match(1).strip
25
+ events << Event.new(period: period, text: Regexp.last_match(2).strip, section: section)
26
+ when /\A:\s*(.+)\z/
27
+ events << Event.new(period: period, text: Regexp.last_match(1).strip, section: section) if period
28
+ when ""
29
+ next
30
+ else
31
+ findings << Merminal::Diagrams.finding("unrecognized timeline statement", source, index + 1)
32
+ end
33
+ end
34
+ [Diagram.new(title: title, events: events.freeze), findings]
35
+ end
36
+
37
+ def self.layout(ast, charset: :unicode, **)
38
+ builder = Builder.new
39
+ y = 0
40
+ if ast.title
41
+ builder.text(0, y, ast.title, role: :emphasis)
42
+ y += 2
43
+ end
44
+ period_width = ast.events.map { |event| Text.width(event.period.to_s) }.max.to_i
45
+ section = nil
46
+ ast.events.each do |event|
47
+ if event.section && event.section != section
48
+ builder.text(0, y, event.section, role: :container_title)
49
+ y += 1
50
+ section = event.section
51
+ end
52
+ builder.text(0, y, Text.pad(event.period.to_s, period_width), role: :axis_label)
53
+ builder.text(period_width + 1, y, charset == :ascii ? "|" : "●", role: :marker)
54
+ builder.text(period_width + 3, y, event.text)
55
+ y += 1
56
+ end
57
+ builder.scene
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams
4
+ # Mermaid XY chart with vertical bars and braille line traces.
5
+ module XYChart
6
+ Diagram = Data.define(:title, :labels, :minimum, :maximum, :bars, :lines, :horizontal)
7
+ def self.diagram_type = :xychart
8
+ def self.keywords = %w[xychart-beta xychart]
9
+
10
+ def self.parse(source)
11
+ title = source.title
12
+ labels = []
13
+ minimum = 0.0
14
+ maximum = nil
15
+ bars = []
16
+ lines = []
17
+ findings = []
18
+ horizontal = source.lines.first.to_s.include?("horizontal")
19
+ source.lines.drop(1).each_with_index do |line, index|
20
+ case line.strip
21
+ when /\Atitle\s+["']?(.+?)["']?\z/
22
+ title = Regexp.last_match(1)
23
+ when /\Ax-axis\s+\[(.*)\]/
24
+ labels = Regexp.last_match(1).split(",").map { |item| item.strip.delete_prefix('"').delete_suffix('"') }
25
+ when /\Ay-axis\s+.*?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)\z/
26
+ minimum = Regexp.last_match(1).to_f
27
+ maximum = Regexp.last_match(2).to_f
28
+ when /\A(bar|line)\s+\[([^\]]*)\]/
29
+ kind = Regexp.last_match(1)
30
+ values = Regexp.last_match(2).split(",").map { |value| Float(value.strip, exception: false) }
31
+ if values.any?(&:nil?)
32
+ findings << Merminal::Diagrams.finding("invalid chart value", source, index + 1)
33
+ else
34
+ (kind == "bar" ? bars : lines) << values
35
+ end
36
+ when ""
37
+ next
38
+ else
39
+ findings << Merminal::Diagrams.finding("unrecognized xychart statement", source, index + 1)
40
+ end
41
+ end
42
+ [Diagram.new(title: title, labels: labels.freeze, minimum: minimum, maximum: maximum,
43
+ bars: bars.freeze, lines: lines.freeze, horizontal: horizontal), findings]
44
+ end
45
+
46
+ def self.layout(ast, charset: :unicode, **)
47
+ builder = Builder.new
48
+ all = ast.bars.flatten + ast.lines.flatten
49
+ count = [ast.labels.length, ast.bars.map(&:length).max.to_i, ast.lines.map(&:length).max.to_i].max
50
+ return builder.scene if count.zero?
51
+ return layout_horizontal(ast, charset, count, all) if ast.horizontal
52
+
53
+ minimum = ast.minimum
54
+ maximum = ast.maximum || [all.max.to_f, 1].max
55
+ maximum = minimum + 1 if maximum <= minimum
56
+ title_offset = ast.title ? 2 : 0
57
+ builder.text(0, 0, ast.title, role: :emphasis) if ast.title
58
+ plot_left = 7
59
+ plot_top = title_offset
60
+ plot_height = 10
61
+ step = [4, ast.bars.length * 2 + 2].max
62
+ plot_width = count * step
63
+ 6.times do |tick|
64
+ value = maximum - (maximum - minimum) * tick / 5.0
65
+ y = plot_top + tick * 2
66
+ label = format("%g", value).sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")
67
+ builder.text(0, y, Text.pad(label, 5, align: :right), role: :axis_label)
68
+ end
69
+ builder.line([[plot_left - 1, plot_top], [plot_left - 1, plot_top + plot_height],
70
+ [plot_left + plot_width, plot_top + plot_height]], role: :axis)
71
+ ast.bars.each_with_index do |series, series_index|
72
+ series.each_with_index do |value, index|
73
+ rows = ((value - minimum) / (maximum - minimum) * plot_height * 8).clamp(0, plot_height * 8).round
74
+ full, partial = rows.divmod(8)
75
+ x = plot_left + index * step + series_index * 2
76
+ full.times { |offset| builder.glyph(x, plot_top + plot_height - 1 - offset, charset == :ascii ? "#" : "█", role: :series_1) }
77
+ if partial.positive? && full < plot_height
78
+ char = charset == :ascii ? "#" : %w[▁ ▂ ▃ ▄ ▅ ▆ ▇][partial - 1]
79
+ builder.glyph(x, plot_top + plot_height - 1 - full, char, role: :series_1)
80
+ end
81
+ end
82
+ end
83
+ ast.lines.each_with_index do |series, series_index|
84
+ draw_line(builder, series, plot_left, plot_top, plot_height, step, minimum, maximum, charset, series_index)
85
+ end
86
+ count.times do |index|
87
+ label = ast.labels[index] || index.to_s
88
+ builder.text(plot_left + index * step, plot_top + plot_height + 1, label, role: :axis_label)
89
+ end
90
+ builder.scene
91
+ end
92
+
93
+ def self.layout_horizontal(ast, charset, count, all)
94
+ builder = Builder.new
95
+ y = 0
96
+ if ast.title
97
+ builder.text(0, y, ast.title, role: :emphasis)
98
+ y += 2
99
+ end
100
+ labels = count.times.map { |index| ast.labels[index] || index.to_s }
101
+ width = labels.map { |label| Text.width(label) }.max
102
+ maximum = ast.maximum || [all.max.to_f, 1].max
103
+ minimum = ast.minimum
104
+ maximum = minimum + 1 if maximum <= minimum
105
+ count.times do |index|
106
+ label = labels[index]
107
+ builder.text(0, y, Text.pad(label, width), role: :axis_label)
108
+ ast.bars.each do |series|
109
+ value = series[index].to_f
110
+ length = ((value - minimum) / (maximum - minimum) * 40).clamp(0, 40).round
111
+ builder.text(width + 2, y, (charset == :ascii ? "#" : "█") * length, role: :series_1)
112
+ builder.text(width + 43, y, format("%g", value), role: :axis_label)
113
+ y += 1
114
+ end
115
+ ast.lines.each do |series|
116
+ value = series[index].to_f
117
+ length = ((value - minimum) / (maximum - minimum) * 40).clamp(0, 40).round
118
+ builder.text(width + 2 + length, y, charset == :ascii ? "*" : "⠿", role: :series_2)
119
+ builder.text(width + 43, y, format("%g", value), role: :axis_label)
120
+ y += 1
121
+ end
122
+ end
123
+ builder.scene
124
+ end
125
+
126
+ def self.draw_line(builder, values, left, top, height, step, minimum, maximum, charset, series_index)
127
+ return if values.empty?
128
+
129
+ points = values.each_with_index.map do |value, index|
130
+ [index * step * 2 + step, ((maximum - value) / (maximum - minimum) * (height * 4 - 1)).clamp(0, height * 4 - 1).round]
131
+ end
132
+ if charset == :ascii
133
+ points.each { |x, y| builder.glyph(left + x / 2, top + y / 4, "*", role: :series_1) }
134
+ return
135
+ end
136
+ pixels = {}
137
+ points.each_cons(2) do |(x1, y1), (x2, y2)|
138
+ steps = [(x2 - x1).abs, (y2 - y1).abs, 1].max
139
+ (0..steps).each do |step_index|
140
+ x = (x1 * (steps - step_index) + x2 * step_index + steps / 2) / steps
141
+ y = (y1 * (steps - step_index) + y2 * step_index + steps / 2) / steps
142
+ bit = [[0, 1, 2, 6], [3, 4, 5, 7]][x % 2][y % 4]
143
+ key = [x / 2, y / 4]
144
+ pixels[key] = pixels.fetch(key, 0) | (1 << bit)
145
+ end
146
+ end
147
+ pixels.each do |(x, y), bits|
148
+ builder.glyph(left + x, top + y, (0x2800 + bits).chr(Encoding::UTF_8), role: :"series_#{series_index + 1}")
149
+ end
150
+ end
151
+ end
152
+ end