@yuneta/gobj-ui 7.23.154 → 7.23.156

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.
@@ -40,6 +40,10 @@
40
40
  * channel, so its edge runs down the corridor in a lane of
41
41
  * its own -- a COMB -- and turns into the gap above its card.
42
42
  *
43
+ * And for CURVED edges (curve_hits, rounded_path): a curve
44
+ * that would cross another card follows the same way an elbow
45
+ * would, with round corners, so it reads as a curve.
46
+ *
43
47
  * Written for the top-down reading; left to right is the same
44
48
  * geometry with the axes swapped.
45
49
  *
@@ -609,3 +613,112 @@ export function elbow_route(s, t, box_s, box_t, lane, vertical, boxes, stagger)
609
613
  }
610
614
  return route || simple;
611
615
  }
616
+
617
+ /*----------------------------------------------------------------*
618
+ * CURVED edges that would cross a card
619
+ *----------------------------------------------------------------*/
620
+ export const CURVE_RADIUS = 24; /* the corners of a curve that goes round */
621
+ export const CURVE_STEPS = 16; /* samples of each curved stretch */
622
+
623
+ /* Points along a G6 path: the ends of its runs and CURVE_STEPS
624
+ * samples of each quadratic or cubic stretch. */
625
+ function path_points(path)
626
+ {
627
+ let pts = [];
628
+ let cur = null;
629
+ for(let c of path) {
630
+ if(c[0] === 'M' || c[0] === 'L') {
631
+ cur = [c[1], c[2]];
632
+ pts.push(cur);
633
+ } else if(c[0] === 'C' && cur) {
634
+ let [x0, y0] = cur;
635
+ for(let k = 1; k <= CURVE_STEPS; k++) {
636
+ let u = k / CURVE_STEPS;
637
+ let v = 1 - u;
638
+ pts.push([
639
+ v * v * v * x0 + 3 * v * v * u * c[1] + 3 * v * u * u * c[3] + u * u * u * c[5],
640
+ v * v * v * y0 + 3 * v * v * u * c[2] + 3 * v * u * u * c[4] + u * u * u * c[6],
641
+ ]);
642
+ }
643
+ cur = [c[5], c[6]];
644
+ } else if(c[0] === 'Q' && cur) {
645
+ let [x0, y0] = cur;
646
+ for(let k = 1; k <= CURVE_STEPS; k++) {
647
+ let u = k / CURVE_STEPS;
648
+ let v = 1 - u;
649
+ pts.push([
650
+ v * v * x0 + 2 * v * u * c[1] + u * u * c[3],
651
+ v * v * y0 + 2 * v * u * c[2] + u * u * c[4],
652
+ ]);
653
+ }
654
+ cur = [c[3], c[4]];
655
+ }
656
+ }
657
+ return pts;
658
+ }
659
+
660
+ /************************************************************
661
+ * Does the G6 path `path` of a curved edge cross a card other than
662
+ * its own two? Measured on the curve itself -- the path G6 would
663
+ * draw, sampled -- and not on the rectangle round it, which took
664
+ * every card the curve merely passed near: the ports of a row
665
+ * stick well into the space above it. Its own two cards are left
666
+ * out: the curve starts and ends on their ports.
667
+ ************************************************************/
668
+ export function curve_hits(path, boxes, box_s, box_t)
669
+ {
670
+ let pts = path_points(path);
671
+ if(pts.length < 2) {
672
+ return false;
673
+ }
674
+ let bb = {x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity};
675
+ for(let p of pts) {
676
+ bb.x1 = Math.min(bb.x1, p[0]);
677
+ bb.y1 = Math.min(bb.y1, p[1]);
678
+ bb.x2 = Math.max(bb.x2, p[0]);
679
+ bb.y2 = Math.max(bb.y2, p[1]);
680
+ }
681
+ for(let b of (boxes || [])) {
682
+ if(same_box(b, box_s) || same_box(b, box_t) || !box_overlaps(b, box_expand(bb, 1))) {
683
+ continue;
684
+ }
685
+ let inner = box_expand(b, -1);
686
+ for(let k = 0; k + 1 < pts.length; k++) {
687
+ if(segment_hits(pts[k], pts[k + 1], inner)) {
688
+ return true;
689
+ }
690
+ }
691
+ }
692
+ return false;
693
+ }
694
+
695
+ /************************************************************
696
+ * A G6 path (['M'|'L'|'Q', ...]) along `points` with every corner
697
+ * rounded -- by `radius`, or by half the shorter of its two
698
+ * segments, so a short run is never overshot. The rounding is the
699
+ * quadratic G6's own polyline uses.
700
+ ************************************************************/
701
+ export function rounded_path(points, radius)
702
+ {
703
+ let path = [['M', points[0][0], points[0][1]]];
704
+ for(let i = 1; i + 1 < points.length; i++) {
705
+ let a = points[i - 1];
706
+ let m = points[i];
707
+ let b = points[i + 1];
708
+ let len_in = Math.hypot(m[0] - a[0], m[1] - a[1]);
709
+ let len_out = Math.hypot(b[0] - m[0], b[1] - m[1]);
710
+ let r = Math.min(radius, len_in / 2, len_out / 2);
711
+ let collinear = Math.abs((m[0] - a[0]) * (b[1] - m[1]) - (m[1] - a[1]) * (b[0] - m[0])) < 1e-9;
712
+ if(r <= 0 || collinear) {
713
+ path.push(['L', m[0], m[1]]);
714
+ continue;
715
+ }
716
+ let p1 = [m[0] - (m[0] - a[0]) / len_in * r, m[1] - (m[1] - a[1]) / len_in * r];
717
+ let p2 = [m[0] + (b[0] - m[0]) / len_out * r, m[1] + (b[1] - m[1]) / len_out * r];
718
+ path.push(['L', p1[0], p1[1]]);
719
+ path.push(['Q', m[0], m[1], p2[0], p2[1]]);
720
+ }
721
+ let last = points[points.length - 1];
722
+ path.push(['L', last[0], last[1]]);
723
+ return path;
724
+ }
@@ -8,7 +8,7 @@
8
8
  import { describe, test, expect } from "vitest";
