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,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Elkrb
|
|
7
|
+
# @api private
|
|
8
|
+
#
|
|
9
|
+
# Shared "sniff JSON/YAML, fall back to ELKT" input-format detection.
|
|
10
|
+
# Every command reads its input through here: Cli#read_input_file,
|
|
11
|
+
# ConvertCommand#load_any_format, ValidateCommand#load_any_format and
|
|
12
|
+
# DiagramCommand#load_graph -- add a new one through this module too,
|
|
13
|
+
# not a fifth private method, or its guards silently drift from the rest.
|
|
14
|
+
module FormatSniffer
|
|
15
|
+
BYTE_ORDER_MARK = "\xEF\xBB\xBF".b.freeze
|
|
16
|
+
private_constant :BYTE_ORDER_MARK
|
|
17
|
+
|
|
18
|
+
UNPARSEABLE =
|
|
19
|
+
"Unable to parse input file. Supported formats: JSON, YAML, ELKT"
|
|
20
|
+
private_constant :UNPARSEABLE
|
|
21
|
+
|
|
22
|
+
DOT_UNSUPPORTED =
|
|
23
|
+
"DOT format input not yet supported. Use JSON, YAML, or ELKT."
|
|
24
|
+
private_constant :DOT_UNSUPPORTED
|
|
25
|
+
|
|
26
|
+
NIL_WHEN_HOLLOW = %i[
|
|
27
|
+
id x y width height layout_options children edges properties
|
|
28
|
+
].freeze
|
|
29
|
+
private_constant :NIL_WHEN_HOLLOW
|
|
30
|
+
|
|
31
|
+
# Psych's own nesting limit raises before the stack ever overflows, so
|
|
32
|
+
# the SystemStackError rescue below never sees it. Matched by message
|
|
33
|
+
# because the raising class, Psych::SyntaxError, is also what a
|
|
34
|
+
# genuinely malformed document raises -- only the text tells them apart.
|
|
35
|
+
NESTING_DEPTH_MESSAGE = "nesting depth exceeds the limit"
|
|
36
|
+
private_constant :NESTING_DEPTH_MESSAGE
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# The single entry point every command reads input through -- a guard
|
|
40
|
+
# belongs here, not per command. String#strip does NOT remove a BOM,
|
|
41
|
+
# so strip it here rather than per branch.
|
|
42
|
+
#
|
|
43
|
+
# @param content [String] raw file content
|
|
44
|
+
# @param extension [String] the file's downcased extension
|
|
45
|
+
# @return [Elkrb::Graph::Graph, Hash] the parsed graph
|
|
46
|
+
# @raise [Lutaml::Model::InvalidFormatError] on unparseable JSON/YAML
|
|
47
|
+
# @raise [Psych::Exception] when .yml/.yaml is valid YAML Psych refuses
|
|
48
|
+
# @raise [ArgumentError] for .dot/.gv, an unusable JSON/YAML model,
|
|
49
|
+
# empty ELKT/sniffed content, or YAML nested past Psych's limit
|
|
50
|
+
def read(content, extension)
|
|
51
|
+
require_relative "graph/graph"
|
|
52
|
+
|
|
53
|
+
read_by_extension(strip_byte_order_mark(content), extension)
|
|
54
|
+
rescue SystemStackError
|
|
55
|
+
# Psych recurses once per nesting level, so a few thousand open
|
|
56
|
+
# brackets overflow the stack. SystemStackError is not a
|
|
57
|
+
# StandardError, so it walked past every rescue below AND the CLI's
|
|
58
|
+
# own, and the user got a raw trace. Caught here because this is
|
|
59
|
+
# where the module states what it raises, and both the sniffed and
|
|
60
|
+
# the declared YAML branch can reach it.
|
|
61
|
+
raise ArgumentError, UNPARSEABLE
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Every branch here runs inside #read's SystemStackError rescue, and
|
|
67
|
+
# needs to: the declared .yml/.yaml branch overflows Psych's recursion
|
|
68
|
+
# as readily as the sniffed one does.
|
|
69
|
+
def read_by_extension(text, extension)
|
|
70
|
+
case extension
|
|
71
|
+
when ".json", ".yml", ".yaml" then read_model(text, extension)
|
|
72
|
+
when ".elkt" then parse_elkt_declared!(text)
|
|
73
|
+
when ".dot", ".gv" then raise ArgumentError, DOT_UNSUPPORTED
|
|
74
|
+
else parse(text)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The mark is the three bytes EF BB BF, so it comes off by byte rather
|
|
79
|
+
# than by character. String#delete_prefix compares CHARACTERS, and a
|
|
80
|
+
# UTF-8 literal raises Encoding::CompatibilityError against a receiver
|
|
81
|
+
# in another encoding that holds any non-ASCII byte -- File.read tags
|
|
82
|
+
# content with Encoding.default_external, which the locale sets.
|
|
83
|
+
#
|
|
84
|
+
# @return [String] the content without its mark, in its own encoding
|
|
85
|
+
def strip_byte_order_mark(content)
|
|
86
|
+
return content unless content.byteslice(0, 3).b == BYTE_ORDER_MARK
|
|
87
|
+
|
|
88
|
+
content.byteslice(3..)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Rescue Psych's safe-load refusals (a !ruby/object tag, an alias)
|
|
92
|
+
# explicitly: they are not in lutaml-model's normalized-error list, and
|
|
93
|
+
# falling through to the ELKT parser would read `foo: 1` as a layout
|
|
94
|
+
# option and exit 0 on valid YAML we decline to load.
|
|
95
|
+
#
|
|
96
|
+
# @param content [String] raw file content
|
|
97
|
+
# @return [Elkrb::Graph::Graph, Hash] a parsed graph model, or an ELKT
|
|
98
|
+
# hash
|
|
99
|
+
# @raise [ArgumentError] when neither JSON/YAML nor ELKT yields real
|
|
100
|
+
# content
|
|
101
|
+
def parse(content)
|
|
102
|
+
sniff(content) || parse_elkt_or_fail(content)
|
|
103
|
+
rescue Psych::Exception
|
|
104
|
+
raise ArgumentError, UNPARSEABLE
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# JSON and YAML differ only in the deserializer; both then need the same
|
|
108
|
+
# shape check. The charset is normalized before either deserializer
|
|
109
|
+
# sees the content: a file tagged with a real source encoding other
|
|
110
|
+
# than UTF-8 (ISO-8859-1, say) or with none at all (BINARY) otherwise
|
|
111
|
+
# surfaces the parser's own encoding complaint instead of being read.
|
|
112
|
+
#
|
|
113
|
+
# @raise [ArgumentError] when the document parses to an unusable model,
|
|
114
|
+
# or is nested deeper than Psych's own limit allows
|
|
115
|
+
def read_model(content, extension)
|
|
116
|
+
text = normalize_charset(content)
|
|
117
|
+
model = if extension == ".json"
|
|
118
|
+
Elkrb::Graph::Graph.from_json(text)
|
|
119
|
+
else
|
|
120
|
+
Elkrb::Graph::Graph.from_yaml(text)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
validate_model!(model, text, extension)
|
|
124
|
+
rescue Lutaml::Model::InvalidFormatError => e
|
|
125
|
+
raise ArgumentError, UNPARSEABLE if nesting_depth_exceeded?(e)
|
|
126
|
+
|
|
127
|
+
raise
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def nesting_depth_exceeded?(error)
|
|
131
|
+
(error.cause || error).message.include?(NESTING_DEPTH_MESSAGE)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# content with a real, named source encoding is transcoded via
|
|
135
|
+
# String#encode, which is what turns a Latin-1 0xE9 into the accented
|
|
136
|
+
# character it actually names. UTF-8 and ASCII-8BIT carry no source
|
|
137
|
+
# encoding to transcode FROM, so they are reinterpreted as UTF-8
|
|
138
|
+
# directly; bytes that are not valid UTF-8 either way (a lone 0xE9
|
|
139
|
+
# tagged BINARY, with no named encoding to recover one from) are
|
|
140
|
+
# treated as Latin-1 as a last resort, since every byte value is a
|
|
141
|
+
# valid Latin-1 character and the transcode can never itself fail.
|
|
142
|
+
NO_SOURCE_ENCODING_TO_TRANSCODE_FROM =
|
|
143
|
+
[Encoding::UTF_8, Encoding::ASCII_8BIT].freeze
|
|
144
|
+
private_constant :NO_SOURCE_ENCODING_TO_TRANSCODE_FROM
|
|
145
|
+
|
|
146
|
+
def normalize_charset(text)
|
|
147
|
+
if NO_SOURCE_ENCODING_TO_TRANSCODE_FROM.include?(text.encoding)
|
|
148
|
+
reinterpret_or_latin1(text)
|
|
149
|
+
else
|
|
150
|
+
begin
|
|
151
|
+
text.encode(Encoding::UTF_8)
|
|
152
|
+
rescue EncodingError
|
|
153
|
+
text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def reinterpret_or_latin1(text)
|
|
159
|
+
reinterpreted = text.dup.force_encoding(Encoding::UTF_8)
|
|
160
|
+
return reinterpreted if reinterpreted.valid_encoding?
|
|
161
|
+
|
|
162
|
+
text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A file whose extension names the format skips sniffing, so it also
|
|
166
|
+
# skips the malformed-shape check the sniffer applies. Both paths need
|
|
167
|
+
# it: lutaml coerces a mapping where a sequence belongs into one
|
|
168
|
+
# coerced model, and every downstream reader then breaks on it.
|
|
169
|
+
#
|
|
170
|
+
# @raise [ArgumentError] when the parsed model is not usable
|
|
171
|
+
def validate_model!(graph, raw_content, extension)
|
|
172
|
+
usable = graph.is_a?(Elkrb::Graph::Graph) &&
|
|
173
|
+
!malformed_shape?(raw_content, extension) &&
|
|
174
|
+
!hollow_model?(graph)
|
|
175
|
+
raise ArgumentError, UNPARSEABLE unless usable
|
|
176
|
+
|
|
177
|
+
graph
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A JSON document can only open with `{` or `[`, so anything else goes
|
|
181
|
+
# straight to YAML. Flow-style YAML opens with `{` too, though, so a
|
|
182
|
+
# failed JSON parse has to fall through to YAML before ELKT gets a
|
|
183
|
+
# turn -- otherwise a flow-style graph is read as ELKT layout options
|
|
184
|
+
# and silently loses its children.
|
|
185
|
+
#
|
|
186
|
+
# Normalized here for the same reason #read_model normalizes before
|
|
187
|
+
# its own deserializer runs: a file with no extension to declare its
|
|
188
|
+
# format still carries whatever charset it was written in, and
|
|
189
|
+
# skipping this would read identical non-UTF-8 content differently
|
|
190
|
+
# depending only on whether the caller happened to name the extension.
|
|
191
|
+
def sniff(text)
|
|
192
|
+
text = normalize_charset(text)
|
|
193
|
+
|
|
194
|
+
if text.lstrip.start_with?("{", "[")
|
|
195
|
+
return try_json(text) || try_yaml(text)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
try_yaml(text)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def try_json(text)
|
|
202
|
+
real_graph(Elkrb::Graph::Graph.from_json(text), text, ".json")
|
|
203
|
+
rescue Lutaml::Model::InvalidFormatError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def try_yaml(text)
|
|
208
|
+
real_graph(Elkrb::Graph::Graph.from_yaml(text), text, ".yaml")
|
|
209
|
+
rescue Lutaml::Model::InvalidFormatError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A top-level sequence (`[]`, `[{}]`, `- id: g`) parses without raising
|
|
214
|
+
# but comes back an Array, and lutaml-model 0.8.19 turns any mapping at
|
|
215
|
+
# all into a Graph with every field nil. Neither is a real parse, and
|
|
216
|
+
# calling #id on the Array would blow up.
|
|
217
|
+
def real_graph(result, raw_content, extension)
|
|
218
|
+
return nil unless result.is_a?(Elkrb::Graph::Graph)
|
|
219
|
+
return nil if malformed_shape?(raw_content, extension)
|
|
220
|
+
|
|
221
|
+
result unless hollow_model?(result)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Given a mapping where a sequence belongs (`"children": {"a": 1}`),
|
|
225
|
+
# lutaml-model's coercion of it can be indistinguishable from a
|
|
226
|
+
# legitimate value by the time it reaches the parsed MODEL -- a
|
|
227
|
+
# coerced `Geometry::Point` defaults its x/y to 0.0, not nil, so it
|
|
228
|
+
# reads exactly like a real point at the origin. The check runs
|
|
229
|
+
# against the RAW parsed document instead, before any of that
|
|
230
|
+
# coercion happens.
|
|
231
|
+
#
|
|
232
|
+
# The key list mirrors every `collection: true` attribute in graph/,
|
|
233
|
+
# by both its JSON and its YAML spelling.
|
|
234
|
+
COLLECTION_KEYS = %w[
|
|
235
|
+
children edges labels ports sections sources targets
|
|
236
|
+
bendPoints bend_points
|
|
237
|
+
incomingSections incoming_sections
|
|
238
|
+
outgoingSections outgoing_sections
|
|
239
|
+
junctionPoints junction_points
|
|
240
|
+
].freeze
|
|
241
|
+
private_constant :COLLECTION_KEYS
|
|
242
|
+
|
|
243
|
+
# properties and layout_options are declared `:hash` on every model
|
|
244
|
+
# (Graph, Node, Edge, Label) -- an opaque caller-defined bag, not part
|
|
245
|
+
# of the graph schema. A key the caller chose for their own metadata
|
|
246
|
+
# can coincidentally match a COLLECTION_KEYS name ("properties":
|
|
247
|
+
# {"children": "metadata"}); checking inside the bag against the
|
|
248
|
+
# schema's own key list is a false positive, not a malformed document.
|
|
249
|
+
OPAQUE_METADATA_KEYS = %w[properties layoutOptions layout_options].freeze
|
|
250
|
+
private_constant :OPAQUE_METADATA_KEYS
|
|
251
|
+
|
|
252
|
+
# @return [Boolean] true when a collection key, at any depth, holds
|
|
253
|
+
# something other than a sequence or an absent/null value
|
|
254
|
+
def malformed_shape?(raw_content, extension)
|
|
255
|
+
raw = parse_raw_structure(raw_content, extension)
|
|
256
|
+
return false if raw.nil?
|
|
257
|
+
|
|
258
|
+
raw_shape_malformed?(raw)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# A parse failure here is not this check's to report: the real
|
|
262
|
+
# deserializer already ran (successfully, to reach this point) or its
|
|
263
|
+
# own failure is handled by its own caller. Either way, nothing to
|
|
264
|
+
# compare the model's shape against, so the model is taken as given.
|
|
265
|
+
def parse_raw_structure(content, extension)
|
|
266
|
+
extension == ".json" ? ::JSON.parse(content) : ::YAML.safe_load(content)
|
|
267
|
+
rescue StandardError
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def raw_shape_malformed?(node)
|
|
272
|
+
case node
|
|
273
|
+
when ::Hash
|
|
274
|
+
hash_shape_malformed?(node)
|
|
275
|
+
when ::Array
|
|
276
|
+
node.any? { |item| raw_shape_malformed?(item) }
|
|
277
|
+
else
|
|
278
|
+
false
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def hash_shape_malformed?(node)
|
|
283
|
+
return true if collection_key_malformed?(node)
|
|
284
|
+
|
|
285
|
+
node.each_pair.any? do |key, value|
|
|
286
|
+
next false if OPAQUE_METADATA_KEYS.include?(key)
|
|
287
|
+
|
|
288
|
+
raw_shape_malformed?(value)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def collection_key_malformed?(node)
|
|
293
|
+
COLLECTION_KEYS.any? do |key|
|
|
294
|
+
node.key?(key) && !node[key].nil? && !node[key].is_a?(::Array)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Mirrors the field list in Graph's json and yaml mapping blocks: when
|
|
299
|
+
# every recognized field comes back nil, lutaml-model matched nothing
|
|
300
|
+
# and the document was never understood. Present-but-empty is the
|
|
301
|
+
# opposite of that -- `"children": []` and `"layoutOptions": {}` were
|
|
302
|
+
# both recognized and are real content, so neither may read as hollow.
|
|
303
|
+
# Deserialization leaves an absent field nil, and an explicit `null`
|
|
304
|
+
# nil too, so a document carrying only those is still rejected.
|
|
305
|
+
def hollow_model?(graph)
|
|
306
|
+
NIL_WHEN_HOLLOW.all? { |field| graph.public_send(field).nil? }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The sniffed path reaches ELKT only because nothing else could read
|
|
310
|
+
# the document, and no extension declared it to be ELKT either, so a
|
|
311
|
+
# childless, edgeless result is rejected as "probably not meant to be
|
|
312
|
+
# ELKT" -- that is what keeps `layout garbage.txt` exiting 1. Keep the
|
|
313
|
+
# generic UNPARSEABLE message even for a located Elkrb::ParseError:
|
|
314
|
+
# nothing here told the user this was ELKT, so a location inside a
|
|
315
|
+
# grammar they never invoked is not actionable.
|
|
316
|
+
def parse_elkt_or_fail(content)
|
|
317
|
+
graph = parse_elkt!(content)
|
|
318
|
+
return graph unless childless?(graph)
|
|
319
|
+
|
|
320
|
+
reject_unparseable!
|
|
321
|
+
rescue StandardError
|
|
322
|
+
reject_unparseable!
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def reject_unparseable!
|
|
326
|
+
raise ArgumentError, UNPARSEABLE
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def childless?(graph)
|
|
330
|
+
blank?(graph[:children]) && blank?(graph[:edges])
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Raises whatever the real parser raised, unwrapped: an
|
|
334
|
+
# Elkrb::ParseError (location-bearing) or the Lexer's `TypeError` for
|
|
335
|
+
# non-String input. Callers decide how much to keep --
|
|
336
|
+
# `parse_elkt_declared!` below keeps the location, `parse_elkt_or_fail`
|
|
337
|
+
# above discards it.
|
|
338
|
+
def parse_elkt!(content)
|
|
339
|
+
require_relative "parsers/elkt_parser"
|
|
340
|
+
Elkrb::Parsers::ElktParser.parse(content)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# The declared `.elkt` path: the extension names the format, so the
|
|
344
|
+
# located Elkrb::ParseError message ("...at line 4, column 9") is kept
|
|
345
|
+
# rather than replaced with the generic UNPARSEABLE text. Only
|
|
346
|
+
# ParseError gets this treatment -- the Lexer's `TypeError` for a
|
|
347
|
+
# non-String input has no location to report, so the generic message
|
|
348
|
+
# is the correct fallback for it.
|
|
349
|
+
def parse_elkt_declared!(content)
|
|
350
|
+
parse_elkt!(content)
|
|
351
|
+
rescue Elkrb::ParseError => e
|
|
352
|
+
raise ArgumentError, e.message
|
|
353
|
+
rescue StandardError
|
|
354
|
+
raise ArgumentError, UNPARSEABLE
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def blank?(collection)
|
|
358
|
+
collection.nil? || collection.empty?
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -16,13 +16,23 @@ module Elkrb
|
|
|
16
16
|
# @param end_point [Point] The ending point (P3)
|
|
17
17
|
# @param control1 [Point] The first control point (P1)
|
|
18
18
|
# @param control2 [Point] The second control point (P2)
|
|
19
|
-
# @param
|
|
19
|
+
# @param sample_count [Integer] Number of sample points to generate, so
|
|
20
|
+
# the curve it describes has one fewer connecting segment: 20 yields
|
|
21
|
+
# 20 points and 19 segments. Fewer than two yields copies of the two
|
|
22
|
+
# endpoints, since a curve cannot be sampled from a single point.
|
|
20
23
|
# @return [Array<Point>] Array of points along the curve
|
|
21
24
|
def self.calculate_curve(start_point, end_point, control1, control2,
|
|
22
|
-
|
|
25
|
+
sample_count = 20)
|
|
26
|
+
# Copies, so changing a returned point never moves the caller's own
|
|
27
|
+
# start or end point.
|
|
28
|
+
if sample_count < 2
|
|
29
|
+
return [Point.new(x: start_point.x, y: start_point.y),
|
|
30
|
+
Point.new(x: end_point.x, y: end_point.y)]
|
|
31
|
+
end
|
|
32
|
+
|
|
23
33
|
points = []
|
|
24
|
-
|
|
25
|
-
t = i.to_f / (
|
|
34
|
+
sample_count.times do |i|
|
|
35
|
+
t = i.to_f / (sample_count - 1)
|
|
26
36
|
points << bezier_point(t, start_point, control1, control2, end_point)
|
|
27
37
|
end
|
|
28
38
|
points
|
data/lib/elkrb/geometry/point.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Elkrb
|
|
|
8
8
|
attribute :x, :float, default: -> { 0.0 }
|
|
9
9
|
attribute :y, :float, default: -> { 0.0 }
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
key_value do
|
|
12
12
|
map "x", to: :x
|
|
13
13
|
map "y", to: :y
|
|
14
14
|
end
|
|
@@ -18,15 +18,14 @@ module Elkrb
|
|
|
18
18
|
map "y", to: :y
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# The explicit zeros matter: a lutaml default is not marked as set and so
|
|
22
|
+
# would not be rendered, making a bare Point serialize to {} instead of
|
|
23
|
+
# {"x":0.0,"y":0.0}.
|
|
21
24
|
def initialize(**attributes)
|
|
22
|
-
# Handle both keyword args and positional args
|
|
23
25
|
if attributes.empty?
|
|
24
26
|
super(x: 0.0, y: 0.0)
|
|
25
|
-
elsif attributes.key?(:x) || attributes.key?(:y)
|
|
26
|
-
super
|
|
27
27
|
else
|
|
28
|
-
|
|
29
|
-
super(x: 0.0, y: 0.0)
|
|
28
|
+
super
|
|
30
29
|
end
|
|
31
30
|
end
|
|
32
31
|
|
|
@@ -16,7 +16,7 @@ module Elkrb
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def position
|
|
19
|
-
Point.new(@x, @y)
|
|
19
|
+
Point.new(x: @x, y: @y)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def position=(point)
|
|
@@ -50,7 +50,7 @@ module Elkrb
|
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
def center
|
|
53
|
-
Point.new(@x + (@width / 2.0), @y + (@height / 2.0))
|
|
53
|
+
Point.new(x: @x + (@width / 2.0), y: @y + (@height / 2.0))
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def contains?(point)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elkrb
|
|
4
|
+
# Reopened only to attach DeepStringifyKeys below. Graph is elkrb's data
|
|
5
|
+
# model namespace, collectively defined across lib/elkrb/graph/*.rb --
|
|
6
|
+
# there is no single file that owns it.
|
|
7
|
+
module Graph
|
|
8
|
+
# Recursively stringifies Hash/Array keys (Symbol -> String). Used only
|
|
9
|
+
# by the five models' `layout_options=` overrides, and only on the
|
|
10
|
+
# options map itself — never on a whole input tree, which would also
|
|
11
|
+
# rewrite opaque data the caller parked in `properties`. Verified
|
|
12
|
+
# against lutaml-model 0.8.19 that :hash-typed attributes otherwise
|
|
13
|
+
# store a raw Hash unchanged, Symbol keys and all.
|
|
14
|
+
module DeepStringifyKeys
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def call(obj)
|
|
18
|
+
case obj
|
|
19
|
+
when ::Hash
|
|
20
|
+
obj.each_with_object({}) { |(k, v), h| h[k.to_s] = call(v) }
|
|
21
|
+
when ::Array
|
|
22
|
+
obj.map { |v| call(v) }
|
|
23
|
+
else
|
|
24
|
+
obj
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private_constant :DeepStringifyKeys
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/elkrb/graph/edge.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "lutaml/model"
|
|
4
4
|
require_relative "../geometry/point"
|
|
5
|
+
require_relative "read_only_mapping"
|
|
5
6
|
|
|
6
7
|
module Elkrb
|
|
7
8
|
module Graph
|
|
@@ -13,14 +14,18 @@ module Elkrb
|
|
|
13
14
|
attribute :bend_points, Geometry::Point, collection: true
|
|
14
15
|
attribute :incoming_shape, :string
|
|
15
16
|
attribute :outgoing_shape, :string
|
|
17
|
+
attribute :incoming_sections, :string, collection: true
|
|
18
|
+
attribute :outgoing_sections, :string, collection: true
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
key_value do
|
|
18
21
|
map "id", to: :id
|
|
19
22
|
map "startPoint", to: :start_point
|
|
20
23
|
map "endPoint", to: :end_point
|
|
21
24
|
map "bendPoints", to: :bend_points
|
|
22
25
|
map "incomingShape", to: :incoming_shape
|
|
23
26
|
map "outgoingShape", to: :outgoing_shape
|
|
27
|
+
map "incomingSections", to: :incoming_sections
|
|
28
|
+
map "outgoingSections", to: :outgoing_sections
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
yaml do
|
|
@@ -30,6 +35,8 @@ module Elkrb
|
|
|
30
35
|
map "bend_points", to: :bend_points
|
|
31
36
|
map "incoming_shape", to: :incoming_shape
|
|
32
37
|
map "outgoing_shape", to: :outgoing_shape
|
|
38
|
+
map "incoming_sections", to: :incoming_sections
|
|
39
|
+
map "outgoing_sections", to: :outgoing_sections
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
def initialize(**attributes)
|
|
@@ -63,15 +70,32 @@ module Elkrb
|
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
class Edge < Lutaml::Model::Serializable
|
|
73
|
+
include ReadOnlyMapping
|
|
74
|
+
|
|
66
75
|
attribute :id, :string
|
|
67
76
|
attribute :sources, :string, collection: true
|
|
68
77
|
attribute :targets, :string, collection: true
|
|
69
78
|
attribute :labels, Label, collection: true
|
|
70
79
|
attribute :sections, EdgeSection, collection: true
|
|
71
|
-
attribute :layout_options,
|
|
80
|
+
attribute :layout_options, :hash
|
|
72
81
|
attribute :properties, :hash
|
|
82
|
+
attribute :junction_points, Geometry::Point, collection: true
|
|
83
|
+
attribute :container, :string
|
|
73
84
|
|
|
74
|
-
|
|
85
|
+
# The legacy elkjs endpoint keys mapped at the end of the block below are
|
|
86
|
+
# read-only, and they reach every key-value format -- JSON, Hash, TOML,
|
|
87
|
+
# JSONL, YAMLS -- but NOT YAML, which declares its own block after it.
|
|
88
|
+
# Precedence: a non-empty sources/targets wins, an explicit [] counts as
|
|
89
|
+
# absent, and a NONBLANK sourcePort precedes source (in ELK JSON the port
|
|
90
|
+
# id IS the endpoint) by declaration order rather than key order. The
|
|
91
|
+
# hooks drop nil and blank ids themselves, so `"sourcePort": ""` next to
|
|
92
|
+
# a real `source` leaves the edge connected to that source; targetPort
|
|
93
|
+
# and target behave the same way.
|
|
94
|
+
#
|
|
95
|
+
# Do NOT "simplify" the pairs into a second `map "source", to: :sources`:
|
|
96
|
+
# a plain rule fires even when its key is absent, clobbering sources with
|
|
97
|
+
# nil, and emits both spellings on write.
|
|
98
|
+
key_value do
|
|
75
99
|
map "id", to: :id
|
|
76
100
|
map "sources", to: :sources
|
|
77
101
|
map "targets", to: :targets
|
|
@@ -79,6 +103,18 @@ module Elkrb
|
|
|
79
103
|
map "sections", to: :sections
|
|
80
104
|
map "layoutOptions", to: :layout_options
|
|
81
105
|
map "properties", to: :properties
|
|
106
|
+
map "junctionPoints", to: :junction_points
|
|
107
|
+
map "container", to: :container
|
|
108
|
+
|
|
109
|
+
# Read-only legacy keys; see the note above the block. Order matters.
|
|
110
|
+
map "sourcePort", with: { from: :__elkrb_merge_legacy_source,
|
|
111
|
+
to: :__elkrb_omit_from_output }
|
|
112
|
+
map "source", with: { from: :__elkrb_merge_legacy_source,
|
|
113
|
+
to: :__elkrb_omit_from_output }
|
|
114
|
+
map "targetPort", with: { from: :__elkrb_merge_legacy_target,
|
|
115
|
+
to: :__elkrb_omit_from_output }
|
|
116
|
+
map "target", with: { from: :__elkrb_merge_legacy_target,
|
|
117
|
+
to: :__elkrb_omit_from_output }
|
|
82
118
|
end
|
|
83
119
|
|
|
84
120
|
yaml do
|
|
@@ -89,6 +125,56 @@ module Elkrb
|
|
|
89
125
|
map "sections", to: :sections
|
|
90
126
|
map "layout_options", to: :layout_options
|
|
91
127
|
map "properties", to: :properties
|
|
128
|
+
map "junction_points", to: :junction_points
|
|
129
|
+
map "container", to: :container
|
|
130
|
+
|
|
131
|
+
# The same read-only legacy endpoint keys as the key_value block, so a
|
|
132
|
+
# YAML edge written the elkjs way keeps its endpoints. Order matters
|
|
133
|
+
# here too; see the note above the key_value block.
|
|
134
|
+
map "sourcePort", with: { from: :__elkrb_merge_legacy_source,
|
|
135
|
+
to: :__elkrb_omit_from_output }
|
|
136
|
+
map "source", with: { from: :__elkrb_merge_legacy_source,
|
|
137
|
+
to: :__elkrb_omit_from_output }
|
|
138
|
+
map "targetPort", with: { from: :__elkrb_merge_legacy_target,
|
|
139
|
+
to: :__elkrb_omit_from_output }
|
|
140
|
+
map "target", with: { from: :__elkrb_merge_legacy_target,
|
|
141
|
+
to: :__elkrb_omit_from_output }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Normalizes a Symbol key however the options arrive — a constructor,
|
|
145
|
+
# a plain setter, or lutaml's own deserialization, which routes through
|
|
146
|
+
# here too. This is the single normalization point: a :hash-typed
|
|
147
|
+
# attribute otherwise stores a raw, unnormalized Hash.
|
|
148
|
+
def layout_options=(value)
|
|
149
|
+
value_set_for(:layout_options)
|
|
150
|
+
attr = self.class.attributes(lutaml_register)[:layout_options]
|
|
151
|
+
cast = attr.cast_value(DeepStringifyKeys.call(value), lutaml_register)
|
|
152
|
+
instance_variable_set(:@layout_options, NormalizeOptionKeys.call(cast))
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Serialization hooks for the legacy endpoint keys above. Public because
|
|
156
|
+
# lutaml invokes them with `public_send`; the `__elkrb_` prefix keeps a
|
|
157
|
+
# subclass from taking the name by accident. Not part of the supported
|
|
158
|
+
# API.
|
|
159
|
+
#
|
|
160
|
+
# @api private
|
|
161
|
+
def __elkrb_merge_legacy_source(model, value)
|
|
162
|
+
ids = __elkrb_endpoint_ids(value)
|
|
163
|
+
model.sources = ids if ids.any? && Array(model.sources).empty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @api private
|
|
167
|
+
def __elkrb_merge_legacy_target(model, value)
|
|
168
|
+
ids = __elkrb_endpoint_ids(value)
|
|
169
|
+
model.targets = ids if ids.any? && Array(model.targets).empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# Drops nil and blank ids. Without this a `"sourcePort": ""` would win
|
|
175
|
+
# over a real `"source"` and leave the edge with an empty endpoint.
|
|
176
|
+
def __elkrb_endpoint_ids(value)
|
|
177
|
+
Array(value).reject { |id| id.nil? || id.to_s.strip.empty? }
|
|
92
178
|
end
|
|
93
179
|
end
|
|
94
180
|
end
|