@yuneta/gobj-ui 7.23.147 → 7.23.149

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.
@@ -132,7 +132,7 @@ import {
132
132
  fold_split_group_key,
133
133
  fold_root_group_key,
134
134
  } from "./treedb_fold_model.js";
135
- import {layout_tree, layout_radial} from "./treedb_layout.js";
135
+ import {layout_tree, layout_radial, layout_compact} from "./treedb_layout.js";
136
136
 
137
137
  import {
138
138
  BaseLayout,
@@ -146,6 +146,7 @@ import {
146
146
  HTML,
147
147
  Toolbar,
148
148
  register,
149
+ Polyline,
149
150
  } from '@antv/g6';
150
151
 
151
152
  import {Circle as CircleGeometry, Rect as RectGeometry} from '@antv/g';
@@ -350,6 +351,16 @@ const _layouts = {
350
351
  nodesep: 18,
351
352
  ranksep: 90,
352
353
  },
354
+ /* The same tree packed by CONTOUR (Moen, as mxGraph drew it): a
355
+ * closed branch sits beside an open one at the depth they share,
356
+ * instead of beside the whole block of the open one. Same rows
357
+ * and order as `treedb-tree` (treedb_layout.js). */
358
+ "compact-tree": {
359
+ type: 'treedb-compact',
360
+ direction: 'TB',
361
+ nodesep: 18,
362
+ ranksep: 90,
363
+ },
353
364
  "dagre": {
354
365
  type: 'dagre',
355
366
  rankdir: 'LR',
@@ -446,6 +457,8 @@ SDATA(data_type_t.DTP_LIST, "loose_topics", 0, "[]", "Topics whos
446
457
 
447
458
  /*---------------- Node shape ----------------*/
448
459
  SDATA(data_type_t.DTP_STRING, "node_mode", 0, "expanded", "How a record is drawn: `expanded` = the card with its pills and ports; `compact` = a one-line pill with the name and small ports; `shape` = a figure of the topic's colour (square, circle, diamond... per node or topic, from the node properties), no ports, no text -- the topology alone. One node can be toggled against it (EV_TOGGLE_NODE_MODE)"),
460
+ SDATA(data_type_t.DTP_STRING, "edge_shape", 0, "curved", "How an edge is drawn: `curved` (the default) or `elbow` -- straight out of its port to the channel between the two rows, along it, and straight into the other port: the bus of an org chart, as mxGraph drew its trees. Layouts with no rows (`rowless_layouts`) keep the curve"),
461
+ SDATA(data_type_t.DTP_LIST, "rowless_layouts", sdata_flag_t.SDF_RD, JSON.stringify(["radial", "d3-force", "force-atlas2"]), "Layouts with no rows for an elbow to run between: their edges stay curved whatever `edge_shape` says (read-only, for the parent to query)"),
449
462
  SDATA(data_type_t.DTP_BOOLEAN, "node_labels", 0, true, "A `shape` node says its name under the figure. Off, it is a coloured figure and nothing else"),
450
463
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
451
464
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
@@ -925,6 +938,9 @@ function register_layouts(gobj)
925
938
  _g6_extensions_registered = true;
926
939
  register(ExtensionCategory.LAYOUT, 'manual', ManualLayout);
927
940
  register(ExtensionCategory.LAYOUT, 'treedb-tree', TreedbTreeLayout);
941
+ register(ExtensionCategory.LAYOUT, 'treedb-compact', TreedbCompactLayout);
942
+ register(ExtensionCategory.EDGE, 'treedb-elbow-v', TreedbElbowV);
943
+ register(ExtensionCategory.EDGE, 'treedb-elbow-h', TreedbElbowH);
928
944
  register(ExtensionCategory.LAYOUT, 'treedb-radial', TreedbRadialLayout);
929
945
  register(ExtensionCategory.NODE, 'light', LightNode);
930
946
  register(ExtensionCategory.NODE, 'treedb-card', TreedbCard);
@@ -2025,9 +2041,17 @@ function layout_direction(gobj)
2025
2041
  return str_in_list(LR_LAYOUTS, gobj.priv.layout)? "LR" : "TB";
2026
2042
  }
2027
2043
 
2044
+ /* A curve, or an elbow when the reader asked for one and the layout
2045
+ * has rows to run it between. */
2028
2046
  function edge_type_for(gobj)
2029
2047
  {
2030
- return (layout_direction(gobj) === "LR")? "cubic-horizontal" : "cubic";
2048
+ let lr = (layout_direction(gobj) === "LR");
2049
+ let rowless = gobj_read_attr(gobj, "rowless_layouts") || [];
2050
+ if(gobj_read_str_attr(gobj, "edge_shape") === "elbow" &&
2051
+ !str_in_list(rowless, gobj.priv.layout)) {
2052
+ return lr? "treedb-elbow-h" : "treedb-elbow-v";
2053
+ }
2054
+ return lr? "cubic-horizontal" : "cubic";
2031
2055
  }
2032
2056
 
2033
2057
  /************************************************************
@@ -4188,6 +4212,55 @@ class TreedbTreeLayout extends BaseLayout
4188
4212
  }
4189
4213
  }
4190
4214
 
4215
+ /************************************************************
4216
+ * An ELBOW edge, the way mxGraph drew a tree: straight out of
4217
+ * the parent's port to the channel half way to the child, along
4218
+ * it, and straight into the child's port. The children of one
4219
+ * hook sit on one row and leave by one port, so they share the
4220
+ * channel and the lines read as one bus.
4221
+ *
4222
+ * Computed on every draw from where the two ends ARE, so a
4223
+ * dragged card, a fold or a layout needs nothing else. `-v` runs
4224
+ * between rows (top to bottom), `-h` between columns.
4225
+ ************************************************************/
4226
+ const ELBOW_RADIUS = 6;
4227
+
4228
+ class TreedbElbowV extends Polyline
4229
+ {
4230
+ getControlPoints(attributes) {
4231
+ let [s, t] = this.getEndpoints(attributes, false);
4232
+ let mid = (s[1] + t[1]) / 2;
4233
+ return [[s[0], mid], [t[0], mid]];
4234
+ }
4235
+
4236
+ getKeyPath(attributes) {
4237
+ return super.getKeyPath(Object.assign({}, attributes,
4238
+ {radius: attributes.radius || ELBOW_RADIUS}));
4239
+ }
4240
+ }
4241
+
4242
+ class TreedbElbowH extends Polyline
4243
+ {
4244
+ getControlPoints(attributes) {
4245
+ let [s, t] = this.getEndpoints(attributes, false);
4246
+ let mid = (s[0] + t[0]) / 2;
4247
+ return [[mid, s[1]], [mid, t[1]]];
4248
+ }
4249
+
4250
+ getKeyPath(attributes) {
4251
+ return super.getKeyPath(Object.assign({}, attributes,
4252
+ {radius: attributes.radius || ELBOW_RADIUS}));
4253
+ }
4254
+ }
4255
+
4256
+ class TreedbCompactLayout extends BaseLayout
4257
+ {
4258
+ async execute(data, options) {
4259
+ let input = treedb_layout_input(data);
4260
+ return treedb_layout_output(layout_compact(input.nodes, input.edges, options));
4261
+ }
4262
+ }
4263
+
4191
4264
  class TreedbRadialLayout extends BaseLayout
4192
4265
  {
4193
4266
  async execute(data, options) {
@@ -10856,6 +10929,25 @@ function ac_set_node_labels(gobj, event, kw, src)
10856
10929
  return 0;
10857
10930
  }
10858
10931
 
10932
+ /************************************************************
10933
+ * Curved or elbow edges (the host's toggle). Nothing moves: every
10934
+ * edge takes the type that goes with the shape and the layout,
10935
+ * and draws again.
10936
+ ************************************************************/
10937
+ function ac_set_edge_shape(gobj, event, kw, src)
10938
+ {
10939
+ let priv = gobj.priv;
10940
+
10941
+ let shape = (kw && kw.edge_shape === "elbow")? "elbow" : "curved";
10942
+ gobj_write_str_attr(gobj, "edge_shape", shape);
10943
+ if(!priv.graph || !priv.graph_rendered) {
10944
+ return 0; /* every edge is typed as it is drawn */
10945
+ }
10946
+ apply_layout_direction(gobj);
10947
+ graph_draw(gobj);
10948
+ return 0;
10949
+ }
10950
+
10859
10951
  /************************************************************
10860
10952
  * ONE node opened or closed against the mode (a double click,
10861
10953
  * or the context menu). It holds still on screen while the
@@ -11499,6 +11591,7 @@ function create_gclass(gclass_name)
11499
11591
  ["EV_SET_LOOSE_TOPICS", ac_set_loose_topics, null],
11500
11592
  ["EV_SET_NODE_MODE", ac_set_node_mode, null],
11501
11593
  ["EV_SET_NODE_LABELS", ac_set_node_labels, null],
11594
+ ["EV_SET_EDGE_SHAPE", ac_set_edge_shape, null],
11502
11595
  ["EV_TOGGLE_NODE_MODE", ac_toggle_node_mode, null],
11503
11596
  ["EV_NODE_DBLCLICK", ac_node_dblclick, null],
11504
11597
  ["EV_CENTER", ac_center, null],
@@ -11566,6 +11659,7 @@ function create_gclass(gclass_name)
11566
11659
  ["EV_SET_LOOSE_TOPICS", 0],
11567
11660
  ["EV_SET_NODE_MODE", 0],
11568
11661
  ["EV_SET_NODE_LABELS", 0],
11662
+ ["EV_SET_EDGE_SHAPE", 0],
11569
11663
  ["EV_TOGGLE_NODE_MODE", 0],
11570
11664
  ["EV_NODE_DBLCLICK", 0],
11571
11665
  ["EV_LEGEND_STATE", event_flag_t.EVF_OUTPUT_EVENT],
@@ -134,6 +134,7 @@ SDATA(data_type_t.DTP_LIST, "hidden_topics", sdata_flag_t.SDF_PERSIST, "[
134
134
  SDATA(data_type_t.DTP_STRING, "main_topic", sdata_flag_t.SDF_PERSIST, "", "The topic the tree hangs from; empty = deduced by the graph. User preference, per treedb"),
135
135
  SDATA(data_type_t.DTP_LIST, "loose_topics", sdata_flag_t.SDF_PERSIST, "[]", "Topics whose LOOSE records (no parent) are shown. User preference, per treedb"),
136
136
  SDATA(data_type_t.DTP_STRING, "node_mode", sdata_flag_t.SDF_PERSIST, "expanded", "How the records are drawn: `expanded` = the cards with their pills and ports, `compact` = one-line pills with the name, `shape` = coloured figures, the topology alone. User preference"),
137
+ SDATA(data_type_t.DTP_STRING, "edge_shape", sdata_flag_t.SDF_PERSIST, "curved", "How the edges are drawn: `curved` or `elbow` (the bus of an org chart). User preference"),
137
138
  SDATA(data_type_t.DTP_BOOLEAN, "node_labels", sdata_flag_t.SDF_PERSIST, true, "A `shape` node says its name under its figure. User preference"),
138
139
  SDATA(data_type_t.DTP_DICT, "camera", sdata_flag_t.SDF_PERSIST, "{}", "Where the reader left the viewport: {zoom, x, y}. Handed to the engine on creation and written back when a move settles, so the graph opens where it was left. User preference, per treedb"),
139
140
  SDATA(data_type_t.DTP_INTEGER, "fold_level", sdata_flag_t.SDF_PERSIST, 0, "The level of the MAIN tree the toolbar's stepper stands on: 1 = the main topic's roots, N = N-1 levels under them; 0 = never stepped, `expand_depth`. Handed to the engine on creation, so the graph opens at the level it was left. User preference, per treedb"),
@@ -283,6 +284,7 @@ function mt_create(gobj)
283
284
  loose_topics: gobj_read_attr(gobj, "loose_topics") || [],
284
285
  node_mode: gobj_read_str_attr(gobj, "node_mode") || "expanded",
285
286
  node_labels: gobj_read_bool_attr(gobj, "node_labels"),
287
+ edge_shape: gobj_read_str_attr(gobj, "edge_shape") || "curved",
286
288
  camera: gobj_read_attr(gobj, "camera") || {},
287
289
  },
288
290
  gobj
@@ -553,6 +555,8 @@ function option_label(name)
553
555
  return t("manual");
554
556
  case "treedb-tree":
555
557
  return t("treedb-tree");
558
+ case "compact-tree":
559
+ return t("compact-tree");
556
560
  case "radial":
557
561
  return t("radial");
558
562
  case "dagre":
@@ -623,6 +627,22 @@ function node_shape_items(gobj, wide)
623
627
  }
624
628
  }
625
629
  ],
630
+ /* How the lines are drawn: curves, or the elbows of an org
631
+ * chart. A toggle, so a pressed pill; disabled on a layout
632
+ * with no rows, where the edges stay curved anyway. */
633
+ ['button', {class: 'GRAPH_EDGE_ELBOW button', type: 'button',
634
+ style: {height: wide, width: '2.5em'},
635
+ title: t('elbow edges'), 'data-i18n-title': 'elbow edges',
636
+ 'aria-label': t('elbow edges'), 'data-i18n-aria-label': 'elbow edges',
637
+ 'aria-pressed': 'false'},
638
+ yui_toolbar_icon('yi-diagram-project'),
639
+ {
640
+ click: (evt) => {
641
+ evt.stopPropagation();
642
+ gobj_send_event(gobj, "EV_TOGGLE_EDGE_SHAPE", {}, gobj);
643
+ }
644
+ }
645
+ ],
626
646
  ];
627
647
  }
628
648
 
@@ -670,6 +690,17 @@ function refresh_node_shape_buttons(gobj, $root)
670
690
  $labels.setAttribute('aria-pressed', labels? 'true' : 'false');
671
691
  $labels.disabled = (mode !== 'shape');
672
692
  }
