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,734 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Merminal::Flowchart
4
+ Scene = Merminal::Scene
5
+ Text = Merminal::Text
6
+
7
+ # Deterministic layered placement with channel routing.
8
+ class Layout
9
+ WorkNode = Data.define(:id, :kind, :label, :edge_id, :source, :target)
10
+
11
+ def initialize(ast, **options)
12
+ @ast = ast
13
+ @options = options
14
+ @horizontal = %i[LR RL].include?(options.fetch(:direction, ast.direction))
15
+ @reverse = %i[BT RL].include?(options.fetch(:direction, ast.direction))
16
+ @node_gap = options.fetch(:node_gap, 3)
17
+ @rank_gap = options.fetch(:rank_gap, 3)
18
+ @padding = options.fetch(:node_padding_x, 2)
19
+ @max_label_width = options.fetch(:max_label_width, 24)
20
+ @ambiguous_width = options.fetch(:ambiguous_width, 1)
21
+ @max_group_depth = @ast.subgraphs.map { |group| group_depth(group) }.max.to_i
22
+ @cluster_margin = @ast.subgraphs.empty? ? 0 : 2 * (@max_group_depth + 1)
23
+ @node_gap = [@node_gap, @cluster_margin * 2 + 1].max if @cluster_margin.positive?
24
+ @rank_gap = [@rank_gap, @cluster_margin * 2 + 1].max if @cluster_margin.positive?
25
+ @items = []
26
+ end
27
+
28
+ def scene
29
+ return Scene.new(width: 0, height: 0, items: [].freeze) if @ast.nodes.empty?
30
+
31
+ orient_edges
32
+ assign_ranks
33
+ normalize_edges
34
+ order_nodes
35
+ place_nodes
36
+ draw_nodes
37
+ draw_edges
38
+ add_subgraphs
39
+ avoid_unrelated_groups
40
+ clip_group_edges
41
+ draw_markers
42
+ flip_items if @reverse
43
+ Scene.new(width: @width, height: @height, items: @items.freeze)
44
+ end
45
+
46
+ private
47
+
48
+ def orient_edges
49
+ outgoing = @ast.nodes.to_h { |node| [node.id, []] }
50
+ @ast.edges.each { |edge| outgoing[edge.from] << edge if outgoing.key?(edge.from) }
51
+ state = {}
52
+ reversed = {}
53
+ visit = lambda do |id|
54
+ state[id] = :visiting
55
+ outgoing.fetch(id, []).each do |edge|
56
+ reversed[edge.id] = true if state[edge.to] == :visiting
57
+ visit.call(edge.to) unless state[edge.to] || !outgoing.key?(edge.to)
58
+ end
59
+ state[id] = :done
60
+ end
61
+ @ast.nodes.each { |node| visit.call(node.id) unless state[node.id] }
62
+ @oriented = @ast.edges.map { |edge| [edge, reversed[edge.id] ? edge.to : edge.from, reversed[edge.id] ? edge.from : edge.to, reversed.key?(edge.id)] }
63
+ end
64
+
65
+ def assign_ranks
66
+ @ranks = @ast.nodes.to_h { |node| [node.id, 0] }
67
+ indegree = @ranks.transform_values { 0 }
68
+ @oriented.each { |_, from, to, _| indegree[to] += 1 unless from == to }
69
+ queue = @ast.nodes.map(&:id).select { |id| indegree[id].zero? }
70
+ until queue.empty?
71
+ id = queue.shift
72
+ @oriented.each do |edge, from, to, _|
73
+ next unless from == id && to != id
74
+
75
+ distance = edge.label && !edge.label.empty? ? edge.minlen * 2 : edge.minlen
76
+ @ranks[to] = [@ranks[to], @ranks[id] + distance].max
77
+ indegree[to] -= 1
78
+ queue << to if indegree[to].zero?
79
+ end
80
+ end
81
+ # A malformed cycle can survive DFS when parallel edges are involved.
82
+ @ranks.keys.each { |id| @ranks[id] = 0 if indegree[id].positive? }
83
+ end
84
+
85
+ def normalize_edges
86
+ @work_nodes = @ast.nodes.dup
87
+ @segments = []
88
+ @edge_paths = {}
89
+ @membership = @ast.nodes.to_h do |node|
90
+ [node.id, @ast.subgraphs.select { |group| group.node_ids.include?(node.id) }.map(&:id)]
91
+ end
92
+ @oriented.each do |edge, from, to, _|
93
+ next if from == to
94
+
95
+ gap = @ranks[to] - @ranks[from]
96
+ path = [from]
97
+ if gap > 1
98
+ middle = @ranks[from] + gap / 2
99
+ (@ranks[from] + 1...@ranks[to]).each do |rank|
100
+ kind = edge.label && !edge.label.empty? && rank == middle ? :label : :dummy
101
+ id = "\0#{edge.id}:#{rank}"
102
+ node = WorkNode.new(id: id, kind: kind, label: kind == :label ? edge.label : nil,
103
+ edge_id: edge.id, source: from, target: to)
104
+ @work_nodes << node
105
+ @ranks[id] = rank
106
+ @membership[id] = @membership.fetch(from, []) & @membership.fetch(to, [])
107
+ path << id
108
+ end
109
+ end
110
+ path << to
111
+ @edge_paths[edge.id] = path
112
+ path.each_cons(2) { |a, b| @segments << [edge, a, b] }
113
+ end
114
+ @work_node_by_id = @work_nodes.grep(WorkNode).to_h { |node| [node.id, node] }
115
+ end
116
+
117
+ def order_nodes
118
+ @groups = @work_nodes.group_by { |node| @ranks[node.id] }
119
+
120
+ best = @groups.transform_values(&:dup)
121
+ best_crossings = crossing_count
122
+ stalled = 0
123
+ 8.times do |pass|
124
+ downward = pass.even?
125
+ ranks = downward ? @groups.keys.sort : @groups.keys.sort.reverse
126
+ ranks.each do |rank|
127
+ neighbor_rank = rank + (downward ? -1 : 1)
128
+ adjacent = @groups[neighbor_rank]
129
+ next unless adjacent
130
+
131
+ positions = adjacent.each_with_index.to_h { |node, index| [node.id, index] }
132
+ scores = @groups[rank].to_h do |node|
133
+ neighbors = @segments.filter_map do |_, from, to|
134
+ if downward && to == node.id && @ranks[from] == neighbor_rank
135
+ positions[from]
136
+ elsif !downward && from == node.id && @ranks[to] == neighbor_rank
137
+ positions[to]
138
+ end
139
+ end.sort
140
+ median = neighbors.empty? ? nil : (neighbors[(neighbors.length - 1) / 2] + neighbors[neighbors.length / 2]) / 2.0
141
+ [node.id, median]
142
+ end
143
+ @groups[rank] = sort_clustered(@groups[rank], scores)
144
+ end
145
+ count = crossing_count
146
+ if count < best_crossings
147
+ best = @groups.transform_values(&:dup)
148
+ best_crossings = count
149
+ stalled = 0
150
+ else
151
+ stalled += 1
152
+ break if stalled >= 2
153
+ end
154
+ end
155
+ @groups = best
156
+ end
157
+
158
+ def sort_clustered(nodes, scores, parent = nil, seen = [])
159
+ children = @ast.subgraphs.select { |group| group.parent == parent && !seen.include?(group.id) }
160
+ blocks = []
161
+ nodes.each do |node|
162
+ group = children.find { |candidate| @membership.fetch(node.id, []).include?(candidate.id) }
163
+ block = group && blocks.find { |entry| entry[:group] == group.id }
164
+ if block
165
+ block[:nodes] << node
166
+ else
167
+ blocks << { group: group&.id, nodes: [node], index: blocks.length }
168
+ end
169
+ end
170
+ blocks.sort_by! do |block|
171
+ values = block[:nodes].filter_map { |node| scores[node.id] }.sort
172
+ median = values.empty? ? block[:index] : (values[(values.length - 1) / 2] + values[values.length / 2]) / 2.0
173
+ [median, block[:index]]
174
+ end
175
+ blocks.flat_map do |block|
176
+ block[:group] ? sort_clustered(block[:nodes], scores, block[:group], seen + [block[:group]]) : block[:nodes]
177
+ end
178
+ end
179
+
180
+ def crossing_count
181
+ positions = @groups.transform_values { |nodes| nodes.each_with_index.to_h { |node, index| [node.id, index] } }
182
+ by_rank = Hash.new { |hash, rank| hash[rank] = [] }
183
+ @segments.each do |_, from, to|
184
+ rank = @ranks[from]
185
+ next unless @ranks[to] == rank + 1
186
+
187
+ by_rank[rank] << [positions[rank][from], positions[rank + 1][to]]
188
+ end
189
+ by_rank.sum do |rank, edges|
190
+ tree = Array.new(@groups[rank + 1].length + 2, 0)
191
+ seen = 0
192
+ edges.sort_by { |from, to| [from, to] }.sum do |_, to|
193
+ index = to + 1
194
+ smaller_or_equal = 0
195
+ while index.positive?
196
+ smaller_or_equal += tree[index]
197
+ index -= index & -index
198
+ end
199
+ crossings = seen - smaller_or_equal
200
+ index = to + 1
201
+ while index < tree.length
202
+ tree[index] += 1
203
+ index += index & -index
204
+ end
205
+ seen += 1
206
+ crossings
207
+ end
208
+ end
209
+ end
210
+
211
+ def place_nodes
212
+ @labels = @ast.nodes.to_h do |node|
213
+ [node.id, Merminal::Text.wrap(node.label, @max_label_width, ambiguous_width: @ambiguous_width)]
214
+ end
215
+ @sizes = @ast.nodes.to_h do |node|
216
+ width = @labels[node.id].map { |line| Merminal::Text.width(line, ambiguous_width: @ambiguous_width) }.max.to_i + @padding * 2 + 2
217
+ width += 2 if %i[circle double_circle decision hexagon subroutine].include?(node.shape)
218
+ height = @labels[node.id].length + 2 + (node.shape == :database ? 1 : 0)
219
+ width = [width, 9].max if %i[fork join].include?(node.shape)
220
+ height = [height, 5].max if @horizontal && %i[fork join].include?(node.shape)
221
+ incoming = @segments.count { |_, from, to| to == node.id && from != to }
222
+ @horizontal ? height = [height, incoming + 2].max : width = [width, incoming + 2].max
223
+ [node.id, [width, height]]
224
+ end
225
+ @work_nodes.grep(WorkNode).each do |node|
226
+ @sizes[node.id] = if node.kind == :label
227
+ [Text.width(node.label, ambiguous_width: @ambiguous_width) + 2, @horizontal ? 2 : 1]
228
+ else
229
+ [1, 1]
230
+ end
231
+ end
232
+ @positions = {}
233
+ order_positions = @ast.subgraphs.empty? ? nil : group_band_positions
234
+ ranks = @groups.keys.sort
235
+ cursor = 3 + @cluster_margin
236
+ ranks.each do |rank|
237
+ order = 1 + @cluster_margin
238
+ @groups[rank].each do |node|
239
+ width, height = @sizes.fetch(node.id)
240
+ node_order = order_positions ? order_positions.fetch(node.id) : order
241
+ @positions[node.id] = @horizontal ? [cursor, node_order] : [node_order, cursor]
242
+ order += (@horizontal ? height : width) + @node_gap
243
+ end
244
+ cursor += @groups[rank].map { |node| @horizontal ? @sizes[node.id][0] : @sizes[node.id][1] }.max.to_i
245
+ end
246
+ align_order_positions if @ast.subgraphs.empty?
247
+ @input_ports = {}
248
+ @ast.nodes.each do |node|
249
+ incoming = @segments.select { |_, from, to| to == node.id && from != to }
250
+ incoming.sort_by! { |edge, from, _| [@horizontal ? @positions[from][1] : @positions[from][0], edge.id] }
251
+ dimension = @horizontal ? @sizes[node.id][1] : @sizes[node.id][0]
252
+ @input_ports[node.id] = incoming.each_with_index.to_h do |(edge, _, _), index|
253
+ [edge.id, ((dimension - 1) * (index + 1).to_f / (incoming.length + 1)).round]
254
+ end
255
+ end
256
+ allocate_channel_tracks
257
+ cursor = 3 + @cluster_margin
258
+ ranks.each_with_index do |rank, index|
259
+ if index.positive?
260
+ previous = ranks[index - 1]
261
+ cursor += [@rank_gap, @track_count.fetch(previous, 0) * 2 + 2].max
262
+ end
263
+ @groups[rank].each do |node|
264
+ point = @positions.fetch(node.id)
265
+ @positions[node.id] = @horizontal ? [cursor, point[1]] : [point[0], cursor]
266
+ end
267
+ cursor += @groups[rank].map { |node| @horizontal ? @sizes[node.id][0] : @sizes[node.id][1] }.max.to_i
268
+ end
269
+ @rank_ends = @groups.to_h do |rank, nodes|
270
+ [rank, nodes.map { |node| @horizontal ? @positions[node.id][0] + @sizes[node.id][0] - 1 :
271
+ @positions[node.id][1] + @sizes[node.id][1] - 1 }.max]
272
+ end
273
+ @width = @positions.map { |id, (x, _)| x + @sizes[id][0] }.max + 2
274
+ @height = @positions.map { |id, (_, y)| y + @sizes[id][1] }.max + 2
275
+ @width += @cluster_margin
276
+ @height += @cluster_margin
277
+ @outer_track = @horizontal ? @height + 2 : @width + 2
278
+ @outer_offsets = {}
279
+ outer_span = 0
280
+ @oriented.each do |edge, _, _, _|
281
+ next if @edge_paths[edge.id]
282
+
283
+ @outer_offsets[edge.id] = outer_span
284
+ outer_span += @horizontal ? 2 : Text.width(edge.label.to_s, ambiguous_width: @ambiguous_width) + 2
285
+ end
286
+ if @horizontal
287
+ @height += outer_span + 5
288
+ else
289
+ @width += outer_span + 5
290
+ end
291
+ @width += @ast.edges.map { |edge| Text.width(edge.label.to_s, ambiguous_width: @ambiguous_width) }.max.to_i + 2
292
+ end
293
+
294
+ def align_order_positions
295
+ @groups.each_value do |nodes|
296
+ sizes = nodes.map { |node| @horizontal ? @sizes.fetch(node.id)[1] : @sizes.fetch(node.id)[0] }
297
+ positions = nodes.map { |node| @horizontal ? @positions.fetch(node.id)[1] : @positions.fetch(node.id)[0] }
298
+ fixed = nodes.each_index.reject { |index| nodes[index].is_a?(WorkNode) }
299
+ movable = nodes.each_index.select { |index| nodes[index].is_a?(WorkNode) }
300
+ movable.sort_by { |index| [nodes[index].kind == :dummy ? 0 : 1, index] }.each do |index|
301
+ node = nodes[index]
302
+ anchors = [node.source, node.target].map do |id|
303
+ coordinate = @horizontal ? @positions.fetch(id)[1] : @positions.fetch(id)[0]
304
+ coordinate + (@horizontal ? @sizes.fetch(id)[1] : @sizes.fetch(id)[0]) / 2
305
+ end
306
+ target = (anchors.sum / 2.0).round - (@horizontal && node.kind == :label ? 1 : 0)
307
+ trial = positions.dup
308
+ trial[index] = target
309
+ (index - 1).downto(0) do |cursor|
310
+ trial[cursor] = [trial[cursor], trial[cursor + 1] - sizes[cursor] - @node_gap].min
311
+ end
312
+ (index + 1...trial.length).each do |cursor|
313
+ trial[cursor] = [trial[cursor], trial[cursor - 1] + sizes[cursor - 1] + @node_gap].max
314
+ end
315
+ next if trial.min < 1 || fixed.any? { |cursor| trial[cursor] != positions[cursor] }
316
+
317
+ positions = trial
318
+ fixed << index
319
+ end
320
+ nodes.each_with_index do |node, index|
321
+ point = @positions.fetch(node.id)
322
+ @positions[node.id] = @horizontal ? [point[0], positions[index]] : [positions[index], point[1]]
323
+ end
324
+ end
325
+ end
326
+
327
+ def group_band_positions
328
+ indices = @work_nodes.each_with_index.to_h { |node, index| [node.id, index] }
329
+ groups = @ast.subgraphs.select { |group| !group.parent && group.node_ids.any? }
330
+ ranges = groups.to_h do |group|
331
+ members = @work_nodes.filter_map { |node| indices[node.id] if @membership.fetch(node.id, []).include?(group.id) }
332
+ [group.id, [members.min, members.max]]
333
+ end
334
+ groups.sort_by! { |group| ranges[group.id].first }
335
+ keys = @work_nodes.to_h do |node|
336
+ group = groups.find { |candidate| @membership.fetch(node.id, []).include?(candidate.id) }
337
+ slot = groups.count { |candidate| ranges[candidate.id].last < indices[node.id] }
338
+ [node.id, group ? [:group, group.id] : [:outside, slot]]
339
+ end
340
+ order = (0..groups.length).flat_map do |slot|
341
+ [[:outside, slot], ([:group, groups[slot].id] if groups[slot])].compact
342
+ end
343
+ widths = order.to_h { |key| [key, 0] }
344
+ @groups.each_value do |nodes|
345
+ nodes.group_by { |node| keys[node.id] }.each do |key, members|
346
+ size = members.sum { |node| @horizontal ? @sizes[node.id][1] : @sizes[node.id][0] } +
347
+ [members.length - 1, 0].max * @node_gap
348
+ widths[key] = [widths[key], size].max
349
+ end
350
+ end
351
+ groups.each do |group|
352
+ key = [:group, group.id]
353
+ widths[key] = [widths[key], Text.width(group.label) + @cluster_margin * 2 + 4].max
354
+ end
355
+ order.select! { |key| widths[key].positive? }
356
+ starts = {}
357
+ cursor = 1 + @cluster_margin
358
+ order.each do |key|
359
+ starts[key] = cursor
360
+ cursor += widths[key] + @node_gap + @cluster_margin * 2
361
+ end
362
+ @groups.values.flatten.to_h do |node|
363
+ key = keys.fetch(node.id)
364
+ peers = @groups.fetch(@ranks.fetch(node.id)).select { |candidate| keys[candidate.id] == key }
365
+ before = peers.take_while { |candidate| candidate.id != node.id }
366
+ offset = before.sum { |candidate| @horizontal ? @sizes[candidate.id][1] : @sizes[candidate.id][0] } +
367
+ before.length * @node_gap
368
+ [node.id, starts.fetch(key) + offset]
369
+ end
370
+ end
371
+
372
+ def allocate_channel_tracks
373
+ intervals = Hash.new { |hash, rank| hash[rank] = {} }
374
+ @segments.each do |edge, from, to|
375
+ next unless @ranks[to] - @ranks[from] == 1
376
+
377
+ rank = @ranks[from]
378
+ a = port(from)
379
+ b = port(to, end_port: true, edge_id: edge.id)
380
+ next if (@horizontal ? a[1] == b[1] : a[0] == b[0])
381
+
382
+ left, right = [@horizontal ? a[1] : a[0], @horizontal ? b[1] : b[0]].minmax
383
+ key = from
384
+ group = intervals[rank][key] ||= { left: left, right: right, edges: [] }
385
+ group[:left] = [group[:left], left].min
386
+ group[:right] = [group[:right], right].max
387
+ group[:edges] << [edge.id, rank]
388
+ end
389
+ @track_for_edge = {}
390
+ @track_count = {}
391
+ intervals.each do |rank, groups|
392
+ track_ends = []
393
+ groups.values.sort_by { |group| [group[:left], group[:right]] }.each do |group|
394
+ track = track_ends.index { |right| right + 1 < group[:left] } || track_ends.length
395
+ track_ends[track] = group[:right]
396
+ group[:edges].each { |id| @track_for_edge[id] = track }
397
+ end
398
+ @track_count[rank] = track_ends.length
399
+ end
400
+ end
401
+
402
+ def draw_nodes
403
+ @ast.nodes.each do |node|
404
+ x, y = @positions.fetch(node.id)
405
+ w, h = @sizes.fetch(node.id)
406
+ if %i[fork join].include?(node.shape)
407
+ points = @horizontal ? [[x + w / 2, y], [x + w / 2, y + h - 1]] : [[x, y + h / 2], [x + w - 1, y + h / 2]]
408
+ @items << Scene::Polyline.new(points: points, stroke: Scene::Stroke.new(weight: :heavy, pattern: :solid),
409
+ role: :node_border, layer: :node)
410
+ next
411
+ end
412
+ corners = %i[rounded stadium circle double_circle].include?(node.shape) ? :rounded : :sharp
413
+ style = @ast.styles[node.id] || node.classes.reverse.filter_map { |name| @ast.styles["class:#{name}"] }.first
414
+ fill = css_color(style, "fill")
415
+ border = css_color(style, "stroke")
416
+ foreground = css_color(style, "color")
417
+ rect = Scene::Rect.new(x: x, y: y, width: w, height: h)
418
+ @items << Scene::Fill.new(rect: rect, role: :"bg:#{fill}", layer: :background) if fill
419
+ @items << Scene::Box.new(rect: rect, stroke: Scene::LIGHT,
420
+ corners: corners, role: border ? :"fg:#{border}" : :node_border, layer: :node)
421
+ @labels[node.id].each_with_index do |line, index|
422
+ offset = (w - Text.width(line, ambiguous_width: @ambiguous_width)) / 2
423
+ start = [(h - @labels[node.id].length) / 2, node.shape == :database ? 2 : 1].max
424
+ text_y = y + start + index
425
+ @items << Scene::Text.new(x: x + offset, y: text_y, string: line, role: foreground ? :"fg:#{foreground}" : :node_text,
426
+ layer: :label, emphasis: nil)
427
+ end
428
+ decorate(node, x, y, w, h)
429
+ end
430
+ end
431
+
432
+ def decorate(node, x, y, w, h)
433
+ case node.shape
434
+ when :decision, :hexagon
435
+ @items << Scene::Glyph.new(x: x, y: y + h / 2, char: "<", role: :node_border, layer: :marker)
436
+ @items << Scene::Glyph.new(x: x + w - 1, y: y + h / 2, char: ">", role: :node_border, layer: :marker)
437
+ when :stadium
438
+ @items << Scene::Glyph.new(x: x, y: y + h / 2, char: "(", role: :node_border, layer: :marker)
439
+ @items << Scene::Glyph.new(x: x + w - 1, y: y + h / 2, char: ")", role: :node_border, layer: :marker)
440
+ when :subroutine
441
+ @items << Scene::Polyline.new(points: [[x + 2, y], [x + 2, y + h - 1]], stroke: Scene::LIGHT, role: :node_border, layer: :node)
442
+ @items << Scene::Polyline.new(points: [[x + w - 3, y], [x + w - 3, y + h - 1]], stroke: Scene::LIGHT, role: :node_border, layer: :node)
443
+ when :database
444
+ @items << Scene::Polyline.new(points: [[x, y + 1], [x + w - 1, y + 1]], stroke: Scene::LIGHT, role: :node_border, layer: :node)
445
+ when :double_circle
446
+ @items << Scene::Glyph.new(x: x + 1, y: y + h / 2, char: "(", role: :node_border, layer: :marker)
447
+ @items << Scene::Glyph.new(x: x + w - 2, y: y + h / 2, char: ")", role: :node_border, layer: :marker)
448
+ when :parallelogram
449
+ @items << Scene::Glyph.new(x: x, y: y + h / 2, char: "/", role: :node_border, layer: :marker)
450
+ @items << Scene::Glyph.new(x: x + w - 1, y: y + h / 2, char: "/", role: :node_border, layer: :marker)
451
+ when :trapezoid
452
+ @items << Scene::Glyph.new(x: x, y: y + h / 2, char: "/", role: :node_border, layer: :marker)
453
+ @items << Scene::Glyph.new(x: x + w - 1, y: y + h / 2, char: "\\", role: :node_border, layer: :marker)
454
+ when :flag
455
+ @items << Scene::Glyph.new(x: x, y: y + h / 2, char: ">", role: :node_border, layer: :marker)
456
+ end
457
+ end
458
+
459
+ def draw_edges
460
+ @edge_item_indices = {}
461
+ @edge_label_indices = {}
462
+ @oriented.each do |edge, from, to, _reversed|
463
+ next if edge.stroke == :invisible
464
+
465
+ path = @edge_paths[edge.id]
466
+ if path
467
+ points = path.each_cons(2).flat_map do |a, b|
468
+ channel_route(a, b, @track_for_edge[[edge.id, @ranks[a]]], edge.id)
469
+ end
470
+ points = points.chunk_while { |a, b| a == b }.map(&:first)
471
+ else
472
+ points = outer_route(from, to, @outer_offsets.fetch(edge.id), edge.id)
473
+ end
474
+ stroke = Scene::Stroke.new(weight: edge.stroke == :heavy ? :heavy : :light,
475
+ pattern: edge.stroke == :dotted ? :dotted : :solid)
476
+ style = @ast.styles["link:#{edge.id}"] || @ast.styles["link:default"]
477
+ color = css_color(style, "stroke")
478
+ @edge_item_indices[edge.id] = @items.length
479
+ @items << Scene::Polyline.new(points: points, stroke: stroke, role: color ? :"fg:#{color}" : :edge, layer: :edge)
480
+ if edge.label && !edge.label.empty?
481
+ @edge_label_indices[edge.id] = @items.length
482
+ label_node = path&.find { |id| @work_node_by_id[id]&.kind == :label }
483
+ if label_node
484
+ x, y = @positions.fetch(label_node)
485
+ @items << Scene::Text.new(x: x + 1, y: y, string: edge.label, role: :edge_label, layer: :label, emphasis: nil)
486
+ else
487
+ label_edge(edge, points)
488
+ end
489
+ end
490
+ end
491
+ end
492
+
493
+ def draw_markers
494
+ @oriented.each do |edge, _, _, reversed|
495
+ index = @edge_item_indices[edge.id]
496
+ marker_endpoint(edge, @items[index].points, reversed) if index
497
+ end
498
+ end
499
+
500
+ def port(id, end_port: false, edge_id: nil)
501
+ x, y = @positions.fetch(id)
502
+ w, h = @sizes.fetch(id)
503
+ work_node = @work_node_by_id[id]
504
+ if work_node
505
+ return @horizontal ? [end_port ? x : x + w - 1, y + (work_node.kind == :label ? 1 : 0)] : [x, y]
506
+ end
507
+ offset = end_port ? @input_ports.fetch(id, {})[edge_id] : nil
508
+ node = @ast.nodes.find { |candidate| candidate.id == id }
509
+ if node && %i[fork join].include?(node.shape)
510
+ return @horizontal ? [x + w / 2, y + (offset || h / 2)] : [x + (offset || w / 2), y + h / 2]
511
+ end
512
+ @horizontal ? [end_port ? x : x + w - 1, y + (offset || h / 2)] : [x + (offset || w / 2), end_port ? y : y + h - 1]
513
+ end
514
+
515
+ def channel_route(from, to, track, edge_id)
516
+ a = port(from)
517
+ b = port(to, end_port: true, edge_id: edge_id)
518
+ return [a, b] if @horizontal ? a[1] == b[1] : a[0] == b[0]
519
+
520
+ middle = @rank_ends.fetch(@ranks[from]) + 2 + track * 2
521
+ if @horizontal
522
+ [a, [middle, a[1]], [middle, b[1]], b]
523
+ else
524
+ [a, [a[0], middle], [b[0], middle], b]
525
+ end
526
+ end
527
+
528
+ def outer_route(from, to, offset, edge_id)
529
+ # ponytail: self loops use outside lanes; add a dedicated loop primitive if dense graphs need tighter layouts.
530
+ a = port(from)
531
+ b = port(to, end_port: true, edge_id: edge_id)
532
+ track = @outer_track + offset
533
+ if @horizontal
534
+ turn = @groups[@ranks[from]].map { |node| @positions[node.id][0] + @sizes[node.id][0] }.max + 1
535
+ [a, [turn, a[1]], [turn, track], [b[0] - 2, track], [b[0] - 2, b[1]], b]
536
+ else
537
+ turn = @groups[@ranks[from]].map { |node| @positions[node.id][1] + @sizes[node.id][1] }.max + 1
538
+ [a, [a[0], turn], [track, turn], [track, b[1] - 2], [b[0], b[1] - 2], b]
539
+ end
540
+ end
541
+
542
+ def marker_endpoint(edge, points, reversed)
543
+ end_tip, end_neighbor = reversed ? points.first(2) : points.last(2).reverse
544
+ start_tip, start_neighbor = reversed ? points.last(2).reverse : points.first(2)
545
+ put_marker(end_tip, edge.end_marker, tip_direction(end_tip, end_neighbor))
546
+ put_marker(start_tip, edge.start_marker, tip_direction(start_tip, start_neighbor))
547
+ end
548
+
549
+ def tip_direction(tip, neighbor)
550
+ return tip[0] > neighbor[0] ? :e : :w if tip[0] != neighbor[0]
551
+
552
+ tip[1] > neighbor[1] ? :s : :n
553
+ end
554
+
555
+ def put_marker(tip, kind, direction)
556
+ return unless kind
557
+
558
+ x = tip[0] + (direction == :e ? -1 : direction == :w ? 1 : 0)
559
+ y = tip[1] + (direction == :s ? -1 : direction == :n ? 1 : 0)
560
+ @items << Scene::Marker.new(x: x, y: y, kind: kind, direction: direction, role: :marker, layer: :marker)
561
+ end
562
+
563
+ def label_edge(edge, points)
564
+ text = edge.label.to_s
565
+ x, y = if points.length > 4
566
+ @horizontal ? [points[1][0], points[2][1] - 1] : [points[2][0] + 1, points[1][1] - 1]
567
+ elsif @horizontal
568
+ [points[1][0], [points[0][1], points[-1][1]].min - 1]
569
+ else
570
+ [[points[0][0], points[-1][0]].max + 2, points[1][1] - 1]
571
+ end
572
+ x = [[x, 0].max, @width - Text.width(text, ambiguous_width: @ambiguous_width)].min
573
+ y = [[y, 0].max, @height - 1].min
574
+ while label_conflict?(x, y, text)
575
+ if @horizontal
576
+ y += 1
577
+ @height = [@height, y + 1].max
578
+ else
579
+ x += 1
580
+ @width = [@width, x + Text.width(text, ambiguous_width: @ambiguous_width)].max
581
+ end
582
+ end
583
+ @items << Scene::Text.new(x: x, y: y, string: text, role: :edge_label, layer: :label, emphasis: nil)
584
+ end
585
+
586
+ def label_conflict?(x, y, text)
587
+ right = x + Text.width(text, ambiguous_width: @ambiguous_width)
588
+ @ast.nodes.any? do |node|
589
+ nx, ny = @positions.fetch(node.id)
590
+ nw, nh = @sizes.fetch(node.id)
591
+ y.between?(ny, ny + nh - 1) && right > nx && x < nx + nw
592
+ end || @items.any? do |item|
593
+ item.is_a?(Scene::Text) && item.role == :edge_label && item.y == y &&
594
+ right > item.x && x < item.x + Text.width(item.string, ambiguous_width: @ambiguous_width)
595
+ end
596
+ end
597
+
598
+ def add_subgraphs
599
+ @group_rects = {}
600
+ @ast.subgraphs.each do |group|
601
+ members = group.node_ids.uniq.filter_map { |id| @positions[id] && [@positions[id], @sizes[id]] }
602
+ next if members.empty?
603
+
604
+ padding = 2 * (@max_group_depth - group_depth(group) + 1)
605
+ left = members.map { |(x, _), _| x }.min - padding
606
+ top = members.map { |(_, y), _| y }.min - padding - 1
607
+ right = members.map { |(x, _), (w, _)| x + w }.max + padding - 1
608
+ right = [right, left + Text.width(group.label) + 3].max
609
+ bottom = members.map { |(_, y), (_, h)| y + h }.max + padding - 1
610
+ next if left.negative? || top.negative?
611
+
612
+ @width = [@width, right + 1].max
613
+ @height = [@height, bottom + 1].max
614
+
615
+ rect = Scene::Rect.new(x: left, y: top, width: right - left + 1, height: bottom - top + 1)
616
+ @group_rects[group.id] = rect
617
+ @items << Scene::Box.new(rect: rect,
618
+ stroke: Scene::LIGHT, corners: :sharp, role: :container_border, layer: :container)
619
+ @items << Scene::Text.new(x: left + 2, y: top + 1, string: group.label, role: :container_title,
620
+ layer: :label, emphasis: nil)
621
+ end
622
+ end
623
+
624
+ def clip_group_edges
625
+ @ast.edges.each do |edge|
626
+ groups = @ast.styles["group_edge:#{edge.id}"]
627
+ index = @edge_item_indices[edge.id]
628
+ next unless groups && index
629
+
630
+ points = @items[index].points
631
+ points = clip_from_group(points, @group_rects[groups[0]]) if groups[0]
632
+ points = clip_from_group(points.reverse, @group_rects[groups[1]]).reverse if groups[1]
633
+ @items[index] = @items[index].with(points: points)
634
+ end
635
+ end
636
+
637
+ def avoid_unrelated_groups
638
+ detours = 0
639
+ @ast.edges.each do |edge|
640
+ index = @edge_item_indices[edge.id]
641
+ next unless index
642
+
643
+ line = @items[index]
644
+ blocked = @ast.subgraphs.any? do |group|
645
+ rect = @group_rects[group.id]
646
+ rect && !group.node_ids.include?(edge.from) && !group.node_ids.include?(edge.to) &&
647
+ crosses_frame?(line.points, rect)
648
+ end
649
+ next unless blocked
650
+
651
+ lane = @group_rects.values.map { |rect| rect.y + rect.height }.max + 2 + detours * 2
652
+ detours += 1
653
+ a = line.points.first
654
+ target = @oriented.find { |candidate| candidate.first.id == edge.id }[2]
655
+ tx, ty = @positions.fetch(target)
656
+ tw, th = @sizes.fetch(target)
657
+ b = @horizontal ? [tx + tw / 2, ty + th - 1] : [line.points.last[0], ty + th - 1]
658
+ @items[index] = line.with(points: [a, [a[0], lane], [b[0], lane], b])
659
+ @height = [@height, lane + 1].max
660
+ label_index = @edge_label_indices[edge.id]
661
+ next unless label_index
662
+
663
+ label = @items[label_index]
664
+ x = [(a[0] + b[0] - Text.width(label.string, ambiguous_width: @ambiguous_width)) / 2, 0].max
665
+ @items[label_index] = label.with(x: x, y: lane - 1)
666
+ end
667
+ end
668
+
669
+ def crosses_frame?(points, rect)
670
+ points.each_cons(2).any? do |(x1, y1), (x2, y2)|
671
+ if x1 == x2
672
+ x1 > rect.x && x1 < rect.x + rect.width - 1 &&
673
+ [y1, y2].max > rect.y && [y1, y2].min < rect.y + rect.height - 1
674
+ else
675
+ y1 > rect.y && y1 < rect.y + rect.height - 1 &&
676
+ [x1, x2].max > rect.x && [x1, x2].min < rect.x + rect.width - 1
677
+ end
678
+ end
679
+ end
680
+
681
+ def clip_from_group(points, rect)
682
+ return points unless rect
683
+
684
+ inside = ->(x, y) { x.between?(rect.x, rect.x + rect.width - 1) && y.between?(rect.y, rect.y + rect.height - 1) }
685
+ index = points.each_cons(2).find_index { |a, b| inside.call(*a) && !inside.call(*b) }
686
+ return points unless index
687
+
688
+ a, b = points[index, 2]
689
+ x = b[0] < rect.x ? rect.x : b[0] >= rect.x + rect.width ? rect.x + rect.width - 1 : a[0]
690
+ y = b[1] < rect.y ? rect.y : b[1] >= rect.y + rect.height ? rect.y + rect.height - 1 : a[1]
691
+ [[x, y]] + points[(index + 1)..]
692
+ end
693
+
694
+ def group_depth(group)
695
+ depth = 0
696
+ parent = group.parent
697
+ visited = []
698
+ while parent
699
+ break if visited.include?(parent)
700
+
701
+ visited << parent
702
+ depth += 1
703
+ parent = @ast.subgraphs.find { |candidate| candidate.id == parent }&.parent
704
+ end
705
+ depth
706
+ end
707
+
708
+ def flip_items
709
+ @items.map! do |item|
710
+ case item
711
+ in Scene::Box
712
+ r = item.rect
713
+ item.with(rect: Scene::Rect.new(x: @horizontal ? @width - r.x - r.width : r.x,
714
+ y: @horizontal ? r.y : @height - r.y - r.height, width: r.width, height: r.height))
715
+ in Scene::Polyline
716
+ item.with(points: item.points.map { |x, y| [@horizontal ? @width - 1 - x : x, @horizontal ? y : @height - 1 - y] })
717
+ in Scene::Text
718
+ item.with(x: @horizontal ? @width - item.x - Text.width(item.string, ambiguous_width: @ambiguous_width) : item.x,
719
+ y: @horizontal ? item.y : @height - 1 - item.y)
720
+ in Scene::Marker
721
+ item.with(x: @horizontal ? @width - 1 - item.x : item.x, y: @horizontal ? item.y : @height - 1 - item.y,
722
+ direction: { e: :w, w: :e, n: :s, s: :n }.fetch(item.direction))
723
+ in Scene::Glyph
724
+ item.with(x: @horizontal ? @width - 1 - item.x : item.x, y: @horizontal ? item.y : @height - 1 - item.y)
725
+ else item
726
+ end
727
+ end
728
+ end
729
+
730
+ def css_color(style, property)
731
+ style.to_s[/\b#{property}\s*:\s*(\#(?:[\da-fA-F]{3}|[\da-fA-F]{6}))\b/, 1]
732
+ end
733
+ end
734
+ end