elkrb 1.0.0 → 2.0.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -1,14 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+ require_relative "atomic_destination"
5
+ require_relative "command_resolver"
6
+
3
7
  module Elkrb
4
- # Wrapper for optional Graphviz integration
5
- # Provides graceful degradation when Graphviz is not installed
8
+ # Wrapper for optional Graphviz integration.
9
+ #
10
+ # A missing Graphviz is a hard failure, not a degraded render. #available?
11
+ # lets a caller ask first; #render raises GraphvizNotFoundError carrying
12
+ # the install instructions, and the diagram command re-raises it after
13
+ # deleting the half-written file rather than leaving DOT text sitting
14
+ # under a .svg name.
15
+ #
16
+ # reek 6.5.0's own directive-comment parser (pinned by the Gemfile's
17
+ # `~> 6.5.0`; re-check this comment on any reek bump) only recognizes a
18
+ # `{ ... }` config hash when it appears on a SINGLE comment line --
19
+ # `Reek::CodeComment::CONFIGURATION_REGEX` has no /m flag, so a `{ }` split
20
+ # across lines silently fails to parse, and reek falls back to disabling
21
+ # the WHOLE detector for this class, which is
22
+ # exactly the whole-class over-reach this is meant to avoid. Measured: a
23
+ # 4th bang method added under the split form was NOT flagged; under this
24
+ # one-line form it IS. Verify with `context.config_for(MissingSafeMethod)`
25
+ # if this line is ever touched -- it must read `{"exclude"=>[...]}`, not
26
+ # `{"enabled"=>false}`.
27
+ # rubocop:disable Layout/LineLength
28
+ # :reek:MissingSafeMethod { exclude: [ validate_engine!, validate_file_exists!, validate_format!, validate_output_file! ] }
29
+ # rubocop:enable Layout/LineLength
30
+ # These four are argument validators, not the dangerous/safe pair the
31
+ # smell looks for; a bang-less companion isn't meaningful for any of them.
32
+ # Scoped to this exact class by reek's inline-comment mechanism, so a new
33
+ # bang method added directly on GraphvizWrapper later is NOT silently
34
+ # exempted. A nested class INHERITS this exclude, though --
35
+ # `CodeContext#config_for` merges the parent's config in -- which is why
36
+ # GraphvizNotFoundError below resets it explicitly rather than relying on
37
+ # this comment's scope.
6
38
  class GraphvizWrapper
39
+ # :reek:MissingSafeMethod { exclude: [] } -- resets the exclude this
40
+ # nested class would otherwise inherit from GraphvizWrapper above, so a
41
+ # bang method added here later is not silently exempted by a sibling's
42
+ # unrelated exclusion.
7
43
  class GraphvizNotFoundError < StandardError; end
8
44
 
9
45
  SUPPORTED_FORMATS = %i[png svg pdf ps eps].freeze
10
46
  SUPPORTED_ENGINES = %w[dot neato fdp sfdp twopi circo].freeze
11
47
 
48
+ # "dot" first, so a plain PATH lookup (CommandResolver, PATHEXT-aware on
49
+ # Windows) always gets the first try. The rest are where the package
50
+ # managers put `dot` when it is not on PATH. macOS cron and launchd hand
51
+ # a process /usr/bin:/bin, and Homebrew -- the install route
52
+ # #installation_message recommends -- is on neither.
53
+ CANDIDATES = [
54
+ "dot",
55
+ "/usr/bin/dot",
56
+ "/usr/local/bin/dot",
57
+ "/opt/homebrew/bin/dot",
58
+ "/opt/local/bin/dot",
59
+ ].freeze
60
+ private_constant :CANDIDATES
61
+
12
62
  def initialize
13
63
  @dot_path = find_graphviz
14
64
  end
@@ -22,21 +72,30 @@ module Elkrb
22
72
 
23
73
  validate_format!(format)
24
74
  validate_file_exists!(dot_file)
75
+ validate_output_file!(output_file)
25
76
 
26
- engine = options[:engine] || "dot"
27
- validate_engine!(engine)
77
+ engine, dpi = command_options(options)
28
78
 
