@yuneta/gobj-ui 7.23.149 → 7.23.151

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 CHANGED
@@ -1438,6 +1438,30 @@ direction, and a layout with no rows (`rowless_layouts`: `radial`,
1438
1438
  `edge_shape` is `SDF_PERSIST` on `C_YUI_TREEDB_GRAPH`, a preference per
1439
1439
  treedb like `node_labels`. Consumer key: `elbow edges`.
1440
1440
 
1441
+ Two cases are not a channel (`7.23.150`, `treedb_elbow.js`, pure and
1442
+ tested). An elbow that does not run **forward** — the target above its
1443
+ source or in the same row, which one edge of every reciprocal pair is —
1444
+ goes **round** the two cards instead of through them: a short run out of its
1445
+ port, along the outside of both, and in from the other port's own side. And
1446
+ the edges joining the **same two cards**, either way round, take **lanes**
1447
+ 10px apart in the order they were made (0, +1, −1…), so a new one never
1448
+ moves the ones already drawn; a detour on a negative lane goes round the
1449
+ left.
1450
+
1451
+ And no elbow crosses a card (`7.23.151`). Each is tried simple first — the
1452
+ channel, or the detour round its two cards — and only when that line crosses
1453
+ a card is it **routed**: out of its port, the shortest way along the gaps
1454
+ between the cards, and in. It is our own orthogonal search on a sparse grid
1455
+ (the lines of the cards' borders, grown by a 12px clearance that grows with
1456
+ the lane, plus the two ends; a turn costs 40px of line), over the cards within
1457
+ 400px first and all of them if that finds no way; with no way at all the
1458
+ simple elbow stays. A parent and the row of children under it keep their bus;
1459
+ what gets routed is a detour with a third card in its way, an edge dropping
1460
+ several rows, an edge between two cards of one row. Ports are not obstacles,
1461
+ only cards. G6's `shortest-path` router is not used: it is not exported, it
1462
+ falls back in silence to a route that crosses cards, and it rewrites its
1463
+ module defaults with every config it is given.
1464
+
1441
1465
  All three take the **same spanning tree**, chosen deterministically: roots are the
1442
1466
  nodes with no incoming edge, in node order; a node belongs to the **first
1443
1467
  parent that reaches it** in a breadth-first walk (the place, not the
@@ -29630,6 +29630,254 @@ function layout_radial(nodes, edges, opts) {
29630
29630
  }
29631
29631
  return pos;
29632
29632
  }
29633
+ /************************************************************
29634
+ * The lane of `id` among `ids`, the edges joining the same two
29635
+ * cards: 0, then +1, -1, +2, -2... in the order they were made.
29636
+ * Edge ids end in a counter, so a numeric sort is that order.
29637
+ ************************************************************/
29638
+ function elbow_lane(ids, id) {
29639
+ let k = ids.slice().sort((a, b) => String(a).localeCompare(String(b), void 0, { numeric: true })).indexOf(id);
29640
+ if (k <= 0) return 0;
29641
+ return (k % 2 ? 1 : -1) * Math.ceil(k / 2);
29642
+ }
29643
+ function swap(p) {
29644
+ return [p[1], p[0]];
29645
+ }
29646
+ function swap_box(b) {
29647
+ return {
29648
+ x1: b.y1,
29649
+ y1: b.x1,
29650
+ x2: b.y2,
29651
+ y2: b.x2
29652
+ };
29653
+ }
29654
+ /************************************************************
29655
+ * The control points of an elbow from the port `s` to the port
29656
+ * `t` ([x, y] each), knowing the boxes of the two cards
29657
+ * ({x1, y1, x2, y2}) and the edge's lane.
29658
+ *
29659
+ * vertical the rows are stacked (top to bottom); false
29660
+ * when they are side by side (left to right)
29661
+ ************************************************************/
29662
+ function elbow_points(s, t, box_s, box_t, lane, vertical) {
29663
+ if (!vertical) return elbow_points(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true).map(swap);
29664
+ let shift = lane * 10;
29665
+ if (t[1] - s[1] > 20) {
29666
+ let mid = (s[1] + t[1]) / 2 + shift;
29667
+ mid = Math.min(Math.max(mid, s[1] + 10), t[1] - 10);
29668
+ return [[s[0], mid], [t[0], mid]];
29669
+ }
29670
+ let out = 16 + Math.abs(shift);
29671
+ let side = lane < 0 ? Math.min(box_s.x1, box_t.x1) - 24 - Math.abs(shift) : Math.max(box_s.x2, box_t.x2) + 24 + Math.abs(shift);
29672
+ let y1 = s[1] + out;
29673
+ let y2 = t[1] - out;
29674
+ return [
29675
+ [s[0], y1],
29676
+ [side, y1],
29677
+ [side, y2],
29678
+ [t[0], y2]
29679
+ ];
29680
+ }
29681
+ function box_expand(b, m) {
29682
+ return {
29683
+ x1: b.x1 - m,
29684
+ y1: b.y1 - m,
29685
+ x2: b.x2 + m,
29686
+ y2: b.y2 + m
29687
+ };
29688
+ }
29689
+ function box_overlaps(a, b) {
29690
+ return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
29691
+ }
29692
+ function point_inside(p, b) {
29693
+ return p[0] > b.x1 && p[0] < b.x2 && p[1] > b.y1 && p[1] < b.y2;
29694
+ }
29695
+ function segment_hits(a, c, b) {
29696
+ let x1 = Math.min(a[0], c[0]);
29697
+ let x2 = Math.max(a[0], c[0]);
29698
+ let y1 = Math.min(a[1], c[1]);
29699
+ let y2 = Math.max(a[1], c[1]);
29700
+ return x1 < b.x2 && x2 > b.x1 && y1 < b.y2 && y2 > b.y1;
29701
+ }
29702
+ /************************************************************
29703
+ * Does the polyline `points` (its ends included) cross any of
29704
+ * `boxes`? A card is a box shrunk by a pixel, so a line along
29705
+ * its border or leaving from a port on it is not a hit.
29706
+ ************************************************************/
29707
+ function elbow_path_hits(points, boxes) {
29708
+ for (let b of boxes) {
29709
+ let inner = box_expand(b, -1);
29710
+ for (let i = 0; i + 1 < points.length; i++) if (segment_hits(points[i], points[i + 1], inner)) return true;
29711
+ }
29712
+ return false;
29713
+ }
29714
+ function heap_push(h, item) {
29715
+ h.push(item);
29716
+ let i = h.length - 1;
29717
+ while (i > 0) {
29718
+ let p = i - 1 >> 1;
29719
+ if (h[p][0] <= h[i][0]) break;
29720
+ [h[p], h[i]] = [h[i], h[p]];
29721
+ i = p;
29722
+ }
29723
+ }
29724
+ function heap_pop(h) {
29725
+ let top = h[0];
29726
+ let last = h.pop();
29727
+ if (h.length) {
29728
+ h[0] = last;
29729
+ let i = 0;
29730
+ for (;;) {
29731
+ let l = 2 * i + 1;
29732
+ let r = l + 1;
29733
+ let m = i;
29734
+ if (l < h.length && h[l][0] < h[m][0]) m = l;
29735
+ if (r < h.length && h[r][0] < h[m][0]) m = r;
29736
+ if (m === i) break;
29737
+ [h[m], h[i]] = [h[i], h[m]];
29738
+ i = m;
29739
+ }
29740
+ }
29741
+ return top;
29742
+ }
29743
+ /************************************************************
29744
+ * The shortest orthogonal line from `a` to `z` that enters none
29745
+ * of `boxes` (already expanded by the clearance), a turn costing
29746
+ * ROUTE_BEND. The line leaves `a` going down and ends at `z`
29747
+ * going down, since after `z` it drops into a port.
29748
+ *
29749
+ * The grid is SPARSE: only the lines of the boxes' borders and
29750
+ * of the two ends, which is all an orthogonal shortest path ever
29751
+ * needs to turn on. Returns [a, ...turns, z], or null when every
29752
+ * way is closed.
29753
+ ************************************************************/
29754
+ function orth_search(a, z, boxes) {
29755
+ for (let b of boxes) if (point_inside(a, b) || point_inside(z, b)) return null;
29756
+ let uniq = (list) => [...new Set(list)].sort((p, q) => p - q);
29757
+ let xs = uniq([a[0], z[0]].concat(...boxes.map((b) => [b.x1, b.x2])));
29758
+ let ys = uniq([a[1], z[1]].concat(...boxes.map((b) => [b.y1, b.y2])));
29759
+ let W = xs.length;
29760
+ let H = ys.length;
29761
+ let free = (x, y) => !boxes.some((b) => point_inside([x, y], b));
29762
+ let ok = new Uint8Array(W * H);
29763
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) ok[j * W + i] = free(xs[i], ys[j]) ? 1 : 0;
29764
+ let step_right = new Uint8Array(W * H);
29765
+ let step_down = new Uint8Array(W * H);
29766
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
29767
+ let k = j * W + i;
29768
+ if (!ok[k]) continue;
29769
+ if (i + 1 < W && ok[k + 1] && free((xs[i] + xs[i + 1]) / 2, ys[j])) step_right[k] = 1;
29770
+ if (j + 1 < H && ok[k + W] && free(xs[i], (ys[j] + ys[j + 1]) / 2)) step_down[k] = 1;
29771
+ }
29772
+ let start = ys.indexOf(a[1]) * W + xs.indexOf(a[0]);
29773
+ let goal = ys.indexOf(z[1]) * W + xs.indexOf(z[0]);
29774
+ let best = new Float64Array(W * H * 4).fill(Infinity);
29775
+ let prev = new Int32Array(W * H * 4).fill(-1);
29776
+ let heap = [];
29777
+ best[start * 4 + 2] = 0;
29778
+ heap_push(heap, [0, start * 4 + 2]);
29779
+ let moves = (k) => {
29780
+ let i = k % W;
29781
+ let j = (k - i) / W;
29782
+ let out = [];
29783
+ if (step_right[k]) out.push([
29784
+ k + 1,
29785
+ 0,
29786
+ xs[i + 1] - xs[i]
29787
+ ]);
29788
+ if (i > 0 && step_right[k - 1]) out.push([
29789
+ k - 1,
29790
+ 1,
29791
+ xs[i] - xs[i - 1]
29792
+ ]);
29793
+ if (step_down[k]) out.push([
29794
+ k + W,
29795
+ 2,
29796
+ ys[j + 1] - ys[j]
29797
+ ]);
29798
+ if (j > 0 && step_down[k - W]) out.push([
29799
+ k - W,
29800
+ 3,
29801
+ ys[j] - ys[j - 1]
29802
+ ]);
29803
+ return out;
29804
+ };
29805
+ let found = -1;
29806
+ let found_cost = Infinity;
29807
+ while (heap.length) {
29808
+ let [cost, state] = heap_pop(heap);
29809
+ if (cost > best[state] || cost >= found_cost) continue;
29810
+ let k = state >> 2;
29811
+ let dir = state & 3;
29812
+ if (k === goal) {
29813
+ let total = cost + (dir === 2 ? 0 : 40);
29814
+ if (total < found_cost) {
29815
+ found_cost = total;
29816
+ found = state;
29817
+ }
29818
+ continue;
29819
+ }
29820
+ for (let [nk, nd, len] of moves(k)) {
29821
+ let c = cost + len + (nd === dir ? 0 : 40);
29822
+ let ns = nk * 4 + nd;
29823
+ if (c < best[ns]) {
29824
+ best[ns] = c;
29825
+ prev[ns] = state;
29826
+ heap_push(heap, [c, ns]);
29827
+ }
29828
+ }
29829
+ }
29830
+ if (found < 0) return null;
29831
+ let states = [];
29832
+ for (let s = found; s >= 0; s = prev[s]) states.push(s);
29833
+ states.reverse();
29834
+ let pts = [a];
29835
+ for (let n = 1; n < states.length; n++) if (n + 1 < states.length && (states[n + 1] & 3) !== (states[n] & 3)) {
29836
+ let k = states[n] >> 2;
29837
+ pts.push([xs[k % W], ys[Math.floor(k / W)]]);
29838
+ }
29839
+ pts.push(z);
29840
+ return pts;
29841
+ }
29842
+ /************************************************************
29843
+ * The control points of an elbow that does not cross any card.
29844
+ *
29845
+ * The simple elbow first (elbow_points): the channel, or the
29846
+ * detour round the two cards. Only when that line crosses a card
29847
+ * -- a third record standing in a detour's way, a row in the way
29848
+ * of an edge that drops several rows -- is it ROUTED: out of the
29849
+ * port, the shortest way along the gaps between the cards, in.
29850
+ * A parent and the row of children under it keep their bus.
29851
+ *
29852
+ * `boxes` are all the cards on screen, the two of the edge
29853
+ * included. The route keeps a clearance from every card, which
29854
+ * grows with the lane so that two routed edges of one pair do not
29855
+ * coincide. Nearby cards first; all of them if that finds no way;
29856
+ * the simple elbow if nothing does.
29857
+ ************************************************************/
29858
+ function elbow_route(s, t, box_s, box_t, lane, vertical, boxes) {
29859
+ let others = boxes || [];
29860
+ if (!vertical) return elbow_route(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true, others.map(swap_box)).map(swap);
29861
+ let simple = elbow_points(s, t, box_s, box_t, lane, true);
29862
+ if (!elbow_path_hits([
29863
+ s,
29864
+ ...simple,
29865
+ t
29866
+ ], others)) return simple;
29867
+ let m = 12 + Math.abs(lane) * 10;
29868
+ let a = [s[0], s[1] + m + 4];
29869
+ let z = [t[0], t[1] - m - 4];
29870
+ let region = box_expand({
29871
+ x1: Math.min(a[0], z[0], box_s.x1, box_t.x1),
29872
+ y1: Math.min(a[1], z[1], box_s.y1, box_t.y1),
29873
+ x2: Math.max(a[0], z[0], box_s.x2, box_t.x2),
29874
+ y2: Math.max(a[1], z[1], box_s.y2, box_t.y2)
29875
+ }, 400);
29876
+ let near = others.filter((b) => box_overlaps(b, region));
29877
+ let route = orth_search(a, z, near.map((b) => box_expand(b, m)));
29878
+ if (!route && near.length < others.length) route = orth_search(a, z, others.map((b) => box_expand(b, m)));
29879
+ return route || simple;
29880
+ }
29633
29881
  //#endregion
