elkrb 1.0.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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 =
|
|
14
|
-
@top =
|
|
15
|
-
@right =
|
|
16
|
-
@bottom =
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
51
|
+
new(**content.split(",", -1).to_h { |entry| side_and_number(entry) })
|
|
52
|
+
end
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 =
|
|
14
|
-
@y =
|
|
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
|
-
#
|
|
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
|
-
|
|
44
|
-
|
|
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
|