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
@@ -10,17 +10,21 @@ module Elkrb
10
10
  attribute :y, :float
11
11
  attribute :width, :float
12
12
  attribute :height, :float
13
+ attribute :labels, Label, collection: true
14
+ attribute :ports, Port, collection: true
13
15
  attribute :children, Node, collection: true
14
16
  attribute :edges, Edge, collection: true
15
- attribute :layout_options, LayoutOptions
17
+ attribute :layout_options, :hash
16
18
  attribute :properties, :hash
17
19
 
18
- json do
20
+ key_value do
19
21
  map "id", to: :id
20
22
  map "x", to: :x
21
23
  map "y", to: :y
22
24
  map "width", to: :width
23
25
  map "height", to: :height
26
+ map "labels", to: :labels
27
+ map "ports", to: :ports
24
28
  map "children", to: :children
25
29
  map "edges", to: :edges
26
30
  map "layoutOptions", to: :layout_options
@@ -33,6 +37,8 @@ module Elkrb
33
37
  map "y", to: :y
34
38
  map "width", to: :width
35
39
  map "height", to: :height
40
+ map "labels", to: :labels
41
+ map "ports", to: :ports
36
42
  map "children", to: :children
37
43
  map "edges", to: :edges
38
44
  map "layout_options", to: :layout_options
@@ -46,10 +52,28 @@ module Elkrb
46
52
  @y ||= 0.0
47
53
  @width ||= 0.0
48
54
  @height ||= 0.0
49
- @children ||= []
50
- @edges ||= []
55
+ set_collection_defaults
51
56
  @properties ||= {}
52
- @layout_options ||= LayoutOptions.new
57
+ @layout_options ||= {}
58
+ end
59
+
60
+ def set_collection_defaults
61
+ @labels = [] if @labels.nil?
62
+ @ports = [] if @ports.nil?
63
+ @children = [] if @children.nil?
64
+ @edges = [] if @edges.nil?
65
+ end
66
+ private :set_collection_defaults
67
+
68
+ # Normalizes a Symbol key however the options arrive — a constructor,
69
+ # a plain setter, or lutaml's own deserialization, which routes through
70
+ # here too. This is the single normalization point: a :hash-typed
71
+ # attribute otherwise stores a raw, unnormalized Hash.
72
+ def layout_options=(value)
73
+ value_set_for(:layout_options)
74
+ attr = self.class.attributes(lutaml_register)[:layout_options]
75
+ cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
76
+ instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
53
77
  end
54
78
 
55
79
  def find_node(node_id)
@@ -69,21 +93,25 @@ module Elkrb
69
93
  end
70
94
 
71
95
  def all_edges
72
- edges = @edges.dup
73
- @children.each do |child|
74
- edges.concat(child.edges) if child.respond_to?(:edges)
75
- next unless child.respond_to?(:children)
76
-
77
- child.children.each do |grandchild|
78
- edges.concat(grandchild.all_edges) if
79
- grandchild.respond_to?(:all_edges)
80
- end
96
+ edges = (@edges || []).dup
97
+ (@children || []).each do |child|
98
+ edges.concat(descendant_edges(child))
81
99
  end
82
100
  edges
83
101
  end
84
102
 
85
103
  def hierarchical?
86
- @children.any?(&:hierarchical?)
104
+ (@children || []).any?(&:hierarchical?)
105
+ end
106
+
107
+ private
108
+
109
+ def descendant_edges(node)
110
+ edges = (node.edges || []).dup
111
+ (node.children || []).each do |child|
112
+ edges.concat(descendant_edges(child))
113
+ end
114
+ edges
87
115
  end
88
116
  end
89
117
  end
@@ -11,9 +11,10 @@ module Elkrb
11
11
  attribute :y, :float
12
12
  attribute :width, :float
13
13
  attribute :height, :float
14
- attribute :layout_options, LayoutOptions
14
+ attribute :layout_options, :hash
15
+ attribute :properties, :hash
15
16
 
16
- json do
17
+ key_value do
17
18
  map "id", to: :id
18
19
  map "text", to: :text
19
20
  map "x", to: :x
@@ -21,6 +22,7 @@ module Elkrb
21
22
  map "width", to: :width
22
23
  map "height", to: :height
23
24
  map "layoutOptions", to: :layout_options
25
+ map "properties", to: :properties
24
26
  end
25
27
 
26
28
  yaml do
@@ -31,6 +33,7 @@ module Elkrb
31
33
  map "width", to: :width
32
34
  map "height", to: :height
