railroad_diagrams 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Diagram < DiagramMultiContainer
5
+ def initialize(*items, **kwargs)
6
+ super('svg', items.to_a, { 'class' => DIAGRAM_CLASS })
7
+ @type = kwargs.fetch(:type, 'simple')
8
+
9
+ if @items.any?
10
+ @items.unshift(Start.new(@type)) unless @items.first.is_a?(Start)
11
+ @items.push(End.new(@type)) unless @items.last.is_a?(End)
12
+ end
13
+
14
+ @up = 0
15
+ @down = 0
16
+ @height = 0
17
+ @width = 0
18
+
19
+ @items.each do |item|
20
+ next if item.is_a?(Style)
21
+
22
+ @width += item.width + (item.needs_space ? 20 : 0)
23
+ @up = [@up, item.up - @height].max
24
+ @height += item.height
25
+ @down = [@down - item.height, item.down].max
26
+ end
27
+
28
+ @width -= 10 if @items[0].needs_space
29
+ @width -= 10 if @items[-1].needs_space
30
+ @formatted = false
31
+ end
32
+
33
+ def to_s
34
+ items = items.map(&:to_s).join(', ')
35
+ pieces = items ? [items] : []
36
+ pieces.push("type=#{@type}") if @type != 'simple'
37
+ "Diagram(#{pieces.join(', ')})"
38
+ end
39
+
40
+ def format(padding_top = 20, padding_right = nil, padding_bottom = nil, padding_left = nil)
41
+ padding_right = padding_top if padding_right.nil?
42
+ padding_bottom = padding_top if padding_bottom.nil?
43
+ padding_left = padding_right if padding_left.nil?
44
+
45
+ x = padding_left
46
+ y = padding_top + @up
47
+ g = DiagramItem.new('g')
48
+ g.attrs['transform'] = 'translate(.5 .5)' if STROKE_ODD_PIXEL_LENGTH
49
+
50
+ @items.each do |item|
51
+ if item.needs_space
52
+ Path.new(x, y).h(10).add(g)
53
+ x += 10
54
+ end
55
+ item.format(x, y, item.width).add(g)
56
+ x += item.width
57
+ y += item.height
58
+ if item.needs_space
59
+ Path.new(x, y).h(10).add(g)
60
+ x += 10
61
+ end
62
+ end
63
+
64
+ @attrs['width'] = (@width + padding_left + padding_right).to_s
65
+ @attrs['height'] = (@up + @height + @down + padding_top + padding_bottom).to_s
66
+ @attrs['viewBox'] = "0 0 #{@attrs['width']} #{@attrs['height']}"
67
+ g.add(self)
68
+ @formatted = true
69
+ self
70
+ end
71
+
72
+ def text_diagram
73
+ separator, = TextDiagram.get_parts(['separator'])
74
+ diagram_td = items[0].text_diagram
75
+ items[1..].each do |item|
76
+ item_td = item.text_diagram
77
+ item_td.expand(1, 1, 0, 0) if item.needs_space
78
+ diagram_td = diagram_td.append_right(separator)
79
+ end
80
+ end
81
+
82
+ def write_svg(write)
83
+ format unless @formatted
84
+
85
+ super
86
+ end
87
+
88
+ def write_text(_write)
89
+ output = text_diagram
90
+ output = "#{output.lines.join("\n")}\n"
91
+ output = output.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;').gsub('"', '&quot;') if ESCAPE_HTML
92
+ write(output)
93
+ end
94
+
95
+ def write_standalone(write, css = nil)
96
+ format unless @formatted
97
+ css = Style.default_style if css
98
+ Style.new(css).add(self)
99
+ @attrs['xmlns'] = 'http://www.w3.org/2000/svg'
100
+ @attrs['xmlns:xlink'] = 'http://www.w3.org/1999/xlink'
101
+ DiagramItem.write_svg(write)
102
+ @children.pop
103
+ @attrs.delete('xmlns')
104
+ @attrs.delete('xmlns:xlink')
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class DiagramItem
5
+ attr_reader :up, :down, :height, :width, :needs_space, :attrs, :children
6
+
7
+ def initialize(name, attrs: {}, text: nil)
8
+ @name = name
9
+ @up = 0
10
+ @height = 0
11
+ @down = 0
12
+ @width = 0
13
+ @needs_space = false
14
+ @attrs = attrs || {}
15
+ @children = text ? [text] : []
16
+ end
17
+
18
+ def format(x, y, width)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def text_diagram
23
+ raise NotImplementedError 'Virtual'
24
+ end
25
+
26
+ def add(parent)
27
+ parent.children.push self
28
+ self
29
+ end
30
+
31
+ def write_svg(write)
32
+ write.call("<#{@name}")
33
+ @attrs.sort.each do |name, value|
34
+ write.call(" #{name}=\"#{RailroadDiagrams.escape_attr(value)}\"")
35
+ end
36
+ write.call('>')
37
+ write.call("\n") if @name in %w[g svg]
38
+ @children.each do |child|
39
+ if child.is_a?(DiagramItem) || child.is_a?(Path) || child.is_a?(Style)
40
+ child.write_svg(write)
41
+ else
42
+ write.call(RailroadDiagrams.escape_html(child))
43
+ end
44
+ end
45
+ write.call("</#{@name}>")
46
+ end
47
+
48
+ def walk(_callback)
49
+ callback(self)
50
+ end
51
+
52
+ def to_str
53
+ "DiagramItem(#{@name}, #{@attrs}, #{@children})"
54
+ end
55
+
56
+ private
57
+
58
+ def wrap_string(value)
59
+ if value.class <= DiagramItem
60
+ value
61
+ else
62
+ Terminal.new(value)
63
+ end
64
+ end
65
+
66
+ def determine_gaps(outer, inner)
67
+ diff = outer - inner
68
+ if INTERNAL_ALIGNMENT == 'left'
69
+ [0, diff]
70
+ elsif INTERNAL_ALIGNMENT == 'right'
71
+ [diff, 0]
72
+ else
73
+ [diff / 2, diff / 2]
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class DiagramMultiContainer < DiagramItem
5
+ def initialize(name, items, attrs = nil, text = nil)
6
+ super(name, attrs:, text:)
7
+ @items = items.map { |item| wrap_string(item) }
8
+ end
9
+
10
+ def format(x, y, width)
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def walk(callback)
15
+ callback(self)
16
+ @items.each { |item| item.walk(callback) }
17
+ end
18
+
19
+ def to_str
20
+ "DiagramMultiContainer(#{@name}, #{@items}, #{@attrs}, #{@children})"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class End < DiagramItem
5
+ def initialize(type = 'simple')
6
+ super('path')
7
+ @width = 20
8
+ @up = 10
9
+ @down = 10
10
+ @type = type
11
+ end
12
+
13
+ def to_s
14
+ "End(type=#{@type})"
15
+ end
16
+
17
+ def format(x, y, _width)
18
+ @attrs['d'] =
19
+ if @type == 'simple'
20
+ "M #{x} #{y} h 20 m -10 -10 v 20 m 10 -20 v 20"
21
+ else
22
+ "M #{x} #{y} h 20 m 0 -10 v 20"
23
+ end
24
+ self
25
+ end
26
+
27
+ def text_diagram
28
+ cross, line, tee_left = TextDiagram.get_parts(%w[cross line tee_left])
29
+ end_node =
30
+ if @type == 'simple'
31
+ line + cross + tee_left
32
+ else
33
+ line + tee_left
34
+ end
35
+
36
+ TextDiagram.new(0, 0, [end_node])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class Group < DiagramItem
5
+ def initialize(item, label = nil)
6
+ super('g')
7
+ @item = wrap_string(item)
8
+
9
+ @label =
10
+ if label.is_a?(DiagramItem)
11
+ label
12
+ elsif label
13
+ Comment.new(label)
14
+ end
15
+
16
+ item_width = @item.width + (@item.needs_space ? 20 : 0)
17
+ label_width = @label ? @label.width : 0
18
+ @width = [item_width, label_width, AR * 2].max
19
+
20
+ @height = @item.height
21
+
22
+ @box_up = [@item.up + VS, AR].max
23
+ @up = @box_up
24
+ @up += @label.up + @label.height + @label.down if @label
25
+
26
+ @down = [@item.down + VS, AR].max
27
+
28
+ @needs_space = true
29
+ end
30
+
31
+ def to_s
32
+ "Group(#{@item}, label=#{@label})"
33
+ end
34
+
35
+ def format(x, y, width)
36
+ left_gap, right_gap = determine_gaps(width, @width)
37
+ Path.new(x, y).h(left_gap).add(self)
38
+ Path.new(x + left_gap + @width, y + @height).h(right_gap).add(self)
39
+ x += left_gap
40
+
41
+ DiagramItem.new(
42
+ 'rect',
43
+ attrs: {
44
+ 'x' => x,
45
+ 'y' => y - @box_up,
46
+ 'width' => @width,
47
+ 'height' => @height + @box_up + @down,
48
+ 'rx' => AR,
49
+ 'ry' => AR,
50
+ 'class' => 'group-box'
51
+ }
52
+ ).add(self)
53
+
54
+ @item.format(x, y, @width).add(self)
55
+ @label.format(x, y - (@box_up + @label.down + @label.height), @width).add(self) if @label
56
+
57
+ self
58
+ end
59
+
60
+ def walk(callback)
61
+ callback.call(self)
62
+ item.walk(callback)
63
+ label&.walk(callback)
64
+ end
65
+
66
+ def text_diagram
67
+ diagram_td = TextDiagram.round_rect(@item.text_diagram, dashed: true)
68
+ if @label
69
+ label_td = @label.text_diagram
70
+ diagram_td = label_td.append_below(diagram_td, [], move_entry: true, move_exit: true).expand(0, 0, 1, 0)
71
+ end
72
+ diagram_td
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailroadDiagrams
4
+ class HorizontalChoice < DiagramMultiContainer
5
+ def self.new(*items)
6
+ return Sequence.new(*items) if items.size <= 1
7
+
8
+ super
9
+ end
10
+
11
+ def initialize(*items)
12
+ super('g', items)
13
+ all_but_last = @items[0...-1]
14
+ middles = @items[1...-1]
15
+ first = @items.first
16
+ last = @items.last
17
+ @needs_space = false
18
+
19
+ @width =
20
+ AR + # starting track
21
+ (AR * 2 * (@items.size - 1)) + # inbetween tracks
22
+ @items.sum { |x| x.width + (x.needs_space ? 20 : 0) } + # items
23
+ (last.height > 0 ? AR : 0) + # needs space to curve up
24
+ AR # ending track
25
+
26
+ # Always exits at entrance height
27
+ @height = 0
28
+
29
+ # All but the last have a track running above them
30
+ @upper_track = [AR * 2, VS, all_but_last.map(&:up).max + VS].max
31
+ @up = [@upper_track, last.up].max
32
+
33
+ # All but the first have a track running below them
34
+ # Last either straight-lines or curves up, so has different calculation
35
+ @lower_track = [
36
+ VS,
37
+ middles.any? ? middles.map { |x| x.height + [x.down + VS, AR * 2].max }.max : 0,
38
+ last.height + last.down + VS
39
+ ].max
40
+ if first.height < @lower_track
41
+ # Make sure there's at least 2*AR room between first exit and lower track
42
+ @lower_track = [@lower_track, first.height + (AR * 2)].max
43
+ end
44
+ @down = [@lower_track, first.height + first.down].max
45
+ end
46
+
47
+ def to_s
48
+ items = @items.map(&:to_s).join(', ')
49
+ "HorizontalChoice(#{items})"
50
+ end
51
+
52
+ def format(x, y, width)
53
+ # Hook up the two sides if self is narrower than its stated width.
54
+ left_gap, right_gap = determine_gaps(width, @width)
55
+ Path.new(x, y).h(left_gap).add(self)
56
+ Path.new(x + left_gap + @width, y + @height).h(right_gap).add(self)
57
+ x += left_gap
58
+
59
+ first = @items.first
60
+ last = @items.last
61
+
62
+ # upper track
63
+ upper_span =
64
+ @items[0...-1].sum { |item| item.width + (item.needs_space ? 20 : 0) } +
65
+ ((@items.size - 2) * AR * 2) -
66
+ AR
67
+
68
+ Path.new(x, y)
69
+ .arc('se')
70
+ .up(@upper_track - (AR * 2))
71
+ .arc('wn')
72
+ .h(upper_span)
73
+ .add(self)
74
+
75
+ # lower track
76
+ lower_span =
77
+ @items[1..].sum { |item| item.width + (item.needs_space ? 20 : 0) } +
78
+ ((@items.size - 2) * AR * 2) +
79
+ (last.height.positive? ? AR : 0) -
80
+ AR
81
+
82
+ lower_start = x + AR + first.width + (first.needs_space ? 20 : 0) + (AR * 2)
83
+
84
+ Path.new(lower_start, y + @lower_track)
85
+ .h(lower_span)
86
+ .arc('se')
87
+ .up(@lower_track - (AR * 2))
88
+ .arc('wn')
89
+ .add(self)
90
+
91
+ # Items
92
+ @items.each_with_index do |item, i|
93
+ # input track
94
+ if i.zero?
95
+ Path.new(x, y)
96
+ .h(AR)
97
+ .add(self)
98
+ x += AR
99
+ else
100
+ Path.new(x, y - @upper_track)
101
+ .arc('ne')
102
+ .v(@upper_track - (AR * 2))
103
+ .arc('ws')
104
+ .add(self)
105
+ x += AR * 2
106
+ end
107
+
108
+ # item
109
+ item_width = item.width + (item.needs_space ? 20 : 0)
110
+ item.format(x, y, item_width).add(self)
111
+ x += item_width
112
+
113
+ # output track
114
+ if i == @items.size - 1
115
+ if item.height.zero?
116
+ Path.new(x, y).h(AR).add(self)
117
+ else
118
+ Path.new(x, y + item.height).arc('se').add(self)
119
+ end
120
+ elsif i.zero? && item.height > @lower_track
121
+ # Needs to arc up to meet the lower track, not down.
122
+ if item.height - @lower_track >= AR * 2
123
+ Path.new(x, y + item.height)
124
+ .arc('se')
125
+ .v(@lower_track - item.height + (AR * 2))
126
+ .arc('wn')
127
+ .add(self)
128
+ else
129
+ # Not enough space to fit two arcs
130
+ # so just bail and draw a straight line for now.
131
+ Path.new(x, y + item.height)
132
+ .l(AR * 2, @lower_track - item.height)
133
+ .add(self)
134
+ end
135
+ else
136
+ Path.new(x, y + item.height)
137
+ .arc('ne')
138
+ .v(@lower_track - item.height - (AR * 2))
139
+ .arc('ws')
140
+ .add(self)
141
+ end
142
+ end
143
+ self
144
+ end
145
+
146
+ def text_diagram
147
+ line, line_vertical, roundcorner_bot_left, roundcorner_bot_right,
148
+ roundcorner_top_left, roundcorner_top_right = TextDiagram.get_parts(
149
+ %w[line line_vertical roundcorner_bot_left roundcorner_bot_right roundcorner_top_left
150
+ roundcorner_top_right]
151
+ )
152
+
153
+ # Format all the child items, so we can know the maximum entry, exit, and height.
154
+ item_tds = @items.map(&:text_diagram)
155
+
156
+ # diagram_entry: distance from top to lowest entry, aka distance from top to diagram entry, aka final diagram entry and exit.
157
+ diagram_entry = item_tds.map(&:entry).max
158
+ # soil_to_baseline: distance from top to lowest entry before rightmost item, aka distance from skip-over-items line to rightmost entry, aka SOIL height.
159
+ soil_to_baseline = item_tds[0..-2].map(&:entry).max || 0
160
+ # top_to_soil: distance from top to skip-over-items line.
161
+ top_to_soil = diagram_entry - soil_to_baseline
162
+ # baseline_to_suil: distance from lowest entry or exit after leftmost item to bottom, aka distance from entry to skip-under-items line, aka SUIL height.
163
+ baseline_to_suil = item_tds[1..-1].map { |td| td.height - [td.entry, td.exit].min }.max.to_i - 1
164
+
165
+ # The diagram starts with a line from its entry up to skip-over-items line:
166
+ lines = Array.new(top_to_soil, ' ')
167
+ lines << (roundcorner_top_left + line)
168
+ lines += Array.new(soil_to_baseline, line_vertical + ' ')
169
+ lines << (roundcorner_bot_right + line)
170
+
171
+ diagram_td = TextDiagram.new(lines.size - 1, lines.size - 1, lines)
172
+
173
+ item_tds.each_with_index do |item_td, item_num|
174
+ if item_num > 0
175
+ # All items except the leftmost start with a line from the skip-over-items line down to their entry,
176
+ # with a joining-line across at the skip-under-items line:
177
+ lines = Array.new(top_to_soil, ' ')
178
+ # All such items except the rightmost also have a continuation of the skip-over-items line:
179
+ line_to_next_item = item_num == item_tds.size - 1 ? ' ' : line
180
+ lines << (roundcorner_top_right + line_to_next_item)
181
+ lines += Array.new(soil_to_baseline, line_vertical + ' ')
182
+ lines << (roundcorner_bot_left + line)
183
+ lines += Array.new(baseline_to_suil, ' ')
184
+ lines << (line * 2)
185
+
186
+ entry_td = TextDiagram.new(diagram_td.exit, diagram_td.exit, lines)
187
+ diagram_td = diagram_td.append_right(entry_td, '')
188
+ end
189
+
190
+ part_td = TextDiagram.new(0, 0, [])
191
+
192
+ if item_num < item_tds.size - 1
193
+ # All items except the rightmost start with a segment of the skip-over-items line at the top.
194
+ # followed by enough blank lines to push their entry down to the previous item's exit:
195
+ lines = []
196
+ lines << (line * item_td.width)
197
+ lines += Array.new(soil_to_baseline - item_td.entry, ' ' * item_td.width)
198
+ soil_segment = TextDiagram.new(0, 0, lines)
199
+ part_td = part_td.append_below(soil_segment, [])
200
+ end
201
+
202
+ part_td = part_td.append_below(item_td, [], move_entry: true, move_exit: true)
203
+
204
+ if item_num > 0
205
+ # All items except the leftmost end with enough blank lines to pad down to the skip-under-items
206
+ # line, followed by a segment of the skip-under-items line:
207
+ lines = Array.new(baseline_to_suil - (item_td.height - item_td.entry) + 1, ' ' * item_td.width)
208
+ lines << (line * item_td.width)
209
+ suil_segment = TextDiagram.new(0, 0, lines)
210
+ part_td = part_td.append_below(suil_segment, [])
211
+ end
212
+
213
+ diagram_td = diagram_td.append_right(part_td, '')
214
+
215
+ if item_num < item_tds.size - 1
216
+ # All items except the rightmost have a line from their exit down to the skip-under-items line,
217
+ # with a joining-line across at the skip-over-items line:
218
+ lines = Array.new(top_to_soil, ' ')
219
+ lines << (line * 2)
220
+ lines += Array.new(diagram_td.exit - top_to_soil - 1, ' ')
221
+ lines << (line + roundcorner_top_right)
222
+ lines += Array.new(baseline_to_suil - (diagram_td.exit - diagram_td.entry), ' ' + line_vertical)
223
+ line_from_prev_item = item_num > 0 ? line : ' '
224
+ lines << (line_from_prev_item + roundcorner_bot_left)
225
+
226
+ entry = diagram_entry + 1 + (diagram_td.exit - diagram_td.entry)
227
+ exit_td = TextDiagram.new(entry, diagram_entry + 1, lines)
228
+ diagram_td = diagram_td.append_right(exit_td, '')
229
+ else
230
+ # The rightmost item has a line from the skip-under-items line and from its exit up to the diagram exit:
231
+ lines = []
232
+ line_from_exit = diagram_td.exit == diagram_td.entry ? line : ' '
233
+ lines << (line_from_exit + roundcorner_top_left)
234
+ lines += Array.new(diagram_td.exit - diagram_td.entry - 1, ' ' + line_vertical)
235
+ lines << (line + roundcorner_bot_right) if diagram_td.exit != diagram_td.entry
236
+ lines += Array.new(baseline_to_suil - (diagram_td.exit - diagram_td.entry), ' ' + line_vertical)
237
+ lines << (line + roundcorner_bot_right)
238
+
239
+ exit_td = TextDiagram.new(diagram_td.exit - diagram_td.entry, 0, lines)
240
+ diagram_td = diagram_td.append_right(exit_td, '')
241
+ end
242
+ end
243
+
244
+ diagram_td
245
+ end
246
+ end
247
+ end