@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.
@@ -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
+ });