29634
29882
  //#region node_modules/@babel/runtime/helpers/esm/typeof.js
29635
29883
  function _typeof(o) {
@@ -59009,14 +59257,64 @@ var TreedbTreeLayout = class extends _antv_g6.BaseLayout {
59009
59257
  *
59010
59258
  * Computed on every draw from where the two ends ARE, so a
59011
59259
  * dragged card, a fold or a layout needs nothing else. `-v` runs
59012
- * between rows (top to bottom), `-h` between columns.
59260
+ * between rows (top to bottom), `-h` between columns. An edge that
59261
+ * does not run forward goes round the two cards, the edges joining
59262
+ * the same two cards take lanes, and a line that would cross any
59263
+ * other card is routed round it (treedb_elbow.js).
59013
59264
  ************************************************************/
59014
59265
  var ELBOW_RADIUS = 6;
59266
+ function elbow_box(node) {
59267
+ try {
59268
+ let b = node.getShape("key").getBounds();
59269
+ return {
59270
+ x1: b.min[0],
59271
+ y1: b.min[1],
59272
+ x2: b.max[0],
59273
+ y2: b.max[1]
59274
+ };
59275
+ } catch (e) {
59276
+ (0, _yuneta_gobj_js.log_error)(`elbow edge: no bounds for node ${node && node.id}: ${e}`);
59277
+ let c = node.getCenter();
59278
+ return {
59279
+ x1: c[0],
59280
+ y1: c[1],
59281
+ x2: c[0],
59282
+ y2: c[1]
59283
+ };
59284
+ }
59285
+ }
59286
+ var _elbow_boxes = null;
59287
+ function elbow_obstacles(edge) {
59288
+ if (_elbow_boxes) return _elbow_boxes;
59289
+ let element = edge.context && edge.context.element;
59290
+ if (!element) return [];
59291
+ try {
59292
+ _elbow_boxes = element.getNodes().map((n) => elbow_box(n));
59293
+ } catch (e) {
59294
+ (0, _yuneta_gobj_js.log_error)(`elbow edge ${edge.id}: cannot read the cards: ${e}`);
59295
+ _elbow_boxes = [];
59296
+ }
59297
+ queueMicrotask(() => {
59298
+ _elbow_boxes = null;
59299
+ });
59300
+ return _elbow_boxes;
59301
+ }
59302
+ function elbow_lane_of(edge) {
59303
+ let graph = edge.context && edge.context.graph;
59304
+ let s = edge.sourceNode && edge.sourceNode.id;
59305
+ let t = edge.targetNode && edge.targetNode.id;
59306
+ if (!graph || s === void 0 || t === void 0 || s === t) return 0;
59307
+ try {
59308
+ return elbow_lane(graph.getRelatedEdgesData(s).filter((d) => d.source === s && d.target === t || d.source === t && d.target === s).map((d) => d.id), edge.id);
59309
+ } catch (e) {
59310
+ (0, _yuneta_gobj_js.log_error)(`elbow edge ${edge.id}: cannot read the edges of ${s}: ${e}`);
59311
+ return 0;
59312
+ }
59313
+ }
59015
59314
  var TreedbElbowV = class extends _antv_g6.Polyline {
59016
59315
  getControlPoints(attributes) {
59017
59316
  let [s, t] = this.getEndpoints(attributes, false);
59018
- let mid = (s[1] + t[1]) / 2;
59019
- return [[s[0], mid], [t[0], mid]];
59317
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode), elbow_lane_of(this), true, elbow_obstacles(this));
59020
59318
  }
59021
59319
  getKeyPath(attributes) {
59022
59320
  return super.getKeyPath(Object.assign({}, attributes, { radius: attributes.radius || ELBOW_RADIUS }));
@@ -59025,8 +59323,7 @@ var TreedbElbowV = class extends _antv_g6.Polyline {
59025
59323
  var TreedbElbowH = class extends _antv_g6.Polyline {
59026
59324
  getControlPoints(attributes) {
59027
59325
  let [s, t] = this.getEndpoints(attributes, false);
59028
- let mid = (s[0] + t[0]) / 2;
59029
- return [[mid, s[1]], [mid, t[1]]];
59326
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode), elbow_lane_of(this), false, elbow_obstacles(this));
59030
59327
  }
59031
59328
  getKeyPath(attributes) {
59032
59329
  return super.getKeyPath(Object.assign({}, attributes, { radius: attributes.radius || ELBOW_RADIUS }));
@@ -29625,6 +29625,254 @@ function layout_radial(nodes, edges, opts) {
29625
29625
  }
29626
29626
  return pos;
29627
29627
  }
29628
+ /************************************************************
29629
+ * The lane of `id` among `ids`, the edges joining the same two
29630
+ * cards: 0, then +1, -1, +2, -2... in the order they were made.
29631
+ * Edge ids end in a counter, so a numeric sort is that order.
29632
+ ************************************************************/
29633
+ function elbow_lane(ids, id) {
29634
+ let k = ids.slice().sort((a, b) => String(a).localeCompare(String(b), void 0, { numeric: true })).indexOf(id);
29635
+ if (k <= 0) return 0;
29636
+ return (k % 2 ? 1 : -1) * Math.ceil(k / 2);
29637
+ }
29638
+ function swap(p) {
29639
+ return [p[1], p[0]];
29640
+ }
29641
+ function swap_box(b) {
29642
+ return {
29643
+ x1: b.y1,
29644
+ y1: b.x1,
29645
+ x2: b.y2,
29646
+ y2: b.x2
29647
+ };
29648
+ }
29649
+ /************************************************************
29650
+ * The control points of an elbow from the port `s` to the port
29651
+ * `t` ([x, y] each), knowing the boxes of the two cards
29652
+ * ({x1, y1, x2, y2}) and the edge's lane.
29653
+ *
29654
+ * vertical the rows are stacked (top to bottom); false
29655
+ * when they are side by side (left to right)
29656
+ ************************************************************/
29657
+ function elbow_points(s, t, box_s, box_t, lane, vertical) {
29658
+ if (!vertical) return elbow_points(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true).map(swap);
29659
+ let shift = lane * 10;
29660
+ if (t[1] - s[1] > 20) {
29661
+ let mid = (s[1] + t[1]) / 2 + shift;
29662
+ mid = Math.min(Math.max(mid, s[1] + 10), t[1] - 10);
29663
+ return [[s[0], mid], [t[0], mid]];
29664
+ }
29665
+ let out = 16 + Math.abs(shift);
29666
+ let side = lane < 0 ? Math.min(box_s.x1, box_t.x1) - 24 - Math.abs(shift) : Math.max(box_s.x2, box_t.x2) + 24 + Math.abs(shift);
29667
+ let y1 = s[1] + out;
29668
+ let y2 = t[1] - out;
29669
+ return [
29670
+ [s[0], y1],
29671
+ [side, y1],
29672
+ [side, y2],
29673
+ [t[0], y2]
29674
+ ];
29675
+ }
29676
+ function box_expand(b, m) {
29677
+ return {
29678
+ x1: b.x1 - m,
29679
+ y1: b.y1 - m,
29680
+ x2: b.x2 + m,
29681
+ y2: b.y2 + m
29682
+ };
29683
+ }
29684
+ function box_overlaps(a, b) {
29685
+ return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
29686
+ }
29687
+ function point_inside(p, b) {
29688
+ return p[0] > b.x1 && p[0] < b.x2 && p[1] > b.y1 && p[1] < b.y2;
29689
+ }
29690
+ function segment_hits(a, c, b) {
29691
+ let x1 = Math.min(a[0], c[0]);
29692
+ let x2 = Math.max(a[0], c[0]);
29693
+ let y1 = Math.min(a[1], c[1]);
29694
+ let y2 = Math.max(a[1], c[1]);
29695
+ return x1 < b.x2 && x2 > b.x1 && y1 < b.y2 && y2 > b.y1;
29696
+ }
29697
+ /************************************************************
29698
+ * Does the polyline `points` (its ends included) cross any of
29699
+ * `boxes`? A card is a box shrunk by a pixel, so a line along
29700
+ * its border or leaving from a port on it is not a hit.
29701
+ ************************************************************/
29702
+ function elbow_path_hits(points, boxes) {
29703
+ for (let b of boxes) {
29704
+ let inner = box_expand(b, -1);
29705
+ for (let i = 0; i + 1 < points.length; i++) if (segment_hits(points[i], points[i + 1], inner)) return true;
29706
+ }
29707
+ return false;
29708
+ }
29709
+ function heap_push(h, item) {
29710
+ h.push(item);
29711
+ let i = h.length - 1;
29712
+ while (i > 0) {
29713
+ let p = i - 1 >> 1;
29714
+ if (h[p][0] <= h[i][0]) break;
29715
+ [h[p], h[i]] = [h[i], h[p]];
29716
+ i = p;
29717
+ }
29718
+ }
29719
+ function heap_pop(h) {
29720
+ let top = h[0];
29721
+ let last = h.pop();
29722
+ if (h.length) {
29723
+ h[0] = last;
29724
+ let i = 0;
29725
+ for (;;) {
29726
+ let l = 2 * i + 1;
29727
+ let r = l + 1;
29728
+ let m = i;
29729
+ if (l < h.length && h[l][0] < h[m][0]) m = l;
29730
+ if (r < h.length && h[r][0] < h[m][0]) m = r;
29731
+ if (m === i) break;
29732
+ [h[m], h[i]] = [h[i], h[m]];
29733
+ i = m;
29734
+ }
29735
+ }
29736
+ return top;
29737
+ }
29738
+ /************************************************************
29739
+ * The shortest orthogonal line from `a` to `z` that enters none
29740
+ * of `boxes` (already expanded by the clearance), a turn costing
29741
+ * ROUTE_BEND. The line leaves `a` going down and ends at `z`
29742
+ * going down, since after `z` it drops into a port.
29743
+ *
29744
+ * The grid is SPARSE: only the lines of the boxes' borders and
29745
+ * of the two ends, which is all an orthogonal shortest path ever
29746
+ * needs to turn on. Returns [a, ...turns, z], or null when every
29747
+ * way is closed.
29748
+ ************************************************************/
29749
+ function orth_search(a, z, boxes) {
29750
+ for (let b of boxes) if (point_inside(a, b) || point_inside(z, b)) return null;
29751
+ let uniq = (list) => [...new Set(list)].sort((p, q) => p - q);
29752
+ let xs = uniq([a[0], z[0]].concat(...boxes.map((b) => [b.x1, b.x2])));
29753
+ let ys = uniq([a[1], z[1]].concat(...boxes.map((b) => [b.y1, b.y2])));
29754
+ let W = xs.length;
29755
+ let H = ys.length;
29756
+ let free = (x, y) => !boxes.some((b) => point_inside([x, y], b));
29757
+ let ok = new Uint8Array(W * H);
29758
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) ok[j * W + i] = free(xs[i], ys[j]) ? 1 : 0;
29759
+ let step_right = new Uint8Array(W * H);
29760
+ let step_down = new Uint8Array(W * H);
29761
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
29762
+ let k = j * W + i;
29763
+ if (!ok[k]) continue;
29764
+ if (i + 1 < W && ok[k + 1] && free((xs[i] + xs[i + 1]) / 2, ys[j])) step_right[k] = 1;
29765
+ if (j + 1 < H && ok[k + W] && free(xs[i], (ys[j] + ys[j + 1]) / 2)) step_down[k] = 1;
29766
+ }
29767
+ let start = ys.indexOf(a[1]) * W + xs.indexOf(a[0]);
29768
+ let goal = ys.indexOf(z[1]) * W + xs.indexOf(z[0]);
29769
+ let best = new Float64Array(W * H * 4).fill(Infinity);
29770
+ let prev = new Int32Array(W * H * 4).fill(-1);
29771
+ let heap = [];
29772
+ best[start * 4 + 2] = 0;
29773
+ heap_push(heap, [0, start * 4 + 2]);
29774
+ let moves = (k) => {
29775
+ let i = k % W;
29776
+ let j = (k - i) / W;
29777
+ let out = [];
29778
+ if (step_right[k]) out.push([
29779
+ k + 1,
29780
+ 0,
29781
+ xs[i + 1] - xs[i]
29782
+ ]);
29783
+ if (i > 0 && step_right[k - 1]) out.push([
29784
+ k - 1,
29785
+ 1,
29786
+ xs[i] - xs[i - 1]
29787
+ ]);
29788
+ if (step_down[k]) out.push([
29789
+ k + W,
29790
+ 2,
29791
+ ys[j + 1] - ys[j]
29792
+ ]);
29793
+ if (j > 0 && step_down[k - W]) out.push([
29794
+ k - W,
29795
+ 3,
29796
+ ys[j] - ys[j - 1]
29797
+ ]);
29798
+ return out;
29799
+ };
29800
+ let found = -1;
29801
+ let found_cost = Infinity;
29802
+ while (heap.length) {
29803
+ let [cost, state] = heap_pop(heap);
29804
+ if (cost > best[state] || cost >= found_cost) continue;
29805
+ let k = state >> 2;
29806
+ let dir = state & 3;
29807
+ if (k === goal) {
29808
+ let total = cost + (dir === 2 ? 0 : 40);
29809
+ if (total < found_cost) {
29810
+ found_cost = total;
29811
+ found = state;
29812
+ }
29813
+ continue;
29814
+ }
29815
+ for (let [nk, nd, len] of moves(k)) {
29816
+ let c = cost + len + (nd === dir ? 0 : 40);
29817
+ let ns = nk * 4 + nd;
29818
+ if (c < best[ns]) {
29819
+ best[ns] = c;
29820
+ prev[ns] = state;
29821
+ heap_push(heap, [c, ns]);
29822
+ }
29823
+ }
29824
+ }
29825
+ if (found < 0) return null;
29826
+ let states = [];
29827
+ for (let s = found; s >= 0; s = prev[s]) states.push(s);
29828
+ states.reverse();
29829
+ let pts = [a];
29830
+ for (let n = 1; n < states.length; n++) if (n + 1 < states.length && (states[n + 1] & 3) !== (states[n] & 3)) {
29831
+ let k = states[n] >> 2;
29832
+ pts.push([xs[k % W], ys[Math.floor(k / W)]]);
29833
+ }
29834
+ pts.push(z);
29835
+ return pts;
29836
+ }
29837
+ /************************************************************
29838
+ * The control points of an elbow that does not cross any card.
29839
+ *
29840
+ * The simple elbow first (elbow_points): the channel, or the
29841
+ * detour round the two cards. Only when that line crosses a card
29842
+ * -- a third record standing in a detour's way, a row in the way
29843
+ * of an edge that drops several rows -- is it ROUTED: out of the
29844
+ * port, the shortest way along the gaps between the cards, in.
29845
+ * A parent and the row of children under it keep their bus.
29846
+ *
29847
+ * `boxes` are all the cards on screen, the two of the edge
29848
+ * included. The route keeps a clearance from every card, which
29849
+ * grows with the lane so that two routed edges of one pair do not
29850
+ * coincide. Nearby cards first; all of them if that finds no way;
29851
+ * the simple elbow if nothing does.
29852
+ ************************************************************/
29853
+ function elbow_route(s, t, box_s, box_t, lane, vertical, boxes) {
29854
+ let others = boxes || [];
29855
+ if (!vertical) return elbow_route(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true, others.map(swap_box)).map(swap);
29856
+ let simple = elbow_points(s, t, box_s, box_t, lane, true);
29857
+ if (!elbow_path_hits([
29858
+ s,
29859
+ ...simple,
29860
+ t
29861
+ ], others)) return simple;
29862
+ let m = 12 + Math.abs(lane) * 10;
29863
+ let a = [s[0], s[1] + m + 4];
29864
+ let z = [t[0], t[1] - m - 4];
29865
+ let region = box_expand({
29866
+ x1: Math.min(a[0], z[0], box_s.x1, box_t.x1),
29867
+ y1: Math.min(a[1], z[1], box_s.y1, box_t.y1),
29868
+ x2: Math.max(a[0], z[0], box_s.x2, box_t.x2),
29869
+ y2: Math.max(a[1], z[1], box_s.y2, box_t.y2)
29870
+ }, 400);
29871
+ let near = others.filter((b) => box_overlaps(b, region));
29872
+ let route = orth_search(a, z, near.map((b) => box_expand(b, m)));
29873
+ if (!route && near.length < others.length) route = orth_search(a, z, others.map((b) => box_expand(b, m)));
29874
+ return route || simple;
29875
+ }
29628
29876
  //#endregion