9
9
  import {
10
10
  elbow_lane, elbow_points, elbow_route, elbow_path_hits, elbow_rank, elbow_combs,
11
- ELBOW_STEP, ELBOW_CLEAR,
11
+ curve_hits, rounded_path, ELBOW_STEP, ELBOW_CLEAR,
12
12
  } from "./treedb_elbow.js";
13
13
  import { layout_compact } from "./treedb_layout.js";
14
14
 
@@ -356,6 +356,50 @@ describe("the comb of a stack", () => {
356
356
  });
357
357
  });
358
358
 
359
+ describe("a curve that would cross a card", () => {
360
+ /* Whole-node boxes (ports 7px out of top and bottom). */
361
+ const PB = {x1: 0, y1: -7, x2: 172, y2: 103}; /* a parent */
362
+ const R0 = {x1: 0, y1: 183, x2: 116, y2: 237}; /* row 0 of a stack */
363
+ const R1 = {x1: 0, y1: 277, x2: 116, y2: 331}; /* row 1, under it */
364
+ const SIB = {x1: 200, y1: -7, x2: 372, y2: 103}; /* the parent's sibling */
365
+ const S = [86, 96];
366
+
367
+ /* The cubic G6 draws between two vertical ports: tangents along
368
+ * the line, as its default curveOffset gives. */
369
+ const cubic = (s, t) => [["M", s[0], s[1]],
370
+ ["C", s[0], (s[1] + t[1]) / 2, t[0], (s[1] + t[1]) / 2, t[0], t[1]]];
371
+
372
+ test("a curve into a card of the next row crosses nothing", () => {
373
+ expect(curve_hits(cubic(S, [58, 190]), [PB, R0, R1, SIB], PB, R0)).toBe(false);
374
+ });
375
+
376
+ test("a curve into a lower row of a stack crosses the card above it", () => {
377
+ expect(curve_hits(cubic(S, [58, 284]), [PB, R0, R1, SIB], PB, R1)).toBe(true);
378
+ });
379
+
380
+ test("a curve that passes NEAR a card, beside its ports, does not count", () => {
381
+ /* N0 in the target row with its port poking 7px up, beside
382
+ * where the curve lands; the rectangle round the curve took it. */
383
+ const T0 = {x1: 250, y1: 183, x2: 366, y2: 237};
384
+ const N0 = {x1: 120, y1: 183, x2: 236, y2: 237};
385
+ expect(curve_hits(cubic(S, [308, 190]), [PB, SIB, T0, N0], PB, T0)).toBe(false);
386
+ });
387
+
388
+ test("the rounded path: a straight line is one run", () => {
389
+ expect(rounded_path([[0, 0], [0, 100]], 24)).toEqual([["M", 0, 0], ["L", 0, 100]]);
390
+ });
391
+
392
+ test("the rounded path: each corner a quadratic, never past half a short run", () => {
393
+ let path = rounded_path([[0, 0], [0, 60], [100, 60], [100, 70]], 24);
394
+ expect(path[0]).toEqual(["M", 0, 0]);
395
+ expect(path[1]).toEqual(["L", 0, 36]); /* 24 before the corner */
396
+ expect(path[2]).toEqual(["Q", 0, 60, 24, 60]);
397
+ expect(path[3]).toEqual(["L", 95, 60]); /* the last run is 10: r = 5 */
398
+ expect(path[4]).toEqual(["Q", 100, 60, 100, 65]);
399
+ expect(path[5]).toEqual(["L", 100, 70]);
400
+ });
401
+ });
402
+
359
403
  describe("the ports are obstacles too", () => {
360
404
  /* Whole-node boxes: a port of 14px sticks 7px out of the bottom
361
405
  * and the top of each card. */
@@ -426,6 +426,63 @@ export function stack_trunk_width(k)
426
426
  2 * STACK_LANE_PAD + Math.max(0, lanes - 1) * STACK_LANE_STEP);
427
427
  }
