elkrb 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -0,0 +1,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