33
35
  map "layout_options", to: :layout_options
36
+ map "properties", to: :properties
34
37
  end
35
38
 
36
39
  def initialize(**attributes)
@@ -40,6 +43,17 @@ module Elkrb
40
43
  @width ||= 0.0
41
44
  @height ||= 0.0
42
45
  end
46
+
47
+ # Normalizes a Symbol key however the options arrive — a constructor,
48
+ # a plain setter, or lutaml's own deserialization, which routes through
49
+ # here too. This is the single normalization point: a :hash-typed
50
+ # attribute otherwise stores a raw, unnormalized Hash.
51
+ def layout_options=(value)
52
+ value_set_for(:layout_options)
53
+ attr = self.class.attributes(lutaml_register)[:layout_options]
54
+ cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
55
+ instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
56
+ end
43
57
  end
44
58
  end
45
59
  end
@@ -15,11 +15,11 @@ module Elkrb
15
15
  attribute :ports, Port, collection: true
16
16
  attribute :children, Node, collection: true
17
17
  attribute :edges, Edge, collection: true
18
- attribute :layout_options, LayoutOptions
18
+ attribute :layout_options, :hash
19
19
  attribute :constraints, NodeConstraints
20
20
  attribute :properties, :hash
21
21
 
22
- json do
22
+ key_value do
23
23
  map "id", to: :id
24
24
  map "x", to: :x
25
25
  map "y", to: :y
@@ -49,6 +49,17 @@ module Elkrb
49
49
  map "properties", to: :properties
50
50
  end
51
51
 
52
+ # Normalizes a Symbol key however the options arrive — a constructor,
53
+ # a plain setter, or lutaml's own deserialization, which routes through
54
+ # here too. This is the single normalization point: a :hash-typed
55
+ # attribute otherwise stores a raw, unnormalized Hash.
56
+ def layout_options=(value)
57
+ value_set_for(:layout_options)
58
+ attr = self.class.attributes(lutaml_register)[:layout_options]
59
+ cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
60
+ instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
61
+ end
62
+
52
63
  def hierarchical?
53
64
  @children && !@children.empty?
54
65
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
+ require_relative "read_only_mapping"
4
5
 
5
6
  module Elkrb
6
7
  module Graph
@@ -15,7 +16,7 @@ module Elkrb
15
16
  attribute :x, :float, default: -> { 0.0 }
16
17
  attribute :y, :float, default: -> { 0.0 }
17
18
 
18
- json do
19
+ key_value do
19
20
  map "x", to: :x
20
21
  map "y", to: :y
21
22
  end
@@ -60,6 +61,8 @@ module Elkrb
60
61
  # )
61
62
  # # Node positioned 150px right of backend_service
62
63
  class NodeConstraints < Lutaml::Model::Serializable
64
+ include ReadOnlyMapping
65
+
63
66
  attribute :fixed_position, :boolean, default: -> { false }
64
67
  attribute :layer, :integer
65
68
  attribute :align_group, :string
@@ -68,7 +71,29 @@ module Elkrb
68
71
  attribute :relative_offset, RelativeOffset
69
72
  attribute :position_priority, :integer, default: -> { 0 }
70
73
 
71
- json do
74
+ # Legacy camelCase YAML spellings, still readable so documents written
75
+ # against the old mapping keep working. Output is snake_case only.
76
+ LEGACY_YAML_KEYS = {
77
+ "fixedPosition" => :fixed_position,
78
+ "alignGroup" => :align_group,
79
+ "alignDirection" => :align_direction,
80
+ "relativeTo" => :relative_to,
81
+ "relativeOffset" => :relative_offset,
82
+ "positionPriority" => :position_priority,
83
+ }.freeze
84
+ private_constant :LEGACY_YAML_KEYS
85
+
86
+ # Every snake_case key, applied by the same hook just before the legacy
87
+ # spellings so a legacy spelling still wins.
88
+ CANONICAL_YAML_KEYS =
89
+ LEGACY_YAML_KEYS.values.to_h { |name| [name.to_s, name] }
90
+ .merge("layer" => :layer).freeze
91
+ private_constant :CANONICAL_YAML_KEYS
92
+
93
+ ALL_YAML_KEYS = CANONICAL_YAML_KEYS.merge(LEGACY_YAML_KEYS).freeze
94
+ private_constant :ALL_YAML_KEYS
95
+
96
+ key_value do
72
97
  map "fixedPosition", to: :fixed_position
73
98
  map "layer", to: :layer
74
99
  map "alignGroup", to: :align_group
