diagrammatron 0.1.1

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,446 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright © 2021 Ismo Kärkkäinen
5
+ # Licensed under Universal Permissive License. See LICENSE.txt.
6
+
7
+ require '../lib/common.rb'
8
+ require 'optparse'
9
+ require 'yaml'
10
+ require 'set'
11
+ require 'pathname'
12
+
13
+ def info(msg, return_value = nil, loud = false)
14
+ $stderr.puts(msg) unless $QUIET && !loud
15
+ return_value
16
+ end
17
+
18
+ BoundingBox = Struct.new(:xmin, :ymin, :xmax, :ymax) do
19
+ def merge(bb)
20
+ return self if bb.nil?
21
+ BoundingBox.new(
22
+ (xmin < bb.xmin) ? xmin : bb.xmin,
23
+ (ymin < bb.ymin) ? ymin : bb.ymin,
24
+ (xmax < bb.xmax) ? bb.xmax : xmax,
25
+ (ymax < bb.ymax) ? bb.ymax : ymax
26
+ )
27
+ end
28
+
29
+ def width
30
+ xmax - xmin
31
+ end
32
+
33
+ def height
34
+ ymax - ymin
35
+ end
36
+
37
+ def area
38
+ (xmax - xmin) * (ymax - ymin)
39
+ end
40
+
41
+ def short_side
42
+ [(xmax - xmin) * $W2HRATIO, (ymax - ymin)].min
43
+ end
44
+
45
+ def long_side
46
+ [(xmax - xmin) * $W2HRATIO, (ymax - ymin)].max
47
+ end
48
+
49
+ def overlap?(bb)
50
+ return false if bb.nil?
51
+ return false if bb.xmax <= xmin
52
+ return false if xmax <= bb.xmin
53
+ return false if bb.ymax <= ymin
54
+ return false if ymax <= bb.ymin
55
+ true
56
+ end
57
+
58
+ def shift(dx, dy)
59
+ self.xmin += dx
60
+ self.ymin += dy
61
+ self.xmax += dx
62
+ self.ymax += dy
63
+ end
64
+ end
65
+
66
+ Edge = Struct.new(:index, :sid, :path) do
67
+ def bounding_box
68
+ xs = path.map { |xy| xy['xo'] }
69
+ ys = path.map { |xy| xy['yo'] }
70
+ BoundingBox.new(xs.min.floor, ys.min.floor, xs.max.floor, ys.max.floor)
71
+ end
72
+
73
+ def shift(dx, dy)
74
+ path.each do |c|
75
+ c['xo'] += dx
76
+ c['yo'] += dy
77
+ end
78
+ end
79
+ end
80
+
81
+ Node = Struct.new(:index, :sid, :xo, :yo) do
82
+ def bounding_box
83
+ BoundingBox.new(xo.floor, yo.floor, xo.floor, yo.floor)
84
+ end
85
+
86
+ def shift(dx, dy)
87
+ self.xo += dx
88
+ self.yo += dy
89
+ end
90
+ end
91
+
92
+ def work_copy(src)
93
+ work = { edges: {}, nodes: {} }
94
+ # Expected nodes, edges. Other pass-through.
95
+ nodes = src.fetch('nodes', [])
96
+ nodes.each_index do |k|
97
+ node = nodes[k]
98
+ sid = node.fetch('sid', nil)
99
+ xo = node.fetch('xo', nil)
100
+ yo = node.fetch('yo', nil)
101
+ next if sid.nil? || xo.nil? || yo.nil?
102
+ work[:nodes][sid] = work[:nodes].fetch(sid, []).push(Node.new(k, sid, xo, yo))
103
+ end
104
+ edges = src.fetch('edges', [])
105
+ edges.each_index do |k|
106
+ edge = edges[k]
107
+ path = edge.fetch('path', nil)
108
+ sid = edge.fetch('sid', nil)
109
+ next if path.nil? || sid.nil?
110
+ work[:edges][sid] = work[:edges].fetch(sid, []).push(Edge.new(k, sid, path))
111
+ end
112
+ work[:subsets] = work[:nodes].keys.to_set.merge(work[:edges].keys.to_set).to_a
113
+ work
114
+ end
115
+
116
+ def bounding_box(work, sid)
117
+ bb = nil
118
+ %i[edges nodes].each do |kind|
119
+ work[kind].fetch(sid, []).each do |item|
120
+ bb = item.bounding_box.merge(bb)
121
+ end
122
+ end
123
+ bb.xmax += 1
124
+ bb.ymax += 1
125
+ bb
126
+ end
127
+
128
+ def area_compare(b, a)
129
+ d = a.area <=> b.area
130
+ return d unless d.zero?
131
+ d = (a.xmax - a.xmin) <=> (b.xmax - b.xmin)
132
+ return d unless d.zero?
133
+ (a.ymax - a.ymin) <=> (b.ymax - b.ymin)
134
+ end
135
+
136
+ def area_order(bbs)
137
+ order = []
138
+ bbs.each_pair do |sid, bbox|
139
+ order.push([sid, bbox])
140
+ end
141
+ order.sort! do |a, b|
142
+ d = area_compare(a[1], b[1])
143
+ (d != 0) ? d : (a[0] <=> b[0])
144
+ end
145
+ order
146
+ end
147
+
148
+ def overlaps(state, cand)
149
+ state.each_value do |s|
150
+ return true if cand.overlap?(s)
151
+ end
152
+ false
153
+ end
154
+
155
+ State = Struct.new(:state, :bbox, :used, :remaining_area) do
156
+ def key?(sid_bbox)
157
+ state.key? sid_bbox.first
158
+ end
159
+
160
+ def can_improve?(small)
161
+ return true if small.nil?
162
+ must_add = [ remaining_area - (bbox.area - used), 0 ].max
163
+ bbox.area + must_add < small.bbox.area
164
+ end
165
+ end
166
+
167
+ def depth_first_search(order, state, small, reverse = false)
168
+ if state.state.size < order.size
169
+ return small unless state.can_improve? small
170
+ # Loop over all sids not in state and the loop over false, true.
171
+ prev = nil
172
+ order.each do |sb|
173
+ next if state.key? sb
174
+ next if sb[1] == prev
175
+ prev = sb[1]
176
+ state.state.each_value do |n|
177
+ [false, true].each do |right|
178
+ right = !right if reverse
179
+ cand = sb[1].clone
180
+ cand.shift(right ? n.xmax : n.xmin, right ? n.ymin : n.ymax)
181
+ next if overlaps(state.state, cand) # Invalid state.
182
+ # Did we create a worse solution?
183
+ bc = state.bbox.merge(cand)
184
+ unless small.nil?
185
+ d = small.bbox.long_side <=> bc.long_side
186
+ next if d.negative?
187
+ next if d.zero? && small.bbox.short_side <= bc.short_side
188
+ end
189
+ s = state.state.clone
190
+ s[sb[0]] = cand
191
+ a = sb[1].area
192
+ small = depth_first_search(order,
193
+ State.new(s, bc, state.used + a, state.remaining_area - a),
194
+ small, !reverse)
195
+ end
196
+ end
197
+ end
198
+ else # We compared with area before call so no need to do it here.
199
+ small = State.new(state.state.clone, state.bbox.clone, state.used, 0)
200
+ info("Found (#{state.bbox.width}, #{state.bbox.height})")
201
+ end
202
+ small
203
+ end
204
+
205
+ def diagram_info(order, remains)
206
+ return if $QUIET
207
+ sizes = [ order[0][1] ]
208
+ (1...order.size).each do |k|
209
+ next if order[k - 1][1] == order[k][1]
210
+ sizes.push(order[k][1])
211
+ end
212
+ sizes = sizes.map { |b| "(#{b.width}, #{b.height})" }
213
+ info("#{order.size} sub-diagrams total area #{remains} in sizes #{sizes.join(' ')}")
214
+ end
215
+
216
+ def dumb_depth_first_search(bbs)
217
+ order = area_order(bbs)
218
+ remains = (order.map { |sb| sb[1].area }).sum
219
+ diagram_info(order, remains)
220
+ # At top level try only widest and tallest as in other places they can swap
221
+ # with a block of others in other locations.
222
+ widest = 0
223
+ tallest = 0
224
+ (1...order.size).each do |k|
225
+ widest = k if order[widest][1].width < order[k][1].width
226
+ tallest = k if order[tallest][1].height < order[k][1].height
227
+ end
228
+ small = nil
229
+ ((widest == tallest) ? [ widest ] : [ widest, tallest ]).each do |k|
230
+ info("Top-level candidate #{k + 1}")
231
+ sb = order[k]
232
+ a = sb[1].area
233
+ small = depth_first_search(order,
234
+ State.new({ sb[0] => sb[1].clone }, sb[1], a, remains - a), small)
235
+ end
236
+ small.state # State is sid to final bbox mapping.
237
+ end
238
+
239
+ def tallest_and_widest(order, state)
240
+ widest = nil
241
+ tallest = nil
242
+ order.each do |sb|
243
+ next if state.key? sb
244
+ unless widest.nil?
245
+ widest = sb if widest[1].width < sb[1].width
246
+ tallest = sb if tallest[1].height < sb[1].height
247
+ else
248
+ widest = sb
249
+ tallest = sb
250
+ end
251
+ end
252
+ return [ tallest ] if widest[1] == tallest[1]
253
+ [ tallest, widest ]
254
+ end
255
+
256
+ def tall_wide_dfs(order, state, small)
257
+ if state.state.size < order.size
258
+ return small unless state.can_improve? small
259
+ tallest_and_widest(order, state).each do |sb|
260
+ state.state.each_value do |n|
261
+ [false, true].each do |right|
262
+ cand = sb[1].clone
263
+ cand.shift(right ? n.xmax : n.xmin, right ? n.ymin : n.ymax)
264
+ next if overlaps(state.state, cand) # Invalid state.
265
+ # Did we create a worse solution?
266
+ bc = state.bbox.merge(cand)
267
+ unless small.nil?
268
+ d = small.bbox.long_side <=> bc.long_side
269
+ next if d.negative?
270
+ next if d.zero? && small.bbox.short_side <= bc.short_side
271
+ end
272
+ s = state.state.clone
273
+ s[sb[0]] = cand
274
+ a = sb[1].area
275
+ small = tall_wide_dfs(order,
276
+ State.new(s, bc, state.used + a, state.remaining_area - a), small)
277
+ end
278
+ end
279
+ end
280
+ else # We compared with area before call so no need to do it here.
281
+ small = State.new(state.state.clone, state.bbox.clone, state.used, 0)
282
+ info("Found (#{state.bbox.width}, #{state.bbox.height})")
283
+ end
284
+ small
285
+ end
286
+
287
+ def tallest_widest_dfs(bbs)
288
+ order = area_order(bbs)
289
+ remains = (order.map { |sb| sb[1].area }).sum
290
+ diagram_info(order, remains)
291
+ small = nil
292
+ tallest_and_widest(order, State.new({})).each do |sb|
293
+ a = sb[1].area
294
+ small = tall_wide_dfs(order,
295
+ State.new({ sb[0] => sb[1].clone }, sb[1], a, remains - a), small)
296
+ end
297
+ small.state # State is sid to final bbox mapping.
298
+ end
299
+
300
+ def pre_search_bounding_boxes(work)
301
+ bbs = {}
302
+ original = {}
303
+ work[:subsets].each do |sid|
304
+ bb = bounding_box(work, sid)
305
+ original[sid] = bb.clone
306
+ bb.shift(-bb.xmin, -bb.ymin) # Ensure the corner is at (0, 0)
307
+ bbs[sid] = bb
308
+ end
309
+ [ original, bbs ]
310
+ end
311
+
312
+ def shift(work, sid, dx, dy)
313
+ [ :edges, :nodes ].each do |kind|
314
+ work[kind].fetch(sid, []).each do |item|
315
+ item.shift(dx, dy)
316
+ end
317
+ end
318
+ end
319
+
320
+ def shift_subdiagrams(work, original, bbs)
321
+ # Shift everything in each subset according to shift compared to original.
322
+ original.each_pair do |sid, bbox|
323
+ shifted = bbs[sid]
324
+ shift(work, sid, shifted.xmin - bbox.xmin, shifted.ymin - bbox.ymin)
325
+ end
326
+ end
327
+
328
+ def place_subsets(work)
329
+ original, bbs = pre_search_bounding_boxes(work)
330
+ bbs = dumb_depth_first_search(bbs)
331
+ shift_subdiagrams(work, original, bbs)
332
+ end
333
+
334
+ def tallest_widest(work)
335
+ original, bbs = pre_search_bounding_boxes(work)
336
+ bbs = tallest_widest_dfs(bbs)
337
+ shift_subdiagrams(work, original, bbs)
338
+ end
339
+
340
+ def prepare_output(doc, work)
341
+ work[:nodes].each_value do |nodes|
342
+ nodes.each do |node|
343
+ doc['nodes'][node.index]['xo'] = node.xo
344
+ doc['nodes'][node.index]['yo'] = node.yo
345
+ end
346
+ end
347
+ work[:edges].each_value do |edges|
348
+ edges.each do |edge|
349
+ doc['edges'][edge.index]['path'] = edge.path
350
+ end
351
+ end
352
+ end
353
+
354
+ $algorithms = {
355
+ 'depthfirst' => method(:place_subsets),
356
+ 'tallwide' => method(:tallest_widest)
357
+ }
358
+
359
+ $W2HRATIO = 1.0
360
+ $QUIET = false
361
+
362
+ def main
363
+ input = nil
364
+ output = nil
365
+ algo = 'tallwide'
366
+ parser = OptionParser.new do |opts|
367
+ opts.summary_indent = ' '
368
+ opts.summary_width = 20
369
+ opts.banner = 'Usage: diagrammatron-place [options]'
370
+ opts.separator ''
371
+ opts.separator 'Options:'
372
+ opts.on('-a', '--algorithm NAME', "Subdiagram placement algoritm. Default #{algo}") do |a|
373
+ algo = a
374
+ end
375
+ opts.on('-r', '--ratio VALUE', 'Node width to height ratio. Default 1.0.') do |value|
376
+ $W2HRATIO = value
377
+ end
378
+ opts.on('-i', '--input FILE', 'Input file name. Read from stdin if not given.') do |filename|
379
+ input = filename
380
+ end
381
+ opts.on('-o', '--output FILE', 'Output file name. Write to stdout if not given.') do |filename|
382
+ output = filename
383
+ end
384
+ opts.on('-q', '--quiet', 'No output except errors.') do
385
+ $QUIET = true
386
+ end
387
+ opts.on('-h', '--help', 'Print this help and exit.') do
388
+ $stdout.puts opts
389
+ $stdout.puts %(
390
+ Algorithm names are: #{$algorithms.keys.sort.join(' ')}
391
+
392
+ Input YAML file is expected to be the output of diagrammatron-edges.
393
+
394
+ Output is the input file with 'xo' and 'yo' modified so that the sub-diagrams
395
+ do not overlap.
396
+ )
397
+ exit 0
398
+ end
399
+ end
400
+ parser.parse! ARGV
401
+ unless $W2HRATIO.is_a? Float
402
+ begin
403
+ whratio = Float($W2HRATIO)
404
+ if whratio <= 0
405
+ return info("Ratio must be greater than zero: #{$W2HRATIO}", 1, true)
406
+ end
407
+ $W2HRATIO = whratio
408
+ rescue StandardError
409
+ return info("Ratio parameter not a number: #{$W2HRATIO}", 1, true)
410
+ end
411
+ end
412
+
413
+ unless $algorithms.key? algo
414
+ return info("Unrecognized algorithm: #{algo}", 2, true)
415
+ end
416
+ algo = $algorithms[algo]
417
+
418
+ doc = load_source(input)
419
+ return 2 if doc.nil?
420
+
421
+ begin
422
+ work = work_copy(doc)
423
+ return 3 if work.nil?
424
+ rescue StandardError
425
+ return info('Error processing input.', 3, true)
426
+ end
427
+
428
+ algo.call(work)
429
+ prepare_output(doc, work)
430
+ begin
431
+ d = YAML.dump(doc, line_width: 1000000)
432
+ if output.nil?
433
+ $stdout.puts d
434
+ else
435
+ fp = Pathname.new output
436
+ fp.open('w') do |f|
437
+ f.puts d
438
+ end
439
+ end
440
+ rescue StandardError => e
441
+ return info("#{e}\nFailed to write output: #{output || 'stdout'}", 4, true)
442
+ end
443
+ 0
444
+ end
445
+
446
+ exit(main) if (defined? $unit_test).nil?
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright © 2021 Ismo Kärkkäinen
5
+ # Licensed under Universal Permissive License. See LICENSE.txt.
6
+
7
+ require '../lib/common.rb'
8
+ require 'optparse'
9
+ require 'yaml'
10
+ require 'set'
11
+ require 'pathname'
12
+
13
+
14
+ def prune_array(original, matching, keep)
15
+ kept = []
16
+ original.each_index do |k|
17
+ if (keep && matching[k]) || (!keep && !matching[k])
18
+ kept.push original[k]
19
+ end
20
+ end
21
+ kept
22
+ end
23
+
24
+ def prune(doc, patterns, keep)
25
+ labels = Set.new
26
+ nodes = doc.fetch('nodes', [])
27
+ matching = Array.new(nodes.size, false)
28
+ nodes.each_index do |k|
29
+ label = nodes[k].fetch('label', '')
30
+ patterns.each do |p|
31
+ next unless p.match(label)
32
+ matching[k] = true
33
+ labels.add label
34
+ break
35
+ end
36
+ end
37
+ doc['nodes'] = prune_array(nodes, matching, keep)
38
+ edges = doc.fetch('edges', [])
39
+ matching = Array.new(edges.size, false)
40
+ edges.each_index do |k|
41
+ between = edges[k].fetch('between', [])
42
+ if between.size != 2
43
+ matching[k] = keep # Removing these suppresses later warnings.
44
+ elsif keep
45
+ matching[k] = (labels.member?(between[0]) && labels.member?(between[1]))
46
+ else
47
+ matching[k] = (labels.member?(between[0]) || labels.member?(between[1]))
48
+ end
49
+ end
50
+ doc['edges'] = prune_array(edges, matching, keep)
51
+ end
52
+
53
+ def main
54
+ input = nil
55
+ output = nil
56
+ keep = false
57
+ ENV['POSIXLY_CORRECT'] = '1' # Leaves patterns as they are.
58
+ parser = OptionParser.new do |opts|
59
+ opts.summary_indent = ' '
60
+ opts.summary_width = 20
61
+ opts.banner = 'Usage: diagrammatron-prune [options] patterns...'
62
+ opts.separator ''
63
+ opts.separator 'Options:'
64
+ opts.on('-i', '--input FILE', 'Input file name. Read from stdin if not given.') do |filename|
65
+ input = filename
66
+ end
67
+ opts.on('-o', '--output FILE', 'Output file name. Write to stdout if not given.') do |filename|
68
+ output = filename
69
+ end
70
+ opts.on('-r', '--reverse', 'Keep nodes that match patterns.') do
71
+ keep = true
72
+ end
73
+ opts.on('-h', '--help', 'Print this help and exit.') do
74
+ $stdout.puts %(#{opts}
75
+ Patterns are strings used to create Ruby Regexps.
76
+
77
+ Input YAML file is expected to be dot_json2diagrammatron output.
78
+
79
+ Output is the input file with nodes that have labels that match patterns
80
+ removed or kept depending on options. Edges to removed nodes are removed.
81
+ )
82
+ exit 0
83
+ end
84
+ end
85
+ parser.parse! ARGV
86
+
87
+ begin
88
+ patterns = ARGV.map { |p| Regexp.new(p) }
89
+ rescue StandardError => e
90
+ return aargh("Error creating Regexp: #{e}", 1)
91
+ end
92
+ doc = load_source(input)
93
+ return 2 if doc.nil?
94
+
95
+ begin
96
+ prune(doc, patterns, keep)
97
+ rescue StandardError
98
+ return aargh('Error processing input.', 3)
99
+ end
100
+
101
+ begin
102
+ d = YAML.dump(doc, line_width: 1000000)
103
+ if output.nil?
104
+ $stdout.puts d
105
+ else
106
+ fp = Pathname.new output
107
+ fp.open('w') do |f|
108
+ f.puts d
109
+ end
110
+ end
111
+ rescue StandardError => e
112
+ return aargh("#{e}\nFailed to write output: #{output || 'stdout'}", 4)
113
+ end
114
+ 0
115
+ end
116
+
117
+ exit(main) if (defined? $unit_test).nil?