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,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
|