merminal 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/docs/adr/000-template.md +24 -0
- data/docs/adr/001-runtime.md +19 -0
- data/docs/adr/002-scene.md +18 -0
- data/docs/adr/003-layout.md +20 -0
- data/docs/adr/004-parser.md +19 -0
- data/docs/adr/005-public-api.md +23 -0
- data/docs/adr/README.md +10 -0
- data/docs/syntax/additional.md +58 -0
- data/docs/syntax/class.md +3 -0
- data/docs/syntax/er.md +3 -0
- data/docs/syntax/flowchart.md +16 -0
- data/docs/syntax/gantt.md +3 -0
- data/docs/syntax/mindmap.md +3 -0
- data/docs/syntax/pie.md +3 -0
- data/docs/syntax/sequence.md +14 -0
- data/docs/syntax/state.md +3 -0
- data/docs/syntax/timeline.md +3 -0
- data/docs/syntax/xychart.md +3 -0
- data/exe/merminal +7 -0
- data/lib/merminal/cli.rb +94 -0
- data/lib/merminal/diagrams/additional.rb +257 -0
- data/lib/merminal/diagrams/additional_layouts.rb +1274 -0
- data/lib/merminal/diagrams/base.rb +65 -0
- data/lib/merminal/diagrams/gantt.rb +109 -0
- data/lib/merminal/diagrams/mindmap.rb +94 -0
- data/lib/merminal/diagrams/pie.rb +64 -0
- data/lib/merminal/diagrams/sequence.rb +186 -0
- data/lib/merminal/diagrams/structure.rb +316 -0
- data/lib/merminal/diagrams/timeline.rb +60 -0
- data/lib/merminal/diagrams/xychart.rb +152 -0
- data/lib/merminal/flowchart/layout.rb +734 -0
- data/lib/merminal/flowchart.rb +279 -0
- data/lib/merminal/markdown.rb +37 -0
- data/lib/merminal/output.rb +93 -0
- data/lib/merminal/raster/box_drawing_table.rb +6 -0
- data/lib/merminal/raster.rb +175 -0
- data/lib/merminal/scene.rb +37 -0
- data/lib/merminal/shareable.rb +9 -0
- data/lib/merminal/source.rb +78 -0
- data/lib/merminal/text/east_asian_width_table.rb +8 -0
- data/lib/merminal/text.rb +110 -0
- data/lib/merminal/version.rb +5 -0
- data/lib/merminal.rb +122 -0
- data/sig/merminal.rbs +59 -0
- metadata +88 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Mutable construction of graphs shared by structural diagram parsers.
|
|
5
|
+
class Structure
|
|
6
|
+
attr_reader :nodes, :edges, :subgraphs, :styles
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@nodes = {}
|
|
10
|
+
@edges = []
|
|
11
|
+
@subgraphs = []
|
|
12
|
+
@styles = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def node(id, label: id, shape: :rectangle, line: 1)
|
|
16
|
+
previous = @nodes[id]
|
|
17
|
+
@nodes[id] = Flowchart::Node.new(id: id, label: label || previous&.label || id,
|
|
18
|
+
shape: shape || previous&.shape || :rectangle, classes: [], source_pos: [line, 1])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def edge(from, to, label: nil, stroke: :light, marker: :arrow, line: 1)
|
|
22
|
+
node(from) unless @nodes.key?(from)
|
|
23
|
+
node(to) unless @nodes.key?(to)
|
|
24
|
+
@edges << Flowchart::Edge.new(id: @edges.length, from: from, to: to, label: label, stroke: stroke,
|
|
25
|
+
start_marker: nil, end_marker: marker, minlen: 1, source_pos: [line, 1])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def diagram(direction = :TB)
|
|
29
|
+
Flowchart::Diagram.new(direction: direction, nodes: @nodes.values.freeze, edges: @edges.freeze,
|
|
30
|
+
subgraphs: @subgraphs.freeze, styles: @styles.freeze)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.endpoint_labels(scene, ast, key)
|
|
35
|
+
edge_lines = scene.items.grep(Scene::Polyline).select { |item| item.role == :edge }
|
|
36
|
+
labels = []
|
|
37
|
+
ast.edges.zip(edge_lines).each do |edge, line|
|
|
38
|
+
values = ast.styles["#{key}:#{edge.id}"]
|
|
39
|
+
next unless values && line
|
|
40
|
+
|
|
41
|
+
values.zip([line.points.first, line.points.last]).each_with_index do |(value, point), index|
|
|
42
|
+
next unless value && !value.empty?
|
|
43
|
+
|
|
44
|
+
x, y = point
|
|
45
|
+
if ast.direction == :LR || ast.direction == :RL
|
|
46
|
+
x += index.zero? ? 1 : -Text.width(value) - 1
|
|
47
|
+
y -= 1
|
|
48
|
+
else
|
|
49
|
+
x += index.zero? ? -Text.width(value) - 1 : 2
|
|
50
|
+
y += index.zero? ? 1 : -1
|
|
51
|
+
end
|
|
52
|
+
labels << Scene::Text.new(x: [x, 0].max, y: [y, 0].max, string: value, role: :edge_label,
|
|
53
|
+
layer: :label, emphasis: nil)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
return scene if labels.empty?
|
|
57
|
+
|
|
58
|
+
width = [scene.width, labels.map { |item| item.x + Text.width(item.string) }.max].max
|
|
59
|
+
height = [scene.height, labels.map { |item| item.y + 1 }.max].max
|
|
60
|
+
Scene.new(width: width, height: height, items: (scene.items + labels).freeze)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# State transitions, choices and composite state frames.
|
|
64
|
+
module State
|
|
65
|
+
def self.diagram_type = :state
|
|
66
|
+
def self.keywords = %w[stateDiagram stateDiagram-v2]
|
|
67
|
+
|
|
68
|
+
def self.parse(source)
|
|
69
|
+
graph = Structure.new
|
|
70
|
+
findings = []
|
|
71
|
+
direction = :TB
|
|
72
|
+
stack = []
|
|
73
|
+
notes = []
|
|
74
|
+
open_note = nil
|
|
75
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
76
|
+
statement = line.strip
|
|
77
|
+
if open_note
|
|
78
|
+
if statement == "end note"
|
|
79
|
+
notes << [open_note[0], open_note[1], open_note[2].join("\n")]
|
|
80
|
+
open_note = nil
|
|
81
|
+
else
|
|
82
|
+
open_note[2] << statement
|
|
83
|
+
end
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
case statement
|
|
87
|
+
when /\Adirection\s+(TB|TD|BT|LR|RL)\z/
|
|
88
|
+
direction = Regexp.last_match(1).to_sym
|
|
89
|
+
when /\Astate\s+"([^"]+)"\s+as\s+(\w+)\z/
|
|
90
|
+
id = Regexp.last_match(2)
|
|
91
|
+
graph.node(id, label: Regexp.last_match(1), line: index + 2)
|
|
92
|
+
graph.subgraphs.each { |group| group.node_ids << id if stack.include?(group.id) }
|
|
93
|
+
when /\Astate\s+(\w+)\s*\{\z/
|
|
94
|
+
id = Regexp.last_match(1)
|
|
95
|
+
graph.subgraphs << Flowchart::Subgraph.new(id: id, label: id, node_ids: [], parent: stack.last)
|
|
96
|
+
stack << id
|
|
97
|
+
when /\Astate\s+(\w+)\s+<<(choice|fork|join)>>\z/
|
|
98
|
+
id = Regexp.last_match(1)
|
|
99
|
+
shape = Regexp.last_match(2) == "choice" ? :decision : Regexp.last_match(2).to_sym
|
|
100
|
+
graph.node(id, label: shape == :decision ? id : "", shape: shape)
|
|
101
|
+
graph.subgraphs.each { |group| group.node_ids << id if stack.include?(group.id) }
|
|
102
|
+
when "}"
|
|
103
|
+
stack.pop || findings << Merminal::Diagrams.finding("unexpected state end", source, index + 1)
|
|
104
|
+
when /\A(\[\*\]|[\w.-]+)\s*-->\s*(\[\*\]|[\w.-]+)(?:\s*:\s*(.+))?\z/
|
|
105
|
+
from, to, label = Regexp.last_match.captures
|
|
106
|
+
scope = stack.empty? ? "" : ":#{stack.join(':')}"
|
|
107
|
+
from = "__start#{scope}" if from == "[*]"
|
|
108
|
+
to = "__end#{scope}" if to == "[*]"
|
|
109
|
+
graph.node(from, label: "●", shape: :circle) if from.start_with?("__start")
|
|
110
|
+
graph.node(to, label: "◉", shape: :circle) if to.start_with?("__end")
|
|
111
|
+
graph.edge(from, to, label: label, line: index + 2)
|
|
112
|
+
graph.subgraphs.each { |group| group.node_ids.concat([from, to]) if stack.include?(group.id) }
|
|
113
|
+
when /\Anote\s+(left|right)\s+of\s+([\w.-]+)\s*:\s*(.+)\z/i
|
|
114
|
+
notes << [Regexp.last_match(1).downcase, Regexp.last_match(2), Regexp.last_match(3)]
|
|
115
|
+
when /\Anote\s+(left|right)\s+of\s+([\w.-]+)\z/i
|
|
116
|
+
open_note = [Regexp.last_match(1).downcase, Regexp.last_match(2), []]
|
|
117
|
+
when "end note"
|
|
118
|
+
findings << Merminal::Diagrams.finding("unexpected end note", source, index + 1)
|
|
119
|
+
when ""
|
|
120
|
+
next
|
|
121
|
+
else
|
|
122
|
+
findings << Merminal::Diagrams.finding("unrecognized state statement", source, index + 1)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
findings << Merminal::Diagrams.finding("unclosed state note", source, source.lines.length - 1) if open_note
|
|
126
|
+
graph.styles["notes"] = notes unless notes.empty?
|
|
127
|
+
group_ids = graph.subgraphs.map(&:id)
|
|
128
|
+
graph.edges.each do |edge|
|
|
129
|
+
from = group_ids.include?(edge.from) ? edge.from : nil
|
|
130
|
+
to = group_ids.include?(edge.to) ? edge.to : nil
|
|
131
|
+
graph.styles["group_edge:#{edge.id}"] = [from, to] if from || to
|
|
132
|
+
end
|
|
133
|
+
graph.subgraphs.each do |group|
|
|
134
|
+
representative = group.node_ids.first
|
|
135
|
+
next unless representative
|
|
136
|
+
|
|
137
|
+
graph.edges.map! do |edge|
|
|
138
|
+
edge.with(from: edge.from == group.id ? representative : edge.from,
|
|
139
|
+
to: edge.to == group.id ? representative : edge.to)
|
|
140
|
+
end
|
|
141
|
+
graph.nodes.delete(group.id) unless group.node_ids.include?(group.id)
|
|
142
|
+
end
|
|
143
|
+
notes.each do |_, id, _|
|
|
144
|
+
findings << Merminal::Diagrams.finding("unknown state note target #{id}", source, source.lines.length - 1) unless graph.nodes.key?(id)
|
|
145
|
+
end
|
|
146
|
+
findings << Merminal::Diagrams.finding("unclosed composite state", source, source.lines.length - 1) unless stack.empty?
|
|
147
|
+
[graph.diagram(direction), findings]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.layout(ast, **options)
|
|
151
|
+
scene = Flowchart.layout(ast, **options)
|
|
152
|
+
notes = ast.styles["notes"]
|
|
153
|
+
return scene unless notes && !notes.empty?
|
|
154
|
+
|
|
155
|
+
visible_nodes = ast.nodes.reject { |node| %i[fork join].include?(node.shape) }
|
|
156
|
+
boxes = visible_nodes.map(&:id).zip(scene.items.grep(Scene::Box).select { |item| item.role == :node_border }).to_h
|
|
157
|
+
left_width = notes.select { |side, _, _| side == "left" }.map do |_, _, content|
|
|
158
|
+
content.split("\n").map { |line| Text.width(line) }.max.to_i + 4
|
|
159
|
+
end.max.to_i
|
|
160
|
+
shift = left_width.positive? ? left_width + 3 : 0
|
|
161
|
+
items = scene.items.map { |item| Scene.translate(item, dx: shift) }
|
|
162
|
+
width = scene.width + shift
|
|
163
|
+
height = scene.height
|
|
164
|
+
bottoms = { "left" => 0, "right" => 0 }
|
|
165
|
+
notes.each do |side, id, content|
|
|
166
|
+
target = boxes[id]&.rect
|
|
167
|
+
next unless target
|
|
168
|
+
|
|
169
|
+
lines = content.split("\n")
|
|
170
|
+
note_width = lines.map { |line| Text.width(line) }.max.to_i + 4
|
|
171
|
+
note_height = lines.length + 2
|
|
172
|
+
x = side == "left" ? 0 : scene.width + shift + 2
|
|
173
|
+
y = [target.y, bottoms[side]].max
|
|
174
|
+
bottoms[side] = y + note_height + 1
|
|
175
|
+
items << Scene::Box.new(rect: Scene::Rect.new(x: x, y: y, width: note_width, height: note_height),
|
|
176
|
+
stroke: Scene::LIGHT, corners: :sharp, role: :container_border, layer: :container)
|
|
177
|
+
lines.each_with_index do |line, index|
|
|
178
|
+
items << Scene::Text.new(x: x + 2, y: y + 1 + index, string: line, role: :node_text, layer: :label, emphasis: nil)
|
|
179
|
+
end
|
|
180
|
+
center_y = target.y + target.height / 2
|
|
181
|
+
points = side == "left" ? [[x + note_width - 1, y + note_height / 2], [target.x + shift, center_y]] :
|
|
182
|
+
[[target.x + shift + target.width - 1, center_y], [x, y + note_height / 2]]
|
|
183
|
+
bend = (points.first[0] + points.last[0]) / 2
|
|
184
|
+
items << Scene::Polyline.new(points: [points.first, [bend, points.first[1]], [bend, points.last[1]], points.last],
|
|
185
|
+
stroke: Scene::LIGHT, role: :edge, layer: :edge)
|
|
186
|
+
width = [width, x + note_width].max
|
|
187
|
+
height = [height, y + note_height].max
|
|
188
|
+
end
|
|
189
|
+
Scene.new(width: width, height: height, items: items.freeze)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Class diagrams with compartmented boxes and relation endpoints.
|
|
194
|
+
module ClassDiagram
|
|
195
|
+
def self.diagram_type = :class
|
|
196
|
+
def self.keywords = %w[classDiagram]
|
|
197
|
+
|
|
198
|
+
def self.parse(source)
|
|
199
|
+
graph = Structure.new
|
|
200
|
+
findings = []
|
|
201
|
+
bodies = Hash.new { |hash, key| hash[key] = [] }
|
|
202
|
+
annotations = {}
|
|
203
|
+
current = nil
|
|
204
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
205
|
+
statement = line.strip
|
|
206
|
+
if current
|
|
207
|
+
if statement == "}"
|
|
208
|
+
current = nil
|
|
209
|
+
else
|
|
210
|
+
bodies[current] << statement unless statement.empty?
|
|
211
|
+
end
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
case statement
|
|
215
|
+
when /\Aclass\s+([\w.-]+)\s*\{\z/
|
|
216
|
+
current = Regexp.last_match(1)
|
|
217
|
+
graph.node(current)
|
|
218
|
+
when /\A(?:class\s+)?([\w.-]+)\s+<<([\w.-]+)>>\z/
|
|
219
|
+
id, annotation = Regexp.last_match.captures
|
|
220
|
+
graph.node(id)
|
|
221
|
+
annotations[id] = annotation
|
|
222
|
+
when /\Aclass\s+([\w.-]+)\z/
|
|
223
|
+
graph.node(Regexp.last_match(1))
|
|
224
|
+
when /\A([\w.-]+)\s*:\s*(.+)\z/
|
|
225
|
+
bodies[Regexp.last_match(1)] << Regexp.last_match(2)
|
|
226
|
+
graph.node(Regexp.last_match(1))
|
|
227
|
+
when /\A([\w.-]+)(?:\s+"([^"]+)")?\s+(<\|--|--\|>|\*--|--\*|o--|--o|\.\.\|>|<\|\.\.|\.\.>|<\.\.|-->|<--|--|\.\.)(?:\s+"([^"]+)")?\s+([\w.-]+)(?:\s*:\s*(.+))?\z/
|
|
228
|
+
left, left_mult, relation, right_mult, right, label = Regexp.last_match.captures
|
|
229
|
+
marker = relation.include?("|") ? :triangle : relation.include?("*") ? :diamond : relation.include?("o") ? :open_diamond : relation.include?(">") || relation.include?("<") ? :arrow : nil
|
|
230
|
+
from, to = relation.start_with?("<") || relation.start_with?("*") || relation.start_with?("o") ? [right, left] : [left, right]
|
|
231
|
+
graph.edge(from, to, label: label, stroke: relation.include?(".") ? :dotted : :light, marker: marker, line: index + 2)
|
|
232
|
+
graph.styles["mult:#{graph.edges.length - 1}"] = from == left ? [left_mult, right_mult] : [right_mult, left_mult]
|
|
233
|
+
when ""
|
|
234
|
+
next
|
|
235
|
+
else
|
|
236
|
+
findings << Merminal::Diagrams.finding("unrecognized class statement", source, index + 1)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
findings << Merminal::Diagrams.finding("unclosed class body", source, source.lines.length - 1) if current
|
|
240
|
+
graph.nodes.each do |id, node|
|
|
241
|
+
body = bodies[id]
|
|
242
|
+
attributes, operations = body.partition { |item| !item.include?("(") }
|
|
243
|
+
name = annotations[id] ? "<<#{annotations[id]}>>\n#{id}" : node.label
|
|
244
|
+
graph.node(id, label: ([name, ""] + (attributes.empty? ? [" "] : attributes) +
|
|
245
|
+
[""] + (operations.empty? ? [" "] : operations)).join("\n"), shape: :rectangle)
|
|
246
|
+
end
|
|
247
|
+
[graph.diagram, findings]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.layout(ast, **options)
|
|
251
|
+
scene = Flowchart.layout(ast, **options)
|
|
252
|
+
boxes = scene.items.grep(Scene::Box).select { |item| item.role == :node_border }
|
|
253
|
+
dividers = ast.nodes.zip(boxes).flat_map do |node, box|
|
|
254
|
+
rect = box.rect
|
|
255
|
+
rows = node.label.split("\n", -1).flat_map do |line|
|
|
256
|
+
line.empty? ? [true] : Text.wrap(line, options.fetch(:max_label_width, 24),
|
|
257
|
+
ambiguous_width: options.fetch(:ambiguous_width, 1)).map { false }
|
|
258
|
+
end
|
|
259
|
+
rows.each_with_index.filter_map do |divider, index|
|
|
260
|
+
next unless divider
|
|
261
|
+
|
|
262
|
+
y = rect.y + index + 1
|
|
263
|
+
Scene::Polyline.new(points: [[rect.x, y], [rect.x + rect.width - 1, y]],
|
|
264
|
+
stroke: Scene::LIGHT, role: :node_border, layer: :node)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
Merminal::Diagrams.endpoint_labels(scene.with(items: (scene.items + dividers).freeze), ast, "mult")
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Entity relationship diagrams with attribute tables.
|
|
272
|
+
module ER
|
|
273
|
+
CARDINALITY = { "||" => "1", "o|" => "0..1", "|o" => "0..1", "|{" => "1..*", "}|" => "1..*",
|
|
274
|
+
"o{" => "0..*", "}o" => "0..*" }.freeze
|
|
275
|
+
def self.diagram_type = :er
|
|
276
|
+
def self.keywords = %w[erDiagram]
|
|
277
|
+
|
|
278
|
+
def self.parse(source)
|
|
279
|
+
graph = Structure.new
|
|
280
|
+
findings = []
|
|
281
|
+
current = nil
|
|
282
|
+
attributes = Hash.new { |hash, key| hash[key] = [] }
|
|
283
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
284
|
+
statement = line.strip
|
|
285
|
+
if current
|
|
286
|
+
if statement == "}"
|
|
287
|
+
current = nil
|
|
288
|
+
elsif !statement.empty?
|
|
289
|
+
attributes[current] << statement.gsub(/\s+/, " ")
|
|
290
|
+
end
|
|
291
|
+
next
|
|
292
|
+
end
|
|
293
|
+
case statement
|
|
294
|
+
when /\A([\w.-]+)\s*\{\z/
|
|
295
|
+
current = Regexp.last_match(1)
|
|
296
|
+
graph.node(current)
|
|
297
|
+
when /\A([\w.-]+)\s+([|o{}]{2})(--|\.\.)([|o{}]{2})\s+([\w.-]+)\s*:\s*(.*)\z/
|
|
298
|
+
from, left, stroke, right, to, text = Regexp.last_match.captures
|
|
299
|
+
graph.edge(from, to, label: text, stroke: stroke == ".." ? :dotted : :light, marker: nil, line: index + 2)
|
|
300
|
+
graph.styles["card:#{graph.edges.length - 1}"] = [CARDINALITY[left] || left, CARDINALITY[right] || right]
|
|
301
|
+
when ""
|
|
302
|
+
next
|
|
303
|
+
else
|
|
304
|
+
findings << Merminal::Diagrams.finding("unrecognized ER statement", source, index + 1)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
findings << Merminal::Diagrams.finding("unclosed entity body", source, source.lines.length - 1) if current
|
|
308
|
+
attributes.each { |id, rows| graph.node(id, label: ([id, ""] + rows).join("\n")) }
|
|
309
|
+
[graph.diagram, findings]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def self.layout(ast, **options)
|
|
313
|
+
Merminal::Diagrams.endpoint_labels(ClassDiagram.layout(ast, **options), ast, "card")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Ordered timeline rendered as a vertical list.
|
|
5
|
+
module Timeline
|
|
6
|
+
Event = Data.define(:period, :text, :section)
|
|
7
|
+
Diagram = Data.define(:title, :events)
|
|
8
|
+
def self.diagram_type = :timeline
|
|
9
|
+
def self.keywords = %w[timeline]
|
|
10
|
+
|
|
11
|
+
def self.parse(source)
|
|
12
|
+
title = source.title
|
|
13
|
+
section = nil
|
|
14
|
+
events = []
|
|
15
|
+
findings = []
|
|
16
|
+
period = nil
|
|
17
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
18
|
+
case line.strip
|
|
19
|
+
when /\Atitle\s+(.+)\z/
|
|
20
|
+
title = Regexp.last_match(1)
|
|
21
|
+
when /\Asection\s+(.+)\z/
|
|
22
|
+
section = Regexp.last_match(1)
|
|
23
|
+
when /\A([^:]+?)\s*:\s*(.+)\z/
|
|
24
|
+
period = Regexp.last_match(1).strip
|
|
25
|
+
events << Event.new(period: period, text: Regexp.last_match(2).strip, section: section)
|
|
26
|
+
when /\A:\s*(.+)\z/
|
|
27
|
+
events << Event.new(period: period, text: Regexp.last_match(1).strip, section: section) if period
|
|
28
|
+
when ""
|
|
29
|
+
next
|
|
30
|
+
else
|
|
31
|
+
findings << Merminal::Diagrams.finding("unrecognized timeline statement", source, index + 1)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
[Diagram.new(title: title, events: events.freeze), findings]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.layout(ast, charset: :unicode, **)
|
|
38
|
+
builder = Builder.new
|
|
39
|
+
y = 0
|
|
40
|
+
if ast.title
|
|
41
|
+
builder.text(0, y, ast.title, role: :emphasis)
|
|
42
|
+
y += 2
|
|
43
|
+
end
|
|
44
|
+
period_width = ast.events.map { |event| Text.width(event.period.to_s) }.max.to_i
|
|
45
|
+
section = nil
|
|
46
|
+
ast.events.each do |event|
|
|
47
|
+
if event.section && event.section != section
|
|
48
|
+
builder.text(0, y, event.section, role: :container_title)
|
|
49
|
+
y += 1
|
|
50
|
+
section = event.section
|
|
51
|
+
end
|
|
52
|
+
builder.text(0, y, Text.pad(event.period.to_s, period_width), role: :axis_label)
|
|
53
|
+
builder.text(period_width + 1, y, charset == :ascii ? "|" : "●", role: :marker)
|
|
54
|
+
builder.text(period_width + 3, y, event.text)
|
|
55
|
+
y += 1
|
|
56
|
+
end
|
|
57
|
+
builder.scene
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Merminal::Diagrams
|
|
4
|
+
# Mermaid XY chart with vertical bars and braille line traces.
|
|
5
|
+
module XYChart
|
|
6
|
+
Diagram = Data.define(:title, :labels, :minimum, :maximum, :bars, :lines, :horizontal)
|
|
7
|
+
def self.diagram_type = :xychart
|
|
8
|
+
def self.keywords = %w[xychart-beta xychart]
|
|
9
|
+
|
|
10
|
+
def self.parse(source)
|
|
11
|
+
title = source.title
|
|
12
|
+
labels = []
|
|
13
|
+
minimum = 0.0
|
|
14
|
+
maximum = nil
|
|
15
|
+
bars = []
|
|
16
|
+
lines = []
|
|
17
|
+
findings = []
|
|
18
|
+
horizontal = source.lines.first.to_s.include?("horizontal")
|
|
19
|
+
source.lines.drop(1).each_with_index do |line, index|
|
|
20
|
+
case line.strip
|
|
21
|
+
when /\Atitle\s+["']?(.+?)["']?\z/
|
|
22
|
+
title = Regexp.last_match(1)
|
|
23
|
+
when /\Ax-axis\s+\[(.*)\]/
|
|
24
|
+
labels = Regexp.last_match(1).split(",").map { |item| item.strip.delete_prefix('"').delete_suffix('"') }
|
|
25
|
+
when /\Ay-axis\s+.*?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)\z/
|
|
26
|
+
minimum = Regexp.last_match(1).to_f
|
|
27
|
+
maximum = Regexp.last_match(2).to_f
|
|
28
|
+
when /\A(bar|line)\s+\[([^\]]*)\]/
|
|
29
|
+
kind = Regexp.last_match(1)
|
|
30
|
+
values = Regexp.last_match(2).split(",").map { |value| Float(value.strip, exception: false) }
|
|
31
|
+
if values.any?(&:nil?)
|
|
32
|
+
findings << Merminal::Diagrams.finding("invalid chart value", source, index + 1)
|
|
33
|
+
else
|
|
34
|
+
(kind == "bar" ? bars : lines) << values
|
|
35
|
+
end
|
|
36
|
+
when ""
|
|
37
|
+
next
|
|
38
|
+
else
|
|
39
|
+
findings << Merminal::Diagrams.finding("unrecognized xychart statement", source, index + 1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
[Diagram.new(title: title, labels: labels.freeze, minimum: minimum, maximum: maximum,
|
|
43
|
+
bars: bars.freeze, lines: lines.freeze, horizontal: horizontal), findings]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.layout(ast, charset: :unicode, **)
|
|
47
|
+
builder = Builder.new
|
|
48
|
+
all = ast.bars.flatten + ast.lines.flatten
|
|
49
|
+
count = [ast.labels.length, ast.bars.map(&:length).max.to_i, ast.lines.map(&:length).max.to_i].max
|
|
50
|
+
return builder.scene if count.zero?
|
|
51
|
+
return layout_horizontal(ast, charset, count, all) if ast.horizontal
|
|
52
|
+
|
|
53
|
+
minimum = ast.minimum
|
|
54
|
+
maximum = ast.maximum || [all.max.to_f, 1].max
|
|
55
|
+
maximum = minimum + 1 if maximum <= minimum
|
|
56
|
+
title_offset = ast.title ? 2 : 0
|
|
57
|
+
builder.text(0, 0, ast.title, role: :emphasis) if ast.title
|
|
58
|
+
plot_left = 7
|
|
59
|
+
plot_top = title_offset
|
|
60
|
+
plot_height = 10
|
|
61
|
+
step = [4, ast.bars.length * 2 + 2].max
|
|
62
|
+
plot_width = count * step
|
|
63
|
+
6.times do |tick|
|
|
64
|
+
value = maximum - (maximum - minimum) * tick / 5.0
|
|
65
|
+
y = plot_top + tick * 2
|
|
66
|
+
label = format("%g", value).sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")
|
|
67
|
+
builder.text(0, y, Text.pad(label, 5, align: :right), role: :axis_label)
|
|
68
|
+
end
|
|
69
|
+
builder.line([[plot_left - 1, plot_top], [plot_left - 1, plot_top + plot_height],
|
|
70
|
+
[plot_left + plot_width, plot_top + plot_height]], role: :axis)
|
|
71
|
+
ast.bars.each_with_index do |series, series_index|
|
|
72
|
+
series.each_with_index do |value, index|
|
|
73
|
+
rows = ((value - minimum) / (maximum - minimum) * plot_height * 8).clamp(0, plot_height * 8).round
|
|
74
|
+
full, partial = rows.divmod(8)
|
|
75
|
+
x = plot_left + index * step + series_index * 2
|
|
76
|
+
full.times { |offset| builder.glyph(x, plot_top + plot_height - 1 - offset, charset == :ascii ? "#" : "█", role: :series_1) }
|
|
77
|
+
if partial.positive? && full < plot_height
|
|
78
|
+
char = charset == :ascii ? "#" : %w[▁ ▂ ▃ ▄ ▅ ▆ ▇][partial - 1]
|
|
79
|
+
builder.glyph(x, plot_top + plot_height - 1 - full, char, role: :series_1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
ast.lines.each_with_index do |series, series_index|
|
|
84
|
+
draw_line(builder, series, plot_left, plot_top, plot_height, step, minimum, maximum, charset, series_index)
|
|
85
|
+
end
|
|
86
|
+
count.times do |index|
|
|
87
|
+
label = ast.labels[index] || index.to_s
|
|
88
|
+
builder.text(plot_left + index * step, plot_top + plot_height + 1, label, role: :axis_label)
|
|
89
|
+
end
|
|
90
|
+
builder.scene
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.layout_horizontal(ast, charset, count, all)
|
|
94
|
+
builder = Builder.new
|
|
95
|
+
y = 0
|
|
96
|
+
if ast.title
|
|
97
|
+
builder.text(0, y, ast.title, role: :emphasis)
|
|
98
|
+
y += 2
|
|
99
|
+
end
|
|
100
|
+
labels = count.times.map { |index| ast.labels[index] || index.to_s }
|
|
101
|
+
width = labels.map { |label| Text.width(label) }.max
|
|
102
|
+
maximum = ast.maximum || [all.max.to_f, 1].max
|
|
103
|
+
minimum = ast.minimum
|
|
104
|
+
maximum = minimum + 1 if maximum <= minimum
|
|
105
|
+
count.times do |index|
|
|
106
|
+
label = labels[index]
|
|
107
|
+
builder.text(0, y, Text.pad(label, width), role: :axis_label)
|
|
108
|
+
ast.bars.each do |series|
|
|
109
|
+
value = series[index].to_f
|
|
110
|
+
length = ((value - minimum) / (maximum - minimum) * 40).clamp(0, 40).round
|
|
111
|
+
builder.text(width + 2, y, (charset == :ascii ? "#" : "█") * length, role: :series_1)
|
|
112
|
+
builder.text(width + 43, y, format("%g", value), role: :axis_label)
|
|
113
|
+
y += 1
|
|
114
|
+
end
|
|
115
|
+
ast.lines.each do |series|
|
|
116
|
+
value = series[index].to_f
|
|
117
|
+
length = ((value - minimum) / (maximum - minimum) * 40).clamp(0, 40).round
|
|
118
|
+
builder.text(width + 2 + length, y, charset == :ascii ? "*" : "⠿", role: :series_2)
|
|
119
|
+
builder.text(width + 43, y, format("%g", value), role: :axis_label)
|
|
120
|
+
y += 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
builder.scene
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.draw_line(builder, values, left, top, height, step, minimum, maximum, charset, series_index)
|
|
127
|
+
return if values.empty?
|
|
128
|
+
|
|
129
|
+
points = values.each_with_index.map do |value, index|
|
|
130
|
+
[index * step * 2 + step, ((maximum - value) / (maximum - minimum) * (height * 4 - 1)).clamp(0, height * 4 - 1).round]
|
|
131
|
+
end
|
|
132
|
+
if charset == :ascii
|
|
133
|
+
points.each { |x, y| builder.glyph(left + x / 2, top + y / 4, "*", role: :series_1) }
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
pixels = {}
|
|
137
|
+
points.each_cons(2) do |(x1, y1), (x2, y2)|
|
|
138
|
+
steps = [(x2 - x1).abs, (y2 - y1).abs, 1].max
|
|
139
|
+
(0..steps).each do |step_index|
|
|
140
|
+
x = (x1 * (steps - step_index) + x2 * step_index + steps / 2) / steps
|
|
141
|
+
y = (y1 * (steps - step_index) + y2 * step_index + steps / 2) / steps
|
|
142
|
+
bit = [[0, 1, 2, 6], [3, 4, 5, 7]][x % 2][y % 4]
|
|
143
|
+
key = [x / 2, y / 4]
|
|
144
|
+
pixels[key] = pixels.fetch(key, 0) | (1 << bit)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
pixels.each do |(x, y), bits|
|
|
148
|
+
builder.glyph(left + x, top + y, (0x2800 + bits).chr(Encoding::UTF_8), role: :"series_#{series_index + 1}")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|