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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-runtime.md +19 -0
- data/docs/adr/002-scene.md +18 -0
- data/docs/adr/003-layout.md +20 -0
- data/docs/adr/004-parser.md +19 -0
- data/docs/adr/005-public-api.md +23 -0
- data/docs/adr/README.md +10 -0
- data/docs/syntax/additional.md +58 -0
- data/docs/syntax/class.md +3 -0
- data/docs/syntax/er.md +3 -0
- data/docs/syntax/flowchart.md +16 -0
- data/docs/syntax/gantt.md +3 -0
- data/docs/syntax/mindmap.md +3 -0
- data/docs/syntax/pie.md +3 -0
- data/docs/syntax/sequence.md +14 -0
- data/docs/syntax/state.md +3 -0
- data/docs/syntax/timeline.md +3 -0
- data/docs/syntax/xychart.md +3 -0
- data/exe/merminal +7 -0
- data/lib/merminal/cli.rb +94 -0
- data/lib/merminal/diagrams/additional.rb +257 -0
- data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
- data/lib/merminal/diagrams/base.rb +65 -0
- data/lib/merminal/diagrams/gantt.rb +109 -0
- data/lib/merminal/diagrams/mindmap.rb +94 -0
- data/lib/merminal/diagrams/pie.rb +64 -0
- data/lib/merminal/diagrams/sequence.rb +186 -0
- data/lib/merminal/diagrams/structure.rb +316 -0
- data/lib/merminal/diagrams/timeline.rb +60 -0
- data/lib/merminal/diagrams/xychart.rb +152 -0
- data/lib/merminal/flowchart/layout.rb +734 -0
- data/lib/merminal/flowchart.rb +279 -0
- data/lib/merminal/markdown.rb +37 -0
- data/lib/merminal/output.rb +93 -0
- data/lib/merminal/raster/box_drawing_table.rb +6 -0
- data/lib/merminal/raster.rb +175 -0
- data/lib/merminal/scene.rb +37 -0
- data/lib/merminal/shareable.rb +9 -0
- data/lib/merminal/source.rb +78 -0
- data/lib/merminal/text/east_asian_width_table.rb +8 -0
- data/lib/merminal/text.rb +110 -0
- data/lib/merminal/version.rb +5 -0
- data/lib/merminal.rb +122 -0
- data/sig/merminal.rbs +59 -0
- 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
|