29
- dpi = options[:dpi] || 96
30
-
31
- cmd = build_command(engine, format, dot_file, output_file, dpi)
32
- execute_command(cmd)
79
+ AtomicDestination.generate(
80
+ output_file, require_nonempty: true
81
+ ) do |scratch|
82
+ execute_command(build_command(engine, format, dot_file, scratch, dpi))
83
+ end
33
84
  end
34
85
 
86
+ # `available?` only proves @dot_path resolved to a real, executable file
87
+ # at CONSTRUCTION time. Between then and this call the binary can be
88
+ # deleted or lose its execute bit -- measured, Open3.capture2e raises
89
+ # Errno::ENOENT or Errno::EACCES in exactly those cases, which would
90
+ # otherwise break #version's own documented "nil when not available"
91
+ # contract instead of honoring it.
35
92
  def version
36
93
  return nil unless available?
37
94
 
38
- output = `#{@dot_path} -V 2>&1`
95
+ output, = Open3.capture2e(@dot_path, "-V")
39
96
  output.match(/version\s+([\d.]+)/i)&.captures&.first
97
+ rescue SystemCallError
98
+ nil
40
99
  end
41
100
 
42
101
  def supported_formats
@@ -49,49 +108,80 @@ module Elkrb
49
108
 
50
109
  private
51
110
 
111
+ def command_options(options)
112
+ engine, dpi = options.values_at(:engine, :dpi)
113
+ engine ||= "dot"
114
+ dpi ||= 96
115
+ validate_engine!(engine)
116
+ [engine, dpi]
117
+ end
118
+
119
+ # Finds the Graphviz `dot` executable.
120
+ #
121
+ # @return [String, nil] the path to `dot`, or nil if not found
122
+ #
123
+ # ELKRB_DOT, if set to a non-empty value, is the sole candidate; a bare
124
+ # override with no directory is anchored to the working directory first
125
+ # so it resolves the same way CommandResolver.runnable? checks it.
126
+ # Resolve the chosen candidate with `File.realpath`, not
127
+ # `File.expand_path` -- expansion is lexical and gets a path crossing a
128
+ # symlinked directory wrong.
52
129
  def find_graphviz
53
- # Try common locations
54
- candidates = [
55
- "dot",
56
- "/usr/bin/dot",
57
- "/usr/local/bin/dot",
58
- "/opt/homebrew/bin/dot",
59
- "/opt/local/bin/dot",
60
- ]
61
-
62
- candidates.each do |path|
63
- if File.executable?(path)
64
- return path
65
- elsif system("which #{path} > /dev/null 2>&1")
66
- return path
67
- end
68
- end
130
+ override = ENV.fetch("ELKRB_DOT", nil)
131
+ candidates = if override.nil? || override.empty?
132
+ CANDIDATES
133
+ else
134
+ [anchor_bare_name(override)]
135
+ end
136
+ resolved = candidates.lazy
137
+ .filter_map { |each| CommandResolver.resolved_path(each) }
138
+ .first
139
+ resolved && resolve_real(resolved)
140
+ end
69
141
 
70
- nil
142
+ def resolve_real(path)
143
+ File.realpath(path)
144
+ rescue SystemCallError
145
+ path
71
146
  end
72
147
 
73
- def build_command(engine, format, input_file, output_file, dpi)
74
- cmd_parts = [
75
- @dot_path,
76
- "-K#{engine}",
77
- "-T#{format}",
78
- "-Gdpi=#{dpi}",
79
- ]
148
+ # File.basename strips a directory, so a path that already carries one
149
+ # comes back different from itself. Backslash counts as a separator on
150
+ # Windows only, where File::ALT_SEPARATOR is set. On POSIX it is nil, so
151
+ # a backslash is an ordinary character in a name and such a path anchors
152
+ # -- which is right, because there it really is a bare name.
153
+ def anchor_bare_name(path)
154
+ File.basename(path) == path ? File.join(Dir.pwd, path) : path
155
+ end
80
156
 
