@yuneta/gobj-ui 7.23.71 → 7.23.73

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
@@ -1882,6 +1882,35 @@ the middle, and **a toolbar left with a single group is centred**. An unknown
1882
1882
  name is reported, not silently dropped: a typo would otherwise remove the save
1883
1883
  button with no trace of why.
1884
1884
 
1885
+ ### `C_YUI_FORM` — which field is editable, and the one `writable` does not govern
1886
+
1887
+ A form opened to LOOK at a record has no editable field: that is a property of
1888
+ the opening (`readonly`), not of the schema, and it wins over everything. Under
1889
+ it the schema decides — a column needs `writable` — **with one exception, and
1890
+ it is the whole of linking: an fkey.**
1891
+
1892
+ `writable` governs the WRITE of a column's VALUE, and an fkey is not written,
1893
+ it is **linked**. An fkey is normally declared with no `writable` flag at all
1894
+ (`treedb_authzs`'s `users.roles` is `['fkey']`, and so is almost every fkey in
1895
+ the tree), so reading "not writable" as "not editable" takes away the only way
1896
+ to link a record to its parent — with `users.roles`, the only way to give a
1897
+ person a role. The other half of the same rule is in the topic view, where the
1898
+ record travels back: it sends "the writable cols, the fkeys (a link is edited
1899
+ by linking) and the pkey". The two halves have to agree; when they did not, the
1900
+ form disabled the control whose value the save was still waiting for.
1901
+
1902
+ The rule lives in `form_field_readonly.js`, pure and tested. A `file` column is
1903
+ an fkey too (`['fkey','file']`) and is deliberately NOT covered: it answers
1904
+ `type: "file"`, its bytes travel beside the record, and the SDK declares as
1905
+ legal a `file` column that only a load fills.
1906
+
1907
+ **And `readonly` is an attribute of a TEXT control and of nothing else.** A
1908
+ `<select>`, a checkbox and a radio accept it and let the reader change the
1909
+ value anyway; only `disabled` stops them, and tom-select has to be told through
1910
+ its API. So the form applies `disabled` to those three, and the record is still
1911
+ read from a disabled control — it is collected by reading each marked control,
1912
+ never by submitting the form.
1913
+
1885
1914
  ### Selecting rows in any table — `yui_table_select.js`
1886
1915
 
1887
1916
  Deleting twenty rows one confirmation at a time is not a workflow. Any view
@@ -25970,6 +25970,7 @@ function ac_paste_rows(gobj, event, kw, src) {
25970
25970
  * fkeys, times and numbers). Route by the form's mode.
25971
25971
  ************************************************************/