693
+
694
+ let elbow = (gobj_read_str_attr(gobj, "edge_shape") === "elbow");
695
+ set_pressed_state($root, '.GRAPH_EDGE_ELBOW', elbow);
696
+ let $elbow = $root.querySelector('.GRAPH_EDGE_ELBOW');
697
+ if($elbow) {
698
+ $elbow.setAttribute('aria-pressed', elbow? 'true' : 'false');
699
+ let child = gobj.priv.gobj_nodes_tree;
700
+ let rowless = child? (gobj_read_attr(child, "rowless_layouts") || []) : [];
701
+ let layout = child? gobj_read_str_attr(child, "layout") : "";
702
+ $elbow.disabled = rowless.includes(layout);
703
+ }
673
704
  }
674
705
 
675
706
  /************************************************************
@@ -1317,6 +1348,7 @@ function populate_nodes_tree_options(gobj)
1317
1348
  $layout_select.value = current_layout;
1318
1349
  }
1319
1350
  refresh_layout_stepper(gobj);
1351
+ refresh_node_shape_buttons(gobj, $container);
1320
1352
  }
1321
1353
 
1322
1354
  // Restore persisted operation_mode selection
@@ -2612,6 +2644,7 @@ function ac_set_layout(gobj, event, kw, src)
2612
2644
  gobj
2613
2645
  );
2614
2646
  refresh_layout_stepper(gobj);
2647
+ refresh_node_shape_buttons(gobj, priv.$container);
2615
2648
 
2616
2649
  return 0;
2617
2650
  }
@@ -3114,6 +3147,7 @@ function ac_layout_autoset(gobj, event, kw, src)
3114
3147
  }
3115
3148
  $select.value = layout;
3116
3149
  refresh_layout_stepper(gobj);
3150
+ refresh_node_shape_buttons(gobj, $container);
3117
3151
  return 0;
3118
3152
  }
3119
3153
 
@@ -3211,6 +3245,20 @@ function ac_set_node_mode(gobj, event, kw, src)
3211
3245
  return 0;
3212
3246
  }
3213
3247
 
3248
+ function ac_toggle_edge_shape(gobj, event, kw, src)
3249
+ {
3250
+ let priv = gobj.priv;
3251
+ let shape = (gobj_read_str_attr(gobj, "edge_shape") === "elbow")? "curved" : "elbow";
3252
+
3253
+ gobj_write_str_attr(gobj, "edge_shape", shape);
3254
+ gobj_save_persistent_attrs(gobj, "edge_shape");
3255
+ refresh_node_shape_buttons(gobj, priv.$container);
3256
+ if(priv.gobj_nodes_tree) {
3257
+ gobj_send_event(priv.gobj_nodes_tree, "EV_SET_EDGE_SHAPE", {edge_shape: shape}, gobj);
3258
+ }
3259
+ return 0;
3260
+ }
3261
+
3214
3262
  function ac_toggle_node_labels(gobj, event, kw, src)
3215
3263
  {
3216
3264
  let priv = gobj.priv;
@@ -3393,6 +3441,7 @@ function create_gclass(gclass_name)
3393
3441
  ["EV_COLLAPSE_LEVEL", ac_fold_level_step, null],
3394
3442
  ["EV_SET_NODE_MODE", ac_set_node_mode, null],
3395
3443
  ["EV_TOGGLE_NODE_LABELS", ac_toggle_node_labels, null],
3444
+ ["EV_TOGGLE_EDGE_SHAPE", ac_toggle_edge_shape, null],
3396
3445
  ["EV_LAYOUT_AUTOSET", ac_layout_autoset, null],
3397
3446
  ["EV_CAMERA_CHANGED", ac_camera_changed, null],
3398
3447
  ["EV_LEGEND_TOPIC", ac_legend_topic, null],
@@ -3439,6 +3488,7 @@ function create_gclass(gclass_name)
3439
3488
  ["EV_COLLAPSE_LEVEL", 0],
3440
3489
  ["EV_SET_NODE_MODE", 0],
3441
3490
  ["EV_TOGGLE_NODE_LABELS", 0],
3491
+ ["EV_TOGGLE_EDGE_SHAPE", 0],
3442
3492
  ["EV_FIND_RESULT", 0],
3443
3493
  ["EV_LAYOUT_AUTOSET", 0],
3444
3494
  ["EV_CAMERA_CHANGED", 0],
@@ -274,6 +274,264 @@ function tidy_tree_lr(nodes, edges, o)
274
274
  return pos;
275
275
  }
276
276
 
277
+ /************************************************************
278
+ * The compact tree.
279
+ *
280
+ * The tidy tree above gives every subtree its whole BLOCK: open a
281
+ * hall with twenty-four devices and its block widens, and with it
282
+ * every block above it, so the siblings of its ancestors move apart
283
+ * at depths where nothing of theirs touches. This one keeps the
284
+ * CONTOUR of each subtree instead -- its outline on each side, depth
285
+ * by depth -- and slides every sibling against the outline of the
286
+ * ones before it. A folded region then sits as close to an open one
287
+ * as their cards allow at each depth, which is what a folded treedb
288
+ * is: one branch open among closed ones.
289
+ *
290
+ * Same spanning tree, same order, same rows as the tidy tree: only
291
+ * the packing across the depths differs, so switching between the
292
+ * two moves cards sideways and never reorders them.
293
+ *
294
+ * The contour arithmetic is Moen's ("Drawing dynamic trees", IEEE
295
+ * Software, 1990) in the form mxGraph gave it in mxCompactTreeLayout
296
+ * -- Copyright (c) 2006-2015 JGraph Ltd, Apache License 2.0 -- read in
297
+ * maxGraph's CompactTreeLayout.ts: leaf, join, merge, offset and
298
+ * bridge below follow that form. What is ours: it runs on the
299
+ * spanning tree of a FOREST, every depth is a row as wide as its
300
+ * widest card (mxGraph's `alignRanks`), several roots sit side by
301
+ * side, and there is no recursion.
302
+ ************************************************************/
303
+ export function layout_compact(nodes, edges, opts)
304
+ {
305
+ let o = Object.assign({}, DEFAULTS, opts || {});
306
+ if(o.direction === "LR") {
307
+ return compact_tree_lr(nodes, edges, o);
308
+ }
309
+ let transposed = nodes.map((n) => Object.assign({}, n, {w: n.h, h: n.w}));
310
+ let lr = compact_tree_lr(transposed, edges, o);
311
+ let pos = new Map();
312
+ for(let [id, p] of lr) {
313
+ pos.set(id, {x: p.y, y: p.x});
314
+ }
315
+ return pos;
316
+ }
317
+
318
+ /* One segment of a contour: a step of `dx` along the depth and `dy`
319
+ * across it, then the next segment. */
320
+ function contour_line(dx, dy, next)
321
+ {
322
+ return {dx: dx, dy: dy, next: next || null};
323
+ }
324
+
325
+ /* How far the segment (a1, a2) at (p1, p2) has to move across to
326
+ * clear the segment (b1, b2) at the origin. */
327
+ function contour_offset(p1, p2, a1, a2, b1, b2)
328
+ {
329
+ if(b1 <= p1 || p1 + a1 <= 0) {
330
+ return 0;
331
+ }
332
+ let d;
333
+ let t = b1 * a2 - a1 * b2;
334
+ if(t > 0) {
335
+ if(p1 < 0) {
336
+ d = (p1 * a2) / a1 - p2;
337
+ } else if(p1 > 0) {
338
+ d = (p1 * b2) / b1 - p2;
339
+ } else {
340
+ d = -p2;
341
+ }
342
+ } else if(b1 < p1 + a1) {
343
+ d = b2 - (p2 + ((b1 - p1) * a2) / a1);
344
+ } else if(b1 > p1 + a1) {
345
+ d = ((a1 + p1) * b2) / b1 - (p2 + a2);
346
+ } else {
347
+ d = b2 - (p2 + a2);
348
+ }
349
+ return (d > 0)? d : 0;
350
+ }
351
+
352
+ function contour_bridge(line1, x1, y1, line2, x2, y2)
353
+ {
354
+ let dx = x2 + line2.dx - x1;
355
+ let dy = (line2.dx === 0)? line2.dy : (dx * line2.dy) / line2.dx;
356
+ let r = contour_line(dx, dy, line2.next);
357
+ line1.next = contour_line(0, y2 + line2.dy - dy - y1, r);
358
+ return r;
359
+ }
360
+
361
+ /* Slide contour `p2` against `p1` until they clear; returns how far,
362
+ * and leaves in `p1` the outline of the two together. */
363
+ function contour_merge(p1, p2)
364
+ {
365
+ let x = 0;
366
+ let y = 0;
367
+ let total = 0;
368
+ let upper = p1.lower_head;
369
+ let lower = p2.upper_head;
370
+
371
+ while(lower && upper) {
372
+ let d = contour_offset(x, y, lower.dx, lower.dy, upper.dx, upper.dy);
373
+ y += d;
374
+ total += d;
375
+ if(x + lower.dx <= upper.dx) {
376
+ x += lower.dx;
377
+ y += lower.dy;
378
+ lower = lower.next;
379
+ } else {
380
+ x -= upper.dx;
381
+ y -= upper.dy;
382
+ upper = upper.next;
383
+ }
384
+ }
385
+
386
+ if(lower) {
387
+ let b = contour_bridge(p1.upper_tail, 0, 0, lower, x, y);
388
+ p1.upper_tail = b.next? p2.upper_tail : b;
389
+ p1.lower_tail = p2.lower_tail;
390
+ } else {
391
+ let b = contour_bridge(p2.lower_tail, x, y, upper, 0, 0);
392
+ if(!b.next) {
393
+ p1.lower_tail = b;
394
+ }
395
+ }
396
+ p1.lower_head = p2.lower_head;
397
+ return total;
398
+ }
399
+
400
+ function compact_tree_lr(nodes, edges, o)
401
+ {
402
+ let t = spanning_tree(nodes, edges);
403
+ let order = walk_order(t);
404
+ let pos = new Map();
405
+
406
+ /* Columns, as in the tidy tree: a depth is as wide as its widest
407
+ * card, and a column's gap is `ranksep`. */
408
+ let col_w = [];
409
+ for(let [id, d] of t.depth) {
410
+ col_w[d] = Math.max(col_w[d] || 0, t.by_id.get(id).w);
411
+ }
412
+ let col_x = [];
413
+ let x = 0;
414
+ for(let d = 0; d < col_w.length; d++) {
415
+ col_x[d] = x;
416
+ x += (col_w[d] || 0) + o.ranksep;
417
+ }
418
+
419
+ /* mxGraph's nodeDistance is padding on EACH side of a card, so two
420
+ * neighbours end `nodesep` apart; its levelDistance is what is
421
+ * left of the column gap after that padding. */
422
+ let pad = o.nodesep / 2;
423
+ let dist = o.nodesep;
424
+ let level_distance = o.ranksep - pad;
425
+
426
+ /* `len` along the depth (the column's width: the rows are aligned),
427
+ * `ext` across it (the card's own height). */
428
+ let tn = new Map();
429
+ for(let id of order) {
430
+ tn.set(id, {
431
+ len: col_w[t.depth.get(id)],
432
+ ext: t.by_id.get(id).h,
433
+ off_x: 0,
434
+ off_y: 0,
435
+ y: 0,
436
+ contour: null,
437
+ });
438
+ }
439
+
440
+ /* Contours, children before parents. */
441
+ for(let i = order.length - 1; i >= 0; i--) {
442
+ let id = order[i];
443
+ let n = tn.get(id);
444
+ let kids = t.children.get(id) || [];
445
+
446
+ if(!kids.length) {
447
+ let upper = contour_line(n.len + dist, 0);
448
+ let lower_tail = contour_line(0, -n.ext - dist);
449
+ n.contour = {
450
+ upper_head: upper,
451
+ upper_tail: upper,
452
+ lower_tail: lower_tail,
453
+ lower_head: contour_line(n.len + dist, 0, lower_tail),
454
+ };
455
+ continue;
456
+ }
457
+
458
+ /* join: the children side by side, each slid against the
459
+ * outline of the ones before it. */
460
+ let first = tn.get(kids[0]);
461
+ n.contour = first.contour;
462
+ let h = first.ext + dist;
463
+ let span = h;
464
+ for(let k = 1; k < kids.length; k++) {
465
+ let kid = tn.get(kids[k]);
466
+ let d = contour_merge(n.contour, kid.contour);
467
+ kid.off_y = d + h;
468
+ kid.off_x = 0;
469
+ h = kid.ext + dist;
470
+ span += d + h;
471
+ }
472
+
473
+ /* attach: the parent centred across the span of its children,
474
+ * one column before them. */
475
+ let gap = pad + level_distance;
476
+ let y2 = (span - n.ext) / 2 - pad;
477
+ let y1 = y2 + n.ext + 2 * pad - span;
478
+ first.off_x = gap + n.len;
479
+ first.off_y = y1;
480
+ n.contour.upper_head = contour_line(n.len, 0,
481
+ contour_line(gap, y1, n.contour.upper_head));
482
+ n.contour.lower_head = contour_line(n.len, 0,
483
+ contour_line(gap, y2, n.contour.lower_head));
484
+ }
485
+
486
+ /* Positions across, parents before children: the first child at
487
+ * its offset from the parent, each next one at its offset from
488
+ * the one before. */
489
+ let root_of = new Map();
490
+ for(let id of order) {
491
+ let n = tn.get(id);
492
+ if(!root_of.has(id)) {
493
+ root_of.set(id, id);
494
+ }
495
+ let kids = t.children.get(id) || [];
496
+ if(!kids.length) {
497
+ continue;
498
+ }
499
+ let running = n.y + tn.get(kids[0]).off_y;
500
+ tn.get(kids[0]).y = running;
501
+ root_of.set(kids[0], root_of.get(id));
502
+ for(let k = 1; k < kids.length; k++) {
503
+ let kid = tn.get(kids[k]);
504
+ kid.y = running + kid.off_y;
505
+ running = kid.y;
506
+ root_of.set(kids[k], root_of.get(id));
507
+ }
508
+ }
509
+
510
+ /* Several roots: each tree after the one before, by its extent. */
511
+ let lo = new Map();
512
+ let hi = new Map();
513
+ for(let id of order) {
514
+ let r = root_of.get(id);
515
+ let n = tn.get(id);
516
+ lo.set(r, Math.min(lo.has(r)? lo.get(r) : Infinity, n.y));
517
+ hi.set(r, Math.max(hi.has(r)? hi.get(r) : -Infinity, n.y + n.ext));
518
+ }
519
+ let shift = new Map();
520
+ let top = 0;
521
+ for(let r of t.roots) {
522
+ shift.set(r, top - lo.get(r));
523
+ top += hi.get(r) - lo.get(r) + o.nodesep * 2;
524
+ }
525
+
526
+ for(let id of order) {
527
+ let d = t.depth.get(id);
528
+ let n = tn.get(id);
529
+ let y = n.y + shift.get(root_of.get(id));
530
+ pos.set(id, {x: col_x[d] + col_w[d] / 2, y: y + n.ext / 2});
531
+ }
532
+ return pos;
533
+ }
534
+
277
535
  /* How much of a card lies along the direction `a`: the projection
278
536
  * of its rectangle on that direction. Full width when the card is
279
537
  * read along its width, full height across it, and the honest
@@ -6,7 +6,7 @@
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 { spanning_tree, layout_tree, layout_radial } from "./treedb_layout.js";
9
+ import { spanning_tree, layout_tree, layout_radial, layout_compact } from "./treedb_layout.js";
10
10
 
11
11
  /* es -> (norte, sur); norte -> nave; nave -> d0, d1 (hook rank 1) and
12
12
  * c1 (hook rank 2); c1 -> d0 as well (second parent). */