81
- cmd_parts << "-o#{output_file}" if output_file
82
- cmd_parts << input_file
157
+ # option_safe_path guards both the input and output paths: dot reads a
158
+ # leading-dash path as an option instead of a positional, so a file
159
+ # named "-V" would make dot print its banner and write nothing while
160
+ # reporting success. Both paths go through `File.path` first -- a
161
+ # `#to_path` object interpolated with `#to_s` instead yields
162
+ # "#<Object:0x...>", which dot then writes by that literal name.
163
+ def build_command(engine, format, input_file, output_file, dpi)
164
+ [@dot_path, "-K#{engine}", "-T#{format}", "-Gdpi=#{dpi}",
165
+ "-o", option_safe_path(File.path(output_file)),
166
+ option_safe_path(File.path(input_file))]
167
+ end
83
168
 
84
- cmd_parts.join(" ")
169
+ # Anchors ONLY a name dot would read as an option, and with File.join,
170
+ # which is purely lexical and leaves any ".." in place for the OS to
171
+ # resolve. File.absolute_path collapses ".." itself, without consulting
172
+ # the filesystem, so across a symlinked directory it names a different
173
+ # file than the OS reaches -- and macOS ships /tmp and /var as symlinks,
174
+ # so that needs no hand-made link to hit. Every other path is passed
175
+ # through byte-identical.
176
+ def option_safe_path(path)
177
+ path.start_with?("-") ? File.join(Dir.pwd, path) : path
85
178
  end
86
179
 
87
- def execute_command(cmd)
88
- success = system(cmd)
89
- unless success
90
- raise GraphvizNotFoundError,
91
- "Graphviz command failed: #{cmd}"
92
- end
180
+ def execute_command(argv)
181
+ success = system(*argv)
182
+ return success if success
93
183
 
94
- success
184
+ raise GraphvizNotFoundError, "Graphviz command failed: #{argv.inspect}"
95
185
  end
96
186
 
97
187
  def validate_format!(format)
@@ -109,12 +199,28 @@ module Elkrb
109
199
  "Supported engines: #{SUPPORTED_ENGINES.join(', ')}"
110
200
  end
111
201
 
202
+ # Only a readable regular file will do. File.exist? is also true of a
203
+ # directory, and dot handed one exits 0 having written nothing, so the
204
+ # caller printed a render that never happened.
112
205
  def validate_file_exists!(file)
113
- return if File.exist?(file)
206
+ return if File.file?(file) && File.readable?(file)
114
207
 
115
208
  raise ArgumentError, "Input file not found: #{file}"
116
209
  end
117
210
 
211
+ # Empty counts as missing; BLANK does not. Measured: `render(input,
212
+ # " ", :png)` writes a real file named " ", because three spaces
213
+ # is a legal POSIX filename -- so do not reach for `strip` here. An
214
+ # empty path, by contrast, reaches `dot` as a bare `-o` with nothing
215
+ # after it, which prints its whole usage banner to stderr and then
216
+ # fails as GraphvizNotFoundError, blaming a Graphviz that is installed
217
+ # and working.
218
+ def validate_output_file!(output_file)
219
+ return unless output_file.to_s.empty?
220
+
221
+ raise ArgumentError, "Output file path is required"
222
+ end
223
+
118
224
  def installation_message
119
225
  <<~MSG
120
226
  Graphviz is required but not found.
@@ -127,6 +233,8 @@ module Elkrb
127
233
 
128
234
  Alternatively, export to DOT format and render manually:
129
235
  elkrb diagram input.json -o output.dot
236
+
237
+ If Graphviz is installed but not on PATH, set ELKRB_DOT=/path/to/dot
130
238
  MSG
131
239
  end
132
240
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../options/registry"
4
+
3
5
  module Elkrb
4
6
  module Layout
5
7
  class AlgorithmRegistry
@@ -7,15 +9,28 @@ module Elkrb
7
9
  @metadata = {}
8
10
 
9
11
  class << self
12
+ # Resolves the same way #get does, so re-registering an algorithm
13
+ # under another spelling replaces it instead of standing up a second
14
+ # entry beside it under the folded key.
10
15
  def register(name, algorithm_class, metadata = {})
11
- name_str = name.to_s
16
+ name_str = resolve_key(name)
12
17
  @algorithms[name_str] = algorithm_class
13
18
  @metadata[name_str] = metadata
14
19
  end
15
20
 
16
21
  def get(name)
