graphomaton 1.0.0 → 1.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 +4 -4
- data/CHANGELOG.md +74 -8
- data/README.md +426 -44
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +255 -18
- data/lib/graphomaton/exporters/mermaid.rb +705 -25
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +250 -13
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2775 -231
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +11 -4
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2869 -54
- data/sig/graphomaton.rbs +127 -0
- metadata +34 -24
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -30
- data/sample/complex.rb +0 -32
- data/sample/long_names.rb +0 -20
- data/sample/nfa.rb +0 -28
- data/sample/skip_states.rb +0 -23
- data/spec/exporters/dot_spec.rb +0 -146
- data/spec/exporters/mermaid_spec.rb +0 -154
- data/spec/exporters/plantuml_spec.rb +0 -144
- data/spec/exporters/svg_spec.rb +0 -314
- data/spec/graphomaton_edge_cases_spec.rb +0 -322
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -1,53 +1,868 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
3
4
|
require 'rexml/document'
|
|
5
|
+
require 'rexml/formatters/pretty'
|
|
4
6
|
|
|
5
7
|
class Graphomaton
|
|
6
8
|
module Exporters
|
|
7
9
|
class Svg
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
include Graphomaton::ExporterIntrospection
|
|
11
|
+
|
|
12
|
+
class SpatialIndex
|
|
13
|
+
MAX_CELLS_PER_ITEM = 256
|
|
14
|
+
|
|
15
|
+
def initialize(cell_size:)
|
|
16
|
+
@cell_size = [cell_size.to_f, 1.0].max
|
|
17
|
+
@cells = Hash.new { |hash, key| hash[key] = [] }
|
|
18
|
+
@values = []
|
|
19
|
+
@oversized_values = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def insert(bounds, value = bounds)
|
|
23
|
+
@values << value
|
|
24
|
+
keys = cell_keys(bounds)
|
|
25
|
+
if keys
|
|
26
|
+
keys.each { |key| @cells[key] << value }
|
|
27
|
+
else
|
|
28
|
+
@oversized_values << value
|
|
29
|
+
end
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def query(bounds)
|
|
34
|
+
keys = cell_keys(bounds)
|
|
35
|
+
return @values.dup unless keys
|
|
36
|
+
|
|
37
|
+
seen = {}
|
|
38
|
+
@oversized_values.each { |value| seen[value.object_id] = true }
|
|
39
|
+
keys.each_with_object(@oversized_values.dup) do |key, values|
|
|
40
|
+
@cells[key].each do |value|
|
|
41
|
+
identity = value.object_id
|
|
42
|
+
next if seen[identity]
|
|
43
|
+
|
|
44
|
+
seen[identity] = true
|
|
45
|
+
values << value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def cell_keys(bounds)
|
|
53
|
+
left = (bounds[:x].to_f / @cell_size).floor
|
|
54
|
+
right = ((bounds[:x].to_f + bounds[:width].to_f) / @cell_size).floor
|
|
55
|
+
top = (bounds[:y].to_f / @cell_size).floor
|
|
56
|
+
bottom = ((bounds[:y].to_f + bounds[:height].to_f) / @cell_size).floor
|
|
57
|
+
return nil if (right - left + 1) * (bottom - top + 1) > MAX_CELLS_PER_ITEM
|
|
58
|
+
|
|
59
|
+
(left..right).flat_map { |x| (top..bottom).map { |y| [x, y] } }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
DEFAULT_STATE_RADIUS = 40
|
|
64
|
+
DEFAULT_AUTO_STATE_RADIUS = false
|
|
65
|
+
DEFAULT_MIN_STATE_RADIUS = 24
|
|
66
|
+
DEFAULT_MAX_STATE_RADIUS = 72
|
|
67
|
+
DEFAULT_STATE_SHAPE = :circle
|
|
68
|
+
DEFAULT_STATE_STROKE_WIDTH = 2
|
|
69
|
+
DEFAULT_TRANSITION_STROKE_WIDTH = 1.5
|
|
70
|
+
DEFAULT_THEME = :light
|
|
71
|
+
DEFAULT_LAYOUT = :linear
|
|
72
|
+
DEFAULT_DIRECTION = :lr
|
|
73
|
+
DEFAULT_MERGE_PARALLEL_TRANSITIONS = true
|
|
74
|
+
DEFAULT_WRAP = false
|
|
75
|
+
DEFAULT_SORT_LABELS = false
|
|
76
|
+
DEFAULT_ROTATE_LABELS = false
|
|
77
|
+
DEFAULT_LABEL_TOOLTIPS = false
|
|
78
|
+
DEFAULT_HTML_TOOLTIPS = false
|
|
79
|
+
DEFAULT_LABEL_BACKGROUND = true
|
|
80
|
+
DEFAULT_LABEL_BORDER = false
|
|
81
|
+
DEFAULT_LABEL_PADDING = 10
|
|
82
|
+
DEFAULT_LABEL_RADIUS = 3
|
|
83
|
+
DEFAULT_FONT_FAMILY = 'Arial, sans-serif'
|
|
84
|
+
DEFAULT_STATE_FONT_WEIGHT = nil
|
|
85
|
+
DEFAULT_TRANSITION_FONT_WEIGHT = nil
|
|
86
|
+
DEFAULT_HIGHLIGHT_UNREACHABLE = false
|
|
87
|
+
DEFAULT_HIGHLIGHT_DEAD_STATES = false
|
|
88
|
+
DEFAULT_HIGHLIGHT_INITIAL_STATE = false
|
|
89
|
+
DEFAULT_HIGHLIGHT_FINAL_STATES = false
|
|
90
|
+
DEFAULT_HIGHLIGHT_TRANSITIONS = [].freeze
|
|
91
|
+
DEFAULT_UNREACHABLE_ZONE = :none
|
|
92
|
+
DEFAULT_XML_DECLARATION = false
|
|
93
|
+
DEFAULT_CSS_VARIABLES = false
|
|
94
|
+
DEFAULT_EMBED_STYLES = true
|
|
95
|
+
DEFAULT_PRETTY = false
|
|
96
|
+
DEFAULT_MINIFY = false
|
|
97
|
+
DEFAULT_STATE_EFFECT = :none
|
|
98
|
+
DEFAULT_LOOP_POSITION = :auto
|
|
99
|
+
DEFAULT_EDGE_STYLE = :auto
|
|
100
|
+
DEFAULT_SHOW_FINAL_ARROWS = false
|
|
101
|
+
DEFAULT_PADDING = 80
|
|
102
|
+
DEFAULT_NODE_SPACING = 120
|
|
103
|
+
DEFAULT_RANK_SPACING = 120
|
|
104
|
+
DEFAULT_AUTO_DENSITY_SPACING = false
|
|
105
|
+
DEFAULT_FORCE_ITERATIONS = 120
|
|
106
|
+
DEFAULT_ARROW_SIZE = 10
|
|
107
|
+
DEFAULT_ARROW_SHAPE = :triangle
|
|
108
|
+
DEFAULT_INITIAL_ARROW_LENGTH = 30
|
|
109
|
+
DEFAULT_INITIAL_ARROW_LABEL = 'start'
|
|
110
|
+
DEFAULT_FINAL_ARROW_LENGTH = 32
|
|
111
|
+
DEFAULT_FINAL_ARROW_LABEL = 'final'
|
|
112
|
+
DEFAULT_INITIAL_POSITION = :auto
|
|
113
|
+
DEFAULT_FINAL_POSITION = :auto
|
|
114
|
+
DEFAULT_AUTO_SIZE = false
|
|
115
|
+
DEFAULT_MAX_LABEL_WIDTH = 120
|
|
116
|
+
DEFAULT_STATE_WRAP = false
|
|
117
|
+
DEFAULT_MAX_STATE_LABEL_WIDTH = 120
|
|
118
|
+
DEFAULT_SCC_GROUPS = false
|
|
119
|
+
DEFAULT_FOLD_GROUPS = false
|
|
120
|
+
LAYOUT_OPTIONS = %i[linear circle grid layered bfs force graphviz dot manual].freeze
|
|
121
|
+
DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
|
|
122
|
+
LOOP_POSITION_OPTIONS = %i[auto top right bottom left].freeze
|
|
123
|
+
EDGE_STYLE_OPTIONS = %i[auto straight curved orthogonal spline].freeze
|
|
124
|
+
UNREACHABLE_ZONE_OPTIONS = %i[none right bottom left top].freeze
|
|
125
|
+
STATE_SHAPE_OPTIONS = %i[circle ellipse rounded_rect diamond bar].freeze
|
|
126
|
+
STATE_EFFECT_OPTIONS = %i[none shadow glow pulse].freeze
|
|
127
|
+
ARROW_SHAPE_OPTIONS = %i[triangle vee stealth].freeze
|
|
128
|
+
TRANSITION_LINE_STYLE_OPTIONS = %i[solid dashed dotted].freeze
|
|
129
|
+
SAFE_STYLE_PROPERTIES = %w[
|
|
130
|
+
color fill fill-opacity font-size font-style font-weight opacity stroke stroke-dasharray
|
|
131
|
+
stroke-linecap stroke-linejoin stroke-opacity stroke-width
|
|
132
|
+
].freeze
|
|
133
|
+
TEXT_UNIT_WIDTH = 14.0
|
|
134
|
+
COMBINING_MARK_RANGES = [
|
|
135
|
+
0x0300..0x036F,
|
|
136
|
+
0x1AB0..0x1AFF,
|
|
137
|
+
0x1DC0..0x1DFF,
|
|
138
|
+
0x20D0..0x20FF,
|
|
139
|
+
0xFE20..0xFE2F
|
|
140
|
+
].freeze
|
|
141
|
+
EAST_ASIAN_WIDE_RANGES = [
|
|
142
|
+
0x1100..0x115F,
|
|
143
|
+
0x2E80..0xA4CF,
|
|
144
|
+
0xAC00..0xD7A3,
|
|
145
|
+
0xF900..0xFAFF,
|
|
146
|
+
0xFE10..0xFE6F,
|
|
147
|
+
0xFF01..0xFF60,
|
|
148
|
+
0xFFE0..0xFFE6,
|
|
149
|
+
0x1F300..0x1FAFF
|
|
150
|
+
].freeze
|
|
151
|
+
|
|
152
|
+
THEMES = {
|
|
153
|
+
light: {
|
|
154
|
+
background: nil,
|
|
155
|
+
state_fill: 'white',
|
|
156
|
+
stroke: '#333',
|
|
157
|
+
state_text: '#333',
|
|
158
|
+
transition_label: '#666',
|
|
159
|
+
label_background: 'white',
|
|
160
|
+
label_opacity: '0.9',
|
|
161
|
+
initial_fill: '#dbeafe',
|
|
162
|
+
final_fill: '#dcfce7',
|
|
163
|
+
highlight_stroke: '#ef4444',
|
|
164
|
+
inactive_opacity: '0.25'
|
|
165
|
+
},
|
|
166
|
+
dark: {
|
|
167
|
+
background: '#111827',
|
|
168
|
+
state_fill: '#1f2937',
|
|
169
|
+
stroke: '#e5e7eb',
|
|
170
|
+
state_text: '#f9fafb',
|
|
171
|
+
transition_label: '#d1d5db',
|
|
172
|
+
label_background: '#111827',
|
|
173
|
+
label_opacity: '0.95',
|
|
174
|
+
initial_fill: '#1e3a8a',
|
|
175
|
+
final_fill: '#14532d',
|
|
176
|
+
highlight_stroke: '#f87171',
|
|
177
|
+
inactive_opacity: '0.35'
|
|
178
|
+
},
|
|
179
|
+
forest: {
|
|
180
|
+
background: '#f0fdf4',
|
|
181
|
+
state_fill: '#ecfdf5',
|
|
182
|
+
stroke: '#166534',
|
|
183
|
+
state_text: '#14532d',
|
|
184
|
+
transition_label: '#15803d',
|
|
185
|
+
label_background: '#f0fdf4',
|
|
186
|
+
label_opacity: '0.95'
|
|
187
|
+
},
|
|
188
|
+
ocean: {
|
|
189
|
+
background: '#eff6ff',
|
|
190
|
+
state_fill: '#f8fafc',
|
|
191
|
+
stroke: '#0369a1',
|
|
192
|
+
state_text: '#0c4a6e',
|
|
193
|
+
transition_label: '#0284c7',
|
|
194
|
+
label_background: '#eff6ff',
|
|
195
|
+
label_opacity: '0.95'
|
|
196
|
+
},
|
|
197
|
+
high_contrast: {
|
|
198
|
+
background: '#000000',
|
|
199
|
+
state_fill: '#ffffff',
|
|
200
|
+
stroke: '#ffffff',
|
|
201
|
+
state_text: '#000000',
|
|
202
|
+
transition_label: '#ffff00',
|
|
203
|
+
label_background: '#000000',
|
|
204
|
+
label_opacity: '0.95'
|
|
205
|
+
},
|
|
206
|
+
color_blind: {
|
|
207
|
+
background: '#f7f7f7',
|
|
208
|
+
state_fill: '#ffffff',
|
|
209
|
+
stroke: '#0072b2',
|
|
210
|
+
state_text: '#000000',
|
|
211
|
+
transition_label: '#d55e00',
|
|
212
|
+
label_background: '#f7f7f7',
|
|
213
|
+
label_opacity: '0.95'
|
|
214
|
+
},
|
|
215
|
+
print: {
|
|
216
|
+
background: '#ffffff',
|
|
217
|
+
state_fill: '#ffffff',
|
|
218
|
+
stroke: '#000000',
|
|
219
|
+
state_text: '#000000',
|
|
220
|
+
transition_label: '#000000',
|
|
221
|
+
label_background: '#ffffff',
|
|
222
|
+
label_opacity: '1'
|
|
223
|
+
},
|
|
224
|
+
minimal: {
|
|
225
|
+
background: nil,
|
|
226
|
+
state_fill: '#ffffff',
|
|
227
|
+
stroke: '#111827',
|
|
228
|
+
state_text: '#111827',
|
|
229
|
+
transition_label: '#374151',
|
|
230
|
+
label_background: '#ffffff',
|
|
231
|
+
label_opacity: '0.85'
|
|
232
|
+
},
|
|
233
|
+
academic: {
|
|
234
|
+
background: '#ffffff',
|
|
235
|
+
state_fill: '#f8fafc',
|
|
236
|
+
stroke: '#1e3a8a',
|
|
237
|
+
state_text: '#111827',
|
|
238
|
+
transition_label: '#1e40af',
|
|
239
|
+
label_background: '#ffffff',
|
|
240
|
+
label_opacity: '0.95'
|
|
241
|
+
},
|
|
242
|
+
presentation: {
|
|
243
|
+
background: '#0f172a',
|
|
244
|
+
state_fill: '#f8fafc',
|
|
245
|
+
stroke: '#38bdf8',
|
|
246
|
+
state_text: '#0f172a',
|
|
247
|
+
transition_label: '#facc15',
|
|
248
|
+
label_background: '#0f172a',
|
|
249
|
+
label_opacity: '0.9'
|
|
250
|
+
}
|
|
251
|
+
}.transform_values do |theme|
|
|
252
|
+
theme.transform_values(&:freeze).freeze
|
|
253
|
+
end.freeze
|
|
10
254
|
|
|
11
255
|
def initialize(automaton)
|
|
12
256
|
@automaton = automaton
|
|
257
|
+
@state_radius = DEFAULT_STATE_RADIUS
|
|
258
|
+
@label_padding = DEFAULT_LABEL_PADDING
|
|
13
259
|
end
|
|
14
260
|
|
|
15
|
-
|
|
16
|
-
|
|
261
|
+
attr_reader :diagnostics
|
|
262
|
+
|
|
263
|
+
def export(width = 800, height = 600, theme: DEFAULT_THEME, layout: DEFAULT_LAYOUT, direction: DEFAULT_DIRECTION, responsive: false,
|
|
264
|
+
state_radius: DEFAULT_STATE_RADIUS, auto_state_radius: DEFAULT_AUTO_STATE_RADIUS,
|
|
265
|
+
min_state_radius: DEFAULT_MIN_STATE_RADIUS, max_state_radius: DEFAULT_MAX_STATE_RADIUS,
|
|
266
|
+
state_shape: DEFAULT_STATE_SHAPE,
|
|
267
|
+
state_stroke_width: DEFAULT_STATE_STROKE_WIDTH,
|
|
268
|
+
transition_stroke_width: DEFAULT_TRANSITION_STROKE_WIDTH,
|
|
269
|
+
wrap: DEFAULT_WRAP, max_transition_label_width: DEFAULT_MAX_LABEL_WIDTH,
|
|
270
|
+
state_wrap: DEFAULT_STATE_WRAP, max_state_label_width: DEFAULT_MAX_STATE_LABEL_WIDTH,
|
|
271
|
+
sort_labels: DEFAULT_SORT_LABELS,
|
|
272
|
+
label_tooltips: DEFAULT_LABEL_TOOLTIPS,
|
|
273
|
+
html_tooltips: DEFAULT_HTML_TOOLTIPS,
|
|
274
|
+
font_family: DEFAULT_FONT_FAMILY,
|
|
275
|
+
state_font_weight: DEFAULT_STATE_FONT_WEIGHT,
|
|
276
|
+
transition_font_weight: DEFAULT_TRANSITION_FONT_WEIGHT,
|
|
277
|
+
padding: DEFAULT_PADDING, node_spacing: DEFAULT_NODE_SPACING, rank_spacing: DEFAULT_RANK_SPACING,
|
|
278
|
+
force_iterations: DEFAULT_FORCE_ITERATIONS, layout_seed: nil, auto_size: DEFAULT_AUTO_SIZE,
|
|
279
|
+
graphviz_command: Graphomaton::DEFAULT_GRAPHVIZ_COMMAND,
|
|
280
|
+
auto_density_spacing: DEFAULT_AUTO_DENSITY_SPACING,
|
|
281
|
+
arrow_size: DEFAULT_ARROW_SIZE,
|
|
282
|
+
arrow_shape: DEFAULT_ARROW_SHAPE,
|
|
283
|
+
initial_arrow_length: DEFAULT_INITIAL_ARROW_LENGTH,
|
|
284
|
+
initial_arrow_label: DEFAULT_INITIAL_ARROW_LABEL,
|
|
285
|
+
final_arrow_length: DEFAULT_FINAL_ARROW_LENGTH,
|
|
286
|
+
final_arrow_label: DEFAULT_FINAL_ARROW_LABEL,
|
|
287
|
+
initial_position: DEFAULT_INITIAL_POSITION, final_position: DEFAULT_FINAL_POSITION,
|
|
288
|
+
merge_parallel_transitions: DEFAULT_MERGE_PARALLEL_TRANSITIONS,
|
|
289
|
+
label_background: DEFAULT_LABEL_BACKGROUND,
|
|
290
|
+
label_border: DEFAULT_LABEL_BORDER,
|
|
291
|
+
label_padding: DEFAULT_LABEL_PADDING,
|
|
292
|
+
label_radius: DEFAULT_LABEL_RADIUS,
|
|
293
|
+
rotate_labels: DEFAULT_ROTATE_LABELS,
|
|
294
|
+
highlight_unreachable: DEFAULT_HIGHLIGHT_UNREACHABLE,
|
|
295
|
+
highlight_dead_states: DEFAULT_HIGHLIGHT_DEAD_STATES,
|
|
296
|
+
highlight_initial_state: DEFAULT_HIGHLIGHT_INITIAL_STATE,
|
|
297
|
+
highlight_final_states: DEFAULT_HIGHLIGHT_FINAL_STATES,
|
|
298
|
+
highlight_transitions: DEFAULT_HIGHLIGHT_TRANSITIONS,
|
|
299
|
+
unreachable_zone: DEFAULT_UNREACHABLE_ZONE,
|
|
300
|
+
xml_declaration: DEFAULT_XML_DECLARATION,
|
|
301
|
+
css_variables: DEFAULT_CSS_VARIABLES,
|
|
302
|
+
embed_styles: DEFAULT_EMBED_STYLES,
|
|
303
|
+
pretty: DEFAULT_PRETTY,
|
|
304
|
+
minify: DEFAULT_MINIFY,
|
|
305
|
+
state_effect: DEFAULT_STATE_EFFECT,
|
|
306
|
+
loop_position: DEFAULT_LOOP_POSITION,
|
|
307
|
+
edge_style: DEFAULT_EDGE_STYLE,
|
|
308
|
+
show_final_arrows: DEFAULT_SHOW_FINAL_ARROWS,
|
|
309
|
+
scc_groups: DEFAULT_SCC_GROUPS,
|
|
310
|
+
fold_groups: DEFAULT_FOLD_GROUPS,
|
|
311
|
+
preserve_manual_positions: Graphomaton::DEFAULT_PRESERVE_MANUAL_POSITIONS,
|
|
312
|
+
fit: Graphomaton::DEFAULT_FIT,
|
|
313
|
+
title: nil, description: nil, svg_id: nil)
|
|
314
|
+
source_automaton = @automaton
|
|
315
|
+
@diagnostics = []
|
|
316
|
+
@state_radius = resolve_state_radius(state_radius, auto_state_radius, min_state_radius, max_state_radius)
|
|
317
|
+
@state_shape = resolve_state_shape(state_shape)
|
|
318
|
+
@state_stroke_width = finite_number!(state_stroke_width, 'state_stroke_width', positive: true)
|
|
319
|
+
@transition_stroke_width = finite_number!(transition_stroke_width, 'transition_stroke_width', positive: true)
|
|
320
|
+
@arrow_size = finite_number!(arrow_size, 'arrow_size', positive: true)
|
|
321
|
+
@arrow_shape = resolve_arrow_shape(arrow_shape)
|
|
322
|
+
@initial_arrow_length = finite_number!(initial_arrow_length, 'initial_arrow_length', positive: true)
|
|
323
|
+
@initial_arrow_label = validated_output_text(initial_arrow_label, 'initial_arrow_label')
|
|
324
|
+
@final_arrow_length = finite_number!(final_arrow_length, 'final_arrow_length', positive: true)
|
|
325
|
+
@final_arrow_label = validated_output_text(final_arrow_label, 'final_arrow_label')
|
|
326
|
+
@auto_dark_theme = false
|
|
327
|
+
@theme = resolve_theme(theme)
|
|
328
|
+
@layout = resolve_layout(layout)
|
|
329
|
+
@direction = resolve_direction(direction)
|
|
330
|
+
@loop_position = resolve_loop_position(loop_position)
|
|
331
|
+
@edge_style = resolve_edge_style(edge_style)
|
|
332
|
+
@state_effect = resolve_state_effect(state_effect)
|
|
333
|
+
@show_final_arrows = show_final_arrows
|
|
334
|
+
@merge_parallel_transitions = merge_parallel_transitions
|
|
335
|
+
@label_background = label_background
|
|
336
|
+
@label_border = label_border
|
|
337
|
+
@label_padding = finite_number!(label_padding, 'label_padding', nonnegative: true)
|
|
338
|
+
@label_radius = finite_number!(label_radius, 'label_radius', nonnegative: true)
|
|
339
|
+
@rotate_labels = rotate_labels
|
|
340
|
+
@highlight_unreachable = highlight_unreachable
|
|
341
|
+
@highlight_dead_states = highlight_dead_states
|
|
342
|
+
@highlight_initial_state = highlight_initial_state
|
|
343
|
+
@highlight_final_states = highlight_final_states
|
|
344
|
+
@highlight_transitions = Array(highlight_transitions)
|
|
345
|
+
@unreachable_zone = resolve_unreachable_zone(unreachable_zone)
|
|
346
|
+
@css_variables = css_variables || @auto_dark_theme
|
|
347
|
+
@unreachable_states = (@highlight_unreachable || @unreachable_zone != :none) ? @automaton.unreachable_states : []
|
|
348
|
+
@dead_states = @highlight_dead_states ? @automaton.dead_states : []
|
|
349
|
+
@trap_states = @highlight_dead_states ? @automaton.trap_states : []
|
|
350
|
+
@padding = finite_number!(padding, 'padding', nonnegative: true)
|
|
351
|
+
@node_spacing, @rank_spacing = density_adjusted_spacings(node_spacing, rank_spacing, auto_density_spacing)
|
|
352
|
+
unless force_iterations.is_a?(Integer) && force_iterations >= 0
|
|
353
|
+
raise ArgumentError, 'force_iterations must be a non-negative Integer'
|
|
354
|
+
end
|
|
355
|
+
@force_iterations = force_iterations
|
|
356
|
+
@layout_seed = layout_seed
|
|
357
|
+
@wrap_labels = wrap
|
|
358
|
+
@state_wrap = state_wrap
|
|
359
|
+
@max_state_label_width = finite_number!(max_state_label_width, 'max_state_label_width', nonnegative: true)
|
|
360
|
+
@scc_groups = scc_groups
|
|
361
|
+
@max_transition_label_width = finite_number!(max_transition_label_width, 'max_transition_label_width', nonnegative: true)
|
|
362
|
+
@sort_labels = sort_labels
|
|
363
|
+
@label_tooltips = label_tooltips
|
|
364
|
+
@html_tooltips = html_tooltips
|
|
365
|
+
@font_family = safe_css_value(font_family, context: 'font_family')
|
|
366
|
+
@state_font_weight = safe_css_value(state_font_weight, context: 'state_font_weight', allow_nil: true)
|
|
367
|
+
@transition_font_weight = safe_css_value(transition_font_weight, context: 'transition_font_weight', allow_nil: true)
|
|
368
|
+
@automaton = folded_automaton(@automaton) if fold_groups
|
|
369
|
+
layout_padding = automatic_layout_padding
|
|
370
|
+
@positions = @automaton.layout_positions(
|
|
371
|
+
width,
|
|
372
|
+
height,
|
|
373
|
+
layout: @layout,
|
|
374
|
+
direction: @direction,
|
|
375
|
+
state_radius: @state_radius,
|
|
376
|
+
padding: layout_padding,
|
|
377
|
+
node_spacing: @node_spacing,
|
|
378
|
+
rank_spacing: @rank_spacing,
|
|
379
|
+
force_iterations: @force_iterations,
|
|
380
|
+
layout_seed: @layout_seed,
|
|
381
|
+
graphviz_command: graphviz_command,
|
|
382
|
+
initial_position: initial_position,
|
|
383
|
+
final_position: final_position,
|
|
384
|
+
preserve_manual_positions: preserve_manual_positions,
|
|
385
|
+
fit: fit
|
|
386
|
+
)
|
|
387
|
+
@positions = apply_unreachable_zone(@positions, width, height)
|
|
388
|
+
if auto_size
|
|
389
|
+
width, height = auto_size_canvas(width, height)
|
|
390
|
+
end
|
|
391
|
+
@canvas_width = width.to_f
|
|
392
|
+
@canvas_height = height.to_f
|
|
393
|
+
@label_boxes = state_collision_boxes + group_label_collision_boxes
|
|
394
|
+
cell_size = [@state_radius * 2, 64].max
|
|
395
|
+
@label_spatial_index = SpatialIndex.new(cell_size: cell_size)
|
|
396
|
+
@label_boxes.each { |box| @label_spatial_index.insert(box) }
|
|
397
|
+
@state_spatial_index = SpatialIndex.new(cell_size: cell_size)
|
|
398
|
+
@positions.each_value do |state|
|
|
399
|
+
next if state[:x].nil? || state[:y].nil?
|
|
400
|
+
|
|
401
|
+
bounds = {
|
|
402
|
+
x: state[:x] - @state_radius - 10.0,
|
|
403
|
+
y: state[:y] - @state_radius - 10.0,
|
|
404
|
+
width: (@state_radius + 10.0) * 2,
|
|
405
|
+
height: (@state_radius + 10.0) * 2
|
|
406
|
+
}
|
|
407
|
+
@state_spatial_index.insert(bounds, state)
|
|
408
|
+
end
|
|
409
|
+
@title_text = validated_output_text(title, 'SVG title')
|
|
410
|
+
@description_text = validated_output_text(description, 'SVG description')
|
|
411
|
+
@svg_id = svg_id ? svg_id_component(svg_id) : default_svg_id(width, height)
|
|
412
|
+
@arrowhead_id = "#{@svg_id}-arrowhead"
|
|
413
|
+
@element_id_counts = Hash.new(0)
|
|
17
414
|
|
|
18
415
|
doc = REXML::Document.new
|
|
19
|
-
svg = doc.add_element('svg',
|
|
20
|
-
'xmlns' => 'http://www.w3.org/2000/svg',
|
|
21
|
-
'width' => width.to_s,
|
|
22
|
-
'height' => height.to_s,
|
|
23
|
-
'viewBox' => "0 0 #{width} #{height}"
|
|
24
|
-
})
|
|
416
|
+
svg = doc.add_element('svg', svg_root_attributes(width, height, responsive: responsive))
|
|
25
417
|
|
|
26
418
|
add_defs(svg)
|
|
27
|
-
add_style(svg)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
419
|
+
add_style(svg) if embed_styles
|
|
420
|
+
add_accessibility_metadata(svg)
|
|
421
|
+
add_embedded_metadata(svg)
|
|
422
|
+
add_background(svg, width, height)
|
|
423
|
+
transition_group = svg.add_element('g', { 'class' => 'transitions' })
|
|
424
|
+
state_group = svg.add_element('g', { 'class' => 'states' })
|
|
425
|
+
add_transitions(transition_group)
|
|
426
|
+
add_initial_arrow(transition_group) if @automaton.initial_state
|
|
427
|
+
add_final_arrows(transition_group) if @show_final_arrows
|
|
428
|
+
add_state_groups(state_group)
|
|
429
|
+
add_states(state_group)
|
|
430
|
+
apply_auto_content_bounds(svg, responsive: responsive) if auto_size
|
|
31
431
|
|
|
32
|
-
doc
|
|
432
|
+
svg_output = serialize_document(doc, pretty: pretty, minify: minify)
|
|
433
|
+
return svg_output unless xml_declaration
|
|
434
|
+
|
|
435
|
+
%(<?xml version="1.0" encoding="UTF-8"?>\n#{svg_output})
|
|
436
|
+
ensure
|
|
437
|
+
@automaton = source_automaton if source_automaton
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def export_result(*arguments, **options)
|
|
441
|
+
output = export(*arguments, **options)
|
|
442
|
+
render_diagnostics = (@diagnostics || []).dup
|
|
443
|
+
render_diagnostics.concat(
|
|
444
|
+
@automaton.layout_diagnostics_for(@positions, @canvas_width, @canvas_height, @state_radius)
|
|
445
|
+
)
|
|
446
|
+
RenderResult.new(
|
|
447
|
+
output: output.dup.freeze,
|
|
448
|
+
diagnostics: render_diagnostics.freeze,
|
|
449
|
+
bounds: { width: @canvas_width, height: @canvas_height }.freeze,
|
|
450
|
+
layout: @positions.transform_values { |position| position.dup.freeze }.freeze
|
|
451
|
+
)
|
|
33
452
|
end
|
|
34
453
|
|
|
35
454
|
private
|
|
36
455
|
|
|
456
|
+
def validated_output_text(value, context)
|
|
457
|
+
return nil if value.nil?
|
|
458
|
+
|
|
459
|
+
InputPolicy.text!(value.to_s, context: context, max_bytes: Graphomaton::DEFAULT_MAX_LABEL_LENGTH)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def serialize_document(doc, pretty:, minify:)
|
|
463
|
+
raise ArgumentError, 'SVG pretty and minify options cannot both be true' if pretty && minify
|
|
464
|
+
|
|
465
|
+
return minify_document(doc) if minify
|
|
466
|
+
return doc.to_s unless pretty
|
|
467
|
+
|
|
468
|
+
output = +''
|
|
469
|
+
formatter = REXML::Formatters::Pretty.new(2)
|
|
470
|
+
formatter.compact = true
|
|
471
|
+
formatter.write(doc, output)
|
|
472
|
+
output
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def minify_document(doc)
|
|
476
|
+
doc.elements.each('//style') do |style|
|
|
477
|
+
style.text = style.text.to_s.gsub(/\s+/, ' ').strip
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
doc.to_s.gsub(/>\s+</, '><')
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def resolve_theme(theme)
|
|
484
|
+
unless theme.is_a?(Hash)
|
|
485
|
+
theme_name = theme.to_s.to_sym
|
|
486
|
+
if theme_name == :auto
|
|
487
|
+
@auto_dark_theme = true
|
|
488
|
+
return Graphomaton::Theme.resolve(theme, context: 'SVG theme', allow_auto: true)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
Graphomaton::Theme.resolve(theme, context: 'SVG theme', allow_auto: true)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def resolve_layout(layout)
|
|
496
|
+
resolved = layout.to_sym
|
|
497
|
+
return resolved if LAYOUT_OPTIONS.include?(resolved)
|
|
498
|
+
|
|
499
|
+
raise ArgumentError, "Unknown SVG layout: #{layout.inspect}. Available layouts: #{LAYOUT_OPTIONS.join(', ')}"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def resolve_direction(direction)
|
|
503
|
+
resolved = direction.to_sym
|
|
504
|
+
return resolved if DIRECTION_OPTIONS.include?(resolved)
|
|
505
|
+
|
|
506
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def resolve_loop_position(loop_position)
|
|
510
|
+
resolved = loop_position.to_sym
|
|
511
|
+
return resolved if LOOP_POSITION_OPTIONS.include?(resolved)
|
|
512
|
+
|
|
513
|
+
raise ArgumentError, "Unknown loop_position: #{loop_position.inspect}. Available values: #{LOOP_POSITION_OPTIONS.join(', ')}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def resolve_edge_style(edge_style)
|
|
517
|
+
resolved = edge_style.to_sym
|
|
518
|
+
return resolved if EDGE_STYLE_OPTIONS.include?(resolved)
|
|
519
|
+
|
|
520
|
+
raise ArgumentError, "Unknown edge_style: #{edge_style.inspect}. Available values: #{EDGE_STYLE_OPTIONS.join(', ')}"
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def resolve_state_shape(state_shape)
|
|
524
|
+
resolved = state_shape.to_sym
|
|
525
|
+
return resolved if STATE_SHAPE_OPTIONS.include?(resolved)
|
|
526
|
+
|
|
527
|
+
raise ArgumentError, "Unknown state_shape: #{state_shape.inspect}. Available values: #{STATE_SHAPE_OPTIONS.join(', ')}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def resolve_state_effect(state_effect)
|
|
531
|
+
resolved = state_effect.to_sym
|
|
532
|
+
return resolved if STATE_EFFECT_OPTIONS.include?(resolved)
|
|
533
|
+
|
|
534
|
+
raise ArgumentError, "Unknown state_effect: #{state_effect.inspect}. Available values: #{STATE_EFFECT_OPTIONS.join(', ')}"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def resolve_unreachable_zone(unreachable_zone)
|
|
538
|
+
resolved = unreachable_zone.to_sym
|
|
539
|
+
return resolved if UNREACHABLE_ZONE_OPTIONS.include?(resolved)
|
|
540
|
+
|
|
541
|
+
raise ArgumentError, "Unknown unreachable_zone: #{unreachable_zone.inspect}. Available values: #{UNREACHABLE_ZONE_OPTIONS.join(', ')}"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def resolve_arrow_shape(arrow_shape)
|
|
545
|
+
resolved = arrow_shape.to_sym
|
|
546
|
+
return resolved if ARROW_SHAPE_OPTIONS.include?(resolved)
|
|
547
|
+
|
|
548
|
+
raise ArgumentError, "Unknown arrow_shape: #{arrow_shape.inspect}. Available values: #{ARROW_SHAPE_OPTIONS.join(', ')}"
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def apply_unreachable_zone(positions, width, height)
|
|
552
|
+
return positions if @unreachable_zone == :none || @unreachable_states.empty?
|
|
553
|
+
|
|
554
|
+
moved_positions = positions.transform_values(&:dup)
|
|
555
|
+
states = @unreachable_states.select { |state| moved_positions.key?(state) }
|
|
556
|
+
return positions if states.empty?
|
|
557
|
+
|
|
558
|
+
margin = [@padding.to_f, @state_radius + 20].max
|
|
559
|
+
spacing = [@node_spacing.to_f, @state_radius * 2.5].max
|
|
560
|
+
if %i[bottom top].include?(@unreachable_zone)
|
|
561
|
+
y = height.to_f - margin
|
|
562
|
+
y = margin if @unreachable_zone == :top
|
|
563
|
+
start_x = centered_zone_start(width.to_f, states.size, spacing, margin)
|
|
564
|
+
states.each_with_index do |state, index|
|
|
565
|
+
moved_positions[state][:x] = start_x + (index * spacing)
|
|
566
|
+
moved_positions[state][:y] = y
|
|
567
|
+
end
|
|
568
|
+
else
|
|
569
|
+
x = width.to_f - margin
|
|
570
|
+
x = margin if @unreachable_zone == :left
|
|
571
|
+
start_y = centered_zone_start(height.to_f, states.size, spacing, margin)
|
|
572
|
+
states.each_with_index do |state, index|
|
|
573
|
+
moved_positions[state][:x] = x
|
|
574
|
+
moved_positions[state][:y] = start_y + (index * spacing)
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
moved_positions
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def centered_zone_start(size, count, spacing, margin)
|
|
582
|
+
span = [count - 1, 0].max * spacing
|
|
583
|
+
[[(size - span) / 2.0, margin].max, size - margin - span].min
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def density_adjusted_spacings(node_spacing, rank_spacing, auto_density_spacing)
|
|
587
|
+
resolved_node_spacing = finite_number!(node_spacing, 'node_spacing', nonnegative: true)
|
|
588
|
+
resolved_rank_spacing = finite_number!(rank_spacing, 'rank_spacing', nonnegative: true)
|
|
589
|
+
return [resolved_node_spacing, resolved_rank_spacing] unless auto_density_spacing
|
|
590
|
+
|
|
591
|
+
state_count = @automaton.state_records.size
|
|
592
|
+
return [resolved_node_spacing, resolved_rank_spacing] if state_count <= 4
|
|
593
|
+
|
|
594
|
+
multiplier = 1.0 + ([[state_count - 4, 16].min, 0].max * 0.06)
|
|
595
|
+
[
|
|
596
|
+
(resolved_node_spacing * multiplier).round(2),
|
|
597
|
+
(resolved_rank_spacing * multiplier).round(2)
|
|
598
|
+
]
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def auto_size_canvas(width, height)
|
|
602
|
+
x_values = @positions.values.map { |position| position[:x].to_f }
|
|
603
|
+
y_values = @positions.values.map { |position| position[:y].to_f }
|
|
604
|
+
return [width.to_f, height.to_f] if x_values.empty? || y_values.empty?
|
|
605
|
+
|
|
606
|
+
min_x = x_values.min
|
|
607
|
+
max_x = x_values.max
|
|
608
|
+
min_y = y_values.min
|
|
609
|
+
max_y = y_values.max
|
|
610
|
+
|
|
611
|
+
return [width.to_f, height.to_f] unless min_x && max_x && min_y && max_y
|
|
612
|
+
|
|
613
|
+
horizontal_margin = [automatic_layout_padding, @state_radius + 20].max
|
|
614
|
+
vertical_margin = [automatic_layout_padding, @state_radius + 20].max
|
|
615
|
+
shift_x = min_x - horizontal_margin
|
|
616
|
+
shift_y = min_y - vertical_margin
|
|
617
|
+
|
|
618
|
+
if shift_x.nonzero? || shift_y.nonzero?
|
|
619
|
+
@positions.each_value do |position|
|
|
620
|
+
position[:x] -= shift_x
|
|
621
|
+
position[:y] -= shift_y
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
width = (max_x - min_x) + (horizontal_margin * 2)
|
|
626
|
+
height = (max_y - min_y) + (vertical_margin * 2)
|
|
627
|
+
[width.to_f, height.to_f]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def apply_auto_content_bounds(svg, responsive:)
|
|
631
|
+
bounds = rendered_content_bounds(svg)
|
|
632
|
+
return unless bounds
|
|
633
|
+
|
|
634
|
+
margin = [@arrow_size, @state_stroke_width, @transition_stroke_width, 12.0].max
|
|
635
|
+
view_x = bounds[:min_x] - margin
|
|
636
|
+
view_y = bounds[:min_y] - margin
|
|
637
|
+
view_width = (bounds[:max_x] - bounds[:min_x]) + (margin * 2)
|
|
638
|
+
view_height = (bounds[:max_y] - bounds[:min_y]) + (margin * 2)
|
|
639
|
+
svg.attributes['viewBox'] = "#{view_x} #{view_y} #{view_width} #{view_height}"
|
|
640
|
+
unless responsive
|
|
641
|
+
svg.attributes['width'] = view_width.to_s
|
|
642
|
+
svg.attributes['height'] = view_height.to_s
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
background = REXML::XPath.first(svg, './/rect[@class="diagram-background"]')
|
|
646
|
+
return unless background
|
|
647
|
+
|
|
648
|
+
background.attributes['x'] = view_x.to_s
|
|
649
|
+
background.attributes['y'] = view_y.to_s
|
|
650
|
+
background.attributes['width'] = view_width.to_s
|
|
651
|
+
background.attributes['height'] = view_height.to_s
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def rendered_content_bounds(svg)
|
|
655
|
+
bounds = nil
|
|
656
|
+
REXML::XPath.each(svg, './/*') do |element|
|
|
657
|
+
next if element_in_defs?(element)
|
|
658
|
+
|
|
659
|
+
element_bounds = rendered_element_bounds(element)
|
|
660
|
+
bounds = merge_bounds(bounds, element_bounds) if element_bounds
|
|
661
|
+
end
|
|
662
|
+
bounds
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def rendered_element_bounds(element)
|
|
666
|
+
case element.name
|
|
667
|
+
when 'circle'
|
|
668
|
+
centered_bounds(element, 'r', 'r')
|
|
669
|
+
when 'ellipse'
|
|
670
|
+
centered_bounds(element, 'rx', 'ry')
|
|
671
|
+
when 'rect'
|
|
672
|
+
return nil if element.attributes['class'] == 'diagram-background'
|
|
673
|
+
|
|
674
|
+
rectangular_bounds(element)
|
|
675
|
+
when 'line'
|
|
676
|
+
coordinate_bounds([
|
|
677
|
+
[numeric_attribute(element, 'x1'), numeric_attribute(element, 'y1')],
|
|
678
|
+
[numeric_attribute(element, 'x2'), numeric_attribute(element, 'y2')]
|
|
679
|
+
], element.attributes['transform'])
|
|
680
|
+
when 'polygon', 'polyline'
|
|
681
|
+
points = element.attributes['points'].to_s.scan(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/i).map(&:to_f).each_slice(2).to_a
|
|
682
|
+
coordinate_bounds(points, element.attributes['transform'])
|
|
683
|
+
when 'path'
|
|
684
|
+
points = element.attributes['d'].to_s.scan(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/i).map(&:to_f).each_slice(2).to_a
|
|
685
|
+
coordinate_bounds(points, element.attributes['transform'])
|
|
686
|
+
when 'text'
|
|
687
|
+
text_bounds(element)
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def centered_bounds(element, radius_x_name, radius_y_name)
|
|
692
|
+
center_x = numeric_attribute(element, 'cx')
|
|
693
|
+
center_y = numeric_attribute(element, 'cy')
|
|
694
|
+
radius_x = numeric_attribute(element, radius_x_name)
|
|
695
|
+
radius_y = numeric_attribute(element, radius_y_name)
|
|
696
|
+
coordinate_bounds(
|
|
697
|
+
[[center_x - radius_x, center_y - radius_y], [center_x + radius_x, center_y + radius_y]],
|
|
698
|
+
element.attributes['transform']
|
|
699
|
+
)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def rectangular_bounds(element)
|
|
703
|
+
x = numeric_attribute(element, 'x')
|
|
704
|
+
y = numeric_attribute(element, 'y')
|
|
705
|
+
width = numeric_attribute(element, 'width')
|
|
706
|
+
height = numeric_attribute(element, 'height')
|
|
707
|
+
coordinate_bounds(
|
|
708
|
+
[[x, y], [x + width, y], [x + width, y + height], [x, y + height]],
|
|
709
|
+
element.attributes['transform']
|
|
710
|
+
)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def text_bounds(element)
|
|
714
|
+
x = numeric_attribute(element, 'x')
|
|
715
|
+
y = numeric_attribute(element, 'y')
|
|
716
|
+
lines = element.get_elements('tspan').map { |line| line.text.to_s }
|
|
717
|
+
lines = [element.text.to_s] if lines.empty?
|
|
718
|
+
width = lines.map { |line| measure_text_width(line) }.max || 0
|
|
719
|
+
height = [lines.size, 1].max * label_line_height
|
|
720
|
+
anchor = element.attributes['text-anchor']
|
|
721
|
+
left = anchor == 'middle' ? x - (width / 2.0) : (anchor == 'end' ? x - width : x)
|
|
722
|
+
coordinate_bounds(
|
|
723
|
+
[[left, y - height], [left + width, y + (height * 0.25)]],
|
|
724
|
+
element.attributes['transform']
|
|
725
|
+
)
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def coordinate_bounds(points, transform = nil)
|
|
729
|
+
return nil if points.empty? || points.any? { |point| point.length < 2 }
|
|
730
|
+
|
|
731
|
+
transformed = rotate_points(points, transform)
|
|
732
|
+
xs = transformed.map(&:first)
|
|
733
|
+
ys = transformed.map(&:last)
|
|
734
|
+
{ min_x: xs.min, min_y: ys.min, max_x: xs.max, max_y: ys.max }
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
def rotate_points(points, transform)
|
|
738
|
+
match = transform.to_s.match(/rotate\((-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\)/)
|
|
739
|
+
return points unless match
|
|
740
|
+
|
|
741
|
+
angle = match[1].to_f * Math::PI / 180.0
|
|
742
|
+
center_x = match[2].to_f
|
|
743
|
+
center_y = match[3].to_f
|
|
744
|
+
points.map do |x, y|
|
|
745
|
+
delta_x = x - center_x
|
|
746
|
+
delta_y = y - center_y
|
|
747
|
+
[
|
|
748
|
+
center_x + (delta_x * Math.cos(angle)) - (delta_y * Math.sin(angle)),
|
|
749
|
+
center_y + (delta_x * Math.sin(angle)) + (delta_y * Math.cos(angle))
|
|
750
|
+
]
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
def numeric_attribute(element, name)
|
|
755
|
+
Float(element.attributes[name])
|
|
756
|
+
rescue ArgumentError, TypeError
|
|
757
|
+
0.0
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def element_in_defs?(element)
|
|
761
|
+
parent = element.parent
|
|
762
|
+
until parent.nil?
|
|
763
|
+
return true if parent.respond_to?(:name) && parent.name == 'defs'
|
|
764
|
+
|
|
765
|
+
parent = parent.parent
|
|
766
|
+
end
|
|
767
|
+
false
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def merge_bounds(left, right)
|
|
771
|
+
return right unless left
|
|
772
|
+
|
|
773
|
+
{
|
|
774
|
+
min_x: [left[:min_x], right[:min_x]].min,
|
|
775
|
+
min_y: [left[:min_y], right[:min_y]].min,
|
|
776
|
+
max_x: [left[:max_x], right[:max_x]].max,
|
|
777
|
+
max_y: [left[:max_y], right[:max_y]].max
|
|
778
|
+
}
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
def svg_root_attributes(width, height, responsive:)
|
|
782
|
+
{
|
|
783
|
+
'xmlns' => 'http://www.w3.org/2000/svg',
|
|
784
|
+
'id' => @svg_id,
|
|
785
|
+
'viewBox' => "0 0 #{width} #{height}",
|
|
786
|
+
'preserveAspectRatio' => 'xMidYMid meet',
|
|
787
|
+
'role' => 'img',
|
|
788
|
+
'aria-labelledby' => "#{@svg_id}-title #{@svg_id}-desc",
|
|
789
|
+
'width' => (responsive ? '100%' : width.to_s),
|
|
790
|
+
'height' => (responsive ? 'auto' : height.to_s)
|
|
791
|
+
}
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def add_accessibility_metadata(svg)
|
|
795
|
+
title = svg.add_element('title', { 'id' => "#{@svg_id}-title" })
|
|
796
|
+
title.text = @title_text || 'Finite state machine diagram'
|
|
797
|
+
|
|
798
|
+
description = svg.add_element('desc', { 'id' => "#{@svg_id}-desc" })
|
|
799
|
+
description.text = @description_text || generated_description
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def generated_description
|
|
803
|
+
state_count = @automaton.state_records.size
|
|
804
|
+
transition_count = @automaton.transition_records.size
|
|
805
|
+
|
|
806
|
+
"Automaton with #{state_count} states and #{transition_count} transitions."
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def add_embedded_metadata(svg)
|
|
810
|
+
metadata = svg.add_element('metadata')
|
|
811
|
+
metadata.add_element('graphomaton', {
|
|
812
|
+
'generator' => 'graphomaton',
|
|
813
|
+
'version' => Graphomaton::VERSION,
|
|
814
|
+
'format' => 'svg'
|
|
815
|
+
})
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def resolve_state_radius(state_radius, auto_state_radius, min_state_radius, max_state_radius)
|
|
819
|
+
radius = finite_number!(state_radius, 'state_radius', positive: true)
|
|
820
|
+
min_radius = finite_number!(min_state_radius, 'min_state_radius', positive: true)
|
|
821
|
+
max_radius = finite_number!(max_state_radius, 'max_state_radius', positive: true)
|
|
822
|
+
raise ArgumentError, 'max_state_radius must be greater than or equal to min_state_radius' if max_radius < min_radius
|
|
823
|
+
return radius unless auto_state_radius
|
|
824
|
+
|
|
825
|
+
label_radius = state_label_radius
|
|
826
|
+
|
|
827
|
+
[[radius, label_radius, min_radius].max, max_radius].min
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def state_label_radius
|
|
831
|
+
max_width_units = @automaton.state_records.map do |name, state|
|
|
832
|
+
text_display_width_units(state_label(name, state))
|
|
833
|
+
end.max || 0
|
|
834
|
+
return DEFAULT_STATE_RADIUS if max_width_units <= 0
|
|
835
|
+
|
|
836
|
+
((max_width_units * 20) / 1.7).ceil
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def finite_number!(value, name, positive: false, nonnegative: false)
|
|
840
|
+
finite = value.is_a?(Numeric) && value.real? && value.to_f.finite?
|
|
841
|
+
valid_range = if positive
|
|
842
|
+
finite && value.positive?
|
|
843
|
+
elsif nonnegative
|
|
844
|
+
finite && value >= 0
|
|
845
|
+
else
|
|
846
|
+
finite
|
|
847
|
+
end
|
|
848
|
+
return value.to_f if valid_range
|
|
849
|
+
|
|
850
|
+
qualifier = positive ? 'positive ' : (nonnegative ? 'non-negative ' : '')
|
|
851
|
+
raise ArgumentError, "#{name} must be a #{qualifier}finite number"
|
|
852
|
+
end
|
|
853
|
+
|
|
37
854
|
def calculate_text_width(text)
|
|
38
|
-
|
|
39
|
-
|
|
855
|
+
width = measure_text_width(text) + (@label_padding * 2)
|
|
856
|
+
[width.ceil, 60].max
|
|
857
|
+
end
|
|
40
858
|
|
|
41
|
-
|
|
42
|
-
|
|
859
|
+
def measure_text_width(text)
|
|
860
|
+
text_display_width_units(text) * TEXT_UNIT_WIDTH
|
|
43
861
|
end
|
|
44
862
|
|
|
45
863
|
def calculate_state_font_size(name)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
estimated_width = (ascii_chars * 0.55) + (non_ascii_chars * 0.9)
|
|
50
|
-
available_width = STATE_RADIUS * 1.7
|
|
864
|
+
estimated_width = text_display_width_units(name)
|
|
865
|
+
available_width = (@state_radius || DEFAULT_STATE_RADIUS) * 1.7
|
|
51
866
|
|
|
52
867
|
base_size = 20
|
|
53
868
|
calculated_size = if estimated_width * base_size > available_width
|
|
@@ -59,270 +874,1999 @@ class Graphomaton
|
|
|
59
874
|
[calculated_size, 12].max
|
|
60
875
|
end
|
|
61
876
|
|
|
877
|
+
def text_display_width_units(text)
|
|
878
|
+
text.to_s.each_char.sum { |char| character_width_units(char) }
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
def character_width_units(char)
|
|
882
|
+
codepoint = char.ord
|
|
883
|
+
return 0.0 if zero_width_codepoint?(codepoint)
|
|
884
|
+
return 0.4 if char.match?(/\s/)
|
|
885
|
+
return 1.0 if codepoint_in_ranges?(codepoint, EAST_ASIAN_WIDE_RANGES)
|
|
886
|
+
return 0.55 if codepoint < 0x80
|
|
887
|
+
|
|
888
|
+
0.65
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
def zero_width_codepoint?(codepoint)
|
|
892
|
+
codepoint == 0x200D ||
|
|
893
|
+
codepoint == 0xFE0F ||
|
|
894
|
+
codepoint_in_ranges?(codepoint, COMBINING_MARK_RANGES)
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def codepoint_in_ranges?(codepoint, ranges)
|
|
898
|
+
ranges.any? { |range| range.cover?(codepoint) }
|
|
899
|
+
end
|
|
900
|
+
|
|
62
901
|
def add_defs(svg)
|
|
63
902
|
defs = svg.add_element('defs')
|
|
903
|
+
marker_height = @arrow_size * 0.6
|
|
64
904
|
marker = defs.add_element('marker', {
|
|
65
|
-
'id' =>
|
|
66
|
-
'markerWidth' =>
|
|
67
|
-
'markerHeight' =>
|
|
68
|
-
'refX' =>
|
|
69
|
-
'refY' =>
|
|
70
|
-
'orient' => 'auto'
|
|
905
|
+
'id' => @arrowhead_id,
|
|
906
|
+
'markerWidth' => @arrow_size.to_s,
|
|
907
|
+
'markerHeight' => marker_height.to_s,
|
|
908
|
+
'refX' => (@arrow_size * 0.9).to_s,
|
|
909
|
+
'refY' => (marker_height / 2).to_s,
|
|
910
|
+
'orient' => 'auto',
|
|
911
|
+
'markerUnits' => 'strokeWidth'
|
|
71
912
|
})
|
|
72
|
-
marker
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
913
|
+
add_arrowhead_shape(marker, marker_height)
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
def add_arrowhead_shape(marker, marker_height)
|
|
917
|
+
case @arrow_shape
|
|
918
|
+
when :vee
|
|
919
|
+
marker.add_element('polyline', {
|
|
920
|
+
'points' => "0 0, #{@arrow_size} #{marker_height / 2}, 0 #{marker_height}",
|
|
921
|
+
'fill' => 'none',
|
|
922
|
+
'stroke' => theme_css_value(:stroke),
|
|
923
|
+
'stroke-width' => '1.5',
|
|
924
|
+
'stroke-linecap' => 'round',
|
|
925
|
+
'stroke-linejoin' => 'round'
|
|
926
|
+
})
|
|
927
|
+
when :stealth
|
|
928
|
+
marker.add_element('polygon', {
|
|
929
|
+
'points' => "0 0, #{@arrow_size} #{marker_height / 2}, 0 #{marker_height}, #{@arrow_size * 0.35} #{marker_height / 2}",
|
|
930
|
+
'fill' => theme_css_value(:stroke)
|
|
931
|
+
})
|
|
932
|
+
else
|
|
933
|
+
marker.add_element('polygon', {
|
|
934
|
+
'points' => "0 0, #{@arrow_size} #{marker_height / 2}, 0 #{marker_height}",
|
|
935
|
+
'fill' => theme_css_value(:stroke)
|
|
936
|
+
})
|
|
937
|
+
end
|
|
76
938
|
end
|
|
77
939
|
|
|
78
940
|
def add_style(svg)
|
|
79
941
|
style = svg.add_element('style')
|
|
942
|
+
background = theme_css_value(:background, fallback: 'transparent')
|
|
943
|
+
scope = "##{@svg_id}"
|
|
80
944
|
style.text = <<-CSS
|
|
81
|
-
|
|
82
|
-
.
|
|
83
|
-
.state-
|
|
84
|
-
.
|
|
85
|
-
.
|
|
86
|
-
.
|
|
87
|
-
.
|
|
945
|
+
#{css_variables_css}
|
|
946
|
+
#{scope} .diagram-background { fill: #{background}; }
|
|
947
|
+
#{scope} .state-circle { fill: #{theme_css_value(:state_fill)}; stroke: #{theme_css_value(:stroke)}; stroke-width: #{@state_stroke_width}; vector-effect: non-scaling-stroke; shape-rendering: geometricPrecision; #{state_effect_css} }
|
|
948
|
+
#{scope} .final-state { stroke-width: #{final_state_stroke_width}; }
|
|
949
|
+
#{scope} .state-text { font-family: #{@font_family}; text-anchor: middle; fill: #{theme_css_value(:state_text)}; text-rendering: geometricPrecision; #{font_weight_css(@state_font_weight)} }
|
|
950
|
+
#{scope} .state-icon { font-family: #{@font_family}; text-anchor: middle; fill: #{theme_css_value(:state_text)}; font-size: 14px; text-rendering: geometricPrecision; }
|
|
951
|
+
#{scope} .transition-line { stroke: #{theme_css_value(:stroke)}; stroke-width: #{@transition_stroke_width}; fill: none; marker-end: url(##{@arrowhead_id}); vector-effect: non-scaling-stroke; shape-rendering: geometricPrecision; stroke-linecap: round; stroke-linejoin: round; }
|
|
952
|
+
#{scope} .transition-label { font-family: #{@font_family}; font-size: 14px; fill: #{theme_css_value(:transition_label)}; text-rendering: geometricPrecision; #{font_weight_css(@transition_font_weight)} }
|
|
953
|
+
#{scope} .label-leader { stroke: #{theme_css_value(:transition_label)}; stroke-width: 1; opacity: 0.45; fill: none; vector-effect: non-scaling-stroke; stroke-linecap: round; }
|
|
954
|
+
#{scope} .initial-arrow { stroke: #{theme_css_value(:stroke)}; stroke-width: #{arrow_stroke_width}; fill: none; marker-end: url(##{@arrowhead_id}); vector-effect: non-scaling-stroke; shape-rendering: geometricPrecision; stroke-linecap: round; stroke-linejoin: round; }
|
|
955
|
+
#{scope} .final-arrow { stroke: #{theme_css_value(:stroke)}; stroke-width: #{arrow_stroke_width}; fill: none; marker-end: url(##{@arrowhead_id}); vector-effect: non-scaling-stroke; shape-rendering: geometricPrecision; stroke-linecap: round; stroke-linejoin: round; }
|
|
956
|
+
#{scope} .label-bg { fill: #{theme_css_value(:label_background)}; opacity: #{theme_css_value(:label_opacity)}; #{label_border_css} }
|
|
957
|
+
#{scope} .state-group-box { fill: #{theme_css_value(:stroke)}; opacity: 0.08; stroke: #{theme_css_value(:stroke)}; stroke-width: 1; stroke-dasharray: 6 4; }
|
|
958
|
+
#{scope} .state-group-label { font-family: #{@font_family}; font-size: 12px; fill: #{theme_css_value(:state_text)}; font-weight: 700; text-rendering: geometricPrecision; }
|
|
959
|
+
#{scope} .unreachable-state { opacity: 0.45; }
|
|
960
|
+
#{scope} .initial-state .state-circle { fill: #{theme_css_value(:initial_fill, fallback: '#dbeafe')}; }
|
|
961
|
+
#{scope} .accepting-state .state-circle { fill: #{theme_css_value(:final_fill, fallback: '#dcfce7')}; }
|
|
962
|
+
#{scope} .dead-state { opacity: 0.65; }
|
|
963
|
+
#{scope} .dead-state .state-circle { stroke-dasharray: 6 4; }
|
|
964
|
+
#{scope} .trap-state .state-circle { stroke-dasharray: 2 4; }
|
|
965
|
+
#{scope} .highlighted-transition .transition-line { stroke: #{theme_css_value(:highlight_stroke, fallback: '#ef4444')}; stroke-width: #{highlighted_transition_stroke_width}; }
|
|
966
|
+
#{scope} .inactive-transition { opacity: #{theme_css_value(:inactive_opacity, fallback: '0.25')}; }
|
|
967
|
+
#{scope} .bundled-transition .transition-line { stroke-dasharray: 10 4; }
|
|
968
|
+
#{state_effect_animation_css}
|
|
88
969
|
CSS
|
|
89
970
|
end
|
|
90
971
|
|
|
91
|
-
def
|
|
92
|
-
|
|
93
|
-
from_state_indices = {}
|
|
94
|
-
|
|
95
|
-
@automaton.transitions.each_with_index do |trans, _idx|
|
|
96
|
-
from_state = @automaton.states[trans[:from]]
|
|
97
|
-
to_state = @automaton.states[trans[:to]]
|
|
98
|
-
|
|
99
|
-
if from_state == to_state
|
|
100
|
-
add_self_loop(svg, from_state, trans)
|
|
101
|
-
else
|
|
102
|
-
from_state_indices[trans[:from]] = 0 unless from_state_indices[trans[:from]]
|
|
103
|
-
from_state_index = from_state_indices[trans[:from]]
|
|
104
|
-
from_state_indices[trans[:from]] += 1
|
|
972
|
+
def label_border_css
|
|
973
|
+
return 'stroke: none;' unless @label_border
|
|
105
974
|
|
|
106
|
-
|
|
107
|
-
end
|
|
108
|
-
end
|
|
975
|
+
"stroke: #{theme_css_value(:stroke)}; stroke-width: 1; vector-effect: non-scaling-stroke;"
|
|
109
976
|
end
|
|
110
977
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
cy = state[:y]
|
|
114
|
-
|
|
115
|
-
loop_height = 80
|
|
116
|
-
loop_width = 45
|
|
117
|
-
|
|
118
|
-
start_angle = -135 * Math::PI / 180
|
|
119
|
-
end_angle = -45 * Math::PI / 180
|
|
120
|
-
radius = STATE_RADIUS
|
|
121
|
-
|
|
122
|
-
start_x = cx + (radius * Math.cos(start_angle))
|
|
123
|
-
start_y = cy + (radius * Math.sin(start_angle))
|
|
124
|
-
end_x = cx + (radius * Math.cos(end_angle))
|
|
125
|
-
end_y = cy + (radius * Math.sin(end_angle))
|
|
978
|
+
def font_weight_css(weight)
|
|
979
|
+
return '' if weight.nil?
|
|
126
980
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
control2_x = cx + loop_width
|
|
130
|
-
control2_y = cy - loop_height
|
|
131
|
-
|
|
132
|
-
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
981
|
+
"font-weight: #{weight};"
|
|
982
|
+
end
|
|
133
983
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
'd' => path_d
|
|
137
|
-
})
|
|
984
|
+
def css_variables_css
|
|
985
|
+
return '' unless @css_variables
|
|
138
986
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
'class' => 'label-bg',
|
|
142
|
-
'x' => (cx - (text_width / 2)).to_s,
|
|
143
|
-
'y' => (cy - loop_height - 5).to_s,
|
|
144
|
-
'width' => text_width.to_s,
|
|
145
|
-
'height' => '20',
|
|
146
|
-
'rx' => '3'
|
|
147
|
-
})
|
|
987
|
+
base_css = css_variable_scope(@svg_id, @theme)
|
|
988
|
+
return base_css unless @auto_dark_theme
|
|
148
989
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'text-anchor' => 'middle'
|
|
154
|
-
})
|
|
155
|
-
label.text = trans[:label]
|
|
990
|
+
<<-CSS
|
|
991
|
+
#{base_css} @media (prefers-color-scheme: dark) {
|
|
992
|
+
#{css_variable_scope(@svg_id, THEMES.fetch(:dark), indentation: ' ')} }
|
|
993
|
+
CSS
|
|
156
994
|
end
|
|
157
995
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
pair_index = processed_pairs[pair_key]
|
|
168
|
-
processed_pairs[pair_key] += 1
|
|
996
|
+
def css_variable_scope(svg_id, theme, indentation: ' ')
|
|
997
|
+
variable_keys = %i[
|
|
998
|
+
background state_fill stroke state_text transition_label label_background label_opacity
|
|
999
|
+
initial_fill final_fill highlight_stroke inactive_opacity
|
|
1000
|
+
]
|
|
1001
|
+
declarations = variable_keys.filter_map do |key|
|
|
1002
|
+
value = theme[key] || (key == :background ? 'transparent' : nil)
|
|
1003
|
+
next unless value
|
|
169
1004
|
|
|
170
|
-
|
|
1005
|
+
"#{indentation} --graphomaton-#{css_variable_name(key)}: #{value};"
|
|
1006
|
+
end.join("\n")
|
|
171
1007
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
dist = Math.sqrt((dx**2) + (dy**2))
|
|
1008
|
+
"#{indentation}##{svg_id} {\n#{declarations}\n#{indentation}}\n"
|
|
1009
|
+
end
|
|
175
1010
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
end_x = x2 - ((dx / dist) * radius)
|
|
180
|
-
end_y = y2 - ((dy / dist) * radius)
|
|
1011
|
+
def theme_css_value(key, fallback: nil)
|
|
1012
|
+
value = @theme[key] || fallback
|
|
1013
|
+
return value unless @css_variables
|
|
181
1014
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
to_index = state_names.index(trans[:to])
|
|
1015
|
+
"var(--graphomaton-#{css_variable_name(key)}, #{value})"
|
|
1016
|
+
end
|
|
185
1017
|
|
|
186
|
-
|
|
187
|
-
|
|
1018
|
+
def css_variable_name(key)
|
|
1019
|
+
key.to_s.tr('_', '-')
|
|
1020
|
+
end
|
|
188
1021
|
|
|
189
|
-
|
|
190
|
-
|
|
1022
|
+
def state_effect_css
|
|
1023
|
+
case @state_effect
|
|
1024
|
+
when :shadow
|
|
1025
|
+
'filter: drop-shadow(0 4px 8px rgba(15, 23, 42, 0.25));'
|
|
1026
|
+
when :glow
|
|
1027
|
+
"filter: drop-shadow(0 0 8px #{theme_css_value(:stroke)});"
|
|
1028
|
+
when :pulse
|
|
1029
|
+
"filter: drop-shadow(0 0 6px #{theme_css_value(:stroke)}); animation: #{pulse_animation_name} 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center;"
|
|
191
1030
|
else
|
|
192
|
-
|
|
1031
|
+
''
|
|
193
1032
|
end
|
|
194
1033
|
end
|
|
195
1034
|
|
|
196
|
-
def
|
|
197
|
-
|
|
198
|
-
'class' => 'transition-line',
|
|
199
|
-
'x1' => start_x.to_s,
|
|
200
|
-
'y1' => start_y.to_s,
|
|
201
|
-
'x2' => end_x.to_s,
|
|
202
|
-
'y2' => end_y.to_s
|
|
203
|
-
})
|
|
1035
|
+
def state_effect_animation_css
|
|
1036
|
+
return '' unless @state_effect == :pulse
|
|
204
1037
|
|
|
205
|
-
|
|
206
|
-
|
|
1038
|
+
<<-CSS
|
|
1039
|
+
@keyframes #{pulse_animation_name} {
|
|
1040
|
+
0%, 100% { opacity: 1; filter: drop-shadow(0 0 4px #{theme_css_value(:stroke)}); }
|
|
1041
|
+
50% { opacity: 0.72; filter: drop-shadow(0 0 14px #{theme_css_value(:stroke)}); }
|
|
1042
|
+
}
|
|
1043
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1044
|
+
##{@svg_id} .state-circle { animation: none; }
|
|
1045
|
+
}
|
|
1046
|
+
CSS
|
|
1047
|
+
end
|
|
207
1048
|
|
|
208
|
-
|
|
1049
|
+
def final_state_stroke_width
|
|
1050
|
+
@state_stroke_width * 2
|
|
209
1051
|
end
|
|
210
1052
|
|
|
211
|
-
def
|
|
212
|
-
|
|
213
|
-
|
|
1053
|
+
def arrow_stroke_width
|
|
1054
|
+
@transition_stroke_width + 0.5
|
|
1055
|
+
end
|
|
214
1056
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
STATE_RADIUS * 2
|
|
219
|
-
end
|
|
1057
|
+
def highlighted_transition_stroke_width
|
|
1058
|
+
@transition_stroke_width + 1.0
|
|
1059
|
+
end
|
|
220
1060
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
else
|
|
225
|
-
base_offset + (50 * pair_index)
|
|
226
|
-
end
|
|
227
|
-
elsif x1 < x2
|
|
228
|
-
-base_offset
|
|
229
|
-
else
|
|
230
|
-
base_offset
|
|
231
|
-
end
|
|
1061
|
+
def pulse_animation_name
|
|
1062
|
+
"graphomaton-pulse-#{@svg_id}"
|
|
1063
|
+
end
|
|
232
1064
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
1065
|
+
def default_svg_id(width, height)
|
|
1066
|
+
components = [
|
|
1067
|
+
@automaton.state_records,
|
|
1068
|
+
@automaton.transition_records,
|
|
1069
|
+
@automaton.initial_state,
|
|
1070
|
+
@automaton.final_states,
|
|
1071
|
+
@positions,
|
|
1072
|
+
width,
|
|
1073
|
+
height,
|
|
1074
|
+
@theme,
|
|
1075
|
+
@layout,
|
|
1076
|
+
@direction
|
|
1077
|
+
]
|
|
1078
|
+
payload = Marshal.dump(components)
|
|
1079
|
+
"graphomaton-#{Digest::SHA256.hexdigest(payload)[0, 12]}"
|
|
1080
|
+
rescue TypeError
|
|
1081
|
+
"graphomaton-#{Digest::SHA256.hexdigest(components.inspect)[0, 12]}"
|
|
1082
|
+
end
|
|
239
1083
|
|
|
240
|
-
|
|
1084
|
+
def add_background(svg, width, height)
|
|
1085
|
+
return unless @theme[:background]
|
|
241
1086
|
|
|
242
|
-
svg.add_element('
|
|
243
|
-
'class' => '
|
|
244
|
-
'
|
|
1087
|
+
svg.add_element('rect', {
|
|
1088
|
+
'class' => 'diagram-background',
|
|
1089
|
+
'x' => '0',
|
|
1090
|
+
'y' => '0',
|
|
1091
|
+
'width' => width.to_s,
|
|
1092
|
+
'height' => height.to_s
|
|
245
1093
|
})
|
|
1094
|
+
end
|
|
246
1095
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
1096
|
+
def add_transitions(svg)
|
|
1097
|
+
processed_pairs = {}
|
|
1098
|
+
self_loop_indices = Hash.new(0)
|
|
1099
|
+
@bundle_points = transition_bundle_points
|
|
1100
|
+
|
|
1101
|
+
transition_groups.each do |group|
|
|
1102
|
+
first = group.first
|
|
1103
|
+
from_state = state_position(first[:from])
|
|
1104
|
+
to_state = state_position(first[:to])
|
|
1105
|
+
next if from_state.nil? || to_state.nil?
|
|
250
1106
|
|
|
251
|
-
|
|
1107
|
+
if @merge_parallel_transitions || group.size == 1
|
|
1108
|
+
transition = first.to_h
|
|
1109
|
+
transition[:label] = merged_label(group)
|
|
1110
|
+
|
|
1111
|
+
if from_state == to_state
|
|
1112
|
+
loop_index = self_loop_indices[first[:from]]
|
|
1113
|
+
self_loop_indices[first[:from]] += 1
|
|
1114
|
+
add_self_loop(svg, from_state, transition, loop_index)
|
|
1115
|
+
else
|
|
1116
|
+
add_transition(svg, from_state, to_state, transition, processed_pairs)
|
|
1117
|
+
end
|
|
1118
|
+
else
|
|
1119
|
+
group.each do |transition|
|
|
1120
|
+
add_self_loop(svg, from_state, transition, self_loop_indices[first[:from]])
|
|
1121
|
+
self_loop_indices[first[:from]] += 1
|
|
1122
|
+
end
|
|
1123
|
+
end
|
|
1124
|
+
end
|
|
252
1125
|
end
|
|
253
1126
|
|
|
254
|
-
def
|
|
255
|
-
|
|
256
|
-
svg.add_element('rect', {
|
|
257
|
-
'class' => 'label-bg',
|
|
258
|
-
'x' => (x - (text_width / 2)).to_s,
|
|
259
|
-
'y' => (y - 10).to_s,
|
|
260
|
-
'width' => text_width.to_s,
|
|
261
|
-
'height' => '20',
|
|
262
|
-
'rx' => '3'
|
|
263
|
-
})
|
|
1127
|
+
def transition_groups
|
|
1128
|
+
return @automaton.transition_records.map { |transition| [transition] } unless @merge_parallel_transitions
|
|
264
1129
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
label.text = text
|
|
1130
|
+
grouped = {}
|
|
1131
|
+
@automaton.transition_records.each do |transition|
|
|
1132
|
+
grouped[transition_key(transition)] ||= []
|
|
1133
|
+
grouped[transition_key(transition)] << transition
|
|
1134
|
+
end
|
|
1135
|
+
grouped.values
|
|
272
1136
|
end
|
|
273
1137
|
|
|
274
|
-
def
|
|
275
|
-
|
|
276
|
-
|
|
1138
|
+
def transition_key(transition)
|
|
1139
|
+
[
|
|
1140
|
+
transition[:from],
|
|
1141
|
+
transition[:to],
|
|
1142
|
+
transition[:style],
|
|
1143
|
+
transition[:line_style],
|
|
1144
|
+
transition[:metadata],
|
|
1145
|
+
highlighted_transition?(transition)
|
|
1146
|
+
]
|
|
1147
|
+
end
|
|
277
1148
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
'x1' => (init[:x] - 60).to_s,
|
|
281
|
-
'y1' => init[:y].to_s,
|
|
282
|
-
'x2' => (init[:x] - 30).to_s,
|
|
283
|
-
'y2' => init[:y].to_s
|
|
284
|
-
})
|
|
1149
|
+
def merged_label(group)
|
|
1150
|
+
return group.first[:label] if group.size == 1
|
|
285
1151
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
'text-anchor' => 'end'
|
|
291
|
-
})
|
|
292
|
-
start_label.text = 'start'
|
|
1152
|
+
labels = group.map { |transition| transition[:label].to_s }
|
|
1153
|
+
labels = labels.uniq
|
|
1154
|
+
labels = labels.sort if @sort_labels
|
|
1155
|
+
labels.join(', ')
|
|
293
1156
|
end
|
|
294
1157
|
|
|
295
|
-
def
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
1158
|
+
def add_self_loop(svg, state, trans, loop_index = 0)
|
|
1159
|
+
transition_node = svg.add_element('g', transition_group_attributes(trans))
|
|
1160
|
+
add_transition_tooltip(transition_node, trans)
|
|
1161
|
+
transition_content = transition_link_container(transition_node, trans)
|
|
1162
|
+
cx = state[:x]
|
|
1163
|
+
cy = state[:y]
|
|
1164
|
+
orientation, layer = self_loop_placement(loop_index, state)
|
|
1165
|
+
loop_specs = self_loop_specs(
|
|
1166
|
+
orientation,
|
|
1167
|
+
layer: layer,
|
|
1168
|
+
loop_index: loop_index
|
|
1169
|
+
)
|
|
299
1170
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
1171
|
+
loop_height = loop_specs[:loop_height]
|
|
1172
|
+
loop_width = loop_specs[:loop_width]
|
|
1173
|
+
loop_offset = loop_specs[:loop_offset]
|
|
1174
|
+
start_angle = loop_specs[:start_angle] * Math::PI / 180
|
|
1175
|
+
end_angle = loop_specs[:end_angle] * Math::PI / 180
|
|
1176
|
+
radius = @state_radius
|
|
306
1177
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
'cy' => state[:y].to_s,
|
|
312
|
-
'r' => STATE_INNER_RADIUS.to_s
|
|
313
|
-
})
|
|
314
|
-
end
|
|
1178
|
+
start_x = cx + (radius * Math.cos(start_angle))
|
|
1179
|
+
start_y = cy + (radius * Math.sin(start_angle))
|
|
1180
|
+
end_x = cx + (radius * Math.cos(end_angle))
|
|
1181
|
+
end_y = cy + (radius * Math.sin(end_angle))
|
|
315
1182
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
1183
|
+
control1_x = cx + loop_specs[:control1][:x]
|
|
1184
|
+
control1_y = cy + loop_specs[:control1][:y]
|
|
1185
|
+
control2_x = cx + loop_specs[:control2][:x]
|
|
1186
|
+
control2_y = cy + loop_specs[:control2][:y]
|
|
1187
|
+
|
|
1188
|
+
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
1189
|
+
|
|
1190
|
+
transition_content.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1191
|
+
|
|
1192
|
+
label_y_shift = loop_offset * (loop_index.odd? ? -1 : 1)
|
|
1193
|
+
add_label(
|
|
1194
|
+
transition_content,
|
|
1195
|
+
cx + loop_specs[:label_offset][:x],
|
|
1196
|
+
cy + loop_specs[:label_offset][:y] + label_y_shift,
|
|
1197
|
+
trans[:label]
|
|
1198
|
+
)
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def self_loop_placement(loop_index, state)
|
|
1202
|
+
return [@loop_position, loop_index] unless @loop_position == :auto
|
|
1203
|
+
|
|
1204
|
+
orientations = %i[top right bottom left].sort_by do |orientation|
|
|
1205
|
+
self_loop_placement_cost(orientation, state)
|
|
1206
|
+
end
|
|
1207
|
+
[orientations[loop_index % orientations.size], loop_index / orientations.size]
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def self_loop_placement_cost(orientation, state)
|
|
1211
|
+
specs = self_loop_specs(orientation, layer: 0, loop_index: 0)
|
|
1212
|
+
points = [specs[:control1], specs[:control2], specs[:label_offset]].map do |offset|
|
|
1213
|
+
[state[:x].to_f + offset[:x], state[:y].to_f + offset[:y]]
|
|
1214
|
+
end
|
|
1215
|
+
outside_cost = points.sum do |x, y|
|
|
1216
|
+
[0.0 - x, x - @canvas_width, 0.0 - y, y - @canvas_height, 0.0].max
|
|
1217
|
+
end
|
|
1218
|
+
obstacle_cost = @positions.values.sum do |position|
|
|
1219
|
+
next 0.0 if position[:x] == state[:x] && position[:y] == state[:y]
|
|
1220
|
+
|
|
1221
|
+
minimum = points.map { |x, y| Math.hypot(x - position[:x].to_f, y - position[:y].to_f) }.min
|
|
1222
|
+
[(@state_radius * 2.0) - minimum, 0.0].max
|
|
1223
|
+
end
|
|
1224
|
+
outside_cost * 10 + obstacle_cost
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
def self_loop_specs(orientation, layer:, loop_index:)
|
|
1228
|
+
angle_shift = loop_index.even? ? 0 : 4
|
|
1229
|
+
loop_height = (@state_radius * 2.0) + (layer * 20)
|
|
1230
|
+
loop_width = @state_radius + 5 + (layer * 10)
|
|
1231
|
+
loop_offset = layer * 8
|
|
1232
|
+
|
|
1233
|
+
case orientation
|
|
1234
|
+
when :top
|
|
1235
|
+
{
|
|
1236
|
+
loop_height: loop_height,
|
|
1237
|
+
loop_width: loop_width,
|
|
1238
|
+
loop_offset: loop_offset,
|
|
1239
|
+
start_angle: -145 + angle_shift,
|
|
1240
|
+
end_angle: -35 - angle_shift,
|
|
1241
|
+
control1: { x: -loop_width - loop_offset, y: -loop_height - loop_offset },
|
|
1242
|
+
control2: { x: loop_width + loop_offset, y: -loop_height - loop_offset },
|
|
1243
|
+
label_offset: { x: 0.0, y: -(loop_height + 8) }
|
|
1244
|
+
}
|
|
1245
|
+
when :right
|
|
1246
|
+
{
|
|
1247
|
+
loop_height: loop_height,
|
|
1248
|
+
loop_width: loop_width,
|
|
1249
|
+
loop_offset: loop_offset,
|
|
1250
|
+
start_angle: -35 + angle_shift,
|
|
1251
|
+
end_angle: 55 - angle_shift,
|
|
1252
|
+
control1: { x: loop_height + loop_offset, y: -loop_width - loop_offset },
|
|
1253
|
+
control2: { x: loop_height + loop_offset, y: loop_width + loop_offset },
|
|
1254
|
+
label_offset: { x: loop_height + 10, y: 0.0 }
|
|
1255
|
+
}
|
|
1256
|
+
when :bottom
|
|
1257
|
+
{
|
|
1258
|
+
loop_height: loop_height,
|
|
1259
|
+
loop_width: loop_width,
|
|
1260
|
+
loop_offset: loop_offset,
|
|
1261
|
+
start_angle: 35 + angle_shift,
|
|
1262
|
+
end_angle: 145 - angle_shift,
|
|
1263
|
+
control1: { x: -loop_width - loop_offset, y: loop_height + loop_offset },
|
|
1264
|
+
control2: { x: loop_width + loop_offset, y: loop_height + loop_offset },
|
|
1265
|
+
label_offset: { x: 0.0, y: loop_height + 8 }
|
|
1266
|
+
}
|
|
1267
|
+
else
|
|
1268
|
+
{
|
|
1269
|
+
loop_height: loop_height,
|
|
1270
|
+
loop_width: loop_width,
|
|
1271
|
+
loop_offset: loop_offset,
|
|
1272
|
+
start_angle: 125 + angle_shift,
|
|
1273
|
+
end_angle: 235 - angle_shift,
|
|
1274
|
+
control1: { x: -(loop_height + loop_offset), y: -loop_width - loop_offset },
|
|
1275
|
+
control2: { x: -(loop_height + loop_offset), y: loop_width + loop_offset },
|
|
1276
|
+
label_offset: { x: -(loop_height + 10), y: 0.0 }
|
|
1277
|
+
}
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
|
|
1281
|
+
def transition_label_lines(label)
|
|
1282
|
+
wrapped_lines(label, @wrap_labels ? @max_transition_label_width : 0)
|
|
1283
|
+
end
|
|
1284
|
+
|
|
1285
|
+
def state_label_lines(name)
|
|
1286
|
+
wrapped_lines(name, @state_wrap ? @max_state_label_width : 0)
|
|
1287
|
+
end
|
|
1288
|
+
|
|
1289
|
+
def wrapped_lines(value, max_width)
|
|
1290
|
+
text = value.to_s
|
|
1291
|
+
paragraphs = text.split(/\r\n?|\n/, -1)
|
|
1292
|
+
return paragraphs if max_width.nil? || max_width <= 0
|
|
1293
|
+
|
|
1294
|
+
max_width = max_width.to_f
|
|
1295
|
+
return paragraphs if max_width <= 0
|
|
1296
|
+
|
|
1297
|
+
paragraphs.flat_map { |paragraph| wrap_paragraph(paragraph, max_width) }
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def wrap_paragraph(text, max_width)
|
|
1301
|
+
lines = []
|
|
1302
|
+
current = +''
|
|
1303
|
+
words = text.split(/\s+/)
|
|
1304
|
+
words.each do |word|
|
|
1305
|
+
if text_exceeds_width?(word, max_width)
|
|
1306
|
+
lines << current unless current.empty?
|
|
1307
|
+
split_words = split_long_word(word, max_width)
|
|
1308
|
+
split_words.each do |split_word|
|
|
1309
|
+
candidate = current.empty? ? split_word : "#{current} #{split_word}"
|
|
1310
|
+
if text_exceeds_width?(candidate, max_width) && !current.empty?
|
|
1311
|
+
lines << current
|
|
1312
|
+
current = split_word
|
|
1313
|
+
else
|
|
1314
|
+
current = candidate
|
|
1315
|
+
end
|
|
1316
|
+
end
|
|
1317
|
+
lines << current unless current.empty?
|
|
1318
|
+
current = +''
|
|
1319
|
+
next
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
1323
|
+
if text_exceeds_width?(candidate, max_width) && !current.empty?
|
|
1324
|
+
lines << current
|
|
1325
|
+
current = word
|
|
1326
|
+
else
|
|
1327
|
+
current = candidate
|
|
1328
|
+
end
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
lines << current unless current.empty?
|
|
1332
|
+
lines = [''] if lines.empty?
|
|
1333
|
+
lines
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def split_long_word(word, max_width)
|
|
1337
|
+
return [word] if word.empty?
|
|
1338
|
+
|
|
1339
|
+
chunks = []
|
|
1340
|
+
current = +''
|
|
1341
|
+
word.scan(/\X/).each do |grapheme|
|
|
1342
|
+
candidate = current.empty? ? grapheme : "#{current}#{grapheme}"
|
|
1343
|
+
if text_exceeds_width?(candidate, max_width) && !current.empty?
|
|
1344
|
+
chunks << current
|
|
1345
|
+
current = grapheme
|
|
1346
|
+
else
|
|
1347
|
+
current = candidate
|
|
1348
|
+
end
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
chunks << current unless current.empty?
|
|
1352
|
+
chunks
|
|
1353
|
+
end
|
|
1354
|
+
|
|
1355
|
+
def text_exceeds_width?(text, max_width)
|
|
1356
|
+
available_width = [max_width.to_f - (@label_padding * 2), 1.0].max
|
|
1357
|
+
measure_text_width(text) > available_width
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
def transition_label_box_lines_width(lines)
|
|
1361
|
+
lines.map { |line| calculate_text_width(line) }.max || 60
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
def transition_label_box_height(lines)
|
|
1365
|
+
[16 * [lines.size, 1].max, 20].max
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
def collision_free_label_box(base_box, angle: nil)
|
|
1369
|
+
box = base_box.dup
|
|
1370
|
+
attempts = 0
|
|
1371
|
+
max_attempts = 80
|
|
1372
|
+
collision_box = rotated_label_collision_box(box, angle)
|
|
1373
|
+
while (label_box_overlap?(collision_box) || label_box_overlaps_state?(collision_box)) && attempts < max_attempts
|
|
1374
|
+
offset_x, offset_y = label_box_offset(attempts)
|
|
1375
|
+
box = {
|
|
1376
|
+
x: base_box[:x] + offset_x,
|
|
1377
|
+
y: base_box[:y] + offset_y,
|
|
1378
|
+
width: base_box[:width],
|
|
1379
|
+
height: base_box[:height]
|
|
1380
|
+
}
|
|
1381
|
+
collision_box = rotated_label_collision_box(box, angle)
|
|
1382
|
+
attempts += 1
|
|
1383
|
+
end
|
|
1384
|
+
if label_box_overlap?(collision_box) || label_box_overlaps_state?(collision_box)
|
|
1385
|
+
@diagnostics << layout_diagnostic('label-overlap-unresolved', 'Transition label overlap could not be resolved')
|
|
1386
|
+
end
|
|
1387
|
+
if label_box_outside_canvas?(collision_box)
|
|
1388
|
+
@diagnostics << layout_diagnostic('label-outside-canvas', 'Transition label extends outside the SVG canvas')
|
|
1389
|
+
end
|
|
1390
|
+
box
|
|
1391
|
+
end
|
|
1392
|
+
|
|
1393
|
+
def label_box_outside_canvas?(box)
|
|
1394
|
+
return false unless @canvas_width&.finite? && @canvas_height&.finite?
|
|
1395
|
+
|
|
1396
|
+
box[:x] < 0 || box[:y] < 0 ||
|
|
1397
|
+
box[:x] + box[:width] > @canvas_width ||
|
|
1398
|
+
box[:y] + box[:height] > @canvas_height
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
def layout_diagnostic(code, message)
|
|
1402
|
+
Graphomaton::Diagnostic.new(code: code, severity: :warning, path: ['layout'], message: message, hint: nil)
|
|
1403
|
+
end
|
|
1404
|
+
|
|
1405
|
+
def rotated_label_collision_box(box, angle)
|
|
1406
|
+
transform = label_rotation_transform(box, angle)
|
|
1407
|
+
return box unless transform
|
|
1408
|
+
|
|
1409
|
+
bounds = coordinate_bounds(
|
|
1410
|
+
[
|
|
1411
|
+
[box[:x], box[:y]],
|
|
1412
|
+
[box[:x] + box[:width], box[:y]],
|
|
1413
|
+
[box[:x] + box[:width], box[:y] + box[:height]],
|
|
1414
|
+
[box[:x], box[:y] + box[:height]]
|
|
1415
|
+
],
|
|
1416
|
+
transform
|
|
1417
|
+
)
|
|
1418
|
+
{
|
|
1419
|
+
x: bounds[:min_x],
|
|
1420
|
+
y: bounds[:min_y],
|
|
1421
|
+
width: bounds[:max_x] - bounds[:min_x],
|
|
1422
|
+
height: bounds[:max_y] - bounds[:min_y]
|
|
1423
|
+
}
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
def label_box_offset(attempt)
|
|
1427
|
+
return [0, 0] if attempt.zero?
|
|
1428
|
+
|
|
1429
|
+
step = [@state_radius * 0.3, 12].max
|
|
1430
|
+
directions = [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]]
|
|
1431
|
+
ring = attempt / directions.size
|
|
1432
|
+
index = attempt % directions.size
|
|
1433
|
+
direction = directions[index]
|
|
1434
|
+
|
|
1435
|
+
multiplier = [1, ring + 1].max
|
|
1436
|
+
[direction[0] * step * multiplier, direction[1] * step * multiplier]
|
|
1437
|
+
end
|
|
1438
|
+
|
|
1439
|
+
def label_box_overlap?(box)
|
|
1440
|
+
candidates = @label_spatial_index ? @label_spatial_index.query(box) : @label_boxes
|
|
1441
|
+
candidates.any? do |existing|
|
|
1442
|
+
!(box[:x] + box[:width] < existing[:x] ||
|
|
1443
|
+
box[:x] > existing[:x] + existing[:width] ||
|
|
1444
|
+
box[:y] + box[:height] < existing[:y] ||
|
|
1445
|
+
box[:y] > existing[:y] + existing[:height])
|
|
1446
|
+
end
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
def label_box_overlaps_state?(box)
|
|
1450
|
+
candidates = @state_spatial_index ? @state_spatial_index.query(box) : @positions.each_value
|
|
1451
|
+
candidates.each do |state|
|
|
1452
|
+
next if state[:x].nil? || state[:y].nil?
|
|
1453
|
+
|
|
1454
|
+
closest_x = if state[:x] < box[:x]
|
|
1455
|
+
box[:x]
|
|
1456
|
+
elsif state[:x] > (box[:x] + box[:width])
|
|
1457
|
+
box[:x] + box[:width]
|
|
1458
|
+
else
|
|
1459
|
+
state[:x]
|
|
1460
|
+
end
|
|
1461
|
+
|
|
1462
|
+
closest_y = if state[:y] < box[:y]
|
|
1463
|
+
box[:y]
|
|
1464
|
+
elsif state[:y] > (box[:y] + box[:height])
|
|
1465
|
+
box[:y] + box[:height]
|
|
1466
|
+
else
|
|
1467
|
+
state[:y]
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
dx = state[:x] - closest_x
|
|
1471
|
+
dy = state[:y] - closest_y
|
|
1472
|
+
clearance_radius = @state_radius + 10.0
|
|
1473
|
+
return true if (dx * dx + dy * dy) < (clearance_radius * clearance_radius)
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
false
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
def add_transition(svg, from_state, to_state, trans, processed_pairs)
|
|
1480
|
+
transition_node = svg.add_element('g', transition_group_attributes(trans))
|
|
1481
|
+
add_transition_tooltip(transition_node, trans)
|
|
1482
|
+
transition_content = transition_link_container(transition_node, trans)
|
|
1483
|
+
x1 = from_state[:x]
|
|
1484
|
+
y1 = from_state[:y]
|
|
1485
|
+
x2 = to_state[:x]
|
|
1486
|
+
y2 = to_state[:y]
|
|
1487
|
+
|
|
1488
|
+
pair_key = undirected_transition_pair_key(trans[:from], trans[:to])
|
|
1489
|
+
processed_pairs[pair_key] = 0 unless processed_pairs[pair_key]
|
|
1490
|
+
|
|
1491
|
+
pair_index = processed_pairs[pair_key]
|
|
1492
|
+
processed_pairs[pair_key] += 1
|
|
1493
|
+
|
|
1494
|
+
parallel_count = @automaton.count_parallel_transitions(trans[:from], trans[:to])
|
|
1495
|
+
|
|
1496
|
+
dx = x2 - x1
|
|
1497
|
+
dy = y2 - y1
|
|
1498
|
+
dist = Math.sqrt((dx**2) + (dy**2))
|
|
1499
|
+
if dist <= 0
|
|
1500
|
+
add_overlapping_state_transition(transition_content, x1, y1, trans)
|
|
1501
|
+
return
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
start_x, start_y = state_connection_point(trans[:from], from_state, to_state)
|
|
1505
|
+
end_x, end_y = state_connection_point(trans[:to], to_state, from_state)
|
|
1506
|
+
|
|
1507
|
+
blocking_states = edge_blocking_state_count(trans, start_x, start_y, end_x, end_y)
|
|
1508
|
+
|
|
1509
|
+
bundle = transition_bundle(trans)
|
|
1510
|
+
if bundle && @bundle_points[bundle]
|
|
1511
|
+
add_bundled_line(transition_content, start_x, start_y, end_x, end_y, trans, @bundle_points[bundle])
|
|
1512
|
+
elsif @edge_style == :straight
|
|
1513
|
+
add_straight_line(transition_content, start_x, start_y, end_x, end_y, trans)
|
|
1514
|
+
elsif @edge_style == :curved
|
|
1515
|
+
add_curved_line(
|
|
1516
|
+
transition_content,
|
|
1517
|
+
start_x,
|
|
1518
|
+
start_y,
|
|
1519
|
+
end_x,
|
|
1520
|
+
end_y,
|
|
1521
|
+
x1,
|
|
1522
|
+
y1,
|
|
1523
|
+
x2,
|
|
1524
|
+
y2,
|
|
1525
|
+
trans,
|
|
1526
|
+
parallel_count,
|
|
1527
|
+
pair_index,
|
|
1528
|
+
blocking_states
|
|
1529
|
+
)
|
|
1530
|
+
elsif @edge_style == :spline
|
|
1531
|
+
add_spline_line(transition_content, start_x, start_y, end_x, end_y, x1, y1, x2, y2, trans, pair_index)
|
|
1532
|
+
elsif @edge_style == :orthogonal
|
|
1533
|
+
add_orthogonal_line(transition_content, start_x, start_y, end_x, end_y, trans)
|
|
1534
|
+
elsif parallel_count > 1
|
|
1535
|
+
add_curved_line(
|
|
1536
|
+
transition_content,
|
|
1537
|
+
start_x,
|
|
1538
|
+
start_y,
|
|
1539
|
+
end_x,
|
|
1540
|
+
end_y,
|
|
1541
|
+
x1,
|
|
1542
|
+
y1,
|
|
1543
|
+
x2,
|
|
1544
|
+
y2,
|
|
1545
|
+
trans,
|
|
1546
|
+
parallel_count,
|
|
1547
|
+
pair_index,
|
|
1548
|
+
blocking_states
|
|
1549
|
+
)
|
|
1550
|
+
elsif forward_direction?(x1, y1, x2, y2) && blocking_states.zero?
|
|
1551
|
+
add_straight_line(transition_content, start_x, start_y, end_x, end_y, trans)
|
|
1552
|
+
else
|
|
1553
|
+
add_curved_line(
|
|
1554
|
+
transition_content,
|
|
1555
|
+
start_x,
|
|
1556
|
+
start_y,
|
|
1557
|
+
end_x,
|
|
1558
|
+
end_y,
|
|
1559
|
+
x1,
|
|
1560
|
+
y1,
|
|
1561
|
+
x2,
|
|
1562
|
+
y2,
|
|
1563
|
+
trans,
|
|
1564
|
+
parallel_count,
|
|
1565
|
+
pair_index,
|
|
1566
|
+
blocking_states
|
|
1567
|
+
)
|
|
1568
|
+
end
|
|
1569
|
+
end
|
|
1570
|
+
|
|
1571
|
+
def undirected_transition_pair_key(from, to)
|
|
1572
|
+
[from, to].sort_by { |endpoint| [endpoint.class.name, endpoint.to_s] }
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
def state_connection_point(name, center, target)
|
|
1576
|
+
delta_x = target[:x].to_f - center[:x].to_f
|
|
1577
|
+
delta_y = target[:y].to_f - center[:y].to_f
|
|
1578
|
+
return [center[:x].to_f, center[:y].to_f] if delta_x.zero? && delta_y.zero?
|
|
1579
|
+
|
|
1580
|
+
shape = state_shape(@automaton.state_records.fetch(name))
|
|
1581
|
+
scale = connection_scale(shape, delta_x, delta_y)
|
|
1582
|
+
[center[:x].to_f + (delta_x * scale), center[:y].to_f + (delta_y * scale)]
|
|
1583
|
+
end
|
|
1584
|
+
|
|
1585
|
+
def connection_scale(shape, delta_x, delta_y)
|
|
1586
|
+
radius = @state_radius.to_f
|
|
1587
|
+
case shape
|
|
1588
|
+
when :ellipse
|
|
1589
|
+
ellipse_connection_scale(delta_x, delta_y, radius * 1.25, radius * 0.8)
|
|
1590
|
+
when :diamond
|
|
1591
|
+
radius / (delta_x.abs + delta_y.abs)
|
|
1592
|
+
when :bar
|
|
1593
|
+
rectangle_connection_scale(delta_x, delta_y, radius * 0.7, [radius * 0.1, 4.0].max)
|
|
1594
|
+
when :rounded_rect
|
|
1595
|
+
rectangle_connection_scale(delta_x, delta_y, radius, radius)
|
|
1596
|
+
else
|
|
1597
|
+
radius / Math.sqrt((delta_x * delta_x) + (delta_y * delta_y))
|
|
1598
|
+
end
|
|
1599
|
+
end
|
|
1600
|
+
|
|
1601
|
+
def ellipse_connection_scale(delta_x, delta_y, radius_x, radius_y)
|
|
1602
|
+
1.0 / Math.sqrt(((delta_x / radius_x)**2) + ((delta_y / radius_y)**2))
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
def rectangle_connection_scale(delta_x, delta_y, half_width, half_height)
|
|
1606
|
+
x_scale = delta_x.zero? ? Float::INFINITY : half_width / delta_x.abs
|
|
1607
|
+
y_scale = delta_y.zero? ? Float::INFINITY : half_height / delta_y.abs
|
|
1608
|
+
[x_scale, y_scale].min
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
def transition_bundle_points
|
|
1612
|
+
bundles = Hash.new { |hash, key| hash[key] = [] }
|
|
1613
|
+
|
|
1614
|
+
@automaton.transition_records.each do |transition|
|
|
1615
|
+
bundle = transition_bundle(transition)
|
|
1616
|
+
next unless bundle
|
|
1617
|
+
|
|
1618
|
+
from_state = state_position(transition[:from])
|
|
1619
|
+
to_state = state_position(transition[:to])
|
|
1620
|
+
next unless from_state && to_state
|
|
1621
|
+
next if from_state == to_state
|
|
1622
|
+
|
|
1623
|
+
bundles[bundle] << {
|
|
1624
|
+
x: (from_state[:x].to_f + to_state[:x].to_f) / 2.0,
|
|
1625
|
+
y: (from_state[:y].to_f + to_state[:y].to_f) / 2.0
|
|
1626
|
+
}
|
|
1627
|
+
end
|
|
1628
|
+
|
|
1629
|
+
bundles.transform_values do |points|
|
|
1630
|
+
average = {
|
|
1631
|
+
x: points.sum { |point| point[:x] } / points.size,
|
|
1632
|
+
y: points.sum { |point| point[:y] } / points.size
|
|
1633
|
+
}
|
|
1634
|
+
safe_bundle_point(average)
|
|
1635
|
+
end
|
|
1636
|
+
end
|
|
1637
|
+
|
|
1638
|
+
def safe_bundle_point(average)
|
|
1639
|
+
clearance = @state_radius * 1.75
|
|
1640
|
+
candidates = [
|
|
1641
|
+
average,
|
|
1642
|
+
{ x: average[:x], y: average[:y] - clearance },
|
|
1643
|
+
{ x: average[:x], y: average[:y] + clearance },
|
|
1644
|
+
{ x: average[:x] - clearance, y: average[:y] },
|
|
1645
|
+
{ x: average[:x] + clearance, y: average[:y] }
|
|
1646
|
+
]
|
|
1647
|
+
candidates.min_by do |candidate|
|
|
1648
|
+
obstacle_count = @positions.values.count do |position|
|
|
1649
|
+
Math.hypot(candidate[:x] - position[:x].to_f, candidate[:y] - position[:y].to_f) < (@state_radius + 12)
|
|
1650
|
+
end
|
|
1651
|
+
[obstacle_count, Math.hypot(candidate[:x] - average[:x], candidate[:y] - average[:y])]
|
|
1652
|
+
end
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
def add_straight_line(svg, start_x, start_y, end_x, end_y, trans)
|
|
1656
|
+
svg.add_element(
|
|
1657
|
+
'line',
|
|
1658
|
+
transition_line_attributes(
|
|
1659
|
+
trans,
|
|
1660
|
+
'x1' => start_x.to_s,
|
|
1661
|
+
'y1' => start_y.to_s,
|
|
1662
|
+
'x2' => end_x.to_s,
|
|
1663
|
+
'y2' => end_y.to_s
|
|
1664
|
+
)
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
label_x = (start_x + end_x) / 2
|
|
1668
|
+
label_y = ((start_y + end_y) / 2) - 10
|
|
1669
|
+
|
|
1670
|
+
add_label(svg, label_x, label_y, trans[:label], angle: label_rotation_angle(start_x, start_y, end_x, end_y))
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
def add_bundled_line(svg, start_x, start_y, end_x, end_y, trans, bundle_point)
|
|
1674
|
+
control_x = bundle_point[:x]
|
|
1675
|
+
control_y = bundle_point[:y]
|
|
1676
|
+
path_d = "M #{start_x} #{start_y} Q #{control_x} #{control_y}, #{end_x} #{end_y}"
|
|
1677
|
+
|
|
1678
|
+
svg.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1679
|
+
|
|
1680
|
+
t = 0.5
|
|
1681
|
+
label_x = ((1 - t) * (1 - t) * start_x) + (2 * (1 - t) * t * control_x) + (t * t * end_x)
|
|
1682
|
+
label_y = ((1 - t) * (1 - t) * start_y) + (2 * (1 - t) * t * control_y) + (t * t * end_y)
|
|
1683
|
+
|
|
1684
|
+
add_label(svg, label_x, label_y, trans[:label], angle: label_rotation_angle(start_x, start_y, end_x, end_y))
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
def add_curved_line(svg, start_x, start_y, end_x, end_y, x1, y1, x2, y2, trans, parallel_count, pair_index, blocking_states)
|
|
1688
|
+
mid_x = (start_x + end_x) / 2
|
|
1689
|
+
mid_y = (start_y + end_y) / 2
|
|
1690
|
+
|
|
1691
|
+
base_offset = if blocking_states.positive?
|
|
1692
|
+
((@state_radius + 8) * 2.0) + (blocking_states * 16)
|
|
1693
|
+
else
|
|
1694
|
+
@state_radius * 2
|
|
1695
|
+
end
|
|
1696
|
+
|
|
1697
|
+
curve_offset = if parallel_count > 1
|
|
1698
|
+
label_width = transition_label_box_lines_width(transition_label_lines(trans[:label]))
|
|
1699
|
+
lane_step = [label_width + 12, 60].max * 2
|
|
1700
|
+
lane = pair_index / 2
|
|
1701
|
+
side = pair_index.even? ? -1 : 1
|
|
1702
|
+
side * (base_offset + (lane_step * lane))
|
|
1703
|
+
elsif forward_direction?(x1, y1, x2, y2)
|
|
1704
|
+
-base_offset
|
|
1705
|
+
else
|
|
1706
|
+
base_offset
|
|
1707
|
+
end
|
|
1708
|
+
|
|
1709
|
+
if vertical_direction?
|
|
1710
|
+
control_x = mid_x + curve_offset
|
|
1711
|
+
control_y = if (y2 - y1).abs < 10
|
|
1712
|
+
mid_y + (50 * (pair_index.even? ? 1 : -1))
|
|
1713
|
+
else
|
|
1714
|
+
mid_y
|
|
1715
|
+
end
|
|
1716
|
+
else
|
|
1717
|
+
control_x = if (x2 - x1).abs < 10
|
|
1718
|
+
mid_x + (50 * (pair_index.even? ? 1 : -1))
|
|
1719
|
+
else
|
|
1720
|
+
mid_x
|
|
1721
|
+
end
|
|
1722
|
+
control_y = mid_y + curve_offset
|
|
1723
|
+
end
|
|
1724
|
+
|
|
1725
|
+
control_x, control_y = clear_quadratic_control(
|
|
1726
|
+
trans, start_x, start_y, end_x, end_y, control_x, control_y, mid_x, mid_y
|
|
1727
|
+
)
|
|
1728
|
+
path_d = "M #{start_x} #{start_y} Q #{control_x} #{control_y}, #{end_x} #{end_y}"
|
|
1729
|
+
|
|
1730
|
+
svg.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1731
|
+
|
|
1732
|
+
t = 0.5
|
|
1733
|
+
label_x = ((1 - t) * (1 - t) * start_x) + (2 * (1 - t) * t * control_x) + (t * t * end_x)
|
|
1734
|
+
label_y = ((1 - t) * (1 - t) * start_y) + (2 * (1 - t) * t * control_y) + (t * t * end_y)
|
|
1735
|
+
|
|
1736
|
+
add_label(svg, label_x, label_y, trans[:label], angle: label_rotation_angle(start_x, start_y, end_x, end_y))
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
def add_spline_line(svg, start_x, start_y, end_x, end_y, x1, y1, x2, y2, trans, pair_index)
|
|
1740
|
+
dx = end_x - start_x
|
|
1741
|
+
dy = end_y - start_y
|
|
1742
|
+
distance = Math.sqrt((dx**2) + (dy**2))
|
|
1743
|
+
return add_straight_line(svg, start_x, start_y, end_x, end_y, trans) if distance <= 0
|
|
1744
|
+
|
|
1745
|
+
normal_x = -dy / distance
|
|
1746
|
+
normal_y = dx / distance
|
|
1747
|
+
blocking_states = edge_blocking_state_count(trans, start_x, start_y, end_x, end_y)
|
|
1748
|
+
bend = @state_radius + (pair_index * 24) + (blocking_states * (@state_radius + 24))
|
|
1749
|
+
bend *= forward_direction?(x1, y1, x2, y2) ? -1 : 1
|
|
1750
|
+
control1_x = start_x + (dx * 0.35) + (normal_x * bend)
|
|
1751
|
+
control1_y = start_y + (dy * 0.35) + (normal_y * bend)
|
|
1752
|
+
control2_x = start_x + (dx * 0.65) + (normal_x * bend)
|
|
1753
|
+
control2_y = start_y + (dy * 0.65) + (normal_y * bend)
|
|
1754
|
+
control1_x, control1_y, control2_x, control2_y = clear_cubic_controls(
|
|
1755
|
+
trans,
|
|
1756
|
+
start_x,
|
|
1757
|
+
start_y,
|
|
1758
|
+
end_x,
|
|
1759
|
+
end_y,
|
|
1760
|
+
control1_x,
|
|
1761
|
+
control1_y,
|
|
1762
|
+
control2_x,
|
|
1763
|
+
control2_y,
|
|
1764
|
+
normal_x,
|
|
1765
|
+
normal_y
|
|
1766
|
+
)
|
|
1767
|
+
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
1768
|
+
|
|
1769
|
+
svg.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1770
|
+
|
|
1771
|
+
t = 0.5
|
|
1772
|
+
label_x = cubic_bezier_point(start_x, control1_x, control2_x, end_x, t)
|
|
1773
|
+
label_y = cubic_bezier_point(start_y, control1_y, control2_y, end_y, t)
|
|
1774
|
+
add_label(svg, label_x, label_y, trans[:label], angle: label_rotation_angle(control1_x, control1_y, control2_x, control2_y))
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
def clear_quadratic_control(transition, start_x, start_y, end_x, end_y, control_x, control_y, mid_x, mid_y)
|
|
1778
|
+
return [control_x, control_y] if quadratic_curve_clear?(transition, start_x, start_y, control_x, control_y, end_x, end_y)
|
|
1779
|
+
|
|
1780
|
+
offset_x = control_x - mid_x
|
|
1781
|
+
offset_y = control_y - mid_y
|
|
1782
|
+
1.upto(8) do |attempt|
|
|
1783
|
+
scale = 1.0 + (attempt * 0.5)
|
|
1784
|
+
[-1, 1].each do |side|
|
|
1785
|
+
candidate_x = mid_x + (offset_x * scale * side)
|
|
1786
|
+
candidate_y = mid_y + (offset_y * scale * side)
|
|
1787
|
+
if quadratic_curve_clear?(transition, start_x, start_y, candidate_x, candidate_y, end_x, end_y)
|
|
1788
|
+
return [candidate_x, candidate_y]
|
|
1789
|
+
end
|
|
1790
|
+
end
|
|
1791
|
+
end
|
|
1792
|
+
[control_x, control_y]
|
|
1793
|
+
end
|
|
1794
|
+
|
|
1795
|
+
def clear_cubic_controls(transition, start_x, start_y, end_x, end_y, control1_x, control1_y, control2_x, control2_y, normal_x, normal_y)
|
|
1796
|
+
return [control1_x, control1_y, control2_x, control2_y] if cubic_curve_clear?(transition, start_x, start_y, control1_x, control1_y, control2_x, control2_y, end_x, end_y)
|
|
1797
|
+
|
|
1798
|
+
1.upto(8) do |attempt|
|
|
1799
|
+
[-1, 1].each do |side|
|
|
1800
|
+
shift = (@state_radius + 16) * attempt * side
|
|
1801
|
+
candidate = [
|
|
1802
|
+
control1_x + (normal_x * shift),
|
|
1803
|
+
control1_y + (normal_y * shift),
|
|
1804
|
+
control2_x + (normal_x * shift),
|
|
1805
|
+
control2_y + (normal_y * shift)
|
|
1806
|
+
]
|
|
1807
|
+
return candidate if cubic_curve_clear?(transition, start_x, start_y, *candidate, end_x, end_y)
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
[control1_x, control1_y, control2_x, control2_y]
|
|
1811
|
+
end
|
|
1812
|
+
|
|
1813
|
+
def quadratic_curve_clear?(transition, start_x, start_y, control_x, control_y, end_x, end_y)
|
|
1814
|
+
sampled_curve_clear?(transition) do |t|
|
|
1815
|
+
inverse = 1 - t
|
|
1816
|
+
[
|
|
1817
|
+
(inverse * inverse * start_x) + (2 * inverse * t * control_x) + (t * t * end_x),
|
|
1818
|
+
(inverse * inverse * start_y) + (2 * inverse * t * control_y) + (t * t * end_y)
|
|
1819
|
+
]
|
|
1820
|
+
end
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
def cubic_curve_clear?(transition, start_x, start_y, control1_x, control1_y, control2_x, control2_y, end_x, end_y)
|
|
1824
|
+
sampled_curve_clear?(transition) do |t|
|
|
1825
|
+
[
|
|
1826
|
+
cubic_bezier_point(start_x, control1_x, control2_x, end_x, t),
|
|
1827
|
+
cubic_bezier_point(start_y, control1_y, control2_y, end_y, t)
|
|
1828
|
+
]
|
|
1829
|
+
end
|
|
1830
|
+
end
|
|
1831
|
+
|
|
1832
|
+
def sampled_curve_clear?(transition)
|
|
1833
|
+
obstacles = @positions.reject { |name, _| name == transition[:from] || name == transition[:to] }.values
|
|
1834
|
+
(1...32).all? do |sample|
|
|
1835
|
+
x, y = yield(sample / 32.0)
|
|
1836
|
+
obstacles.all? do |position|
|
|
1837
|
+
Math.hypot(x - position[:x].to_f, y - position[:y].to_f) >= (@state_radius + 8)
|
|
1838
|
+
end
|
|
1839
|
+
end
|
|
1840
|
+
end
|
|
1841
|
+
|
|
1842
|
+
def edge_blocking_state_count(transition, start_x, start_y, end_x, end_y)
|
|
1843
|
+
@positions.count do |name, position|
|
|
1844
|
+
next false if name == transition[:from] || name == transition[:to]
|
|
1845
|
+
next false unless position[:x] && position[:y]
|
|
1846
|
+
|
|
1847
|
+
distance_to_segment(
|
|
1848
|
+
position[:x].to_f,
|
|
1849
|
+
position[:y].to_f,
|
|
1850
|
+
start_x,
|
|
1851
|
+
start_y,
|
|
1852
|
+
end_x,
|
|
1853
|
+
end_y
|
|
1854
|
+
) < (@state_radius + 8)
|
|
1855
|
+
end
|
|
1856
|
+
end
|
|
1857
|
+
|
|
1858
|
+
def distance_to_segment(point_x, point_y, start_x, start_y, end_x, end_y)
|
|
1859
|
+
dx = end_x - start_x
|
|
1860
|
+
dy = end_y - start_y
|
|
1861
|
+
length_squared = (dx**2) + (dy**2)
|
|
1862
|
+
return Math.sqrt(((point_x - start_x)**2) + ((point_y - start_y)**2)) if length_squared <= 0
|
|
1863
|
+
|
|
1864
|
+
t = (((point_x - start_x) * dx) + ((point_y - start_y) * dy)) / length_squared
|
|
1865
|
+
t = [[t, 0.0].max, 1.0].min
|
|
1866
|
+
projection_x = start_x + (t * dx)
|
|
1867
|
+
projection_y = start_y + (t * dy)
|
|
1868
|
+
Math.sqrt(((point_x - projection_x)**2) + ((point_y - projection_y)**2))
|
|
1869
|
+
end
|
|
1870
|
+
|
|
1871
|
+
def cubic_bezier_point(start_value, control1_value, control2_value, end_value, t)
|
|
1872
|
+
inverse = 1 - t
|
|
1873
|
+
(inverse**3 * start_value) +
|
|
1874
|
+
(3 * inverse * inverse * t * control1_value) +
|
|
1875
|
+
(3 * inverse * t * t * control2_value) +
|
|
1876
|
+
(t**3 * end_value)
|
|
1877
|
+
end
|
|
1878
|
+
|
|
1879
|
+
def add_orthogonal_line(svg, start_x, start_y, end_x, end_y, trans)
|
|
1880
|
+
points = orthogonal_route(start_x, start_y, end_x, end_y, trans)
|
|
1881
|
+
path_d = points.each_with_index.map do |(x, y), index|
|
|
1882
|
+
"#{index.zero? ? 'M' : 'L'} #{x} #{y}"
|
|
1883
|
+
end.join(' ')
|
|
1884
|
+
middle_start = points[1]
|
|
1885
|
+
middle_end = points[2]
|
|
1886
|
+
label_x = (middle_start[0] + middle_end[0]) / 2.0
|
|
1887
|
+
label_y = ((middle_start[1] + middle_end[1]) / 2.0) - 8
|
|
1888
|
+
label_angle = label_rotation_angle(*middle_start, *middle_end)
|
|
1889
|
+
|
|
1890
|
+
svg.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1891
|
+
add_label(svg, label_x, label_y, trans[:label], angle: label_angle)
|
|
1892
|
+
end
|
|
1893
|
+
|
|
1894
|
+
def orthogonal_route(start_x, start_y, end_x, end_y, transition)
|
|
1895
|
+
clearance = @state_radius + 12.0
|
|
1896
|
+
x_candidates = [(start_x + end_x) / 2.0]
|
|
1897
|
+
y_candidates = [(start_y + end_y) / 2.0]
|
|
1898
|
+
@positions.each do |name, position|
|
|
1899
|
+
next if name == transition[:from] || name == transition[:to]
|
|
1900
|
+
|
|
1901
|
+
x_candidates.concat([position[:x].to_f - clearance, position[:x].to_f + clearance])
|
|
1902
|
+
y_candidates.concat([position[:y].to_f - clearance, position[:y].to_f + clearance])
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
routes = x_candidates.uniq.map do |middle_x|
|
|
1906
|
+
[[start_x, start_y], [middle_x, start_y], [middle_x, end_y], [end_x, end_y]]
|
|
1907
|
+
end
|
|
1908
|
+
routes.concat(y_candidates.uniq.map do |middle_y|
|
|
1909
|
+
[[start_x, start_y], [start_x, middle_y], [end_x, middle_y], [end_x, end_y]]
|
|
1910
|
+
end)
|
|
1911
|
+
clear_routes = routes.select { |route| orthogonal_route_clear?(route, transition) }
|
|
1912
|
+
(clear_routes.empty? ? routes : clear_routes).min_by { |route| orthogonal_route_length(route) }
|
|
1913
|
+
end
|
|
1914
|
+
|
|
1915
|
+
def orthogonal_route_clear?(route, transition)
|
|
1916
|
+
@positions.all? do |name, position|
|
|
1917
|
+
next true if name == transition[:from] || name == transition[:to]
|
|
1918
|
+
|
|
1919
|
+
route.each_cons(2).all? do |(from_x, from_y), (to_x, to_y)|
|
|
1920
|
+
distance_to_segment(position[:x].to_f, position[:y].to_f, from_x, from_y, to_x, to_y) >= (@state_radius + 8)
|
|
1921
|
+
end
|
|
1922
|
+
end
|
|
1923
|
+
end
|
|
1924
|
+
|
|
1925
|
+
def orthogonal_route_length(route)
|
|
1926
|
+
route.each_cons(2).sum do |(from_x, from_y), (to_x, to_y)|
|
|
1927
|
+
(to_x - from_x).abs + (to_y - from_y).abs
|
|
1928
|
+
end
|
|
1929
|
+
end
|
|
1930
|
+
|
|
1931
|
+
def add_overlapping_state_transition(svg, x, y, trans)
|
|
1932
|
+
radius = @state_radius
|
|
1933
|
+
loop_height = radius * 2.0
|
|
1934
|
+
loop_width = radius + 10
|
|
1935
|
+
start_x = x + radius
|
|
1936
|
+
start_y = y
|
|
1937
|
+
end_x = x
|
|
1938
|
+
end_y = y - radius
|
|
1939
|
+
control1_x = x + loop_width
|
|
1940
|
+
control1_y = y - loop_height
|
|
1941
|
+
control2_x = x + loop_height
|
|
1942
|
+
control2_y = y - loop_width
|
|
1943
|
+
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
1944
|
+
|
|
1945
|
+
svg.add_element('path', transition_line_attributes(trans, 'd' => path_d))
|
|
1946
|
+
add_label(svg, x + loop_width, y - loop_height, trans[:label])
|
|
1947
|
+
end
|
|
1948
|
+
|
|
1949
|
+
def add_label(svg, x, y, text, angle: nil)
|
|
1950
|
+
lines = transition_label_lines(text)
|
|
1951
|
+
text_width = transition_label_box_lines_width(lines)
|
|
1952
|
+
text_height = transition_label_box_height(lines)
|
|
1953
|
+
|
|
1954
|
+
base_box = {
|
|
1955
|
+
x: x - (text_width / 2),
|
|
1956
|
+
y: y - (text_height / 2),
|
|
1957
|
+
width: text_width,
|
|
1958
|
+
height: text_height
|
|
1959
|
+
}
|
|
1960
|
+
box = collision_free_label_box(base_box, angle: angle)
|
|
1961
|
+
collision_box = rotated_label_collision_box(box, angle)
|
|
1962
|
+
@label_boxes << collision_box
|
|
1963
|
+
@label_spatial_index&.insert(collision_box)
|
|
1964
|
+
transform = label_rotation_transform(box, angle)
|
|
1965
|
+
add_label_leader(svg, x, y, box) unless transform
|
|
1966
|
+
|
|
1967
|
+
if @label_background
|
|
1968
|
+
label_background_attributes = {
|
|
1969
|
+
'class' => 'label-bg',
|
|
1970
|
+
'x' => box[:x].to_s,
|
|
1971
|
+
'y' => box[:y].to_s,
|
|
1972
|
+
'width' => box[:width].to_s,
|
|
1973
|
+
'height' => box[:height].to_s,
|
|
1974
|
+
'rx' => @label_radius.to_s
|
|
1975
|
+
}
|
|
1976
|
+
label_background_attributes['transform'] = transform if transform
|
|
1977
|
+
svg.add_element('rect', label_background_attributes)
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
label_attributes = {
|
|
1981
|
+
'class' => 'transition-label',
|
|
1982
|
+
'x' => (box[:x] + (box[:width] / 2)).to_s,
|
|
1983
|
+
'y' => label_text_y(box, lines).to_s,
|
|
1984
|
+
'text-anchor' => 'middle'
|
|
1985
|
+
}
|
|
1986
|
+
label_attributes['transform'] = transform if transform
|
|
1987
|
+
label = svg.add_element('text', label_attributes)
|
|
1988
|
+
if lines.size == 1
|
|
1989
|
+
label.text = lines.first
|
|
1990
|
+
else
|
|
1991
|
+
lines.each_with_index do |line, index|
|
|
1992
|
+
tspan = label.add_element('tspan', { 'x' => (box[:x] + (box[:width] / 2)).to_s })
|
|
1993
|
+
tspan.text = line
|
|
1994
|
+
tspan.attributes['dy'] = index.zero? ? '0' : label_line_height.to_s
|
|
1995
|
+
end
|
|
1996
|
+
end
|
|
1997
|
+
end
|
|
1998
|
+
|
|
1999
|
+
def label_text_y(box, lines)
|
|
2000
|
+
line_height = label_line_height
|
|
2001
|
+
text_block_height = line_height * lines.size
|
|
2002
|
+
box[:y] + ((box[:height] - text_block_height) / 2.0) + (line_height * 0.72)
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
def label_line_height
|
|
2006
|
+
16.0
|
|
2007
|
+
end
|
|
2008
|
+
|
|
2009
|
+
def add_label_leader(svg, target_x, target_y, box)
|
|
2010
|
+
center_x = box[:x] + (box[:width] / 2.0)
|
|
2011
|
+
center_y = box[:y] + (box[:height] / 2.0)
|
|
2012
|
+
dx = center_x - target_x
|
|
2013
|
+
dy = center_y - target_y
|
|
2014
|
+
return if Math.sqrt((dx * dx) + (dy * dy)) < 18.0
|
|
2015
|
+
|
|
2016
|
+
svg.add_element('line', {
|
|
2017
|
+
'class' => 'label-leader',
|
|
2018
|
+
'x1' => center_x.to_s,
|
|
2019
|
+
'y1' => center_y.to_s,
|
|
2020
|
+
'x2' => target_x.to_s,
|
|
2021
|
+
'y2' => target_y.to_s
|
|
2022
|
+
})
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
def label_rotation_angle(start_x, start_y, end_x, end_y)
|
|
2026
|
+
angle = Math.atan2(end_y - start_y, end_x - start_x) * 180.0 / Math::PI
|
|
2027
|
+
angle += 180.0 if angle < -90.0
|
|
2028
|
+
angle -= 180.0 if angle > 90.0
|
|
2029
|
+
angle.round(2)
|
|
2030
|
+
end
|
|
2031
|
+
|
|
2032
|
+
def label_rotation_transform(box, angle)
|
|
2033
|
+
return nil unless @rotate_labels && angle
|
|
2034
|
+
|
|
2035
|
+
center_x = box[:x] + (box[:width] / 2.0)
|
|
2036
|
+
center_y = box[:y] + (box[:height] / 2.0)
|
|
2037
|
+
"rotate(#{angle} #{center_x} #{center_y})"
|
|
2038
|
+
end
|
|
2039
|
+
|
|
2040
|
+
def add_initial_arrow(svg)
|
|
2041
|
+
init = state_position(@automaton.initial_state)
|
|
2042
|
+
init ||= @automaton.state_records[@automaton.initial_state]
|
|
2043
|
+
return unless init
|
|
2044
|
+
|
|
2045
|
+
return unless init[:x] && init[:y]
|
|
2046
|
+
|
|
2047
|
+
initial_node = svg.add_element('g', {
|
|
2048
|
+
'class' => 'initial-transition',
|
|
2049
|
+
'id' => unique_svg_id("transition-start-#{svg_id_component(@automaton.initial_state)}"),
|
|
2050
|
+
'data-to' => @automaton.initial_state.to_s
|
|
2051
|
+
})
|
|
2052
|
+
|
|
2053
|
+
x1, y1, x2, y2, label_x, label_y, anchor = initial_arrow_points(init)
|
|
2054
|
+
initial_node.add_element('line', {
|
|
2055
|
+
'class' => 'initial-arrow',
|
|
2056
|
+
'x1' => x1.to_s,
|
|
2057
|
+
'y1' => y1.to_s,
|
|
2058
|
+
'x2' => x2.to_s,
|
|
2059
|
+
'y2' => y2.to_s
|
|
2060
|
+
})
|
|
2061
|
+
|
|
2062
|
+
return if @initial_arrow_label.nil?
|
|
2063
|
+
|
|
2064
|
+
start_label = initial_node.add_element('text', {
|
|
2065
|
+
'class' => 'transition-label',
|
|
2066
|
+
'x' => label_x.to_s,
|
|
2067
|
+
'y' => label_y.to_s,
|
|
2068
|
+
'text-anchor' => anchor
|
|
2069
|
+
})
|
|
2070
|
+
start_label.text = @initial_arrow_label
|
|
2071
|
+
end
|
|
2072
|
+
|
|
2073
|
+
def initial_arrow_points(state)
|
|
2074
|
+
x = state[:x].to_f
|
|
2075
|
+
y = state[:y].to_f
|
|
2076
|
+
gap = 8
|
|
2077
|
+
radius = @state_radius
|
|
2078
|
+
length = @initial_arrow_length
|
|
2079
|
+
|
|
2080
|
+
case @direction
|
|
2081
|
+
when :rl
|
|
2082
|
+
x2 = [x + radius + gap, @canvas_width].compact.min
|
|
2083
|
+
x1 = [x2 + length, @canvas_width].compact.min
|
|
2084
|
+
[x1, y, x2, y, x1 + 4, y - 10, 'start']
|
|
2085
|
+
when :tb
|
|
2086
|
+
y2 = [y - radius - gap, 0.0].max
|
|
2087
|
+
y1 = [y2 - length, 0.0].max
|
|
2088
|
+
[x, y1, x, y2, x + 8, y1 + 14, 'start']
|
|
2089
|
+
when :bt
|
|
2090
|
+
y2 = [y + radius + gap, @canvas_height].compact.min
|
|
2091
|
+
y1 = [y2 + length, @canvas_height].compact.min
|
|
2092
|
+
[x, y1, x, y2, x + 8, y1 - 4, 'start']
|
|
2093
|
+
else
|
|
2094
|
+
x2 = [x - radius - gap, 0.0].max
|
|
2095
|
+
x1 = [x2 - length, 0.0].max
|
|
2096
|
+
label_x = [x1 - 4, 0.0].max
|
|
2097
|
+
label_width = @initial_arrow_label ? calculate_text_width(@initial_arrow_label) : 0.0
|
|
2098
|
+
arrow_start_x = @initial_arrow_label ? [label_x - (label_width * 0.45), 0.0].max : x1
|
|
2099
|
+
[arrow_start_x, y, x2, y, label_x, y - 10, 'end']
|
|
2100
|
+
end
|
|
2101
|
+
end
|
|
2102
|
+
|
|
2103
|
+
def add_final_arrows(svg)
|
|
2104
|
+
@automaton.final_states.each do |state_name|
|
|
2105
|
+
state = state_position(state_name) || @automaton.state_records[state_name]
|
|
2106
|
+
next unless state && state[:x] && state[:y]
|
|
2107
|
+
|
|
2108
|
+
x1, y1, x2, y2, label_x, label_y, anchor = final_arrow_points(state)
|
|
2109
|
+
final_node = svg.add_element('g', {
|
|
2110
|
+
'class' => 'final-transition',
|
|
2111
|
+
'id' => unique_svg_id("transition-#{svg_id_component(state_name)}-final"),
|
|
2112
|
+
'data-from' => state_name.to_s
|
|
2113
|
+
})
|
|
2114
|
+
final_node.add_element('line', {
|
|
2115
|
+
'class' => 'final-arrow',
|
|
2116
|
+
'x1' => x1.to_s,
|
|
2117
|
+
'y1' => y1.to_s,
|
|
2118
|
+
'x2' => x2.to_s,
|
|
2119
|
+
'y2' => y2.to_s
|
|
2120
|
+
})
|
|
2121
|
+
next if @final_arrow_label.nil?
|
|
2122
|
+
|
|
2123
|
+
label = final_node.add_element('text', {
|
|
2124
|
+
'class' => 'transition-label',
|
|
2125
|
+
'x' => label_x.to_s,
|
|
2126
|
+
'y' => label_y.to_s,
|
|
2127
|
+
'text-anchor' => anchor
|
|
2128
|
+
})
|
|
2129
|
+
label.text = @final_arrow_label
|
|
2130
|
+
end
|
|
2131
|
+
end
|
|
2132
|
+
|
|
2133
|
+
def final_arrow_points(state)
|
|
2134
|
+
x = state[:x].to_f
|
|
2135
|
+
y = state[:y].to_f
|
|
2136
|
+
radius = @state_radius
|
|
2137
|
+
length = @final_arrow_length
|
|
2138
|
+
|
|
2139
|
+
case @direction
|
|
2140
|
+
when :rl
|
|
2141
|
+
x1 = [x - radius, 0.0].max
|
|
2142
|
+
x2 = [x - radius - length, 0.0].max
|
|
2143
|
+
[x1, y, x2, y, x2 + 4, y - 8, 'start']
|
|
2144
|
+
when :tb
|
|
2145
|
+
y1 = [y + radius, @canvas_height].compact.min
|
|
2146
|
+
y2 = [y + radius + length, @canvas_height].compact.min
|
|
2147
|
+
[x, y1, x, y2, x + 8, y2 - 4, 'start']
|
|
2148
|
+
when :bt
|
|
2149
|
+
y2 = [y - radius - length, 0.0].max
|
|
2150
|
+
[x, y - radius, x, y2, x + 8, y2 + 14, 'start']
|
|
2151
|
+
else
|
|
2152
|
+
x1 = [x + radius, @canvas_width].compact.min
|
|
2153
|
+
x2 = [x + radius + length, @canvas_width].compact.min
|
|
2154
|
+
[x1, y, x2, y, x2 - 4, y - 8, 'end']
|
|
2155
|
+
end
|
|
2156
|
+
end
|
|
2157
|
+
|
|
2158
|
+
def add_state_groups(svg)
|
|
2159
|
+
groups = grouped_state_positions
|
|
2160
|
+
return if groups.empty?
|
|
2161
|
+
|
|
2162
|
+
groups.each do |name, positions|
|
|
2163
|
+
bounds = state_group_bounds(positions)
|
|
2164
|
+
group_node = svg.add_element('g', {
|
|
2165
|
+
'class' => 'state-group',
|
|
2166
|
+
'id' => unique_svg_id("state-group-#{svg_id_component(name)}"),
|
|
2167
|
+
'data-group' => name.to_s
|
|
2168
|
+
})
|
|
2169
|
+
group_node.add_element('rect', {
|
|
2170
|
+
'class' => 'state-group-box',
|
|
2171
|
+
'x' => bounds[:x].to_s,
|
|
2172
|
+
'y' => bounds[:y].to_s,
|
|
2173
|
+
'width' => bounds[:width].to_s,
|
|
2174
|
+
'height' => bounds[:height].to_s,
|
|
2175
|
+
'rx' => '12'
|
|
2176
|
+
})
|
|
2177
|
+
label = group_node.add_element('text', {
|
|
2178
|
+
'class' => 'state-group-label',
|
|
2179
|
+
'x' => (bounds[:x] + 12).to_s,
|
|
2180
|
+
'y' => (bounds[:y] + 22).to_s
|
|
2181
|
+
})
|
|
2182
|
+
label.text = name.to_s
|
|
2183
|
+
end
|
|
2184
|
+
end
|
|
2185
|
+
|
|
2186
|
+
def grouped_state_positions
|
|
2187
|
+
groups = @automaton.state_records.each_with_object({}) do |(name, state), grouped|
|
|
2188
|
+
group_name = state_group_name(state)
|
|
2189
|
+
next unless group_name
|
|
2190
|
+
|
|
2191
|
+
position = state_position(name)
|
|
2192
|
+
next unless position && position[:x] && position[:y]
|
|
2193
|
+
|
|
2194
|
+
grouped[group_name] ||= []
|
|
2195
|
+
grouped[group_name] << position
|
|
2196
|
+
end
|
|
2197
|
+
add_scc_state_groups(groups) if @scc_groups
|
|
2198
|
+
groups
|
|
2199
|
+
end
|
|
2200
|
+
|
|
2201
|
+
def add_scc_state_groups(groups)
|
|
2202
|
+
group_index = 0
|
|
2203
|
+
strongly_connected_components.each do |component|
|
|
2204
|
+
next if component.size < 2
|
|
2205
|
+
next if component.any? { |state| explicit_state_group?(state) }
|
|
2206
|
+
|
|
2207
|
+
group_index += 1
|
|
2208
|
+
group_name = "SCC #{group_index}"
|
|
2209
|
+
groups[group_name] ||= []
|
|
2210
|
+
component.each do |state|
|
|
2211
|
+
position = state_position(state)
|
|
2212
|
+
groups[group_name] << position if position && position[:x] && position[:y]
|
|
2213
|
+
end
|
|
2214
|
+
groups.delete(group_name) if groups[group_name].empty?
|
|
2215
|
+
end
|
|
2216
|
+
end
|
|
2217
|
+
|
|
2218
|
+
def strongly_connected_components
|
|
2219
|
+
@automaton.strongly_connected_components
|
|
2220
|
+
end
|
|
2221
|
+
|
|
2222
|
+
def automatic_group_margin
|
|
2223
|
+
return 0 if @layout == :manual
|
|
2224
|
+
return 0 unless svg_group_decorations?
|
|
2225
|
+
|
|
2226
|
+
state_group_padding
|
|
2227
|
+
end
|
|
2228
|
+
|
|
2229
|
+
def automatic_layout_padding
|
|
2230
|
+
[@padding.to_f + automatic_group_margin, automatic_arrow_margin].max
|
|
2231
|
+
end
|
|
2232
|
+
|
|
2233
|
+
def automatic_arrow_margin
|
|
2234
|
+
margin = 0.0
|
|
2235
|
+
if @automaton.initial_state
|
|
2236
|
+
label_margin = @initial_arrow_label ? calculate_text_width(@initial_arrow_label) : 0.0
|
|
2237
|
+
margin = [margin, 30.0 + @initial_arrow_length.to_f + label_margin + 8.0].max
|
|
2238
|
+
end
|
|
2239
|
+
if @show_final_arrows && @automaton.final_states.any?
|
|
2240
|
+
label_margin = @final_arrow_label ? calculate_text_width(@final_arrow_label) : 0.0
|
|
2241
|
+
margin = [margin, @state_radius.to_f + @final_arrow_length.to_f + label_margin + 8.0].max
|
|
2242
|
+
end
|
|
2243
|
+
margin
|
|
2244
|
+
end
|
|
2245
|
+
|
|
2246
|
+
def svg_group_decorations?
|
|
2247
|
+
@scc_groups || @automaton.state_records.any? { |_, state| state_group_name(state) }
|
|
2248
|
+
end
|
|
2249
|
+
|
|
2250
|
+
def explicit_state_group?(state_name)
|
|
2251
|
+
state = @automaton.state_records[state_name]
|
|
2252
|
+
state && state_group_name(state)
|
|
2253
|
+
end
|
|
2254
|
+
|
|
2255
|
+
def state_group_name(state)
|
|
2256
|
+
metadata = state[:metadata]
|
|
2257
|
+
return nil unless metadata.is_a?(Hash)
|
|
2258
|
+
|
|
2259
|
+
metadata[:group] || metadata['group'] || metadata[:cluster] || metadata['cluster']
|
|
2260
|
+
end
|
|
2261
|
+
|
|
2262
|
+
def folded_automaton(source)
|
|
2263
|
+
groups = source.states.each_with_object({}) do |(name, state), grouped|
|
|
2264
|
+
group_name = state_group_name(state)
|
|
2265
|
+
next unless group_name
|
|
2266
|
+
|
|
2267
|
+
grouped[group_name] ||= []
|
|
2268
|
+
grouped[group_name] << name
|
|
2269
|
+
end
|
|
2270
|
+
groups.select! { |_, members| members.size > 1 }
|
|
2271
|
+
return source if groups.empty?
|
|
2272
|
+
|
|
2273
|
+
folded = Graphomaton.new
|
|
2274
|
+
group_ids = {}
|
|
2275
|
+
member_to_group = {}
|
|
2276
|
+
|
|
2277
|
+
groups.each do |group_name, members|
|
|
2278
|
+
group_id = folded_group_state_id(group_name, source.states, group_ids.values)
|
|
2279
|
+
group_ids[group_name] = group_id
|
|
2280
|
+
members.each { |member| member_to_group[member] = group_id }
|
|
2281
|
+
end
|
|
2282
|
+
|
|
2283
|
+
source.states.each do |name, state|
|
|
2284
|
+
group_name = state_group_name(state)
|
|
2285
|
+
if group_name && groups.key?(group_name)
|
|
2286
|
+
next if folded.states.key?(group_ids[group_name])
|
|
2287
|
+
|
|
2288
|
+
position = folded_group_position(groups[group_name], source.states)
|
|
2289
|
+
folded.add_state(
|
|
2290
|
+
group_ids[group_name],
|
|
2291
|
+
position[:x],
|
|
2292
|
+
position[:y],
|
|
2293
|
+
label: group_name.to_s,
|
|
2294
|
+
metadata: folded_group_metadata(group_name, groups[group_name]),
|
|
2295
|
+
shape: :rounded_rect
|
|
2296
|
+
)
|
|
2297
|
+
else
|
|
2298
|
+
folded.add_state(
|
|
2299
|
+
name,
|
|
2300
|
+
state[:x],
|
|
2301
|
+
state[:y],
|
|
2302
|
+
label: state[:label],
|
|
2303
|
+
style: state[:style],
|
|
2304
|
+
metadata: state[:metadata],
|
|
2305
|
+
shape: state[:shape]
|
|
2306
|
+
)
|
|
2307
|
+
end
|
|
2308
|
+
end
|
|
2309
|
+
|
|
2310
|
+
source.transitions.each do |transition|
|
|
2311
|
+
from = member_to_group.fetch(transition[:from], transition[:from])
|
|
2312
|
+
to = member_to_group.fetch(transition[:to], transition[:to])
|
|
2313
|
+
next if from == to
|
|
2314
|
+
|
|
2315
|
+
folded.add_transition(
|
|
2316
|
+
from,
|
|
2317
|
+
to,
|
|
2318
|
+
transition[:label],
|
|
2319
|
+
style: transition[:style],
|
|
2320
|
+
metadata: transition[:metadata],
|
|
2321
|
+
line_style: transition[:line_style]
|
|
2322
|
+
)
|
|
2323
|
+
end
|
|
2324
|
+
|
|
2325
|
+
folded.set_initial(member_to_group.fetch(source.initial_state, source.initial_state)) if source.initial_state
|
|
2326
|
+
source.final_states.each do |state|
|
|
2327
|
+
folded.add_final(member_to_group.fetch(state, state))
|
|
2328
|
+
end
|
|
2329
|
+
|
|
2330
|
+
folded
|
|
2331
|
+
end
|
|
2332
|
+
|
|
2333
|
+
def folded_group_state_id(group_name, states, reserved)
|
|
2334
|
+
base = "group:#{group_name}"
|
|
2335
|
+
candidate = base
|
|
2336
|
+
index = 2
|
|
2337
|
+
while states.key?(candidate) || reserved.include?(candidate)
|
|
2338
|
+
candidate = "#{base}:#{index}"
|
|
2339
|
+
index += 1
|
|
2340
|
+
end
|
|
2341
|
+
candidate
|
|
2342
|
+
end
|
|
2343
|
+
|
|
2344
|
+
def folded_group_position(members, states)
|
|
2345
|
+
positioned = members.filter_map do |member|
|
|
2346
|
+
state = states[member]
|
|
2347
|
+
next unless state && state[:x] && state[:y]
|
|
2348
|
+
|
|
2349
|
+
{ x: state[:x].to_f, y: state[:y].to_f }
|
|
2350
|
+
end
|
|
2351
|
+
return { x: nil, y: nil } if positioned.empty?
|
|
2352
|
+
|
|
2353
|
+
{
|
|
2354
|
+
x: positioned.sum { |position| position[:x] } / positioned.size,
|
|
2355
|
+
y: positioned.sum { |position| position[:y] } / positioned.size
|
|
2356
|
+
}
|
|
2357
|
+
end
|
|
2358
|
+
|
|
2359
|
+
def folded_group_metadata(group_name, members)
|
|
2360
|
+
{
|
|
2361
|
+
folded_group: group_name,
|
|
2362
|
+
folded_states: members,
|
|
2363
|
+
tooltip: "Folded group #{group_name}: #{members.join(', ')}"
|
|
2364
|
+
}
|
|
2365
|
+
end
|
|
2366
|
+
|
|
2367
|
+
def state_group_bounds(positions)
|
|
2368
|
+
padding = state_group_padding
|
|
2369
|
+
min_x = positions.map { |position| position[:x].to_f }.min - @state_radius - padding
|
|
2370
|
+
max_x = positions.map { |position| position[:x].to_f }.max + @state_radius + padding
|
|
2371
|
+
min_y = positions.map { |position| position[:y].to_f }.min - @state_radius - padding
|
|
2372
|
+
max_y = positions.map { |position| position[:y].to_f }.max + @state_radius + padding
|
|
2373
|
+
|
|
2374
|
+
clamp_state_group_bounds(
|
|
2375
|
+
x: min_x,
|
|
2376
|
+
y: min_y,
|
|
2377
|
+
width: max_x - min_x,
|
|
2378
|
+
height: max_y - min_y
|
|
2379
|
+
)
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
def state_group_padding
|
|
2383
|
+
[@state_radius * 0.75, 28].max
|
|
2384
|
+
end
|
|
2385
|
+
|
|
2386
|
+
def clamp_state_group_bounds(bounds)
|
|
2387
|
+
return bounds unless @canvas_width&.positive? && @canvas_height&.positive?
|
|
2388
|
+
|
|
2389
|
+
right = bounds[:x] + bounds[:width]
|
|
2390
|
+
bottom = bounds[:y] + bounds[:height]
|
|
2391
|
+
x = bounds[:x].clamp(0.0, @canvas_width)
|
|
2392
|
+
y = bounds[:y].clamp(0.0, @canvas_height)
|
|
2393
|
+
clamped_right = right.clamp(x, @canvas_width)
|
|
2394
|
+
clamped_bottom = bottom.clamp(y, @canvas_height)
|
|
2395
|
+
|
|
2396
|
+
{
|
|
2397
|
+
x: x,
|
|
2398
|
+
y: y,
|
|
2399
|
+
width: [clamped_right - x, 1.0].max,
|
|
2400
|
+
height: [clamped_bottom - y, 1.0].max
|
|
2401
|
+
}
|
|
2402
|
+
end
|
|
2403
|
+
|
|
2404
|
+
def state_collision_boxes
|
|
2405
|
+
margin = 10.0
|
|
2406
|
+
@positions.values.filter_map do |position|
|
|
2407
|
+
next unless position[:x] && position[:y]
|
|
2408
|
+
|
|
2409
|
+
radius = @state_radius + margin
|
|
2410
|
+
{
|
|
2411
|
+
x: position[:x].to_f - radius,
|
|
2412
|
+
y: position[:y].to_f - radius,
|
|
2413
|
+
width: radius * 2.0,
|
|
2414
|
+
height: radius * 2.0
|
|
2415
|
+
}
|
|
2416
|
+
end
|
|
2417
|
+
end
|
|
2418
|
+
|
|
2419
|
+
def group_label_collision_boxes
|
|
2420
|
+
grouped_state_positions.map do |name, positions|
|
|
2421
|
+
bounds = state_group_bounds(positions)
|
|
2422
|
+
{
|
|
2423
|
+
x: bounds[:x] + 8.0,
|
|
2424
|
+
y: bounds[:y] + 8.0,
|
|
2425
|
+
width: [name.to_s.length * 8.0, 32.0].max,
|
|
2426
|
+
height: 18.0
|
|
2427
|
+
}
|
|
2428
|
+
end
|
|
2429
|
+
end
|
|
2430
|
+
|
|
2431
|
+
def add_states(svg)
|
|
2432
|
+
@automaton.state_records.each do |name, state|
|
|
2433
|
+
label = state_label(name, state)
|
|
2434
|
+
lines = state_label_lines(label)
|
|
2435
|
+
position = state_position(name) || state
|
|
2436
|
+
state_node = svg.add_element('g', state_group_attributes(name))
|
|
2437
|
+
add_state_tooltip(state_node, state, label)
|
|
2438
|
+
state_content = state_link_container(state_node, state)
|
|
2439
|
+
shape = state_shape(state)
|
|
2440
|
+
circle_class = 'state-circle'
|
|
2441
|
+
circle_class += ' final-state' if @automaton.final_states.include?(name)
|
|
2442
|
+
|
|
2443
|
+
state_content.add_element(state_shape_element(shape), state_shape_attributes(shape, circle_class, position, state))
|
|
2444
|
+
|
|
2445
|
+
if @automaton.final_states.include?(name)
|
|
2446
|
+
inner_radius = [@state_radius - 8, 8].max
|
|
2447
|
+
state_content.add_element(state_shape_element(shape), state_shape_attributes(shape, 'state-circle', position, state, radius: inner_radius))
|
|
2448
|
+
end
|
|
2449
|
+
add_state_icon(state_content, state, position)
|
|
2450
|
+
|
|
2451
|
+
font_size = calculate_state_font_size(label.to_s)
|
|
2452
|
+
if lines.size == 1
|
|
2453
|
+
text = state_content.add_element('text', {
|
|
2454
|
+
'class' => 'state-text',
|
|
2455
|
+
'x' => position[:x].to_s,
|
|
2456
|
+
'y' => (position[:y] + (font_size * 0.35)).to_s,
|
|
2457
|
+
'font-size' => font_size.to_s
|
|
2458
|
+
})
|
|
2459
|
+
text.text = lines.first
|
|
2460
|
+
else
|
|
2461
|
+
line_gap = font_size + 2
|
|
2462
|
+
text_start_y = position[:y].to_f - ((lines.size - 1) * line_gap / 2.0) + (line_gap * 0.35)
|
|
2463
|
+
text = state_content.add_element('text', {
|
|
2464
|
+
'class' => 'state-text',
|
|
2465
|
+
'x' => position[:x].to_s,
|
|
2466
|
+
'y' => text_start_y.to_s,
|
|
2467
|
+
'font-size' => font_size.to_s
|
|
2468
|
+
})
|
|
2469
|
+
lines.each_with_index do |line, index|
|
|
2470
|
+
tspan = text.add_element('tspan', { 'x' => position[:x].to_s })
|
|
2471
|
+
tspan.text = line
|
|
2472
|
+
tspan.attributes['dy'] = index.zero? ? '0' : line_gap.to_s
|
|
2473
|
+
end
|
|
2474
|
+
end
|
|
2475
|
+
end
|
|
2476
|
+
end
|
|
2477
|
+
|
|
2478
|
+
def state_label(name, state)
|
|
2479
|
+
state.fetch(:label, name)
|
|
2480
|
+
end
|
|
2481
|
+
|
|
2482
|
+
def add_state_icon(state_content, state, position)
|
|
2483
|
+
icon = state_icon(state)
|
|
2484
|
+
return unless icon
|
|
2485
|
+
|
|
2486
|
+
icon_text = state_content.add_element('text', {
|
|
2487
|
+
'class' => 'state-icon',
|
|
2488
|
+
'x' => position[:x].to_s,
|
|
2489
|
+
'y' => (position[:y].to_f - (@state_radius * 0.35)).to_s
|
|
2490
|
+
})
|
|
2491
|
+
icon_text.text = icon.to_s
|
|
2492
|
+
end
|
|
2493
|
+
|
|
2494
|
+
def state_icon(state)
|
|
2495
|
+
metadata = state[:metadata]
|
|
2496
|
+
return nil unless metadata.is_a?(Hash)
|
|
2497
|
+
|
|
2498
|
+
metadata[:icon] || metadata['icon']
|
|
2499
|
+
end
|
|
2500
|
+
|
|
2501
|
+
def state_shape(state)
|
|
2502
|
+
return resolve_state_shape(state[:shape]) if state[:shape]
|
|
2503
|
+
|
|
2504
|
+
pseudostate_shape(state) || @state_shape
|
|
2505
|
+
end
|
|
2506
|
+
|
|
2507
|
+
def pseudostate_shape(state)
|
|
2508
|
+
type = nested_state_metadata_value(state, :svg, :shape) ||
|
|
2509
|
+
nested_state_metadata_value(state, :svg, :type) ||
|
|
2510
|
+
state[:kind] ||
|
|
2511
|
+
state_metadata_value(state, :kind) ||
|
|
2512
|
+
state_metadata_value(state, :svg_shape) ||
|
|
2513
|
+
state_metadata_value(state, :svg_type)
|
|
2514
|
+
normalized = type.to_s.tr('-', '_').to_sym
|
|
2515
|
+
|
|
2516
|
+
case normalized
|
|
2517
|
+
when :choice
|
|
2518
|
+
:diamond
|
|
2519
|
+
when :fork, :join
|
|
2520
|
+
:bar
|
|
2521
|
+
end
|
|
2522
|
+
end
|
|
2523
|
+
|
|
2524
|
+
def state_metadata_value(state, key)
|
|
2525
|
+
metadata = state[:metadata]
|
|
2526
|
+
return nil unless metadata.is_a?(Hash)
|
|
2527
|
+
|
|
2528
|
+
metadata[key] || metadata[key.to_s]
|
|
2529
|
+
end
|
|
2530
|
+
|
|
2531
|
+
def nested_state_metadata_value(state, namespace, key)
|
|
2532
|
+
metadata = state[:metadata]
|
|
2533
|
+
return nil unless metadata.is_a?(Hash)
|
|
2534
|
+
|
|
2535
|
+
nested = metadata[namespace] || metadata[namespace.to_s]
|
|
2536
|
+
return nil unless nested.is_a?(Hash)
|
|
2537
|
+
|
|
2538
|
+
nested[key] || nested[key.to_s]
|
|
2539
|
+
end
|
|
2540
|
+
|
|
2541
|
+
def add_state_tooltip(state_node, state, label)
|
|
2542
|
+
tooltip = state_tooltip(state, label)
|
|
2543
|
+
return unless tooltip
|
|
2544
|
+
|
|
2545
|
+
title = state_node.add_element('title')
|
|
2546
|
+
title.text = tooltip
|
|
2547
|
+
add_html_tooltip_attributes(state_node, tooltip)
|
|
2548
|
+
end
|
|
2549
|
+
|
|
2550
|
+
def state_link_container(state_node, state)
|
|
2551
|
+
url = state_url(state)
|
|
2552
|
+
return state_node unless url
|
|
2553
|
+
|
|
2554
|
+
state_node.add_element('a', {
|
|
2555
|
+
'href' => url.to_s,
|
|
2556
|
+
'target' => '_blank',
|
|
2557
|
+
'rel' => 'noopener noreferrer'
|
|
2558
|
+
})
|
|
2559
|
+
end
|
|
2560
|
+
|
|
2561
|
+
def state_url(state)
|
|
2562
|
+
metadata = state[:metadata]
|
|
2563
|
+
return nil unless metadata.is_a?(Hash)
|
|
2564
|
+
|
|
2565
|
+
url = metadata[:url] || metadata['url'] || metadata[:href] || metadata['href']
|
|
2566
|
+
return nil unless url
|
|
2567
|
+
|
|
2568
|
+
UrlPolicy.validate(url, context: 'SVG state URL')
|
|
2569
|
+
end
|
|
2570
|
+
|
|
2571
|
+
def state_tooltip(state, label)
|
|
2572
|
+
metadata = state[:metadata]
|
|
2573
|
+
if metadata.is_a?(Hash)
|
|
2574
|
+
tooltip = metadata[:tooltip] || metadata['tooltip'] || metadata[:description] || metadata['description']
|
|
2575
|
+
return tooltip if tooltip
|
|
2576
|
+
end
|
|
2577
|
+
|
|
2578
|
+
return nil unless @label_tooltips
|
|
2579
|
+
|
|
2580
|
+
label.to_s
|
|
2581
|
+
end
|
|
2582
|
+
|
|
2583
|
+
def state_shape_element(shape)
|
|
2584
|
+
return 'polygon' if shape == :diamond
|
|
2585
|
+
return 'rect' if shape == :bar
|
|
2586
|
+
return 'ellipse' if shape == :ellipse
|
|
2587
|
+
return 'rect' if shape == :rounded_rect
|
|
2588
|
+
|
|
2589
|
+
'circle'
|
|
2590
|
+
end
|
|
2591
|
+
|
|
2592
|
+
def state_shape_attributes(shape, shape_class, position, state, radius: @state_radius)
|
|
2593
|
+
attributes = case shape
|
|
2594
|
+
when :diamond
|
|
2595
|
+
{
|
|
2596
|
+
'class' => shape_class,
|
|
2597
|
+
'points' => [
|
|
2598
|
+
"#{position[:x]} #{position[:y].to_f - radius}",
|
|
2599
|
+
"#{position[:x].to_f + radius} #{position[:y]}",
|
|
2600
|
+
"#{position[:x]} #{position[:y].to_f + radius}",
|
|
2601
|
+
"#{position[:x].to_f - radius} #{position[:y]}"
|
|
2602
|
+
].join(', ')
|
|
2603
|
+
}
|
|
2604
|
+
when :bar
|
|
2605
|
+
bar_width = radius * 1.4
|
|
2606
|
+
bar_height = [radius * 0.2, 8].max
|
|
2607
|
+
{
|
|
2608
|
+
'class' => shape_class,
|
|
2609
|
+
'x' => (position[:x].to_f - (bar_width / 2.0)).to_s,
|
|
2610
|
+
'y' => (position[:y].to_f - (bar_height / 2.0)).to_s,
|
|
2611
|
+
'width' => bar_width.to_s,
|
|
2612
|
+
'height' => bar_height.to_s,
|
|
2613
|
+
'rx' => (bar_height / 2.0).to_s
|
|
2614
|
+
}
|
|
2615
|
+
when :ellipse
|
|
2616
|
+
{
|
|
2617
|
+
'class' => shape_class,
|
|
2618
|
+
'cx' => position[:x].to_s,
|
|
2619
|
+
'cy' => position[:y].to_s,
|
|
2620
|
+
'rx' => (radius * 1.25).to_s,
|
|
2621
|
+
'ry' => (radius * 0.8).to_s
|
|
2622
|
+
}
|
|
2623
|
+
when :rounded_rect
|
|
2624
|
+
{
|
|
2625
|
+
'class' => shape_class,
|
|
2626
|
+
'x' => (position[:x] - radius).to_s,
|
|
2627
|
+
'y' => (position[:y] - radius).to_s,
|
|
2628
|
+
'width' => (radius * 2).to_s,
|
|
2629
|
+
'height' => (radius * 2).to_s,
|
|
2630
|
+
'rx' => '10'
|
|
2631
|
+
}
|
|
2632
|
+
else
|
|
2633
|
+
{
|
|
2634
|
+
'class' => shape_class,
|
|
2635
|
+
'cx' => position[:x].to_s,
|
|
2636
|
+
'cy' => position[:y].to_s,
|
|
2637
|
+
'r' => radius.to_s
|
|
2638
|
+
}
|
|
2639
|
+
end
|
|
2640
|
+
style = css_style(state[:style])
|
|
2641
|
+
attributes['style'] = style unless style.empty?
|
|
2642
|
+
attributes
|
|
2643
|
+
end
|
|
2644
|
+
|
|
2645
|
+
def css_style(style)
|
|
2646
|
+
return '' unless style.is_a?(Hash)
|
|
2647
|
+
|
|
2648
|
+
style.map do |key, value|
|
|
2649
|
+
property = key.to_s.tr('_', '-')
|
|
2650
|
+
unless SAFE_STYLE_PROPERTIES.include?(property)
|
|
2651
|
+
raise Graphomaton::SecurityError, "Unsafe SVG style property: #{property.inspect}"
|
|
2652
|
+
end
|
|
2653
|
+
|
|
2654
|
+
css_value = safe_css_value(value, context: "style value for #{property}")
|
|
2655
|
+
|
|
2656
|
+
"#{property}: #{css_value}"
|
|
2657
|
+
end.join('; ')
|
|
2658
|
+
end
|
|
2659
|
+
|
|
2660
|
+
def safe_css_value(value, context:, allow_nil: false)
|
|
2661
|
+
return nil if value.nil? && allow_nil
|
|
2662
|
+
|
|
2663
|
+
css_value = value.to_s
|
|
2664
|
+
if css_value.match?(/[\u0000-\u001f\u007f;{}]/) || css_value.match?(/url\s*\(/i)
|
|
2665
|
+
raise Graphomaton::SecurityError, "Unsafe SVG #{context}: #{value.inspect}"
|
|
2666
|
+
end
|
|
2667
|
+
|
|
2668
|
+
css_value
|
|
2669
|
+
end
|
|
2670
|
+
|
|
2671
|
+
def state_group_attributes(name)
|
|
2672
|
+
classes = ['state']
|
|
2673
|
+
classes << 'unreachable-state' if @unreachable_states.include?(name)
|
|
2674
|
+
classes << 'dead-state' if @dead_states.include?(name)
|
|
2675
|
+
classes << 'trap-state' if @trap_states.include?(name)
|
|
2676
|
+
classes << 'initial-state' if @highlight_initial_state && @automaton.initial_state == name
|
|
2677
|
+
classes << 'accepting-state' if @highlight_final_states && @automaton.final_states.include?(name)
|
|
2678
|
+
|
|
2679
|
+
attributes = {
|
|
2680
|
+
'class' => classes.join(' '),
|
|
2681
|
+
'id' => unique_svg_id("state-#{svg_id_component(name)}"),
|
|
2682
|
+
'data-state' => name.to_s
|
|
2683
|
+
}
|
|
2684
|
+
state = @automaton.state_records[name]
|
|
2685
|
+
folded_group = state_metadata_value(state, :folded_group) if state
|
|
2686
|
+
folded_states = state_metadata_value(state, :folded_states) if state
|
|
2687
|
+
attributes['data-folded-group'] = folded_group.to_s if folded_group
|
|
2688
|
+
attributes['data-folded-states'] = Array(folded_states).join(',') if folded_states
|
|
2689
|
+
attributes
|
|
2690
|
+
end
|
|
2691
|
+
|
|
2692
|
+
def transition_group_attributes(transition)
|
|
2693
|
+
from = transition[:from]
|
|
2694
|
+
to = transition[:to]
|
|
2695
|
+
label = transition[:label]
|
|
2696
|
+
bundle = transition_bundle(transition)
|
|
2697
|
+
classes = ['transition']
|
|
2698
|
+
classes << 'bundled-transition' if bundle
|
|
2699
|
+
if highlighted_transition?(transition)
|
|
2700
|
+
classes << 'highlighted-transition'
|
|
2701
|
+
elsif @highlight_transitions.any?
|
|
2702
|
+
classes << 'inactive-transition'
|
|
2703
|
+
end
|
|
2704
|
+
|
|
2705
|
+
attributes = {
|
|
2706
|
+
'class' => classes.join(' '),
|
|
2707
|
+
'id' => unique_svg_id(
|
|
2708
|
+
"transition-#{svg_id_component(from)}-#{svg_id_component(to)}-#{svg_id_component(label)}"
|
|
2709
|
+
),
|
|
2710
|
+
'data-from' => from.to_s,
|
|
2711
|
+
'data-to' => to.to_s,
|
|
2712
|
+
'data-label' => label.to_s
|
|
2713
|
+
}
|
|
2714
|
+
attributes['data-bundle'] = bundle.to_s if bundle
|
|
2715
|
+
attributes
|
|
2716
|
+
end
|
|
2717
|
+
|
|
2718
|
+
def transition_bundle(transition)
|
|
2719
|
+
metadata = transition[:metadata]
|
|
2720
|
+
return nil unless metadata.is_a?(Hash)
|
|
2721
|
+
|
|
2722
|
+
metadata[:bundle] || metadata['bundle']
|
|
2723
|
+
end
|
|
2724
|
+
|
|
2725
|
+
def transition_line_attributes(transition, attributes)
|
|
2726
|
+
line_attributes = { 'class' => 'transition-line' }.merge(attributes)
|
|
2727
|
+
style = transition_css_style(transition)
|
|
2728
|
+
line_attributes['style'] = style unless style.empty?
|
|
2729
|
+
line_attributes
|
|
2730
|
+
end
|
|
2731
|
+
|
|
2732
|
+
def transition_css_style(transition)
|
|
2733
|
+
style = []
|
|
2734
|
+
line_style = transition[:line_style]
|
|
2735
|
+
line_style_value = line_style_css(line_style) if line_style
|
|
2736
|
+
style << line_style_value unless line_style_value.to_s.empty?
|
|
2737
|
+
custom_style = css_style(transition[:style])
|
|
2738
|
+
style << custom_style unless custom_style.empty?
|
|
2739
|
+
style.join('; ')
|
|
2740
|
+
end
|
|
2741
|
+
|
|
2742
|
+
def line_style_css(line_style)
|
|
2743
|
+
resolved = line_style.to_sym
|
|
2744
|
+
unless TRANSITION_LINE_STYLE_OPTIONS.include?(resolved)
|
|
2745
|
+
raise ArgumentError, "Unknown transition line_style: #{line_style.inspect}. Available values: #{TRANSITION_LINE_STYLE_OPTIONS.join(', ')}"
|
|
2746
|
+
end
|
|
2747
|
+
|
|
2748
|
+
case resolved
|
|
2749
|
+
when :dashed
|
|
2750
|
+
'stroke-dasharray: 8 5'
|
|
2751
|
+
when :dotted
|
|
2752
|
+
'stroke-dasharray: 2 5'
|
|
2753
|
+
else
|
|
2754
|
+
''
|
|
2755
|
+
end
|
|
2756
|
+
end
|
|
2757
|
+
|
|
2758
|
+
def add_transition_tooltip(transition_node, transition)
|
|
2759
|
+
tooltip = transition_tooltip(transition)
|
|
2760
|
+
return unless tooltip
|
|
2761
|
+
|
|
2762
|
+
title = transition_node.add_element('title')
|
|
2763
|
+
title.text = tooltip
|
|
2764
|
+
add_html_tooltip_attributes(transition_node, tooltip)
|
|
2765
|
+
end
|
|
2766
|
+
|
|
2767
|
+
def add_html_tooltip_attributes(node, tooltip)
|
|
2768
|
+
return unless @html_tooltips
|
|
2769
|
+
|
|
2770
|
+
node.add_attribute('data-tooltip', tooltip.to_s)
|
|
2771
|
+
node.add_attribute('aria-label', tooltip.to_s)
|
|
2772
|
+
node.add_attribute('tabindex', '0')
|
|
2773
|
+
end
|
|
2774
|
+
|
|
2775
|
+
def transition_link_container(transition_node, transition)
|
|
2776
|
+
url = transition_url(transition)
|
|
2777
|
+
return transition_node unless url
|
|
2778
|
+
|
|
2779
|
+
transition_node.add_element('a', {
|
|
2780
|
+
'href' => url.to_s,
|
|
2781
|
+
'target' => '_blank',
|
|
2782
|
+
'rel' => 'noopener noreferrer'
|
|
2783
|
+
})
|
|
2784
|
+
end
|
|
2785
|
+
|
|
2786
|
+
def transition_url(transition)
|
|
2787
|
+
metadata = transition[:metadata]
|
|
2788
|
+
return nil unless metadata.is_a?(Hash)
|
|
2789
|
+
|
|
2790
|
+
url = metadata[:url] || metadata['url'] || metadata[:href] || metadata['href']
|
|
2791
|
+
return nil unless url
|
|
2792
|
+
|
|
2793
|
+
UrlPolicy.validate(url, context: 'SVG transition URL')
|
|
2794
|
+
end
|
|
2795
|
+
|
|
2796
|
+
def transition_tooltip(transition)
|
|
2797
|
+
metadata = transition[:metadata]
|
|
2798
|
+
if metadata.is_a?(Hash)
|
|
2799
|
+
tooltip = metadata[:tooltip] || metadata['tooltip'] || metadata[:description] || metadata['description']
|
|
2800
|
+
return tooltip if tooltip
|
|
2801
|
+
end
|
|
2802
|
+
|
|
2803
|
+
return nil unless @label_tooltips
|
|
2804
|
+
|
|
2805
|
+
transition[:label].to_s
|
|
2806
|
+
end
|
|
2807
|
+
|
|
2808
|
+
def highlighted_transition?(transition)
|
|
2809
|
+
@highlight_transitions.any? do |target|
|
|
2810
|
+
transition_highlight_match?(transition, target)
|
|
2811
|
+
end
|
|
2812
|
+
end
|
|
2813
|
+
|
|
2814
|
+
def transition_highlight_match?(transition, target)
|
|
2815
|
+
case target
|
|
2816
|
+
when Hash
|
|
2817
|
+
transition_match_value?(transition, target, :from) &&
|
|
2818
|
+
transition_match_value?(transition, target, :to) &&
|
|
2819
|
+
transition_match_value?(transition, target, :label)
|
|
2820
|
+
when Array
|
|
2821
|
+
transition[:from] == target[0] &&
|
|
2822
|
+
transition[:to] == target[1] &&
|
|
2823
|
+
(target.size < 3 || transition[:label].to_s == target[2].to_s)
|
|
2824
|
+
else
|
|
2825
|
+
false
|
|
2826
|
+
end
|
|
2827
|
+
end
|
|
2828
|
+
|
|
2829
|
+
def transition_match_value?(transition, target, key)
|
|
2830
|
+
return true unless target.key?(key) || target.key?(key.to_s)
|
|
2831
|
+
|
|
2832
|
+
value = target.key?(key) ? target[key] : target[key.to_s]
|
|
2833
|
+
transition[key].to_s == value.to_s
|
|
2834
|
+
end
|
|
2835
|
+
|
|
2836
|
+
def unique_svg_id(base_id)
|
|
2837
|
+
@element_id_counts[base_id] += 1
|
|
2838
|
+
return base_id if @element_id_counts[base_id] == 1
|
|
2839
|
+
|
|
2840
|
+
"#{base_id}-#{@element_id_counts[base_id]}"
|
|
2841
|
+
end
|
|
2842
|
+
|
|
2843
|
+
def svg_id_component(value)
|
|
2844
|
+
component = value.to_s.downcase.gsub(/[^a-z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
2845
|
+
component.empty? ? 'item' : component
|
|
2846
|
+
end
|
|
2847
|
+
|
|
2848
|
+
def state_position(state_name)
|
|
2849
|
+
@positions[state_name]
|
|
2850
|
+
end
|
|
2851
|
+
|
|
2852
|
+
def forward_direction?(x1, y1, x2, y2)
|
|
2853
|
+
case @direction
|
|
2854
|
+
when :lr
|
|
2855
|
+
x2 >= x1
|
|
2856
|
+
when :rl
|
|
2857
|
+
x2 <= x1
|
|
2858
|
+
when :tb
|
|
2859
|
+
y2 >= y1
|
|
2860
|
+
when :bt
|
|
2861
|
+
y2 <= y1
|
|
2862
|
+
else
|
|
2863
|
+
true
|
|
324
2864
|
end
|
|
325
2865
|
end
|
|
2866
|
+
|
|
2867
|
+
def vertical_direction?
|
|
2868
|
+
@direction == :tb || @direction == :bt
|
|
2869
|
+
end
|
|
326
2870
|
end
|
|
327
2871
|
end
|
|
328
2872
|
end
|