25972
25972
  function ac_form_save_record(gobj, event, kw, src) {
25973
+ let priv = gobj.priv;
25973
25974
  if (refuse_if_readonly$2(gobj, event)) return -1;
25974
25975
  let mode = (0, _yuneta_gobj_js.gobj_read_str_attr)(src, "form_mode");
25975
25976
  if (priv.reading_files) {
@@ -27491,6 +27492,28 @@ function spanning_tree(nodes, edges) {
27491
27492
  };
27492
27493
  }
27493
27494
  /************************************************************
27495
+ * The ids of a spanning tree in PARENT-BEFORE-CHILD order.
27496
+ *
27497
+ * Every pass below used to be a recursive walk, which is the natural
27498
+ * way to write it and the one that dies on a deep tree: a
27499
+ * self-referent hook (a place inside a place inside a place) is as
27500
+ * deep as the data says, and the stack is not. Read this list
27501
+ * forwards for a pre-order pass and BACKWARDS for a post-order one
27502
+ * -- a child always sits after its parent, so reversed it always
27503
+ * sits before it, which is the whole of what post-order was for.
27504
+ ************************************************************/
27505
+ function walk_order(t) {
27506
+ let order = [];
27507
+ let stack = t.roots.slice().reverse();
27508
+ while (stack.length) {
27509
+ let id = stack.pop();
27510
+ order.push(id);
27511
+ let kids = t.children.get(id) || [];
27512
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
27513
+ }
27514
+ return order;
27515
+ }
27516
+ /************************************************************
27494
27517
  * The tidy tree, left to right.
27495
27518
  *
27496
27519
  * Returns Map<id, {x, y}> with the CENTRE of each node, the way G6
@@ -27511,56 +27534,45 @@ function layout_tree(nodes, edges, opts) {
27511
27534
  col_x[d] = x;
27512
27535
  x += (col_w[d] || 0) + o.ranksep;
27513
27536
  }
27537
+ let order = walk_order(t);
27514
27538
  let height = /* @__PURE__ */ new Map();
27515
- let measure = (id) => {
27539
+ for (let i = order.length - 1; i >= 0; i--) {
27540
+ let id = order[i];
27516
27541
  let kids = t.children.get(id) || [];
27517
27542
  let own = t.by_id.get(id).h;
27518
- if (!kids.length) {
27519
- height.set(id, own);
27520
- return own;
27521
- }
27522
27543
  let sum = 0;
27523
- for (let i = 0; i < kids.length; i++) {
27524
- sum += measure(kids[i]);
27525
- if (i > 0) sum += o.nodesep;
27544
+ for (let k = 0; k < kids.length; k++) {
27545
+ sum += height.get(kids[k]);
27546
+ if (k > 0) sum += o.nodesep;
27526
27547
  }
27527
- let h = Math.max(own, sum);
27528
- height.set(id, h);
27529
- return h;
27530
- };
27531
- for (let root of t.roots) measure(root);
27532
- let place = (id, top) => {
27533
- let n = t.by_id.get(id);
27548
+ height.set(id, kids.length ? Math.max(own, sum) : own);
27549
+ }
27550
+ let tops = /* @__PURE__ */ new Map();
27551
+ let top = 0;
27552
+ for (let root of t.roots) {
27553
+ tops.set(root, top);
27554
+ top += height.get(root) + o.nodesep * 2;
27555
+ }
27556
+ for (let id of order) {
27534
27557
  let d = t.depth.get(id);
27535
27558
  let kids = t.children.get(id) || [];
27536
27559
  let block = height.get(id);
27537
- let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
27538
- if (!kids.length) {
27539
- pos.set(id, {
27540
- x: cx,
27541
- y: top + block / 2
27542
- });
27543
- return;
27544
- }
27560
+ let own_top = tops.get(id);
27561
+ pos.set(id, {
27562
+ x: col_x[d] + col_w[d] / 2,
27563
+ y: own_top + block / 2
27564
+ });
27565
+ if (!kids.length) continue;
27545
27566
  let kids_h = 0;
27546
- for (let i = 0; i < kids.length; i++) {
27547
- kids_h += height.get(kids[i]);
27548
- if (i > 0) kids_h += o.nodesep;
27567
+ for (let k = 0; k < kids.length; k++) {
27568
+ kids_h += height.get(kids[k]);
27569
+ if (k > 0) kids_h += o.nodesep;
27549
27570
  }
27550
- let cursor = top + (block - kids_h) / 2;
27571
+ let cursor = own_top + (block - kids_h) / 2;
27551
27572
  for (let kid of kids) {
27552
- place(kid, cursor);
27573
+ tops.set(kid, cursor);
27553
27574
  cursor += height.get(kid) + o.nodesep;
27554
27575
  }
27555
- pos.set(id, {
27556
- x: cx,
27557
- y: top + block / 2
27558
- });
27559
- };
27560
- let top = 0;
27561
- for (let root of t.roots) {
27562
- place(root, top);
27563
- top += height.get(root) + o.nodesep * 2;
27564
27576
  }
27565
27577
  return pos;
27566
27578
  }
@@ -27587,40 +27599,53 @@ function layout_radial(nodes, edges, opts) {
27587
27599
  let t = spanning_tree(nodes, edges);
27588
27600
  let pos = /* @__PURE__ */ new Map();
27589
27601
  if (!t.roots.length) return pos;
27602
+ let order = walk_order(t);
27590
27603
  let leaves = /* @__PURE__ */ new Map();
27591
- let count = (id) => {
27604
+ for (let i = order.length - 1; i >= 0; i--) {
27605
+ let id = order[i];
27592
27606
  let kids = t.children.get(id) || [];
27593
27607
  if (!kids.length) {
27594
27608
  leaves.set(id, 1);
27595
- return 1;
27609
+ continue;
27596
27610
  }
27597
27611
  let n = 0;
27598
- for (let kid of kids) n += count(kid);
27612
+ for (let kid of kids) n += leaves.get(kid);
27599
27613
  leaves.set(id, n);
27600
- return n;
27601
- };
27614
+ }
27602
27615
  let total = 0;
27603
- for (let root of t.roots) total += count(root);
27616
+ for (let root of t.roots) total += leaves.get(root);
27604
27617
  let single = t.roots.length === 1;
27605
27618
  let ring_of = (id) => t.depth.get(id) + (single ? 0 : 1);
27606
27619
  let angle = /* @__PURE__ */ new Map();
27607
27620
  let rings = [];
27608
- let sector = (id, from, span) => {
27609
- let a = from + span / 2;
27610
- angle.set(id, a);
27611
- let ring = ring_of(id);
27612
- if (!rings[ring]) rings[ring] = [];
27613
- let n = t.by_id.get(id);
27614
- rings[ring].push({
27615
- id,
27616
- angle: a,
27617
- diag: Math.hypot(n.w, n.h)
27618
- });
27619
- let cursor = from;
27620
- for (let kid of t.children.get(id) || []) {
27621
- let part = span * leaves.get(kid) / leaves.get(id);
27622
- sector(kid, cursor, part);
27623
- cursor += part;
27621
+ let sector = (root_id, root_from, root_span) => {
27622
+ let stack = [{
27623
+ id: root_id,
27624
+ from: root_from,
27625
+ span: root_span
27626
+ }];
27627
+ while (stack.length) {
27628
+ let { id, from, span } = stack.pop();
27629
+ let a = from + span / 2;
27630
+ angle.set(id, a);
27631
+ let ring = ring_of(id);
27632
+ if (!rings[ring]) rings[ring] = [];
27633
+ let n = t.by_id.get(id);
27634
+ rings[ring].push({
27635
+ id,
27636
+ angle: a,
27637
+ diag: Math.hypot(n.w, n.h)
27638
+ });
27639
+ let cursor = from;
27640
+ for (let kid of t.children.get(id) || []) {
27641
+ let part = span * leaves.get(kid) / leaves.get(id);
27642
+ stack.push({
27643
+ id: kid,
27644
+ from: cursor,
27645
+ span: part
27646
+ });
27647
+ cursor += part;
27648
+ }
27624
27649
  }
27625
27650
  };
27626
27651
  if (single) {
@@ -27671,15 +27696,20 @@ function layout_outline(nodes, edges, opts) {
27671
27696
  let t = spanning_tree(nodes, edges);
27672
27697
  let pos = /* @__PURE__ */ new Map();
27673
27698
  let y = 0;
27674
- let visit = (id) => {
27675
- let n = t.by_id.get(id);
27676
- let d = t.depth.get(id);
27677
- pos.set(id, {
27678
- x: d * o.indent + n.w / 2,
27679
- y: y + n.h / 2
27680
- });
27681
- y += n.h + o.nodesep;
27682
- for (let kid of t.children.get(id) || []) visit(kid);
27699
+ let visit = (root_id) => {
27700
+ let stack = [root_id];
27701
+ while (stack.length) {
27702
+ let id = stack.pop();
27703
+ let n = t.by_id.get(id);
27704
+ let d = t.depth.get(id);
27705
+ pos.set(id, {
27706
+ x: d * o.indent + n.w / 2,
27707
+ y: y + n.h / 2
27708
+ });
27709
+ y += n.h + o.nodesep;
27710
+ let kids = t.children.get(id) || [];
27711
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
27712
+ }
27683
27713
  };
27684
27714
  for (let root of t.roots) {
27685
27715
  visit(root);
@@ -62106,6 +62136,8 @@ function ac_set_operation_mode(gobj, event, kw, src) {
62106
62136
  * into the URL, so what you are looking at stays linkable.
62107
62137
  ************************************************************/
62108
62138
  var LEGEND_GLYPH_STYLE = "font-size:1.5em; line-height:1; color:inherit;";
62139
+ var LEGEND_NAME_STYLE = "font-size:1.2em; line-height:1.2;";
62140
+ var LEGEND_COUNT_STYLE = "margin-left:.4rem; font-size:1em;";
62109
62141
  function refresh_legend(gobj) {
62110
62142
  let priv = gobj.priv;
62111
62143
  let $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
@@ -62131,19 +62163,19 @@ function refresh_legend(gobj) {
62131
62163
  "span",
62132
62164
  {
62133
62165
  class: "GRAPH_LEGEND_NAME",
62134
- style: hidden ? "text-decoration:line-through; opacity:.6;" : ""
62166
+ style: LEGEND_NAME_STYLE + (hidden ? " text-decoration:line-through; opacity:.6;" : "")
62135
62167
  },
62136
62168
  topic_name
62137
62169
  ], [
62138
62170
  "span",
62139
62171
  {
62140
62172
  class: "GRAPH_LEGEND_COUNT has-text-grey",
62141
- style: "margin-left:.35rem; font-size:.8em;"
62173
+ style: LEGEND_COUNT_STYLE
62142
62174
  },
62143
62175
  hidden ? `${entry.total}` : `${entry.visible}/${entry.total}`
62144
62176
  ]);
62145
62177
  let body_attrs = {
62146
- class: "GRAPH_LEGEND_ITEM button is-small" + (focused ? " is-primary is-light" : "") + (is_main ? " GRAPH_LEGEND_MAIN" : ""),
62178
+ class: "GRAPH_LEGEND_ITEM button is-small" + (focused ? " pressed_state" : "") + (is_main ? " GRAPH_LEGEND_MAIN" : ""),
62147
62179
  type: "button",
62148
62180
  style: "gap:0; flex:0 0 auto;",
62149
62181
  title: is_main ? (0, i18next.t)("main topic") : hidden ? (0, i18next.t)("show topic") : (0, i18next.t)("hide topic"),
@@ -62167,7 +62199,7 @@ function refresh_legend(gobj) {
62167
62199
  if (is_main && state.main_chosen) extras.push([
62168
62200
  "button",
62169
62201
  {
62170
- class: "GRAPH_LEGEND_STAR button is-small is-warning is-light",
62202
+ class: "GRAPH_LEGEND_STAR button is-small pressed_state",
62171
62203
  type: "button",
62172
62204
  style: "padding:0 .4rem;",
62173
62205
  title: (0, i18next.t)("main topic"),
@@ -62228,7 +62260,7 @@ function refresh_legend(gobj) {
62228
62260
  if (!hidden && entry.linked && entry.loose > 0) extras.push([
62229
62261
  "button",
62230
62262
  {
62231
- class: "GRAPH_LEGEND_LOOSE button is-small" + (entry.loose_shown ? " is-warning is-light" : ""),
62263
+ class: "GRAPH_LEGEND_LOOSE button is-small" + (entry.loose_shown ? " pressed_state" : ""),
62232
62264
  type: "button",
62233
62265
  style: "padding:0 .45rem;",
62234
62266
  title: (0, i18next.t)("loose records"),
@@ -62248,7 +62280,7 @@ function refresh_legend(gobj) {
62248
62280
  if (!hidden) extras.push([
62249
62281
  "button",
62250
62282
  {
62251
- class: "GRAPH_LEGEND_FOCUS button is-small" + (focused ? " is-primary is-light" : ""),
62283
+ class: "GRAPH_LEGEND_FOCUS button is-small" + (focused ? " pressed_state" : ""),
62252
62284
  type: "button",
62253
62285
  style: "padding:0 .4rem;",
62254
62286
  title: (0, i18next.t)("highlight topic"),
@@ -25965,6 +25965,7 @@ function ac_paste_rows(gobj, event, kw, src) {
25965
25965
  * fkeys, times and numbers). Route by the form's mode.
25966
25966
  ************************************************************/
25967
25967
  function ac_form_save_record(gobj, event, kw, src) {
25968
+ let priv = gobj.priv;
25968
25969
  if (refuse_if_readonly$2(gobj, event)) return -1;
25969
25970
  let mode = gobj_read_str_attr(src, "form_mode");
25970
25971
  if (priv.reading_files) {
@@ -27486,6 +27487,28 @@ function spanning_tree(nodes, edges) {
27486
27487
  };
27487
27488
  }
27488
27489
  /************************************************************
27490
+ * The ids of a spanning tree in PARENT-BEFORE-CHILD order.
27491
+ *
27492
+ * Every pass below used to be a recursive walk, which is the natural
27493
+ * way to write it and the one that dies on a deep tree: a
27494
+ * self-referent hook (a place inside a place inside a place) is as
27495
+ * deep as the data says, and the stack is not. Read this list
27496
+ * forwards for a pre-order pass and BACKWARDS for a post-order one
27497
+ * -- a child always sits after its parent, so reversed it always
27498
+ * sits before it, which is the whole of what post-order was for.
27499
+ ************************************************************/
27500
+ function walk_order(t) {
27501
+ let order = [];
27502
+ let stack = t.roots.slice().reverse();
27503
+ while (stack.length) {
27504
+ let id = stack.pop();
27505
+ order.push(id);
27506
+ let kids = t.children.get(id) || [];
27507
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
27508
+ }
27509
+ return order;
27510
+ }
27511
+ /************************************************************
27489
27512
  * The tidy tree, left to right.
27490
27513
  *
27491
27514
  * Returns Map<id, {x, y}> with the CENTRE of each node, the way G6
@@ -27506,56 +27529,45 @@ function layout_tree(nodes, edges, opts) {
27506
27529
  col_x[d] = x;
27507
27530
  x += (col_w[d] || 0) + o.ranksep;
27508
27531
  }
27532
+ let order = walk_order(t);
27509
27533
  let height = /* @__PURE__ */ new Map();
27510
- let measure = (id) => {
27534
+ for (let i = order.length - 1; i >= 0; i--) {
27535
+ let id = order[i];
27511
27536
  let kids = t.children.get(id) || [];
27512
27537
  let own = t.by_id.get(id).h;
27513
- if (!kids.length) {
27514
- height.set(id, own);
27515
- return own;
27516
- }
27517
27538
  let sum = 0;
27518
- for (let i = 0; i < kids.length; i++) {
27519
- sum += measure(kids[i]);
27520
- if (i > 0) sum += o.nodesep;
27539
+ for (let k = 0; k < kids.length; k++) {
27540
+ sum += height.get(kids[k]);
27541
+ if (k > 0) sum += o.nodesep;
27521
27542
  }
27522
- let h = Math.max(own, sum);
27523
- height.set(id, h);
27524
- return h;
27525
- };
27526
- for (let root of t.roots) measure(root);
27527
- let place = (id, top) => {
27528
- let n = t.by_id.get(id);
27543
+ height.set(id, kids.length ? Math.max(own, sum) : own);
27544
+ }
27545
+ let tops = /* @__PURE__ */ new Map();
27546
+ let top = 0;
27547
+ for (let root of t.roots) {
27548
+ tops.set(root, top);
27549
+ top += height.get(root) + o.nodesep * 2;
27550
+ }
27551
+ for (let id of order) {
27529
27552
  let d = t.depth.get(id);
27530
27553
  let kids = t.children.get(id) || [];
27531
27554
  let block = height.get(id);
27532
- let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
27533
- if (!kids.length) {
27534
- pos.set(id, {
27535
- x: cx,
27536
- y: top + block / 2
27537
- });
27538
- return;
27539
- }
27555
+ let own_top = tops.get(id);
27556
+ pos.set(id, {
27557
+ x: col_x[d] + col_w[d] / 2,
27558
+ y: own_top + block / 2
27559
+ });
27560
+ if (!kids.length) continue;
27540
27561
  let kids_h = 0;
27541
- for (let i = 0; i < kids.length; i++) {
27542
- kids_h += height.get(kids[i]);
27543
- if (i > 0) kids_h += o.nodesep;
27562
+ for (let k = 0; k < kids.length; k++) {
27563
+ kids_h += height.get(kids[k]);
27564
+ if (k > 0) kids_h += o.nodesep;
27544
27565
  }
27545
- let cursor = top + (block - kids_h) / 2;
27566
+ let cursor = own_top + (block - kids_h) / 2;
27546
27567
  for (let kid of kids) {
27547
- place(kid, cursor);
27568
+ tops.set(kid, cursor);
27548
27569
  cursor += height.get(kid) + o.nodesep;
27549
27570
  }
27550
- pos.set(id, {
27551
- x: cx,
27552
- y: top + block / 2
27553
- });
27554
- };
27555
- let top = 0;
27556
- for (let root of t.roots) {
27557
- place(root, top);
27558
- top += height.get(root) + o.nodesep * 2;
27559
27571
  }
27560
27572
  return pos;
27561
27573
  }
@@ -27582,40 +27594,53 @@ function layout_radial(nodes, edges, opts) {
27582
27594
  let t = spanning_tree(nodes, edges);
27583
27595
  let pos = /* @__PURE__ */ new Map();
27584
27596
  if (!t.roots.length) return pos;
27597
+ let order = walk_order(t);
27585
27598
  let leaves = /* @__PURE__ */ new Map();
27586
- let count = (id) => {
27599
+ for (let i = order.length - 1; i >= 0; i--) {
27600
+ let id = order[i];
27587
27601
  let kids = t.children.get(id) || [];
27588
27602
  if (!kids.length) {
27589
27603
  leaves.set(id, 1);
27590
- return 1;
27604
+ continue;
27591
27605
  }
27592
27606
  let n = 0;
27593
- for (let kid of kids) n += count(kid);
27607
+ for (let kid of kids) n += leaves.get(kid);
27594
27608
  leaves.set(id, n);
27595
- return n;
27596
- };
27609
+ }
27597
27610
  let total = 0;
27598
- for (let root of t.roots) total += count(root);
27611
+ for (let root of t.roots) total += leaves.get(root);
27599
27612
  let single = t.roots.length === 1;
27600
27613
  let ring_of = (id) => t.depth.get(id) + (single ? 0 : 1);
27601
27614
  let angle = /* @__PURE__ */ new Map();
27602
27615
  let rings = [];
27603
- let sector = (id, from, span) => {
27604
- let a = from + span / 2;
27605
- angle.set(id, a);
27606
- let ring = ring_of(id);
27607
- if (!rings[ring]) rings[ring] = [];
27608
- let n = t.by_id.get(id);
27609
- rings[ring].push({
27610
- id,
27611
- angle: a,
27612
- diag: Math.hypot(n.w, n.h)
27613
- });
27614
- let cursor = from;
27615
- for (let kid of t.children.get(id) || []) {
27616
- let part = span * leaves.get(kid) / leaves.get(id);
27617
- sector(kid, cursor, part);
27618
- cursor += part;
27616
+ let sector = (root_id, root_from, root_span) => {
27617
+ let stack = [{
27618
+ id: root_id,
27619
+ from: root_from,
27620
+ span: root_span
27621
+ }];
27622
+ while (stack.length) {
27623
+ let { id, from, span } = stack.pop();
27624
+ let a = from + span / 2;
27625
+ angle.set(id, a);
27626
+ let ring = ring_of(id);
27627
+ if (!rings[ring]) rings[ring] = [];
27628
+ let n = t.by_id.get(id);
27629
+ rings[ring].push({
27630
+ id,
27631
+ angle: a,
27632
+ diag: Math.hypot(n.w, n.h)
27633
+ });
27634
+ let cursor = from;
27635
+ for (let kid of t.children.get(id) || []) {
27636
+ let part = span * leaves.get(kid) / leaves.get(id);
27637
+ stack.push({
27638
+ id: kid,
27639
+ from: cursor,
27640
+ span: part
27641
+ });
27642
+ cursor += part;
27643
+ }
27619
27644
  }
27620
27645
  };
27621
27646
  if (single) {
@@ -27666,15 +27691,20 @@ function layout_outline(nodes, edges, opts) {
27666
27691
  let t = spanning_tree(nodes, edges);
27667
27692
  let pos = /* @__PURE__ */ new Map();
27668
27693
  let y = 0;
27669
- let visit = (id) => {
27670
- let n = t.by_id.get(id);
27671
- let d = t.depth.get(id);
27672
- pos.set(id, {
27673
- x: d * o.indent + n.w / 2,
27674
- y: y + n.h / 2
27675
- });
27676
- y += n.h + o.nodesep;
27677
- for (let kid of t.children.get(id) || []) visit(kid);
27694
+ let visit = (root_id) => {
27695
+ let stack = [root_id];
27696
+ while (stack.length) {
27697
+ let id = stack.pop();
27698
+ let n = t.by_id.get(id);
27699
+ let d = t.depth.get(id);
27700
+ pos.set(id, {
27701
+ x: d * o.indent + n.w / 2,
27702
+ y: y + n.h / 2
27703
+ });
27704
+ y += n.h + o.nodesep;
27705
+ let kids = t.children.get(id) || [];
27706
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
27707
+ }
27678
27708
  };
27679
27709
  for (let root of t.roots) {
27680
27710
  visit(root);
@@ -62101,6 +62131,8 @@ function ac_set_operation_mode(gobj, event, kw, src) {
62101
62131
  * into the URL, so what you are looking at stays linkable.
62102
62132
  ************************************************************/
62103
62133
  var LEGEND_GLYPH_STYLE = "font-size:1.5em; line-height:1; color:inherit;";
62134
+ var LEGEND_NAME_STYLE = "font-size:1.2em; line-height:1.2;";
62135
+ var LEGEND_COUNT_STYLE = "margin-left:.4rem; font-size:1em;";
62104
62136
  function refresh_legend(gobj) {
62105
62137
  let priv = gobj.priv;
62106
62138
  let $container = gobj_read_attr(gobj, "$container");
@@ -62126,19 +62158,19 @@ function refresh_legend(gobj) {
62126
62158
  "span",
62127
62159
  {
62128
62160
  class: "GRAPH_LEGEND_NAME",
62129
- style: hidden ? "text-decoration:line-through; opacity:.6;" : ""
62161
+ style: LEGEND_NAME_STYLE + (hidden ? " text-decoration:line-through; opacity:.6;" : "")
62130
62162
  },
62131
62163
  topic_name
62132
62164
  ], [
62133
62165
  "span",
62134
62166
  {
62135
62167
  class: "GRAPH_LEGEND_COUNT has-text-grey",
62136
- style: "margin-left:.35rem; font-size:.8em;"
62168
+ style: LEGEND_COUNT_STYLE
62137
62169
  },
62138
62170
  hidden ? `${entry.total}` : `${entry.visible}/${entry.total}`
62139
62171
  ]);
62140
62172
  let body_attrs = {
62141
- class: "GRAPH_LEGEND_ITEM button is-small" + (focused ? " is-primary is-light" : "") + (is_main ? " GRAPH_LEGEND_MAIN" : ""),
62173
+ class: "GRAPH_LEGEND_ITEM button is-small" + (focused ? " pressed_state" : "") + (is_main ? " GRAPH_LEGEND_MAIN" : ""),
62142
62174
  type: "button",
62143
62175
  style: "gap:0; flex:0 0 auto;",
62144
62176
  title: is_main ? t("main topic") : hidden ? t("show topic") : t("hide topic"),
@@ -62162,7 +62194,7 @@ function refresh_legend(gobj) {
62162
62194
  if (is_main && state.main_chosen) extras.push([
62163
62195
  "button",
62164
62196
  {
62165
- class: "GRAPH_LEGEND_STAR button is-small is-warning is-light",
62197
+ class: "GRAPH_LEGEND_STAR button is-small pressed_state",
62166
62198
  type: "button",
62167
62199
  style: "padding:0 .4rem;",
62168
62200
  title: t("main topic"),
@@ -62223,7 +62255,7 @@ function refresh_legend(gobj) {
62223
62255
  if (!hidden && entry.linked && entry.loose > 0) extras.push([
62224
62256
  "button",
62225
62257
  {
62226
- class: "GRAPH_LEGEND_LOOSE button is-small" + (entry.loose_shown ? " is-warning is-light" : ""),
62258
+ class: "GRAPH_LEGEND_LOOSE button is-small" + (entry.loose_shown ? " pressed_state" : ""),
62227
62259
  type: "button",
62228
62260
  style: "padding:0 .45rem;",
62229
62261
  title: t("loose records"),
@@ -62243,7 +62275,7 @@ function refresh_legend(gobj) {
62243
62275
  if (!hidden) extras.push([
62244
62276
  "button",
62245
62277
  {
62246
- class: "GRAPH_LEGEND_FOCUS button is-small" + (focused ? " is-primary is-light" : ""),
62278
+ class: "GRAPH_LEGEND_FOCUS button is-small" + (focused ? " pressed_state" : ""),
62247
62279
  type: "button",
62248
62280
  style: "padding:0 .4rem;",
62249
62281
  title: t("highlight topic"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.71",
3
+ "version": "7.23.73",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -2339,6 +2339,15 @@ function ac_set_operation_mode(gobj, event, kw, src)
2339
2339
  * stays small so the strip keeps its height; the glyph inside grows. */
2340
2340
  const LEGEND_GLYPH_STYLE = 'font-size:1.5em; line-height:1; color:inherit;';
2341
2341
 
2342
+ /* And the same for what a chip SAYS. The legend is a layer control --
2343
+ * a gadget somebody uses, not a footnote somebody glances at -- and a
2344
+ * topic name is the thing it is read for, so 0.75rem is the wrong
2345
+ * size for it. The button stays `is-small` because the strip has to
2346
+ * hold a chip per topic on one row; the name and the count come back
2347
+ * up inside it, exactly as the glyphs did. */
2348
+ const LEGEND_NAME_STYLE = 'font-size:1.2em; line-height:1.2;';
2349
+ const LEGEND_COUNT_STYLE = 'margin-left:.4rem; font-size:1em;';
2350
+
2342
2351
  function refresh_legend(gobj)
2343
2352
  {
2344
2353
  let priv = gobj.priv;
@@ -2389,10 +2398,11 @@ function refresh_legend(gobj)
2389
2398
  `border:1px solid rgba(0,0,0,.25);` +
2390
2399
  (hidden? 'opacity:.35;' : '')}],
2391
2400
  ['span', {class: 'GRAPH_LEGEND_NAME',
2392
- style: hidden? 'text-decoration:line-through; opacity:.6;' : ''},
2401
+ style: LEGEND_NAME_STYLE +
2402
+ (hidden? ' text-decoration:line-through; opacity:.6;' : '')},
2393
2403
  topic_name],
2394
2404
  ['span', {class: 'GRAPH_LEGEND_COUNT has-text-grey',
2395
- style: 'margin-left:.35rem; font-size:.8em;'},
2405
+ style: LEGEND_COUNT_STYLE},
2396
2406
  hidden? `${entry.total}` : `${entry.visible}/${entry.total}`]
2397
2407
  );
2398
2408
 
@@ -2402,7 +2412,7 @@ function refresh_legend(gobj)
2402
2412
  * every chip was dead except its star. */
2403
2413
  let body_attrs = {
2404
2414
  class: 'GRAPH_LEGEND_ITEM button is-small' +
2405
- (focused? ' is-primary is-light' : '') +
2415
+ (focused? ' pressed_state' : '') +
2406
2416
  (is_main? ' GRAPH_LEGEND_MAIN' : ''),
2407
2417
  type: 'button',
2408
2418
  style: 'gap:0; flex:0 0 auto;',
@@ -2430,7 +2440,7 @@ function refresh_legend(gobj)
2430
2440
  * is a button too: pressing it hands the choice back to the
2431
2441
  * graph (deduced again). On a deduced one it is a mark. */
2432
2442
  if(is_main && state.main_chosen) {
2433
- extras.push(['button', {class: 'GRAPH_LEGEND_STAR button is-small is-warning is-light',
2443
+ extras.push(['button', {class: 'GRAPH_LEGEND_STAR button is-small pressed_state',
2434
2444
  type: 'button', style: 'padding:0 .4rem;',
2435
2445
  title: t('main topic'), 'data-i18n-title': 'main topic',
2436
2446
  'aria-label': t('main topic'), 'aria-pressed': 'true'},
@@ -2466,7 +2476,7 @@ function refresh_legend(gobj)
2466
2476
  if(!hidden && entry.linked && entry.loose > 0) {
2467
2477
  extras.push(['button', {
2468
2478
  class: 'GRAPH_LEGEND_LOOSE button is-small' +
2469
- (entry.loose_shown? ' is-warning is-light' : ''),
2479
+ (entry.loose_shown? ' pressed_state' : ''),
2470
2480
  type: 'button', style: 'padding:0 .45rem;',
2471
2481
  title: t('loose records'), 'data-i18n-title': 'loose records',
2472
2482
  'aria-label': t('loose records'),
@@ -2483,7 +2493,7 @@ function refresh_legend(gobj)
2483
2493
  if(!hidden) {
2484
2494
  extras.push(['button', {
2485
2495
  class: 'GRAPH_LEGEND_FOCUS button is-small' +
2486
- (focused? ' is-primary is-light' : ''),
2496
+ (focused? ' pressed_state' : ''),
2487
2497
  type: 'button', style: 'padding:0 .4rem;',
2488
2498
  title: t('highlight topic'), 'data-i18n-title': 'highlight topic',
2489
2499
  'aria-label': t('highlight topic'),
@@ -3479,6 +3479,8 @@ function ac_paste_rows(gobj, event, kw, src)
3479
3479
  ************************************************************/
3480
3480
  function ac_form_save_record(gobj, event, kw, src)
3481
3481
  {
3482
+ let priv = gobj.priv;
3483
+
3482
3484
  if(refuse_if_readonly(gobj, event)) {
3483
3485
  return -1; /* Error already logged */
3484
3486
  }
@@ -0,0 +1,212 @@
1
+ /***********************************************************************
2
+ * priv_declared.test.js
3
+ *
4
+ * Every function that reads `priv` must have declared it.
5
+ *
6
+ * WHY THIS IS A TEST AND NOT A REVIEW HABIT: a missing
7
+ * `let priv = gobj.priv;` is a ReferenceError that throws only
8
+ * when its LINE runs, and the line that reads `priv` is usually
9
+ * the first line of a path nobody walks every day. One shipped in
10
+ * `ac_form_save_record()` (7.23.64, the "form is busy while it
11
+ * reads the picked files" guard) and it meant that for three
12
+ * releases NO record could be saved from a treedb form dialog, in
13
+ * any consumer — the record travelled, the action threw before
14
+ * writing it, and the dialog just stayed open.
15
+ *
16
+ * The rule is the one every gclass of this repo already follows:
17
+ * the function that uses `priv` opens with `let priv = gobj.priv;`,
18
+ * or takes it as an argument, or is nested inside one that did.
19
+ *
20
+ * Copyright (c) 2026, ArtGins.
21
+ * All Rights Reserved.
22
+ ***********************************************************************/
23
+ import {describe, it, expect} from "vitest";
24
+ import {readdirSync, readFileSync} from "node:fs";
25
+ import {dirname, join} from "node:path";
26
+ import {fileURLToPath} from "node:url";
27
+
28
+ const SRC = dirname(fileURLToPath(import.meta.url));
29
+
30
+
31
+ /***************************************************************
32
+ * The source with its comments blanked out, line count kept.
33
+ *
34
+ * A doc block that SHOWS the idiom (`priv.theme_observer = ...`)
35
+ * is not a use of it, and every early version of this check
36
+ * reported four of those before the one real bug.
37
+ ***************************************************************/
38
+ function without_comments(src)
39
+ {
40
+ let out = "";
41
+ let i = 0;
42
+ let state = "code"; /* code | line | block | s | d | t */
43
+
44
+ while(i < src.length) {
45
+ const c = src[i];
46
+ const d = src[i + 1];
47
+
48
+ if(state === "code") {
49
+ if(c === "/" && d === "/") {
50
+ state = "line";
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if(c === "/" && d === "*") {
55
+ state = "block";
56
+ out += " ";
57
+ i += 2;
58
+ continue;
59
+ }
60
+ if(c === "'") {
61
+ state = "s";
62
+ } else if(c === '"') {
63
+ state = "d";
64
+ } else if(c === "`") {
65
+ state = "t";
66
+ }
67
+ out += c;
68
+ i++;
69
+ continue;
70
+ }
71
+
72
+ if(state === "line") {
73
+ if(c === "\n") {
74
+ state = "code";
75
+ out += c;
76
+ }
77
+ i++;
78
+ continue;
79
+ }
80
+
81
+ if(state === "block") {
82
+ if(c === "*" && d === "/") {
83
+ state = "code";
84
+ out += " ";
85
+ i += 2;
86
+ continue;
87
+ }
88
+ out += (c === "\n") ? "\n" : " ";
89
+ i++;
90
+ continue;
91
+ }
92
+
93
+ /* Inside a string: it ends at its own quote, and a backslash
94
+ * escapes whatever follows. */
95
+ if(c === "\\") {
96
+ out += " ";
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if((state === "s" && c === "'") || (state === "d" && c === '"')
101
+ || (state === "t" && c === "`")) {
102
+ state = "code";
103
+ }
104
+ out += c;
105
+ i++;
106
+ }
107
+
108
+ return out;
109
+ }
110
+
111
+
112
+ /***************************************************************
113
+ * Every top-level function of a file, as {name, line, params,
114
+ * body}. A nested function belongs to the body of the one that
115
+ * holds it, which is what lets it use the parent's `priv`.
116
+ ***************************************************************/
117
+ function top_level_functions(src)
118
+ {
119
+ const lines = src.split("\n");
120
+ const starts = [];
121
+ for(let i = 0; i < lines.length; i++) {
122
+ if(/^(?:async )?function\s+\w+/.test(lines[i])) {
123
+ starts.push(i);
124
+ }
125
+ }
126
+ starts.push(lines.length);
127
+
128
+ const out = [];
129
+ for(let k = 0; k < starts.length - 1; k++) {
130
+ const a = starts[k];
131
+ const m = /^(?:async )?function\s+(\w+)\s*\(([^)]*)/.exec(lines[a]);
132
+ if(!m) {
133
+ continue;
134
+ }
135
+ out.push({
136
+ name: m[1],
137
+ line: a + 1,
138
+ params: m[2],
139
+ body: lines.slice(a, starts[k + 1]).join("\n"),
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+
145
+
146
+ describe("every function that reads `priv` declares it", () => {
147
+
148
+ it("no bare `priv.` without `priv =` in the same function", () => {
149
+ const files = readdirSync(SRC)
150
+ .filter((f) => f.endsWith(".js") && !f.endsWith(".test.js"));
151
+ expect(files.length).toBeGreaterThan(10);
152
+
153
+ const offenders = [];
154
+ for(const f of files) {
155
+ const src = without_comments(readFileSync(join(SRC, f), "utf8"));
156
+ for(const fn of top_level_functions(src)) {
157
+ if(/(?<![.\w])priv(?![\w])/.test(fn.params)) {
158
+ continue; /* it takes priv as an argument */
159
+ }
160
+ const uses = /(?<![.\w])priv\s*\./.test(fn.body);
161
+ const declared = /(?<![.\w])priv\s*=[^=]/.test(fn.body);
162
+ if(uses && !declared) {
163
+ offenders.push(`${f}:${fn.line} ${fn.name}()`);
164
+ }
165
+ }
166
+ }
167
+
168
+ expect(offenders).toEqual([]);
169
+ });
170
+
171
+ it("the checker sees a missing declaration", () => {
172
+ /* The guard needs its own guard: a check that cannot fail is
173
+ * a check nobody can trust. */
174
+ const src = [
175
+ "function ac_broken(gobj, event, kw, src)",
176
+ "{",
177
+ " if(priv.reading_files) {",
178
+ " return -1;",
179
+ " }",
180
+ " return 0;",
181
+ "}",
182
+ ].join("\n");
183
+ const fns = top_level_functions(without_comments(src));
184
+ expect(fns.length).toBe(1);
185
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(true);
186
+ expect(/(?<![.\w])priv\s*=[^=]/.test(fns[0].body)).toBe(false);
187
+ });
188
+
189
+ it("a doc block that shows the idiom is not a use of it", () => {
190
+ const src = [
191
+ "/* priv.theme_observer = yui_watch_theme(gobj); */",
192
+ "function yui_is_dark()",
193
+ "{",
194
+ " return yui_theme_now() === 'dark';",
195
+ "}",
196
+ ].join("\n");
197
+ const fns = top_level_functions(without_comments(src));
198
+ expect(fns.length).toBe(1);
199
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(false);
200
+ });
201
+
202
+ it("`gobj.priv.x` is not a bare `priv`", () => {
203
+ const src = [
204
+ "function reads_through_gobj(gobj)",
205
+ "{",
206
+ " return gobj.priv.something;",
207
+ "}",
208
+ ].join("\n");
209
+ const fns = top_level_functions(without_comments(src));
210
+ expect(/(?<![.\w])priv\s*\./.test(fns[0].body)).toBe(false);
211
+ });
212
+ });
@@ -142,6 +142,32 @@ export function spanning_tree(nodes, edges)
142
142
  return {roots: roots, children: children, depth: depth, by_id: by_id};
143
143
  }
144
144
 
145
+ /************************************************************
146
+ * The ids of a spanning tree in PARENT-BEFORE-CHILD order.
147
+ *
148
+ * Every pass below used to be a recursive walk, which is the natural
149
+ * way to write it and the one that dies on a deep tree: a
150
+ * self-referent hook (a place inside a place inside a place) is as
151
+ * deep as the data says, and the stack is not. Read this list
152
+ * forwards for a pre-order pass and BACKWARDS for a post-order one
153
+ * -- a child always sits after its parent, so reversed it always
154
+ * sits before it, which is the whole of what post-order was for.
155
+ ************************************************************/
156
+ function walk_order(t)
157
+ {
158
+ let order = [];
159
+ let stack = t.roots.slice().reverse();
160
+ while(stack.length) {
161
+ let id = stack.pop();
162
+ order.push(id);
163
+ let kids = t.children.get(id) || [];
164
+ for(let i = kids.length - 1; i >= 0; i--) {
165
+ stack.push(kids[i]);
166
+ }
167
+ }
168
+ return order;
169
+ }
170
+
145
171
  /************************************************************
146
172
  * The tidy tree, left to right.
147
173
  *
@@ -167,60 +193,54 @@ export function layout_tree(nodes, edges, opts)
167
193
  x += (col_w[d] || 0) + o.ranksep;
168
194
  }
169
195
 
170
- /* Subtree heights, post-order. */
196
+ /* Subtree heights: post-order, so children before parents. */
197
+ let order = walk_order(t);
171
198
  let height = new Map();
172
- let measure = (id) => {
199
+ for(let i = order.length - 1; i >= 0; i--) {
200
+ let id = order[i];
173
201
  let kids = t.children.get(id) || [];
174
202
  let own = t.by_id.get(id).h;
175
- if(!kids.length) {
176
- height.set(id, own);
177
- return own;
178
- }
179
203
  let sum = 0;
180
- for(let i = 0; i < kids.length; i++) {
181
- sum += measure(kids[i]);
182
- if(i > 0) {
204
+ for(let k = 0; k < kids.length; k++) {
205
+ sum += height.get(kids[k]);
206
+ if(k > 0) {
183
207
  sum += o.nodesep;
184
208
  }
185
209
  }
186
- let h = Math.max(own, sum);
187
- height.set(id, h);
188
- return h;
189
- };
190
- for(let root of t.roots) {
191
- measure(root);
210
+ height.set(id, kids.length? Math.max(own, sum) : own);
192
211
  }
193
212
 
194
213
  /* Placement, pre-order: a node is centred on the block of its
195
- * children, which starts at the top of its own block. */
196
- let place = (id, top) => {
197
- let n = t.by_id.get(id);
214
+ * children, which starts at the top of its own block. Every node
215
+ * in a column is centred on the column, whatever its own width --
216
+ * the cards of one depth line up by their middles. */
217
+ let tops = new Map();
218
+ let top = 0;
219
+ for(let root of t.roots) {
220
+ tops.set(root, top);
221
+ top += height.get(root) + o.nodesep * 2;
222
+ }
223
+ for(let id of order) {
198
224
  let d = t.depth.get(id);
199
225
  let kids = t.children.get(id) || [];
200
226
  let block = height.get(id);
201
- let cx = col_x[d] + (col_w[d] - n.w) / 2 + n.w / 2;
227
+ let own_top = tops.get(id);
228
+ pos.set(id, {x: col_x[d] + col_w[d] / 2, y: own_top + block / 2});
202
229
  if(!kids.length) {
203
- pos.set(id, {x: cx, y: top + block / 2});
204
- return;
230
+ continue;
205
231
  }
206
232
  let kids_h = 0;
207
- for(let i = 0; i < kids.length; i++) {
208
- kids_h += height.get(kids[i]);
209
- if(i > 0) {
233
+ for(let k = 0; k < kids.length; k++) {
234
+ kids_h += height.get(kids[k]);
235
+ if(k > 0) {
210
236
  kids_h += o.nodesep;
211
237
  }
212
238
  }
213
- let cursor = top + (block - kids_h) / 2;
239
+ let cursor = own_top + (block - kids_h) / 2;
214
240
  for(let kid of kids) {
215
- place(kid, cursor);
241
+ tops.set(kid, cursor);
216
242
  cursor += height.get(kid) + o.nodesep;
217
243
  }
218
- pos.set(id, {x: cx, y: top + block / 2});
219
- };
220
- let top = 0;
221
- for(let root of t.roots) {
222
- place(root, top);
223
- top += height.get(root) + o.nodesep * 2;
224
244
  }
225
245
 
226
246
  return pos;
@@ -253,24 +273,25 @@ export function layout_radial(nodes, edges, opts)
253
273
  return pos;
254
274
  }
255
275
 
256
- /* Leaves under each node: the sector it deserves. */
276
+ /* Leaves under each node: the sector it deserves. Post-order. */
277
+ let order = walk_order(t);
257
278
  let leaves = new Map();
258
- let count = (id) => {
279
+ for(let i = order.length - 1; i >= 0; i--) {
280
+ let id = order[i];
259
281
  let kids = t.children.get(id) || [];
260
282
  if(!kids.length) {
261
283
  leaves.set(id, 1);
262
- return 1;
284
+ continue;
263
285
  }
264
286
  let n = 0;
265
287
  for(let kid of kids) {
266
- n += count(kid);
288
+ n += leaves.get(kid);
267
289
  }
268
290
  leaves.set(id, n);
269
- return n;
270
- };
291
+ }
271
292
  let total = 0;
272
293
  for(let root of t.roots) {
273
- total += count(root);
294
+ total += leaves.get(root);
274
295
  }
275
296
 
276
297
  /* One root sits at the centre; several sit on the first ring
@@ -283,20 +304,27 @@ export function layout_radial(nodes, edges, opts)
283
304
  * Angles do not depend on the radii, so they come first. */
284
305
  let angle = new Map();
285
306
  let rings = []; /* ring -> [{id, angle, diag}] */
286
- let sector = (id, from, span) => {
287
- let a = from + span / 2;
288
- angle.set(id, a);
289
- let ring = ring_of(id);
290
- if(!rings[ring]) {
291
- rings[ring] = [];
292
- }
293
- let n = t.by_id.get(id);
294
- rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
295
- let cursor = from;
296
- for(let kid of (t.children.get(id) || [])) {
297
- let part = span * leaves.get(kid) / leaves.get(id);
298
- sector(kid, cursor, part);
299
- cursor += part;
307
+ /* An explicit stack, not recursion: the sectors of a ring are
308
+ * sorted by angle in pass 2, so the order they are pushed in does
309
+ * not matter -- only that every node gets its own. */
310
+ let sector = (root_id, root_from, root_span) => {
311
+ let stack = [{id: root_id, from: root_from, span: root_span}];
312
+ while(stack.length) {
313
+ let {id, from, span} = stack.pop();
314
+ let a = from + span / 2;
315
+ angle.set(id, a);
316
+ let ring = ring_of(id);
317
+ if(!rings[ring]) {
318
+ rings[ring] = [];
319
+ }
320
+ let n = t.by_id.get(id);
321
+ rings[ring].push({id: id, angle: a, diag: Math.hypot(n.w, n.h)});
322
+ let cursor = from;
323
+ for(let kid of (t.children.get(id) || [])) {
324
+ let part = span * leaves.get(kid) / leaves.get(id);
325
+ stack.push({id: kid, from: cursor, span: part});
326
+ cursor += part;
327
+ }
300
328
  }
301
329
  };
302
330
  if(single) {
@@ -362,13 +390,20 @@ export function layout_outline(nodes, edges, opts)
362
390
  let pos = new Map();
363
391
 
364
392
  let y = 0;
365
- let visit = (id) => {
366
- let n = t.by_id.get(id);
367
- let d = t.depth.get(id);
368
- pos.set(id, {x: d * o.indent + n.w / 2, y: y + n.h / 2});
369
- y += n.h + o.nodesep;
370
- for(let kid of (t.children.get(id) || [])) {
371
- visit(kid);
393
+ let visit = (root_id) => {
394
+ let stack = [root_id];
395
+ while(stack.length) {
396
+ let id = stack.pop();
397
+ let n = t.by_id.get(id);
398
+ let d = t.depth.get(id);
399
+ pos.set(id, {x: d * o.indent + n.w / 2, y: y + n.h / 2});
400
+ y += n.h + o.nodesep;
401
+ /* Reversed, so a sibling pops in the order it was given:
402
+ * here the order of the walk IS the order of the rows. */
403
+ let kids = t.children.get(id) || [];
404
+ for(let i = kids.length - 1; i >= 0; i--) {
405
+ stack.push(kids[i]);
406
+ }
372
407
  }
373
408
  };
374
409
  for(let root of t.roots) {
@@ -170,3 +170,35 @@ describe("the outline", () => {
170
170
  expect(pos.get("norte").y).toBe(96 + 10 + 48);
171
171
  });
172
172
  });
173
+
174
+ describe("a deep tree", () => {
175
+ /* A self-referent hook -- a place inside a place inside a place --
176
+ * is as deep as the data says. The walks are iterative for exactly
177
+ * this: recursive ones died here with "Maximum call stack size
178
+ * exceeded", and a graph that cannot be drawn is not a layout
179
+ * choice, it is an exception in the console. */
180
+ const DEEP = 20000;
181
+ const deep_nodes = [];
182
+ const deep_edges = [];
183
+ for(let i = 0; i < DEEP; i++) {
184
+ deep_nodes.push({id: `n${i}`, w: 100, h: 40});
185
+ if(i > 0) {
186
+ deep_edges.push({source: `n${i - 1}`, target: `n${i}`, rank: 0});
187
+ }
188
+ }
189
+
190
+ test("the spanning tree reaches the bottom", () => {
191
+ let t = spanning_tree(deep_nodes, deep_edges);
192
+ expect(t.roots).toEqual(["n0"]);
193
+ expect(t.depth.get(`n${DEEP - 1}`)).toBe(DEEP - 1);
194
+ });
195
+
196
+ test("the three layouts place every node of it", () => {
197
+ for(let layout of [layout_tree, layout_outline, layout_radial]) {
198
+ let pos = layout(deep_nodes, deep_edges);
199
+ expect(pos.size).toBe(DEEP);
200
+ expect(Number.isFinite(pos.get(`n${DEEP - 1}`).x)).toBe(true);
201
+ expect(Number.isFinite(pos.get(`n${DEEP - 1}`).y)).toBe(true);
202
+ }
203
+ });
204
+ });