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.
- checksums.yaml +7 -0
- data/LICENSE.txt +17 -0
- data/bin/diagrammatron-edges +845 -0
- data/bin/diagrammatron-nodes +439 -0
- data/bin/diagrammatron-place +446 -0
- data/bin/diagrammatron-prune +117 -0
- data/bin/diagrammatron-render +355 -0
- data/bin/diagrammatron-template +107 -0
- data/bin/dot_json2diagrammatron +136 -0
- data/lib/common.rb +26 -0
- metadata +65 -0
@@ -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?
|