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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# Rectangle arithmetic shared by the SPOrE overlap removal phases.
|
|
7
|
+
# Rectangles are anything answering x, y, width and height.
|
|
8
|
+
module Geometry
|
|
9
|
+
FUZZINESS = 0.0001
|
|
10
|
+
DISTANCE_EPSILON = 0.00001
|
|
11
|
+
|
|
12
|
+
# Two rectangles seen along one axis.
|
|
13
|
+
Axis = Struct.new(:low_a, :size_a, :low_b, :size_b) do
|
|
14
|
+
# Signed gap between the nearer sides; negative when they overlap.
|
|
15
|
+
def separation
|
|
16
|
+
[low_a - (low_b + size_b), low_b - (low_a + size_a)].max
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Distance from the far side of one to the near side of the other,
|
|
20
|
+
# the smaller of the two ways round.
|
|
21
|
+
def span_gap
|
|
22
|
+
[(low_a - (low_b + size_b)).abs, (low_a + size_a - low_b).abs].min
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def center_distance
|
|
26
|
+
((low_a + (size_a / 2.0)) - (low_b + (size_b / 2.0))).abs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def within_reach?
|
|
30
|
+
center_distance <= (size_a / 2.0) + (size_b / 2.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def coincident?
|
|
34
|
+
center_distance.zero?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ratio
|
|
38
|
+
span_gap / center_distance
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def depth
|
|
42
|
+
[low_a + size_a, low_b + size_b].min - [low_a, low_b].max
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def distance(from_x, from_y, to_x, to_y)
|
|
49
|
+
dx = from_x - to_x
|
|
50
|
+
dy = from_y - to_y
|
|
51
|
+
Math.sqrt((dx * dx) + (dy * dy))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Guava's DoubleMath.fuzzyCompare: 0 when within +tolerance+.
|
|
55
|
+
def fuzzy_compare(left, right, tolerance)
|
|
56
|
+
return 0 if fuzzy_equal?(left, right, tolerance)
|
|
57
|
+
return left <=> right unless left.nan? || right.nan?
|
|
58
|
+
|
|
59
|
+
left.nan? ? 1 : -1
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def fuzzy_equal?(left, right, tolerance)
|
|
63
|
+
left == right || (left - right).abs <= tolerance ||
|
|
64
|
+
(left.nan? && right.nan?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def axes(first, second)
|
|
68
|
+
[Axis.new(first.x, first.width, second.x, second.width),
|
|
69
|
+
Axis.new(first.y, first.height, second.y, second.height)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The factor by which the line between the two centres must be
|
|
73
|
+
# stretched for the rectangles to just touch (1.0 when they already
|
|
74
|
+
# do not overlap). The centres must differ.
|
|
75
|
+
def overlap(first, second)
|
|
76
|
+
both = axes(first, second)
|
|
77
|
+
return 1.0 unless both.all?(&:within_reach?)
|
|
78
|
+
|
|
79
|
+
apart = both.reject(&:coincident?)
|
|
80
|
+
return 0.0 if apart.empty?
|
|
81
|
+
|
|
82
|
+
apart.map(&:ratio).min + 1
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Whether the rectangles share more than rounding noise in both axes.
|
|
86
|
+
def intersect?(first, second)
|
|
87
|
+
axes(first, second).all? { |axis| axis.depth > FUZZINESS }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Distance between the closest sides (touching sides: 0), or the
|
|
91
|
+
# negated corner distance when the rectangles overlap.
|
|
92
|
+
def shortest_distance(first, second)
|
|
93
|
+
horizontal, vertical = axes(first, second).map(&:separation)
|
|
94
|
+
h_clear = fuzzy_compare(horizontal, 0.0, DISTANCE_EPSILON)
|
|
95
|
+
v_clear = fuzzy_compare(vertical, 0.0, DISTANCE_EPSILON)
|
|
96
|
+
return [vertical, horizontal].max if (h_clear >= 0) ^ (v_clear >= 0)
|
|
97
|
+
|
|
98
|
+
corner = Math.sqrt((vertical * vertical) + (horizontal * horizontal))
|
|
99
|
+
h_clear.positive? ? corner : -corner
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# Top-to-bottom scanline that reports the pairs of bodies whose
|
|
7
|
+
# rectangles overlap. Mirrors the Java sweep, including its early stop:
|
|
8
|
+
# once a newly inserted body has met an overlapping neighbour, the scan
|
|
9
|
+
# of the left-sorted active set ends at the next non-overlapping one.
|
|
10
|
+
class OverlapSweep
|
|
11
|
+
# @return [Array<Array(Body, Body)>] each pair once, in sweep order
|
|
12
|
+
def self.pairs(bodies)
|
|
13
|
+
new.pairs(bodies)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def pairs(bodies)
|
|
17
|
+
@active = []
|
|
18
|
+
@pairs = []
|
|
19
|
+
events(bodies).each do |body, low|
|
|
20
|
+
low ? insert(body) : remove(body)
|
|
21
|
+
end
|
|
22
|
+
@pairs
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Each body twice (top border, bottom border), by row.
|
|
28
|
+
def events(bodies)
|
|
29
|
+
stamps = bodies.flat_map { |body| [[body, true], [body, false]] }
|
|
30
|
+
stamps.each_with_index.sort do |(first, i), (second, j)|
|
|
31
|
+
compare(first, second).nonzero? || (i <=> j)
|
|
32
|
+
end.map(&:first)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# At equal rows the bottom borders come first.
|
|
36
|
+
def compare(first, second)
|
|
37
|
+
by_row = row(first) <=> row(second)
|
|
38
|
+
return by_row unless by_row.zero?
|
|
39
|
+
|
|
40
|
+
(first.last ? 1 : 0) <=> (second.last ? 1 : 0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def row(stamp)
|
|
44
|
+
body, low = stamp
|
|
45
|
+
low ? body.y : body.y + body.height
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remove(body)
|
|
49
|
+
@active.delete_if { |other| other.equal?(body) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def insert(body)
|
|
53
|
+
at = @active.bsearch_index do |other|
|
|
54
|
+
(sort_key(other) <=> sort_key(body)) >= 0
|
|
55
|
+
end
|
|
56
|
+
@active.insert(at || @active.size, body)
|
|
57
|
+
collect_pairs(body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The active set is not guaranteed to hold the overlaps contiguously
|
|
61
|
+
# (a wide body can hide behind a narrow one); the scan stops at the
|
|
62
|
+
# first gap after a hit, as Java's does.
|
|
63
|
+
def collect_pairs(body)
|
|
64
|
+
met = false
|
|
65
|
+
@active.each do |other|
|
|
66
|
+
if overlapping?(body, other)
|
|
67
|
+
@pairs << [body, other]
|
|
68
|
+
met = true
|
|
69
|
+
elsif met
|
|
70
|
+
break
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def sort_key(body)
|
|
76
|
+
[body.x, body.origin_x, body.origin_y]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def overlapping?(first, second)
|
|
80
|
+
return false if first.equal?(second)
|
|
81
|
+
|
|
82
|
+
Geometry.fuzzy_compare(first.x, second.x + second.width,
|
|
83
|
+
Geometry::FUZZINESS).negative? &&
|
|
84
|
+
Geometry.fuzzy_compare(second.x, first.x + first.width,
|
|
85
|
+
Geometry::FUZZINESS).negative?
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "body"
|
|
4
|
+
require_relative "geometry"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
module Layout
|
|
8
|
+
module Spore
|
|
9
|
+
# Reading nodes into bodies and writing them back, shared by the two
|
|
10
|
+
# SPOrE algorithms. The including algorithm supplies +rng+ and +option+.
|
|
11
|
+
module Placement
|
|
12
|
+
# ELK's own defaults for the SPOrE algorithms; the shared registry
|
|
13
|
+
# values are for the other algorithms.
|
|
14
|
+
DEFAULT_SPACING = 8.0
|
|
15
|
+
DEFAULT_PADDING = {
|
|
16
|
+
left: 8.0, top: 8.0, right: 8.0, bottom: 8.0
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def padding
|
|
22
|
+
option("elk.padding", default: DEFAULT_PADDING).to_h
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_bodies(nodes, spacing)
|
|
26
|
+
seen = {}
|
|
27
|
+
nodes.map do |node|
|
|
28
|
+
body = Body.new(node, spacing)
|
|
29
|
+
body.nudge(rng) while seen.key?(body.origin)
|
|
30
|
+
seen[body.origin] = true
|
|
31
|
+
body
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The body whose centre is nearest the middle of the graph; the first
|
|
36
|
+
# of equally near ones.
|
|
37
|
+
def most_central(bodies, graph)
|
|
38
|
+
cx, cy = middle(graph)
|
|
39
|
+
bodies.each_with_index.min_by do |body, i|
|
|
40
|
+
[Geometry.distance(body.origin_x, body.origin_y, cx, cy), i]
|
|
41
|
+
end.first
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def middle(graph)
|
|
45
|
+
[graph.x.to_f + (graph.width.to_f / 2.0),
|
|
46
|
+
graph.y.to_f + (graph.height.to_f / 2.0)]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def place(body)
|
|
50
|
+
node = body.source
|
|
51
|
+
node.x = body.center_x - (node.width.to_f / 2.0)
|
|
52
|
+
node.y = body.center_y - (node.height.to_f / 2.0)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# Distance between two line segments measured along a direction, by
|
|
7
|
+
# tracing rays from each segment's end points onto the other. Points
|
|
8
|
+
# and vectors are [x, y] pairs of floats.
|
|
9
|
+
module Rays
|
|
10
|
+
# How close two points must be to count as the same end point.
|
|
11
|
+
END_POINT_TOLERANCE = 0.05
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @return [Float] the distance from segment b to segment a along
|
|
16
|
+
# +direction+, or from a to b along its opposite, whichever is
|
|
17
|
+
# smaller; Infinity when neither reaches
|
|
18
|
+
def distance(a_from, a_to, b_from, b_to, direction)
|
|
19
|
+
[trace(a_from, a_to, b_from, b_to, direction),
|
|
20
|
+
trace(b_from, b_to, a_from, a_to, negate(direction))].min
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def trace(a_from, a_to, b_from, b_to, direction)
|
|
24
|
+
a_span = minus(a_to, a_from)
|
|
25
|
+
grazing = grazing_ray?([a_from, a_to], [b_from, b_to], direction)
|
|
26
|
+
hits = [b_from, b_to].map do |origin|
|
|
27
|
+
[origin, crossing(a_from, a_span, origin, direction)]
|
|
28
|
+
end
|
|
29
|
+
shortest(a_from, a_to, hits, grazing)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One ray lands exactly on an end point of a while the shifted
|
|
33
|
+
# segment crosses a elsewhere, so the other ray's miss is not a miss.
|
|
34
|
+
def grazing_ray?(segment_a, segment_b, direction)
|
|
35
|
+
a_from, a_to = segment_a
|
|
36
|
+
b_from, b_to = segment_b
|
|
37
|
+
shifted = crossing(a_from, minus(a_to, a_from),
|
|
38
|
+
plus(b_from, direction), minus(b_to, b_from))
|
|
39
|
+
!shifted.nil? && !near?(shifted, a_from) && !near?(shifted, a_to)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The first ray that lands on an end point of a is dropped, but lets
|
|
43
|
+
# the second one through.
|
|
44
|
+
def shortest(a_from, a_to, hits, grazing)
|
|
45
|
+
forgiven = grazing
|
|
46
|
+
hits.filter_map do |origin, point|
|
|
47
|
+
next unless point
|
|
48
|
+
|
|
49
|
+
if near?(point, a_from) != near?(point, a_to) && !forgiven
|
|
50
|
+
forgiven = true
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
length(minus(point, origin))
|
|
54
|
+
end.min || Float::INFINITY
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where the segments p + t*r and q + u*s meet (0 <= t, u <= 1), the
|
|
58
|
+
# point of the collinear overlap nearest the middle of the second
|
|
59
|
+
# when they overlap along a line, or nil.
|
|
60
|
+
def crossing(p_from, r_span, q_from, s_span)
|
|
61
|
+
offset = minus(q_from, p_from)
|
|
62
|
+
return transversal(p_from, r_span, offset, s_span) unless
|
|
63
|
+
cross(r_span, s_span).zero?
|
|
64
|
+
|
|
65
|
+
return unless cross(offset, r_span).zero?
|
|
66
|
+
|
|
67
|
+
collinear(p_from, r_span, q_from, s_span)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transversal(p_from, r_span, offset, s_span)
|
|
71
|
+
r_cross_s = cross(r_span, s_span)
|
|
72
|
+
t = cross(offset, s_span) / r_cross_s
|
|
73
|
+
u = cross(offset, r_span) / r_cross_s
|
|
74
|
+
plus(p_from, scale(r_span, t)) if t.between?(0, 1) && u.between?(0, 1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def collinear(p_from, r_span, q_from, s_span)
|
|
78
|
+
middle = plus(q_from, scale(s_span, 0.5))
|
|
79
|
+
near_start = length(minus(p_from, middle))
|
|
80
|
+
p_to = plus(p_from, r_span)
|
|
81
|
+
near_end = length(minus(p_to, middle))
|
|
82
|
+
reach = length(s_span) * 0.5
|
|
83
|
+
return p_from if near_start < near_end && near_start <= reach
|
|
84
|
+
|
|
85
|
+
p_to if near_end <= reach
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def near?(first, second)
|
|
89
|
+
(first[0] - second[0]).abs <= END_POINT_TOLERANCE &&
|
|
90
|
+
(first[1] - second[1]).abs <= END_POINT_TOLERANCE
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def minus(first, second)
|
|
94
|
+
[first[0] - second[0], first[1] - second[1]]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def plus(first, second)
|
|
98
|
+
[first[0] + second[0], first[1] + second[1]]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def scale(vector, factor)
|
|
102
|
+
[vector[0] * factor, vector[1] * factor]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def negate(vector)
|
|
106
|
+
[-vector[0], -vector[1]]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cross(first, second)
|
|
110
|
+
(first[0] * second[1]) - (first[1] * second[0])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def length(vector)
|
|
114
|
+
Math.sqrt((vector[0] * vector[0]) + (vector[1] * vector[1]))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# Minimum spanning tree grown outward from a root by repeatedly adding
|
|
7
|
+
# the cheapest edge that joins a tree body to a body not yet in it.
|
|
8
|
+
# Edges of equal cost are taken in the order given.
|
|
9
|
+
module SpanningTree
|
|
10
|
+
# A body and the bodies reached through it.
|
|
11
|
+
Branch = Struct.new(:body, :children)
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param edges [Array<Array(Body, Body)>]
|
|
16
|
+
# @param root [Body]
|
|
17
|
+
# @yieldparam first [Body]
|
|
18
|
+
# @yieldparam second [Body]
|
|
19
|
+
# @yieldreturn [Float] the edge's cost
|
|
20
|
+
# @return [Branch]
|
|
21
|
+
def build(edges, root, &)
|
|
22
|
+
pending = by_cost(edges, &)
|
|
23
|
+
tree = Branch.new(root, [])
|
|
24
|
+
branches = {}.compare_by_identity
|
|
25
|
+
branches[root] = tree
|
|
26
|
+
while (index = cheapest_crossing(pending, branches))
|
|
27
|
+
attach(branches, *pending.delete_at(index))
|
|
28
|
+
end
|
|
29
|
+
tree
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def by_cost(edges, &)
|
|
33
|
+
edges.each_with_index
|
|
34
|
+
.sort_by { |(first, second), i| [yield(first, second), i] }
|
|
35
|
+
.map(&:first)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def cheapest_crossing(pending, branches)
|
|
39
|
+
pending.index do |first, second|
|
|
40
|
+
branches.key?(first) != branches.key?(second)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def attach(branches, first, second)
|
|
45
|
+
from, added = branches.key?(first) ? [first, second] : [second, first]
|
|
46
|
+
branch = Branch.new(added, [])
|
|
47
|
+
branches[from].children << branch
|
|
48
|
+
branches[added] = branch
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Layout
|
|
5
|
+
module Spore
|
|
6
|
+
# Delaunay triangulation by incremental Bowyer-Watson insertion into a
|
|
7
|
+
# super-triangle. Points are [x, y] pairs and must be distinct.
|
|
8
|
+
module Triangulation
|
|
9
|
+
WIGGLE_ROOM = 50.0
|
|
10
|
+
|
|
11
|
+
# The extent of a point set.
|
|
12
|
+
Bounds = Struct.new(:min_x, :max_x, :min_y, :max_y) do
|
|
13
|
+
def width
|
|
14
|
+
max_x - min_x
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def half_height
|
|
18
|
+
(max_y - min_y) / 2
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def frame_top
|
|
22
|
+
[min_x - WIGGLE_ROOM, min_y - width - WIGGLE_ROOM]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def frame_bottom
|
|
26
|
+
[min_x - WIGGLE_ROOM, max_y + width + WIGGLE_ROOM]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def frame_tip
|
|
30
|
+
[max_x + half_height + WIGGLE_ROOM, min_y + half_height]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Vector arithmetic on [x, y] pairs.
|
|
35
|
+
module Pair
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
def delta(from, to)
|
|
39
|
+
[to[0] - from[0], to[1] - from[1]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sum(first, second)
|
|
43
|
+
[first[0] + second[0], first[1] + second[1]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def dot(first, second)
|
|
47
|
+
(first[0] * second[0]) + (first[1] * second[1])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cross(first, second)
|
|
51
|
+
(first[0] * second[1]) - (first[1] * second[0])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def distance(from, to)
|
|
55
|
+
Math.sqrt(dot(delta(from, to), delta(from, to)))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A triangle with its circumcentre cached.
|
|
60
|
+
Triangle = Struct.new(:a, :b, :c) do
|
|
61
|
+
def vertices
|
|
62
|
+
[a, b, c]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def edges
|
|
66
|
+
[[a, b], [b, c], [c, a]]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def contains_edge?(edge)
|
|
70
|
+
edges.any? { |own| Triangulation.same_edge?(own, edge) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def circumcenter
|
|
74
|
+
@circumcenter ||= compute_circumcenter
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def in_circumcircle?(point)
|
|
78
|
+
Geometry.fuzzy_compare(Pair.distance(circumcenter, point),
|
|
79
|
+
Pair.distance(circumcenter, a),
|
|
80
|
+
Geometry::FUZZINESS).negative?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def compute_circumcenter
|
|
86
|
+
ab = Pair.delta(a, b)
|
|
87
|
+
ac = Pair.delta(a, c)
|
|
88
|
+
e, f, g = circumcenter_terms(ab, ac)
|
|
89
|
+
[term(1, ac, e, ab, f) / g, term(0, ab, f, ac, e) / g]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def circumcenter_terms(ab_vec, ac_vec)
|
|
93
|
+
[Pair.dot(ab_vec, Pair.sum(a, b)),
|
|
94
|
+
Pair.dot(ac_vec, Pair.sum(a, c)),
|
|
95
|
+
2 * Pair.cross(ab_vec, Pair.delta(b, c))]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def term(index, first, first_scale, second, second_scale)
|
|
99
|
+
(first[index] * first_scale) - (second[index] * second_scale)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
module_function
|
|
104
|
+
|
|
105
|
+
def same_edge?(first, second)
|
|
106
|
+
first == second || first == second.reverse
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param points [Array<Array(Float, Float)>]
|
|
110
|
+
# @return [Array<Array(Array, Array)>] each edge once
|
|
111
|
+
def triangulate(points)
|
|
112
|
+
return [] if points.empty?
|
|
113
|
+
|
|
114
|
+
frame = enclosing_triangle(points)
|
|
115
|
+
triangles = points.reduce([frame]) do |current, point|
|
|
116
|
+
insert(current, point)
|
|
117
|
+
end
|
|
118
|
+
inner_edges(triangles, frame).uniq(&:sort)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inner_edges(triangles, frame)
|
|
122
|
+
triangles.flat_map(&:edges).reject do |edge|
|
|
123
|
+
edge.any? { |vertex| frame.vertices.include?(vertex) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Large enough that no point sits near its border, which would give
|
|
128
|
+
# circumcircles too big for a double to compare.
|
|
129
|
+
def enclosing_triangle(points)
|
|
130
|
+
bounds = Bounds.new(*points.map(&:first).minmax,
|
|
131
|
+
*points.map(&:last).minmax)
|
|
132
|
+
Triangle.new(bounds.frame_top, bounds.frame_bottom,
|
|
133
|
+
bounds.frame_tip)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def insert(triangles, point)
|
|
137
|
+
invalid, valid = triangles.partition do |triangle|
|
|
138
|
+
triangle.in_circumcircle?(point)
|
|
139
|
+
end
|
|
140
|
+
boundary(invalid).each do |from, to|
|
|
141
|
+
valid << Triangle.new(point, from, to)
|
|
142
|
+
end
|
|
143
|
+
valid
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Edges of the invalid triangles that no other invalid triangle has.
|
|
147
|
+
def boundary(invalid)
|
|
148
|
+
invalid.flat_map do |triangle|
|
|
149
|
+
triangle.edges.select do |edge|
|
|
150
|
+
invalid.none? do |other|
|
|
151
|
+
!other.equal?(triangle) && other.contains_edge?(edge)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
# @api private
|
|
5
|
+
#
|
|
6
|
+
# The one table that turns CLI flags into layout options. A flag the user
|
|
7
|
+
# typed becomes a canonical option on the ROOT graph, where it outranks
|
|
8
|
+
# whatever the file says for itself. A flag the user did not type is never
|
|
9
|
+
# written, so the file's own options (including its elk.algorithm pin)
|
|
10
|
+
# stay in charge.
|
|
11
|
+
module LayoutFlags
|
|
12
|
+
OPTION_IDS = {
|
|
13
|
+
algorithm: "elk.algorithm",
|
|
14
|
+
spacing: "elk.spacing.nodeNode",
|
|
15
|
+
layer_spacing: "elk.layered.spacing.nodeNodeBetweenLayers",
|
|
16
|
+
direction: "elk.direction",
|
|
17
|
+
edge_routing: "elk.edgeRouting",
|
|
18
|
+
}.freeze
|
|
19
|
+
private_constant :OPTION_IDS
|
|
20
|
+
|
|
21
|
+
PADDING_FLAGS = %i[padding_top padding_bottom padding_left padding_right]
|
|
22
|
+
.freeze
|
|
23
|
+
private_constant :PADDING_FLAGS
|
|
24
|
+
|
|
25
|
+
# ELK parses a padding string with missing sides as 0, so the sides the
|
|
26
|
+
# user did not give are written out as the registry default.
|
|
27
|
+
DEFAULT_PADDING = 12
|
|
28
|
+
private_constant :DEFAULT_PADDING
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# @param graph [Elkrb::Graph::Graph, Hash] a Graph, or the Hash an
|
|
32
|
+
# ELKT parse returns
|
|
33
|
+
# @param options [Hash] Thor options, Symbol keys; only the flags
|
|
34
|
+
# present are applied
|
|
35
|
+
# @return [Elkrb::Graph::Graph] the graph, with the flags written onto
|
|
36
|
+
# its layoutOptions
|
|
37
|
+
def apply(graph, options)
|
|
38
|
+
graph = Graph::Graph.from_hash(graph) if graph.is_a?(::Hash)
|
|
39
|
+
graph.layout_options = {} if graph.layout_options.nil?
|
|
40
|
+
|
|
41
|
+
flag_options(options).each do |id, value|
|
|
42
|
+
graph.layout_options[id] = value
|
|
43
|
+
end
|
|
44
|
+
graph
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def flag_options(options)
|
|
50
|
+
given = OPTION_IDS.filter_map do |flag, id|
|
|
51
|
+
[id, options[flag]] unless options[flag].nil?
|
|
52
|
+
end.to_h
|
|
53
|
+
|
|
54
|
+
padding = padding_string(options)
|
|
55
|
+
given["elk.padding"] = padding if padding
|
|
56
|
+
given
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def padding_string(options)
|
|
60
|
+
return nil if PADDING_FLAGS.all? { |flag| options[flag].nil? }
|
|
61
|
+
|
|
62
|
+
side = ->(flag) { options[flag] || DEFAULT_PADDING }
|
|
63
|
+
"[top=#{side.call(:padding_top)},left=#{side.call(:padding_left)}," \
|
|
64
|
+
"bottom=#{side.call(:padding_bottom)}," \
|
|
65
|
+
"right=#{side.call(:padding_right)}]"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
module Options
|
|
5
|
+
# Reads the numbers inside an option value. String#to_f answers 0.0 for
|
|
6
|
+
# "abc" and for "0x10", which would lay a graph out with zero spacing and
|
|
7
|
+
# no sign anything was wrong, so a string must be plain decimal notation.
|
|
8
|
+
# Float() is not the guard: it also takes "0x10".
|
|
9
|
+
module Decimal
|
|
10
|
+
PATTERN = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
11
|
+
# Ruby 3.3's Float() rejects "5." and "1.e3"; 3.4 accepts them.
|
|
12
|
+
TRAILING_DOT = /\.(?=\D|\z)/
|
|
13
|
+
private_constant :PATTERN, :TRAILING_DOT
|
|
14
|
+
|
|
15
|
+
# @param text [String]
|
|
16
|
+
# @return [Float, nil] nil unless text is plain decimal in a readable
|
|
17
|
+
# encoding
|
|
18
|
+
def self.parse(text)
|
|
19
|
+
return unless text.encoding.ascii_compatible? && text.valid_encoding?
|
|
20
|
+
|
|
21
|
+
stripped = text.strip
|
|
22
|
+
Float(stripped.sub(TRAILING_DOT, ".0")) if PATTERN.match?(stripped)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param value [String, Numeric, nil] a number or its text; nil is 0.0
|
|
26
|
+
# @return [Float]
|
|
27
|
+
# @raise [ArgumentError] when a String is not plain decimal, or the
|
|
28
|
+
# value is neither a number nor a String (false, true, an Array)
|
|
29
|
+
def self.to_f(value)
|
|
30
|
+
return value.to_f if value.nil? || value.is_a?(Numeric)
|
|
31
|
+
|
|
32
|
+
(value.is_a?(String) && parse(value)) ||
|
|
33
|
+
raise(ArgumentError, "Invalid number: #{value.inspect}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The first non-nil of hash[key as Symbol], hash[key as String], so an
|
|
37
|
+
# explicit false reaches to_f and is rejected instead of reading as
|
|
38
|
+
# absent the way `||` would.
|
|
39
|
+
#
|
|
40
|
+
# @param hash [Hash]
|
|
41
|
+
# @param key [Symbol]
|
|
42
|
+
# @param default [Object] returned when both spellings are nil
|
|
43
|
+
def self.component(hash, key, default)
|
|
44
|
+
value = hash[key]
|
|
45
|
+
value = hash[key.to_s] if value.nil?
|
|
46
|
+
value.nil? ? default : value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|