428
428
 
429
+ /************************************************************
430
+ * The children of every node, with each run of STACK_MIN_RUN or
431
+ * more consecutive LEAF children replaced by one STACK. Runs, not
432
+ * all the leaves of a parent: the children keep their order. A
433
+ * stack's key is an OBJECT, so it can never be taken for a node id,
434
+ * which is a string. Shared by the compact tree and the radial.
435
+ *
436
+ * Returns {kids_of: Map<id, [id | stack]>, stacks: Map<stack, {ids, depth}>}
437
+ ************************************************************/
438
+ function leaf_runs(t)
439
+ {
440
+ let kids_of = new Map();
441
+ let stacks = new Map();
442
+ for(let [id, kids] of t.children) {
443
+ let out = [];
444
+ let run = [];
445
+ let flush = () => {
446
+ if(run.length >= STACK_MIN_RUN) {
447
+ let key = {stack: stacks.size};
448
+ stacks.set(key, {ids: run, depth: t.depth.get(run[0])});
449
+ out.push(key);
450
+ } else {
451
+ out.push(...run);
452
+ }
453
+ run = [];
454
+ };
455
+ for(let kid of kids) {
456
+ if((t.children.get(kid) || []).length === 0) {
457
+ run.push(kid);
458
+ } else {
459
+ flush();
460
+ out.push(kid);
461
+ }
462
+ }
463
+ flush();
464
+ kids_of.set(id, out);
465
+ }
466
+ return {kids_of: kids_of, stacks: stacks};
467
+ }
468
+
469
+ /* Parents before children, a stack being one child (walk_order's
470
+ * form, over `kids_of`). */
471
+ function stacked_order(t, kids_of)
472
+ {
473
+ let order = [];
474
+ let todo = t.roots.slice().reverse();
475
+ while(todo.length) {
476
+ let id = todo.pop();
477
+ order.push(id);
478
+ let kids = kids_of.get(id) || [];
479
+ for(let i = kids.length - 1; i >= 0; i--) {
480
+ todo.push(kids[i]);
481
+ }
482
+ }
483
+ return order;
484
+ }
485
+
429
486
  /* A stack in the LR frame (depth along x, siblings along y): `side`
430
487
  * is how far each column reaches across, `starts`/`row_len` where
431
488
  * each row begins and how long it is along the depth. Its first row
@@ -479,50 +536,11 @@ function compact_tree_lr(nodes, edges, o)
479
536
  let t = spanning_tree(nodes, edges);
480
537
  let pos = new Map();
481
538
 
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
- }
539
+ /* Runs of leaf children become STACKS (leaf_runs); the contour
540
+ * packing below takes a stack as one leaf of its own length, so
541
+ * nothing is packed under it. */
542
+ let {kids_of, stacks} = leaf_runs(t);
543
+ let order = stacked_order(t, kids_of);
526
544
 
