graphomaton 1.0.0 → 1.2.0

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