graphomaton 0.1.1 → 1.1.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.md +78 -3
- data/README.md +454 -27
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +284 -0
- data/lib/graphomaton/exporters/mermaid.rb +774 -0
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +284 -0
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2872 -0
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +13 -0
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2865 -240
- data/sig/graphomaton.rbs +127 -0
- metadata +39 -17
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -19
- data/sample/complex.rb +0 -24
- data/sample/nfa.rb +0 -19
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
|
|
5
|
+
require_relative 'svg'
|
|
6
|
+
|
|
7
|
+
class Graphomaton
|
|
8
|
+
module Exporters
|
|
9
|
+
class Webp
|
|
10
|
+
include Graphomaton::ExporterIntrospection
|
|
11
|
+
class ConversionError < Graphomaton::ConversionError; end
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONVERTER = :auto
|
|
14
|
+
DEFAULT_TIMEOUT = ProcessRunner::DEFAULT_TIMEOUT
|
|
15
|
+
DEFAULT_MAX_OUTPUT_BYTES = ProcessRunner::DEFAULT_MAX_STDOUT_BYTES
|
|
16
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
17
|
+
|
|
18
|
+
CONVERTER_COMMANDS = {
|
|
19
|
+
rsvg_magick: [
|
|
20
|
+
['rsvg-convert', '--format', 'png', '-'],
|
|
21
|
+
['magick', 'png:-', 'webp:-']
|
|
22
|
+
],
|
|
23
|
+
magick: ['magick', 'svg:-', 'webp:-'],
|
|
24
|
+
convert: ['convert', 'svg:-', 'webp:-']
|
|
25
|
+
}.freeze
|
|
26
|
+
CONVERTER_OPTIONS = ([:auto] + CONVERTER_COMMANDS.keys).freeze
|
|
27
|
+
|
|
28
|
+
def self.available?(converter: DEFAULT_CONVERTER)
|
|
29
|
+
!available_command(converter: converter).nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.available_command(converter: DEFAULT_CONVERTER)
|
|
33
|
+
resolved_converter = resolve_converter(converter)
|
|
34
|
+
if resolved_converter != :auto
|
|
35
|
+
command = CONVERTER_COMMANDS[resolved_converter]
|
|
36
|
+
return command if command_available?(command)
|
|
37
|
+
end
|
|
38
|
+
return nil if resolved_converter != :auto
|
|
39
|
+
|
|
40
|
+
CONVERTER_COMMANDS.values.find { |command| command_available?(command) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(automaton)
|
|
44
|
+
@automaton = automaton
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def export(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
|
|
48
|
+
timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
|
|
49
|
+
export_result(
|
|
50
|
+
width,
|
|
51
|
+
height,
|
|
52
|
+
theme: theme,
|
|
53
|
+
converter: converter,
|
|
54
|
+
timeout: timeout,
|
|
55
|
+
max_output_bytes: max_output_bytes,
|
|
56
|
+
**svg_options
|
|
57
|
+
).output
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def export_result(width = 800, height = 600, theme: Svg::DEFAULT_THEME, converter: DEFAULT_CONVERTER,
|
|
61
|
+
timeout: DEFAULT_TIMEOUT, max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES, **svg_options)
|
|
62
|
+
command = available_command(converter: converter)
|
|
63
|
+
raise ConversionError, missing_converter_message(converter) unless command
|
|
64
|
+
|
|
65
|
+
svg_result = Svg.new(@automaton).export_result(width, height, theme: theme, **svg_options)
|
|
66
|
+
webp, error, status = run_conversion(
|
|
67
|
+
command,
|
|
68
|
+
svg_result.output,
|
|
69
|
+
timeout: timeout,
|
|
70
|
+
max_output_bytes: max_output_bytes
|
|
71
|
+
)
|
|
72
|
+
webp = webp.b
|
|
73
|
+
|
|
74
|
+
if status.success? && webp?(webp)
|
|
75
|
+
return RenderResult.new(
|
|
76
|
+
output: webp.freeze,
|
|
77
|
+
diagnostics: svg_result.diagnostics,
|
|
78
|
+
bounds: svg_result.bounds,
|
|
79
|
+
layout: svg_result.layout
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
raise ConversionError, invalid_webp_message(command, error) if status.success?
|
|
83
|
+
|
|
84
|
+
raise ConversionError, failed_conversion_message(command, error)
|
|
85
|
+
rescue ProcessRunner::Error => e
|
|
86
|
+
raise ConversionError, failed_conversion_message(command, e.message)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def available_command(converter: DEFAULT_CONVERTER)
|
|
92
|
+
self.class.available_command(converter: converter)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.executable?(command)
|
|
96
|
+
!ProcessRunner.which(command).nil?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.command_available?(command)
|
|
100
|
+
stages = command.first.is_a?(Array) ? command : [command]
|
|
101
|
+
stages.all? { |stage| executable?(stage.first) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.resolve_converter(converter)
|
|
105
|
+
resolved = converter.to_sym
|
|
106
|
+
return resolved if CONVERTER_OPTIONS.include?(resolved)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Unknown WebP converter: #{converter.inspect}. Available converters: #{CONVERTER_OPTIONS.join(', ')}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def webp?(data)
|
|
112
|
+
data.start_with?('RIFF') && data.byteslice(8, 4) == 'WEBP'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def run_conversion(command, svg, timeout:, max_output_bytes:)
|
|
116
|
+
unless command.first.is_a?(Array)
|
|
117
|
+
return ProcessRunner.capture3(
|
|
118
|
+
*command,
|
|
119
|
+
stdin_data: svg,
|
|
120
|
+
binmode: true,
|
|
121
|
+
timeout: timeout,
|
|
122
|
+
max_stdout_bytes: max_output_bytes
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
raster_command, webp_command = command
|
|
127
|
+
png, raster_error, raster_status = ProcessRunner.capture3(
|
|
128
|
+
*raster_command,
|
|
129
|
+
stdin_data: svg,
|
|
130
|
+
binmode: true,
|
|
131
|
+
timeout: timeout,
|
|
132
|
+
max_stdout_bytes: max_output_bytes
|
|
133
|
+
)
|
|
134
|
+
unless raster_status.success? && png.b.start_with?(PNG_SIGNATURE)
|
|
135
|
+
detail = raster_error.to_s.strip
|
|
136
|
+
detail = 'converter did not produce PNG data' if detail.empty?
|
|
137
|
+
raise ConversionError, "Failed to rasterize SVG using #{raster_command.first}: #{detail}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
ProcessRunner.capture3(
|
|
141
|
+
*webp_command,
|
|
142
|
+
stdin_data: png,
|
|
143
|
+
binmode: true,
|
|
144
|
+
timeout: timeout,
|
|
145
|
+
max_stdout_bytes: max_output_bytes
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def missing_converter_message(converter)
|
|
150
|
+
resolved_converter = self.class.resolve_converter(converter)
|
|
151
|
+
required = if resolved_converter == :auto
|
|
152
|
+
'magick or convert (optionally with rsvg-convert)'
|
|
153
|
+
else
|
|
154
|
+
CONVERTER_COMMANDS[resolved_converter].first
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
"WebP export requires #{required} to be installed. #{install_hint}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def install_hint
|
|
161
|
+
'Install hints: macOS: brew install imagemagick librsvg; Debian/Ubuntu: apt install imagemagick librsvg2-bin; Windows: install ImageMagick.'
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def failed_conversion_message(command, error)
|
|
165
|
+
detail = error.to_s.strip
|
|
166
|
+
detail = 'unknown error' if detail.empty?
|
|
167
|
+
|
|
168
|
+
"Failed to convert SVG to WebP using #{converter_name(command)}: #{detail}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def invalid_webp_message(command, error)
|
|
172
|
+
detail = error.to_s.strip
|
|
173
|
+
return "Failed to convert SVG to WebP using #{converter_name(command)}: converter did not produce WebP data" if detail.empty?
|
|
174
|
+
|
|
175
|
+
"Failed to convert SVG to WebP using #{converter_name(command)}: converter did not produce WebP data (#{detail})"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def converter_name(command)
|
|
179
|
+
return command.first unless command.first.is_a?(Array)
|
|
180
|
+
|
|
181
|
+
command.map(&:first).join(' + ')
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Exporters
|
|
5
|
+
autoload :Svg, File.expand_path('exporters/svg', __dir__)
|
|
6
|
+
autoload :Png, File.expand_path('exporters/png', __dir__)
|
|
7
|
+
autoload :Pdf, File.expand_path('exporters/pdf', __dir__)
|
|
8
|
+
autoload :Webp, File.expand_path('exporters/webp', __dir__)
|
|
9
|
+
autoload :Mermaid, File.expand_path('exporters/mermaid', __dir__)
|
|
10
|
+
autoload :Dot, File.expand_path('exporters/dot', __dir__)
|
|
11
|
+
autoload :Plantuml, File.expand_path('exporters/plantuml', __dir__)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
# Assigns deterministic, collision-free identifiers within one export.
|
|
5
|
+
class IdentifierAllocator
|
|
6
|
+
def initialize(reserved: [])
|
|
7
|
+
@identifiers = {}
|
|
8
|
+
@used = reserved.to_h { |identifier| [identifier.to_s, true] }
|
|
9
|
+
@counters = Hash.new(0)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def allocate(key, preferred: nil, prefix: 'id')
|
|
13
|
+
return @identifiers[key] if @identifiers.key?(key)
|
|
14
|
+
|
|
15
|
+
candidate = preferred.to_s unless preferred.nil?
|
|
16
|
+
candidate = nil if candidate&.empty? || @used.key?(candidate)
|
|
17
|
+
candidate ||= next_identifier(prefix)
|
|
18
|
+
|
|
19
|
+
@used[candidate] = true
|
|
20
|
+
@identifiers[key] = candidate
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def next_identifier(prefix)
|
|
26
|
+
loop do
|
|
27
|
+
@counters[prefix] += 1
|
|
28
|
+
candidate = "#{prefix}_#{@counters[prefix]}"
|
|
29
|
+
return candidate unless @used.key?(candidate)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
# Validates untrusted model input before it reaches exporters or graph analysis.
|
|
5
|
+
class InputPolicy
|
|
6
|
+
XML_INVALID_CHARACTERS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF]/
|
|
7
|
+
TOP_LEVEL_KEYS = %i[version states transitions initial initial_state final final_states].freeze
|
|
8
|
+
STATE_KEYS = %i[id name x y label style metadata shape kind initial final accepting].freeze
|
|
9
|
+
TRANSITION_KEYS = %i[from to label style metadata line_style].freeze
|
|
10
|
+
|
|
11
|
+
def self.text!(value, context:, max_bytes: nil)
|
|
12
|
+
return value unless value.is_a?(String)
|
|
13
|
+
|
|
14
|
+
unless value.encoding == Encoding::UTF_8 ? value.valid_encoding? : value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
15
|
+
raise ArgumentError, "#{context} must be valid UTF-8"
|
|
16
|
+
end
|
|
17
|
+
utf8 = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
18
|
+
raise ArgumentError, "#{context} contains characters that are invalid in XML" if utf8.match?(XML_INVALID_CHARACTERS)
|
|
19
|
+
if max_bytes && utf8.bytesize > max_bytes
|
|
20
|
+
raise ArgumentError, "#{context} exceeds max_label_length (#{max_bytes})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.identifier!(value, context:)
|
|
27
|
+
raise ArgumentError, "#{context} cannot be nil" if value.nil?
|
|
28
|
+
|
|
29
|
+
text!(value, context: context)
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.label!(value, context:, max_bytes: nil)
|
|
34
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
35
|
+
raise ArgumentError, "#{context} must be scalar text or a Graphomaton::Label"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
text!(value.to_s, context: context, max_bytes: max_bytes) unless value.nil?
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.mapping!(value, context:)
|
|
43
|
+
return value if value.nil? || value.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
raise ArgumentError, "#{context} must be a Hash"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.boolean!(value, context:)
|
|
49
|
+
return value if value == true || value == false || value.nil?
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "#{context} must be true or false"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.known_keys!(hash, allowed, context:, strict:)
|
|
55
|
+
return unless strict
|
|
56
|
+
|
|
57
|
+
unknown = hash.keys.reject { |key| allowed.include?(key.to_s.to_sym) }
|
|
58
|
+
return if unknown.empty?
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Unknown #{context} keys: #{unknown.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.nested_depth!(value, maximum:, context:, max_string_bytes: nil)
|
|
64
|
+
raise ArgumentError, 'max_metadata_depth must be a positive Integer' unless maximum.is_a?(Integer) && maximum.positive?
|
|
65
|
+
|
|
66
|
+
stack = [[value, 1]]
|
|
67
|
+
visited = {}
|
|
68
|
+
until stack.empty?
|
|
69
|
+
current, depth = stack.pop
|
|
70
|
+
text!(current, context: context, max_bytes: max_string_bytes) if current.is_a?(String)
|
|
71
|
+
next unless current.is_a?(Hash) || current.is_a?(Array)
|
|
72
|
+
next if visited[current.object_id]
|
|
73
|
+
|
|
74
|
+
raise ArgumentError, "#{context} exceeds max_metadata_depth (#{maximum})" if depth > maximum
|
|
75
|
+
|
|
76
|
+
visited[current.object_id] = true
|
|
77
|
+
children = current.is_a?(Hash) ? current.flat_map { |key, item| [key, item] } : current
|
|
78
|
+
children.each { |child| stack << [child, depth + 1] }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Layout
|
|
5
|
+
class ForceTree
|
|
6
|
+
DEFAULT_THETA = 0.6
|
|
7
|
+
LEAF_CAPACITY = 4
|
|
8
|
+
MAX_DEPTH = 20
|
|
9
|
+
|
|
10
|
+
Node = Struct.new(
|
|
11
|
+
:left, :top, :size, :mass, :center_x, :center_y, :points, :children,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def initialize(positions)
|
|
16
|
+
@root = build_root(positions)
|
|
17
|
+
positions.each { |name, position| insert(@root, name, position, 0) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def force_on(name, position, coefficient, theta: DEFAULT_THETA, &coincident_delta)
|
|
21
|
+
force_from_node(@root, name, position, coefficient.to_f, theta.to_f, coincident_delta)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_root(positions)
|
|
27
|
+
coordinates = positions.values
|
|
28
|
+
return Node.new(left: 0.0, top: 0.0, size: 1.0, mass: 0, center_x: 0.0, center_y: 0.0, points: []) if coordinates.empty?
|
|
29
|
+
|
|
30
|
+
xs = coordinates.map { |position| position[:x].to_f }
|
|
31
|
+
ys = coordinates.map { |position| position[:y].to_f }
|
|
32
|
+
min_x, max_x = xs.minmax
|
|
33
|
+
min_y, max_y = ys.minmax
|
|
34
|
+
size = [max_x - min_x, max_y - min_y, 1.0].max
|
|
35
|
+
Node.new(left: min_x, top: min_y, size: size, mass: 0, center_x: 0.0, center_y: 0.0, points: [])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def insert(node, name, position, depth)
|
|
39
|
+
x = position[:x].to_f
|
|
40
|
+
y = position[:y].to_f
|
|
41
|
+
previous_mass = node.mass
|
|
42
|
+
node.mass += 1
|
|
43
|
+
node.center_x = ((node.center_x * previous_mass) + x) / node.mass
|
|
44
|
+
node.center_y = ((node.center_y * previous_mass) + y) / node.mass
|
|
45
|
+
|
|
46
|
+
if node.children
|
|
47
|
+
insert(child_for(node, x, y), name, position, depth + 1)
|
|
48
|
+
elsif node.points.size < LEAF_CAPACITY || depth >= MAX_DEPTH
|
|
49
|
+
node.points << [name, position]
|
|
50
|
+
else
|
|
51
|
+
existing = node.points
|
|
52
|
+
node.points = []
|
|
53
|
+
node.children = subdivide(node)
|
|
54
|
+
existing.each { |point_name, point| insert_without_mass(child_for(node, point[:x], point[:y]), point_name, point, depth + 1) }
|
|
55
|
+
insert_without_mass(child_for(node, x, y), name, position, depth + 1)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def insert_without_mass(node, name, position, depth)
|
|
60
|
+
insert(node, name, position, depth)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def subdivide(node)
|
|
64
|
+
half = node.size / 2.0
|
|
65
|
+
[
|
|
66
|
+
Node.new(left: node.left, top: node.top, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
|
|
67
|
+
Node.new(left: node.left + half, top: node.top, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
|
|
68
|
+
Node.new(left: node.left, top: node.top + half, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: []),
|
|
69
|
+
Node.new(left: node.left + half, top: node.top + half, size: half, mass: 0, center_x: 0.0, center_y: 0.0, points: [])
|
|
70
|
+
]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def child_for(node, x, y)
|
|
74
|
+
horizontal = x.to_f >= node.left + (node.size / 2.0) ? 1 : 0
|
|
75
|
+
vertical = y.to_f >= node.top + (node.size / 2.0) ? 1 : 0
|
|
76
|
+
node.children[(vertical * 2) + horizontal]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def force_from_node(node, name, position, coefficient, theta, coincident_delta)
|
|
80
|
+
return [0.0, 0.0] if node.mass.zero?
|
|
81
|
+
|
|
82
|
+
if node.children.nil?
|
|
83
|
+
return node.points.each_with_object([0.0, 0.0]) do |(other_name, other_position), force|
|
|
84
|
+
next if other_name == name
|
|
85
|
+
|
|
86
|
+
delta_x = position[:x].to_f - other_position[:x].to_f
|
|
87
|
+
delta_y = position[:y].to_f - other_position[:y].to_f
|
|
88
|
+
if delta_x.zero? && delta_y.zero?
|
|
89
|
+
delta_x, delta_y = coincident_delta.call(name, other_name)
|
|
90
|
+
end
|
|
91
|
+
add_repulsion(force, delta_x, delta_y, coefficient, 1)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
delta_x = position[:x].to_f - node.center_x
|
|
96
|
+
delta_y = position[:y].to_f - node.center_y
|
|
97
|
+
distance = Math.hypot(delta_x, delta_y)
|
|
98
|
+
contains_target = contains?(node, position[:x].to_f, position[:y].to_f)
|
|
99
|
+
if !contains_target && distance.positive? && (node.size / distance) < theta
|
|
100
|
+
force = [0.0, 0.0]
|
|
101
|
+
add_repulsion(force, delta_x, delta_y, coefficient, node.mass)
|
|
102
|
+
return force
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
node.children.each_with_object([0.0, 0.0]) do |child, force|
|
|
106
|
+
child_force = force_from_node(child, name, position, coefficient, theta, coincident_delta)
|
|
107
|
+
force[0] += child_force[0]
|
|
108
|
+
force[1] += child_force[1]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def contains?(node, x, y)
|
|
113
|
+
x >= node.left && x <= node.left + node.size && y >= node.top && y <= node.top + node.size
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def add_repulsion(force, delta_x, delta_y, coefficient, mass)
|
|
117
|
+
distance = Math.hypot(delta_x, delta_y)
|
|
118
|
+
return force unless distance.positive?
|
|
119
|
+
|
|
120
|
+
magnitude = coefficient * mass / distance
|
|
121
|
+
force[0] += (delta_x / distance) * magnitude
|
|
122
|
+
force[1] += (delta_y / distance) * magnitude
|
|
123
|
+
force
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
class Graphomaton
|
|
6
|
+
module ModelValue
|
|
7
|
+
def self.copy(value, copies = {})
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
return copies[value.object_id] if copies.key?(value.object_id)
|
|
11
|
+
|
|
12
|
+
duplicate = {}
|
|
13
|
+
copies[value.object_id] = duplicate
|
|
14
|
+
value.each { |key, item| duplicate[copy(key, copies)] = copy(item, copies) }
|
|
15
|
+
duplicate.freeze
|
|
16
|
+
when Array
|
|
17
|
+
return copies[value.object_id] if copies.key?(value.object_id)
|
|
18
|
+
|
|
19
|
+
duplicate = []
|
|
20
|
+
copies[value.object_id] = duplicate
|
|
21
|
+
value.each { |item| duplicate << copy(item, copies) }
|
|
22
|
+
duplicate.freeze
|
|
23
|
+
when Set
|
|
24
|
+
return copies[value.object_id] if copies.key?(value.object_id)
|
|
25
|
+
|
|
26
|
+
duplicate = Set.new
|
|
27
|
+
copies[value.object_id] = duplicate
|
|
28
|
+
value.each { |item| duplicate << copy(item, copies) }
|
|
29
|
+
duplicate.freeze
|
|
30
|
+
when String
|
|
31
|
+
value.dup.freeze
|
|
32
|
+
else
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_constant :ModelValue
|
|
38
|
+
|
|
39
|
+
Label = Data.define(:kind, :value) do
|
|
40
|
+
KINDS = %i[text symbols epsilon uml].freeze
|
|
41
|
+
|
|
42
|
+
def initialize(kind:, value: nil)
|
|
43
|
+
resolved_kind = kind.to_sym
|
|
44
|
+
raise ArgumentError, "Unknown label kind: #{kind.inspect}" unless KINDS.include?(resolved_kind)
|
|
45
|
+
|
|
46
|
+
normalized_value = case resolved_kind
|
|
47
|
+
when :text
|
|
48
|
+
value.to_s
|
|
49
|
+
when :symbols
|
|
50
|
+
symbols = Array(value).map(&:to_s)
|
|
51
|
+
raise ArgumentError, 'Symbol label requires at least one symbol' if symbols.empty?
|
|
52
|
+
|
|
53
|
+
symbols
|
|
54
|
+
when :epsilon
|
|
55
|
+
value.nil? ? Graphomaton::DEFAULT_EPSILON_LABEL : value.to_s
|
|
56
|
+
when :uml
|
|
57
|
+
raise ArgumentError, 'UML label value must be a Hash' unless value.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
uml_value = {
|
|
60
|
+
event: value.key?(:event) ? value[:event] : value['event'],
|
|
61
|
+
guard: value.key?(:guard) ? value[:guard] : value['guard'],
|
|
62
|
+
action: value.key?(:action) ? value[:action] : value['action']
|
|
63
|
+
}.compact
|
|
64
|
+
raise ArgumentError, 'UML label requires an event' unless uml_value.key?(:event)
|
|
65
|
+
|
|
66
|
+
uml_value
|
|
67
|
+
end
|
|
68
|
+
super(kind: resolved_kind, value: ModelValue.copy(normalized_value))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.text(value)
|
|
72
|
+
new(kind: :text, value: value)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.symbols(*values)
|
|
76
|
+
new(kind: :symbols, value: values.flatten)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.epsilon(display = Graphomaton::DEFAULT_EPSILON_LABEL)
|
|
80
|
+
new(kind: :epsilon, value: display)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.uml(event:, guard: nil, action: nil)
|
|
84
|
+
new(kind: :uml, value: { event: event, guard: guard, action: action }.compact)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_s
|
|
88
|
+
case kind
|
|
89
|
+
when :symbols
|
|
90
|
+
value.join(', ')
|
|
91
|
+
when :epsilon
|
|
92
|
+
value
|
|
93
|
+
when :uml
|
|
94
|
+
event = value.fetch(:event).to_s
|
|
95
|
+
guard = value[:guard] ? " [#{value[:guard]}]" : ''
|
|
96
|
+
action = value[:action] ? " / #{value[:action]}" : ''
|
|
97
|
+
"#{event}#{guard}#{action}"
|
|
98
|
+
else
|
|
99
|
+
value.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_h
|
|
104
|
+
{ type: kind, value: value }.compact
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
State = Data.define(:id, :x, :y, :label, :style, :metadata, :shape, :kind) do
|
|
109
|
+
MISSING = Object.new.freeze
|
|
110
|
+
|
|
111
|
+
def initialize(id:, x:, y:, label:, style:, metadata:, shape:, kind:)
|
|
112
|
+
super(
|
|
113
|
+
id: ModelValue.copy(id),
|
|
114
|
+
x: x,
|
|
115
|
+
y: y,
|
|
116
|
+
label: ModelValue.copy(label),
|
|
117
|
+
style: ModelValue.copy(style),
|
|
118
|
+
metadata: ModelValue.copy(metadata),
|
|
119
|
+
shape: ModelValue.copy(shape),
|
|
120
|
+
kind: kind.nil? ? nil : kind.to_sym
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def [](key)
|
|
125
|
+
return id if key == :name || key == :id
|
|
126
|
+
|
|
127
|
+
public_send(key)
|
|
128
|
+
rescue NoMethodError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fetch(key, default = MISSING)
|
|
133
|
+
value = self[key]
|
|
134
|
+
return value unless value.nil?
|
|
135
|
+
return default unless default.equal?(MISSING)
|
|
136
|
+
|
|
137
|
+
raise KeyError, "key not found: #{key.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_h
|
|
141
|
+
output = { name: id, x: x, y: y }
|
|
142
|
+
output[:label] = label unless label.nil?
|
|
143
|
+
output[:style] = style unless style.nil?
|
|
144
|
+
output[:metadata] = metadata unless metadata.nil?
|
|
145
|
+
output[:shape] = shape unless shape.nil?
|
|
146
|
+
output[:kind] = kind unless kind.nil?
|
|
147
|
+
output
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Transition = Data.define(:id, :from, :to, :label, :style, :metadata, :line_style) do
|
|
152
|
+
def initialize(id:, from:, to:, label:, style:, metadata:, line_style:)
|
|
153
|
+
super(
|
|
154
|
+
id: id,
|
|
155
|
+
from: ModelValue.copy(from),
|
|
156
|
+
to: ModelValue.copy(to),
|
|
157
|
+
label: ModelValue.copy(label),
|
|
158
|
+
style: ModelValue.copy(style),
|
|
159
|
+
metadata: ModelValue.copy(metadata),
|
|
160
|
+
line_style: ModelValue.copy(line_style)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def [](key)
|
|
165
|
+
public_send(key)
|
|
166
|
+
rescue NoMethodError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def to_h
|
|
171
|
+
output = { from: from, to: to, label: label.is_a?(Label) ? label.to_s : label }
|
|
172
|
+
output[:style] = style unless style.nil?
|
|
173
|
+
output[:metadata] = metadata unless metadata.nil?
|
|
174
|
+
output[:line_style] = line_style unless line_style.nil?
|
|
175
|
+
output
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
Diagnostic = Data.define(:code, :severity, :path, :message, :hint) do
|
|
180
|
+
def initialize(code:, severity:, path:, message:, hint: nil)
|
|
181
|
+
super(
|
|
182
|
+
code: ModelValue.copy(code.to_s),
|
|
183
|
+
severity: severity.to_sym,
|
|
184
|
+
path: ModelValue.copy(Array(path)),
|
|
185
|
+
message: ModelValue.copy(message.to_s),
|
|
186
|
+
hint: hint.nil? ? nil : ModelValue.copy(hint.to_s)
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def to_h
|
|
191
|
+
{ code: code, severity: severity, path: path, message: message, hint: hint }.compact
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
RenderOptions = Data.define(:format, :width, :height, :options) do
|
|
197
|
+
def initialize(format: :svg, width: 800, height: 600, options: {})
|
|
198
|
+
super(format: format.to_sym, width: width, height: height, options: ModelValue.copy(options))
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
SvgOptions = Data.define(:width, :height, :options) do
|
|
203
|
+
def initialize(width: 800, height: 600, **options)
|
|
204
|
+
super(width: width, height: height, options: ModelValue.copy(options))
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
RenderResult = Data.define(:output, :diagnostics, :bounds, :layout) do
|
|
209
|
+
def initialize(output:, diagnostics:, bounds:, layout:)
|
|
210
|
+
super(
|
|
211
|
+
output: ModelValue.copy(output),
|
|
212
|
+
diagnostics: ModelValue.copy(Array(diagnostics)),
|
|
213
|
+
bounds: ModelValue.copy(bounds),
|
|
214
|
+
layout: ModelValue.copy(layout)
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|