527
545
  /* Columns, as in the tidy tree: a depth is as wide as its widest
528
546
  * card, and a column's gap is `ranksep`. */
@@ -693,76 +711,148 @@ function extent_at(n, a)
693
711
  *
694
712
  * ranksep the step between rings
695
713
  * nodesep the gap between neighbours on a ring
714
+ * stack (default true) a run of three or more LEAF children
715
+ * is a STACK, as in the compact tree: it asks its
716
+ * ring for one row of about the square root of its
717
+ * cards, not for every leaf -- a hall with a hundred
718
+ * devices no longer blows the ring up -- and lays
719
+ * them in rows going OUTWARD, each row as full as
720
+ * the room it has: rings round the parent when it is
721
+ * alone, a fan inside its sector when it is not.
722
+ * false keeps every leaf on the ring.
696
723
  ************************************************************/
697
724
  export function layout_radial(nodes, edges, opts)
698
725
  {
699
- let o = Object.assign({}, DEFAULTS, opts || {});
726
+ let o = Object.assign({}, DEFAULTS, {stack: true}, opts || {});
700
727
  let t = spanning_tree(nodes, edges);
701
728
  let pos = new Map();
702
729
  if(!t.roots.length) {
703
730
  return pos;
704
731
  }
705
732
 
706
- /* Leaves under each node: the sector it deserves. Post-order. */
707
- let order = walk_order(t);
708
- let leaves = new Map();
733
+ let runs = o.stack? leaf_runs(t) : {kids_of: t.children, stacks: new Map()};
734
+ let kids_of = runs.kids_of;
735
+ let stacks = runs.stacks;
736
+ let order = stacked_order(t, kids_of);
737
+
738
+ /* A stack asks its ring for one row of about the square root of
739
+ * its cards; how many each row really holds is decided once the
740
+ * ring's radius is known (pass 2), by the room it has there. */
741
+ for(let st of stacks.values()) {
742
+ st.per_row = Math.ceil(Math.sqrt(st.ids.length));
743
+ st.cards = st.ids.map((id) => t.by_id.get(id));
744
+ }
745
+ /* A stack across its ring, at angle `a`: a card's width there, and
746
+ * the row it asks for. Along the radius: how far one row reaches. */
747
+ let stack_across = (st, a) => {
748
+ let w = 0;
749
+ for(let c of st.cards) {
750
+ w = Math.max(w, extent_at(c, a + Math.PI / 2));
751
+ }
752
+ return {card: w, row: st.per_row * w + (st.per_row - 1) * o.nodesep};
753
+ };
754
+ let stack_along = (st, a) => {
755
+ let e = 0;
756
+ for(let c of st.cards) {
757
+ e = Math.max(e, extent_at(c, a));
758
+ }
759
+ return e;
760
+ };
761
+ /* The widest a set of cards is along the direction `th`, over
762
+ * the arc a-half..a+half: a stack's cards stand anywhere in its
763
+ * sector, and a card is wider slanted than square. */
764
+ let widest_along = (cards, a, half) => {
765
+ let e = 0;
766
+ for(let k = 0; k <= 16; k++) {
767
+ let th = a - half + 2 * half * k / 16;
768
+ for(let c of cards) {
769
+ e = Math.max(e, extent_at(c, th));
770
+ }
771
+ }
772
+ return e;
773
+ };
774
+ let depth_of = (id) => stacks.has(id)? stacks.get(id).depth : t.depth.get(id);
775
+ let circ = (a, b) => {
776
+ let d = Math.abs(a - b) % (2 * Math.PI);
777
+ return (d > Math.PI)? 2 * Math.PI - d : d;
778
+ };
779
+
780
+ /* What each node needs across its ring, the sector it deserves: a
781
+ * card is one, a stack is one ROW of cards, a parent the sum of
782
+ * its children. Post-order. */
783
+ let weight = new Map();
709
784
  for(let i = order.length - 1; i >= 0; i--) {
710
785
  let id = order[i];
711
- let kids = t.children.get(id) || [];
786
+ let st = stacks.get(id);
787
+ if(st) {
788
+ weight.set(id, st.per_row);
789
+ continue;
790
+ }
791
+ let kids = kids_of.get(id) || [];
712
792
  if(!kids.length) {
713
- leaves.set(id, 1);
793
+ weight.set(id, 1);
714
794
  continue;
715
795
  }
716
796
  let n = 0;
717
797
  for(let kid of kids) {
718
- n += leaves.get(kid);
798
+ n += weight.get(kid);
719
799
  }
720
- leaves.set(id, n);
800
+ weight.set(id, n);
721
801
  }
722
802
  let total = 0;
723
803
  for(let root of t.roots) {
724
- total += leaves.get(root);
804
+ total += weight.get(root);
725
805
  }
726
806
 
727
807
  /* One root sits at the centre; several sit on the first ring
728
808
  * around an empty centre, so every depth shifts out by one. */
729
809
  let single = (t.roots.length === 1);
730
- let ring_of = (id) => t.depth.get(id) + (single? 0 : 1);
810
+ let ring_of = (id) => depth_of(id) + (single? 0 : 1);
731
811
 
732
812
  /* Pass 1, the ANGLES: a node's children split its sector by
733
- * their leaves, and a node sits at the middle of its sector.
813
+ * their weight, and a node sits at the middle of its sector.
734
814
  * Angles do not depend on the radii, so they come first. */
735
815
  let angle = new Map();
736
- let rings = []; /* ring -> [{id, angle, diag}] */
816
+ let rings = []; /* ring -> [{id, angle, span, diag, ext, parent_ext}] */
737
817
  /* An explicit stack, not recursion: the sectors of a ring are
738
818
  * sorted by angle in pass 2, so the order they are pushed in does
739
819
  * not matter -- only that every node gets its own. */
740
820
  let sector = (root_id, root_from, root_span, root_parent) => {
741
- let stack = [{id: root_id, from: root_from, span: root_span, parent: root_parent}];
742
- while(stack.length) {
743
- let {id, from, span, parent} = stack.pop();
821
+ let todo = [{id: root_id, from: root_from, span: root_span, parent: root_parent}];
822
+ while(todo.length) {
823
+ let {id, from, span, parent} = todo.pop();
744
824
  let a = from + span / 2;
745
825
  angle.set(id, a);
746
826
  let ring = ring_of(id);
747
827
  if(!rings[ring]) {
748
828
  rings[ring] = [];
749
829
  }
750
- let n = t.by_id.get(id);
830
+ let st = stacks.get(id);
831
+ let n = st? null : t.by_id.get(id);
751
832
  rings[ring].push({
752
833
  id: id,
753
834
  angle: a,
754
- diag: Math.hypot(n.w, n.h),
835
+ span: span,
836
+ /* How much it needs along the ring: a card's diagonal,
837
+ * a stack's first row. */
838
+ diag: st? stack_across(st, a).row : Math.hypot(n.w, n.h),
755
839
  /* How much of the card lies along the RADIUS at this
756
840
  * angle -- the projection of the rectangle on that
757
841
  * direction -- for itself and for the node it hangs
758
842
  * from, which is the one it has to clear. */
759
- ext: extent_at(n, a),
760
- parent_ext: parent? extent_at(t.by_id.get(parent), a) : 0,
843
+ /* A stack is measured, and measures its parent, over
844
+ * the whole sector it can cover: round a parent its
845
+ * first row stands at every angle. */
846
+ ext: st? widest_along(st.cards, a, Math.min(span / 2, Math.PI))
847
+ : extent_at(n, a),
848
+ parent_ext: !parent? 0
849
+ : (st? widest_along([t.by_id.get(parent)], a, Math.min(span / 2, Math.PI))
850
+ : extent_at(t.by_id.get(parent), a)),
761
851
  });
762
852
  let cursor = from;
763
- for(let kid of (t.children.get(id) || [])) {
764
- let part = span * leaves.get(kid) / leaves.get(id);
765
- stack.push({id: kid, from: cursor, span: part, parent: id});
853
+ for(let kid of (kids_of.get(id) || [])) {
854
+ let part = span * weight.get(kid) / weight.get(id);
855
+ todo.push({id: kid, from: cursor, span: part, parent: id});
766
856
  cursor += part;
767
857
  }
768
858
  }
@@ -771,15 +861,15 @@ export function layout_radial(nodes, edges, opts)
771
861
  let root = t.roots[0];
772
862
  angle.set(root, 0);
773
863
  let cursor = -Math.PI / 2; /* the first child at the top */
774
- for(let kid of (t.children.get(root) || [])) {
775
- let part = 2 * Math.PI * leaves.get(kid) / leaves.get(root);
864
+ for(let kid of (kids_of.get(root) || [])) {
865
+ let part = 2 * Math.PI * weight.get(kid) / weight.get(root);
776
866
  sector(kid, cursor, part, root);
777
867
  cursor += part;
778
868
  }
779
869
  } else {
780
870
  let cursor = -Math.PI / 2;
781
871
  for(let root of t.roots) {
782
- let part = 2 * Math.PI * leaves.get(root) / total;
872
+ let part = 2 * Math.PI * weight.get(root) / total;
783
873
  sector(root, cursor, part, null);
784
874
  cursor += part;
785
875
  }
@@ -803,31 +893,132 @@ export function layout_radial(nodes, edges, opts)
803
893
  * the one it HANGS FROM, in the direction it hangs in -- the
804
894
  * two are radially aligned, or nearly, since a child lives
805
895
  * inside its parent's sector -- and the ring goes out far
806
- * enough for the worst of them. */
896
+ * enough for the worst of them.
897
+ *
898
+ * Then the STACKS of the ring get their rows: each row holds as
899
+ * many cards as fit in the room the stack has -- its own sector,
900
+ * less what its neighbours on the ring take up -- at that row's
901
+ * radius. A stack alone on its ring has the whole circle, and its
902
+ * rows are rings round its parent; one between neighbours fans
903
+ * out inside its sector, wider as it goes. The rows reaching out
904
+ * are neighbours of the outer rings too, by the ANGLE they cover. */
905
+ let fit = (list, r0) => {
906
+ let r = r0;
907
+ let sorted = list.slice().sort((a, b) => a.angle - b.angle);
908
+ for(let i = 0; i < sorted.length && sorted.length > 1; i++) {
909
+ let a = sorted[i];
910
+ let b = sorted[(i + 1) % sorted.length];
911
+ let gap = (i + 1 < sorted.length)
912
+ ? b.angle - a.angle
913
+ : b.angle + 2 * Math.PI - a.angle;
914
+ if(gap > 1e-9) {
915
+ r = Math.max(r, (a.diag / 2 + b.diag / 2 + o.nodesep) / gap);
916
+ }
917
+ }
918
+ return r;
919
+ };
807
920
  let radius = [0];
921
+ let reaching = []; /* planned stacks: {angle, half, reach} */
808
922
  for(let ring = 1; ring < rings.length; ring++) {
923
+ let members = rings[ring] || [];
809
924
  let r = radius[ring - 1] + o.ranksep;
810
- for(let n of (rings[ring] || [])) {
925
+ let band = 0;
926
+ for(let n of members) {
811
927
  r = Math.max(r, radius[ring - 1] + (n.parent_ext + n.ext) / 2 + o.nodesep);
928
+ band = Math.max(band, n.ext);
812
929
  }
813
- let list = (rings[ring] || []).slice().sort((a, b) => a.angle - b.angle);
814
- for(let i = 0; i < list.length && list.length > 1; i++) {
815
- let a = list[i];
816
- let b = list[(i + 1) % list.length];
817
- let gap = (i + 1 < list.length)
818
- ? b.angle - a.angle
819
- : b.angle + 2 * Math.PI - a.angle;
820
- if(gap > 1e-9) {
821
- r = Math.max(r, (a.diag / 2 + b.diag / 2 + o.nodesep) / gap);
930
+ r = fit(members, r);
931
+ /* Clear the rows of the inner stacks that reach this far: by
932
+ * the angle they cover, or, for a card standing in it, by
933
+ * going past their reach. */
934
+ for(let pass = 0; pass < 2; pass++) {
935
+ for(let g of reaching) {
936
+ if(g.reach < r - band / 2 - o.nodesep) {
937
+ continue;
938
+ }
939
+ for(let m of members) {
940
+ let d = circ(g.angle, m.angle);
941
+ if(d > g.half + 1e-9) {
942
+ r = Math.max(r, (m.diag / 2 + o.nodesep) / (d - g.half));
943
+ } else {
944
+ r = Math.max(r, g.reach + m.ext / 2 + o.nodesep);
945
+ }
946
+ }
822
947
  }
823
948
  }
824
949
  radius[ring] = r;
950
+
951
+ let sorted = members.slice().sort((a, b) => a.angle - b.angle);
952
+ for(let i = 0; i < sorted.length; i++) {
953
+ let m = sorted[i];
954
+ let st = stacks.get(m.id);
955
+ if(!st) {
956
+ continue;
957
+ }
958
+ /* The room each side: to the neighbour, less its half and
959
+ * the gap; never past the stack's own sector. */
960
+ let room = m.span / 2;
961
+ if(sorted.length > 1) {
962
+ let left = sorted[(i - 1 + sorted.length) % sorted.length];
963
+ let right = sorted[(i + 1) % sorted.length];
964
+ room = Math.min(room,
965
+ circ(m.angle, left.angle) - (left.diag / 2 + o.nodesep) / r,
966
+ circ(m.angle, right.angle) - (right.diag / 2 + o.nodesep) / r);
967
+ }
968
+ room = Math.min(room, Math.PI);
969
+ /* Measured over the whole arc it will cover, not at its
970
+ * middle: round a parent a card stands at every angle,
971
+ * and a 172x96 card is wider at 45 degrees than facing
972
+ * down. */
973
+ let across = 0;
974
+ let along = 0;
975
+ for(let k = 0; k <= 16; k++) {
976
+ let th = m.angle - room + 2 * room * k / 16;
977
+ for(let c of st.cards) {
978
+ across = Math.max(across, extent_at(c, th + Math.PI / 2));
979
+ along = Math.max(along, extent_at(c, th));
980
+ }
981
+ }
982
+ let rows = [];
983
+ let left_to_place = st.ids.length;
984
+ let rr = r;
985
+ let half = 0;
986
+ while(left_to_place > 0) {
987
+ let fits = Math.floor(2 * room * rr / (across + o.nodesep));
988
+ let cnt = Math.max(1, Math.min(left_to_place, fits));
989
+ rows.push({rr: rr, cnt: cnt});
990
+ half = Math.max(half, ((cnt * (across + o.nodesep) - o.nodesep) / 2) / rr);
991
+ left_to_place -= cnt;
992
+ rr += along + STACK_GAP;
993
+ }
994
+ st.plan = {angle: m.angle, across: across, rows: rows};
995
+ reaching.push({
996
+ angle: m.angle,
997
+ half: half,
998
+ reach: rows[rows.length - 1].rr + along / 2,
999
+ });
1000
+ }
825
1001
  }
826
1002
 
827
- /* Pass 3, the positions. */
1003
+ /* Pass 3, the positions: a node at its angle on its ring; a
1004
+ * stack's cards row by row outward, each row centred on the
1005
+ * stack's angle, a card's width plus the gap apart along the arc. */
828
1006
  for(let [id, a] of angle) {
829
- let r = radius[ring_of(id)] || 0;
830
- pos.set(id, {x: r * Math.cos(a), y: r * Math.sin(a)});
1007
+ let st = stacks.get(id);
1008
+ if(!st) {
1009
+ let r0 = radius[ring_of(id)] || 0;
1010
+ pos.set(id, {x: r0 * Math.cos(a), y: r0 * Math.sin(a)});
1011
+ continue;
1012
+ }
1013
+ let i = 0;
1014
+ for(let row of st.plan.rows) {
1015
+ let step = (st.plan.across + o.nodesep) / row.rr;
1016
+ for(let j = 0; j < row.cnt; j++) {
1017
+ let th = a + (j - (row.cnt - 1) / 2) * step;
1018
+ pos.set(st.ids[i], {x: row.rr * Math.cos(th), y: row.rr * Math.sin(th)});
1019
+ i++;
1020
+ }
1021
+ }
831
1022
  }
832
1023
 
833
1024
  return pos;