17
- algorithm_name = normalize_name(name)
18
- @algorithms[algorithm_name]
22
+ @algorithms[resolve_key(name)]
23
+ end
24
+
25
+ # @api private
26
+ # @param name [String] an algorithm name, in any spelling #get takes
27
+ # @return [String, nil] the key the algorithm is registered under, or
28
+ # nil when none is, as for a name with invalid bytes
29
+ def registered_name(name)
30
+ return unless name.to_s.valid_encoding?
31
+
32
+ key = resolve_key(name)
33
+ key if @algorithms.key?(key)
19
34
  end
20
35
 
21
36
  def available_algorithms
@@ -23,10 +38,10 @@ module Elkrb
23
38
  end
24
39
 
25
40
  def algorithm_info(name)
26
- algorithm_class = get(name)
41
+ name_str = resolve_key(name)
42
+ algorithm_class = @algorithms[name_str]
27
43
  return nil unless algorithm_class
28
44
 
29
- name_str = normalize_name(name)
30
45
  metadata = @metadata[name_str] || {}
31
46
 
32
47
  {
@@ -35,6 +50,7 @@ module Elkrb
35
50
  description: metadata[:description] || "",
36
51
  category: metadata[:category] || "general",
37
52
  supports_hierarchy: metadata[:supports_hierarchy] || false,
53
+ supported_options: supported_options_for(name_str, algorithm_class),
38
54
  }
39
55
  end
40
56
 
@@ -44,11 +60,52 @@ module Elkrb
44
60
 
45
61
  private
46
62
 
63
+ # Core (algorithms: :all) registry ids apply generically through
64
+ # BaseAlgorithm's shared mixins (padding, spacing, labels, ports,
65
+ # self-loops). A custom registration using the "compatible
66
+ # interface" escape hatch documented on .register_algorithm (not
67
+ # inheriting BaseAlgorithm) does not get those mixins for free,
68
+ # so it advertises only its own algorithm-specific ids. Checked
69
+ # via `defined?` rather than a require: base_algorithm.rb pulls
70
+ # in the full Graph model chain, which this file must not require
71
+ # just to be usable on its own (this class works standalone) —
72
+ # by the time any real caller invokes #algorithm_info, the gem's
73
+ # normal load order has already defined BaseAlgorithm anyway.
74
+ def supported_options_for(name_str, algorithm_class)
75
+ base = ::Elkrb::Layout::Algorithms::BaseAlgorithm if defined?(::Elkrb::Layout::Algorithms::BaseAlgorithm)
76
+ # #ancestors answers the same question as Class#<= and returns a real
77
+ # boolean. `<=` answers nil for an unrelated class, which the
78
+ # escape-hatch path would then hand down as a flag.
79
+ include_all = !base.nil? && algorithm_class.ancestors.include?(base)
80
+ Options::Registry.for_algorithm(name_str, include_all: include_all)
81
+ end
82
+
83
+ # Used by #get and #algorithm_info to find the key actually
84
+ # registered for `name`: try the properly snake_cased form first,
85
+ # then fall back to the plain downcased form for the handful of
86
+ # legacy run-together registrations ("mrtree", "libavoid", ...)
87
+ # that predate word-boundary folding. Falls back to the folded
88
+ # form even when unregistered, so callers always get a single,
89
+ # well-defined key back rather than nil.
90
+ def resolve_key(name)
91
+ folded = normalize_name(name)
92
+ return folded if @algorithms.key?(folded)
93
+
94
+ legacy = legacy_normalize_name(name)
95
+ @algorithms.key?(legacy) ? legacy : folded
96
+ end
97
+
47
98
  def normalize_name(name)
48
- # Support both full names and short names
49
- # e.g., "org.eclipse.elk.layered" -> "layered"
99
+ # "org.eclipse.elk.sporeOverlap" / "sporeOverlap" / "spore_overlap"
100
+ # all fold to the same "spore_overlap" key.
101
+ name = name.to_s
102
+ name = name.split(".").last.to_s if name.include?(".")
103
+ name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
104
+ end
105
+
106
+ def legacy_normalize_name(name)
50
107
  name = name.to_s
51
- name = name.split(".").last if name.include?(".")
108
+ name = name.split(".").last.to_s if name.include?(".")
52
109
  name.downcase
53
110
  end
54
111
  end
@@ -5,6 +5,7 @@ require_relative "../hierarchical_processor"
5
5
  require_relative "../label_placer"