@@ -78,14 +103,100 @@ module Elkrb
78
103
  map "positionPriority", to: :position_priority
79
104
  end
80
105
 
106
+ # YAML is snake_case like every other model here. The camelCase spellings
107
+ # stay readable so existing documents keep working, and a legacy spelling
108
+ # wins when a document carries both -- but only once the canonical rules
109
+ # have run. They run first, so a canonical value that fails to cast or
110
+ # validate raises before the hook is reached: `align_direction: sideways`
111
+ # alongside a valid `alignDirection: horizontal` raises rather than
112
+ # yielding "horizontal". The base had no snake_case key at all, so it
113
+ # could not hit this.
114
+ #
115
+ # The hook also re-applies every snake_case key, just before the legacy
116
+ # ones. Newer lutaml 0.8.x releases no longer run the cardinality check
117
+ # or the public setter for a canonical key (`layer: [1]` parses, and
118
+ # `align_direction: sideways` is stored), so the hook does both for the
119
+ # canonical spelling as well, and the two spellings reject the same input
120
+ # (an array for align_direction fails with a different error class).
121
+ #
122
+ # For every value shape -- scalar, wrong-typed, array and blank -- the
123
+ # hook casts through the attribute the way lutaml's own deserializer
124
+ # does, applies the same cardinality check, and assigns through the
125
+ # public setter, so it produces the same Ruby types and runs
126
+ # align_direction's validation.
127
+ #
128
+ # Two things about the shape of the rule, neither of them free choices:
129
+ #
130
+ # * It is ONE root-document rule, not six per-key ones. A per-key custom
131
+ # rule is dropped before its hook runs when its value is blank
132
+ # (key_value/transform.rb:251), which would turn `alignDirection: ''`
133
+ # from a parse error into a silent nil. A plain `map ... to:` alias is
134
+ # no good either -- it fires even when its key is absent, overwriting a
135
+ # canonical-only read: with nil for an attribute that has no default,
136
+ # and with the declared default otherwise (`fixed_position` back to
137
+ # false, `position_priority` back to 0).
138
+ # * Its `to:` names a sentinel rather than a real attribute. lutaml
139
+ # unwraps a root rule when the document has exactly one key equal to
140
+ # `to:`, handing over that key's VALUE instead of the document
141
+ # (services/rule_value_extractor.rb:36-47). It does that whatever the
142
+ # value's type, whether or not `to:` names an attribute, and for the
143
+ # key spelled as EITHER a String or a Symbol -- line 41 is the
144
+ # size == 1 guard, line 43 tests the two spellings. So
145
+ # the sentinel REDUCES the collision to documents whose sole key is
146
+ # `__legacy_yaml_aliases` in either spelling; it does not eliminate it.
147
+ # Naming a real attribute instead would collide on an ordinary key such
148
+ # as `layer`, which is why the sentinel is still the better target. The
149
+ # guard in the hook makes a NONBLANK NON-HASH collision inert -- a
150
+ # scalar or a nonempty Array, neither of which answers to `key?`; a
151
+ # nonempty Hash is still merged as though it were the document, which
152
+ # is an accepted residual pinned in the spec. A blank unwrapped value
153
+ # never reaches the hook at all -- transform.rb:251 drops the rule via
154
+ # Utils.present? (utils.rb:119) for nil, "", [] and {}.
155
+ #
156
+ # `to:` cannot simply be dropped: without it `nil.to_sym` raises for a
157
+ # single-key document -- except a sole
158
+ # empty-string key, which matches `nil.to_s` and short-circuits first.
159
+ # A custom-method rule does not require the attribute to exist
160
+ # (valid_rule? is `attribute || rule.custom_methods[:from]`).
81
161
  yaml do
82
- map "fixedPosition", to: :fixed_position
162
+ map "fixed_position", to: :fixed_position
83
163
  map "layer", to: :layer
