@yuneta/gobj-ui 7.23.68 → 7.23.70
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.
- package/README.md +49 -0
- package/dist/gobj-ui.cjs.js +438 -6
- package/dist/gobj-ui.es.js +438 -6
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +103 -2
- package/src/c_yui_treedb_graph.js +15 -4
- package/src/treedb_layout.js +380 -0
- package/src/treedb_layout.test.js +172 -0
|
@@ -506,6 +506,12 @@ function option_label(name)
|
|
|
506
506
|
/* Layouts (`layout_names` of the G6 child). */
|
|
507
507
|
case "manual":
|
|
508
508
|
return t("manual");
|
|
509
|
+
case "treedb-tree":
|
|
510
|
+
return t("treedb-tree");
|
|
511
|
+
case "treedb-outline":
|
|
512
|
+
return t("treedb-outline");
|
|
513
|
+
case "radial":
|
|
514
|
+
return t("radial");
|
|
509
515
|
case "dagre":
|
|
510
516
|
return t("dagre");
|
|
511
517
|
case "antv-dagre":
|
|
@@ -2328,6 +2334,11 @@ function ac_set_operation_mode(gobj, event, kw, src)
|
|
|
2328
2334
|
* card's graph icon travels — as EV_TOPIC_SELECTED, which the host turns
|
|
2329
2335
|
* into the URL, so what you are looking at stays linkable.
|
|
2330
2336
|
************************************************************/
|
|
2337
|
+
/* The glyphs of a chip's small buttons: `is-small` sets a 0.75rem
|
|
2338
|
+
* font and a star or a crosshair at that size is a speck. The BUTTON
|
|
2339
|
+
* stays small so the strip keeps its height; the glyph inside grows. */
|
|
2340
|
+
const LEGEND_GLYPH_STYLE = 'font-size:1.5em; line-height:1; color:inherit;';
|
|
2341
|
+
|
|
2331
2342
|
function refresh_legend(gobj)
|
|
2332
2343
|
{
|
|
2333
2344
|
let priv = gobj.priv;
|
|
@@ -2423,7 +2434,7 @@ function refresh_legend(gobj)
|
|
|
2423
2434
|
type: 'button', style: 'padding:0 .4rem;',
|
|
2424
2435
|
title: t('main topic'), 'data-i18n-title': 'main topic',
|
|
2425
2436
|
'aria-label': t('main topic'), 'aria-pressed': 'true'},
|
|
2426
|
-
'★', {
|
|
2437
|
+
[['span', {style: LEGEND_GLYPH_STYLE}, '★']], {
|
|
2427
2438
|
click: (evt) => {
|
|
2428
2439
|
evt.stopPropagation();
|
|
2429
2440
|
gobj_send_event(gobj, "EV_LEGEND_TOPIC",
|
|
@@ -2434,13 +2445,13 @@ function refresh_legend(gobj)
|
|
|
2434
2445
|
extras.push(['span', {class: 'GRAPH_LEGEND_STAR button is-small is-static',
|
|
2435
2446
|
style: 'padding:0 .4rem;',
|
|
2436
2447
|
title: t('main topic'), 'data-i18n-title': 'main topic'},
|
|
2437
|
-
'★']);
|
|
2448
|
+
[['span', {style: LEGEND_GLYPH_STYLE}, '★']]]);
|
|
2438
2449
|
} else if(!hidden) {
|
|
2439
2450
|
extras.push(['button', {class: 'GRAPH_LEGEND_STAR button is-small',
|
|
2440
2451
|
type: 'button', style: 'padding:0 .4rem;',
|
|
2441
2452
|
title: t('main topic'), 'data-i18n-title': 'main topic',
|
|
2442
2453
|
'aria-label': t('main topic')},
|
|
2443
|
-
'☆', {
|
|
2454
|
+
[['span', {style: LEGEND_GLYPH_STYLE}, '☆']], {
|
|
2444
2455
|
click: (evt) => {
|
|
2445
2456
|
evt.stopPropagation();
|
|
2446
2457
|
gobj_send_event(gobj, "EV_LEGEND_TOPIC",
|
|
@@ -2477,7 +2488,7 @@ function refresh_legend(gobj)
|
|
|
2477
2488
|
title: t('highlight topic'), 'data-i18n-title': 'highlight topic',
|
|
2478
2489
|
'aria-label': t('highlight topic'),
|
|
2479
2490
|
'aria-pressed': focused? 'true' : 'false'
|
|
2480
|
-
}, [['i', {class: 'yi-location-crosshairs'}]], {
|
|
2491
|
+
}, [['i', {class: 'yi-location-crosshairs', style: LEGEND_GLYPH_STYLE}]], {
|
|
2481
2492
|
click: (evt) => {
|
|
2482
2493
|
evt.stopPropagation();
|
|
2483
2494
|
gobj_send_event(gobj, "EV_LEGEND_TOPIC",
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* treedb_layout.js
|
|
3
|
+
*
|
|
4
|
+
* Two layouts made for a treedb, and nothing else.
|
|
5
|
+
*
|
|
6
|
+
* What a treedb graph IS, once it is folded (treedb_fold_model.js):
|
|
7
|
+
* a forest read left to right. A main topic is a tree (places:
|
|
8
|
+
* country, region, site, hall), the other topics hang from its
|
|
9
|
+
* nodes through hooks (devices, users, controllers), a record may
|
|
10
|
+
* hang from two parents (a device from its place AND its
|
|
11
|
+
* controller), and every visible node was reached from a root.
|
|
12
|
+
* Tens to a few hundred cards on screen, never the whole store.
|
|
13
|
+
*
|
|
14
|
+
* G6's own layouts either do not know that or pay for not knowing:
|
|
15
|
+
*
|
|
16
|
+
* - `dagre` gets the reading direction right and then ranks by
|
|
17
|
+
* longest path and minimises crossings with a heuristic that
|
|
18
|
+
* REORDERS the siblings on every run -- open one hook and the
|
|
19
|
+
* column next to it shuffles. It is O(n·m) per sweep and it is
|
|
20
|
+
* the one thing that takes time on a big expansion.
|
|
21
|
+
* - the tree layouts (`compact-box`, `indented`, `dendrogram`,
|
|
22
|
+
* `mindmap`) draw exactly the shape wanted, but G6 runs them on
|
|
23
|
+
* a TREE it builds from the edges: a node with two parents is
|
|
24
|
+
* placed by whichever parent the builder met first, unstably.
|
|
25
|
+
* - the force family scatters a tree, which is the pile with
|
|
26
|
+
* physics.
|
|
27
|
+
*
|
|
28
|
+
* So: a tidy tree (`tree`) and an outline (`outline`), both fed by
|
|
29
|
+
* the SAME spanning tree, chosen deterministically:
|
|
30
|
+
*
|
|
31
|
+
* - roots are the nodes with no incoming edge, in node order;
|
|
32
|
+
* - a node belongs to the FIRST parent that reaches it in a
|
|
33
|
+
* breadth-first walk from the roots -- the place, not the
|
|
34
|
+
* controller, because the place's column came first -- and the
|
|
35
|
+
* other links are drawn as edges across the tree;
|
|
36
|
+
* - the children of a node are ordered by the HOOK they hang from
|
|
37
|
+
* (the parent's port order, which is the schema's column order)
|
|
38
|
+
* and then by node order (record order, the pages' order);
|
|
39
|
+
* - a node nothing reaches (a cycle) is a root too.
|
|
40
|
+
*
|
|
41
|
+
* `tree`: the classic tidy tree, left to right. Each depth is a
|
|
42
|
+
* column as wide as its widest card; a node is centred on the
|
|
43
|
+
* block of its children; siblings stack with `nodesep` between
|
|
44
|
+
* them. O(n), and opening a hook moves NOTHING that is not below
|
|
45
|
+
* or beside it -- the cards keep their order, the eye keeps its
|
|
46
|
+
* place.
|
|
47
|
+
*
|
|
48
|
+
* `outline`: one node per row, indented by depth -- what a JSON
|
|
49
|
+
* viewer draws, and what a treedb is when read as one. Tall, but
|
|
50
|
+
* nothing is ever beside anything: the reader scrolls a list.
|
|
51
|
+
*
|
|
52
|
+
* Pure: takes plain nodes and edges, returns positions. The G6
|
|
53
|
+
* classes in c_g6_nodes_tree.js are two thin adapters over it.
|
|
54
|
+
*
|
|
55
|
+
* Copyright (c) 2026, ArtGins.
|
|
56
|
+
* All Rights Reserved.
|
|
57
|
+
***********************************************************************/
|
|
58
|
+
|
|
59
|
+
const DEFAULTS = {
|
|
60
|
+
nodesep: 18, /* gap between siblings (tree) / rows (outline) */
|
|
61
|
+
ranksep: 110, /* gap between a column and the next (tree) */
|
|
62
|
+
indent: 48, /* what a depth adds to the x of a row (outline) */
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/************************************************************
|
|
66
|
+
* The spanning tree of a graph, deterministic.
|
|
67
|
+
*
|
|
68
|
+
* nodes [{id, w, h, order}] order: creation index
|
|
69
|
+
* edges [{source, target, rank}] rank: the hook's position in
|
|
70
|
+
* the parent's ports (0 = first)
|
|
71
|
+
*
|
|
72
|
+
* Returns {roots: [id], children: Map<id, [id]>, depth: Map<id, n>,
|
|
73
|
+
* by_id: Map<id, node>}
|
|
74
|
+
************************************************************/
|
|
75
|
+
export function spanning_tree(nodes, edges)
|
|
76
|
+
{
|
|
77
|
+
let by_id = new Map();
|
|
78
|
+
for(let i = 0; i < nodes.length; i++) {
|
|
79
|
+
let n = nodes[i];
|
|
80
|
+
by_id.set(n.id, Object.assign({order: i}, n));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Outgoing links per node, with the rank of the hook they leave by. */
|
|
84
|
+
let out = new Map();
|
|
85
|
+
let has_parent = new Set();
|
|
86
|
+
for(let e of edges) {
|
|
87
|
+
if(!by_id.has(e.source) || !by_id.has(e.target) || e.source === e.target) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if(!out.has(e.source)) {
|
|
91
|
+
out.set(e.source, []);
|
|
92
|
+
}
|
|
93
|
+
out.get(e.source).push({id: e.target, rank: (e.rank > 0)? e.rank : 0});
|
|
94
|
+
has_parent.add(e.target);
|
|
95
|
+
}
|
|
96
|
+
for(let list of out.values()) {
|
|
97
|
+
list.sort((a, b) => {
|
|
98
|
+
if(a.rank !== b.rank) {
|
|
99
|
+
return a.rank - b.rank;
|
|
100
|
+
}
|
|
101
|
+
return by_id.get(a.id).order - by_id.get(b.id).order;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let roots = [];
|
|
106
|
+
for(let n of by_id.values()) {
|
|
107
|
+
if(!has_parent.has(n.id)) {
|
|
108
|
+
roots.push(n.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let children = new Map();
|
|
113
|
+
let depth = new Map();
|
|
114
|
+
let assign = (start) => {
|
|
115
|
+
let queue = [start];
|
|
116
|
+
depth.set(start, 0);
|
|
117
|
+
while(queue.length) {
|
|
118
|
+
let id = queue.shift();
|
|
119
|
+
let kids = [];
|
|
120
|
+
for(let link of (out.get(id) || [])) {
|
|
121
|
+
if(depth.has(link.id)) {
|
|
122
|
+
continue; /* reached before: that parent keeps it */
|
|
123
|
+
}
|
|
124
|
+
depth.set(link.id, depth.get(id) + 1);
|
|
125
|
+
kids.push(link.id);
|
|
126
|
+
queue.push(link.id);
|
|
127
|
+
}
|
|
128
|
+
children.set(id, kids);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
for(let root of roots) {
|
|
132
|
+
assign(root);
|
|
133
|
+
}
|
|
134
|
+
/* A cycle with no way in: its first node, in node order, is a root. */
|
|
135
|
+
for(let n of by_id.values()) {
|
|
136
|
+
if(!depth.has(n.id)) {
|
|
137
|
+
roots.push(n.id);
|
|
138
|
+
assign(n.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {roots: roots, children: children, depth: depth, by_id: by_id};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/************************************************************
|
|
146
|
+
* The tidy tree, left to right.
|
|
147
|
+
*
|
|
148
|
+
* Returns Map<id, {x, y}> with the CENTRE of each node, the way G6
|
|
149
|
+
* positions a node.
|
|
150
|
+
************************************************************/
|
|
151
|
+
export function layout_tree(nodes, edges, opts)
|
|
152
|
+
{
|
|
153
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
154
|
+
let t = spanning_tree(nodes, edges);
|
|
155
|
+
let pos = new Map();
|
|
156
|
+
|
|
157
|
+
/* Column widths: the widest card of each depth. */
|
|
158
|
+
let col_w = [];
|
|
159
|
+
for(let [id, d] of t.depth) {
|
|
160
|
+
let w = t.by_id.get(id).w;
|
|
161
|
+
col_w[d] = Math.max(col_w[d] || 0, w);
|
|
162
|
+
}
|
|
163
|
+
let col_x = [];
|
|
164
|
+
let x = 0;
|
|
165
|
+
for(let d = 0; d < col_w.length; d++) {
|
|
166
|
+
col_x[d] = x;
|
|
167
|
+
x += (col_w[d] || 0) + o.ranksep;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Subtree heights, post-order. */
|
|
171
|
+
let height = new Map();
|
|
172
|
+
let measure = (id) => {
|
|
173
|
+
let kids = t.children.get(id) || [];
|
|
174
|
+
let own = t.by_id.get(id).h;
|
|
175
|
+
if(!kids.length) {
|
|
176
|
+
height.set(id, own);
|
|
177
|
+
return own;
|
|
178
|
+
}
|
|
179
|
+
let sum = 0;
|
|
180
|
+
for(let i = 0; i < kids.length; i++) {
|
|
181
|
+
sum += measure(kids[i]);
|
|
182
|
+
if(i > 0) {
|
|
183
|
+
sum += o.nodesep;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let h = Math.max(own, sum);
|
|
187
|
+
height.set(id, h);
|
|
188
|
+
return h;
|
|
189
|
+
};
|
|
190
|
+
for(let root of t.roots) {
|
|
191
|
+
measure(root);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* Placement, pre-order: a node is centred on the block of its
|
|
195
|
+
* children, which starts at the top of its own block. */
|
|
196
|
+
let place = (id, top) => {
|
|
197
|
+
let n = t.by_id.get(id);
|
|
198
|
+
let d = t.depth.get(id);
|
|
199
|
+
let kids = t.children.get(id) || [];
|
|
200
|
+
let block = height.get(id);
|
|
201
|
+
let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
|
|
202
|
+
if(!kids.length) {
|
|
203
|
+
pos.set(id, {x: cx, y: top + block / 2});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
let kids_h = 0;
|
|
207
|
+
for(let i = 0; i < kids.length; i++) {
|
|
208
|
+
kids_h += height.get(kids[i]);
|
|
209
|
+
if(i > 0) {
|
|
210
|
+
kids_h += o.nodesep;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let cursor = top + (block - kids_h) / 2;
|
|
214
|
+
for(let kid of kids) {
|
|
215
|
+
place(kid, cursor);
|
|
216
|
+
cursor += height.get(kid) + o.nodesep;
|
|
217
|
+
}
|
|
218
|
+
pos.set(id, {x: cx, y: top + block / 2});
|
|
219
|
+
};
|
|
220
|
+
let top = 0;
|
|
221
|
+
for(let root of t.roots) {
|
|
222
|
+
place(root, top);
|
|
223
|
+
top += height.get(root) + o.nodesep * 2;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return pos;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/************************************************************
|
|
230
|
+
* The radial tree: the root in the middle, a ring per depth.
|
|
231
|
+
*
|
|
232
|
+
* G6's `radial` places by MDS on the graph distances and pushes
|
|
233
|
+
* overlaps apart afterwards, with a ring radius it is TOLD; twenty
|
|
234
|
+
* cards of 172px on a ring of radius 200 have 52px each and pile
|
|
235
|
+
* up. This one is the radial form of the tidy tree above: every
|
|
236
|
+
* subtree gets an angular SECTOR proportional to its leaves, a node
|
|
237
|
+
* sits in the middle of its sector, and the radius of each ring is
|
|
238
|
+
* the larger of "one step further out than the ring before" and
|
|
239
|
+
* "long enough for its nodes to sit side by side" -- so a hall
|
|
240
|
+
* with a hundred devices is a wide, sparse fan, and nothing
|
|
241
|
+
* overlaps by construction. Several roots share the circle around
|
|
242
|
+
* a virtual centre.
|
|
243
|
+
*
|
|
244
|
+
* ranksep the step between rings
|
|
245
|
+
* nodesep the gap between neighbours on a ring
|
|
246
|
+
************************************************************/
|
|
247
|
+
export function layout_radial(nodes, edges, opts)
|
|
248
|
+
{
|
|
249
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
250
|
+
let t = spanning_tree(nodes, edges);
|
|
251
|
+
let pos = new Map();
|
|
252
|
+
if(!t.roots.length) {
|
|
253
|
+
return pos;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Leaves under each node: the sector it deserves. */
|
|
257
|
+
let leaves = new Map();
|
|
258
|
+
let count = (id) => {
|
|
259
|
+
let kids = t.children.get(id) || [];
|
|
260
|
+
if(!kids.length) {
|
|
261
|
+
leaves.set(id, 1);
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
let n = 0;
|
|
265
|
+
for(let kid of kids) {
|
|
266
|
+
n += count(kid);
|
|
267
|
+
}
|
|
268
|
+
leaves.set(id, n);
|
|
269
|
+
return n;
|
|
270
|
+
};
|
|
271
|
+
let total = 0;
|
|
272
|
+
for(let root of t.roots) {
|
|
273
|
+
total += count(root);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* One root sits at the centre; several sit on the first ring
|
|
277
|
+
* around an empty centre, so every depth shifts out by one. */
|
|
278
|
+
let single = (t.roots.length === 1);
|
|
279
|
+
let ring_of = (id) => t.depth.get(id) + (single? 0 : 1);
|
|
280
|
+
|
|
281
|
+
/* Pass 1, the ANGLES: a node's children split its sector by
|
|
282
|
+
* their leaves, and a node sits at the middle of its sector.
|
|
283
|
+
* Angles do not depend on the radii, so they come first. */
|
|
284
|
+
let angle = new Map();
|
|
285
|
+
let rings = []; /* ring -> [{id, angle, diag}] */
|
|
286
|
+
let sector = (id, from, span) => {
|
|
287
|
+
let a = from + span / 2;
|
|
288
|
+
angle.set(id, a);
|
|
289
|
+
let ring = ring_of(id);
|
|
290
|
+
if(!rings[ring]) {
|
|
291
|
+
rings[ring] = [];
|
|
292
|
+
}
|
|
293
|
+
let n = t.by_id.get(id);
|
|
294
|
+
rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
|
|
295
|
+
let cursor = from;
|
|
296
|
+
for(let kid of (t.children.get(id) || [])) {
|
|
297
|
+
let part = span * leaves.get(kid) / leaves.get(id);
|
|
298
|
+
sector(kid, cursor, part);
|
|
299
|
+
cursor += part;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
if(single) {
|
|
303
|
+
let root = t.roots[0];
|
|
304
|
+
angle.set(root, 0);
|
|
305
|
+
let cursor = -Math.PI / 2; /* the first child at the top */
|
|
306
|
+
for(let kid of (t.children.get(root) || [])) {
|
|
307
|
+
let part = 2 * Math.PI * leaves.get(kid) / leaves.get(root);
|
|
308
|
+
sector(kid, cursor, part);
|
|
309
|
+
cursor += part;
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
let cursor = -Math.PI / 2;
|
|
313
|
+
for(let root of t.roots) {
|
|
314
|
+
let part = 2 * Math.PI * leaves.get(root) / total;
|
|
315
|
+
sector(root, cursor, part);
|
|
316
|
+
cursor += part;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* Pass 2, the RADII: a ring is one step further out than the
|
|
321
|
+
* ring before, and as far out as its two closest neighbours need
|
|
322
|
+
* to sit side by side -- the arc between two adjacent centres is
|
|
323
|
+
* r·Δangle, and it has to hold half of each card plus the gap.
|
|
324
|
+
* Measured on NEIGHBOURS, not on the ring's total length: a
|
|
325
|
+
* leafless root squeezed between two big subtrees gets a sliver
|
|
326
|
+
* of angle, and it is the sliver that sets the radius. */
|
|
327
|
+
let radius = [0];
|
|
328
|
+
for(let ring = 1; ring < rings.length; ring++) {
|
|
329
|
+
let r = radius[ring - 1] + o.ranksep;
|
|
330
|
+
let list = (rings[ring] || []).slice().sort((a, b) => a.angle - b.angle);
|
|
331
|
+
for(let i = 0; i < list.length && list.length > 1; i++) {
|
|
332
|
+
let a = list[i];
|
|
333
|
+
let b = list[(i + 1) % list.length];
|
|
334
|
+
let gap = (i + 1 < list.length)
|
|
335
|
+
? b.angle - a.angle
|
|
336
|
+
: b.angle + 2 * Math.PI - a.angle;
|
|
337
|
+
if(gap > 1e-9) {
|
|
338
|
+
r = Math.max(r, (a.diag / 2 + b.diag / 2 + o.nodesep) / gap);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
radius[ring] = r;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Pass 3, the positions. */
|
|
345
|
+
for(let [id, a] of angle) {
|
|
346
|
+
let r = radius[ring_of(id)] || 0;
|
|
347
|
+
pos.set(id, {x: r * Math.cos(a), y: r * Math.sin(a)});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return pos;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/************************************************************
|
|
354
|
+
* The outline: one node per row, indented by depth, in the
|
|
355
|
+
* pre-order of the spanning tree. Rows are left-aligned on
|
|
356
|
+
* their indent, so a column of chips reads as a list.
|
|
357
|
+
************************************************************/
|
|
358
|
+
export function layout_outline(nodes, edges, opts)
|
|
359
|
+
{
|
|
360
|
+
let o = Object.assign({}, DEFAULTS, opts || {});
|
|
361
|
+
let t = spanning_tree(nodes, edges);
|
|
362
|
+
let pos = new Map();
|
|
363
|
+
|
|
364
|
+
let y = 0;
|
|
365
|
+
let visit = (id) => {
|
|
366
|
+
let n = t.by_id.get(id);
|
|
367
|
+
let d = t.depth.get(id);
|
|
368
|
+
pos.set(id, {x: d * o.indent + n.w / 2, y: y + n.h / 2});
|
|
369
|
+
y += n.h + o.nodesep;
|
|
370
|
+
for(let kid of (t.children.get(id) || [])) {
|
|
371
|
+
visit(kid);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
for(let root of t.roots) {
|
|
375
|
+
visit(root);
|
|
376
|
+
y += o.nodesep; /* a breath between two trees */
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return pos;
|
|
380
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/***********************************************************************
|
|
2
|
+
* treedb_layout.test.js
|
|
3
|
+
*
|
|
4
|
+
* The two treedb layouts, pinned: the spanning tree they share
|
|
5
|
+
* (roots, first parent wins, hook order then record order), the
|
|
6
|
+
* tidy tree's columns and centring, the outline's rows.
|
|
7
|
+
***********************************************************************/
|
|
8
|
+
import { describe, test, expect } from "vitest";
|
|
9
|
+
import { spanning_tree, layout_tree, layout_outline, layout_radial } from "./treedb_layout.js";
|
|
10
|
+
|
|
11
|
+
/* es -> (norte, sur); norte -> nave; nave -> d0, d1 (hook rank 1) and
|
|
12
|
+
* c1 (hook rank 2); c1 -> d0 as well (second parent). */
|
|
13
|
+
const N = (id, w, h) => ({id, w, h});
|
|
14
|
+
const nodes = [
|
|
15
|
+
N("es", 172, 96), N("norte", 172, 96), N("sur", 172, 96), N("nave", 172, 120),
|
|
16
|
+
N("d0", 116, 40), N("d1", 116, 40), N("c1", 172, 96),
|
|
17
|
+
];
|
|
18
|
+
const edges = [
|
|
19
|
+
{source: "es", target: "norte", rank: 0},
|
|
20
|
+
{source: "es", target: "sur", rank: 0},
|
|
21
|
+
{source: "norte", target: "nave", rank: 0},
|
|
22
|
+
{source: "nave", target: "c1", rank: 2},
|
|
23
|
+
{source: "nave", target: "d0", rank: 1},
|
|
24
|
+
{source: "nave", target: "d1", rank: 1},
|
|
25
|
+
{source: "c1", target: "d0", rank: 0},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe("the spanning tree", () => {
|
|
29
|
+
test("roots are the nodes with no incoming edge, in node order", () => {
|
|
30
|
+
let t = spanning_tree(nodes, edges);
|
|
31
|
+
expect(t.roots).toEqual(["es"]);
|
|
32
|
+
expect(t.depth.get("nave")).toBe(2);
|
|
33
|
+
expect(t.depth.get("d0")).toBe(3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("children are ordered by hook rank, then by node order", () => {
|
|
37
|
+
let t = spanning_tree(nodes, edges);
|
|
38
|
+
expect(t.children.get("nave")).toEqual(["d0", "d1", "c1"]);
|
|
39
|
+
expect(t.children.get("es")).toEqual(["norte", "sur"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a node with two parents belongs to the first that reaches it", () => {
|
|
43
|
+
let t = spanning_tree(nodes, edges);
|
|
44
|
+
expect(t.children.get("c1")).toEqual([]); /* d0 is nave's */
|
|
45
|
+
expect(t.depth.get("d0")).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a cycle nothing reaches gets a root; a self edge is ignored", () => {
|
|
49
|
+
let t = spanning_tree(
|
|
50
|
+
[N("a", 10, 10), N("b", 10, 10), N("c", 10, 10)],
|
|
51
|
+
[{source: "a", target: "b"}, {source: "b", target: "c"}, {source: "c", target: "a"},
|
|
52
|
+
{source: "b", target: "b"}]
|
|
53
|
+
);
|
|
54
|
+
expect(t.roots).toEqual(["a"]);
|
|
55
|
+
expect(t.children.get("a")).toEqual(["b"]);
|
|
56
|
+
expect(t.depth.get("c")).toBe(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("an edge to an unknown node is ignored", () => {
|
|
60
|
+
let t = spanning_tree([N("a", 10, 10)], [{source: "a", target: "ghost"}]);
|
|
61
|
+
expect(t.roots).toEqual(["a"]);
|
|
62
|
+
expect(t.children.get("a")).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("the tidy tree", () => {
|
|
67
|
+
test("depths are columns; a column is as wide as its widest card", () => {
|
|
68
|
+
let pos = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
69
|
+
expect(pos.get("es").x).toBe(86); /* 172/2 */
|
|
70
|
+
expect(pos.get("norte").x).toBe(172 + 100 + 86);
|
|
71
|
+
expect(pos.get("sur").x).toBe(pos.get("norte").x);
|
|
72
|
+
/* depth 3 holds chips (116) and c1 (172): column is 172 wide,
|
|
73
|
+
* the chips are centred in it */
|
|
74
|
+
let col3 = 3 * (172 + 100);
|
|
75
|
+
expect(pos.get("c1").x).toBe(col3 + 86);
|
|
76
|
+
expect(pos.get("d0").x).toBe(col3 + 86);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("siblings stack with the gap; a parent is centred on its children", () => {
|
|
80
|
+
let pos = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
81
|
+
/* nave's children: d0 (40), d1 (40), c1 (96) = 196 tall */
|
|
82
|
+
expect(pos.get("d1").y - pos.get("d0").y).toBe(50);
|
|
83
|
+
expect(pos.get("c1").y - pos.get("d1").y).toBe(20 + 10 + 48);
|
|
84
|
+
let first = pos.get("d0").y - 20;
|
|
85
|
+
let last = pos.get("c1").y + 48;
|
|
86
|
+
expect(pos.get("nave").y).toBe((first + last) / 2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a parent taller than its children keeps its own height", () => {
|
|
90
|
+
let pos = layout_tree(
|
|
91
|
+
[N("p", 100, 200), N("k", 50, 20)],
|
|
92
|
+
[{source: "p", target: "k"}], {nodesep: 10, ranksep: 50}
|
|
93
|
+
);
|
|
94
|
+
expect(pos.get("p").y).toBe(100);
|
|
95
|
+
expect(pos.get("k").y).toBe(100); /* centred in the parent's block */
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("two trees are stacked, not overlapped", () => {
|
|
99
|
+
let pos = layout_tree(
|
|
100
|
+
[N("a", 100, 50), N("b", 100, 50)], [], {nodesep: 10}
|
|
101
|
+
);
|
|
102
|
+
expect(pos.get("a").y).toBe(25);
|
|
103
|
+
expect(pos.get("b").y).toBe(25 + 50 + 20);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("opening a hook does not move what is not under or beside it", () => {
|
|
107
|
+
let base = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
108
|
+
let more = nodes.concat([N("d2", 116, 40)]);
|
|
109
|
+
let more_edges = edges.concat([{source: "nave", target: "d2", rank: 1}]);
|
|
110
|
+
let pos = layout_tree(more, more_edges, {nodesep: 10, ranksep: 100});
|
|
111
|
+
expect(pos.get("es").x).toBe(base.get("es").x);
|
|
112
|
+
expect(pos.get("d0").y).toBe(base.get("d0").y); /* above the new one */
|
|
113
|
+
expect(pos.get("d1").y).toBe(base.get("d1").y);
|
|
114
|
+
expect(pos.get("d2").y).toBe(base.get("d1").y + 50);
|
|
115
|
+
expect(pos.get("c1").y).toBe(base.get("c1").y + 50); /* pushed down, in order */
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the radial tree", () => {
|
|
120
|
+
test("one root sits at the centre, its children on a ring around it", () => {
|
|
121
|
+
let pos = layout_radial(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
122
|
+
expect(pos.get("es")).toEqual({x: 0, y: 0});
|
|
123
|
+
let r = Math.hypot(pos.get("norte").x, pos.get("norte").y);
|
|
124
|
+
expect(r).toBeGreaterThanOrEqual(100);
|
|
125
|
+
expect(Math.hypot(pos.get("sur").x, pos.get("sur").y)).toBeCloseTo(r, 6);
|
|
126
|
+
/* norte carries 3 of es's 4 leaves: three quarters of the
|
|
127
|
+
* circle, from the top (-90°) clockwise, so its middle is at
|
|
128
|
+
* +45°; sur gets the last quarter, from 180° to 270°, centred
|
|
129
|
+
* at 225° (-135°) */
|
|
130
|
+
let ang = (id) => Math.atan2(pos.get(id).y, pos.get(id).x);
|
|
131
|
+
expect(ang("norte")).toBeCloseTo(Math.PI / 4, 6);
|
|
132
|
+
expect(ang("sur")).toBeCloseTo(-3 * Math.PI / 4, 6);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a ring is long enough for its nodes: no two neighbours closer than a card", () => {
|
|
136
|
+
let many = [N("root", 172, 96)];
|
|
137
|
+
let e = [];
|
|
138
|
+
for(let i = 0; i < 40; i++) {
|
|
139
|
+
many.push(N(`k${i}`, 172, 96));
|
|
140
|
+
e.push({source: "root", target: `k${i}`, rank: 0});
|
|
141
|
+
}
|
|
142
|
+
let pos = layout_radial(many, e, {nodesep: 10, ranksep: 100});
|
|
143
|
+
let r = Math.hypot(pos.get("k0").x, pos.get("k0").y);
|
|
144
|
+
/* 40 diagonals of ~197 plus gaps on the circumference */
|
|
145
|
+
expect(2 * Math.PI * r).toBeGreaterThanOrEqual(40 * (Math.hypot(172, 96) + 10) - 1e-6);
|
|
146
|
+
let d = Math.hypot(pos.get("k1").x - pos.get("k0").x, pos.get("k1").y - pos.get("k0").y);
|
|
147
|
+
expect(d).toBeGreaterThan(190);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("several roots share the circle around an empty centre", () => {
|
|
151
|
+
let pos = layout_radial(
|
|
152
|
+
[N("a", 100, 50), N("b", 100, 50), N("c", 100, 50)], [], {ranksep: 100}
|
|
153
|
+
);
|
|
154
|
+
for(let id of ["a", "b", "c"]) {
|
|
155
|
+
expect(Math.hypot(pos.get(id).x, pos.get(id).y)).toBeCloseTo(100, 6);
|
|
156
|
+
}
|
|
157
|
+
expect(pos.get("a").x).not.toBe(pos.get("b").x);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("the outline", () => {
|
|
162
|
+
test("one node per row, pre-order, indented by depth", () => {
|
|
163
|
+
let pos = layout_outline(nodes, edges, {nodesep: 10, indent: 40});
|
|
164
|
+
let rows = [...pos.entries()].sort((a, b) => a[1].y - b[1].y).map((e) => e[0]);
|
|
165
|
+
expect(rows).toEqual(["es", "norte", "nave", "d0", "d1", "c1", "sur"]);
|
|
166
|
+
expect(pos.get("es").x).toBe(86);
|
|
167
|
+
expect(pos.get("nave").x).toBe(2 * 40 + 86);
|
|
168
|
+
expect(pos.get("d0").x).toBe(3 * 40 + 58);
|
|
169
|
+
expect(pos.get("es").y).toBe(48);
|
|
170
|
+
expect(pos.get("norte").y).toBe(96 + 10 + 48);
|
|
171
|
+
});
|
|
172
|
+
});
|