graphomaton 0.1.0 → 1.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.md +9 -0
- data/README.md +49 -4
- data/lib/graphomaton/exporters/dot.rb +47 -0
- data/lib/graphomaton/exporters/mermaid.rb +94 -0
- data/lib/graphomaton/exporters/plantuml.rb +47 -0
- data/lib/graphomaton/exporters/svg.rb +328 -0
- data/lib/graphomaton/exporters.rb +6 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +22 -212
- data/sample/basic.rb +19 -8
- data/sample/complex.rb +9 -1
- data/sample/long_names.rb +20 -0
- data/sample/nfa.rb +9 -0
- data/sample/skip_states.rb +23 -0
- data/spec/exporters/dot_spec.rb +146 -0
- data/spec/exporters/mermaid_spec.rb +154 -0
- data/spec/exporters/plantuml_spec.rb +144 -0
- data/spec/exporters/svg_spec.rb +314 -0
- data/spec/graphomaton_edge_cases_spec.rb +322 -0
- data/spec/graphomaton_spec.rb +3 -3
- metadata +33 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01b9006b89b4b5e93348f4dd47e9c8173ed036a8cffb357a165f68d1048e8be6
|
|
4
|
+
data.tar.gz: 2b8c1975ce405f8ffa6a2f550431281cc82605f536b76efa72d6e092e05902bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 538893d73e2cdbcfe697bcf194ab259c7c796d6efdb9cd100b810ed7aa5363144819880d6d813ec76dc9350d455bdb232608b7b0ec39c00efa664cba54bcc2bd
|
|
7
|
+
data.tar.gz: 41e703f607023ceebd0aba66965084f3466cd60b414a3ba14670cc0f4e6d82391e18cd64d47911d80efa28dbb4f05378ea693de3ebb0461f84a465e9721de9ca
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.0.0 (2025-12-23)
|
|
6
|
+
|
|
7
|
+
- Add support multiple style outputs, including .dot, mermaid, and .plantuml formats.
|
|
8
|
+
- Improve output format for SVG files.
|
|
9
|
+
|
|
10
|
+
## 0.1.1 (2025-08-26)
|
|
11
|
+
|
|
12
|
+
Fix gemspec dependency declaration for 'rexml'.
|
|
13
|
+
|
|
5
14
|
## 0.1.0 (2025-08-26)
|
|
6
15
|
|
|
7
16
|
- Initial release
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Graphomaton [](https://badge.fury.io/rb/graphomaton)
|
|
1
|
+
# Graphomaton [](https://badge.fury.io/rb/graphomaton) [](https://github.com/ydah/graphomaton/actions/workflows/ci.yml)
|
|
2
2
|
|
|
3
|
-
A tiny Ruby library for generating finite state machine (automaton) diagrams
|
|
3
|
+
A tiny Ruby library for generating finite state machine (automaton) diagrams in multiple formats: SVG, HTML (Mermaid.js), GraphViz (DOT), and PlantUML.
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
@@ -49,8 +49,53 @@ automaton.add_transition('q1', 'q0', 'a')
|
|
|
49
49
|
automaton.add_transition('q2', 'q0', 'b')
|
|
50
50
|
automaton.add_transition('q2', 'q1', 'a')
|
|
51
51
|
|
|
52
|
-
# Save
|
|
53
|
-
automaton.save_svg('output.svg')
|
|
52
|
+
# Save in different formats
|
|
53
|
+
automaton.save_svg('output.svg') # SVG format
|
|
54
|
+
automaton.save_html('output.html') # HTML with Mermaid.js (requires internet)
|
|
55
|
+
automaton.save_dot('output.dot') # GraphViz DOT format
|
|
56
|
+
automaton.save_plantuml('output.puml') # PlantUML format
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Output Formats
|
|
60
|
+
|
|
61
|
+
Graphomaton supports multiple output formats:
|
|
62
|
+
|
|
63
|
+
#### 1. SVG (Native)
|
|
64
|
+
```ruby
|
|
65
|
+
automaton.save_svg('diagram.svg', width = 800, height = 600)
|
|
66
|
+
```
|
|
67
|
+
Generates a standalone SVG file with custom rendering.
|
|
68
|
+
|
|
69
|
+
#### 2. HTML (Mermaid.js)
|
|
70
|
+
```ruby
|
|
71
|
+
automaton.save_html('diagram.html')
|
|
72
|
+
```
|
|
73
|
+
Generates an HTML file with embedded Mermaid.js state diagram. The diagram is rendered in the browser using Mermaid.js from CDN.
|
|
74
|
+
|
|
75
|
+
**Note:** Requires internet connection to load Mermaid.js from CDN. Does not work in offline environments.
|
|
76
|
+
|
|
77
|
+
#### 3. GraphViz (DOT)
|
|
78
|
+
```ruby
|
|
79
|
+
automaton.save_dot('diagram.dot')
|
|
80
|
+
```
|
|
81
|
+
Generates a DOT file that can be converted to images using GraphViz:
|
|
82
|
+
```bash
|
|
83
|
+
dot -Tpng diagram.dot -o diagram.png
|
|
84
|
+
dot -Tsvg diagram.dot -o diagram.svg
|
|
85
|
+
dot -Tpdf diagram.dot -o diagram.pdf
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
#### 4. PlantUML
|
|
89
|
+
```ruby
|
|
90
|
+
automaton.save_plantuml('diagram.puml')
|
|
91
|
+
```
|
|
92
|
+
Generates a PlantUML file that can be converted to images using PlantUML server or JAR:
|
|
93
|
+
```bash
|
|
94
|
+
# Using PlantUML JAR
|
|
95
|
+
java -jar plantuml.jar diagram.puml
|
|
96
|
+
|
|
97
|
+
# Using online server
|
|
98
|
+
curl -X POST --data-binary @diagram.puml https://www.plantuml.com/plantuml/png > diagram.png
|
|
54
99
|
```
|
|
55
100
|
|
|
56
101
|
## Contributing
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Exporters
|
|
5
|
+
class Dot
|
|
6
|
+
def initialize(automaton)
|
|
7
|
+
@automaton = automaton
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def export
|
|
11
|
+
lines = ['digraph finite_state_machine {']
|
|
12
|
+
lines << ' rankdir=LR;'
|
|
13
|
+
lines << ' node [shape = circle];'
|
|
14
|
+
lines << ''
|
|
15
|
+
|
|
16
|
+
if @automaton.initial_state
|
|
17
|
+
lines << ' __start__ [shape=point];'
|
|
18
|
+
lines << " __start__ -> \"#{escape_label(@automaton.initial_state)}\";"
|
|
19
|
+
lines << ''
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless @automaton.final_states.empty?
|
|
23
|
+
final_states_str = @automaton.final_states.map { |s| "\"#{escape_label(s)}\"" }.join(' ')
|
|
24
|
+
lines << " node [shape = doublecircle]; #{final_states_str};"
|
|
25
|
+
lines << ' node [shape = circle];'
|
|
26
|
+
lines << ''
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@automaton.transitions.each do |trans|
|
|
30
|
+
from = escape_label(trans[:from])
|
|
31
|
+
to = escape_label(trans[:to])
|
|
32
|
+
label = escape_label(trans[:label])
|
|
33
|
+
lines << " \"#{from}\" -> \"#{to}\" [label=\"#{label}\"];"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
lines << '}'
|
|
37
|
+
lines.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def escape_label(label)
|
|
43
|
+
label.to_s.gsub('"', '\\"')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Exporters
|
|
5
|
+
class Mermaid
|
|
6
|
+
def initialize(automaton)
|
|
7
|
+
@automaton = automaton
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def export
|
|
11
|
+
lines = ['stateDiagram-v2']
|
|
12
|
+
|
|
13
|
+
lines << " [*] --> #{sanitize_state_name(@automaton.initial_state)}" if @automaton.initial_state
|
|
14
|
+
|
|
15
|
+
@automaton.transitions.each do |trans|
|
|
16
|
+
from = sanitize_state_name(trans[:from])
|
|
17
|
+
to = sanitize_state_name(trans[:to])
|
|
18
|
+
label = trans[:label]
|
|
19
|
+
lines << " #{from} --> #{to} : #{label}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
@automaton.final_states.each do |state|
|
|
23
|
+
lines << " #{sanitize_state_name(state)} --> [*]"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
lines.join("\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def export_html
|
|
30
|
+
mermaid_code = export
|
|
31
|
+
<<~HTML
|
|
32
|
+
<!DOCTYPE html>
|
|
33
|
+
<html lang="ja">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="UTF-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
37
|
+
<title>状態図 - Graphomaton</title>
|
|
38
|
+
<script type="module">
|
|
39
|
+
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
|
|
40
|
+
mermaid.initialize({ startOnLoad: true, theme: 'default' });
|
|
41
|
+
</script>
|
|
42
|
+
<style>
|
|
43
|
+
body {
|
|
44
|
+
font-family: Arial, sans-serif;
|
|
45
|
+
max-width: 1200px;
|
|
46
|
+
margin: 0 auto;
|
|
47
|
+
padding: 20px;
|
|
48
|
+
}
|
|
49
|
+
.mermaid {
|
|
50
|
+
text-align: center;
|
|
51
|
+
background: white;
|
|
52
|
+
border: 1px solid #ddd;
|
|
53
|
+
border-radius: 8px;
|
|
54
|
+
padding: 20px;
|
|
55
|
+
margin: 20px 0;
|
|
56
|
+
}
|
|
57
|
+
h1 {
|
|
58
|
+
color: #333;
|
|
59
|
+
text-align: center;
|
|
60
|
+
}
|
|
61
|
+
.info {
|
|
62
|
+
background: #f5f5f5;
|
|
63
|
+
padding: 10px;
|
|
64
|
+
border-radius: 4px;
|
|
65
|
+
margin-bottom: 20px;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<h1>状態図</h1>
|
|
71
|
+
<div class="info">
|
|
72
|
+
<p><strong>注意:</strong> この図はMermaid.jsを使用してブラウザ上でレンダリングされます。オフライン環境では動作しません。</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="mermaid">
|
|
75
|
+
#{mermaid_code}
|
|
76
|
+
</div>
|
|
77
|
+
</body>
|
|
78
|
+
</html>
|
|
79
|
+
HTML
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def sanitize_state_name(name)
|
|
85
|
+
sanitized = name.to_s.gsub(/[\s-]/, '_')
|
|
86
|
+
if sanitized =~ /[^\x00-\x7F]/
|
|
87
|
+
"\"#{sanitized}\""
|
|
88
|
+
else
|
|
89
|
+
sanitized
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Exporters
|
|
5
|
+
class Plantuml
|
|
6
|
+
def initialize(automaton)
|
|
7
|
+
@automaton = automaton
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def export
|
|
11
|
+
lines = ['@startuml']
|
|
12
|
+
lines << 'hide empty description'
|
|
13
|
+
lines << ''
|
|
14
|
+
|
|
15
|
+
if @automaton.initial_state
|
|
16
|
+
lines << "[*] --> #{sanitize_state_name(@automaton.initial_state)}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@automaton.transitions.each do |trans|
|
|
20
|
+
from = sanitize_state_name(trans[:from])
|
|
21
|
+
to = sanitize_state_name(trans[:to])
|
|
22
|
+
label = trans[:label]
|
|
23
|
+
lines << "#{from} --> #{to} : #{label}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@automaton.final_states.each do |state|
|
|
27
|
+
lines << "#{sanitize_state_name(state)} --> [*]"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
lines << ''
|
|
31
|
+
lines << '@enduml'
|
|
32
|
+
lines.join("\n")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def sanitize_state_name(name)
|
|
38
|
+
sanitized = name.to_s.gsub(/[\s-]/, '_')
|
|
39
|
+
if sanitized =~ /[^\x00-\x7F]/
|
|
40
|
+
"\"#{sanitized}\""
|
|
41
|
+
else
|
|
42
|
+
sanitized
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rexml/document'
|
|
4
|
+
|
|
5
|
+
class Graphomaton
|
|
6
|
+
module Exporters
|
|
7
|
+
class Svg
|
|
8
|
+
STATE_RADIUS = 40
|
|
9
|
+
STATE_INNER_RADIUS = 32
|
|
10
|
+
|
|
11
|
+
def initialize(automaton)
|
|
12
|
+
@automaton = automaton
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def export(width = 800, height = 600)
|
|
16
|
+
@automaton.auto_layout(width, height)
|
|
17
|
+
|
|
18
|
+
doc = REXML::Document.new
|
|
19
|
+
svg = doc.add_element('svg', {
|
|
20
|
+
'xmlns' => 'http://www.w3.org/2000/svg',
|
|
21
|
+
'width' => width.to_s,
|
|
22
|
+
'height' => height.to_s,
|
|
23
|
+
'viewBox' => "0 0 #{width} #{height}"
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
add_defs(svg)
|
|
27
|
+
add_style(svg)
|
|
28
|
+
add_transitions(svg)
|
|
29
|
+
add_initial_arrow(svg) if @automaton.initial_state
|
|
30
|
+
add_states(svg)
|
|
31
|
+
|
|
32
|
+
doc.to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def calculate_text_width(text)
|
|
38
|
+
ascii_chars = text.chars.count { |c| c.ascii_only? }
|
|
39
|
+
non_ascii_chars = text.length - ascii_chars
|
|
40
|
+
|
|
41
|
+
width = (ascii_chars * 8) + (non_ascii_chars * 16) + 20
|
|
42
|
+
[width, 60].max
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def calculate_state_font_size(name)
|
|
46
|
+
ascii_chars = name.chars.count { |c| c.ascii_only? }
|
|
47
|
+
non_ascii_chars = name.length - ascii_chars
|
|
48
|
+
|
|
49
|
+
estimated_width = (ascii_chars * 0.55) + (non_ascii_chars * 0.9)
|
|
50
|
+
available_width = STATE_RADIUS * 1.7
|
|
51
|
+
|
|
52
|
+
base_size = 20
|
|
53
|
+
calculated_size = if estimated_width * base_size > available_width
|
|
54
|
+
(available_width / estimated_width).floor
|
|
55
|
+
else
|
|
56
|
+
base_size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
[calculated_size, 12].max
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def add_defs(svg)
|
|
63
|
+
defs = svg.add_element('defs')
|
|
64
|
+
marker = defs.add_element('marker', {
|
|
65
|
+
'id' => 'arrowhead',
|
|
66
|
+
'markerWidth' => '10',
|
|
67
|
+
'markerHeight' => '10',
|
|
68
|
+
'refX' => '9',
|
|
69
|
+
'refY' => '3',
|
|
70
|
+
'orient' => 'auto'
|
|
71
|
+
})
|
|
72
|
+
marker.add_element('polygon', {
|
|
73
|
+
'points' => '0 0, 10 3, 0 6',
|
|
74
|
+
'fill' => '#333'
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_style(svg)
|
|
79
|
+
style = svg.add_element('style')
|
|
80
|
+
style.text = <<-CSS
|
|
81
|
+
.state-circle { fill: white; stroke: #333; stroke-width: 2; }
|
|
82
|
+
.final-state { stroke-width: 4; }
|
|
83
|
+
.state-text { font-family: Arial, sans-serif; text-anchor: middle; }
|
|
84
|
+
.transition-line { stroke: #333; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); }
|
|
85
|
+
.transition-label { font-family: Arial, sans-serif; font-size: 14px; fill: #666; }
|
|
86
|
+
.initial-arrow { stroke: #333; stroke-width: 2; fill: none; marker-end: url(#arrowhead); }
|
|
87
|
+
.label-bg { fill: white; opacity: 0.9; }
|
|
88
|
+
CSS
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def add_transitions(svg)
|
|
92
|
+
processed_pairs = {}
|
|
93
|
+
from_state_indices = {}
|
|
94
|
+
|
|
95
|
+
@automaton.transitions.each_with_index do |trans, _idx|
|
|
96
|
+
from_state = @automaton.states[trans[:from]]
|
|
97
|
+
to_state = @automaton.states[trans[:to]]
|
|
98
|
+
|
|
99
|
+
if from_state == to_state
|
|
100
|
+
add_self_loop(svg, from_state, trans)
|
|
101
|
+
else
|
|
102
|
+
from_state_indices[trans[:from]] = 0 unless from_state_indices[trans[:from]]
|
|
103
|
+
from_state_index = from_state_indices[trans[:from]]
|
|
104
|
+
from_state_indices[trans[:from]] += 1
|
|
105
|
+
|
|
106
|
+
add_transition(svg, from_state, to_state, trans, processed_pairs, from_state_index)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add_self_loop(svg, state, trans)
|
|
112
|
+
cx = state[:x]
|
|
113
|
+
cy = state[:y]
|
|
114
|
+
|
|
115
|
+
loop_height = 80
|
|
116
|
+
loop_width = 45
|
|
117
|
+
|
|
118
|
+
start_angle = -135 * Math::PI / 180
|
|
119
|
+
end_angle = -45 * Math::PI / 180
|
|
120
|
+
radius = STATE_RADIUS
|
|
121
|
+
|
|
122
|
+
start_x = cx + (radius * Math.cos(start_angle))
|
|
123
|
+
start_y = cy + (radius * Math.sin(start_angle))
|
|
124
|
+
end_x = cx + (radius * Math.cos(end_angle))
|
|
125
|
+
end_y = cy + (radius * Math.sin(end_angle))
|
|
126
|
+
|
|
127
|
+
control1_x = cx - loop_width
|
|
128
|
+
control1_y = cy - loop_height
|
|
129
|
+
control2_x = cx + loop_width
|
|
130
|
+
control2_y = cy - loop_height
|
|
131
|
+
|
|
132
|
+
path_d = "M #{start_x} #{start_y} C #{control1_x} #{control1_y}, #{control2_x} #{control2_y}, #{end_x} #{end_y}"
|
|
133
|
+
|
|
134
|
+
svg.add_element('path', {
|
|
135
|
+
'class' => 'transition-line',
|
|
136
|
+
'd' => path_d
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
text_width = calculate_text_width(trans[:label])
|
|
140
|
+
svg.add_element('rect', {
|
|
141
|
+
'class' => 'label-bg',
|
|
142
|
+
'x' => (cx - (text_width / 2)).to_s,
|
|
143
|
+
'y' => (cy - loop_height - 5).to_s,
|
|
144
|
+
'width' => text_width.to_s,
|
|
145
|
+
'height' => '20',
|
|
146
|
+
'rx' => '3'
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
label = svg.add_element('text', {
|
|
150
|
+
'class' => 'transition-label',
|
|
151
|
+
'x' => cx.to_s,
|
|
152
|
+
'y' => (cy - loop_height + 10).to_s,
|
|
153
|
+
'text-anchor' => 'middle'
|
|
154
|
+
})
|
|
155
|
+
label.text = trans[:label]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def add_transition(svg, from_state, to_state, trans, processed_pairs, from_state_index)
|
|
159
|
+
x1 = from_state[:x]
|
|
160
|
+
y1 = from_state[:y]
|
|
161
|
+
x2 = to_state[:x]
|
|
162
|
+
y2 = to_state[:y]
|
|
163
|
+
|
|
164
|
+
pair_key = [trans[:from].to_s, trans[:to].to_s].sort.join('-')
|
|
165
|
+
processed_pairs[pair_key] = 0 unless processed_pairs[pair_key]
|
|
166
|
+
|
|
167
|
+
pair_index = processed_pairs[pair_key]
|
|
168
|
+
processed_pairs[pair_key] += 1
|
|
169
|
+
|
|
170
|
+
parallel_count = @automaton.count_parallel_transitions(trans[:from], trans[:to])
|
|
171
|
+
|
|
172
|
+
dx = x2 - x1
|
|
173
|
+
dy = y2 - y1
|
|
174
|
+
dist = Math.sqrt((dx**2) + (dy**2))
|
|
175
|
+
|
|
176
|
+
radius = STATE_RADIUS
|
|
177
|
+
start_x = x1 + ((dx / dist) * radius)
|
|
178
|
+
start_y = y1 + ((dy / dist) * radius)
|
|
179
|
+
end_x = x2 - ((dx / dist) * radius)
|
|
180
|
+
end_y = y2 - ((dy / dist) * radius)
|
|
181
|
+
|
|
182
|
+
state_names = @automaton.states.keys
|
|
183
|
+
from_index = state_names.index(trans[:from])
|
|
184
|
+
to_index = state_names.index(trans[:to])
|
|
185
|
+
|
|
186
|
+
is_adjacent = (to_index - from_index).abs == 1
|
|
187
|
+
states_between = (to_index - from_index).abs - 1
|
|
188
|
+
|
|
189
|
+
if is_adjacent && x1 < x2
|
|
190
|
+
add_straight_line(svg, start_x, start_y, end_x, end_y, trans)
|
|
191
|
+
else
|
|
192
|
+
add_curved_line(svg, start_x, start_y, end_x, end_y, x1, x2, trans, parallel_count, pair_index, states_between, from_state_index)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def add_straight_line(svg, start_x, start_y, end_x, end_y, trans)
|
|
197
|
+
svg.add_element('line', {
|
|
198
|
+
'class' => 'transition-line',
|
|
199
|
+
'x1' => start_x.to_s,
|
|
200
|
+
'y1' => start_y.to_s,
|
|
201
|
+
'x2' => end_x.to_s,
|
|
202
|
+
'y2' => end_y.to_s
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
label_x = (start_x + end_x) / 2
|
|
206
|
+
label_y = ((start_y + end_y) / 2) - 10
|
|
207
|
+
|
|
208
|
+
add_label(svg, label_x, label_y, trans[:label])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def add_curved_line(svg, start_x, start_y, end_x, end_y, x1, x2, trans, parallel_count, pair_index, states_between, from_state_index)
|
|
212
|
+
mid_x = (start_x + end_x) / 2
|
|
213
|
+
mid_y = (start_y + end_y) / 2
|
|
214
|
+
|
|
215
|
+
base_offset = if states_between > 0
|
|
216
|
+
(STATE_RADIUS * 1.5) + (states_between * 30) + (from_state_index * 120)
|
|
217
|
+
else
|
|
218
|
+
STATE_RADIUS * 2
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
curve_offset = if parallel_count > 1
|
|
222
|
+
if trans[:from] < trans[:to]
|
|
223
|
+
-(base_offset + (50 * pair_index))
|
|
224
|
+
else
|
|
225
|
+
base_offset + (50 * pair_index)
|
|
226
|
+
end
|
|
227
|
+
elsif x1 < x2
|
|
228
|
+
-base_offset
|
|
229
|
+
else
|
|
230
|
+
base_offset
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
control_x = if (x2 - x1).abs < 10
|
|
234
|
+
mid_x + (50 * (pair_index.even? ? 1 : -1))
|
|
235
|
+
else
|
|
236
|
+
mid_x
|
|
237
|
+
end
|
|
238
|
+
control_y = mid_y + curve_offset
|
|
239
|
+
|
|
240
|
+
path_d = "M #{start_x} #{start_y} Q #{control_x} #{control_y}, #{end_x} #{end_y}"
|
|
241
|
+
|
|
242
|
+
svg.add_element('path', {
|
|
243
|
+
'class' => 'transition-line',
|
|
244
|
+
'd' => path_d
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
t = 0.5
|
|
248
|
+
label_x = ((1 - t) * (1 - t) * start_x) + (2 * (1 - t) * t * control_x) + (t * t * end_x)
|
|
249
|
+
label_y = ((1 - t) * (1 - t) * start_y) + (2 * (1 - t) * t * control_y) + (t * t * end_y)
|
|
250
|
+
|
|
251
|
+
add_label(svg, label_x, label_y, trans[:label])
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def add_label(svg, x, y, text)
|
|
255
|
+
text_width = calculate_text_width(text)
|
|
256
|
+
svg.add_element('rect', {
|
|
257
|
+
'class' => 'label-bg',
|
|
258
|
+
'x' => (x - (text_width / 2)).to_s,
|
|
259
|
+
'y' => (y - 10).to_s,
|
|
260
|
+
'width' => text_width.to_s,
|
|
261
|
+
'height' => '20',
|
|
262
|
+
'rx' => '3'
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
label = svg.add_element('text', {
|
|
266
|
+
'class' => 'transition-label',
|
|
267
|
+
'x' => x.to_s,
|
|
268
|
+
'y' => (y + 5).to_s,
|
|
269
|
+
'text-anchor' => 'middle'
|
|
270
|
+
})
|
|
271
|
+
label.text = text
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def add_initial_arrow(svg)
|
|
275
|
+
init = @automaton.states[@automaton.initial_state]
|
|
276
|
+
return unless init
|
|
277
|
+
|
|
278
|
+
svg.add_element('line', {
|
|
279
|
+
'class' => 'initial-arrow',
|
|
280
|
+
'x1' => (init[:x] - 60).to_s,
|
|
281
|
+
'y1' => init[:y].to_s,
|
|
282
|
+
'x2' => (init[:x] - 30).to_s,
|
|
283
|
+
'y2' => init[:y].to_s
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
start_label = svg.add_element('text', {
|
|
287
|
+
'class' => 'transition-label',
|
|
288
|
+
'x' => (init[:x] - 70).to_s,
|
|
289
|
+
'y' => (init[:y] - 10).to_s,
|
|
290
|
+
'text-anchor' => 'end'
|
|
291
|
+
})
|
|
292
|
+
start_label.text = 'start'
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def add_states(svg)
|
|
296
|
+
@automaton.states.each do |name, state|
|
|
297
|
+
circle_class = 'state-circle'
|
|
298
|
+
circle_class += ' final-state' if @automaton.final_states.include?(name)
|
|
299
|
+
|
|
300
|
+
svg.add_element('circle', {
|
|
301
|
+
'class' => circle_class,
|
|
302
|
+
'cx' => state[:x].to_s,
|
|
303
|
+
'cy' => state[:y].to_s,
|
|
304
|
+
'r' => STATE_RADIUS.to_s
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
if @automaton.final_states.include?(name)
|
|
308
|
+
svg.add_element('circle', {
|
|
309
|
+
'class' => 'state-circle',
|
|
310
|
+
'cx' => state[:x].to_s,
|
|
311
|
+
'cy' => state[:y].to_s,
|
|
312
|
+
'r' => STATE_INNER_RADIUS.to_s
|
|
313
|
+
})
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
font_size = calculate_state_font_size(name.to_s)
|
|
317
|
+
text = svg.add_element('text', {
|
|
318
|
+
'class' => 'state-text',
|
|
319
|
+
'x' => state[:x].to_s,
|
|
320
|
+
'y' => (state[:y] + (font_size * 0.35)).to_s,
|
|
321
|
+
'font-size' => font_size.to_s
|
|
322
|
+
})
|
|
323
|
+
text.text = name.to_s
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
data/lib/graphomaton/version.rb
CHANGED