@yuneta/gobj-ui 7.23.153 → 7.23.154
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 +12 -2
- package/dist/gobj-ui.cjs.js +274 -37
- package/dist/gobj-ui.es.js +274 -37
- package/package.json +1 -1
- package/src/c_g6_nodes_tree.js +21 -8
- package/src/treedb_elbow.js +179 -22
- package/src/treedb_elbow.test.js +86 -1
- package/src/treedb_layout.js +146 -14
- package/src/treedb_layout.test.js +84 -12
package/src/treedb_elbow.js
CHANGED
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
* farthest turns lowest -- the two orders in which the lines of
|
|
35
35
|
* one end nest without crossing.
|
|
36
36
|
*
|
|
37
|
+
* - A card in a lower row of a STACK (treedb_layout.js lays a
|
|
38
|
+
* run of leaf children out as two columns with a corridor
|
|
39
|
+
* between them): the card above it stands in the way of the
|
|
40
|
+
* channel, so its edge runs down the corridor in a lane of
|
|
41
|
+
* its own -- a COMB -- and turns into the gap above its card.
|
|
42
|
+
*
|
|
37
43
|
* Written for the top-down reading; left to right is the same
|
|
38
44
|
* geometry with the axes swapped.
|
|
39
45
|
*
|
|
@@ -41,6 +47,8 @@
|
|
|
41
47
|
* All Rights Reserved.
|
|
42
48
|
***********************************************************************/
|
|
43
49
|
|
|
50
|
+
import {STACK_GAP, STACK_LANE_PAD, STACK_LANE_STEP, STACK_TRUNK_MIN} from "./treedb_layout.js";
|
|
51
|
+
|
|
44
52
|
export const ELBOW_STEP = 10; /* between two lanes */
|
|
45
53
|
export const ELBOW_JETTY = 16; /* straight run out of a port before a detour turns */
|
|
46
54
|
export const ELBOW_CLEAR = 24; /* how far outside the two cards a detour runs */
|
|
@@ -92,6 +100,154 @@ function swap_box(b)
|
|
|
92
100
|
return {x1: b.y1, y1: b.x1, x2: b.y2, y2: b.x2};
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
/* Where a forward edge turns between `lo` and `hi`: the middle, or
|
|
104
|
+
* its staggered place among the edges sharing one of its ends --
|
|
105
|
+
* leaving, the farthest highest; arriving, the farthest lowest. */
|
|
106
|
+
function staggered_turn(lo, hi, st)
|
|
107
|
+
{
|
|
108
|
+
let mid = (lo + hi) / 2;
|
|
109
|
+
let group = null;
|
|
110
|
+
let k = 0;
|
|
111
|
+
if(st.dep && st.dep.count > 1) {
|
|
112
|
+
group = st.dep;
|
|
113
|
+
k = st.dep.rank;
|
|
114
|
+
} else if(st.arr && st.arr.count > 1) {
|
|
115
|
+
group = st.arr;
|
|
116
|
+
k = st.arr.count - 1 - st.arr.rank;
|
|
117
|
+
}
|
|
118
|
+
if(group) {
|
|
119
|
+
let room = Math.max(0, hi - lo - 2 * ELBOW_STAGGER_PAD);
|
|
120
|
+
let step = Math.min(ELBOW_STEP, room / (group.count - 1));
|
|
121
|
+
mid = mid - (group.count - 1) * step / 2 + k * step;
|
|
122
|
+
}
|
|
123
|
+
return mid;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/************************************************************
|
|
127
|
+
* The combs of the stacks under one card.
|
|
128
|
+
*
|
|
129
|
+
* treedb_layout.js lays a run of leaf children out as two columns
|
|
130
|
+
* with a corridor between them. A card in the first row is reached
|
|
131
|
+
* from the channel like any other; one in a lower row cannot be --
|
|
132
|
+
* the card above it stands in the way -- so its edge runs down
|
|
133
|
+
* the corridor in a LANE of its own and turns into the gap above
|
|
134
|
+
* its card, which it shares only with the other card of its row,
|
|
135
|
+
* reached from the other side.
|
|
136
|
+
*
|
|
137
|
+
* targets [{id, box}]: the forward edges leaving one card,
|
|
138
|
+
* each with the box of the card it reaches
|
|
139
|
+
* vertical as in elbow_points; false swaps the boxes
|
|
140
|
+
*
|
|
141
|
+
* Returns Map id -> {lane, z, top}: the lane, the height of the
|
|
142
|
+
* gap it turns into, and the top of the stack's first row (where
|
|
143
|
+
* the channel over the stack ends). Only for a card that stands
|
|
144
|
+
* under another of the group, within a stack gap and lined up
|
|
145
|
+
* with it on the corridor side -- what a stack looks like.
|
|
146
|
+
*
|
|
147
|
+
* Lanes go outermost first, row by row: a line into a higher row
|
|
148
|
+
* keeps outside the lines going deeper, and none crosses another.
|
|
149
|
+
************************************************************/
|
|
150
|
+
export function elbow_combs(targets, vertical)
|
|
151
|
+
{
|
|
152
|
+
let list = vertical? targets : targets.map((it) => ({id: it.id, box: swap_box(it.box)}));
|
|
153
|
+
let cy = (b) => (b.y1 + b.y2) / 2;
|
|
154
|
+
let near = (a, b) => Math.abs(a - b) < 1.5;
|
|
155
|
+
|
|
156
|
+
let under = [];
|
|
157
|
+
for(let it of list) {
|
|
158
|
+
let b = it.box;
|
|
159
|
+
let above = null;
|
|
160
|
+
for(let o of list) {
|
|
161
|
+
let a = o.box;
|
|
162
|
+
if(o === it || a.y2 > b.y1 || b.y1 - a.y2 > STACK_GAP) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if(!(near(a.x2, b.x2) || near(a.x1, b.x1))) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if(!above || a.y2 > above.y2) {
|
|
169
|
+
above = a;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if(!above) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
/* Its column: lined up on the corridor side, which is the
|
|
176
|
+
* right edge of the first column and the left edge of the
|
|
177
|
+
* second. Two cards of one width line up on both; then the
|
|
178
|
+
* card beside it in its row says. */
|
|
179
|
+
let side;
|
|
180
|
+
if(near(above.x2, b.x2) && !near(above.x1, b.x1)) {
|
|
181
|
+
side = 0;
|
|
182
|
+
} else if(near(above.x1, b.x1) && !near(above.x2, b.x2)) {
|
|
183
|
+
side = 1;
|
|
184
|
+
} else {
|
|
185
|
+
let left_of_it = list.some((o) => o !== it && near(cy(o.box), cy(b)) &&
|
|
186
|
+
o.box.x2 <= b.x1);
|
|
187
|
+
side = left_of_it? 1 : 0;
|
|
188
|
+
}
|
|
189
|
+
under.push({id: it.id, box: b, above: above, side: side});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* The corridor of a card: from the first column's edge to the
|
|
193
|
+
* second's, read off any row of the stack that has both. */
|
|
194
|
+
let corridor = (u) => {
|
|
195
|
+
if(u.side === 0) {
|
|
196
|
+
let l = u.box.x2;
|
|
197
|
+
let col = list.filter((o) => near(o.box.x2, l));
|
|
198
|
+
let r = Infinity;
|
|
199
|
+
for(let c of col) {
|
|
200
|
+
for(let o of list) {
|
|
201
|
+
if(o !== c && near(cy(o.box), cy(c.box)) && o.box.x1 >= l - 0.5) {
|
|
202
|
+
r = Math.min(r, o.box.x1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {l: l, r: isFinite(r)? r : l + STACK_TRUNK_MIN, col: col};
|
|
207
|
+
}
|
|
208
|
+
let r = u.box.x1;
|
|
209
|
+
let col = list.filter((o) => near(o.box.x1, r));
|
|
210
|
+
let l = -Infinity;
|
|
211
|
+
for(let c of col) {
|
|
212
|
+
for(let o of list) {
|
|
213
|
+
if(o !== c && near(cy(o.box), cy(c.box)) && o.box.x2 <= r + 0.5) {
|
|
214
|
+
l = Math.max(l, o.box.x2);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return {l: isFinite(l)? l : r - STACK_TRUNK_MIN, r: r, col: col};
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
let groups = new Map();
|
|
222
|
+
for(let u of under) {
|
|
223
|
+
let c = corridor(u);
|
|
224
|
+
u.top = Math.min(...c.col.map((o) => o.box.y1));
|
|
225
|
+
let key = `${Math.round(c.l)}|${Math.round(c.r)}`;
|
|
226
|
+
if(!groups.has(key)) {
|
|
227
|
+
groups.set(key, {l: c.l, r: c.r, left: [], right: []});
|
|
228
|
+
}
|
|
229
|
+
groups.get(key)[(u.side === 0)? 'left' : 'right'].push(u);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let out = new Map();
|
|
233
|
+
for(let g of groups.values()) {
|
|
234
|
+
g.left.sort((a, b) => a.box.y1 - b.box.y1);
|
|
235
|
+
g.right.sort((a, b) => a.box.y1 - b.box.y1);
|
|
236
|
+
let n = g.left.length + g.right.length;
|
|
237
|
+
let room = g.r - g.l - 2 * STACK_LANE_PAD;
|
|
238
|
+
let step = (n > 1)? Math.max(0, Math.min(STACK_LANE_STEP, room / (n - 1))) : 0;
|
|
239
|
+
g.left.forEach((u, i) => {
|
|
240
|
+
out.set(u.id, {lane: g.l + STACK_LANE_PAD + i * step,
|
|
241
|
+
z: (u.above.y2 + u.box.y1) / 2, top: u.top});
|
|
242
|
+
});
|
|
243
|
+
g.right.forEach((u, j) => {
|
|
244
|
+
out.set(u.id, {lane: g.r - STACK_LANE_PAD - j * step,
|
|
245
|
+
z: (u.above.y2 + u.box.y1) / 2, top: u.top});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
95
251
|
/************************************************************
|
|
96
252
|
* The control points of an elbow from the port `s` to the port
|
|
97
253
|
* `t` ([x, y] each), knowing the boxes of the two cards
|
|
@@ -99,10 +255,12 @@ function swap_box(b)
|
|
|
99
255
|
*
|
|
100
256
|
* vertical the rows are stacked (top to bottom); false
|
|
101
257
|
* when they are side by side (left to right)
|
|
102
|
-
* stagger {dep, arr}: the edge's rank among the
|
|
103
|
-
* edges leaving its source card and among
|
|
104
|
-
* reaching its target card (elbow_rank)
|
|
105
|
-
* the
|
|
258
|
+
* stagger {dep, arr, comb}: the edge's rank among the
|
|
259
|
+
* forward edges leaving its source card and among
|
|
260
|
+
* those reaching its target card (elbow_rank), and
|
|
261
|
+
* its comb when the target is in a lower row of a
|
|
262
|
+
* stack (elbow_combs). Absent, the channel is the
|
|
263
|
+
* middle.
|
|
106
264
|
************************************************************/
|
|
107
265
|
export function elbow_points(s, t, box_s, box_t, lane, vertical, stagger)
|
|
108
266
|
{
|
|
@@ -115,27 +273,20 @@ export function elbow_points(s, t, box_s, box_t, lane, vertical, stagger)
|
|
|
115
273
|
|
|
116
274
|
/* Forward: the channel half way between the two cards, each
|
|
117
275
|
* edge of a shared end at its own height round it, moved by the
|
|
118
|
-
* lane, and kept between the two rows.
|
|
276
|
+
* lane, and kept between the two rows. Into a lower row of a
|
|
277
|
+
* stack: along the channel to its corridor lane, down the lane,
|
|
278
|
+
* and into the gap above its card. */
|
|
119
279
|
if(t[1] - s[1] > 2 * ELBOW_STEP) {
|
|
120
|
-
let lo = Math.max(s[1], box_s.y2);
|
|
121
|
-
let hi = Math.min(t[1], box_t.y1);
|
|
122
|
-
let mid = (lo + hi) / 2;
|
|
123
280
|
let st = stagger || {};
|
|
124
|
-
let
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
group = st.arr;
|
|
131
|
-
k = st.arr.count - 1 - st.arr.rank; /* farthest lowest */
|
|
132
|
-
}
|
|
133
|
-
if(group) {
|
|
134
|
-
let room = Math.max(0, hi - lo - 2 * ELBOW_STAGGER_PAD);
|
|
135
|
-
let step = Math.min(ELBOW_STEP, room / (group.count - 1));
|
|
136
|
-
mid = mid - (group.count - 1) * step / 2 + k * step;
|
|
281
|
+
let lo = Math.max(s[1], box_s.y2);
|
|
282
|
+
if(st.comb) {
|
|
283
|
+
let c = st.comb;
|
|
284
|
+
let turn = staggered_turn(lo, c.top, {dep: st.dep}) + shift;
|
|
285
|
+
turn = Math.min(Math.max(turn, lo + 4), c.top - 4);
|
|
286
|
+
return [[s[0], turn], [c.lane, turn], [c.lane, c.z], [t[0], c.z]];
|
|
137
287
|
}
|
|
138
|
-
|
|
288
|
+
let hi = Math.min(t[1], box_t.y1);
|
|
289
|
+
let mid = staggered_turn(lo, hi, st) + shift;
|
|
139
290
|
mid = Math.min(Math.max(mid, lo + 4), hi - 4);
|
|
140
291
|
return [[s[0], mid], [t[0], mid]];
|
|
141
292
|
}
|
|
@@ -429,6 +580,12 @@ export function elbow_route(s, t, box_s, box_t, lane, vertical, boxes, stagger)
|
|
|
429
580
|
others.map(swap_box), stagger).map(swap);
|
|
430
581
|
}
|
|
431
582
|
|
|
583
|
+
/* A comb runs where the layout left room for it: its corridor
|
|
584
|
+
* and its gap are empty by construction. */
|
|
585
|
+
if(stagger && stagger.comb) {
|
|
586
|
+
return elbow_points(s, t, box_s, box_t, lane, true, stagger);
|
|
587
|
+
}
|
|
588
|
+
|
|
432
589
|
let simple = elbow_points(s, t, box_s, box_t, lane, true, stagger);
|
|
433
590
|
if(!elbow_path_hits([s, ...simple, t], others, box_s, box_t)) {
|
|
434
591
|
return simple;
|
package/src/treedb_elbow.test.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
***********************************************************************/
|
|
8
8
|
import { describe, test, expect } from "vitest";
|
|
9
9
|
import {
|
|
10
|
-
elbow_lane, elbow_points, elbow_route, elbow_path_hits, elbow_rank,
|
|
10
|
+
elbow_lane, elbow_points, elbow_route, elbow_path_hits, elbow_rank, elbow_combs,
|
|
11
|
+
ELBOW_STEP, ELBOW_CLEAR,
|
|
11
12
|
} from "./treedb_elbow.js";
|
|
13
|
+
import { layout_compact } from "./treedb_layout.js";
|
|
12
14
|
|
|
13
15
|
/* Two cards 100x40: A on top, B one row below it. */
|
|
14
16
|
const A = {x1: 0, y1: 0, x2: 100, y2: 40};
|
|
@@ -271,6 +273,89 @@ describe("edges sharing an end are staggered", () => {
|
|
|
271
273
|
});
|
|
272
274
|
});
|
|
273
275
|
|
|
276
|
+
describe("the comb of a stack", () => {
|
|
277
|
+
/* A parent over eight leaves, laid out by the compact tree (a
|
|
278
|
+
* stack: four rows of two). Boxes as the graph measures them,
|
|
279
|
+
* with 14px ports sticking 7px out of the top and bottom. */
|
|
280
|
+
const P = {id: "p", w: 172, h: 96};
|
|
281
|
+
const LEAVES = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({id: `l${i}`, w: 116, h: 40}));
|
|
282
|
+
const pos = layout_compact([P, ...LEAVES], LEAVES.map((l) => ({source: "p", target: l.id})),
|
|
283
|
+
{nodesep: 18, ranksep: 90});
|
|
284
|
+
const box = (n) => {
|
|
285
|
+
let c = pos.get(n.id);
|
|
286
|
+
return {x1: c.x - n.w / 2, y1: c.y - n.h / 2 - 7, x2: c.x + n.w / 2, y2: c.y + n.h / 2 + 7};
|
|
287
|
+
};
|
|
288
|
+
const BOX_P = box(P);
|
|
289
|
+
const PORT = [pos.get("p").x, pos.get("p").y + 48];
|
|
290
|
+
const targets = LEAVES.map((l, i) => ({id: `e-${i + 1}`, box: box(l), leaf: l}));
|
|
291
|
+
const combs = elbow_combs(targets.map((t) => ({id: t.id, box: t.box})), true);
|
|
292
|
+
const paths = targets.map((t) => {
|
|
293
|
+
let comb = combs.get(t.id) || null;
|
|
294
|
+
let items = targets.map((u) => {
|
|
295
|
+
let c = combs.get(u.id);
|
|
296
|
+
let far = c? c.lane : pos.get(u.leaf.id).x;
|
|
297
|
+
return {id: u.id, span: Math.abs(far - PORT[0])};
|
|
298
|
+
});
|
|
299
|
+
let end = [pos.get(t.leaf.id).x, t.box.y1 + 7];
|
|
300
|
+
let pts = elbow_points(PORT, end, BOX_P, t.box, 0, true,
|
|
301
|
+
{dep: elbow_rank(items, t.id), comb: comb});
|
|
302
|
+
return {id: t.id, box: t.box, full: [PORT, ...pts, end]};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("only the cards under another card get a comb", () => {
|
|
306
|
+
expect(combs.size).toBe(6);
|
|
307
|
+
expect(combs.has("e-1")).toBe(false);
|
|
308
|
+
expect(combs.has("e-2")).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("no line crosses a card", () => {
|
|
312
|
+
let all = [BOX_P, ...targets.map((t) => t.box)];
|
|
313
|
+
for(let p of paths) {
|
|
314
|
+
expect(elbow_path_hits(p.full, all, BOX_P, p.box)).toBe(false);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("no two lines lie on each other past the stub of the port", () => {
|
|
319
|
+
/* Two axis-aligned segments overlap when they are on one line
|
|
320
|
+
* and share more than a point of it. */
|
|
321
|
+
let overlap = (a, b, c, d) => {
|
|
322
|
+
let va = a[0] === b[0];
|
|
323
|
+
let vc = c[0] === d[0];
|
|
324
|
+
let ha = a[1] === b[1];
|
|
325
|
+
let hc = c[1] === d[1];
|
|
326
|
+
if(va && vc && Math.abs(a[0] - c[0]) < 0.5) {
|
|
327
|
+
return Math.min(Math.max(a[1], b[1]), Math.max(c[1], d[1])) -
|
|
328
|
+
Math.max(Math.min(a[1], b[1]), Math.min(c[1], d[1])) > 0.5;
|
|
329
|
+
}
|
|
330
|
+
if(ha && hc && Math.abs(a[1] - c[1]) < 0.5) {
|
|
331
|
+
return Math.min(Math.max(a[0], b[0]), Math.max(c[0], d[0])) -
|
|
332
|
+
Math.max(Math.min(a[0], b[0]), Math.min(c[0], d[0])) > 0.5;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
};
|
|
336
|
+
for(let i = 0; i < paths.length; i++) {
|
|
337
|
+
for(let j = i + 1; j < paths.length; j++) {
|
|
338
|
+
let A = paths[i].full;
|
|
339
|
+
let B = paths[j].full;
|
|
340
|
+
for(let m = 1; m + 1 < A.length; m++) {
|
|
341
|
+
for(let n = 1; n + 1 < B.length; n++) {
|
|
342
|
+
expect(overlap(A[m], A[m + 1], B[n], B[n + 1])).toBe(false);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("a comb is drawn as laid out, not routed", () => {
|
|
350
|
+
let t = targets[6];
|
|
351
|
+
let c = combs.get(t.id);
|
|
352
|
+
let end = [pos.get(t.leaf.id).x, t.box.y1 + 7];
|
|
353
|
+
let st = {comb: c};
|
|
354
|
+
expect(elbow_route(PORT, end, BOX_P, t.box, 0, true, [BOX_P, ...targets.map((u) => u.box)], st))
|
|
355
|
+
.toEqual(elbow_points(PORT, end, BOX_P, t.box, 0, true, st));
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
274
359
|
describe("the ports are obstacles too", () => {
|
|
275
360
|
/* Whole-node boxes: a port of 14px sticks 7px out of the bottom
|
|
276
361
|
* and the top of each card. */
|
package/src/treedb_layout.js
CHANGED
|
@@ -291,6 +291,14 @@ function tidy_tree_lr(nodes, edges, o)
|
|
|
291
291
|
* the packing across the depths differs, so switching between the
|
|
292
292
|
* two moves cards sideways and never reorders them.
|
|
293
293
|
*
|
|
294
|
+
* And a run of three or more LEAF children -- a hall's devices, a
|
|
295
|
+
* region's places when none is open -- is STACKED: two columns
|
|
296
|
+
* under the parent with a corridor between them, row after row.
|
|
297
|
+
* That is what makes it compact when a whole level is open, where
|
|
298
|
+
* the contour alone gains nothing: every parent needed the whole
|
|
299
|
+
* row of its children beside it. The tree grows taller instead of
|
|
300
|
+
* wider. Its elbow edges run down the corridor (elbow_combs).
|
|
301
|
+
*
|
|
294
302
|
* The contour arithmetic is Moen's ("Drawing dynamic trees", IEEE
|
|
295
303
|
* Software, 1990) in the form mxGraph gave it in mxCompactTreeLayout
|
|
296
304
|
* -- Copyright (c) 2006-2015 JGraph Ltd, Apache License 2.0 -- read in
|
|
@@ -397,12 +405,125 @@ function contour_merge(p1, p2)
|
|
|
397
405
|
return total;
|
|
398
406
|
}
|
|
399
407
|
|
|
408
|
+
/* How the compact tree STACKS a run of leaf children: two columns
|
|
409
|
+
* under their parent, a CORRIDOR between them that the elbow edges
|
|
410
|
+
* run down (treedb_elbow.js, elbow_combs), a gap between the rows
|
|
411
|
+
* that they turn into. The corridor is sized here from how many
|
|
412
|
+
* lines will run down it; the edges count the lanes from where the
|
|
413
|
+
* cards stand. */
|
|
414
|
+
export const STACK_MIN_RUN = 3; /* shorter runs stay in the row */
|
|
415
|
+
export const STACK_GAP = 40; /* between two rows of a stack */
|
|
416
|
+
export const STACK_LANE_PAD = 10; /* from a column to its first lane */
|
|
417
|
+
export const STACK_LANE_STEP = 6; /* between two lanes */
|
|
418
|
+
export const STACK_TRUNK_MIN = 36;
|
|
419
|
+
|
|
420
|
+
/* The corridor of a stack of `k` cards: every card below the first
|
|
421
|
+
* row gets a lane. */
|
|
422
|
+
export function stack_trunk_width(k)
|
|
423
|
+
{
|
|
424
|
+
let lanes = Math.max(0, k - 2);
|
|
425
|
+
return Math.max(STACK_TRUNK_MIN,
|
|
426
|
+
2 * STACK_LANE_PAD + Math.max(0, lanes - 1) * STACK_LANE_STEP);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* A stack in the LR frame (depth along x, siblings along y): `side`
|
|
430
|
+
* is how far each column reaches across, `starts`/`row_len` where
|
|
431
|
+
* each row begins and how long it is along the depth. Its first row
|
|
432
|
+
* IS the depth's column, so it lines up with every other card there. */
|
|
433
|
+
function stack_geometry(t, st, col_x, col_w)
|
|
434
|
+
{
|
|
435
|
+
let d = st.depth;
|
|
436
|
+
let k = st.ids.length;
|
|
437
|
+
let rows = Math.ceil(k / 2);
|
|
438
|
+
let side = [0, 0];
|
|
439
|
+
let row_len = [];
|
|
440
|
+
for(let i = 0; i < k; i++) {
|
|
441
|
+
let n = t.by_id.get(st.ids[i]);
|
|
442
|
+
let r = Math.floor(i / 2);
|
|
443
|
+
side[i % 2] = Math.max(side[i % 2], n.h);
|
|
444
|
+
row_len[r] = Math.max(row_len[r] || 0, n.w);
|
|
445
|
+
}
|
|
446
|
+
row_len[0] = col_w[d];
|
|
447
|
+
let trunk = stack_trunk_width(k);
|
|
448
|
+
let starts = [col_x[d]];
|
|
449
|
+
for(let r = 1; r < rows; r++) {
|
|
450
|
+
starts[r] = starts[r - 1] + row_len[r - 1] + STACK_GAP;
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
side: side,
|
|
454
|
+
trunk: trunk,
|
|
455
|
+
starts: starts,
|
|
456
|
+
row_len: row_len,
|
|
457
|
+
len: starts[rows - 1] + row_len[rows - 1] - col_x[d],
|
|
458
|
+
ext: side[0] + trunk + side[1],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* The cards of a stack whose block starts across at `top`: row by
|
|
463
|
+
* row, the first of each row against the corridor from one side,
|
|
464
|
+
* the second against it from the other. */
|
|
465
|
+
function place_stack(t, st, g, top, pos)
|
|
466
|
+
{
|
|
467
|
+
for(let i = 0; i < st.ids.length; i++) {
|
|
468
|
+
let n = t.by_id.get(st.ids[i]);
|
|
469
|
+
let r = Math.floor(i / 2);
|
|
470
|
+
let across = (i % 2 === 0)
|
|
471
|
+
? top + g.side[0] - n.h / 2
|
|
472
|
+
: top + g.side[0] + g.trunk + n.h / 2;
|
|
473
|
+
pos.set(st.ids[i], {x: g.starts[r] + g.row_len[r] / 2, y: across});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
400
477
|
function compact_tree_lr(nodes, edges, o)
|
|
401
478
|
{
|
|
402
479
|
let t = spanning_tree(nodes, edges);
|
|
403
|
-
let order = walk_order(t);
|
|
404
480
|
let pos = new Map();
|
|
405
481
|
|
|
482
|
+
/* A run of STACK_MIN_RUN or more consecutive LEAF children is laid
|
|
483
|
+
* out as one block, a STACK. The contour packing below takes the
|
|
484
|
+
* block as one leaf of its own length, so nothing is packed under
|
|
485
|
+
* it. Runs, not all the leaves of a parent: the children keep
|
|
486
|
+
* their order. A stack's key is an OBJECT, so it can never be
|
|
487
|
+
* taken for a node id, which is a string. */
|
|
488
|
+
let kids_of = new Map();
|
|
489
|
+
let stacks = new Map();
|
|
490
|
+
for(let [id, kids] of t.children) {
|
|
491
|
+
let out = [];
|
|
492
|
+
let run = [];
|
|
493
|
+
let flush = () => {
|
|
494
|
+
if(run.length >= STACK_MIN_RUN) {
|
|
495
|
+
let key = {stack: stacks.size};
|
|
496
|
+
stacks.set(key, {ids: run, depth: t.depth.get(run[0])});
|
|
497
|
+
out.push(key);
|
|
498
|
+
} else {
|
|
499
|
+
out.push(...run);
|
|
500
|
+
}
|
|
501
|
+
run = [];
|
|
502
|
+
};
|
|
503
|
+
for(let kid of kids) {
|
|
504
|
+
if((t.children.get(kid) || []).length === 0) {
|
|
505
|
+
run.push(kid);
|
|
506
|
+
} else {
|
|
507
|
+
flush();
|
|
508
|
+
out.push(kid);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
flush();
|
|
512
|
+
kids_of.set(id, out);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/* Parents before children, a stack being one child. */
|
|
516
|
+
let order = [];
|
|
517
|
+
let todo = t.roots.slice().reverse();
|
|
518
|
+
while(todo.length) {
|
|
519
|
+
let id = todo.pop();
|
|
520
|
+
order.push(id);
|
|
521
|
+
let kids = kids_of.get(id) || [];
|
|
522
|
+
for(let i = kids.length - 1; i >= 0; i--) {
|
|
523
|
+
todo.push(kids[i]);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
406
527
|
/* Columns, as in the tidy tree: a depth is as wide as its widest
|
|
407
528
|
* card, and a column's gap is `ranksep`. */
|
|
408
529
|
let col_w = [];
|
|
@@ -423,25 +544,31 @@ function compact_tree_lr(nodes, edges, o)
|
|
|
423
544
|
let dist = o.nodesep;
|
|
424
545
|
let level_distance = o.ranksep - pad;
|
|
425
546
|
|
|
426
|
-
/* `len` along the depth (the column's width: the rows are aligned
|
|
427
|
-
* `ext` across it
|
|
547
|
+
/* `len` along the depth (the column's width: the rows are aligned;
|
|
548
|
+
* a stack's own length), `ext` across it. */
|
|
428
549
|
let tn = new Map();
|
|
429
550
|
for(let id of order) {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
off_x: 0,
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
551
|
+
let st = stacks.get(id);
|
|
552
|
+
if(st) {
|
|
553
|
+
let g = stack_geometry(t, st, col_x, col_w);
|
|
554
|
+
tn.set(id, {len: g.len, ext: g.ext, geom: g, off_x: 0, off_y: 0, y: 0, contour: null});
|
|
555
|
+
} else {
|
|
556
|
+
tn.set(id, {
|
|
557
|
+
len: col_w[t.depth.get(id)],
|
|
558
|
+
ext: t.by_id.get(id).h,
|
|
559
|
+
off_x: 0,
|
|
560
|
+
off_y: 0,
|
|
561
|
+
y: 0,
|
|
562
|
+
contour: null,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
438
565
|
}
|
|
439
566
|
|
|
440
567
|
/* Contours, children before parents. */
|
|
441
568
|
for(let i = order.length - 1; i >= 0; i--) {
|
|
442
569
|
let id = order[i];
|
|
443
570
|
let n = tn.get(id);
|
|
444
|
-
let kids =
|
|
571
|
+
let kids = kids_of.get(id) || [];
|
|
445
572
|
|
|
446
573
|
if(!kids.length) {
|
|
447
574
|
let upper = contour_line(n.len + dist, 0);
|
|
@@ -492,7 +619,7 @@ function compact_tree_lr(nodes, edges, o)
|
|
|
492
619
|
if(!root_of.has(id)) {
|
|
493
620
|
root_of.set(id, id);
|
|
494
621
|
}
|
|
495
|
-
let kids =
|
|
622
|
+
let kids = kids_of.get(id) || [];
|
|
496
623
|
if(!kids.length) {
|
|
497
624
|
continue;
|
|
498
625
|
}
|
|
@@ -524,9 +651,14 @@ function compact_tree_lr(nodes, edges, o)
|
|
|
524
651
|
}
|
|
525
652
|
|
|
526
653
|
for(let id of order) {
|
|
527
|
-
let d = t.depth.get(id);
|
|
528
654
|
let n = tn.get(id);
|
|
529
655
|
let y = n.y + shift.get(root_of.get(id));
|
|
656
|
+
let st = stacks.get(id);
|
|
657
|
+
if(st) {
|
|
658
|
+
place_stack(t, st, n.geom, y, pos);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
let d = t.depth.get(id);
|
|
530
662
|
pos.set(id, {x: col_x[d] + col_w[d] / 2, y: y + n.ext / 2});
|
|
531
663
|
}
|
|
532
664
|
return pos;
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
* tidy tree's rows and centring, the radial tree's rings.
|
|
7
7
|
***********************************************************************/
|
|
8
8
|
import { describe, test, expect } from "vitest";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
spanning_tree, layout_tree, layout_radial, layout_compact, stack_trunk_width, STACK_GAP,
|
|
11
|
+
} from "./treedb_layout.js";
|
|
10
12
|
|
|
11
13
|
/* es -> (norte, sur); norte -> nave; nave -> d0, d1 (hook rank 1) and
|
|
12
14
|
* c1 (hook rank 2); c1 -> d0 as well (second parent). */
|
|
@@ -134,12 +136,15 @@ const wide_edges = [
|
|
|
134
136
|
].concat([0, 1, 2, 3, 4, 5].map((i) => ({source: "hall", target: `d${i}`})));
|
|
135
137
|
|
|
136
138
|
describe("the compact tree", () => {
|
|
137
|
-
test("the rows are the tidy tree's rows", () => {
|
|
139
|
+
test("the rows are the tidy tree's rows; a stack starts on its row", () => {
|
|
138
140
|
let tidy = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
139
141
|
let pos = layout_compact(nodes, edges, {nodesep: 10, ranksep: 100});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
+
/* nave's three leaves (d0, d1, c1) are a stack: d0 and d1 are
|
|
143
|
+
* its first row, on the tidy row; c1 opens the next one. */
|
|
144
|
+
for(let id of ["es", "norte", "sur", "nave", "d0", "d1"]) {
|
|
145
|
+
expect(pos.get(id).y).toBe(tidy.get(id).y);
|
|
142
146
|
}
|
|
147
|
+
expect(pos.get("c1").y).toBeGreaterThan(pos.get("d0").y);
|
|
143
148
|
});
|
|
144
149
|
|
|
145
150
|
test("no two cards of a row are closer than the gap", () => {
|
|
@@ -147,14 +152,13 @@ describe("the compact tree", () => {
|
|
|
147
152
|
expect_no_overlap_in_rows(wide, layout_compact(wide, wide_edges, {nodesep: 18, ranksep: 90}), 18);
|
|
148
153
|
});
|
|
149
154
|
|
|
150
|
-
test("
|
|
151
|
-
let
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
expect(pos.get("
|
|
157
|
-
expect(pos.get("norte").x).toBeLessThan(pos.get("sur").x);
|
|
155
|
+
test("two leaves stay side by side, and the parent is centred over them", () => {
|
|
156
|
+
let two = [N("p", 172, 96), N("a", 116, 40), N("b", 116, 40)];
|
|
157
|
+
let pos = layout_compact(two, [{source: "p", target: "a"}, {source: "p", target: "b"}],
|
|
158
|
+
{nodesep: 10, ranksep: 100});
|
|
159
|
+
expect(pos.get("a").y).toBe(pos.get("b").y);
|
|
160
|
+
expect(pos.get("b").x - pos.get("a").x).toBe(116 + 10);
|
|
161
|
+
expect(pos.get("p").x).toBeCloseTo((pos.get("a").x + pos.get("b").x) / 2, 6);
|
|
158
162
|
});
|
|
159
163
|
|
|
160
164
|
test("a closed sibling sits beside an open branch, not beside its block", () => {
|
|
@@ -198,6 +202,74 @@ describe("the compact tree", () => {
|
|
|
198
202
|
});
|
|
199
203
|
});
|
|
200
204
|
|
|
205
|
+
describe("the compact tree stacks a run of leaves", () => {
|
|
206
|
+
const P6 = N("p", 172, 96);
|
|
207
|
+
const L6 = [0, 1, 2, 3, 4, 5].map((i) => N(`l${i}`, 116, 40));
|
|
208
|
+
const E6 = L6.map((l) => ({source: "p", target: l.id}));
|
|
209
|
+
const OPTS = {nodesep: 18, ranksep: 90};
|
|
210
|
+
|
|
211
|
+
test("in two columns, row after row, against a corridor", () => {
|
|
212
|
+
let pos = layout_compact([P6, ...L6], E6, OPTS);
|
|
213
|
+
let y = (id) => pos.get(id).y;
|
|
214
|
+
let x = (id) => pos.get(id).x;
|
|
215
|
+
expect(y("l0")).toBe(y("l1"));
|
|
216
|
+
expect(y("l2")).toBe(y("l3"));
|
|
217
|
+
expect(y("l2") - y("l0")).toBe(40 + STACK_GAP);
|
|
218
|
+
expect(y("l4") - y("l2")).toBe(40 + STACK_GAP);
|
|
219
|
+
/* The first column lines up on its right edge, the second on
|
|
220
|
+
* its left, and between them the corridor. */
|
|
221
|
+
expect(x("l0")).toBe(x("l2"));
|
|
222
|
+
expect(x("l1")).toBe(x("l5"));
|
|
223
|
+
expect((x("l1") - 58) - (x("l0") + 58)).toBe(stack_trunk_width(6));
|
|
224
|
+
/* The parent over the block. */
|
|
225
|
+
expect(x("p")).toBeCloseTo(((x("l0") - 58) + (x("l1") + 58)) / 2, 6);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("no two cards overlap", () => {
|
|
229
|
+
let all = [P6, ...L6];
|
|
230
|
+
let pos = layout_compact(all, E6, OPTS);
|
|
231
|
+
for(let i = 0; i < all.length; i++) {
|
|
232
|
+
for(let j = i + 1; j < all.length; j++) {
|
|
233
|
+
let a = all[i];
|
|
234
|
+
let b = all[j];
|
|
235
|
+
let pa = pos.get(a.id);
|
|
236
|
+
let pb = pos.get(b.id);
|
|
237
|
+
let apart_x = Math.abs(pa.x - pb.x) >= (a.w + b.w) / 2;
|
|
238
|
+
let apart_y = Math.abs(pa.y - pb.y) >= (a.h + b.h) / 2;
|
|
239
|
+
expect(apart_x || apart_y).toBe(true);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a whole level of leaves takes a fraction of the row it took", () => {
|
|
245
|
+
let tidy = layout_tree([P6, ...L6], E6, OPTS);
|
|
246
|
+
let pos = layout_compact([P6, ...L6], E6, OPTS);
|
|
247
|
+
let span = (p) => {
|
|
248
|
+
let xs = L6.map((l) => p.get(l.id).x);
|
|
249
|
+
return Math.max(...xs) - Math.min(...xs) + 116;
|
|
250
|
+
};
|
|
251
|
+
expect(span(pos)).toBeLessThan(span(tidy) / 2);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a run is broken by a child with children, and the order is kept", () => {
|
|
255
|
+
/* p -> a, b, c (leaves), m (has a child), d, e (leaves). */
|
|
256
|
+
let list = [N("p", 172, 96), N("a", 116, 40), N("b", 116, 40), N("c", 116, 40),
|
|
257
|
+
N("m", 172, 96), N("d", 116, 40), N("e", 116, 40), N("mk", 116, 40)];
|
|
258
|
+
let links = ["a", "b", "c", "m", "d", "e"].map((k) => ({source: "p", target: k}))
|
|
259
|
+
.concat([{source: "m", target: "mk"}]);
|
|
260
|
+
let pos = layout_compact(list, links, OPTS);
|
|
261
|
+
/* a, b, c: a stack (c under a); d, e: a pair on the row. */
|
|
262
|
+
expect(pos.get("c").y).toBeGreaterThan(pos.get("a").y);
|
|
263
|
+
expect(pos.get("c").x).toBe(pos.get("a").x);
|
|
264
|
+
expect(pos.get("d").y).toBe(pos.get("a").y);
|
|
265
|
+
expect(pos.get("e").y).toBe(pos.get("a").y);
|
|
266
|
+
/* Left to right: the stack, m, then d and e. */
|
|
267
|
+
expect(pos.get("b").x).toBeLessThan(pos.get("m").x);
|
|
268
|
+
expect(pos.get("m").x).toBeLessThan(pos.get("d").x);
|
|
269
|
+
expect(pos.get("d").x).toBeLessThan(pos.get("e").x);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
201
273
|
describe("the tidy tree, read right", () => {
|
|
202
274
|
test("depths are columns; a column is as wide as its widest card", () => {
|
|
203
275
|
let pos = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100, direction: "LR"});
|