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,1274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Diagrams::Additional::Specialized
4
+ Scene = Merminal::Scene
5
+ Text = Merminal::Text
6
+ Builder = Merminal::Diagrams::Builder
7
+ Flowchart = Merminal::Flowchart
8
+
9
+ module_function
10
+
11
+ def scene(ast, **options)
12
+ case ast.keyword
13
+ when "journey" then journey(ast)
14
+ when "quadrantChart" then quadrant(ast)
15
+ when "requirementDiagram" then requirement(ast)
16
+ when "usecaseDiagram", "usecase-beta" then usecase(ast, **options)
17
+ when "gitGraph" then git_graph(ast, **options)
18
+ when /^C4/ then c4(ast, **options)
19
+ when "zenuml" then zenuml(ast)
20
+ when "sankey-beta" then sankey(ast, **options)
21
+ when "block-beta" then block(ast)
22
+ when "packet-beta" then packet(ast)
23
+ when "kanban" then kanban(ast)
24
+ when "architecture-beta" then architecture(ast, **options)
25
+ when "radar-beta" then radar(ast)
26
+ when "treemap-beta" then treemap(ast)
27
+ when "venn-beta" then venn(ast)
28
+ when "ishikawa-beta" then ishikawa(ast)
29
+ when "wardley-beta" then wardley(ast)
30
+ when "treeView", "treeView-beta" then tree_view(ast)
31
+ when "cynefin-beta" then cynefin(ast)
32
+ when "swimlane-beta" then swimlane(ast, **options)
33
+ when "eventModeling" then event_modeling(ast)
34
+ when "agentflow-beta" then agentflow(ast, **options)
35
+ when "railroad-ebnf-beta", "railroad-abnf-beta", "railroad-peg-beta", "railroad-beta" then railroad(ast)
36
+ end
37
+ end
38
+
39
+ def railroad(ast)
40
+ rules = railroad_rules(ast)
41
+ builder = Builder.new
42
+ builder.text(0, 0, railroad_title(ast), role: :emphasis)
43
+ y = 2
44
+ rules.each do |name, expression|
45
+ branches = railroad_choices(expression)
46
+ branches = [expression] if branches.empty?
47
+ builder.text(0, y, name, role: :container_title)
48
+ branches.each_with_index do |branch, index|
49
+ row_y = y + 2 + index * 5
50
+ builder.marker(0, row_y + 1, kind: :circle, direction: :e)
51
+ x = 2
52
+ railroad_tokens(branch).each do |token, terminal|
53
+ label = Text.truncate(token, 18)
54
+ width = [Text.width(label) + 4, 6].max
55
+ builder.line([[x - 1, row_y + 1], [x, row_y + 1]])
56
+ builder.box(x, row_y, width, 3, rounded: terminal)
57
+ builder.text(x + 2, row_y + 1, label)
58
+ x += width + 1
59
+ end
60
+ builder.line([[x - 1, row_y + 1], [x, row_y + 1]])
61
+ builder.marker(x, row_y + 1, direction: :e)
62
+ end
63
+ y += [branches.length, 1].max * 5 + 3
64
+ end
65
+ builder.text(0, y, "No grammar rules", role: :container_title) if rules.empty?
66
+ builder.scene
67
+ end
68
+
69
+ def railroad_title(ast)
70
+ ast.title.to_s.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'").then do |title|
71
+ title.empty? ? "Railroad Diagram" : title
72
+ end
73
+ end
74
+
75
+ def railroad_rules(ast)
76
+ railroad_statements(ast.lines).filter_map do |statement|
77
+ match = statement.match(/\A\s*([A-Za-z_][\w-]*)\s*(::=|=|<-)\s*(.+?)\s*\z/m)
78
+ [match[1], match[3]] if match
79
+ end
80
+ end
81
+
82
+ def railroad_statements(lines)
83
+ statements = []
84
+ current = +""
85
+ quote = nil
86
+ comment = nil
87
+ depth = 0
88
+ lines.each do |line|
89
+ text = line.strip
90
+ next if text.empty? || text.match?(/\A(?:title|accTitle|accDescr)\b/i)
91
+
92
+ current << " " unless current.empty?
93
+ index = 0
94
+ while index < text.length
95
+ if comment
96
+ if text[index, comment.length] == comment
97
+ index += comment.length
98
+ comment = nil
99
+ else
100
+ index += 1
101
+ end
102
+ next
103
+ end
104
+
105
+ if quote.nil? && ["/*", "(*"].include?(text[index, 2])
106
+ comment = text[index, 2] == "/*" ? "*/" : "*)"
107
+ index += 2
108
+ next
109
+ end
110
+
111
+ char = text[index]
112
+ break if quote.nil? && char == "#"
113
+
114
+ if quote
115
+ quote = nil if quote == char
116
+ elsif ['"', "'"].include?(char)
117
+ quote = char
118
+ elsif "([{".include?(char)
119
+ depth += 1
120
+ elsif ")]}".include?(char) && depth.positive?
121
+ depth -= 1
122
+ end
123
+ current << char
124
+ if char == ";" && quote.nil? && depth.zero?
125
+ statements << current.delete_suffix(";").strip
126
+ current = +""
127
+ break
128
+ end
129
+ index += 1
130
+ end
131
+ end
132
+ statements << current.strip unless current.strip.empty?
133
+ statements
134
+ end
135
+
136
+ def railroad_choices(expression)
137
+ if (match = expression.match(/\Achoice\s*\((.*)\)\z/m))
138
+ return railroad_split(match[1], [","])
139
+ end
140
+
141
+ choices = []
142
+ current = +""
143
+ quote = nil
144
+ depth = 0
145
+ expression.each_char do |char|
146
+ if quote
147
+ quote = nil if quote == char
148
+ elsif ['"', "'"].include?(char)
149
+ quote = char
150
+ elsif "([{".include?(char)
151
+ depth += 1
152
+ elsif ")]}".include?(char) && depth.positive?
153
+ depth -= 1
154
+ end
155
+ if quote.nil? && depth.zero? && ["|", "/"].include?(char)
156
+ choices << current.strip
157
+ current = +""
158
+ else
159
+ current << char
160
+ end
161
+ end
162
+ choices << current.strip unless current.strip.empty?
163
+ choices.length > 1 ? choices : []
164
+ end
165
+
166
+ def railroad_split(expression, separators)
167
+ parts = []
168
+ current = +""
169
+ quote = nil
170
+ depth = 0
171
+ expression.each_char do |char|
172
+ if quote
173
+ quote = nil if quote == char
174
+ elsif ['"', "'"].include?(char)
175
+ quote = char
176
+ elsif "([{".include?(char)
177
+ depth += 1
178
+ elsif ")]}".include?(char) && depth.positive?
179
+ depth -= 1
180
+ end
181
+ if quote.nil? && depth.zero? && separators.include?(char)
182
+ parts << current.strip
183
+ current = +""
184
+ else
185
+ current << char
186
+ end
187
+ end
188
+ parts << current.strip unless current.strip.empty?
189
+ parts
190
+ end
191
+
192
+ def railroad_tokens(expression)
193
+ tokens = []
194
+ scanner = StringScanner.new(expression)
195
+ until scanner.eos?
196
+ scanner.skip(/\s+|,/)
197
+ break if scanner.eos?
198
+
199
+ next if scanner.scan(%r{/\*.*?\*/|\(\*.*?\*\)|#.*\z/m})
200
+
201
+ if (match = scanner.scan(/(?:terminal|nonterminal|special)\s*\(\s*(["'])(.*?)\1\s*\)/))
202
+ value = match[/["']((?:\\.|[^"'])*)["']/, 1]
203
+ tokens << [value, !match.start_with?("nonterminal")]
204
+ elsif (match = scanner.scan(/["']((?:\\.|[^"'])*)["']/))
205
+ tokens << [match[/["']((?:\\.|[^"'])*)["']/, 1], true]
206
+ elsif (match = scanner.scan(/%[xbd][0-9A-Fa-f.\-]+|[.!&]/))
207
+ tokens << [match, true]
208
+ elsif (match = scanner.scan(/\?([^?\n]+)\?/))
209
+ tokens << [match[/\?([^?\n]+)\?/, 1].strip, true]
210
+ elsif (match = scanner.scan(/[A-Za-z_][\w-]*/))
211
+ tokens << [match, false] unless %w[sequence choice optional zeroOrMore oneOrMore].include?(match)
212
+ elsif scanner.peek(1) && "?*+|/()[]{}-".include?(scanner.peek(1))
213
+ marker = scanner.getch
214
+ if %w[? * +].include?(marker) && tokens.last
215
+ tokens[-1][0] = "#{tokens.last[0]}#{marker}"
216
+ elsif marker == "-"
217
+ tokens << [marker, true]
218
+ elsif %w[? * +].include?(marker)
219
+ tokens << [marker, false]
220
+ end
221
+ else
222
+ scanner.getch
223
+ end
224
+ end
225
+ tokens
226
+ end
227
+
228
+ def journey(ast)
229
+ sections = []
230
+ section = "Journey"
231
+ ast.lines.each do |line|
232
+ if line =~ /\A\s*section\s+(.+)\z/i
233
+ section = Regexp.last_match(1).strip
234
+ sections << [section, []]
235
+ elsif line =~ /\A\s*(.+?):\s*(\d+)(?::\s*(.*))?\z/
236
+ sections << [section, []] if sections.empty? || sections.last.first != section
237
+ sections.last.last << [Regexp.last_match(1).strip, Regexp.last_match(2).to_i, Regexp.last_match(3).to_s.strip]
238
+ end
239
+ end
240
+ width = [sections.flat_map { |_, rows| rows }.map { |name, _, actors| Text.width("#{name} #{actors}") }.max.to_i + 18, 44].max
241
+ builder = Builder.new
242
+ y = 0
243
+ builder.text(0, y, ast.title || "User Journey", role: :emphasis)
244
+ y += 2
245
+ sections.each do |name, rows|
246
+ builder.box(0, y, width, 3, role: :container_border)
247
+ builder.text(2, y + 1, name, role: :container_title)
248
+ y += 4
249
+ rows.each do |label, score, actors|
250
+ builder.box(0, y, width, 3)
251
+ bar = "#" * [[score, 5].min, 0].max + "." * [5 - score, 0].max
252
+ builder.text(2, y + 1, "#{Text.pad(Text.truncate(label, 18), 18)} #{bar} #{actors}")
253
+ y += 4
254
+ end
255
+ end
256
+ builder.scene
257
+ end
258
+
259
+ def quadrant(ast)
260
+ x_labels = ast.lines.find { |line| line =~ /\Ax-axis\s+/i }.to_s.sub(/\Ax-axis\s+/i, "").split(/\s+-->\s+/)
261
+ y_labels = ast.lines.find { |line| line =~ /\Ay-axis\s+/i }.to_s.sub(/\Ay-axis\s+/i, "").split(/\s+-->\s+/)
262
+ quadrants = ast.lines.filter_map do |line|
263
+ match = line.match(/\A\s*quadrant-([1-4])\s+(.+)\z/i)
264
+ [match[1].to_i, match[2].strip] if match
265
+ end
266
+ points = ast.lines.filter_map do |line|
267
+ match = line.match(/\A\s*([^:]+):\s*\[\s*([\d.]+)\s*,\s*([\d.]+)\s*\]/)
268
+ [match[1].strip, match[2].to_f, match[3].to_f] if match
269
+ end
270
+ left, top, width, height = 8, 3, 48, 16
271
+ builder = Builder.new
272
+ builder.text(0, 0, ast.title || "Quadrant Chart", role: :emphasis)
273
+ builder.box(left, top, width, height)
274
+ builder.line([[left + width / 2, top], [left + width / 2, top + height - 1]])
275
+ builder.line([[left, top + height / 2], [left + width - 1, top + height / 2]])
276
+ builder.text(left, top + height, x_labels.first.to_s)
277
+ builder.text(left + width - Text.width(x_labels.last.to_s), top + height, x_labels.last.to_s)
278
+ builder.text(0, top + height - 1, y_labels.first.to_s)
279
+ builder.text(0, top, y_labels.last.to_s)
280
+ quadrant_positions = {
281
+ 1 => [left + width / 2 + 2, top + 1],
282
+ 2 => [left + 2, top + 1],
283
+ 3 => [left + 2, top + height / 2 + 1],
284
+ 4 => [left + width / 2 + 2, top + height / 2 + 1]
285
+ }
286
+ quadrants.each { |number, label| builder.text(*quadrant_positions.fetch(number), label) }
287
+ points.each do |label, x, y|
288
+ px = left + 1 + (x.clamp(0, 1) * (width - 3)).round
289
+ py = top + height - 2 - (y.clamp(0, 1) * (height - 3)).round
290
+ builder.glyph(px, py, "o")
291
+ builder.text(px + 2, py, label)
292
+ end
293
+ builder.scene
294
+ end
295
+
296
+ def requirement(ast)
297
+ requirements = []
298
+ current = nil
299
+ ast.lines.each do |line|
300
+ if line =~ /\A\s*(requirement|functionalRequirement|interfaceRequirement|performanceRequirement|physicalRequirement|designConstraint|element)\s+([^\s{]+)/i
301
+ current = { kind: Regexp.last_match(1), id: Regexp.last_match(2), values: {} }
302
+ requirements << current
303
+ elsif current && line =~ /\A\s*(id|text|risk|verifymethod|docref|type):\s*(.+)\z/i
304
+ current[:values][Regexp.last_match(1).downcase] = Regexp.last_match(2).strip
305
+ elsif line.strip == "}"
306
+ current = nil
307
+ end
308
+ end
309
+ return if requirements.empty?
310
+
311
+ relation_width = ast.edges.map { |edge| Text.width("#{edge.from} --#{edge.label || "relates"}--> #{edge.to}") }.max.to_i
312
+ detail_width = requirements.map do |item|
313
+ values = item[:values]
314
+ Text.width("risk: #{values["risk"] || "-"} verify: #{values["verifymethod"] || "-"} type: #{values["type"]} doc: #{values["docref"]}")
315
+ end.max.to_i
316
+ width = [requirements.map { |item| Text.width(item[:values]["text"].to_s) }.max.to_i + 24, detail_width + 4, relation_width + 4, 44].max
317
+ builder = Builder.new
318
+ builder.text(0, 0, ast.title || "Requirements", role: :emphasis)
319
+ y = 2
320
+ requirements.each do |item|
321
+ builder.box(0, y, width, 5, rounded: true)
322
+ values = item[:values]
323
+ builder.text(2, y + 1, "#{item[:kind]} #{item[:id]}", role: :container_title)
324
+ builder.text(2, y + 2, values["text"].to_s)
325
+ details = "risk: #{values["risk"] || "-"} verify: #{values["verifymethod"] || "-"}"
326
+ details += " type: #{values["type"]}" if values["type"]
327
+ details += " doc: #{values["docref"]}" if values["docref"]
328
+ builder.text(2, y + 3, details)
329
+ y += 6
330
+ end
331
+ unless ast.edges.empty?
332
+ builder.text(0, y, "Relations", role: :container_title)
333
+ y += 2
334
+ ast.edges.each do |edge|
335
+ builder.text(2, y, "#{edge.from} --#{edge.label || "relates"}--> #{edge.to}")
336
+ y += 2
337
+ end
338
+ end
339
+ builder.scene
340
+ end
341
+
342
+ def usecase(ast, **options)
343
+ return if ast.nodes.empty?
344
+
345
+ declarations = {}
346
+ actors = []
347
+ ast.lines.each do |line|
348
+ if (match = line.match(/\A\s*(?:actor|person)\s+([\w-]+)(?:\s+as\s+(.+))?/i))
349
+ id = match[1]
350
+ actors << id
351
+ declarations[id] = [match[2].to_s.strip.delete_prefix('"').delete_suffix('"').then { |label| label.empty? ? id : label }, :circle]
352
+ elsif (match = line.match(/\A\s*(?:usecase\s+)?["']([^"']+)["']\s+as\s+([\w-]+)/i))
353
+ declarations[match[2]] = [match[1], :stadium]
354
+ elsif (match = line.match(/\A\s*usecase\s+([\w-]+)(?:\s+as\s+(.+))?/i))
355
+ declarations[match[1]] = [match[2].to_s.strip.delete_prefix('"').delete_suffix('"').then { |label| label.empty? ? match[1] : label }, :stadium]
356
+ end
357
+ end
358
+ ast.lines.each do |line|
359
+ if (match = line.match(/\A\s*([\w-]+)\s*\[([^\]]+)\]/))
360
+ declarations[match[1]] = [match[2].strip.delete_prefix('"').delete_suffix('"'), :rectangle]
361
+ elsif (match = line.match(/\A\s*([\w-]+)\s*\(([^)]+)\)/))
362
+ declarations[match[1]] = [match[2].strip.delete_prefix('"').delete_suffix('"'), :stadium]
363
+ elsif (match = line.match(/\A\s*\(([^)]+)\)/))
364
+ id = match[1].gsub(/\W+/, "_")
365
+ declarations[id] = [match[1].strip.delete_prefix('"').delete_suffix('"'), :stadium]
366
+ end
367
+ end
368
+ boundaries = []
369
+ stack = []
370
+ ast.lines.each do |line|
371
+ if (match = line.match(/\A\s*(?:systemBoundary|rectangle)\s+([^\s\[{]+)(?:\[([^\]]+)\]|\(([^)]+)\))?\s*\{/i))
372
+ boundary = [match[1], (match[2] || match[3]).to_s.strip.then { |label| label.empty? ? match[1] : label }, []]
373
+ boundaries << boundary
374
+ stack << boundary
375
+ elsif line.strip == "}"
376
+ stack.pop
377
+ elsif stack.any?
378
+ declarations.keys.each { |id| stack.last[2] << id if line.match?(Regexp.new("\\b#{Regexp.escape(id)}\\b")) }
379
+ end
380
+ end
381
+ boundary_ids = boundaries.map(&:first)
382
+ inferred = ast.nodes.reject { |node| node.id.start_with?("line") || node.id == "title" || boundary_ids.include?(node.id) }.map(&:id)
383
+ known_ids = (inferred + actors + declarations.keys + ast.edges.flat_map { |edge| [edge.from, edge.to] }).uniq - boundary_ids
384
+ return if known_ids.empty?
385
+
386
+ nodes = known_ids.map do |id|
387
+ original = ast.nodes.find { |node| node.id == id }
388
+ label, shape = declarations.fetch(id, [original&.label || id, actors.include?(id) ? :circle : :stadium])
389
+ Flowchart::Node.new(id: id, label: label, shape: actors.include?(id) ? :circle : shape, classes: [].freeze, source_pos: [1, 1])
390
+ end
391
+ subgraphs = boundaries.map { |id, label, members| Flowchart::Subgraph.new(id: id, label: label, node_ids: members.uniq.freeze, parent: nil) }
392
+ edges = ast.edges.select { |edge| known_ids.include?(edge.from) && known_ids.include?(edge.to) }.each_with_index.map do |edge, index|
393
+ Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1])
394
+ end
395
+ flow_scene(nodes, edges, direction: ast.direction, subgraphs: subgraphs, **options)
396
+ end
397
+
398
+ def git_graph(ast, **options)
399
+ commits = []
400
+ branches = { "main" => nil }
401
+ current = "main"
402
+ attributes = lambda do |line|
403
+ [line[/\bid\s*:\s*["']?([^"'\s]+)["']?/i, 1],
404
+ line[/\btype\s*:\s*([A-Za-z]+)/i, 1].to_s.upcase,
405
+ line[/\btag\s*:\s*["']?([^"']+)["']?/i, 1]]
406
+ end
407
+ ast.lines.each do |line|
408
+ if line =~ /\A\s*commit\b/i
409
+ custom_id, type, tag = attributes.call(line)
410
+ id = custom_id || "c#{commits.length + 1}"
411
+ commits << [id, current, branches[current], nil, type, tag]
412
+ branches[current] = id
413
+ elsif line =~ /\A\s*branch\s+([^\s]+)/i
414
+ name = Regexp.last_match(1).delete_prefix('"').delete_suffix('"')
415
+ branches[name] = branches[current]
416
+ elsif line =~ /\A\s*(?:checkout|switch)\s+([^\s]+)/i
417
+ current = Regexp.last_match(1).delete_prefix('"').delete_suffix('"')
418
+ branches[current] ||= branches["main"]
419
+ elsif line =~ /\A\s*cherry-pick\b/i
420
+ source = line[/\bid\s*:\s*["']?([^"'\s]+)["']?/i, 1]
421
+ next unless source && commits.any? { |commit| commit[0] == source }
422
+
423
+ custom_id, type, tag = attributes.call(line)
424
+ id = custom_id || "c#{commits.length + 1}"
425
+ commits << [id, current, branches[current], source, type, tag]
426
+ branches[current] = id
427
+ elsif line =~ /\A\s*merge\s+([^\s]+)/i
428
+ source = branches[Regexp.last_match(1).delete_prefix('"').delete_suffix('"')]
429
+ custom_id, type, tag = attributes.call(line)
430
+ id = custom_id || "c#{commits.length + 1}"
431
+ commits << [id, current, branches[current], source, type, tag]
432
+ branches[current] = id
433
+ end
434
+ end
435
+ nodes = commits.map do |id, branch, _parent, _merge, type, tag|
436
+ shape = type == "REVERSE" ? :cross : type == "HIGHLIGHT" ? :rectangle : :circle
437
+ label = [id, branch, tag].compact.reject(&:empty?).join(" ")
438
+ Flowchart::Node.new(id: id, label: label, shape: shape, classes: [].freeze, source_pos: [1, 1])
439
+ end
440
+ edges = []
441
+ commits.each do |id, _branch, parent, merge, _type, _tag|
442
+ edges << Flowchart::Edge.new(id: edges.length, from: parent, to: id, label: nil, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) if parent
443
+ edges << Flowchart::Edge.new(id: edges.length, from: merge, to: id, label: "merge", stroke: :dotted, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) if merge
444
+ end
445
+ return if nodes.empty?
446
+
447
+ flow = Flowchart::Diagram.new(direction: ast.direction, nodes: nodes.freeze, edges: edges.freeze, subgraphs: [].freeze, styles: {}.freeze)
448
+ Flowchart.layout(flow, **options)
449
+ end
450
+
451
+ def c4(ast, **options)
452
+ return if ast.nodes.empty?
453
+
454
+ boundaries = []
455
+ stack = []
456
+ ast.lines.each do |line|
457
+ if (match = line.match(/\A\s*((?:Enterprise_)?Boundary|System_Boundary|Container_Boundary|Component_Boundary)\(([^,]+),\s*["']?([^"')]+)["']?\)\s*\{/i))
458
+ boundary = [match[2].strip, match[3].strip, [], stack.last&.first]
459
+ boundaries << boundary
460
+ stack << boundary
461
+ elsif line.strip == "}"
462
+ stack.pop
463
+ elsif stack.any?
464
+ ast.nodes.each { |node| stack.last[2] << node.id if line.match?(Regexp.new("\\b#{Regexp.escape(node.id)}\\b")) }
465
+ end
466
+ end
467
+ boundary_ids = boundaries.map(&:first)
468
+ nodes = ast.nodes.reject { |node| boundary_ids.include?(node.id) }.map do |node|
469
+ type = ast.lines.find { |line| line =~ /\A\s*([A-Za-z_]+)\(#{Regexp.escape(node.id)}[,\s]/ }&.match(/\A\s*([A-Za-z_]+)/)&.[](1).to_s
470
+ shape = if type =~ /Person/ || type == "Node" then :circle
471
+ elsif type =~ /Db/ then :database
472
+ elsif type =~ /Queue/ then :stadium
473
+ else :rectangle end
474
+ Flowchart::Node.new(id: node.id, label: node.label, shape: shape, classes: [].freeze, source_pos: [1, 1])
475
+ end
476
+ node_ids = nodes.map(&:id)
477
+ subgraphs = boundaries.map { |id, label, members, parent| Flowchart::Subgraph.new(id: id, label: label, node_ids: members.uniq.freeze, parent: parent) }
478
+ edges = ast.edges.select { |edge| node_ids.include?(edge.from) && node_ids.include?(edge.to) }.each_with_index.map do |edge, index|
479
+ Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1])
480
+ end
481
+ flow_scene(nodes, edges, direction: ast.direction, subgraphs: subgraphs, **options)
482
+ end
483
+
484
+ def zenuml(ast)
485
+ participants = []
486
+ messages = []
487
+ aliases = {}
488
+ ast.lines.each do |line|
489
+ if line =~ /\A\s*title\b/i || line.match?(/\A\s*\/\//)
490
+ next
491
+ elsif (match = line.match(/\A\s*}\s*(else(?:\s+if)?|catch|finally)(?:\s*\(([^)]*)\))?\s*\{\s*\z/i))
492
+ condition = match[2].to_s
493
+ messages << [nil, nil, "end", :fragment]
494
+ messages << [nil, nil, "#{match[1]}#{condition.empty? ? "" : "(#{condition})"}", :fragment]
495
+ elsif (match = line.match(/\A\s*(while|for|forEach|foreach|loop|if|else(?:\s+if)?|opt|par|try|catch|finally|break)(?:\s*\(([^)]*)\))?\s*\{\s*\z/i))
496
+ condition = match[2].to_s
497
+ messages << [nil, nil, "#{match[1]}#{condition.empty? ? "" : "(#{condition})"}", :fragment]
498
+ elsif line.match?(/\A\s*}\s*\z/)
499
+ messages << [nil, nil, "end", :fragment]
500
+ elsif line =~ /\A\s*([\w.-]+)\s+as\s+["']?(.+?)["']?\s*\z/i
501
+ aliases[Regexp.last_match(1)] = Regexp.last_match(2).strip
502
+ elsif line =~ /\A\s*([\w.-]+)\s*(?:->|-->|<-|<--)\s*([\w.-]+)(?:\s*:\s*(.*))?\z/
503
+ from, to, text = Regexp.last_match.captures
504
+ from, to = [to, from] if line.include?("<-")
505
+ participants |= [from, to]
506
+ messages << [from, to, text.to_s.strip]
507
+ elsif line =~ /\A\s*([\w.-]+)\.([\w.-]+)(?:\(([^)]*)\))?\s*\z/
508
+ participant, method, arguments = Regexp.last_match.captures
509
+ participants << participant
510
+ suffix = arguments.nil? ? "" : "(#{arguments})"
511
+ messages << [participant, participant, "#{method}#{suffix}"]
512
+ elsif line =~ /\A\s*(?:@\w+\s+|new\s+)?([\w.-]+)\s*\z/i
513
+ participants << Regexp.last_match(1)
514
+ end
515
+ end
516
+ participants = participants.map { |participant| aliases.fetch(participant, participant) }
517
+ messages = messages.map { |from, to, text| [aliases.fetch(from, from), aliases.fetch(to, to), text] }
518
+ sequence_scene(participants, messages, ast.title || "ZenUML")
519
+ end
520
+
521
+ def sankey(ast, **options)
522
+ flows = ast.lines.filter_map do |line|
523
+ fields = csv_fields(line)
524
+ next unless fields&.length == 3 && fields[2].to_s.match?(/\A\s*\d+(?:\.\d+)?\s*\z/)
525
+
526
+ [fields[0].strip, fields[1].strip, fields[2].strip]
527
+ end
528
+ return if flows.empty?
529
+
530
+ nodes = flows.flat_map { |from, to,| [from, to] }.uniq.map { |id| Flowchart::Node.new(id: id, label: id, shape: :rectangle, classes: [].freeze, source_pos: [1, 1]) }
531
+ edges = flows.each_with_index.map { |(from, to, amount), index| Flowchart::Edge.new(id: index, from: from, to: to, label: amount, stroke: :heavy, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) }
532
+ flow = Flowchart::Diagram.new(direction: :LR, nodes: nodes.freeze, edges: edges.freeze, subgraphs: [].freeze, styles: {}.freeze)
533
+ Flowchart.layout(flow, **options)
534
+ end
535
+
536
+ def csv_fields(line)
537
+ fields = []
538
+ field = +""
539
+ quoted = false
540
+ index = 0
541
+ while index < line.length
542
+ char = line[index]
543
+ if quoted
544
+ if char == '"' && line[index + 1] == '"'
545
+ field << '"'
546
+ index += 1
547
+ elsif char == '"'
548
+ quoted = false
549
+ else
550
+ field << char
551
+ end
552
+ elsif char == '"' && field.empty?
553
+ quoted = true
554
+ elsif char == ","
555
+ fields << field.strip
556
+ field = +""
557
+ else
558
+ field << char
559
+ end
560
+ index += 1
561
+ end
562
+ fields << field.strip
563
+ fields
564
+ end
565
+
566
+ def block(ast)
567
+ columns = ast.lines.find { |line| line =~ /\A\s*columns\s+(\d+)/i }&.match(/(\d+)/)&.[](1).to_i
568
+ columns = 3 if columns.zero?
569
+ links = ast.lines.filter_map do |line|
570
+ match = line.match(/\A\s*([\w-]+)\s*(?:-->|---)\s*([\w-]+)/)
571
+ [match[1], match[2]] if match
572
+ end
573
+ cells = ast.lines.reject { |line| line =~ /\A\s*(?:columns\b|[\w-]+\s*(?:-->|---)\s*[\w-]+)/i }
574
+ .reject { |line| line.strip.casecmp("end").zero? }
575
+ .flat_map { |line| block_cells(line) }
576
+ .each_slice(columns).to_a.reject(&:empty?)
577
+ cells << links.map { |from, to| "#{from} -> #{to}" } unless links.empty?
578
+ grid_scene(ast.title || "Block Diagram", cells)
579
+ end
580
+
581
+ def block_cells(line)
582
+ tokens = []
583
+ token = +""
584
+ depth = 0
585
+ quote = nil
586
+ line.each_char do |char|
587
+ if quote
588
+ quote = nil if char == quote
589
+ elsif ['"', "'"].include?(char)
590
+ quote = char
591
+ elsif "[({<".include?(char)
592
+ depth += 1
593
+ elsif "])}>".include?(char)
594
+ depth -= 1 if depth.positive?
595
+ elsif char.match?(/\s/) && depth.zero?
596
+ tokens << token unless token.empty?
597
+ token = +""
598
+ next
599
+ end
600
+ token << char
601
+ end
602
+ tokens << token unless token.empty?
603
+ tokens.flat_map do |cell|
604
+ if (space = cell.match(/\Aspace(?::(\d+))?\z/i))
605
+ Array.new([space[1].to_i, 1].max, "")
606
+ elsif (span = cell.match(/\A([\w-]+):(\d+)\z/))
607
+ [block_label(span[1])] + Array.new([span[2].to_i, 1].max - 1, "")
608
+ else
609
+ [block_label(cell)]
610
+ end
611
+ end
612
+ end
613
+
614
+ def block_label(cell)
615
+ label = cell[/\[\"?([^\]"]+)\"?\]/, 1] || cell[/\(\"?([^\)"]+)\"?\)/, 1]
616
+ label ||= cell[/<\[\"?([^\]"]*)\"?\]/, 1]
617
+ return label unless label.to_s.empty?
618
+
619
+ cell.sub(/\Ablock:/i, "").sub(/:([0-9]+)\z/, "")
620
+ end
621
+
622
+ def packet(ast)
623
+ bit = 0
624
+ fields = ast.lines.filter_map do |line|
625
+ match = line.match(/\A\s*(\+?\d+(?:-\d+)?)\s*:\s*["']?(.+?)["']?\s*(?:%%.*)?\z/)
626
+ next unless match
627
+
628
+ range = match[1]
629
+ start_bit, end_bit = if range.start_with?("+")
630
+ [bit, bit + range.delete_prefix("+").to_i - 1]
631
+ elsif range.include?("-")
632
+ range.split("-", 2).map(&:to_i)
633
+ else
634
+ value = range.to_i
635
+ [value, value]
636
+ end
637
+ bit = end_bit + 1
638
+ [start_bit, end_bit, match[2].strip]
639
+ end
640
+ fields = [[0, 0, "packet"]] if fields.empty?
641
+ builder = Builder.new
642
+ builder.text(0, 0, ast.title || "Packet", role: :emphasis)
643
+ total = [fields.map { |_start_bit, end_bit,| end_bit }.max.to_i + 1, 1].max
644
+ scale = [48.0 / total, 2.0].max
645
+ x = 0
646
+ fields.each do |start_bit, end_bit, label|
647
+ width = [[((end_bit - start_bit + 1) * scale).round, 4].max, 48 - x].min
648
+ builder.box(x, 3, width, 5)
649
+ builder.text(x + 1, 5, Text.truncate(label, width - 2))
650
+ builder.text(x + 1, 4, Text.truncate("#{start_bit}-#{end_bit}", width - 2))
651
+ x += width
652
+ end
653
+ builder.scene
654
+ end
655
+
656
+ def kanban(ast)
657
+ columns = []
658
+ current = nil
659
+ entries = ast.lines.reject { |line| line.strip.empty? }
660
+ base_indent = entries.map { |line| line[/\A\s*/].to_s.length }.min || 0
661
+ ast.lines.each do |line|
662
+ indent = line[/\A\s*/].to_s.length
663
+ content = line.strip
664
+ relative_indent = indent - base_indent
665
+ if relative_indent.zero? && content =~ /\A(?:([^\s\[]+)\s*)?\[([^\]]+)\]/
666
+ current = { name: Regexp.last_match(2), tasks: [] }
667
+ columns << current
668
+ elsif relative_indent.zero? && content =~ /\A([^\s\[]+)\s*\z/
669
+ current = { name: Regexp.last_match(1), tasks: [] }
670
+ columns << current
671
+ elsif current && relative_indent.positive? && (match = content.match(/\A(?:[^\s\[]+\s*)?\[([^\]]+)\](?:@\{\s*(.+?)\s*\})?/))
672
+ label = match[1]
673
+ label += " {#{match[2]}}" if match[2]
674
+ current[:tasks] << label
675
+ end
676
+ end
677
+ columns = [{ name: "Board", tasks: ast.nodes.map(&:label) }] if columns.empty?
678
+ width = columns.map { |column| [Text.width(column[:name]), column[:tasks].map { |task| Text.width(task) }.max.to_i].max + 4 }.max
679
+ builder = Builder.new
680
+ columns.each_with_index do |column, index|
681
+ x = index * (width + 2)
682
+ height = [column[:tasks].length * 4 + 3, 3].max
683
+ builder.box(x, 0, width, height, role: :container_border)
684
+ builder.text(x + 2, 1, column[:name], role: :container_title)
685
+ column[:tasks].each_with_index do |task, row|
686
+ builder.box(x + 1, 3 + row * 4, width - 2, 3, rounded: true)
687
+ builder.text(x + 2, 4 + row * 4, task)
688
+ end
689
+ end
690
+ builder.scene
691
+ end
692
+
693
+ def architecture(ast, **options)
694
+ groups = []
695
+ services = []
696
+ ast.lines.each do |line|
697
+ if line =~ /\A\s*group\s+([\w-]+)(?:\(([^)]*)\))?(?:\[([^\]]+)\])?(?:\s+in\s+([\w-]+))?/i
698
+ groups << [Regexp.last_match(1), Regexp.last_match(3).to_s.empty? ? Regexp.last_match(1) : Regexp.last_match(3), [], Regexp.last_match(4)]
699
+ elsif line =~ /\A\s*service\s+([\w-]+)(?:\(([^)]*)\))?(?:\[([^\]]+)\])?(?:\s+in\s+([\w-]+))?/i
700
+ services << [Regexp.last_match(1), Regexp.last_match(2).to_s, Regexp.last_match(3).to_s.empty? ? Regexp.last_match(1) : Regexp.last_match(3), Regexp.last_match(4)]
701
+ elsif line =~ /\A\s*junction\s+([\w-]+)/i
702
+ services << [Regexp.last_match(1), "", Regexp.last_match(1), nil]
703
+ end
704
+ end
705
+ return usecase(ast, **options) if services.empty?
706
+
707
+ known_ids = services.map(&:first)
708
+ ast.edges.each { |edge| known_ids |= [edge.from, edge.to] }
709
+ service_nodes = services + known_ids.reject { |id| services.any? { |service| service[0] == id } }.map { |id| [id, "", id, nil] }
710
+ nodes = service_nodes.map do |id, icon, label,|
711
+ shape = icon == "database" ? :database : icon == "cloud" ? :stadium : :rectangle
712
+ Flowchart::Node.new(id: id, label: label, shape: shape, classes: [].freeze, source_pos: [1, 1])
713
+ end
714
+ groups.each { |id, _label, members, _parent| members.concat(service_nodes.filter_map { |service| service[0] if service[3] == id }) }
715
+ subgraphs = groups.map { |id, label, members, parent| Flowchart::Subgraph.new(id: id, label: label, node_ids: members.freeze, parent: parent) }
716
+ flow = Flowchart::Diagram.new(direction: ast.direction, nodes: nodes.freeze,
717
+ edges: ast.edges.each_with_index.map { |edge, index| Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) }.freeze,
718
+ subgraphs: subgraphs.freeze, styles: {}.freeze)
719
+ Flowchart.layout(flow, **options)
720
+ end
721
+
722
+ def radar(ast)
723
+ axis_lines = ast.lines.select { |line| line =~ /\A\s*axis\b/i }
724
+ axes = axis_lines.flat_map { |line| line.sub(/\A\s*axis\s+/i, "").split(/\s*,\s*/) }.filter_map do |token|
725
+ match = token.match(/\A\s*([\w-]+)\s*\[\s*["']?([^\]"]+)["']?\s*\]\s*\z/)
726
+ next [match[1], match[2].strip] if match
727
+
728
+ label = token.strip.delete_prefix('"').delete_suffix('"')
729
+ [label, label] unless label.empty?
730
+ end
731
+ axes = %w[A B C D].map { |axis| [axis, axis] } if axes.empty?
732
+ curves = ast.lines.flat_map do |line|
733
+ line.scan(/([\w-]+)(?:\s*\[\s*["']?([^\]"]+)["']?\s*\])?\s*\{([^}]+)\}/i).map do |name, label, body|
734
+ values = body.split(/\s*,\s*/).filter_map do |part|
735
+ key, value = part.split(/\s*:\s*/, 2)
736
+ value ? [key.strip, value.to_f] : part.to_f
737
+ end
738
+ [name, label.to_s.strip, values]
739
+ end
740
+ end
741
+ max_value = ast.lines.find { |line| line =~ /\A\s*max\s+[-+]?\d+(?:\.\d+)?/i }&.match(/[-+]?\d+(?:\.\d+)?/)&.[](0)&.to_f
742
+ min_value = ast.lines.find { |line| line =~ /\A\s*min\s+[-+]?\d+(?:\.\d+)?/i }&.match(/[-+]?\d+(?:\.\d+)?/)&.[](0)&.to_f || 0
743
+ observed = curves.flat_map { |_, _, values| values.map { |value| value.is_a?(Array) ? value[1] : value } }
744
+ max_value ||= [observed.max.to_f, min_value + 1].max
745
+ range = [max_value - min_value, 1].max
746
+ radius = 8
747
+ cx = radius + 2
748
+ cy = radius + 2
749
+ builder = Builder.new
750
+ builder.text(0, 0, ast.title || "Radar", role: :emphasis)
751
+ axes.each_with_index do |(_id, label), index|
752
+ angle = (Math::PI * 2 * index / axes.length) - Math::PI / 2
753
+ ex = cx + (Math.cos(angle) * radius).round
754
+ ey = cy + (Math.sin(angle) * radius).round
755
+ builder.line([[cx, cy], [ex, cy], [ex, ey]])
756
+ builder.text(ex, ey, label)
757
+ end
758
+ curves.each_with_index do |(name, label, values), curve_index|
759
+ points = axes.each_index.map do |index|
760
+ value = if values.any? { |item| item.is_a?(Array) }
761
+ values.find { |axis,| axis == axes[index][0] }&.last.to_f
762
+ else
763
+ values.fetch(index, 0).to_f
764
+ end
765
+ value = (value - min_value) / range
766
+ angle = (Math::PI * 2 * index / axes.length) - Math::PI / 2
767
+ [cx + (Math.cos(angle) * radius * value).round, cy + (Math.sin(angle) * radius * value).round]
768
+ end
769
+ points.each_with_index do |point, index|
770
+ next_point = points[(index + 1) % points.length]
771
+ builder.line([[point[0], point[1]], [next_point[0], point[1]], [next_point[0], next_point[1]]])
772
+ end
773
+ builder.text(cx + radius + 4, cy + curve_index * 2 - 2, label.empty? ? name : label)
774
+ end
775
+ builder.glyph(cx, cy, "o") if curves.empty?
776
+ builder.scene
777
+ end
778
+
779
+ def treemap(ast)
780
+ nodes = []
781
+ stack = []
782
+ ast.lines.each do |line|
783
+ next if line.strip.empty? || line.match?(/\A\s*(?:classDef|style)\b/i)
784
+
785
+ value_match = line.match(/\s*:\s*(\d+(?:\.\d+)?)\s*\z/)
786
+ value = value_match && value_match[1].to_f
787
+ label = value_match ? line[0...value_match.begin(0)] : line
788
+ label = label.sub(/\s*:::[\w-]+\s*\z/, "").strip
789
+ label = label.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
790
+ next if label.empty?
791
+
792
+ depth = line[/\A\s*/].to_s.gsub("\t", " ").length / 2
793
+ node = { depth: depth, label: label, value: value, children: [] }
794
+ stack.pop while stack.any? && stack.last[:depth] >= depth
795
+ stack.last[:children] << node if stack.any?
796
+ stack << node
797
+ nodes << node
798
+ end
799
+ nodes = [{ depth: 0, label: "Root", value: 1.0, children: [] }] if nodes.empty?
800
+ total_value = lambda do |node|
801
+ node[:size] = node[:value] || node[:children].sum { |child| total_value.call(child) }
802
+ node[:size] = 1.0 if node[:size].zero?
803
+ node[:size]
804
+ end
805
+ nodes.select { |node| node[:depth].zero? }.each { |node| total_value.call(node) }
806
+ builder = Builder.new
807
+ builder.text(0, 0, ast.title || "Treemap", role: :emphasis)
808
+ nodes.group_by { |node| node[:depth] }.sort.each do |depth, row|
809
+ total = [row.sum { |node| node[:size] || total_value.call(node) }, 1].max
810
+ x = depth * 3
811
+ row.each do |node|
812
+ width = [[(48 * node[:size] / total).round, Text.width(node[:label]) + 4].max, 10].max
813
+ y = 2 + depth * 5
814
+ builder.box(x, y, width, 4, role: depth.zero? ? :container_border : :node_border)
815
+ builder.text(x + 2, y + 1, node[:label])
816
+ size = node[:size]
817
+ builder.text(x + 2, y + 2, size.to_i == size ? size.to_i.to_s : size.to_s)
818
+ x += width + 1
819
+ end
820
+ end
821
+ builder.scene
822
+ end
823
+
824
+ def venn(ast)
825
+ sets = []
826
+ unions = []
827
+ text_nodes = []
828
+ ast.lines.each do |line|
829
+ if (match = line.match(/\A\s*set\s+(?:"([^"]+)"|([^\s\[:]+))(?:\[\s*"?([^\]"]+)"?\s*\])?(?:\s*:\s*(\d+(?:\.\d+)?))?\s*\z/i))
830
+ id = match[1] || match[2]
831
+ sets << [id, match[3].to_s.empty? ? id : match[3], match[4]&.to_f]
832
+ elsif line.match?(/\A\s*union\s+/i)
833
+ body = line.sub(/\A\s*union\s+/i, "").strip
834
+ value = body[/:\s*(\d+(?:\.\d+)?)\s*\z/, 1]&.to_f
835
+ body = body.sub(/:\s*\d+(?:\.\d+)?\s*\z/, "")
836
+ label = body[/\[\s*["']?([^\]"']+)["']?\s*\]\z/, 1]
837
+ members = body.sub(/\[[^\]]+\]\s*\z/, "").split(/\s*[&,]\s*/).map { |member| member.delete_prefix('"').delete_suffix('"') }
838
+ unions << [members, label || members.join("&"), value]
839
+ elsif line.match?(/\A\s*text\s+/i)
840
+ body = line.sub(/\A\s*text\s+/i, "").strip
841
+ if (match = body.match(/\A([^\s\[]+)\s*\[\s*["']?([^\]"']+)["']?\s*\]\s*\z/))
842
+ text_nodes << [match[1], match[2]]
843
+ elsif (match = body.match(/\A([^:]+):\s*["']?(.+?)["']?\s*\z/))
844
+ text_nodes << [match[1].strip, match[2].strip]
845
+ end
846
+ end
847
+ end
848
+ sets = [["A", "A", 1.0], ["B", "B", 1.0]] if sets.empty?
849
+ builder = Builder.new
850
+ builder.text(0, 0, ast.title || "Venn Diagram", role: :emphasis)
851
+ positions = []
852
+ x = 0
853
+ label_x = 0
854
+ sets.each do |_id, label, value|
855
+ width = [Text.width(label) + 6, 24].max
856
+ positions << [x, width]
857
+ builder.box(x, 3, width, 8, rounded: true)
858
+ builder.text(label_x, 1, label)
859
+ builder.text(label_x, 2, value.to_s) if value
860
+ x += [width / 2, 10].max
861
+ label_x += width + 2
862
+ end
863
+ unions.each_with_index do |(_members, union_label, value), index|
864
+ label = union_label
865
+ label += ": #{value}" if value
866
+ center = positions.sum { |position, width| position + width / 2 } / [positions.length, 1].max
867
+ builder.text([center - Text.width(label) / 2, 0].max, 12 + index, label)
868
+ end
869
+ text_nodes.each_with_index do |(target, label), index|
870
+ builder.text(0, 14 + index, "#{target}: #{label}")
871
+ end
872
+ builder.scene
873
+ end
874
+
875
+ def ishikawa(ast)
876
+ problem = ast.lines.find { |line| !line.strip.empty? }&.strip || "Problem"
877
+ causes = ast.lines.drop(1).filter_map do |line|
878
+ next if line.strip.empty?
879
+
880
+ indent = line[/\A\s*/].to_s.gsub("\t", " ").length / 2
881
+ [indent, line.strip]
882
+ end
883
+ builder = Builder.new
884
+ builder.text(0, 0, ast.title || "Ishikawa", role: :emphasis)
885
+ y = [causes.length + 3, 6].max
886
+ builder.line([[0, y], [42, y]])
887
+ builder.text(43, y - 1, problem, role: :emphasis)
888
+ causes.each_with_index do |(indent, cause), index|
889
+ x = 4 + index * 8
890
+ branch_y = y - 3 - indent
891
+ builder.line([[x, y], [x, branch_y], [x + 5 + indent * 2, branch_y]])
892
+ builder.text(x + indent * 2, branch_y - 1, cause)
893
+ end
894
+ builder.scene
895
+ end
896
+
897
+ def wardley(ast)
898
+ points = ast.lines.filter_map do |line|
899
+ match = line.match(/\A\s*(?:anchor|component)\s+(.+?)\s*\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]/i)
900
+ next unless match
901
+
902
+ [match[1].strip.delete_prefix('"').delete_suffix('"'), match[2].to_f, match[3].to_f]
903
+ end
904
+ evolutions = ast.lines.filter_map do |line|
905
+ match = line.match(/\A\s*evolve\s+(.+?)\s+(\d+(?:\.\d+)?)\s*\z/i)
906
+ [match[1].strip.delete_prefix('"').delete_suffix('"'), match[2].to_f] if match
907
+ end.to_h
908
+ points = points.map { |label, visibility, evolution| [label, visibility, evolutions.fetch(label, evolution)] }
909
+ links = ast.lines.filter_map do |line|
910
+ match = line.match(/\A\s*(.+?)\s+\+['"](.+?)['"]>\s+(.+?)\s*\z/)
911
+ next [match[1].strip.delete_prefix('"').delete_suffix('"'), match[3].strip.delete_prefix('"').delete_suffix('"'), match[2]] if match
912
+
913
+ match = line.match(/\A\s*(.+?)\s+(?:-->|->|\+>|<\+|\+<|\+<>|-\.->)\s+(.+?)(?:\s*;\s*(.+?))?\s*\z/)
914
+ [match[1].strip.delete_prefix('"').delete_suffix('"'), match[2].strip.delete_prefix('"').delete_suffix('"'), match[3]&.strip] if match
915
+ end
916
+ trends = ast.lines.filter_map do |line|
917
+ match = line.match(/\A\s*(.+?)\s+-\.\-\s*\[?\(?\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]?\)?\s*\z/)
918
+ [match[1].strip.delete_prefix('"').delete_suffix('"'), match[2].to_f, match[3].to_f] if match
919
+ end
920
+ notes = ast.lines.filter_map do |line|
921
+ match = line.match(/\A\s*note\s+["'](.+?)["']\s*\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]\s*\z/i)
922
+ [match[1], match[2].to_f, match[3].to_f] if match
923
+ end
924
+ annotations = ast.lines.filter_map do |line|
925
+ match = line.match(/\A\s*annotation\s+(\d+)\s*,\s*\[\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\]\s+["'](.+?)["']\s*\z/i)
926
+ [match[1], match[2].to_f, match[3].to_f, match[4]] if match
927
+ end
928
+ decorators = ast.lines.filter_map do |line|
929
+ match = line.match(/\A\s*(?:anchor|component)\s+(.+?)\s*\[\s*\d+(?:\.\d+)?\s*,\s*\d+(?:\.\d+)?\s*\]\s*\((inertia|build|buy|outsource|market)\)\s*\z/i)
930
+ [match[1].strip.delete_prefix('"').delete_suffix('"'), match[2].downcase] if match
931
+ end.to_h
932
+ pipelines = []
933
+ pipeline_stack = []
934
+ ast.lines.each do |line|
935
+ if (match = line.match(/\A\s*pipeline\s+(.+?)\s*\{\s*\z/i))
936
+ pipeline = [match[1].strip.delete_prefix('"').delete_suffix('"'), []]
937
+ pipelines << pipeline
938
+ pipeline_stack << pipeline
939
+ elsif line.strip == "}"
940
+ pipeline_stack.pop unless pipeline_stack.empty?
941
+ elsif pipeline_stack.any? && (match = line.match(/\A\s*(?:anchor|component)\s+(.+?)\s*\[/i))
942
+ pipeline_stack.last[1] << match[1].strip.delete_prefix('"').delete_suffix('"')
943
+ end
944
+ end
945
+ evolution_stages = ast.lines.filter_map do |line|
946
+ match = line.match(/\A\s*evolution\s+(.+?)\s*\z/i)
947
+ match[1].split(/\s*->\s*/).map(&:strip) if match
948
+ end
949
+ builder = Builder.new
950
+ builder.text(0, 0, ast.title || "Wardley Map", role: :emphasis)
951
+ builder.line([[5, 3], [5, 19]])
952
+ builder.line([[5, 19], [55, 19]])
953
+ coordinates = points.to_h { |label, visibility, evolution| [label, [5 + (evolution.clamp(0, 1) * 48).round, 19 - (visibility.clamp(0, 1) * 14).round]] }
954
+ links.each do |from, to, label|
955
+ a = coordinates[from]
956
+ b = coordinates[to]
957
+ next unless a && b
958
+
959
+ builder.line([a, [b[0], a[1]], b])
960
+ builder.text((a[0] + b[0]) / 2, [a[1], b[1]].min - 1, label) if label
961
+ end
962
+ points.each do |label, visibility, evolution|
963
+ px = 5 + (evolution.clamp(0, 1) * 48).round
964
+ py = 19 - (visibility.clamp(0, 1) * 14).round
965
+ builder.glyph(px, py, "o")
966
+ builder.text(px + 2, py, label)
967
+ end
968
+ decorators.each do |label, decorator|
969
+ point = coordinates[label]
970
+ next unless point
971
+
972
+ builder.text(point[0] + 2, point[1] + 1, "(#{decorator})", role: :muted)
973
+ end
974
+ trends.each do |label, evolution, visibility|
975
+ point = coordinates[label]
976
+ next unless point
977
+
978
+ target = [5 + (evolution.clamp(0, 1) * 48).round, 19 - (visibility.clamp(0, 1) * 14).round]
979
+ builder.line([point, [target[0], point[1]], target])
980
+ builder.text(target[0] + 1, target[1], "trend")
981
+ end
982
+ notes.each do |text, visibility, evolution|
983
+ px = 5 + (evolution.clamp(0, 1) * 48).round
984
+ py = 19 - (visibility.clamp(0, 1) * 14).round
985
+ builder.text(px, py + 1, "note: #{text}", role: :container_title)
986
+ end
987
+ annotations.each do |number, x, y, text|
988
+ px = 5 + (x.clamp(0, 1) * 48).round
989
+ py = 19 - (y.clamp(0, 1) * 14).round
990
+ builder.text(px, py, "#{number}: #{text}", role: :container_title)
991
+ end
992
+ y = 22
993
+ unless pipelines.empty?
994
+ builder.text(0, y, "Pipelines", role: :container_title)
995
+ pipelines.each do |name, members|
996
+ label = "#{name}: #{members.join(' -> ')}"
997
+ builder.box(0, y + 1, [Text.width(label) + 4, 24].max, 3, role: :container_border)
998
+ builder.text(2, y + 2, label)
999
+ y += 4
1000
+ end
1001
+ end
1002
+ evolution_stages.each do |stages|
1003
+ label = "Evolution: #{stages.join(' -> ')}"
1004
+ builder.text(0, y, label, role: :container_title)
1005
+ y += 2
1006
+ end
1007
+ builder.scene
1008
+ end
1009
+
1010
+ def tree_view(ast)
1011
+ tree_scene(ast.title || "TreeView", ast.lines)
1012
+ end
1013
+
1014
+ def cynefin(ast)
1015
+ names = %w[clear complicated complex chaotic confusion]
1016
+ domains = names.to_h { |name| [name, []] }
1017
+ current = nil
1018
+ transitions = []
1019
+ ast.lines.each do |line|
1020
+ if (match = line.match(/\A\s*(#{names.join("|")})\s*\z/i))
1021
+ current = match[1].downcase
1022
+ elsif (match = line.match(/\A\s*(#{names.join("|")})\s*-->\s*(#{names.join("|")})(?:\s*:\s*["']?(.+?)["']?)?\s*\z/i))
1023
+ transitions << [match[1].downcase, match[2].downcase, match[3].to_s.strip]
1024
+ current = nil
1025
+ elsif current && line.strip != "" && !line.match?(/\Atitle\b/i)
1026
+ domains[current] << line.strip.delete_prefix('"').delete_suffix('"')
1027
+ end
1028
+ end
1029
+ domains = { "clear" => ["Sense", "Categorise", "Respond"], "complicated" => ["Sense", "Analyse", "Respond"],
1030
+ "complex" => ["Probe", "Sense", "Respond"], "chaotic" => ["Act", "Sense", "Respond"], "confusion" => [] } if domains.values.all?(&:empty?)
1031
+ builder = Builder.new
1032
+ builder.text(0, 0, ast.title || "Cynefin", role: :emphasis)
1033
+ labels = { "clear" => "Clear", "complicated" => "Complicated", "complex" => "Complex", "chaotic" => "Chaotic", "confusion" => "Confusion" }
1034
+ %w[complex complicated chaotic clear].each_with_index do |domain, index|
1035
+ x = index % 2 * 30
1036
+ y = 2 + index / 2 * 9
1037
+ height = [domains[domain].length * 2 + 4, 7].max
1038
+ builder.box(x, y, 28, height, role: :container_border)
1039
+ builder.text(x + 2, y + 1, labels[domain], role: :container_title)
1040
+ domains[domain].each_with_index { |item, row| builder.text(x + 2, y + 3 + row * 2, item) }
1041
+ end
1042
+ builder.box(15, 20, 28, [domains["confusion"].length * 2 + 4, 5].max, rounded: true)
1043
+ builder.text(17, 21, labels["confusion"], role: :container_title)
1044
+ domains["confusion"].each_with_index { |item, row| builder.text(17, 23 + row * 2, item) }
1045
+ transitions.each_with_index { |(from, to, label), index| builder.text(0, 27 + index * 2, "#{labels[from]} -> #{labels[to]} #{label}") }
1046
+ builder.scene
1047
+ end
1048
+
1049
+ def swimlane(ast, **options)
1050
+ lanes = []
1051
+ current = nil
1052
+ ast.lines.each do |line|
1053
+ if (match = line.match(/\A\s*subgraph\s+([^\[]+)(?:\[([^\]]+)\])?/i))
1054
+ current = [match[1].strip, match[2].to_s.strip, []]
1055
+ lanes << current
1056
+ elsif line.strip.casecmp("end").zero?
1057
+ current = nil
1058
+ elsif current
1059
+ ast.nodes.each { |node| current[2] << node.id if line.match?(Regexp.new("\\b#{Regexp.escape(node.id)}\\b")) }
1060
+ end
1061
+ end
1062
+ return if lanes.empty?
1063
+
1064
+ member_ids = lanes.flat_map { |_, _, members| members }.uniq
1065
+ nodes = ast.nodes.select { |node| member_ids.include?(node.id) }.map do |node|
1066
+ Flowchart::Node.new(id: node.id, label: node.label, shape: :rectangle, classes: [].freeze, source_pos: [1, 1])
1067
+ end
1068
+ subgraphs = lanes.map { |id, label, members| Flowchart::Subgraph.new(id: id, label: label.empty? ? id : label, node_ids: members.uniq.freeze, parent: nil) }
1069
+ flow = Flowchart::Diagram.new(direction: ast.direction, nodes: nodes.freeze,
1070
+ edges: ast.edges.each_with_index.map { |edge, index| Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) }.freeze,
1071
+ subgraphs: subgraphs.freeze, styles: {}.freeze)
1072
+ Flowchart.layout(flow, **options)
1073
+ end
1074
+
1075
+ def event_modeling(ast)
1076
+ relations = []
1077
+ frames = ast.lines.filter_map do |line|
1078
+ match = line.match(/\A\s*(tf|timeframe|rf|resetframe)\s+(\S+)\s+(\S+)\s+(.+)\z/i)
1079
+ next unless match
1080
+
1081
+ label = match[4].strip.delete_prefix('"').delete_suffix('"')
1082
+ inline_data = label[/\s+\{.*\}\s*\z/m]
1083
+ label = label.delete_suffix(inline_data.to_s).strip
1084
+ parts = label.split(/\s+->>\s+/).map(&:strip)
1085
+ if parts.length > 1
1086
+ parts.each_cons(2) { |from, to| relations << [from, to] }
1087
+ label = parts.first
1088
+ end
1089
+ type = case match[3].downcase
1090
+ when "ui", "processor", "pcr" then "pcr"
1091
+ when "command", "cmd", "readmodel", "rmo" then "cmd"
1092
+ when "event", "evt" then "evt"
1093
+ else match[3].downcase
1094
+ end
1095
+ label = "#{label}#{inline_data}" if inline_data
1096
+ namespace = label.split(/[.]/, 2).first if label.include?(".")
1097
+ lane = namespace ? "#{type}:#{namespace}" : type
1098
+ [match[2], lane, label, %w[rf resetframe].include?(match[1].downcase)]
1099
+ end
1100
+ return tree_scene(ast.title || "Event Modeling", ast.lines) if frames.empty?
1101
+
1102
+ data_blocks = []
1103
+ current_data = nil
1104
+ ast.lines.each do |line|
1105
+ if (match = line.match(/\A\s*data\s+(\S+)(?:\s+`[^`]+`)?\s*\{(.*)\z/i))
1106
+ current_data = { name: match[1], fields: [] }
1107
+ data_blocks << current_data
1108
+ inline = match[2].to_s.strip
1109
+ current_data[:fields] << inline unless inline.empty? || inline == "}"
1110
+ current_data = nil if line.include?("}")
1111
+ elsif current_data
1112
+ content = line.strip
1113
+ closing = content.end_with?("}")
1114
+ content = content.delete_suffix("}").strip if closing
1115
+ current_data[:fields] << content unless content.empty?
1116
+ current_data = nil if closing
1117
+ end
1118
+ end
1119
+ lanes = frames.map { |_, type,| type }.uniq
1120
+ gap = [frames.map { |number, _, label, reset| Text.width("#{reset ? "rf " : ""}#{number} #{label}") + 7 }.max.to_i, 14].max
1121
+ lane_width = [14 + (frames.length - 1) * gap + gap, 24].max
1122
+ builder = Builder.new
1123
+ builder.text(0, 0, ast.title || "Event Modeling", role: :emphasis)
1124
+ lanes.each_with_index do |lane, lane_index|
1125
+ y = 2 + lane_index * 5
1126
+ builder.box(0, y, lane_width, 4, role: :container_border)
1127
+ builder.text(2, y + 1, lane, role: :container_title)
1128
+ end
1129
+ frames.each_with_index do |(number, type, label, reset), index|
1130
+ lane_index = lanes.index(type)
1131
+ x = 14 + index * gap
1132
+ y = 2 + lane_index * 5
1133
+ display = "#{reset ? "rf " : ""}#{number} #{label}"
1134
+ builder.box(x, y, [Text.width(display) + 5, 10].max, 4, rounded: true)
1135
+ builder.text(x + 2, y + 1, display)
1136
+ builder.line([[x - 2, y + 2], [x, y + 2]]) if index.positive?
1137
+ end
1138
+ y = 2 + lanes.length * 5
1139
+ unless relations.empty?
1140
+ builder.text(0, y, "Relations", role: :container_title)
1141
+ relations.each_with_index { |(from, to), index| builder.text(2, y + 1 + index * 2, "#{from} ->> #{to}") }
1142
+ y += relations.length * 2 + 2
1143
+ end
1144
+ unless data_blocks.empty?
1145
+ builder.text(0, y, "Data", role: :container_title)
1146
+ data_blocks.each do |name|
1147
+ row = y + 1
1148
+ fields = name[:fields]
1149
+ lines = [name[:name], *fields]
1150
+ width = [lines.map { |line| Text.width(line) }.max.to_i + 4, 10].max
1151
+ height = [lines.length + 2, 3].max
1152
+ builder.box(0, row, width, height, rounded: true)
1153
+ lines.each_with_index { |line, line_index| builder.text(2, row + 1 + line_index, line) }
1154
+ y = row + height + 1
1155
+ end
1156
+ end
1157
+ builder.scene
1158
+ end
1159
+
1160
+ def agentflow(ast, **options)
1161
+ flows = []
1162
+ stack = []
1163
+ shapes = {}
1164
+ ast.lines.each do |line|
1165
+ if (match = line.match(/\A\s*flow\s+([\w-]+)\s*\[\"?([^\]"}]+)\"?\]/i))
1166
+ flow = [match[1], match[2].strip, []]
1167
+ flows << flow
1168
+ stack << flow
1169
+ elsif line.strip.casecmp("end").zero?
1170
+ stack.pop
1171
+ else
1172
+ ast.nodes.each do |node|
1173
+ next unless line.match?(Regexp.new("\\b#{Regexp.escape(node.id)}\\b"))
1174
+
1175
+ stack.last[2] << node.id if stack.any?
1176
+ if (match = line.match(/\b#{Regexp.escape(node.id)}\b[^@]*@\{\s*shape:\s*([\w-]+)/i))
1177
+ shapes[node.id] = { "input" => :parallelogram, "task" => :rectangle, "decision" => :decision,
1178
+ "start" => :stadium, "end" => :stadium }.fetch(match[1].downcase, :rectangle)
1179
+ end
1180
+ end
1181
+ end
1182
+ end
1183
+ nodes = ast.nodes.reject { |node| flows.any? { |id, _,| id == node.id } }.map do |node|
1184
+ label = node.label.delete_prefix('"').delete_suffix('"')
1185
+ Flowchart::Node.new(id: node.id, label: label, shape: shapes.fetch(node.id, :rectangle), classes: [].freeze, source_pos: [1, 1])
1186
+ end
1187
+ subgraphs = flows.map { |id, label, members| Flowchart::Subgraph.new(id: id, label: label, node_ids: members.uniq.freeze, parent: nil) }
1188
+ edges = ast.edges.each_with_index.map { |edge, index| Flowchart::Edge.new(id: index, from: edge.from, to: edge.to, label: edge.label, stroke: :light, start_marker: nil, end_marker: :arrow, minlen: 1, source_pos: [1, 1]) }
1189
+ if nodes.empty? && edges.empty?
1190
+ rows = flows.map { |id, label,| [label.empty? ? id : label] }
1191
+ return grid_scene(ast.title || "AgentFlow", rows) unless rows.empty?
1192
+ end
1193
+
1194
+ flow_scene(nodes, edges, direction: ast.direction, subgraphs: subgraphs, **options)
1195
+ end
1196
+
1197
+ def flow_scene(nodes, edges, direction:, subgraphs: [], **options)
1198
+ node_ids = nodes.map(&:id)
1199
+ edges = edges.select { |edge| node_ids.include?(edge.from) && node_ids.include?(edge.to) }
1200
+ flow = Flowchart::Diagram.new(direction: direction, nodes: nodes.freeze, edges: edges.freeze,
1201
+ subgraphs: subgraphs.freeze, styles: {}.freeze)
1202
+ Flowchart.layout(flow, **options)
1203
+ end
1204
+
1205
+ def sequence_scene(participants, messages, title)
1206
+ participants = ["Actor"] if participants.empty?
1207
+ builder = Builder.new
1208
+ gap = 14
1209
+ builder.text(0, 0, title, role: :emphasis)
1210
+ participants.each_with_index do |participant, index|
1211
+ x = index * gap
1212
+ builder.box(x, 2, [Text.width(participant) + 4, 9].max, 3, rounded: true)
1213
+ builder.text(x + 2, 3, participant)
1214
+ builder.line([[x + 3, 5], [x + 3, 5 + [messages.length, 1].max * 3]])
1215
+ end
1216
+ messages.each_with_index do |(from, to, text, kind), index|
1217
+ y = 6 + index * 3
1218
+ if kind == :fragment
1219
+ builder.text(1, y - 1, text, role: :container_title)
1220
+ next
1221
+ end
1222
+ x1 = participants.index(from).to_i * gap + 3
1223
+ x2 = participants.index(to).to_i * gap + 3
1224
+ builder.line([[x1, y], [x2, y]])
1225
+ builder.marker(x2, y, direction: x2 >= x1 ? :e : :w)
1226
+ builder.text([x1, x2].min + 1, y - 1, text)
1227
+ end
1228
+ builder.scene
1229
+ end
1230
+
1231
+ def grid_scene(title, rows)
1232
+ rows = [["block"]] if rows.empty?
1233
+ width = rows.flatten.reject(&:empty?).map { |cell| Text.width(cell) }.max.to_i + 4
1234
+ builder = Builder.new
1235
+ builder.text(0, 0, title, role: :emphasis)
1236
+ rows.each_with_index do |row, y|
1237
+ row.each_with_index do |cell, x|
1238
+ next if cell.empty?
1239
+
1240
+ builder.box(x * width, 2 + y * 4, width, 3)
1241
+ builder.text(x * width + 2, 3 + y * 4, cell)
1242
+ end
1243
+ end
1244
+ builder.scene
1245
+ end
1246
+
1247
+ def tree_scene(title, lines)
1248
+ builder = Builder.new
1249
+ builder.text(0, 0, title, role: :emphasis)
1250
+ lines = ["root"] if lines.empty?
1251
+ lines.each_with_index do |line, index|
1252
+ branch = line.match(/[\u251c\u2514\u2523\u2517]/)
1253
+ if branch
1254
+ indent = Text.width(line[0...branch.begin(0)]) / 4 + 1
1255
+ label = line.sub(/.*?[\u251c\u2514\u2523\u2517][\u2500\u2501]+/, "").strip
1256
+ else
1257
+ indent = line[/\A\s*/].to_s.length / 2
1258
+ label = line.strip
1259
+ end
1260
+ description = label[/\s*##\s*(.+)\z/, 1]
1261
+ label = label.sub(/\s*##\s*.+\z/, "").sub(/\s*:::[\w-]+\s*\z/, "").strip
1262
+ label = label.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
1263
+ label += " (#{description})" if description
1264
+ x = indent * 4
1265
+ y = 2 + index * 2
1266
+ if indent.positive?
1267
+ builder.line([[x - 3, y], [x, y]])
1268
+ builder.line([[x - 3, y - 1], [x - 3, y]])
1269
+ end
1270
+ builder.text(x + (indent.positive? ? 1 : 0), y, label, role: label.end_with?("/") ? :emphasis : :node_text)
1271
+ end
1272
+ builder.scene
1273
+ end
1274
+ end