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
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "option_map"
4
+ require_relative "registry"
5
+
6
+ module Elkrb
7
+ module Options
8
+ # @api private
9
+ #
10
+ # The option maps of one element, in the order Resolver#get reads them:
11
+ # layoutOptions, the deprecated map nested in it, then properties.
12
+ # Resolver#get and UnhonouredReport both take their sources from here, so
13
+ # a source added to this list is read and reported together.
14
+ class ElementOptions
15
+ # @param element [#layout_options, #properties, nil]
16
+ # @param spellings [Spellings]
17
+ def initialize(element, spellings)
18
+ @layout = OptionMap.new(element&.layout_options, spellings)
19
+ @properties = OptionMap.new(element&.properties, spellings)
20
+ @read_order = [@layout, @layout.nested, @properties].compact
21
+ end
22
+
23
+ # @return [Object, nil] the first non-nil value for a canonical id; an
24
+ # explicit false is a value
25
+ def value(id)
26
+ @read_order.each do |map|
27
+ found = map.value(id)
28
+ return found unless found.nil?
29
+ end
30
+ nil
31
+ end
32
+
33
+ # Every canonical id the element names as an option: all keys of
34
+ # layoutOptions, nested ones included, and the registered keys of
35
+ # properties. An unknown name in properties is metadata, not an option.
36
+ #
37
+ # @return [Array<String>]
38
+ def ids
39
+ @layout.ids + @properties.own_ids.select { |id| Registry.status(id) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "decimal"
4
+
3
5
  module Elkrb
4
6
  module Options
5
7
  # ElkPadding parser for padding specifications
@@ -10,10 +12,10 @@ module Elkrb
10
12
  attr_reader :left, :top, :right, :bottom
11
13
 
12
14
  def initialize(left: 0, top: 0, right: 0, bottom: 0)
13
- @left = left.to_f
14
- @top = top.to_f
15
- @right = right.to_f
16
- @bottom = bottom.to_f
15
+ @left = Decimal.to_f(left)
16
+ @top = Decimal.to_f(top)
17
+ @right = Decimal.to_f(right)
18
+ @bottom = Decimal.to_f(bottom)
17
19
  end
18
20
 
19
21
  # Parse padding from string or hash
@@ -34,10 +36,9 @@ module Elkrb
34
36
  # @return [ElkPadding] Parsed padding object
35
37
  def self.from_hash(hash)
36
38
  new(
37
- left: hash[:left] || hash["left"] || 0,
38
- top: hash[:top] || hash["top"] || 0,
39
- right: hash[:right] || hash["right"] || 0,
40
- bottom: hash[:bottom] || hash["bottom"] || 0,
39
+ **%i[left top right bottom].to_h do |side|
40
+ [side, Decimal.component(hash, side, 0)]
41
+ end,
41
42
  )
42
43
  end
43
44
 
@@ -46,17 +47,22 @@ module Elkrb
46
47
  # @param str [String] String like "[left=2, top=3, right=3, bottom=2]"
47
48
  # @return [ElkPadding] Parsed padding object
48
49
  def self.from_string(str)
49
- # Remove brackets and split by comma
50
50
  content = str.strip.gsub(/^\[|\]$/, "")
51
- parts = {}
51
+ new(**content.split(",", -1).to_h { |entry| side_and_number(entry) })
52
+ end
52
53
 
53
- content.split(",").each do |part|
54
- key, value = part.split("=").map(&:strip)
55
- parts[key.to_sym] = value.to_f
54
+ # One "side=number" entry, validated here so a repeated side cannot
55
+ # hide a malformed earlier value; an entry without exactly one '=' is
56
+ # malformed.
57
+ def self.side_and_number(entry)
58
+ key, value, extra = entry.split("=", -1).map(&:strip)
59
+ if extra || !value
60
+ raise ArgumentError, "Invalid padding entry: #{entry.inspect}"
56
61
  end
57
62
 
58
- new(**parts)
63
+ [key.to_sym, Decimal.to_f(value)]
59
64
  end
65
+ private_class_method :side_and_number
60
66
 
61
67
  # Convert to hash
62
68
  #
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "decimal"
4
+
3
5
  module Elkrb
4
6
  module Options
5
7
  # KVector parser for coordinate pairs
@@ -10,8 +12,8 @@ module Elkrb
10
12
  attr_reader :x, :y
11
13
 
12
14
  def initialize(x, y)
13
- @x = x.to_f
14
- @y = y.to_f
15
+ @x = Decimal.to_f(x)
16
+ @y = Decimal.to_f(y)
15
17
  end
16
18
 
17
19
  # Parse KVector from string, hash, or array
@@ -32,10 +34,7 @@ module Elkrb
32
34
  # @param hash [Hash] Hash with :x and :y keys
33
35
  # @return [KVector] Parsed coordinate object
34
36
  def self.from_hash(hash)
35
- new(
36
- hash[:x] || hash["x"] || 0,
37
- hash[:y] || hash["y"] || 0,
38
- )
37
+ new(Decimal.component(hash, :x, 0), Decimal.component(hash, :y, 0))
39
38
  end
40
39
 
41
40
  # Parse from array
@@ -55,7 +54,7 @@ module Elkrb
55
54
  def self.from_string(str)
56
55
  # Remove parentheses and split by comma
57
56
  content = str.strip.gsub(/^\(|\)$/, "")
58
- parts = content.split(",").map(&:strip)
57
+ parts = content.split(",", -1).map(&:strip)
59
58
 
60
59
  unless parts.size == 2
61
60
  raise ArgumentError,
@@ -37,30 +37,22 @@ module Elkrb
37
37
 
38
38
  # Parse from string
39
39
  #
40
- # @param str [String] String like "( {1,2}, {3,4} )"
40
+ # Mirrors ELK's own KVectorChain parser: split on any run of comma,
41
+ # semicolon, and bracket/brace/paren characters (plus whitespace),
42
+ # then group the resulting tokens into pairs; KVector rejects a token
43
+ # that is not a plain decimal number. Accepts every
44
+ # bracket style elkrb previously emitted as well as ELK's own
45
+ # canonical "(x,y; x,y)" output.
46
+ #
47
+ # @param str [String] e.g. "(1,2; 3,4)", "({1,2},{3,4})", "(1,2),(3,4)"
41
48
  # @return [KVectorChain] Parsed coordinate chain object
42
49
  def self.from_string(str)
43
- # Remove outer parentheses
44
- content = str.strip.gsub(/^\(\s*|\s*\)$/, "")
45
-
46
- # Split by },{ or } , {
47
- parts = content.split(/\}\s*,\s*\{/)
48
-
49
- # Clean up first and last parts
50
- parts[0] = parts[0].sub(/^\{/, "") if parts[0]
51
- parts[-1] = parts[-1].sub(/\}$/, "") if parts[-1]
52
-
53
- # Parse each coordinate pair
54
- vectors = parts.map do |part|
55
- coords = part.split(",").map(&:strip)
56
- unless coords.size == 2
57
- raise ArgumentError,
58
- "Invalid coordinate pair: #{part}"
59
- end
60
-
61
- KVector.new(coords[0], coords[1])
50
+ tokens = str.split(/[,;()\[\]{}\s]+/).reject(&:empty?)
51
+ unless tokens.size.even?
52
+ raise ArgumentError, "Invalid KVectorChain format: #{str}"
62
53
  end
63
54
 
55
+ vectors = tokens.each_slice(2).map { |x, y| KVector.new(x, y) }
64
56
  new(vectors)
65
57
  end
66
58
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spellings"
4
+
5
+ module Elkrb
6
+ module Options
7
+ # @api private
8
+ #
9
+ # One option map read once into { canonical id => value }. Read option
10
+ # maps only through this table: it walks with each_pair, so a Hash
11
+ # default is never a value, and it folds String and Symbol keys and every
12
+ # spelling of an id. The best spelling wins, then a String over a Symbol,
13
+ # then the first inserted, which only two keys of another class can reach.
14
+ # A nil value is a miss, but its id still counts as present for the report.
15
+ class OptionMap
16
+ NESTED_KEY = "properties"
17
+ STRING_KEY = 0
18
+ OTHER_KEY = 1
19
+ private_constant :STRING_KEY, :OTHER_KEY
20
+
21
+ # @param map [Hash, nil] anything else reads as an empty map
22
+ # @param spellings [Spellings]
23
+ def initialize(map, spellings)
24
+ @spellings = spellings
25
+ @ids = {}
26
+ @best = {}
27
+ map.each_pair { |key, value| add(key, value) } if map.is_a?(Hash)
28
+ end
29
+
30
+ # @return [Object, nil] the raw value for a canonical id; nil is a miss
31
+ def value(id)
32
+ @best[id]&.last
33
+ end
34
+
35
+ # The deprecated map inside layoutOptions that older graphs nest
36
+ # options under. Only a Hash counts; it is read after the map's own
37
+ # keys.
38
+ #
39
+ # @return [OptionMap, nil]
40
+ def nested
41
+ return @nested if defined?(@nested)
42
+
43
+ raw = value(nested_id)
44
+ @nested = raw.is_a?(Hash) ? self.class.new(raw, @spellings) : nil
45
+ end
46
+
47
+ # Every canonical id the map names, nested properties included, in
48
+ # first-seen order. A nested map replaces its own "properties" key.
49
+ #
50
+ # @return [Array<String>]
51
+ def ids
52
+ inner = nested
53
+ return own_ids unless inner
54
+
55
+ own_ids - [nested_id] + inner.own_ids
56
+ end
57
+
58
+ # The canonical ids of the map's own keys, without the nested
59
+ # properties map.
60
+ #
61
+ # @return [Array<String>]
62
+ def own_ids
63
+ @ids.keys
64
+ end
65
+
66
+ private
67
+
68
+ def nested_id
69
+ @spellings.id(NESTED_KEY)
70
+ end
71
+
72
+ def add(key, value)
73
+ id, spelling_rank = @spellings.resolve(key)
74
+ @ids[id] = true
75
+ return if value.nil?
76
+
77
+ rank = [spelling_rank, key.is_a?(String) ? STRING_KEY : OTHER_KEY]
78
+ held = @best[id]
79
+ return unless held.nil? || (rank <=> held.first).negative?
80
+
81
+ @best[id] = [rank, value]
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decimal"
4
+ require_relative "elk_padding"
5
+ require_relative "k_vector"
6
+ require_relative "k_vector_chain"
7
+ require_relative "../errors"
8
+
9
+ module Elkrb
10
+ module Options
11
+ # Single source of truth for ELK/elkrb layout option metadata: id,
12
+ # type, default, allowed values, aliases, owning algorithms, and the
13
+ # elkrb 2.0 truthfulness status. Elkrb.known_layout_options,
14
+ # LayoutEngine.known_layout_options, and AlgorithmRegistry#algorithm_info
15
+ # all read from OPTIONS through the methods below — nothing else does.
16
+ class Registry
17
+ ELK_PREFIX = "org.eclipse.elk."
18
+ BOOLEAN_LITERALS = [true, false].freeze
19
+ private_constant :BOOLEAN_LITERALS
20
+ private_constant :ELK_PREFIX
21
+
22
+ # A :partial row either lists `readers`, the algorithms that read the id
23
+ # on every applicable carrier (the id is partial ACROSS algorithms), or
24
+ # lists none (it is partial WITHIN each algorithm). `carriers` narrows
25
+ # where a reader consumes the id. With `readers`, `note` holds only what
26
+ # the list leaves out; .note joins the two.
27
+ #
28
+ # The OPTIONS constant name/path is private; .all below returns
29
+ # this same frozen object, so use .all (or .canonical/.coerce/
30
+ # .default/.status/.note/.for_algorithm) rather than reaching for
31
+ # the constant directly. Rows are sorted by id; insert new rows in
32
+ # sorted position, never at the end.
33
+ # rubocop:disable Layout/LineLength
34
+ OPTIONS = {
35
+ "disco.componentAlgorithm" => { type: :string, default: "layered", namespace: :elkrb, algorithms: %w[disco], status: :honoured, description: "Algorithm used to lay out each component" },
36
+ "disco.componentArrangement" => { type: :string, values: %w[row column grid], default: "row", namespace: :elkrb, algorithms: %w[disco], status: :honoured, description: "How components are arranged" },
37
+ "disco.componentSpacing" => { type: :float, default: 20.0, namespace: :elkrb, algorithms: %w[disco], status: :honoured, description: "Spacing between components" },
38
+ "elk.algorithm" => { type: :string, default: "layered", aliases: %w[algorithm], algorithms: :all, status: :honoured, description: "The layout algorithm to use" },
39
+ "elk.aspectRatio" => { type: :float, default: 1.6, aliases: %w[aspectRatio aspect_ratio], algorithms: %w[box disco layered random], status: :honoured, description: "Target width/height ratio (box, disco, layered, random)" },
40
+ "elk.bendPoints" => { type: :kvector_chain, default: nil, aliases: %w[bendPoints], algorithms: :all, status: :partial, readers: %w[fixed], carriers: %i[edge call], note: "No other algorithm applies explicit bend points", description: "Manual bend points for an edge" },
41
+ "elk.box.packingMode" => { type: :enum, values: %w[SIMPLE GROUP_DEC GROUP_MIXED GROUP_INC], default: "SIMPLE", algorithms: %w[box], status: :accepted, description: "Box layout packing mode; SIMPLE is implemented and GROUP_* values fall back to it" },
42
+ "elk.compaction.orthogonal" => { type: :boolean, default: false, aliases: %w[compaction.orthogonal], algorithms: %w[spore_compaction], status: :honoured, description: "Whether SPOrE compaction slides subtrees along one axis only" },
43
+ "elk.direction" => { type: :enum, values: %w[UNDEFINED RIGHT LEFT DOWN UP], value_aliases: { "HORIZONTAL" => "RIGHT", "VERTICAL" => "DOWN" }, default: "RIGHT", aliases: %w[direction], algorithms: %w[layered mrtree], status: :partial, readers: %w[layered mrtree], carriers: %i[root compound call], note: "Parent and call options drive node placement; HORIZONTAL and VERTICAL are accepted elkrb spline-orientation aliases and normalize to RIGHT and DOWN; DOT export reads every registered spelling from the graph's layoutOptions", description: "Overall direction of layout" },
44
+ "elk.disco.componentCompaction.strategy" => { type: :enum, values: %w[POLYOMINO], accepted_values: %w[POLYOMINO ROW COLUMN GRID], default: "POLYOMINO", algorithms: %w[disco], status: :honoured, description: "DisCo component compaction; POLYOMINO packs components as polyominoes, with edges counted as straight lines between their end nodes. ROW, COLUMN and GRID are accepted elkrb extensions matching disco.componentArrangement" },
45
+ "elk.edgeLabels.placement" => { type: :string, default: "CENTER", algorithms: :all, status: :honoured, description: "Edge label placement" },
46
+ "elk.edgeRouting" => { type: :enum, values: %w[UNDEFINED POLYLINE ORTHOGONAL SPLINES], default: "UNDEFINED", aliases: %w[edgeRouting edge_routing edge.routing], algorithms: :all, status: :partial, readers: %w[box disco force layered mrtree radial rectpacking spore_compaction spore_overlap stress topdownpacking vertiflex], carriers: %i[root compound edge call], note: "Fixed preserves existing edge routes. libavoid routes every edge between two nodes of a level itself as an orthogonal route around the nodes and ignores the style for them; its self-loops and edges that leave the level follow the style like any other algorithm's edges", description: "Edge routing style" },
47
+ "elk.force.iterations" => { type: :integer, default: 300, aliases: %w[iterations], algorithms: %w[force], status: :honoured, description: "Force simulation iteration count" },
48
+ "elk.force.repulsion" => { type: :float, default: 5.0, aliases: %w[repulsion], algorithms: %w[force], status: :honoured, description: "Force simulation repulsion strength" },
49
+ "elk.force.temperature" => { type: :float, default: 0.001, aliases: %w[temperature], algorithms: %w[force], status: :honoured, description: "Force simulation cooling temperature" },
50
+ "elk.hierarchyHandling" => { type: :enum, values: %w[INHERIT INCLUDE_CHILDREN SEPARATE_CHILDREN], default: "INHERIT", algorithms: :all, status: :partial, note: "cross-level edges are routed; no cross-level layering", description: "Whether a compound node's children are laid out separately or together with it" },
51
+ "elk.layered.compaction.postCompaction.strategy" => { type: :enum, values: %w[NONE LEFT RIGHT LEFT_RIGHT_CONSTRAINT_LOCKING LEFT_RIGHT_CONNECTION_LOCKING EDGE_LENGTH], default: "NONE", algorithms: %w[layered], status: :accepted, description: "Post-layout compaction strategy; not honoured today" },
52
+ "elk.layered.considerModelOrder.strategy" => { type: :enum, values: %w[NONE NODES_AND_EDGES PREFER_EDGES PREFER_NODES], default: "NONE", algorithms: %w[layered], status: :honoured, description: "Whether to preserve model node/edge order as a crossing-minimization tie-break" },
53
+ "elk.layered.crossingMinimization.strategy" => { type: :enum, values: %w[LAYER_SWEEP MEDIAN_LAYER_SWEEP INTERACTIVE NONE], default: "LAYER_SWEEP", algorithms: %w[layered], status: :honoured, description: "Crossing minimization strategy; NONE skips the sweeps and keeps only the greedy switch, other values use layer sweep" },
54
+ "elk.layered.layering.layerConstraint" => { type: :enum, values: %w[NONE FIRST FIRST_SEPARATE LAST LAST_SEPARATE], default: "NONE", algorithms: %w[layered], status: :honoured, description: "Forces a node to the first or last layer" },
55
+ "elk.layered.nodePlacement.strategy" => { type: :enum, values: %w[SIMPLE INTERACTIVE LINEAR_SEGMENTS BRANDES_KOEPF NETWORK_SIMPLEX], default: "BRANDES_KOEPF", algorithms: %w[layered], status: :accepted, description: "Node placement strategy; not honoured today (elkrb places nodes with BRANDES_KOEPF only, every value falls back to it)" },
56
+ "elk.layered.spacing.nodeNodeBetweenLayers" => { type: :float, default: 20.0, aliases: %w[layer_spacing layered.spacing.nodeNodeBetweenLayers], algorithms: %w[layered], status: :honoured, description: "Spacing between layers" },
57
+ "elk.nodeLabels.placement" => { type: :string, default: "INSIDE CENTER", aliases: %w[node.label.placement label.placement], algorithms: :all, status: :honoured, description: "Node label placement" },
58
+ "elk.padding" => { type: :padding, default: "[top=12,left=12,bottom=12,right=12]", aliases: %w[padding], algorithms: :all, status: :honoured, description: "Padding around the graph" },
59
+ "elk.port.index" => { type: :integer, default: -1, aliases: %w[port.index], algorithms: :all, status: :honoured, description: "Order of a port among its side's ports" },
60
+ "elk.port.side" => { type: :enum, values: %w[NORTH SOUTH EAST WEST UNDEFINED], default: "UNDEFINED", aliases: %w[port.side], algorithms: :all, status: :honoured, description: "Side of the node a port is attached to" },
61
+ "elk.portConstraints" => { type: :enum, values: %w[UNDEFINED FREE FIXED_SIDE FIXED_ORDER FIXED_RATIO FIXED_POS], default: "UNDEFINED", aliases: %w[portConstraints], algorithms: :all, status: :honoured, description: "How strictly port positions are respected" },
62
+ "elk.portLabels.placement" => { type: :string, default: "OUTSIDE", aliases: %w[port.label.placement], algorithms: :all, status: :honoured, description: "Port label placement" },
63
+ "elk.position" => { type: :kvector, default: nil, aliases: %w[position], algorithms: %w[fixed], status: :honoured, description: "Fixed position for a node (fixed algorithm)" },
64
+ "elk.processingOrder.preferredRoot" => { type: :string, default: nil, aliases: %w[processingOrder.preferredRoot], algorithms: %w[spore_compaction], status: :honoured, description: "Id of the node the SPOrE spanning tree grows from (rootSelection FIXED)" },
65
+ "elk.processingOrder.rootSelection" => { type: :enum, values: %w[FIXED CENTER_NODE], default: "CENTER_NODE", aliases: %w[processingOrder.rootSelection], algorithms: %w[spore_compaction], status: :honoured, description: "How the SPOrE spanning tree's root node is chosen" },
66
+ "elk.processingOrder.spanningTreeCostFunction" => { type: :enum, values: %w[CENTER_DISTANCE CIRCLE_UNDERLAP RECTANGLE_UNDERLAP INVERTED_OVERLAP MINIMUM_ROOT_DISTANCE], default: "CIRCLE_UNDERLAP", aliases: %w[processingOrder.spanningTreeCostFunction], algorithms: %w[spore_compaction], status: :honoured, description: "Edge cost the SPOrE spanning tree minimises" },
67
+ "elk.processingOrder.treeConstruction" => { type: :enum, values: %w[MINIMUM_SPANNING_TREE MAXIMUM_SPANNING_TREE], default: "MINIMUM_SPANNING_TREE", aliases: %w[processingOrder.treeConstruction], algorithms: %w[spore_compaction], status: :honoured, description: "Whether the SPOrE spanning tree is a minimum or a maximum one" },
68
+ "elk.radial.centerOnRoot" => { type: :boolean, default: false, algorithms: %w[radial], status: :honoured, description: "Whether the root node is placed at the centre" },
69
+ "elk.radial.radius" => { type: :float, default: 100.0, algorithms: %w[radial], status: :honoured, description: "Radius for radial layout" },
70
+ "elk.randomSeed" => { type: :integer, default: 1, aliases: %w[randomSeed], algorithms: %w[force random], status: :honoured, description: "Seed for algorithms with random behaviour" },
71
+ "elk.selfLoopOffset" => { type: :float, default: 20.0, aliases: %w[selfLoopOffset], namespace: :elkrb, algorithms: :all, status: :partial, readers: %w[box disco force layered libavoid mrtree radial rectpacking spore_compaction spore_overlap stress topdownpacking vertiflex], carriers: %i[node edge call], note: "Fixed preserves existing edge routes", description: "elkrb-private: distance between a self-loop and its node; the nth loop on a node sits at n times this distance" },
72
+ "elk.selfLoopRouting" => { type: :string, default: nil, aliases: %w[selfLoopRouting], namespace: :elkrb, algorithms: :all, status: :accepted, description: "elkrb-private: no routing styles are defined, so every value is ignored; a self-loop follows elk.edgeRouting" },
73
+ "elk.selfLoopSide" => { type: :enum, values: %w[NORTH SOUTH EAST WEST], default: "EAST", aliases: %w[selfLoopSide], namespace: :elkrb, algorithms: :all, status: :partial, readers: %w[box disco force layered libavoid mrtree radial rectpacking spore_compaction spore_overlap stress topdownpacking vertiflex], carriers: %i[node edge call], note: "Fixed preserves existing edge routes", description: "elkrb-private: side a self-loop is drawn on" },
74
+ "elk.separateConnectedComponents" => { type: :boolean, default: true, algorithms: %w[layered], status: :honoured, description: "Whether each connected component is laid out on its own and the components are packed in rows" },
75
+ "elk.spacing.componentComponent" => { type: :float, default: 20.0, algorithms: %w[disco layered], status: :honoured, description: "Spacing between disconnected components" },
76
+ "elk.spacing.edgeEdge" => { type: :float, default: 10.0, algorithms: %w[layered], status: :accepted, description: "Spacing between two edges; not honoured today" },
77
+ "elk.spacing.edgeNode" => { type: :float, default: 10.0, algorithms: %w[layered], status: :accepted, description: "Spacing between an edge and a node it does not connect to; not honoured today" },
78
+ "elk.spacing.nodeNode" => { type: :float, default: 20.0, aliases: %w[spacing.nodeNode spacing_node_node], algorithms: :all, status: :partial, readers: %w[box force layered mrtree random rectpacking spore_compaction topdownpacking vertiflex], carriers: %i[root compound call], note: "Every reader takes it under every spelling, from the call options and from the layoutOptions of the graph or compound node it lays out. libavoid also reads it, unless every node has an input position; disco reads it only from a compound node's layoutOptions. SPOrE compaction reads it unless spore.nodeSpacing is set, and moves nothing while no node has an input position. No other algorithm reads it", description: "Spacing between nodes" },
79
+ "elk.spline.curvature" => { type: :float, default: 0.5, aliases: %w[spline.curvature], namespace: :elkrb, algorithms: :all, status: :partial, readers: %w[box disco force layered rectpacking spore_compaction spore_overlap stress topdownpacking vertiflex], carriers: %i[edge call], note: "Fixed preserves routes; MRTree and Radial replace generic spline routing; libavoid does not apply it to edges between two nodes of a level, which it routes itself as orthogonal routes", description: "elkrb-private: curvature factor for SPLINES routing" },
80
+ "elk.stress.desiredEdgeLength" => { type: :float, default: 100.0, algorithms: %w[stress], status: :honoured, description: "Desired edge length for stress majorization" },
81
+ "elk.stress.epsilon" => { type: :float, default: 0.001, aliases: %w[epsilon], algorithms: %w[stress], status: :honoured, description: "Stress majorization convergence threshold" },
82
+ "elk.stress.iterationLimit" => { type: :integer, default: 2_147_483_647, algorithms: %w[stress], status: :honoured, description: "Stress majorization iteration limit" },
83
+ "label.margin" => { type: :float, default: 5.0, namespace: :elkrb, algorithms: :all, status: :honoured, description: "elkrb-private: outer label margin" },
84
+ "label.padding" => { type: :float, default: 5.0, namespace: :elkrb, algorithms: :all, status: :honoured, description: "elkrb-private: inner label padding" },
85
+ "label.placement.disabled" => { type: :boolean, default: false, namespace: :elkrb, algorithms: :all, status: :honoured, description: "elkrb-private: skip automatic label placement entirely" },
86
+ "libavoid.bendPenalty" => { type: :float, default: 2.0, namespace: :elkrb, algorithms: %w[libavoid], status: :honoured, description: "Penalty per bend in a routed connector" },
87
+ "libavoid.maxExpansions" => { type: :integer, default: 2000, namespace: :elkrb, algorithms: %w[libavoid], status: :honoured, description: "Search expansions per edge before falling back to a direct line" },
88
+ "libavoid.routingPadding" => { type: :float, default: 10.0, namespace: :elkrb, algorithms: %w[libavoid], status: :honoured, description: "Obstacle padding for connector routing" },
89
+ "libavoid.segmentPenalty" => { type: :float, default: 1.0, namespace: :elkrb, algorithms: %w[libavoid], status: :honoured, description: "Penalty per routed segment" },
90
+ "libavoid.stepSize" => { type: :float, default: 10.0, namespace: :elkrb, algorithms: %w[libavoid], status: :honoured, description: "Grid step of the connector search" },
91
+ "spore.compactionDirection" => { type: :string, values: %w[both horizontal vertical], default: "both", namespace: :elkrb, algorithms: %w[spore_compaction], status: :honoured, description: "Axis SPOrE compaction slides subtrees along; both is ELK's own behaviour" },
92
+ "spore.maxIterations" => { type: :integer, default: 64, namespace: :elkrb, algorithms: %w[spore_overlap], status: :honoured, description: "Maximum overlap-removal iterations" },
93
+ "spore.nodeSpacing" => { type: :float, default: 10.0, namespace: :elkrb, algorithms: %w[spore_overlap spore_compaction], status: :honoured, description: "Minimum spacing enforced by the SPOrE algorithms" },
94
+ "topdownpacking.aspectRatio" => { type: :float, default: 1.0, namespace: :elkrb, algorithms: %w[topdownpacking], status: :honoured, description: "Target cell aspect ratio" },
95
+ "topdownpacking.nodeWidth" => { type: :float, default: nil, namespace: :elkrb, algorithms: %w[topdownpacking], status: :honoured, description: "Explicit node width override" },
96
+ "vertiflex.balanceColumns" => { type: :boolean, default: true, namespace: :elkrb, algorithms: %w[vertiflex], status: :honoured, description: "Balance node counts across columns" },
97
+ "vertiflex.columnCount" => { type: :integer, default: 3, namespace: :elkrb, algorithms: %w[vertiflex], status: :honoured, description: "Number of columns" },
98
+ "vertiflex.columnSpacing" => { type: :float, default: 50.0, namespace: :elkrb, algorithms: %w[vertiflex], status: :honoured, description: "Spacing between columns" },
99
+ "vertiflex.verticalSpacing" => { type: :float, default: 30.0, namespace: :elkrb, algorithms: %w[vertiflex], status: :honoured, description: "Spacing between nodes within a column" },
100
+ }.freeze
101
+ # rubocop:enable Layout/LineLength
102
+ private_constant :OPTIONS
103
+
104
+ # LayoutEngine, not the algorithm, honours the selector — so it applies
105
+ # to every registration, including a custom class that inherits no
106
+ # BaseAlgorithm mixins and therefore gets none of the shared ids.
107
+ ENGINE_OPTIONS = %w[elk.algorithm].freeze
108
+ private_constant :ENGINE_OPTIONS
109
+
110
+ OPTIONS.each_value do |entry|
111
+ entry.freeze
112
+ entry[:aliases]&.freeze
113
+ entry[:values]&.freeze
114
+ entry[:accepted_values]&.freeze
115
+ entry[:value_aliases]&.freeze
116
+ entry[:algorithms].freeze if entry[:algorithms].is_a?(Array)
117
+ entry[:readers]&.freeze
118
+ entry[:carriers]&.freeze
119
+ end
120
+
121
+ ALIAS_LOOKUP = OPTIONS.each_with_object({}) do |(id, entry), lookup|
122
+ Array(entry[:aliases]).each { |a| lookup[a] = id }
123
+ end.freeze
124
+ private_constant :ALIAS_LOOKUP
125
+
126
+ class << self
127
+ # @param key [String, Symbol] any id, alias, or ELK-prefixed id
128
+ # @return [String, nil] the canonical id, or nil if unknown
129
+ def canonical(key)
130
+ key_str = key.to_s
131
+ return key_str if OPTIONS.key?(key_str)
132
+
133
+ if key_str.start_with?(ELK_PREFIX)
134
+ stripped = key_str.sub(ELK_PREFIX, "elk.")
135
+ return stripped if OPTIONS.key?(stripped)
136
+ end
137
+
138
+ return ALIAS_LOOKUP[key_str] if ALIAS_LOOKUP.key?(key_str)
139
+
140
+ suffix_match(key_str)
141
+ end
142
+
143
+ # @param id [String, Symbol] any id or alias
144
+ # @param value [Object] the raw value to coerce
145
+ # @return [Object] value coerced to the id's registered type
146
+ def coerce(id, value)
147
+ entry = entry_for(id)
148
+ return value unless entry
149
+
150
+ coerce_typed(entry[:type], value, entry, canonical(id))
151
+ end
152
+
153
+ # @param id [String, Symbol] any id or alias
154
+ # @return [Object, nil] the id's default, coerced to its type
155
+ def default(id)
156
+ entry = entry_for(id)
157
+ return nil unless entry
158
+ return nil if entry[:default].nil?
159
+
160
+ coerce_typed(entry[:type], entry[:default], entry, canonical(id))
161
+ end
162
+
163
+ # @param id [String, Symbol] any id or alias
164
+ # @return [Symbol, nil] :honoured, :partial, :accepted, :unsupported, or nil if unknown
165
+ def status(id)
166
+ entry_for(id)&.[](:status)
167
+ end
168
+
169
+ # @param id [String, Symbol] any id or alias
170
+ # @return [String, nil] explanatory note for a :partial id; for an id
171
+ # with readers it names them first
172
+ def note(id)
173
+ entry = entry_for(id)
174
+ return unless entry
175
+ return entry[:note] unless entry[:readers]
176
+
177
+ "Read by #{entry[:readers].join(', ')}. #{entry[:note]}"
178
+ end
179
+
180
+ # @param id [String, Symbol] any id or alias
181
+ # @param algorithm [String, nil] a normalised algorithm name
182
+ # @param carrier [Symbol, nil] where the option is written; nil checks
183
+ # only algorithm readership
184
+ # @return [Boolean] whether the id lists the algorithm among its
185
+ # readers; false for an id with no readers. Readers are the
186
+ # built-in algorithms, matched by registered name: a class
187
+ # registered under another name is not one, and a class registered
188
+ # over a built-in name is taken to read what that name reads.
189
+ def read_by?(id, algorithm, carrier: nil)
190
+ entry = entry_for(id)
191
+ return false unless Array(entry&.[](:readers)).include?(algorithm)
192
+
193
+ carrier.nil? || Array(entry[:carriers]).include?(carrier)
194
+ end
195
+
196
+ # Membership, not truthfulness: an id's presence here means it's
197
+ # scoped to this algorithm, regardless of whether it's currently
198
+ # honoured, accepted, or unsupported — cross-reference #status(id)
199
+ # for that.
200
+ #
201
+ # @param name [String] a normalised algorithm name (e.g. "layered")
202
+ # @param include_all [Boolean] whether to include algorithms: :all
203
+ # ids (AlgorithmRegistry passes false for a registration that
204
+ # does not inherit BaseAlgorithm, so does not get those for free)
205
+ # @return [Array<String>] canonical ids scoped to that algorithm
206
+ def for_algorithm(name, include_all: true)
207
+ OPTIONS.select do |id, entry|
208
+ ENGINE_OPTIONS.include?(id) ||
209
+ (include_all && entry[:algorithms] == :all) ||
210
+ Array(entry[:algorithms]).include?(name)
211
+ end.keys
212
+ end
213
+
214
+ # @return [Hash] the full, frozen options table
215
+ def all
216
+ OPTIONS
217
+ end
218
+
219
+ # Renders OPTIONS into the documented shape Elkrb.known_layout_options
220
+ # and LayoutEngine.known_layout_options both return. The single
221
+ # owner of that rendering — the two callers are one-line
222
+ # delegators, mirroring how they already both delegate straight to
223
+ # AlgorithmRegistry.all_algorithm_info for known_layout_algorithms.
224
+ #
225
+ # @param algorithm_values [Array<String>] registered algorithm ids,
226
+ # patched onto the elk.algorithm row's :values. Passed in rather
227
+ # than read from AlgorithmRegistry directly, so Options never
228
+ # depends on Layout.
229
+ # @return [Hash{String => Hash}] canonical id => {type:, description:,
230
+ # default:, values:, parser:, status:, note:}
231
+ def render_known_options(algorithm_values:)
232
+ parsers = {
233
+ padding: "Elkrb::Options::ElkPadding",
234
+ kvector: "Elkrb::Options::KVector",
235
+ kvector_chain: "Elkrb::Options::KVectorChain",
236
+ }
237
+
238
+ rendered = OPTIONS.each_with_object({}) do |(id, entry), hash|
239
+ hash[id] = {
240
+ type: entry[:type],
241
+ description: entry[:description],
242
+ default: entry[:default],
243
+ values: entry[:values],
244
+ parser: parsers[entry[:type]],
245
+ status: entry[:status],
246
+ note: note(id),
247
+ }
248
+ end
249
+
250
+ rendered["elk.algorithm"][:values] = algorithm_values
251
+ rendered
252
+ end
253
+
254
+ private
255
+
256
+ # canonical(id) already checks OPTIONS.key?(key_str) as its very
257
+ # first step, so when it returns nil, OPTIONS[id.to_s] is
258
+ # provably nil too — no `|| id.to_s` fallback needed here.
259
+ def entry_for(id)
260
+ OPTIONS[canonical(id)]
261
+ end
262
+
263
+ def suffix_match(key_str)
264
+ matches = OPTIONS.keys.select { |id| id.end_with?(".#{key_str}") }
265
+ matches.first if matches.size == 1
266
+ end
267
+
268
+ def coerce_typed(type, value, entry, id)
269
+ case type
270
+ when :float then value.to_f
271
+ when :integer then value.to_i
272
+ when :string then value.to_s
273
+ when :boolean then coerce_boolean(value)
274
+ when :enum then coerce_enum(value, entry, id)
275
+ when :padding then coerce_padding(value, entry[:default])
276
+ when :kvector then Options::KVector.parse(value)
277
+ when :kvector_chain then Options::KVectorChain.parse(value)
278
+ else value
279
+ end
280
+ end
281
+
282
+ def coerce_enum(value, entry, id)
283
+ normalized = value.to_s.upcase
284
+ normalized = entry.fetch(:value_aliases, {}).fetch(
285
+ normalized, normalized
286
+ )
287
+ allowed = entry[:accepted_values] || entry[:values]
288
+ return normalized if allowed.include?(normalized)
289
+
290
+ raise Elkrb::ValidationError,
291
+ "Invalid value for #{id}: #{normalized}; " \
292
+ "allowed values: #{allowed.join(', ')}"
293
+ end
294
+
295
+ # Strict boolean-env semantics: only true itself, or the literal
296
+ # string "true" (case-insensitive), coerces to true. "1", "yes",
297
+ # and everything else — including malformed input — coerce to
298
+ # false; no numeric or word aliases for "true" are recognised.
299
+ def coerce_boolean(value)
300
+ # `value` stays the receiver so a delegator wrapping a boolean answers
301
+ # for itself and comes back unwrapped. Both rewrites rubocop offers
302
+ # here -- Array#include? and any?(value) -- compare the other way
303
+ # round, and one of them swaps == for ===, so neither is equivalent.
304
+ # rubocop:disable Performance/RedundantEqualityComparisonBlock
305
+ return value if BOOLEAN_LITERALS.any? { |literal| value == literal }
306
+ # rubocop:enable Performance/RedundantEqualityComparisonBlock
307
+
308
+ value.to_s.strip.casecmp("true").zero?
309
+ end
310
+
311
+ def coerce_padding(value, default_string)
312
+ return value if value.is_a?(ElkPadding)
313
+ return ElkPadding.parse(value) if value.is_a?(String)
314
+
315
+ if value.is_a?(Numeric)
316
+ return ElkPadding.new(top: value, left: value, bottom: value,
317
+ right: value)
318
+ end
319
+
320
+ unless value.is_a?(Hash)
321
+ raise ArgumentError,
322
+ "Invalid padding value: #{value.inspect}"
323
+ end
324
+
325
+ fallback = ElkPadding.parse(default_string)
326
+ ElkPadding.new(
327
+ **fallback.to_h.to_h do |side, default|
328
+ [side, Decimal.component(value, side, default)]
329
+ end,
330
+ )
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end