graphomaton 1.0.0 → 1.1.0

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