29629
29877
  //#region node_modules/@babel/runtime/helpers/esm/typeof.js
29630
29878
  function _typeof(o) {
@@ -59004,14 +59252,64 @@ var TreedbTreeLayout = class extends BaseLayout {
59004
59252
  *
59005
59253
  * Computed on every draw from where the two ends ARE, so a
59006
59254
  * dragged card, a fold or a layout needs nothing else. `-v` runs
59007
- * between rows (top to bottom), `-h` between columns.
59255
+ * between rows (top to bottom), `-h` between columns. An edge that
59256
+ * does not run forward goes round the two cards, the edges joining
59257
+ * the same two cards take lanes, and a line that would cross any
59258
+ * other card is routed round it (treedb_elbow.js).
59008
59259
  ************************************************************/
59009
59260
  var ELBOW_RADIUS = 6;
59261
+ function elbow_box(node) {
59262
+ try {
59263
+ let b = node.getShape("key").getBounds();
59264
+ return {
59265
+ x1: b.min[0],
59266
+ y1: b.min[1],
59267
+ x2: b.max[0],
59268
+ y2: b.max[1]
59269
+ };
59270
+ } catch (e) {
59271
+ log_error(`elbow edge: no bounds for node ${node && node.id}: ${e}`);
59272
+ let c = node.getCenter();
59273
+ return {
59274
+ x1: c[0],
59275
+ y1: c[1],
59276
+ x2: c[0],
59277
+ y2: c[1]
59278
+ };
59279
+ }
59280
+ }
59281
+ var _elbow_boxes = null;
59282
+ function elbow_obstacles(edge) {
59283
+ if (_elbow_boxes) return _elbow_boxes;
59284
+ let element = edge.context && edge.context.element;
59285
+ if (!element) return [];
59286
+ try {
59287
+ _elbow_boxes = element.getNodes().map((n) => elbow_box(n));
59288
+ } catch (e) {
59289
+ log_error(`elbow edge ${edge.id}: cannot read the cards: ${e}`);
59290
+ _elbow_boxes = [];
59291
+ }
59292
+ queueMicrotask(() => {
59293
+ _elbow_boxes = null;
59294
+ });
59295
+ return _elbow_boxes;
59296
+ }
59297
+ function elbow_lane_of(edge) {
59298
+ let graph = edge.context && edge.context.graph;
59299
+ let s = edge.sourceNode && edge.sourceNode.id;
59300
+ let t = edge.targetNode && edge.targetNode.id;
59301
+ if (!graph || s === void 0 || t === void 0 || s === t) return 0;
59302
+ try {
59303
+ return elbow_lane(graph.getRelatedEdgesData(s).filter((d) => d.source === s && d.target === t || d.source === t && d.target === s).map((d) => d.id), edge.id);
59304
+ } catch (e) {
59305
+ log_error(`elbow edge ${edge.id}: cannot read the edges of ${s}: ${e}`);
59306
+ return 0;
59307
+ }
59308
+ }
59010
59309
  var TreedbElbowV = class extends Polyline {
59011
59310
  getControlPoints(attributes) {
59012
59311
  let [s, t] = this.getEndpoints(attributes, false);
59013
- let mid = (s[1] + t[1]) / 2;
59014
- return [[s[0], mid], [t[0], mid]];
59312
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode), elbow_lane_of(this), true, elbow_obstacles(this));
59015
59313
  }
59016
59314
  getKeyPath(attributes) {
59017
59315
  return super.getKeyPath(Object.assign({}, attributes, { radius: attributes.radius || ELBOW_RADIUS }));
@@ -59020,8 +59318,7 @@ var TreedbElbowV = class extends Polyline {
59020
59318
  var TreedbElbowH = class extends Polyline {
59021
59319
  getControlPoints(attributes) {
59022
59320
  let [s, t] = this.getEndpoints(attributes, false);
59023
- let mid = (s[0] + t[0]) / 2;
59024
- return [[mid, s[1]], [mid, t[1]]];
59321
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode), elbow_lane_of(this), false, elbow_obstacles(this));
59025
59322
  }
59026
59323
  getKeyPath(attributes) {
59027
59324
  return super.getKeyPath(Object.assign({}, attributes, { radius: attributes.radius || ELBOW_RADIUS }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.149",
3
+ "version": "7.23.151",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -133,6 +133,7 @@ import {
133
133
  fold_root_group_key,
134
134
  } from "./treedb_fold_model.js";
135
135
  import {layout_tree, layout_radial, layout_compact} from "./treedb_layout.js";
136
+ import {elbow_lane, elbow_route} from "./treedb_elbow.js";
136
137
 
137
138
  import {
138
139
  BaseLayout,
@@ -4221,16 +4222,81 @@ class TreedbTreeLayout extends BaseLayout
4221
4222
  *
4222
4223
  * Computed on every draw from where the two ends ARE, so a
4223
4224
  * dragged card, a fold or a layout needs nothing else. `-v` runs
4224
- * between rows (top to bottom), `-h` between columns.
4225
+ * between rows (top to bottom), `-h` between columns. An edge that
4226
+ * does not run forward goes round the two cards, the edges joining
4227
+ * the same two cards take lanes, and a line that would cross any
4228
+ * other card is routed round it (treedb_elbow.js).
4225
4229
  ************************************************************/
4226
4230
  const ELBOW_RADIUS = 6;
4227
4231
 
4232
+ /* The box of a card on the canvas, for a detour to go round it. */
4233
+ function elbow_box(node)
4234
+ {
4235
+ try {
4236
+ let b = node.getShape('key').getBounds();
4237
+ return {x1: b.min[0], y1: b.min[1], x2: b.max[0], y2: b.max[1]};
4238
+ } catch(e) {
4239
+ log_error(`elbow edge: no bounds for node ${node && node.id}: ${e}`);
4240
+ let c = node.getCenter();
4241
+ return {x1: c[0], y1: c[1], x2: c[0], y2: c[1]};
4242
+ }
4243
+ }
4244
+
4245
+ /* Every card on screen, as boxes: what a routed elbow keeps clear
4246
+ * of. Read ONCE per draw -- G6 draws all its edges in one go, so
4247
+ * the list lives until the next microtask and every edge of that
4248
+ * draw shares it. */
4249
+ let _elbow_boxes = null;
4250
+
4251
+ function elbow_obstacles(edge)
4252
+ {
4253
+ if(_elbow_boxes) {
4254
+ return _elbow_boxes;
4255
+ }
4256
+ let element = edge.context && edge.context.element;
4257
+ if(!element) {
4258
+ return [];
4259
+ }
4260
+ try {
4261
+ _elbow_boxes = element.getNodes().map((n) => elbow_box(n));
4262
+ } catch(e) {
4263
+ log_error(`elbow edge ${edge.id}: cannot read the cards: ${e}`);
4264
+ _elbow_boxes = [];
4265
+ }
4266
+ queueMicrotask(() => {
4267
+ _elbow_boxes = null;
4268
+ });
4269
+ return _elbow_boxes;
4270
+ }
4271
+
4272
+ /* The lane of an edge among the edges joining the same two cards,
4273
+ * either way round (treedb_elbow.js). */
4274
+ function elbow_lane_of(edge)
4275
+ {
4276
+ let graph = edge.context && edge.context.graph;
4277
+ let s = edge.sourceNode && edge.sourceNode.id;
4278
+ let t = edge.targetNode && edge.targetNode.id;
4279
+ if(!graph || s === undefined || t === undefined || s === t) {
4280
+ return 0;
4281
+ }
4282
+ try {
4283
+ let ids = graph.getRelatedEdgesData(s)
4284
+ .filter((d) => (d.source === s && d.target === t) ||
4285
+ (d.source === t && d.target === s))
4286
+ .map((d) => d.id);
4287
+ return elbow_lane(ids, edge.id);
4288
+ } catch(e) {
4289
+ log_error(`elbow edge ${edge.id}: cannot read the edges of ${s}: ${e}`);
4290
+ return 0;
4291
+ }
4292
+ }
4293
+
4228
4294
  class TreedbElbowV extends Polyline
4229
4295
  {
4230
4296
  getControlPoints(attributes) {
4231
4297
  let [s, t] = this.getEndpoints(attributes, false);
4232
- let mid = (s[1] + t[1]) / 2;
4233
- return [[s[0], mid], [t[0], mid]];
4298
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode),
4299
+ elbow_lane_of(this), true, elbow_obstacles(this));
4234
4300
  }
4235
4301
 
4236
4302
  getKeyPath(attributes) {
@@ -4243,8 +4309,8 @@ class TreedbElbowH extends Polyline
4243
4309
  {
4244
4310
  getControlPoints(attributes) {
4245
4311
  let [s, t] = this.getEndpoints(attributes, false);
4246
- let mid = (s[0] + t[0]) / 2;
4247
- return [[mid, s[1]], [mid, t[1]]];
4312
+ return elbow_route(s, t, elbow_box(this.sourceNode), elbow_box(this.targetNode),
4313
+ elbow_lane_of(this), false, elbow_obstacles(this));
4248
4314
  }
4249
4315
 
4250
4316
  getKeyPath(attributes) {
@@ -0,0 +1,370 @@
1
+ /***********************************************************************
2
+ * treedb_elbow.js
3
+ *
4
+ * Where an elbow edge of the treedb graph turns, as plain
5
+ * geometry: the two elbow edge types of c_g6_nodes_tree.js ask
6
+ * this module for their control points on every draw, and it
7
+ * can be tested without G6.
8
+ *
9
+ * An elbow is mxGraph's tree routing: straight out of the
10
+ * parent's port to the channel half way to the child, along it,
11
+ * and straight into the child's port. Two cases are not that:
12
+ *
13
+ * - An edge that does not run FORWARD: the child above its
14
+ * parent, or in the same row. One of the two edges of a
15
+ * reciprocal pair (A -> B and B -> A) is always one. A channel
16
+ * half way between would cross both cards and lie on top of
17
+ * its partner, so it goes ROUND: a short run out of its port,
18
+ * along the outside of both cards, and into the other port
19
+ * from that port's own side.
20
+ *
21
+ * - Several edges joining the SAME two cards: a reciprocal pair,
22
+ * or two hooks of one parent reaching one child. Each gets a
23
+ * LANE: the first stays where it is, the next moves one step to
24
+ * one side, the next one step to the other. The lane follows
25
+ * the order the edges were made, so a new edge never moves the
26
+ * ones already drawn.
27
+ *
28
+ * Written for the top-down reading; left to right is the same
29
+ * geometry with the axes swapped.
30
+ *
31
+ * Copyright (c) 2026, ArtGins.
32
+ * All Rights Reserved.
33
+ ***********************************************************************/
34
+
35
+ export const ELBOW_STEP = 10; /* between two lanes */
36
+ export const ELBOW_JETTY = 16; /* straight run out of a port before a detour turns */
37
+ export const ELBOW_CLEAR = 24; /* how far outside the two cards a detour runs */
38
+
39
+ /************************************************************
40
+ * The lane of `id` among `ids`, the edges joining the same two
41
+ * cards: 0, then +1, -1, +2, -2... in the order they were made.
42
+ * Edge ids end in a counter, so a numeric sort is that order.
43
+ ************************************************************/
44
+ export function elbow_lane(ids, id)
45
+ {
46
+ let sorted = ids.slice().sort(
47
+ (a, b) => String(a).localeCompare(String(b), undefined, {numeric: true})
48
+ );
49
+ let k = sorted.indexOf(id);
50
+ if(k <= 0) {
51
+ return 0;
52
+ }
53
+ let side = (k % 2)? 1 : -1;
54
+ return side * Math.ceil(k / 2);
55
+ }
56
+
57
+ function swap(p)
58
+ {
59
+ return [p[1], p[0]];
60
+ }
61
+
62
+ function swap_box(b)
63
+ {
64
+ return {x1: b.y1, y1: b.x1, x2: b.y2, y2: b.x2};
65
+ }
66
+
67
+ /************************************************************
68
+ * The control points of an elbow from the port `s` to the port
69
+ * `t` ([x, y] each), knowing the boxes of the two cards
70
+ * ({x1, y1, x2, y2}) and the edge's lane.
71
+ *
72
+ * vertical the rows are stacked (top to bottom); false
73
+ * when they are side by side (left to right)
74
+ ************************************************************/
75
+ export function elbow_points(s, t, box_s, box_t, lane, vertical)
76
+ {
77
+ if(!vertical) {
78
+ return elbow_points(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true)
79
+ .map(swap);
80
+ }
81
+
82
+ let shift = lane * ELBOW_STEP;
83
+
84
+ /* Forward: the channel half way down, moved by the lane and
85
+ * kept strictly between the two ports. */
86
+ if(t[1] - s[1] > 2 * ELBOW_STEP) {
87
+ let mid = (s[1] + t[1]) / 2 + shift;
88
+ mid = Math.min(Math.max(mid, s[1] + ELBOW_STEP), t[1] - ELBOW_STEP);
89
+ return [[s[0], mid], [t[0], mid]];
90
+ }
91
+
92
+ /* Backwards, or across one row: round the outside. A negative
93
+ * lane goes round the left, any other the right, so the two
94
+ * detours of one pair never share a side. */
95
+ let out = ELBOW_JETTY + Math.abs(shift);
96
+ let side = (lane < 0)
97
+ ? Math.min(box_s.x1, box_t.x1) - ELBOW_CLEAR - Math.abs(shift)
98
+ : Math.max(box_s.x2, box_t.x2) + ELBOW_CLEAR + Math.abs(shift);
99
+ let y1 = s[1] + out; /* a hook port is on the bottom edge */
100
+ let y2 = t[1] - out; /* an fkey port is on the top edge */
101
+ return [[s[0], y1], [side, y1], [side, y2], [t[0], y2]];
102
+ }
103
+
104
+ /*----------------------------------------------------------------*
105
+ * Round the OTHER cards too
106
+ *----------------------------------------------------------------*/
107
+ export const ROUTE_REACH = 400; /* how far around the two cards the first search looks */
108
+ export const ROUTE_BEND = 40; /* a turn costs as much as this many pixels of line */
109
+
110
+ function box_expand(b, m)
111
+ {
112
+ return {x1: b.x1 - m, y1: b.y1 - m, x2: b.x2 + m, y2: b.y2 + m};
113
+ }
114
+
115
+ function box_overlaps(a, b)
116
+ {
117
+ return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
118
+ }
119
+
120
+ function point_inside(p, b)
121
+ {
122
+ return p[0] > b.x1 && p[0] < b.x2 && p[1] > b.y1 && p[1] < b.y2;
123
+ }
124
+
125
+ /* Does the axis-aligned segment a-c pass through the INSIDE of b?
126
+ * Running along its border does not count. */
127
+ function segment_hits(a, c, b)
128
+ {
129
+ let x1 = Math.min(a[0], c[0]);
130
+ let x2 = Math.max(a[0], c[0]);
131
+ let y1 = Math.min(a[1], c[1]);
132
+ let y2 = Math.max(a[1], c[1]);
133
+ return x1 < b.x2 && x2 > b.x1 && y1 < b.y2 && y2 > b.y1;
134
+ }
135
+
136
+ /************************************************************
137
+ * Does the polyline `points` (its ends included) cross any of
138
+ * `boxes`? A card is a box shrunk by a pixel, so a line along
139
+ * its border or leaving from a port on it is not a hit.
140
+ ************************************************************/
141
+ export function elbow_path_hits(points, boxes)
142
+ {
143
+ for(let b of boxes) {
144
+ let inner = box_expand(b, -1);
145
+ for(let i = 0; i + 1 < points.length; i++) {
146
+ if(segment_hits(points[i], points[i + 1], inner)) {
147
+ return true;
148
+ }
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+
154
+ /* A binary heap of [cost, state], smallest cost first. */
155
+ function heap_push(h, item)
156
+ {
157
+ h.push(item);
158
+ let i = h.length - 1;
159
+ while(i > 0) {
160
+ let p = (i - 1) >> 1;
161
+ if(h[p][0] <= h[i][0]) {
162
+ break;
163
+ }
164
+ [h[p], h[i]] = [h[i], h[p]];
165
+ i = p;
166
+ }
167
+ }
168
+
169
+ function heap_pop(h)
170
+ {
171
+ let top = h[0];
172
+ let last = h.pop();
173
+ if(h.length) {
174
+ h[0] = last;
175
+ let i = 0;
176
+ for(;;) {
177
+ let l = 2 * i + 1;
178
+ let r = l + 1;
179
+ let m = i;
180
+ if(l < h.length && h[l][0] < h[m][0]) {
181
+ m = l;
182
+ }
183
+ if(r < h.length && h[r][0] < h[m][0]) {
184
+ m = r;
185
+ }
186
+ if(m === i) {
187
+ break;
188
+ }
189
+ [h[m], h[i]] = [h[i], h[m]];
190
+ i = m;
191
+ }
192
+ }
193
+ return top;
194
+ }
195
+
196
+ /************************************************************
197
+ * The shortest orthogonal line from `a` to `z` that enters none
198
+ * of `boxes` (already expanded by the clearance), a turn costing
199
+ * ROUTE_BEND. The line leaves `a` going down and ends at `z`
200
+ * going down, since after `z` it drops into a port.
201
+ *
202
+ * The grid is SPARSE: only the lines of the boxes' borders and
203
+ * of the two ends, which is all an orthogonal shortest path ever
204
+ * needs to turn on. Returns [a, ...turns, z], or null when every
205
+ * way is closed.
206
+ ************************************************************/
207
+ function orth_search(a, z, boxes)
208
+ {
209
+ for(let b of boxes) {
210
+ if(point_inside(a, b) || point_inside(z, b)) {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ let uniq = (list) => [...new Set(list)].sort((p, q) => p - q);
216
+ let xs = uniq([a[0], z[0]].concat(...boxes.map((b) => [b.x1, b.x2])));
217
+ let ys = uniq([a[1], z[1]].concat(...boxes.map((b) => [b.y1, b.y2])));
218
+ let W = xs.length;
219
+ let H = ys.length;
220
+
221
+ let free = (x, y) => !boxes.some((b) => point_inside([x, y], b));
222
+ let ok = new Uint8Array(W * H);
223
+ for(let j = 0; j < H; j++) {
224
+ for(let i = 0; i < W; i++) {
225
+ ok[j * W + i] = free(xs[i], ys[j])? 1 : 0;
226
+ }
227
+ }
228
+ /* A step to the next line is open when both points are free and
229
+ * so is the middle of the step: consecutive lines have no border
230
+ * between them, so the middle speaks for the whole step. */
231
+ let step_right = new Uint8Array(W * H);
232
+ let step_down = new Uint8Array(W * H);
233
+ for(let j = 0; j < H; j++) {
234
+ for(let i = 0; i < W; i++) {
235
+ let k = j * W + i;
236
+ if(!ok[k]) {
237
+ continue;
238
+ }
239
+ if(i + 1 < W && ok[k + 1] && free((xs[i] + xs[i + 1]) / 2, ys[j])) {
240
+ step_right[k] = 1;
241
+ }
242
+ if(j + 1 < H && ok[k + W] && free(xs[i], (ys[j] + ys[j + 1]) / 2)) {
243
+ step_down[k] = 1;
244
+ }
245
+ }
246
+ }
247
+
248
+ /* States are (point, heading): 0 right, 1 left, 2 down, 3 up. */
249
+ let start = ys.indexOf(a[1]) * W + xs.indexOf(a[0]);
250
+ let goal = ys.indexOf(z[1]) * W + xs.indexOf(z[0]);
251
+ let best = new Float64Array(W * H * 4).fill(Infinity);
252
+ let prev = new Int32Array(W * H * 4).fill(-1);
253
+ let heap = [];
254
+ best[start * 4 + 2] = 0;
255
+ heap_push(heap, [0, start * 4 + 2]);
256
+
257
+ let moves = (k) => {
258
+ let i = k % W;
259
+ let j = (k - i) / W;
260
+ let out = [];
261
+ if(step_right[k]) {
262
+ out.push([k + 1, 0, xs[i + 1] - xs[i]]);
263
+ }
264
+ if(i > 0 && step_right[k - 1]) {
265
+ out.push([k - 1, 1, xs[i] - xs[i - 1]]);
266
+ }
267
+ if(step_down[k]) {
268
+ out.push([k + W, 2, ys[j + 1] - ys[j]]);
269
+ }
270
+ if(j > 0 && step_down[k - W]) {
271
+ out.push([k - W, 3, ys[j] - ys[j - 1]]);
272
+ }
273
+ return out;
274
+ };
275
+
276
+ let found = -1;
277
+ let found_cost = Infinity;
278
+ while(heap.length) {
279
+ let [cost, state] = heap_pop(heap);
280
+ if(cost > best[state] || cost >= found_cost) {
281
+ continue;
282
+ }
283
+ let k = state >> 2;
284
+ let dir = state & 3;
285
+ if(k === goal) {
286
+ let total = cost + ((dir === 2)? 0 : ROUTE_BEND);
287
+ if(total < found_cost) {
288
+ found_cost = total;
289
+ found = state;
290
+ }
291
+ continue;
292
+ }
293
+ for(let [nk, nd, len] of moves(k)) {
294
+ let c = cost + len + ((nd === dir)? 0 : ROUTE_BEND);
295
+ let ns = nk * 4 + nd;
296
+ if(c < best[ns]) {
297
+ best[ns] = c;
298
+ prev[ns] = state;
299
+ heap_push(heap, [c, ns]);
300
+ }
301
+ }
302
+ }
303
+ if(found < 0) {
304
+ return null;
305
+ }
306
+
307
+ /* Back from the goal, keeping only the points where it turns. */
308
+ let states = [];
309
+ for(let s = found; s >= 0; s = prev[s]) {
310
+ states.push(s);
311
+ }
312
+ states.reverse();
313
+ let pts = [a];
314
+ for(let n = 1; n < states.length; n++) {
315
+ let turn = (n + 1 < states.length) && ((states[n + 1] & 3) !== (states[n] & 3));
316
+ if(turn) {
317
+ let k = states[n] >> 2;
318
+ pts.push([xs[k % W], ys[Math.floor(k / W)]]);
319
+ }
320
+ }
321
+ pts.push(z);
322
+ return pts;
323
+ }
324
+
325
+ /************************************************************
326
+ * The control points of an elbow that does not cross any card.
327
+ *
328
+ * The simple elbow first (elbow_points): the channel, or the
329
+ * detour round the two cards. Only when that line crosses a card
330
+ * -- a third record standing in a detour's way, a row in the way
331
+ * of an edge that drops several rows -- is it ROUTED: out of the
332
+ * port, the shortest way along the gaps between the cards, in.
333
+ * A parent and the row of children under it keep their bus.
334
+ *
335
+ * `boxes` are all the cards on screen, the two of the edge
336
+ * included. The route keeps a clearance from every card, which
337
+ * grows with the lane so that two routed edges of one pair do not
338
+ * coincide. Nearby cards first; all of them if that finds no way;
339
+ * the simple elbow if nothing does.
340
+ ************************************************************/
341
+ export function elbow_route(s, t, box_s, box_t, lane, vertical, boxes)
342
+ {
343
+ let others = boxes || [];
344
+ if(!vertical) {
345
+ return elbow_route(swap(s), swap(t), swap_box(box_s), swap_box(box_t), lane, true,
346
+ others.map(swap_box)).map(swap);
347
+ }
348
+
349
+ let simple = elbow_points(s, t, box_s, box_t, lane, true);
350
+ if(!elbow_path_hits([s, ...simple, t], others)) {
351
+ return simple;
352
+ }
353
+
354
+ let m = ELBOW_CLEAR / 2 + Math.abs(lane) * ELBOW_STEP;
355
+ let a = [s[0], s[1] + m + 4]; /* out of a bottom port */
356
+ let z = [t[0], t[1] - m - 4]; /* into a top port */
357
+ let region = box_expand({
358
+ x1: Math.min(a[0], z[0], box_s.x1, box_t.x1),
359
+ y1: Math.min(a[1], z[1], box_s.y1, box_t.y1),
360
+ x2: Math.max(a[0], z[0], box_s.x2, box_t.x2),
361
+ y2: Math.max(a[1], z[1], box_s.y2, box_t.y2),
362
+ }, ROUTE_REACH);
363
+ let near = others.filter((b) => box_overlaps(b, region));
364
+
365
+ let route = orth_search(a, z, near.map((b) => box_expand(b, m)));
366
+ if(!route && near.length < others.length) {
367
+ route = orth_search(a, z, others.map((b) => box_expand(b, m)));
368
+ }
369
+ return route || simple;
370
+ }
@@ -0,0 +1,182 @@
1
+ /***********************************************************************
2
+ * treedb_elbow.test.js
3
+ *
4
+ * The elbow edges of the treedb graph, pinned: the lane of an
5
+ * edge among those joining the same two cards, the channel of a
6
+ * forward edge, the detour of one that is not.
7
+ ***********************************************************************/
8
+ import { describe, test, expect } from "vitest";
9
+ import {
10
+ elbow_lane, elbow_points, elbow_route, elbow_path_hits, ELBOW_STEP, ELBOW_CLEAR,
11
+ } from "./treedb_elbow.js";
12
+
13
+ /* Two cards 100x40: A on top, B one row below it. */
14
+ const A = {x1: 0, y1: 0, x2: 100, y2: 40};
15
+ const B = {x1: 0, y1: 130, x2: 100, y2: 170};
16
+ const A_HOOK = [50, 40]; /* A's bottom edge */
17
+ const A_FKEY = [50, 0]; /* A's top edge */
18
+ const B_HOOK = [60, 170];
19
+ const B_FKEY = [50, 130];
20
+
21
+ describe("the lane", () => {
22
+ test("one edge alone keeps the middle", () => {
23
+ expect(elbow_lane(["edge-t-7"], "edge-t-7")).toBe(0);
24
+ });
25
+
26
+ test("lanes follow the order the edges were made, not the string order", () => {
27
+ let ids = ["edge-t-10", "edge-t-9", "edge-t-11"];
28
+ expect(elbow_lane(ids, "edge-t-9")).toBe(0);
29
+ expect(elbow_lane(ids, "edge-t-10")).toBe(1);
30
+ expect(elbow_lane(ids, "edge-t-11")).toBe(-1);
31
+ });
32
+
33
+ test("a new edge does not move the ones already drawn", () => {
34
+ let before = ["edge-t-1", "edge-t-2"];
35
+ let after = before.concat(["edge-t-3"]);
36
+ for(let id of before) {
37
+ expect(elbow_lane(after, id)).toBe(elbow_lane(before, id));
38
+ }
39
+ });
40
+ });
41
+
42
+ describe("a forward edge", () => {
43
+ test("turns in the channel half way between the ports", () => {
44
+ let pts = elbow_points(A_HOOK, B_FKEY, A, B, 0, true);
45
+ expect(pts).toEqual([[50, 85], [50, 85]]);
46
+ });
47
+
48
+ test("its lane moves the channel, and keeps it between the ports", () => {
49
+ let pts = elbow_points(A_HOOK, B_FKEY, A, B, 1, true);
50
+ expect(pts[0][1]).toBe(85 + ELBOW_STEP);
51
+ let far = elbow_points(A_HOOK, B_FKEY, A, B, 20, true);
52
+ expect(far[0][1]).toBeLessThan(B_FKEY[1]);
53
+ expect(far[0][1]).toBeGreaterThan(A_HOOK[1]);
54
+ });
55
+ });
56
+
57
+ describe("an edge that does not run forward", () => {
58
+ test("goes round the outside of both cards", () => {
59
+ /* B -> A: out of B's bottom, back up into A's top. */
60
+ let pts = elbow_points(B_HOOK, A_FKEY, B, A, 0, true);
61
+ expect(pts.length).toBe(4);
62
+ let side = pts[1][0];
63
+ expect(side).toBeGreaterThanOrEqual(100 + ELBOW_CLEAR);
64
+ expect(pts[2][0]).toBe(side);
65
+ expect(pts[0][1]).toBeGreaterThan(B.y2); /* below B */
66
+ expect(pts[3][1]).toBeLessThan(A.y1); /* above A */
67
+ });
68
+
69
+ test("a reciprocal pair shares no segment", () => {
70
+ let forward = elbow_points(A_HOOK, B_FKEY, A, B, 0, true);
71
+ let back = elbow_points(B_HOOK, A_FKEY, B, A, 1, true);
72
+ let channel = forward[0][1];
73
+ expect(back.every((p) => p[1] !== channel)).toBe(true);
74
+ expect(back[1][0]).toBeGreaterThan(A.x2);
75
+ });
76
+
77
+ test("a negative lane goes round the other side", () => {
78
+ let right = elbow_points(B_HOOK, A_FKEY, B, A, 1, true);
79
+ let left = elbow_points(B_HOOK, A_FKEY, B, A, -1, true);
80
+ expect(right[1][0]).toBeGreaterThan(A.x2);
81
+ expect(left[1][0]).toBeLessThan(A.x1);
82
+ });
83
+
84
+ test("two cards in one row are not joined through them", () => {
85
+ /* C beside A, same row: A's hook (bottom) to C's fkey (top). */
86
+ let C = {x1: 200, y1: 0, x2: 300, y2: 40};
87
+ let pts = elbow_points(A_HOOK, [250, 0], A, C, 0, true);
88
+ expect(pts.length).toBe(4);
89
+ expect(pts[1][0]).toBeGreaterThan(C.x2);
90
+ });
91
+ });
92
+
93
+ describe("the route round the other cards", () => {
94
+ test("a line that crosses no card is left as it is", () => {
95
+ let others = [A, B, {x1: 400, y1: 0, x2: 500, y2: 40}];
96
+ expect(elbow_route(A_HOOK, B_FKEY, A, B, 0, true, others))
97
+ .toEqual(elbow_points(A_HOOK, B_FKEY, A, B, 0, true));
98
+ });
99
+
100
+ test("a detour with a third card in its way goes round that one too", () => {
101
+ /* D stands right where the detour of B -> A runs, beside both. */
102
+ let D = {x1: 110, y1: -20, x2: 210, y2: 200};
103
+ let boxes = [A, B, D];
104
+ let simple = elbow_points(B_HOOK, A_FKEY, B, A, 0, true);
105
+ expect(elbow_path_hits([B_HOOK, ...simple, A_FKEY], boxes)).toBe(true);
106
+ let route = elbow_route(B_HOOK, A_FKEY, B, A, 0, true, boxes);
107
+ expect(elbow_path_hits([B_HOOK, ...route, A_FKEY], boxes)).toBe(false);
108
+ expect(route).not.toEqual(simple);
109
+ });
110
+
111
+ test("an edge dropping several rows goes round the row in between", () => {
112
+ /* P on top, M in the row between, C two rows down. */
113
+ let P = {x1: 0, y1: 0, x2: 100, y2: 40};
114
+ let M = {x1: -40, y1: 130, x2: 140, y2: 170};
115
+ let C = {x1: 0, y1: 260, x2: 100, y2: 300};
116
+ let boxes = [P, M, C];
117
+ let route = elbow_route([50, 40], [50, 260], P, C, 0, true, boxes);
118
+ expect(elbow_path_hits([[50, 40], ...route, [50, 260]], boxes)).toBe(false);
119
+ });
120
+
121
+ test("when every way is closed it keeps the simple elbow", () => {
122
+ let wall = {x1: -1000, y1: 60, x2: 1000, y2: 100};
123
+ let route = elbow_route(A_HOOK, B_FKEY, A, B, 0, true, [A, B, wall]);
124
+ expect(route).toEqual(elbow_points(A_HOOK, B_FKEY, A, B, 0, true));
125
+ });
126
+
127
+ test("two routed edges of one pair keep apart", () => {
128
+ let D = {x1: 110, y1: -20, x2: 210, y2: 200};
129
+ let boxes = [A, B, D];
130
+ let r1 = elbow_route(B_HOOK, A_FKEY, B, A, 1, true, boxes);
131
+ let r2 = elbow_route(B_HOOK, A_FKEY, B, A, -1, true, boxes);
132
+ expect(elbow_path_hits([B_HOOK, ...r1, A_FKEY], boxes)).toBe(false);
133
+ expect(elbow_path_hits([B_HOOK, ...r2, A_FKEY], boxes)).toBe(false);
134
+ expect(r1).not.toEqual(r2);
135
+ });
136
+
137
+ test("read left to right, the route is the transpose", () => {
138
+ let sw = (p) => [p[1], p[0]];
139
+ let swb = (b) => ({x1: b.y1, y1: b.x1, x2: b.y2, y2: b.x2});
140
+ let D = {x1: 110, y1: -20, x2: 210, y2: 200};
141
+ let down = elbow_route(B_HOOK, A_FKEY, B, A, 0, true, [A, B, D]);
142
+ let right = elbow_route(sw(B_HOOK), sw(A_FKEY), swb(B), swb(A), 0, false,
143
+ [A, B, D].map(swb));
144
+ expect(right).toEqual(down.map(sw));
145
+ });
146
+
147
+ test("a screen of eighty cards is routed in a few milliseconds", () => {
148
+ let boxes = [];
149
+ for(let r = 0; r < 4; r++) {
150
+ for(let c = 0; c < 20; c++) {
151
+ boxes.push({x1: c * 190, y1: r * 186, x2: c * 190 + 172, y2: r * 186 + 96});
152
+ }
153
+ }
154
+ /* From a card of the bottom row back up to one of the top row,
155
+ * across the whole screen. */
156
+ let src = boxes[3 * 20 + 18];
157
+ let dst = boxes[2];
158
+ let s = [(src.x1 + src.x2) / 2, src.y2];
159
+ let t = [(dst.x1 + dst.x2) / 2, dst.y1];
160
+ let t0 = performance.now();
161
+ let route = elbow_route(s, t, src, dst, 0, true, boxes);
162
+ let ms = performance.now() - t0;
163
+ expect(elbow_path_hits([s, ...route, t], boxes)).toBe(false);
164
+ expect(ms).toBeLessThan(200);
165
+ });
166
+ });
167
+
168
+ describe("read left to right", () => {
169
+ test("is the same geometry with the axes swapped", () => {
170
+ let sw = (p) => [p[1], p[0]];
171
+ let swb = (b) => ({x1: b.y1, y1: b.x1, x2: b.y2, y2: b.x2});
172
+ for(let [s, t, bs, bt, lane] of [
173
+ [A_HOOK, B_FKEY, A, B, 0],
174
+ [A_HOOK, B_FKEY, A, B, 1],
175
+ [B_HOOK, A_FKEY, B, A, -1],
176
+ ]) {
177
+ let down = elbow_points(s, t, bs, bt, lane, true);
178
+ let right = elbow_points(sw(s), sw(t), swb(bs), swb(bt), lane, false);
179
+ expect(right).toEqual(down.map(sw));
180
+ }
181
+ });
182
+ });