railroad_diagrams 0.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.
@@ -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