@yuneta/gobj-ui 7.23.65 → 7.23.67

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.
@@ -37,6 +37,7 @@ import {
37
37
  kw_get_dict_value,
38
38
  gobj_short_name,
39
39
  gobj_read_str_attr,
40
+ gobj_read_integer_attr,
40
41
  gobj_destroy,
41
42
  json_object_update,
42
43
  is_object,
@@ -80,6 +81,7 @@ import {
80
81
  import {yui_toolbar} from "./yui_toolbar.js";
81
82
  import {attach_clear} from "./yui_inputs.js";
82
83
  import {register_c_g6_nodes_tree} from "./c_g6_nodes_tree.js";
84
+ import {yui_graph_fold_items} from "./yui_graph_camera.js";
83
85
  import {
84
86
  removeChildElements,
85
87
  disableElements,
@@ -107,6 +109,8 @@ SDATA(data_type_t.DTP_LIST, "operation_modes", 0,
107
109
  '["reading", "operation", "writing", "edition"]',
108
110
  "Available **permission** or behaviour modes. These operation modes are required to be accomplish by the graph handler (G6 child). TODO permissions must match treedb permissions."),
109
111
  SDATA(data_type_t.DTP_BOOLEAN, "with_treedb_tables",0, false, "Include treedb tables"),
112
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels of the treedb open when it loads: 1 = the roots alone, 2 = the roots and their children. Forwarded to the G6 child"),
113
+ SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page in the graph; a `+N` chip opens the next page. Forwarded to the G6 child"),
110
114
 
111
115
  /*---------------- User last selections ----------------*/
112
116
  SDATA(data_type_t.DTP_STRING, "operation_mode", sdata_flag_t.SDF_PERSIST, "reading", "Current operation mode (internal behaviour or role). Changed by the user trough the gui."),
@@ -240,6 +244,8 @@ function mt_create(gobj)
240
244
  with_treedb_tables: priv.with_treedb_tables,
241
245
  hook_port_position: "bottom",
242
246
  fkey_port_position: "top",
247
+ expand_depth: gobj_read_integer_attr(gobj, "expand_depth"),
248
+ fold_page_size: gobj_read_integer_attr(gobj, "fold_page_size"),
243
249
  },
244
250
  gobj
245
251
  );
@@ -563,6 +569,12 @@ function make_toolbar(gobj)
563
569
  }
564
570
  }],
565
571
 
572
+ /* The fold pair, the same two chevrons the JSON graph and the
573
+ * lazy tree use: the graph opens folded (see the G6 child), and
574
+ * these are the whole thing and the roots alone. Refresh is the
575
+ * way back to the default depth. */
576
+ ...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")),
577
+
566
578
  /* Which colour is which topic. The port colour of a node encodes
567
579
  * the topic it links to, which is the whole point of the graph and
568
580
  * which nothing on screen explained. Clicking an entry focuses that
@@ -2423,7 +2435,12 @@ function ac_legend_topic(gobj, event, kw, src)
2423
2435
  * DEDUPED: a legend that only announced moved the URL and focused
2424
2436
  * nothing. This is the same contract the topics view keeps, where
2425
2437
  * clicking a topic shows it and says so. */
2426
- gobj_send_event(gobj, "EV_SET_FOCUS_TOPIC", {topic: next}, gobj);
2438
+ /* `reveal: "all"`: a click on the legend is "show me these", and
2439
+ * these means every record of the topic, however deep it is folded
2440
+ * away. The route that lands on a topic sends no `reveal` and gets
2441
+ * one page -- it fires on every load, and a whole topic there is
2442
+ * the pile back before anybody touched anything. */
2443
+ gobj_send_event(gobj, "EV_SET_FOCUS_TOPIC", {topic: next, reveal: "all"}, gobj);
2427
2444
  gobj_publish_event(gobj, "EV_TOPIC_SELECTED", {topic: next});
2428
2445
  return 0;
2429
2446
  }
@@ -2466,6 +2483,28 @@ function ac_find_nodes(gobj, event, kw, src)
2466
2483
  return 0;
2467
2484
  }
2468
2485
 