@@ -97,6 +97,107 @@ describe("the tidy tree, read down (the default)", () => {
97
97
  });
98
98
  });
99
99
 
100
+ /* Every two cards of one row stand at least `gap` apart. */
101
+ function expect_no_overlap_in_rows(list, pos, gap)
102
+ {
103
+ for(let i = 0; i < list.length; i++) {
104
+ for(let j = i + 1; j < list.length; j++) {
105
+ let a = list[i];
106
+ let b = list[j];
107
+ let pa = pos.get(a.id);
108
+ let pb = pos.get(b.id);
109
+ if(Math.abs(pa.y - pb.y) > 1e-6) {
110
+ continue;
111
+ }
112
+ expect(Math.abs(pa.x - pb.x) + 1e-6).toBeGreaterThanOrEqual((a.w + b.w) / 2 + gap);
113
+ }
114
+ }
115
+ }
116
+
117
+ function width_of(list, pos)
118
+ {
119
+ let lo = Infinity;
120
+ let hi = -Infinity;
121
+ for(let n of list) {
122
+ let p = pos.get(n.id);
123
+ lo = Math.min(lo, p.x - n.w / 2);
124
+ hi = Math.max(hi, p.x + n.w / 2);
125
+ }
126
+ return hi - lo;
127
+ }
128
+
129
+ /* r -> a, b; a -> hall -> six devices; b is a closed sibling. */
130
+ const wide = [N("r", 172, 96), N("a", 172, 96), N("b", 172, 96), N("hall", 172, 96)]
131
+ .concat([0, 1, 2, 3, 4, 5].map((i) => N(`d${i}`, 116, 40)));
132
+ const wide_edges = [
133
+ {source: "r", target: "a"}, {source: "r", target: "b"}, {source: "a", target: "hall"},
134
+ ].concat([0, 1, 2, 3, 4, 5].map((i) => ({source: "hall", target: `d${i}`})));
135
+
136
+ describe("the compact tree", () => {
137
+ test("the rows are the tidy tree's rows", () => {
138
+ let tidy = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100});
139
+ let pos = layout_compact(nodes, edges, {nodesep: 10, ranksep: 100});
140
+ for(let n of nodes) {
141
+ expect(pos.get(n.id).y).toBe(tidy.get(n.id).y);
142
+ }
143
+ });
144
+
145
+ test("no two cards of a row are closer than the gap", () => {
146
+ expect_no_overlap_in_rows(nodes, layout_compact(nodes, edges, {nodesep: 10, ranksep: 100}), 10);
147
+ expect_no_overlap_in_rows(wide, layout_compact(wide, wide_edges, {nodesep: 18, ranksep: 90}), 18);
148
+ });
149
+
150
+ test("a parent is centred over its children, which keep their order", () => {
151
+ let pos = layout_compact(nodes, edges, {nodesep: 10, ranksep: 100});
152
+ expect(pos.get("d0").x).toBeLessThan(pos.get("d1").x);
153
+ expect(pos.get("d1").x).toBeLessThan(pos.get("c1").x);
154
+ let first = pos.get("d0").x - 58;
155
+ let last = pos.get("c1").x + 86;
156
+ expect(pos.get("nave").x).toBeCloseTo((first + last) / 2, 6);
157
+ expect(pos.get("norte").x).toBeLessThan(pos.get("sur").x);
158
+ });
159
+
160
+ test("a closed sibling sits beside an open branch, not beside its block", () => {
161
+ let opts = {nodesep: 18, ranksep: 90};
162
+ let tidy = layout_tree(wide, wide_edges, opts);
163
+ let pos = layout_compact(wide, wide_edges, opts);
164
+ /* In the tidy tree `b` waits for the whole fan of devices; here it
165
+ * only has to clear `a`, the card beside it. */
166
+ expect(pos.get("b").x - pos.get("a").x).toBeLessThan(tidy.get("b").x - tidy.get("a").x);
167
+ expect(pos.get("b").x - pos.get("a").x).toBeGreaterThanOrEqual(172 + 18 - 1e-6);
168
+ expect(width_of(wide, pos)).toBeLessThanOrEqual(width_of(wide, tidy));
169
+ });
170
+
171
+ test("two trees sit side by side, not overlapped", () => {
172
+ let pos = layout_compact([N("a", 100, 50), N("b", 100, 50)], [], {nodesep: 10});
173
+ expect(pos.get("b").x - pos.get("a").x).toBe(100 + 20);
174
+ });
175
+
176
+ test("read right, it is the same tree with the axes swapped", () => {
177
+ let down = layout_compact(wide, wide_edges, {nodesep: 18, ranksep: 90});
178
+ let swapped = wide.map((n) => N(n.id, n.h, n.w));
179
+ let right = layout_compact(swapped, wide_edges, {nodesep: 18, ranksep: 90, direction: "LR"});
180
+ for(let n of wide) {
181
+ expect(right.get(n.id).x).toBeCloseTo(down.get(n.id).y, 6);
182
+ expect(right.get(n.id).y).toBeCloseTo(down.get(n.id).x, 6);
183
+ }
184
+ });
185
+
186
+ test("a tree as deep as the data places every node, without recursion", () => {
187
+ let chain = [];
188
+ let links = [];
189
+ for(let i = 0; i < 5000; i++) {
190
+ chain.push(N(`n${i}`, 40, 20));
191
+ if(i > 0) {
192
+ links.push({source: `n${i - 1}`, target: `n${i}`});
193
+ }
194
+ }
195
+ let pos = layout_compact(chain, links, {});
196
+ expect(pos.size).toBe(5000);
197
+ expect(Number.isFinite(pos.get("n4999").x)).toBe(true);
198
+ });
199
+ });
200
+
100
201
  describe("the tidy tree, read right", () => {
101
202
  test("depths are columns; a column is as wide as its widest card", () => {
102
203
  let pos = layout_tree(nodes, edges, {nodesep: 10, ranksep: 100, direction: "LR"});
package/src/yui_icons.css CHANGED
@@ -444,3 +444,9 @@
444
444
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zm11.3-387.3l104 104c4.6 4.6 5.9 11.5 3.5 17.4S366.5 256 360 256l-56 0 0 96c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-96-56 0c-6.5 0-12.3-3.9-14.8-9.9s-1.1-12.9 3.5-17.4l104-104c6.2-6.2 16.4-6.2 22.6 0z'/%3E%3C/svg%3E");
445
445
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zm11.3-387.3l104 104c4.6 4.6 5.9 11.5 3.5 17.4S366.5 256 360 256l-56 0 0 96c0 17.7-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32l0-96-56 0c-6.5 0-12.3-3.9-14.8-9.9s-1.1-12.9 3.5-17.4l104-104c6.2-6.2 16.4-6.2 22.6 0z'/%3E%3C/svg%3E");
446
446
  }
447
+
448
+ /* Elbow edges: boxes joined by right-angled lines (FontAwesome Free 7.3.1). */
449
+ .yi-diagram-project::before {
450
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M0 80C0 53.5 21.5 32 48 32l96 0c26.5 0 48 21.5 48 48l0 16 128 0 0-16c0-26.5 21.5-48 48-48l96 0c26.5 0 48 21.5 48 48l0 96c0 26.5-21.5 48-48 48l-96 0c-26.5 0-48-21.5-48-48l0-16-128 0 0 16c0 7.3-1.7 14.3-4.6 20.5l68.6 91.5 80 0c26.5 0 48 21.5 48 48l0 96c0 26.5-21.5 48-48 48l-96 0c-26.5 0-48-21.5-48-48l0-96c0-7.3 1.7-14.3 4.6-20.5L128 224 48 224c-26.5 0-48-21.5-48-48L0 80z'/%3E%3C/svg%3E");
451
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M0 80C0 53.5 21.5 32 48 32l96 0c26.5 0 48 21.5 48 48l0 16 128 0 0-16c0-26.5 21.5-48 48-48l96 0c26.5 0 48 21.5 48 48l0 96c0 26.5-21.5 48-48 48l-96 0c-26.5 0-48-21.5-48-48l0-16-128 0 0 16c0 7.3-1.7 14.3-4.6 20.5l68.6 91.5 80 0c26.5 0 48 21.5 48 48l0 96c0 26.5-21.5 48-48 48l-96 0c-26.5 0-48-21.5-48-48l0-96c0-7.3 1.7-14.3 4.6-20.5L128 224 48 224c-26.5 0-48-21.5-48-48L0 80z'/%3E%3C/svg%3E");
452
+ }