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