2486
+ /************************************************************
2487
+ * The fold pair of the toolbar, forwarded to the G6 child,
2488
+ * which owns the tree.
2489
+ ************************************************************/
2490
+ function ac_expand_all(gobj, event, kw, src)
2491
+ {
2492
+ let priv = gobj.priv;
2493
+ if(priv.gobj_nodes_tree) {
2494
+ gobj_send_event(priv.gobj_nodes_tree, "EV_EXPAND_ALL", {}, gobj);
2495
+ }
2496
+ return 0;
2497
+ }
2498
+
2499
+ function ac_collapse_all(gobj, event, kw, src)
2500
+ {
2501
+ let priv = gobj.priv;
2502
+ if(priv.gobj_nodes_tree) {
2503
+ gobj_send_event(priv.gobj_nodes_tree, "EV_COLLAPSE_ALL", {}, gobj);
2504
+ }
2505
+ return 0;
2506
+ }
2507
+
2469
2508
  /************************************************************
2470
2509
  * How many nodes the term matched, from the graph child. Zero with a
2471
2510
  * term typed is an ANSWER and is shown; an empty box shows nothing.
@@ -2502,7 +2541,7 @@ function ac_set_focus_topic(gobj, event, kw, src)
2502
2541
  gobj_send_event(
2503
2542
  priv.gobj_nodes_tree,
2504
2543
  "EV_FOCUS_TOPIC",
2505
- {topic: priv.focus_topic},
2544
+ {topic: priv.focus_topic, reveal: (kw && kw.reveal) || "page"},
2506
2545
  gobj
2507
2546
  );
2508
2547
  }
@@ -2602,6 +2641,8 @@ function create_gclass(gclass_name)
2602
2641
  ["EV_SET_OPERATION_MODE", ac_set_operation_mode, null],
2603
2642
  ["EV_SET_FOCUS_TOPIC", ac_set_focus_topic, null],
2604
2643
  ["EV_FIND_NODES", ac_find_nodes, null],
2644
+ ["EV_EXPAND_ALL", ac_expand_all, null],
2645
+ ["EV_COLLAPSE_ALL", ac_collapse_all, null],
2605
2646
  ["EV_LAYOUT_AUTOSET", ac_layout_autoset, null],
2606
2647
  ["EV_TOGGLE_LEGEND", ac_toggle_legend, null],
2607
2648
  ["EV_LEGEND_TOPIC", ac_legend_topic, null],
@@ -2641,6 +2682,8 @@ function create_gclass(gclass_name)
2641
2682
  ["EV_SET_OPERATION_MODE", 0],
2642
2683
  ["EV_SET_FOCUS_TOPIC", 0],
2643
2684
  ["EV_FIND_NODES", 0],
2685
+ ["EV_EXPAND_ALL", 0],
2686
+ ["EV_COLLAPSE_ALL", 0],
2644
2687
  ["EV_FIND_RESULT", 0],
2645
2688
  ["EV_LAYOUT_AUTOSET", 0],
2646
2689
  ["EV_TOGGLE_LEGEND", 0],
@@ -0,0 +1,592 @@
1
+ /***********************************************************************
2
+ * treedb_fold_model.js
3
+ *
4
+ * A treedb read as a TREE, and which part of it is on screen.
5
+ *
6
+ * A treedb is a graph: a record hangs from every parent one of its
7
+ * fkeys names, and a parent reaches its children through a hook.
8
+ * Drawn whole it is a pile -- a nave with a hundred and forty
9
+ * devices is a row of a hundred and forty cards -- so the graph
10
+ * opens FOLDED, the way a JSON viewer opens: the roots, a level or
11
+ * two under them, and a count on every cut saying what went with it.
12
+ *
13
+ * This module is the arithmetic of that, and nothing else: no G6,
14
+ * no DOM, no gobj. The graph engine (c_g6_nodes_tree.js) asks it
15
+ * what is visible and draws exactly that.
16
+ *
17
+ * Vocabulary
18
+ *
19
+ * node key "topic^id" -- one record
20
+ * group the children of ONE node through ONE hook,
21
+ * keyed "topic^id|hook"; the roots of a topic are
22
+ * a group of their own, keyed "|topic"
23
+ * shown how many of a group's children are on screen:
24
+ * absent means folded, a number means the first
25
+ * N, in record order, a page at a time
26
+ *
27
+ * Two rules that are not obvious:
28
+ *
29
+ * - A node is visible when SOME visible parent shows it, or it is
30
+ * a shown root. A record with two parents (a device hangs from
31
+ * its place AND its controller) is drawn ONCE, under whichever
32
+ * opened first, and folding one of the two leaves it where the
33
+ * other one shows it. The union, never the intersection.
34
+ *
35
+ * - A parent that the engine does not draw an edge to is not a
36
+ * parent here either: the `extended` tier (hooks and no fkeys)
37
+ * gets no edges in draw_link(), so its children are roots. The
38
+ * tree on screen and the tree in this model have to agree, or a
39
+ * pill counts children that no line reaches.
40
+ *
41
+ * Copyright (c) 2026, ArtGins.
42
+ * All Rights Reserved.
43
+ ***********************************************************************/
44
+ import {
45
+ is_array,
46
+ is_object,
47
+ treedb_decoder_fkey,
48
+ } from "@yuneta/gobj-js";
49
+
50
+ const PAGE_SIZE_DEFAULT = 24;
51
+
52
+ /************************************************************
53
+ * Keys
54
+ ************************************************************/
55
+ export function fold_node_key(topic_name, id)
56
+ {
57
+ return `${topic_name}^${id}`;
58
+ }
59
+
60
+ export function fold_group_key(node_key, hook_name)
61
+ {
62
+ return `${node_key}|${hook_name}`;
63
+ }
64
+
65
+ export function fold_root_group_key(topic_name)
66
+ {
67
+ return `|${topic_name}`;
68
+ }
69
+
70
+ /* "topic^id|hook" -> {node_key, hook}; "|topic" -> {node_key: "", hook: topic} */
71
+ export function fold_split_group_key(group_key)
72
+ {
73
+ let i = group_key.lastIndexOf("|");
74
+ if(i < 0) {
75
+ return null;
76
+ }
77
+ return {node_key: group_key.slice(0, i), hook: group_key.slice(i + 1)};
78
+ }
79
+
80
+ /************************************************************
81
+ * Every parent a record names, decoded, in column order.
82
+ *
83
+ * Only the parents that will be DRAWN: a topic missing from
84
+ * `descs` (a system topic such as `__assets__`, never fetched)
85
+ * and an `extended` parent (no edge is drawn to one, see the
86
+ * header) are left out, so a child of nothing else is a root.
87
+ ************************************************************/
88
+ function parents_of(descs, desc, record)
89
+ {
90
+ let out = [];
91
+ let cols = desc.cols || [];
92
+
93
+ for(let i = 0; i < cols.length; i++) {
94
+ let col = cols[i];
95
+ if(!col.fkey) {
96
+ continue;
97
+ }
98
+ let fkeys = record[col.id];
99
+ if(!fkeys) {
100
+ continue;
101
+ }
102
+ let list = is_array(fkeys)? fkeys : [fkeys];
103
+ for(let j = 0; j < list.length; j++) {
104
+ let ref = treedb_decoder_fkey(col, list[j]);
105
+ if(!ref || !ref.topic_name || !ref.hook_name) {
106
+ continue; /* Error already logged by the decoder */
107
+ }
108
+ let parent_desc = descs[ref.topic_name];
109
+ if(!parent_desc) {
110
+ continue; /* a topic that is not drawn: no parent here */
111
+ }
112
+ if(parent_desc.node_treedb_type === "extended") {
113
+ continue; /* the engine draws no edge to it, see draw_link */
114
+ }
115
+ out.push({
116
+ node_key: fold_node_key(ref.topic_name, String(ref.id)),
117
+ hook: ref.hook_name,
118
+ fkey_name: col.id,
119
+ });
120
+ }
121
+ }
122
+
123
+ return out;
124
+ }
125
+
126
+ /************************************************************
127
+ * Build the model from the topic descriptions and their records.
128
+ *
129
+ * descs {topic_name: desc}, system topics included (skipped)
130
+ * records {topic_name: [record, ...]}, in the order the
131
+ * backend returned them -- that order is the order of
132
+ * the children on screen, and of the pages
133
+ *
134
+ * Returns
135
+ * {
136
+ * nodes: Map<node_key, {key, topic_name, id, record,
137
+ * parents: [{node_key, hook, fkey_name}],
138
+ * hooks: {hook: [child_key, ...]}}>
139
+ * groups: Map<group_key, [child_key, ...]> (roots included)
140
+ * topics: [topic_name, ...] topics with records, in desc order
141
+ * }
142
+ ************************************************************/
143
+ export function fold_build_model(descs, records)
144
+ {
145
+ let nodes = new Map();
146
+ let groups = new Map();
147
+ let topics = [];
148
+
149
+ descs = is_object(descs)? descs : {};
150
+ records = is_object(records)? records : {};
151
+
152
+ /* Pass 1: every record is a node. */
153
+ for(let topic_name of Object.keys(descs)) {
154
+ if(topic_name.substring(0, 2) === "__") {
155
+ continue;
156
+ }
157
+ let desc = descs[topic_name];
158
+ let list = records[topic_name];
159
+ if(!is_array(list)) {
160
+ continue;
161
+ }
162
+ topics.push(topic_name);
163
+ for(let i = 0; i < list.length; i++) {
164
+ let record = list[i];
165
+ if(!is_object(record) || record.id === undefined || record.id === null) {
166
+ continue;
167
+ }
168
+ let key = fold_node_key(topic_name, String(record.id));
169
+ nodes.set(key, {
170
+ key: key,
171
+ topic_name: topic_name,
172
+ id: String(record.id),
173
+ record: record,
174
+ parents: [],
175
+ hooks: {},
176
+ });
177
+ }
178
+ }
179
+
180
+ /* Pass 2: the parents, and through them the children groups. A
181
+ * parent that names a record nobody fetched is not a parent. */
182
+ for(let node of nodes.values()) {
183
+ let desc = descs[node.topic_name];
184
+ let parents = parents_of(descs, desc, node.record);
185
+ for(let p of parents) {
186
+ let parent = nodes.get(p.node_key);
187
+ if(!parent) {
188
+ continue;
189
+ }
190
+ node.parents.push(p);
191
+ if(!parent.hooks[p.hook]) {
192
+ parent.hooks[p.hook] = [];
193
+ }
194
+ parent.hooks[p.hook].push(node.key);
195
+ }
196
+ }
197
+
198
+ /* Children groups, in parent order (Map order = insertion = record
199
+ * order within a topic, topics in desc order). */
200
+ for(let node of nodes.values()) {
201
+ for(let hook of Object.keys(node.hooks)) {
202
+ groups.set(fold_group_key(node.key, hook), node.hooks[hook]);
203
+ }
204
+ }
205
+
206
+ /* Roots: no drawable parent. Then whatever the roots cannot reach
207
+ * -- a cycle with no way in -- becomes a root too, so that every
208
+ * record has a place on screen to be opened from. */
209
+ let roots = {};
210
+ for(let topic_name of topics) {
211
+ roots[topic_name] = [];
212
+ }
213
+ for(let node of nodes.values()) {
214
+ if(node.parents.length === 0) {
215
+ roots[node.topic_name].push(node.key);
216
+ }
217
+ }
218
+ let reached = reach_from(nodes, [].concat(...Object.values(roots)));
219
+ for(let node of nodes.values()) {
220
+ if(!reached.has(node.key)) {
221
+ roots[node.topic_name].push(node.key);
222
+ /* Its own subtree is reachable now; mark it so a whole
223
+ * cycle does not become a row of roots. */
224
+ for(let k of reach_from(nodes, [node.key])) {
225
+ reached.add(k);
226
+ }
227
+ }
228
+ }
229
+ for(let topic_name of topics) {
230
+ if(roots[topic_name].length > 0) {
231
+ groups.set(fold_root_group_key(topic_name), roots[topic_name]);
232
+ }
233
+ }
234
+
235
+ return {nodes: nodes, groups: groups, topics: topics};
236
+ }
237
+
238
+ /************************************************************
239
+ * Everything reachable downwards from `start`, cycles included.
240
+ ************************************************************/
241
+ function reach_from(nodes, start)
242
+ {
243
+ let seen = new Set();
244
+ let queue = start.slice();
245
+ while(queue.length) {
246
+ let key = queue.shift();
247
+ if(seen.has(key)) {
248
+ continue;
249
+ }
250
+ seen.add(key);
251
+ let node = nodes.get(key);
252
+ if(!node) {
253
+ continue;
254
+ }
255
+ for(let hook of Object.keys(node.hooks)) {
256
+ for(let child of node.hooks[hook]) {
257
+ if(!seen.has(child)) {
258
+ queue.push(child);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ return seen;
264
+ }
265
+
266
+ /************************************************************
267
+ * Fold state: which groups are open, and how far.
268
+ ************************************************************/
269
+ export function fold_new_state(page_size)
270
+ {
271
+ return {
272
+ page_size: (page_size > 0)? page_size : PAGE_SIZE_DEFAULT,
273
+ shown: new Map(), /* group_key -> number of children shown */
274
+ };
275
+ }
276
+
277
+ export function fold_group_total(model, group_key)
278
+ {
279
+ let list = model.groups.get(group_key);
280
+ return list? list.length : 0;
281
+ }
282
+
283
+ export function fold_group_shown(model, state, group_key)
284
+ {
285
+ let total = fold_group_total(model, group_key);
286
+ let n = state.shown.get(group_key) || 0;
287
+ return Math.min(n, total);
288
+ }
289
+
290
+ export function fold_is_open(state, group_key)
291
+ {
292
+ return (state.shown.get(group_key) || 0) > 0;
293
+ }
294
+
295
+ /* The next page boundary at or above `n`: 25 -> 48 with pages of 24. */
296
+ function page_ceiling(state, n)
297
+ {
298
+ let p = state.page_size;
299
+ return Math.ceil(n / p) * p;
300
+ }
301
+
302
+ /************************************************************
303
+ * Open a group on its first page, or close it.
304
+ * Returns true when it ends OPEN.
305
+ ************************************************************/
306
+ export function fold_toggle(model, state, group_key)
307
+ {
308
+ if(fold_is_open(state, group_key)) {
309
+ state.shown.delete(group_key);
310
+ return false;
311
+ }
312
+ if(fold_group_total(model, group_key) === 0) {
313
+ return false; /* nothing to open */
314
+ }
315
+ state.shown.set(group_key, state.page_size);
316
+ return true;
317
+ }
318
+
319
+ /************************************************************
320
+ * One more page of a group. Returns how many are shown now.
321
+ ************************************************************/
322
+ export function fold_show_more(model, state, group_key)
323
+ {
324
+ let total = fold_group_total(model, group_key);
325
+ let n = fold_group_shown(model, state, group_key) + state.page_size;
326
+ n = Math.min(n, total);
327
+ if(n > 0) {
328
+ state.shown.set(group_key, n);
329
+ }
330
+ return n;
331
+ }
332
+
333
+ /************************************************************
334
+ * Every group open on ALL its children. This is the reader
335
+ * asking for the whole thing; the pages are for the other way in.
336
+ ************************************************************/
337
+ export function fold_expand_all(model, state)
338
+ {
339
+ state.shown.clear();
340
+ for(let [group_key, list] of model.groups) {
341
+ if(list.length > 0) {
342
+ state.shown.set(group_key, list.length);
343
+ }
344
+ }
345
+ }
346
+
347
+ /************************************************************
348
+ * The roots and nothing else -- the first page of each topic's
349
+ * roots, the way the graph looks before anything is opened.
350
+ ************************************************************/
351
+ export function fold_collapse_all(model, state)
352
+ {
353
+ state.shown.clear();
354
+ for(let topic_name of model.topics) {
355
+ let key = fold_root_group_key(topic_name);
356
+ if(fold_group_total(model, key) > 0) {
357
+ state.shown.set(key, state.page_size);
358
+ }
359
+ }
360
+ }
361
+
362
+ /************************************************************
363
+ * Open the tree down to `depth` levels: the roots are level 1,
364
+ * their children level 2. A node reached by two paths takes the
365
+ * shallowest. Each group opens on its first page. `depth` of 1
366
+ * is the roots alone; anything below that is treated as 1.
367
+ ************************************************************/
368
+ export function fold_expand_to_depth(model, state, depth)
369
+ {
370
+ fold_collapse_all(model, state);
371
+ if(!(depth > 1)) {
372
+ return;
373
+ }
374
+
375
+ let level = new Map();
376
+ let queue = [];
377
+ for(let topic_name of model.topics) {
378
+ let roots = model.groups.get(fold_root_group_key(topic_name)) || [];
379
+ for(let key of roots) {
380
+ if(!level.has(key)) {
381
+ level.set(key, 1);
382
+ queue.push(key);
383
+ }
384
+ }
385
+ }
386
+ while(queue.length) {
387
+ let key = queue.shift();
388
+ let lv = level.get(key);
389
+ if(lv >= depth) {
390
+ continue;
391
+ }
392
+ let node = model.nodes.get(key);
393
+ if(!node) {
394
+ continue;
395
+ }
396
+ for(let hook of Object.keys(node.hooks)) {
397
+ let children = node.hooks[hook];
398
+ if(children.length === 0) {
399
+ continue;
400
+ }
401
+ state.shown.set(fold_group_key(key, hook), state.page_size);
402
+ for(let child of children) {
403
+ if(!level.has(child)) {
404
+ level.set(child, lv + 1);
405
+ queue.push(child);
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ /************************************************************
413
+ * The set of node keys on screen, from the roots down through
414
+ * every open group -- and only through open groups whose parent
415
+ * is itself on screen: a group remembered open under a folded
416
+ * ancestor stays remembered, not shown.
417
+ ************************************************************/
418
+ export function fold_visible_set(model, state)
419
+ {
420
+ let visible = new Set();
421
+ let queue = [];
422
+
423
+ let push_group = (group_key, children) => {
424
+ let n = fold_group_shown(model, state, group_key);
425
+ for(let i = 0; i < n; i++) {
426
+ let key = children[i];
427
+ if(!visible.has(key)) {
428
+ visible.add(key);
429
+ queue.push(key);
430
+ }
431
+ }
432
+ };
433
+
434
+ for(let topic_name of model.topics) {
435
+ let key = fold_root_group_key(topic_name);
436
+ let roots = model.groups.get(key);
437
+ if(roots) {
438
+ push_group(key, roots);
439
+ }
440
+ }
441
+ while(queue.length) {
442
+ let key = queue.shift();
443
+ let node = model.nodes.get(key);
444
+ if(!node) {
445
+ continue;
446
+ }
447
+ for(let hook of Object.keys(node.hooks)) {
448
+ push_group(fold_group_key(key, hook), node.hooks[hook]);
449
+ }
450
+ }
451
+
452
+ return visible;
453
+ }
454
+
455
+ /************************************************************
456
+ * The open groups that still have children off screen, with the
457
+ * parent that owns each -- what the "+N" chips are drawn for.
458
+ * Only groups whose parent is visible (or root groups).
459
+ *
460
+ * Returns [{group_key, node_key, hook, shown, total, rest}]
461
+ ************************************************************/
462
+ export function fold_pending_groups(model, state, visible)
463
+ {
464
+ let out = [];
465
+ for(let [group_key, list] of model.groups) {
466
+ let shown = fold_group_shown(model, state, group_key);
467
+ if(shown === 0 || shown >= list.length) {
468
+ continue;
469
+ }
470
+ let g = fold_split_group_key(group_key);
471
+ if(g.node_key && !visible.has(g.node_key)) {
472
+ continue;
473
+ }
474
+ out.push({
475
+ group_key: group_key,
476
+ node_key: g.node_key,
477
+ hook: g.hook,
478
+ shown: shown,
479
+ total: list.length,
480
+ rest: list.length - shown,
481
+ });
482
+ }
483
+ return out;
484
+ }
485
+
486
+ /************************************************************
487
+ * The pills of one card: one per hook that has children.
488
+ *
489
+ * Returns [{hook, group_key, total, shown, open, child_topic}]
490
+ * `child_topic` is the topic of the first child, which is what
491
+ * the pill is coloured with (a hook can name several topics in
492
+ * the schema, but the children it has are what it links).
493
+ ************************************************************/
494
+ export function fold_pills_of(model, state, node_key)
495
+ {
496
+ let node = model.nodes.get(node_key);
497
+ if(!node) {
498
+ return [];
499
+ }
500
+ let out = [];
501
+ for(let hook of Object.keys(node.hooks)) {
502
+ let children = node.hooks[hook];
503
+ if(children.length === 0) {
504
+ continue;
505
+ }
506
+ let group_key = fold_group_key(node_key, hook);
507
+ let first = model.nodes.get(children[0]);
508
+ out.push({
509
+ hook: hook,
510
+ group_key: group_key,
511
+ total: children.length,
512
+ shown: fold_group_shown(model, state, group_key),
513
+ open: fold_is_open(state, group_key),
514
+ child_topic: first? first.topic_name : "",
515
+ });
516
+ }
517
+ return out;
518
+ }
519
+
520
+ /************************************************************
521
+ * Make ONE node visible, opening the groups on a path from a
522
+ * root to it, and as many pages of each as it takes for the
523
+ * node to be inside the shown prefix. Prefers the shortest path
524
+ * (breadth-first upwards). Returns false if no root reaches it,
525
+ * which cannot happen with a model built here (see roots) but
526
+ * is the honest answer for a key the model does not know.
527
+ ************************************************************/
528
+ export function fold_reveal(model, state, node_key)
529
+ {
530
+ let node = model.nodes.get(node_key);
531
+ if(!node) {
532
+ return false;
533
+ }
534
+
535
+ /* Upwards BFS: from the node to the first ancestor that is a
536
+ * root, remembering how each step was reached. */
537
+ let root_group_of = (key) => {
538
+ let n = model.nodes.get(key);
539
+ let rg = fold_root_group_key(n.topic_name);
540
+ let roots = model.groups.get(rg) || [];
541
+ return roots.indexOf(key) >= 0? rg : null;
542
+ };
543
+
544
+ let via = new Map(); /* key -> {parent_key, hook} */
545
+ let queue = [node_key];
546
+ via.set(node_key, null);
547
+ let root_key = null;
548
+ while(queue.length) {
549
+ let key = queue.shift();
550
+ if(root_group_of(key)) {
551
+ root_key = key;
552
+ break;
553
+ }
554
+ let n = model.nodes.get(key);
555
+ for(let p of n.parents) {
556
+ if(!model.nodes.has(p.node_key) || via.has(p.node_key)) {
557
+ continue;
558
+ }
559
+ via.set(p.node_key, {child_key: key, hook: p.hook});
560
+ queue.push(p.node_key);
561
+ }
562
+ }
563
+ if(root_key === null) {
564
+ return false;
565
+ }
566
+
567
+ /* Walk back down, opening each group far enough. */
568
+ let open_up_to = (group_key, child_key) => {
569
+ let list = model.groups.get(group_key) || [];
570
+ let idx = list.indexOf(child_key);
571
+ if(idx < 0) {
572
+ return;
573
+ }
574
+ let need = page_ceiling(state, idx + 1);
575
+ let have = state.shown.get(group_key) || 0;
576
+ if(need > have) {
577
+ state.shown.set(group_key, Math.min(need, list.length));
578
+ }
579
+ };
580
+
581
+ open_up_to(root_group_of(root_key), root_key);
582
+ let key = root_key;
583
+ while(true) {
584
+ let step = via.get(key);
585
+ if(!step) {
586
+ break;
587
+ }
588
+ open_up_to(fold_group_key(key, step.hook), step.child_key);
589
+ key = step.child_key;
590
+ }
591
+ return true;
592
+ }