84
- map "alignGroup", to: :align_group
85
- map "alignDirection", to: :align_direction
86
- map "relativeTo", to: :relative_to
87
- map "relativeOffset", to: :relative_offset
88
- map "positionPriority", to: :position_priority
164
+ map "align_group", to: :align_group
165
+ map "align_direction", to: :align_direction
166
+ map "relative_to", to: :relative_to
167
+ map "relative_offset", to: :relative_offset
168
+ map "position_priority", to: :position_priority
169
+ # Declared last, so a legacy spelling wins. See the note above the
170
+ # block for why this is one rule and why `to:` names no attribute.
171
+ map nil, to: :__legacy_yaml_aliases,
172
+ with: { from: :__elkrb_merge_legacy_aliases,
173
+ to: :__elkrb_omit_from_output }
174
+ end
175
+
176
+ # Serialization hook for every legacy camelCase spelling. Receives the
177
+ # whole YAML document, so a key that is present with a blank value is
178
+ # still seen. Public because lutaml invokes it with `public_send`; the
179
+ # `__elkrb_` prefix keeps a subclass from taking the name by accident.
180
+ # Not part of the supported API.
181
+ #
182
+ # @api private
183
+ def __elkrb_merge_legacy_aliases(model, doc)
184
+ # Only a NONBLANK value reaches here: transform.rb:251 drops the
185
+ # rule for nil, "", [] and {} before the hook runs. So a non-Hash
186
+ # arrives only when lutaml unwrapped a sole `__legacy_yaml_aliases`
187
+ # key holding a nonblank non-Hash value -- a scalar or a nonempty
188
+ # Array -- and ignoring it keeps that case inert. A nonempty Hash is
189
+ # still merged -- see the note above `yaml do`.
190
+ return unless doc.respond_to?(:key?) && doc.respond_to?(:[])
191
+
192
+ ALL_YAML_KEYS.each do |key_name, name|
193
+ # The standard YAML adapter preserves Symbol keys, and lutaml's own
194
+ # mappings resolve either spelling, so both are honoured here.
195
+ key = [key_name, key_name.to_sym].find { |candidate| doc.key?(candidate) }
196
+ next unless key
197
+
198
+ model.public_send(:"#{name}=", cast_legacy(model, name, doc[key]))
199
+ end
89
200
  end
90
201
 
91
202
  # Valid alignment directions
@@ -102,6 +213,26 @@ module Elkrb
102
213
  end
103
214
  @align_direction = value&.to_s&.downcase
104
215
  end
216
+
217
+ private
218
+
219
+ # Mirrors what lutaml's own deserializer does for a canonical
220
+ # `map ... to:` rule (key_value/transform.rb:261-266): cast through the
221
+ # attribute -- wired to the same parent and root -- then apply the
222
+ # cardinality check. Skipping the cast leaks lutaml wrapper objects into
223
+ # public attributes; skipping valid_collection! lets an array through for
224
+ # a scalar attribute, which the canonical spelling rejects and which
225
+ # reaches the layout engine as an Array.
226
+ def cast_legacy(model, name, value)
227
+ register = model.lutaml_register
228
+ attribute = model.class.attributes(register)[name]
229
+ cast = attribute.cast(
230
+ value, :yaml, register,
231
+ lutaml_parent: model, lutaml_root: model.lutaml_root || model
232
+ )
233
+ attribute.valid_collection!(cast, model.class)
234
+ cast
235
+ end
105
236
  end
106
237
  end
107
238
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ # Reopened only to attach NormalizeOptionKeys below. Graph is elkrb's data
5
+ # model namespace, collectively defined across lib/elkrb/graph/*.rb --
6
+ # there is no single file that owns it.
7
+ module Graph
8
+ # Normalizes the result of `attr.cast_value` for an options map, and
9
+ # rejects the one shape that result can take when a caller used a key
10
+ # lutaml-model has reserved.
11
+ #
12
+ # A non-Hash here is an INFERENCE, not a direct observation: lutaml reads
13
+ # a bare `text` or `elements` key as its own wrapper and casts the map
14
+ # down to that key's value, so anything but a Hash means a reserved key
15
+ # was used. Saying so beats letting the next line fail with a bare
16
+ # NoMethodError. The message is pinned by a matcher in
17
+ # spec/elkrb/graph/layout_options_spec.rb and must stay verbatim.
18
+ #
19
+ # Stringifies the TOP LEVEL only, matching the constructor this replaces.
20
+ # Deliberately not DeepStringifyKeys: that one walks nested Hashes and
21
+ # Arrays, this never did, and the models already ran it on the way in.
22
+ module NormalizeOptionKeys
23
+ module_function
24
+
25
+ def call(cast)
26
+ return cast if cast.nil?
27
+
28
+ unless cast.is_a?(::Hash)
29
+ raise Elkrb::ValidationError,
30
+ "layoutOptions cannot use `text` or `elements` as a key; " \
31
+ "both are reserved by lutaml-model"
32
+ end
33
+
34
+ cast.transform_keys(&:to_s)
35
+ end
36
+ end
37
+ private_constant :NormalizeOptionKeys
38
+ end
39
+ end
@@ -11,11 +11,11 @@ module Elkrb
11
11
  attribute :width, :float