6
6
  require_relative "../port_constraint_processor"
7
7
  require_relative "../constraints/constraint_processor"
8
+ require_relative "../../options/resolver"
8
9
 
9
10
  module Elkrb
10
11
  module Layout
@@ -20,45 +21,57 @@ module Elkrb
20
21
  include LabelPlacer
21
22
  include PortConstraintProcessor
22
23
 
23
- attr_reader :options
24
+ attr_reader :options, :resolver
24
25
 
25
26
  def initialize(options = {})
26
27
  @options = options
28
+ @resolver = Options::Resolver.new(options)
27
29
  end
28
30
 
29
- # Main layout method - automatically handles hierarchical graphs and labels
31
+ # Default a node's unset x/y to 0.0, matching Java ELK's treatment
32
+ # of an unset position. Shared by algorithms (currently SporeOverlap
33
+ # and SporeCompaction) that read node.x/node.y arithmetically before
34
+ # any other pass has had a chance to assign them. A class method
35
+ # because it depends only on its argument, never on an instance's
36
+ # state (reek: UtilityFunction fires on the instance-method form).
37
+ #
38
+ # @param nodes [Array<Elkrb::Graph::Node>] The nodes to normalize
39
+ def self.normalize_nil_positions(nodes)
40
+ nodes.each do |node|
41
+ node.x ||= 0.0
42
+ node.y ||= 0.0
43
+ end
44
+ end
45
+
46
+ # Main layout method - handles hierarchical graphs and labels.
30
47
  #
31
48
  # Subclasses should implement #layout_flat for their specific
32
- # algorithm logic. This method will automatically handle hierarchical
33
- # graphs by calling layout_hierarchical when needed, and place labels
34
- # after layout is complete.
49
+ # algorithm logic. This method sizes compound children bottom-up, then
50
+ # lays out this level and places its labels.
35
51
  #
36
52
  # @param graph [Elkrb::Graph::Graph] The graph to layout
37
53
  # @return [Elkrb::Graph::Graph] The graph with updated positions
38
54
  def layout(graph)
39
- # Apply port constraints before layout
40
- apply_port_constraints(graph)
41
-
42
- # Apply pre-layout constraints (marks nodes)
43
- apply_pre_layout_constraints(graph)
55
+ @graph = graph
44
56
 
45
- # Perform layout
46
- if option("hierarchical", false) || graph.hierarchical?
47
- layout_hierarchical(graph, @options)
48
- else
49
- layout_flat(graph, @options)
50
- end
51
-
52
- # Enforce post-layout constraints (adjust positions)
53
- enforce_post_layout_constraints(graph)
57
+ # Compound sizes must be known before their own ports or the parent
58
+ # level are laid out. Each child algorithm recursively sizes deeper
59
+ # compounds before returning.
60
+ size_compound_children(graph) if graph.hierarchical?
54
61
 
55
- # Apply edge routing
62
+ apply_port_constraints(graph)
63
+ processor = apply_pre_layout_constraints(graph)
64
+
65
+ # Only nil (children key absent from deserialized
66
+ # input) skips dispatch — an explicit empty array still reaches
67
+ # layout_flat, preserving its documented NotImplementedError
68
+ # contract for subclasses that don't override it.
69
+ layout_flat(graph, @options) if graph.children
70
+ enforce_post_layout_constraints(graph, processor)
56
71
  apply_edge_routing(graph)
57
-
58
- # Place labels after layout (unless disabled)
59
- unless option("label.placement.disabled", false)
60
- place_labels(graph)
61
- end
72
+ route_cross_level_edges(graph)
73
+ place_labels(graph) unless
74
+ option("label.placement.disabled", default: false)
62
75
 
63
76
  graph
64
77
  end
@@ -78,35 +91,33 @@ module Elkrb
78
91
 
79
92
  protected
80
93
 
81
- # Get an option value with a default fallback
94
+ # Get an option value, resolved against the graph being laid out and
95
+ # then the call-level options (see Options::Resolver for the rule).
82
96
  #
83
- # @param key [String, Symbol] The option key
84
- # @param default [Object] The default value if option is not set
97
+ # @param key [String, Symbol] The option id, alias, or custom key
98
+ # @param default [Object] Returned when nothing names the option;
99
+ # :registry means the registry default, nil means nil
85
100
  # @return [Object] The option value or default
