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,439 @@
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 vertical(work)
15
+ work[:nodes].each_index do |k|
16
+ node = work[:nodes][k]
17
+ node[:xo] = 0
18
+ node[:yo] = k
19
+ node[:sid] = 0
20
+ end
21
+ end
22
+
23
+ def shortest_paths(work)
24
+ # https://en.wikipedia.org/wiki/Floyd–Warshall_algorithm
25
+ dists = Hash.new(Float::INFINITY)
26
+ work[:edges].each do |edge|
27
+ edge = edge[:between]
28
+ dists[edge] = 1
29
+ dists[edge.reverse] = 1
30
+ end
31
+ nodes = work[:nodes]
32
+ nodes.each_index do |k|
33
+ dists[[k, k]] = 0
34
+ end
35
+ nodes.each_index do |k|
36
+ nodes.each_index do |i|
37
+ nodes.each_index do |j|
38
+ idx = [i, j]
39
+ cand = dists[[i, k]] + dists[[k, j]]
40
+ next unless cand < dists[idx]
41
+ dists[idx] = cand
42
+ end
43
+ end
44
+ end
45
+ dists
46
+ end
47
+
48
+ def separate_subdiagrams(work, dists)
49
+ used = Set.new
50
+ subsets = []
51
+ work[:nodes].each_index do |k|
52
+ next if used.include? k
53
+ subset = Set[k]
54
+ used.add k
55
+ work[:nodes].each_index do |n|
56
+ next if k == n
57
+ next if dists[[k, n]] == Float::INFINITY
58
+ subset.add n
59
+ used.add n
60
+ end
61
+ subsets.push(subset.to_a)
62
+ end
63
+ subsets
64
+ end
65
+
66
+ def place_subset(work, dists, subset, subset_index)
67
+ if subset.size < 3
68
+ work[:nodes][subset[0]][:sid] = subset_index
69
+ work[:nodes][subset[0]][:xo] = 0
70
+ work[:nodes][subset[0]][:yo] = 0
71
+ if subset.size == 2
72
+ work[:nodes][subset[1]][:sid] = subset_index
73
+ work[:nodes][subset[1]][:xo] = 1
74
+ work[:nodes][subset[1]][:yo] = 0
75
+ end
76
+ return
77
+ end
78
+ # Subset distances.
79
+ sd = {}
80
+ subset.each_index do |k|
81
+ subset.each_index do |n|
82
+ sd[[k, n]] = dists[[subset[k], subset[n]]]
83
+ end
84
+ end
85
+ # Use distances as coordinates.
86
+ # Find pair for which there are most unique coordinate pairs.
87
+ maxpair = nil
88
+ maxuniques = nil
89
+ subset.each_index do |k|
90
+ subset.each_index do |n|
91
+ next if n <= k # Same pair in different direction swaps coordinates.
92
+ uniques = Hash.new(0)
93
+ subset.each_index do |p|
94
+ idx = [sd[[k, p]], sd[[n, p]]]
95
+ uniques[idx] = uniques[idx] + 1
96
+ end
97
+ better = false
98
+ compares = (maxuniques.nil? ? 0 : maxuniques.size) <=> uniques.size
99
+ if compares.negative?
100
+ better = true
101
+ elsif compares.zero?
102
+ # We want to avoid lots of nodes in the same location.
103
+ compares = uniques.values.max <=> maxuniques.values.max
104
+ if compares.negative?
105
+ better = true
106
+ elsif compares.zero?
107
+ # This could be better main tie-breaker than simple maximum.
108
+ compares = uniques.values.sum { |v| v * v }
109
+ better = compares < maxuniques.values.sum { |v| v * v }
110
+ end
111
+ end
112
+ next unless better
113
+ maxuniques = uniques
114
+ maxpair = [k, n]
115
+ end
116
+ end
117
+ subset.each_index do |p|
118
+ work[:nodes][subset[p]][:sid] = subset_index
119
+ xo = (sd[[maxpair[0], p]] + 1) * 5 * subset.size
120
+ yo = (sd[[maxpair[1], p]] + 1) * 5 * subset.size
121
+ work[:nodes][subset[p]][:xo] = xo
122
+ work[:nodes][subset[p]][:yo] = yo
123
+ end
124
+ end
125
+
126
+ def coordinate_collisions(work, subset)
127
+ collisions = {}
128
+ subset.each do |p|
129
+ xo = work[:nodes][p][:xo]
130
+ yo = work[:nodes][p][:yo]
131
+ c = [xo, yo]
132
+ collisions[c] = [] unless collisions.key? c
133
+ collisions[c].push(p)
134
+ end
135
+ collisions
136
+ end
137
+
138
+ def shifts(count)
139
+ side = (count / 2.0).ceil
140
+ side = ((side / 4) + ((side % 4).positive? ? 1 : 0)) * 4
141
+ xs = Array.new(side) { |index| Integer((index - side / 2).round) }
142
+ (0..3).each { |k| xs.push(-xs[k]) }
143
+ ys = Array.new(xs)
144
+ xs.rotate!(side / 2 - 1)
145
+ ys.rotate!(side - 1) # First half-way to offset with xs, then like xs.
146
+ [ xs, ys ]
147
+ end
148
+
149
+ def spread(work, indexes)
150
+ indexes.sort!
151
+ xs, ys = shifts(indexes.size - 1)
152
+ step = xs.size / (indexes.size - 1.0)
153
+ loc = 0.0
154
+ (1...indexes.size).each do |k|
155
+ node_index = indexes[k]
156
+ idx = loc.round
157
+ loc += step
158
+ work[:nodes][node_index][:xo] += xs[idx]
159
+ work[:nodes][node_index][:yo] += ys[idx]
160
+ end
161
+ end
162
+
163
+ def uniques(nodes, subset, key, ortho)
164
+ u = {}
165
+ subset.each do |p|
166
+ c = nodes[p][key]
167
+ u[c] = [] unless u.key? c
168
+ u[c].push(nodes[p][ortho])
169
+ end
170
+ u.each_value(&:sort!)
171
+ u
172
+ end
173
+
174
+ def overlap(a, b)
175
+ seen = Set.new(a)
176
+ b.each do |v|
177
+ return true if seen.add?(v).nil?
178
+ end
179
+ false
180
+ end
181
+
182
+ def combinable(coords)
183
+ c = {}
184
+ ordered = coords.keys.sort
185
+ ordered.each_index do |k|
186
+ n = k + 1
187
+ break if n == ordered.size
188
+ next if overlap(coords[ordered[k]], coords[ordered[n]])
189
+ c[ordered[k]] = {
190
+ size: coords[ordered[k]].size + coords[ordered[n]].size,
191
+ next: ordered[n]
192
+ }
193
+ end
194
+ c
195
+ end
196
+
197
+ def find_shortest(combinables)
198
+ cand = nil
199
+ combinables.each_pair do |coord, v|
200
+ cand = coord if cand.nil? || v[:size] < combinables[cand][:size]
201
+ end
202
+ cand
203
+ end
204
+
205
+ def combine(work, subset, coord, eliminated, key)
206
+ subset.each do |p|
207
+ node = work[:nodes][p]
208
+ next unless node[key] == eliminated
209
+ node[key] = coord
210
+ end
211
+ end
212
+
213
+ def reduce(work, subset)
214
+ # Loop checking both directions until there is nothing to combine.
215
+ loop do
216
+ cx = combinable(uniques(work[:nodes], subset, :xo, :yo))
217
+ cy = combinable(uniques(work[:nodes], subset, :yo, :xo))
218
+ # To avoid rows/columns with few nodes, take shortest combination first.
219
+ xcand = find_shortest(cx)
220
+ ycand = find_shortest(cy)
221
+ cand = nil
222
+ c = nil
223
+ key = nil
224
+ if xcand.nil?
225
+ break if ycand.nil?
226
+ cand = ycand
227
+ c = cy
228
+ key = :yo
229
+ else
230
+ cand = xcand
231
+ c = cx
232
+ key = :xo
233
+ end
234
+ combine(work, subset, cand, c[cand][:next], key)
235
+ end
236
+ end
237
+
238
+ def remap(work, subset, key)
239
+ coords = Set.new
240
+ subset.each { |p| coords.add(work[:nodes][p][key]) }
241
+ coords = coords.to_a.sort
242
+ rec = {}
243
+ coords.each_index { |k| rec[coords[k]] = k }
244
+ subset.each do |p|
245
+ work[:nodes][p][key] = rec[work[:nodes][p][key]]
246
+ end
247
+ end
248
+
249
+ def pathlength2coordinates(work)
250
+ dists = shortest_paths(work)
251
+ # Split to connected subsets.
252
+ subsets = separate_subdiagrams(work, dists)
253
+ subsets.each_index do |subset_index|
254
+ subset = subsets[subset_index]
255
+ place_subset(work, dists, subset, subset_index)
256
+ end
257
+ subsets.each_index do |subset_index|
258
+ subset = subsets[subset_index]
259
+ collisions = coordinate_collisions(work, subset)
260
+ # Check collisions and spread them out.
261
+ collisions.each_value do |indexes|
262
+ next unless indexes.size > 1
263
+ spread(work, indexes)
264
+ end
265
+ reduce(work, subset)
266
+ remap(work, subset, :xo)
267
+ remap(work, subset, :yo)
268
+ end
269
+ # Placing separate subsets in relation to each other is done separately.
270
+ end
271
+
272
+ $algorithms = {
273
+ 'pathlength' => method(:pathlength2coordinates),
274
+ 'vertical' => method(:vertical)
275
+ }
276
+
277
+ def work_copy(src, quiet)
278
+ work = { edges: [], nodes: [] }
279
+ # Expected nodes, edges. Other pass-through.
280
+ label2idx = {}
281
+ errors = false
282
+ edge_nodes = Set.new
283
+ edges = src.fetch('edges', [])
284
+ unedges = []
285
+ selfedges = []
286
+ edges.each_index do |k|
287
+ edge = edges[k]
288
+ labels = edge.fetch('between', [])
289
+ if labels.nil? || labels.empty?
290
+ unedges.push(k)
291
+ elsif labels.size == 2
292
+ if labels.first == labels.last
293
+ selfedges.push(k)
294
+ else
295
+ edge_nodes.add labels.first
296
+ edge_nodes.add labels.last
297
+ work[:edges].push({ idx: k, between: [ labels[0], labels[1] ] })
298
+ end
299
+ else
300
+ aargh "Edge #{k + 1} does not have two labels in 'between'"
301
+ errors = true
302
+ end
303
+ end
304
+ labeled_nodes = Set.new
305
+ unlabeled = []
306
+ nodes = src.fetch('nodes', [])
307
+ nodes.each_index do |k|
308
+ work[:nodes].push({ idx: k })
309
+ node = nodes[k]
310
+ unless node.key? 'label'
311
+ unlabeled.push k
312
+ next
313
+ end
314
+ label = node['label']
315
+ if label2idx.key?(label) && edge_nodes.key?(label)
316
+ aargh "Edge-referred label used twice: #{label}"
317
+ errors = true
318
+ end
319
+ label2idx[label] = k
320
+ labeled_nodes.add label
321
+ end
322
+ missing = edge_nodes - labeled_nodes
323
+ unless missing.empty?
324
+ aargh "Edges refer to missing node labels: #{missing.to_a.join(' ')}"
325
+ errors = true
326
+ end
327
+ return nil if errors
328
+ unused = labeled_nodes - edge_nodes
329
+ [ [ unused.to_a, 'unconnected labeled nodes' ],
330
+ [ unlabeled, 'unlabeled nodes' ],
331
+ [ selfedges, 'edges from node to itself' ],
332
+ [ unedges, 'edges without end-points' ]
333
+ ].each do |x|
334
+ next if quiet || x.first.empty?
335
+ aargh("Note, #{x.last}: #{x.first.join(' ')}")
336
+ end
337
+ work[:edges].each do |edge|
338
+ edge[:between][0] = label2idx[edge[:between][0]]
339
+ edge[:between][1] = label2idx[edge[:between][1]]
340
+ edge[:between].reverse! if edge[:between][1] < edge[:between][0]
341
+ end
342
+ work
343
+ end
344
+
345
+ def prepare_output(doc, work)
346
+ work[:nodes].each do |node|
347
+ doc['nodes'][node[:idx]]['xo'] = node[:xo]
348
+ doc['nodes'][node[:idx]]['yo'] = node[:yo]
349
+ doc['nodes'][node[:idx]]['sid'] = node[:sid]
350
+ end
351
+ end
352
+
353
+ def main
354
+ input = nil
355
+ output = nil
356
+ algo = 'pathlength'
357
+ quiet = false
358
+ parser = OptionParser.new do |opts|
359
+ opts.summary_indent = ' '
360
+ opts.summary_width = 20
361
+ opts.banner = 'Usage: diagrammatron-nodes [options]'
362
+ opts.separator ''
363
+ opts.separator 'Options:'
364
+ opts.on('-a', '--algorithm NAME', "Node placement algoritm. Default #{algo}") do |a|
365
+ algo = a
366
+ end
367
+ opts.on('-i', '--input FILE', 'Input file name. Read from stdin if not given.') do |filename|
368
+ input = filename
369
+ end
370
+ opts.on('-o', '--output FILE', 'Output file name. Write to stdout if not given.') do |filename|
371
+ output = filename
372
+ end
373
+ opts.on('-q', '--quiet', 'No output except errors.') do
374
+ quiet = true
375
+ end
376
+ opts.on('-h', '--help', 'Print this help and exit.') do
377
+ $stdout.puts opts
378
+ $stdout.puts %(
379
+ Algorithm names are: #{$algorithms.keys.sort.join(' ')}
380
+
381
+ Input YAML file is expected to be like:
382
+ ---
383
+ nodes:
384
+ - label: something
385
+ - label: another
386
+ - ignored: "Since no label. Still placed."
387
+ - label: "Unused and ok. Still placed."
388
+ edges:
389
+ - between: [ something, another ]
390
+ - between: [ something, something ] # Ignored.
391
+ - between: [ ] # Ignored.
392
+ - ignored: "Since no between."
393
+ ...
394
+ There can be other fields present but they are ignored. The nodes will
395
+ receive values xo and yo that indicate horizontal and vertical coordinates.
396
+
397
+ Output is the input file with 'xo', 'yo' and 'sid' added to each node.
398
+ The 'xo' and 'yo' indicate which unique x- and y-coordinate the value is.
399
+ The 'sid' indicates the sub-diagram consisting of connected nodes.
400
+ )
401
+ exit 0
402
+ end
403
+ end
404
+ parser.parse! ARGV
405
+
406
+ unless $algorithms.key? algo
407
+ return aargh("Unrecognized algorithm: #{algo}", 2)
408
+ end
409
+ algo = $algorithms[algo]
410
+
411
+ doc = load_source(input)
412
+ return 2 if doc.nil?
413
+
414
+ begin
415
+ work = work_copy(doc, quiet)
416
+ return 3 if work.nil?
417
+ rescue StandardError
418
+ return aargh('Error processing input.', 3)
419
+ end
420
+
421
+ algo.call(work)
422
+ prepare_output(doc, work)
423
+ begin
424
+ d = YAML.dump(doc, line_width: 1000000)
425
+ if output.nil?
426
+ $stdout.puts d
427
+ else
428
+ fp = Pathname.new output
429
+ fp.open('w') do |f|
430
+ f.puts d
431
+ end
432
+ end
433
+ rescue StandardError => e
434
+ return aargh("#{e}\nFailed to write output: #{output || 'stdout'}", 4)
435
+ end
436
+ 0
437
+ end
438
+
439
+ exit(main) if (defined? $unit_test).nil?