12
12
  attribute :height, :float
13
13
  attribute :labels, Label, collection: true
14
- attribute :layout_options, LayoutOptions
14
+ attribute :layout_options, :hash
15
15
  attribute :properties, :hash
16
- attribute :side, :string, default: -> { "UNDEFINED" }
17
- attribute :index, :integer, default: -> { -1 }
18
- attribute :offset, :float, default: -> { 0.0 }
16
+ attribute :side, :string
17
+ attribute :index, :integer
18
+ attribute :offset, :float
19
19
 
20
20
  # Port sides
21
21
  NORTH = "NORTH"
@@ -29,7 +29,7 @@ module Elkrb
29
29
  # Node reference (not serialized)
30
30
  attr_accessor :node
31
31
 
32
- json do
32
+ key_value do
33
33
  map "id", to: :id
34
34
  map "x", to: :x
35
35
  map "y", to: :y
@@ -57,19 +57,21 @@ module Elkrb
57
57
  map "offset", to: :offset
58
58
  end
59
59
 
60
- # Validate and set port side
61
- #
62
- # @param value [String] The port side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
63
- # @raise [ArgumentError] If the side value is invalid
64
- def side=(value)
65
- return if value.nil?
60
+ # Normalizes a Symbol key however the options arrive — a constructor,
61
+ # a plain setter, or lutaml's own deserialization, which routes through
62
+ # here too. This is the single normalization point: a :hash-typed
63
+ # attribute otherwise stores a raw, unnormalized Hash.
64
+ def layout_options=(value)
65
+ value_set_for(:layout_options)
66
+ attr = self.class.attributes(lutaml_register)[:layout_options]
67
+ cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
68
+ instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
69
+ end
66
70
 
67
- normalized = value.to_s.upcase
68
- unless SIDES.include?(normalized)
69
- raise ArgumentError,
70
- "Invalid port side: #{value}. Must be one of #{SIDES.join(', ')}"
71
- end
72
- @side = normalized
71
+ # @param value [Object] candidate side
72
+ # @return [Boolean] whether the value is a canonical port side
73
+ def valid_side?(value)
74
+ SIDES.include?(value)
73
75
  end
74
76
 
75
77
  # Detect port side from position relative to node
@@ -82,11 +84,17 @@ module Elkrb
82
84
  # @return [String] The detected side (NORTH, SOUTH, EAST, WEST, UNDEFINED)
83
85
  def detect_side(node_width, node_height)
84
86
  return UNDEFINED if x.nil? || y.nil? || node_width.nil? || node_height.nil?
85
- return UNDEFINED if node_width <= 0 || node_height <= 0
87
+ return UNDEFINED unless node_width.finite? && node_height.finite?
88
+ return UNDEFINED if node_width.zero? || node_height.zero?
86
89
 
87
- # Calculate relative position (0.0 to 1.0)
88
- rel_x = x / node_width.to_f
89
- rel_y = y / node_height.to_f
90
+ # Relative position (0.0 to 1.0) from the real left/top edge, not
91
+ # from local x=0/y=0 -- for a negative dimension, local 0 is the
92
+ # real RIGHT/BOTTOM edge, so dividing by the raw signed value alone
93
+ # (as this used to) mislabels EAST ports as WEST and vice versa.
94
+ left = [0, node_width].min
95
+ top = [0, node_height].min
96
+ rel_x = (x - left) / node_width.abs.to_f
97
+ rel_y = (y - top) / node_height.abs.to_f
90
98
 
91
99
  # Calculate distance to each side
92
100
  distances = {
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ # Reopened only to attach ReadOnlyMapping below. Graph is elkrb's data
5
+ # model namespace, collectively defined across lib/elkrb/graph/*.rb --
6
+ # there is no single file that owns it.
7
+ module Graph
8
+ # The write half of a read-only `with:` mapping. lutaml requires both
9
+ # halves of a `with:` pair, and a legacy alias must contribute nothing to
10
+ # output -- one output vocabulary -- so this writes nothing on purpose.
11
+ module ReadOnlyMapping
12
+ # Public because lutaml invokes it with `public_send`; the `__elkrb_`
13
+ # prefix keeps a subclass from taking the name by accident. Not part of
14
+ # the supported API.
15
+ #
16
+ # @api private
17
+ def __elkrb_omit_from_output(model, doc); end
18
+ end
19
+ private_constant :ReadOnlyMapping
20
+ end
21
+ end