86
- def option(key, default = nil)
87
- key_str = key.to_s
88
- @options[key_str] || @options[key.to_sym] || default
101
+ def option(key, default: :registry)
102
+ @resolver.get(key, @graph, default: default)
103
+ end
104
+
105
+ def rng
106
+ @rng ||= ::Random.new(option("elk.randomSeed").to_i)
89
107
  end
90
108
 
91
109
  # Get spacing between nodes
92
110
  #
93
111
  # @return [Float] The node spacing value
94
112
  def node_spacing
95
- option("spacing_node_node", 20.0).to_f
113
+ option("elk.spacing.nodeNode").to_f
96
114
  end
97
115
 
98
116
  # Get padding values
99
117
  #
100
118
  # @return [Hash] Padding values for top, bottom, left, right
101
119
  def padding
102
- default_padding = { top: 12, bottom: 12, left: 12, right: 12 }
103
- padding_opt = option("padding", default_padding)
104
-
105
- if padding_opt.is_a?(Hash)
106
- default_padding.merge(padding_opt)
107
- else
108
- default_padding
109
- end
120
+ option("elk.padding").to_h
110
121
  end
111
122
 
112
123
  # Calculate the bounding box for a set of nodes
@@ -116,10 +127,10 @@ module Elkrb
116
127
  def calculate_bounding_box(nodes)
117
128
  return Elkrb::Geometry::Rectangle.new(0, 0, 0, 0) if nodes.empty?
118
129
 
119
- min_x = nodes.map(&:x).min
120
- min_y = nodes.map(&:y).min
121
- max_x = nodes.map { |n| n.x + n.width }.max
122
- max_y = nodes.map { |n| n.y + n.height }.max
130
+ min_x = nodes.map { |n| n.x || 0.0 }.min
131
+ min_y = nodes.map { |n| n.y || 0.0 }.min
132
+ max_x = nodes.map { |n| (n.x || 0.0) + (n.width || 0.0) }.max
133
+ max_y = nodes.map { |n| (n.y || 0.0) + (n.height || 0.0) }.max
123
134
 
124
135
  Elkrb::Geometry::Rectangle.new(
125
136
  min_x,
@@ -153,34 +164,28 @@ module Elkrb
153
164
  #
154
165
  # @param graph [Elkrb::Graph::Graph] The graph
155
166
  def apply_edge_routing(graph)
156
- routing_style = get_edge_routing_style(graph)
157
- route_edges(graph, nil, routing_style)
167
+ route_edges(graph)
158
168
  end
159
169
 
160
- # Get edge routing style from graph options
161
- #
162
- # @param graph [Elkrb::Graph::Graph] The graph
163
- # @return [String] Routing style (ORTHOGONAL, POLYLINE, SPLINES)
164
- def get_edge_routing_style(graph)
165
- return "ORTHOGONAL" unless graph.layout_options
166
-
167
- style = graph.layout_options["elk.edgeRouting"] ||
168
- graph.layout_options["edgeRouting"] ||
169
- graph.layout_options.edge_routing ||
170
- option("elk.edgeRouting") ||
171
- option("edgeRouting")
172
-
173
- style ? style.to_s.upcase : "ORTHOGONAL"
174
- end
170
+ # Defined in EdgeRouter. Released as a protected method here, so
171
+ # subclasses override it and call super.
172
+ protected :get_edge_routing_style
175
173
 
176
174
  # Apply pre-layout constraints
177
175
  #
178
- # These constraints mark nodes for special algorithm handling.
179
- #
180
176
  # @param graph [Elkrb::Graph::Graph] The graph
177
+ # @return [Constraints::ConstraintProcessor] the processor holding the
178
+ # state #enforce_post_layout_constraints needs
181
179
  def apply_pre_layout_constraints(graph)
182
- processor = Constraints::ConstraintProcessor.new
180
+ # Only read the spacing option when a constraint needs it, so a
181
+ # graph without constraints never resolves an option it did not
182
+ # resolve before.
183
+ constrained = graph.children&.any?(&:constraints)
184
+ processor = Constraints::ConstraintProcessor.new(
185
+ spacing: constrained ? node_spacing : 0.0,
186
+ )
183
187
  processor.apply_pre_layout(graph)
188
+ processor
184
189
  end
185
190
 
186
191
  # Enforce post-layout constraints
@@ -188,18 +193,13 @@ module Elkrb
188
193
  # These constraints adjust positions after layout algorithm runs.
189
194
  #
190
195
  # @param graph [Elkrb::Graph::Graph] The graph
191
- def enforce_post_layout_constraints(graph)
192
- processor = Constraints::ConstraintProcessor.new
196
+ # @param processor [Constraints::ConstraintProcessor] the processor
197
+ # returned by #apply_pre_layout_constraints
198
+ def enforce_post_layout_constraints(graph, processor)
193
199
  processor.enforce_post_layout(graph)
194
200
 
195
- # Validate all constraints
196
- errors = processor.validate_all(graph)
197
-
198
- return if errors.empty?
199
-
200
- # Log warnings for constraint violations
201
- errors.each do |error|
202
- warn "Layout constraint violation: #{error}"
201
+ processor.validate_all(graph).each do |error|
202
+ Elkrb.logger.warn("Layout constraint violation: #{error}")
203
203
  end
204
204
  end
205
205
  end
@@ -14,33 +14,60 @@ module Elkrb
14
14
  def layout_flat(graph, _options = {})
15
15
  return graph if graph.children.nil? || graph.children.empty?
16
16
 
17
- # Get configuration
18
- aspect_ratio = option("aspect_ratio", 1.6).to_f
19
- spacing = node_spacing
20
-
21
- # Calculate number of columns based on aspect ratio
22
- num_nodes = graph.children.length
23
- cols = Math.sqrt(num_nodes * aspect_ratio).ceil
24
- cols = [cols, 1].max
25
-
26
- # Find maximum node dimensions for uniform grid
27
- max_width = graph.children.map(&:width).max
28
- max_height = graph.children.map(&:height).max
29
-
30
- # Position nodes in grid
31
- graph.children.each_with_index do |node, i|
32
- row = i / cols
33
- col = i % cols
34
-
35
- node.x = col * (max_width + spacing)
36
- node.y = row * (max_height + spacing)
17
+ aspect_ratio = option("elk.aspectRatio")
18
+ spacing = option("elk.spacing.nodeNode", default: 15.0).to_f
19
+ nodes = graph.children.sort_by do |node|
20
+ (node.width || 0.0) * (node.height || 0.0)
37
21
  end
22
+ budget = row_budget(nodes, spacing, aspect_ratio)
23
+ place_rows(nodes, spacing, budget)
38
24
 
39
25
  # Apply padding and set graph dimensions
40
26
  apply_padding(graph)
41
27
 
42
28
  graph
43
29
  end
30
+
31
+ private
32
+
33
+ def row_budget(nodes, spacing, aspect_ratio)
34
+ area = nodes.sum do |node|
35
+ ((node.width || 0.0) + spacing) *
36
+ ((node.height || 0.0) + spacing)
37
+ end
38
+ computed = Math.sqrt(area * Math.sqrt(aspect_ratio.to_f))
39
+ pair_width = nodes.first(2).sum { |node| node.width || 0.0 }
40
+ pair_width += spacing if nodes.length > 1
41
+ [computed, pair_width].max
42
+ end
43
+
44
+ def place_rows(nodes, spacing, budget)
45
+ x = 0.0
46
+ y = 0.0
47
+ row_height = 0.0
48
+
49
+ nodes.each do |node|
50
+ width = node.width || 0.0
51
+ height = node.height || 0.0
52
+ if x.positive? && (x + width) > budget
53
+ x = 0.0
54
+ y += row_height + spacing
55
+ row_height = 0.0
56
+ end
57
+
58
+ node.x = x
59
+ node.y = y
60
+ x += width + spacing
61
+ row_height = [row_height, height].max
62
+ end
63
+ end
64
+
65
+ def padding
66
+ option(
67
+ "elk.padding",
68
+ default: { left: 15.0, top: 15.0, right: 15.0, bottom: 15.0 },
69
+ ).to_h
70
+ end
44
71
  end
45
72
  end
46
73
  end