@yuneta/gobj-ui 7.23.64 → 7.23.66

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.
@@ -10,6 +10,23 @@
10
10
  * record: null, // Data of node
11
11
  * }
12
12
  *
13
+ * The graph opens FOLDED, like a JSON viewer.
14
+ *
15
+ * Every record of every topic is fetched, but only the ones on
16
+ * screen become G6 nodes: the roots, `expand_depth` levels under
17
+ * them, one page (`fold_page_size`) of children per hook. Each
18
+ * card carries a pill per hook that has children -- `▸ devices
19
+ * 142` -- which opens or folds that hook; an open hook with more
20
+ * children than its page ends in a `+N` chip that opens the next
21
+ * page. The arithmetic lives in treedb_fold_model.js; this file
22
+ * reconciles the G6 data with what that model says is visible
23
+ * (see reconcile_fold) and lays it out again.
24
+ *
25
+ * `dagre` reads LEFT TO RIGHT: the children of a node are a
26
+ * column beside it, the ports move to the sides with it, and
27
+ * the edges are horizontal beziers. `antv-dagre` keeps the
28
+ * top-down reading.
29
+ *
13
30
  * Copyright (c) 2020-2021 Niyamaka.
14
31
  * Copyright (c) 2025-2026, ArtGins.
15
32
  * All Rights Reserved.
@@ -87,7 +104,26 @@ import {
87
104
 
88
105
  import {node_label} from "./treedb_node_label.js";
89
106
  import {delete_impact} from "./delete_impact.js";
90
- import {yui_graph_center_on} from "./yui_graph_camera.js";
107
+ import {
108
+ yui_graph_center_on,
109
+ yui_graph_viewport_of,
110
+ yui_graph_place_at,
111
+ } from "./yui_graph_camera.js";
112
+ import {
113
+ fold_build_model,
114
+ fold_new_state,
115
+ fold_visible_set,
116
+ fold_expand_to_depth,
117
+ fold_expand_all,
118
+ fold_collapse_all,
119
+ fold_toggle,
120
+ fold_show_more,
121
+ fold_reveal,
122
+ fold_pills_of,
123
+ fold_pending_groups,
124
+ fold_node_key,
125
+ fold_split_group_key,
126
+ } from "./treedb_fold_model.js";
91
127
 
92
128
  import {
93
129
  BaseLayout,
@@ -218,11 +254,23 @@ const _layouts = {
218
254
  "manual": {
219
255
  type: 'manual',
220
256
  },
257
+ /* LEFT TO RIGHT, the house direction for a topology: a parent's
258
+ * children stack in a column beside it and read like a file tree.
259
+ * The separations are explicit because the default ones put the
260
+ * cards edge to edge. `antv-dagre` below keeps the top-down reading
261
+ * for whoever wants it -- the two are the same algorithm read two
262
+ * ways, so the layout picker is also the direction picker. */
221
263
  "dagre": {
222
264
  type: 'dagre',
265
+ rankdir: 'LR',
266
+ nodesep: 18,
267
+ ranksep: 110,
223
268
  },
224
269
  "antv-dagre": {
225
270
  type: 'antv-dagre',
271
+ rankdir: 'TB',
272
+ nodesep: 30,
273
+ ranksep: 80,
226
274
  },
227
275
  "d3-force": {
228
276
  type: 'd3-force',
@@ -289,6 +337,10 @@ SDATA(data_type_t.DTP_LIST, "layout_names", sdata_flag_t.SDF_RD,
289
337
 
290
338
  SDATA(data_type_t.DTP_STRING, "hook_port_position", 0, "bottom", "Hook port position"),
291
339
  SDATA(data_type_t.DTP_STRING, "fkey_port_position", 0, "top", "Fkey port position"),
340
+
341
+ /*---------------- Folding ----------------*/
342
+ SDATA(data_type_t.DTP_INTEGER, "expand_depth", 0, 2, "Levels open when a treedb loads: 1 = the roots alone, 2 = the roots and their children. Every open hook shows its first page"),
343
+ SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of one hook shown per page; the `+N` chip after the page opens the next one"),
292
344
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
293
345
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
294
346
 
@@ -370,8 +422,17 @@ let PRIVATE_DATA = {
370
422
  _pending_focus_topic: null, // focus requested before data was loaded
371
423
  _pending_find: null, // find requested before data was loaded
372
424
  _layout_asked: "", // layout the host asked for at create (see mt_create)
373
- _nodes_total: 0, // nodes built so far (auto_layout)
374
- _nodes_placed: 0, // ...of which carried a saved position
425
+ _nodes_total: 0, // records of the treedb (auto_layout)
426
+ _nodes_placed: 0, // ...of which carry a saved position
427
+
428
+ /*---------------- folding ----------------*/
429
+ expand_depth: 2,
430
+ fold_page_size: 24,
431
+ _fold_model: null, // treedb_fold_model: the tree of the records
432
+ _fold_state: null, // ...and which groups of it are open
433
+ _fold_listener: null, // delegated click on the card pills
434
+ _pill_sig: null, // Map<node_id, string>: the pills a card was painted with
435
+ _fold_busy: false, // a reconcile is in flight
375
436
  };
376
437
 
377
438
  let __gclass__ = null;
@@ -429,6 +490,7 @@ function mt_create(gobj)
429
490
  build_ui(gobj);
430
491
  register_layouts(gobj);
431
492
  build_graph(gobj);
493
+ install_fold_listener(gobj);
432
494
 
433
495
  /* Self-contained resize: the old shell pushed EV_RESIZE from
434
496
  * __yui_main__; the new C_YUI_SHELL does not. Observe our own
@@ -489,6 +551,8 @@ function mt_destroy(gobj)
489
551
  priv._on_language_changed = null;
490
552
  }
491
553
 
554
+ uninstall_fold_listener(gobj);
555
+
492
556
  if(priv._on_pointerdown_focus) {
493
557
  priv.$container.removeEventListener(
494
558
  "pointerdown", priv._on_pointerdown_focus, true
@@ -864,9 +928,10 @@ function configure_events(gobj)
864
928
  }
865
929
  });
866
930
 
867
- // Re-render G6 toolbars when language changes
931
+ // Re-render G6 toolbars (and the `+N` chips' titles) when language changes
868
932
  priv._on_language_changed = () => {
869
933
  update_toolbar(gobj);
934
+ repaint_more_chips(gobj);
870
935
  };
871
936
  i18next.on('languageChanged', priv._on_language_changed);
872
937
 
@@ -1681,22 +1746,107 @@ function build_ports(gobj, desc)
1681
1746
  }
1682
1747
  }
1683
1748
 
1684
- /*
1685
- * Place the ports
1686
- */
1687
- for(let i=0; i<top_ports.length; i++) {
1688
- top_ports[i].placement = [0.5, 0]; // all from same top point
1749
+ place_ports(gobj, top_ports, bottom_ports);
1750
+
1751
+ return [...top_ports, ...bottom_ports];
1752
+ }
1753
+
1754
+ /************************************************************
1755
+ * Which way the graph reads. `dagre` is the left-to-right
1756
+ * layout; everything else keeps the top-down reading, `manual`
1757
+ * included, because the saved arrangements were made with the
1758
+ * ports on the top and bottom edges.
1759
+ ************************************************************/
1760
+ function layout_direction(gobj)
1761
+ {
1762
+ return (gobj.priv.layout === "dagre")? "LR" : "TB";
1763
+ }
1764
+
1765
+ function edge_type_for(gobj)
1766
+ {
1767
+ return (layout_direction(gobj) === "LR")? "cubic-horizontal" : "cubic";
1768
+ }
1769
+
1770
+ /************************************************************
1771
+ * Where the ports sit, by direction: fkeys (the way in from a
1772
+ * parent) all on one point of the leading edge, hooks (the way
1773
+ * out to the children) spread along the trailing edge. Left and
1774
+ * right when the graph reads left to right; top and bottom
1775
+ * otherwise.
1776
+ ************************************************************/
1777
+ function place_ports(gobj, fkey_ports, hook_ports)
1778
+ {
1779
+ let lr = (layout_direction(gobj) === "LR");
1780
+
1781
+ for(let i = 0; i < fkey_ports.length; i++) {
1782
+ fkey_ports[i].placement = lr? [0, 0.5] : [0.5, 0];
1689
1783
  }
1690
- for(let i=0; i<bottom_ports.length; i++) {
1691
- let point = getPointPosition(bottom_ports.length, i);
1692
- if(top_ports.length === 0) {
1693
- bottom_ports[i].placement = [0, 0.5];
1784
+ for(let i = 0; i < hook_ports.length; i++) {
1785
+ let point = getPointPosition(hook_ports.length, i);
1786
+ if(fkey_ports.length === 0 && !lr) {
1787
+ hook_ports[i].placement = [0, 0.5];
1788
+ } else if(lr) {
1789
+ hook_ports[i].placement = [1, point];
1694
1790
  } else {
1695
- bottom_ports[i].placement = [point, 1];
1791
+ hook_ports[i].placement = [point, 1];
1696
1792
  }
1697
1793
  }
1794
+ }
1698
1795
 
1699
- return [...top_ports, ...bottom_ports];
1796
+ /************************************************************
1797
+ * The layout changed direction: move every port to the edge
1798
+ * the new reading needs, and give every edge the curve that
1799
+ * goes with it. Called before the layout runs, so the lines
1800
+ * are drawn once, in the right place.
1801
+ ************************************************************/
1802
+ function apply_layout_direction(gobj)
1803
+ {
1804
+ let priv = gobj.priv;
1805
+ let graph = priv.graph;
1806
+
1807
+ if(!graph) {
1808
+ return;
1809
+ }
1810
+
1811
+ let node_updates = [];
1812
+ for(let nd of (graph.getNodeData() || [])) {
1813
+ if(!nd || !nd.data || !nd.data.desc || !nd.style || !is_array(nd.style.ports)) {
1814
+ continue;
1815
+ }
1816
+ let desc = nd.data.desc;
1817
+ let fkey_ports = [];
1818
+ let hook_ports = [];
1819
+ for(let port of nd.style.ports) {
1820
+ let col = get_col_by_id(desc, port.key);
1821
+ let copy = Object.assign({}, port);
1822
+ if(col && col.fkey) {
1823
+ fkey_ports.push(copy);
1824
+ } else {
1825
+ hook_ports.push(copy);
1826
+ }
1827
+ }
1828
+ place_ports(gobj, fkey_ports, hook_ports);
1829
+ node_updates.push({id: nd.id, style: {ports: [...fkey_ports, ...hook_ports]}});
1830
+ }
1831
+
1832
+ let type = edge_type_for(gobj);
1833
+ let edge_updates = [];
1834
+ for(let ed of (graph.getEdgeData() || [])) {
1835
+ if(ed && ed.type !== type) {
1836
+ edge_updates.push({id: ed.id, type: type});
1837
+ }
1838
+ }
1839
+
1840
+ try {
1841
+ if(node_updates.length) {
1842
+ graph.updateNodeData(node_updates);
1843
+ }
1844
+ if(edge_updates.length) {
1845
+ graph.updateEdgeData(edge_updates);
1846
+ }
1847
+ } catch(e) {
1848
+ log_error(`${gobj_short_name(gobj)}: cannot turn the ports: ${e}`);
1849
+ }
1700
1850
  }
1701
1851
 
1702
1852
  /************************************************************
@@ -1714,18 +1864,14 @@ function create_topic_node(gobj, desc, record)
1714
1864
  let xy = get_default_ne_xy(gobj);
1715
1865
  let geometry = get_node_graph_props(gobj, desc.topic_name, record.id, record);
1716
1866
 
1717
- /* Counted HERE, and before the two kw_get_int() below, because those
1718
- * carry KW_CREATE: `get_node_graph_props()` hands back the record's own
1719
- * `_geometry` object when it has one — and `{}` is one — so the very
1720
- * next lines WRITE the invented cascade coordinates into it. Ask the
1721
- * question a moment later and every node of every treedb answers
1722
- * "placed", which is exactly how the dagre default kept refusing to
1723
- * fire: 126 of 126, all of them positions the app had just made up. */
1724
- priv._nodes_total++;
1725
- if(geometry_has_position(geometry)) {
1726
- priv._nodes_placed++;
1727
- }
1728
-
1867
+ /* The two kw_get_int() below carry KW_CREATE: `get_node_graph_props()`
1868
+ * hands back the record's own `_geometry` object when it has one — and
1869
+ * `{}` is one — so they WRITE the invented cascade coordinates into
1870
+ * it. That is why the placed/total count auto_layout decides on is
1871
+ * taken over the records BEFORE any card is built
1872
+ * (count_placed_records): asked afterwards, every node of every
1873
+ * treedb answers "placed" -- 126 of 126, all of them positions the
1874
+ * app had just made up. */
1729
1875
  let x = kw_get_int(gobj, geometry, "x", xy, kw_flag_t.KW_CREATE);
1730
1876
  let y = kw_get_int(gobj, geometry, "y", xy, kw_flag_t.KW_CREATE);
1731
1877
 
@@ -1745,13 +1891,18 @@ function create_topic_node(gobj, desc, record)
1745
1891
  //labelText: desc.topic_name + "^" + record.id,
1746
1892
  };
1747
1893
 
1894
+ /* The pills are part of the card, so the card is taller when it
1895
+ * has any: a row of them on a 96px card sat on the subtitle. */
1896
+ let pills_html = pills_html_of_key(gobj, fold_node_key(desc.topic_name, String(record.id)));
1897
+ let size = card_size_for(node_treedb_type, !!pills_html);
1898
+
1748
1899
  if(node_treedb_type === 'child') {
1749
1900
  // Pure child (LEAF): smallest tier. Rounded-rect chip,
1750
1901
  // same card language as entities, lighter.
1751
1902
  node_graph_type = 'html';
1752
- style.size = [116, 40];
1753
- style.dx = -58;
1754
- style.dy = -20;
1903
+ style.size = size;
1904
+ style.dx = -size[0] / 2;
1905
+ style.dy = -size[1] / 2;
1755
1906
  style.innerHTML = build_chip_innerHTML(
1756
1907
  desc.color, priv.theme, record.icon,
1757
1908
  node_label(desc, record), record.id
@@ -1762,23 +1913,23 @@ function create_topic_node(gobj, desc, record)
1762
1913
  // with name; "structural" style (neutral grey, dashed
1763
1914
  // border) to read as a container/junction.
1764
1915
  node_graph_type = 'html';
1765
- style.size = [144, 66];
1766
- style.dx = -72;
1767
- style.dy = -33;
1916
+ style.size = size;
1917
+ style.dx = -size[0] / 2;
1918
+ style.dy = -size[1] / 2;
1768
1919
  style.innerHTML = build_node_innerHTML(
1769
1920
  desc.color, priv.theme, record.icon, node_label(desc, record),
1770
- desc.topic_name, true, record.id
1921
+ desc.topic_name, true, record.id, false, false, false, pills_html
1771
1922
  );
1772
1923
 
1773
1924
  } else {
1774
1925
  // Hierarchical entity (ROOT / container): largest tier.
1775
1926
  node_graph_type = 'html';
1776
- style.size = [172, 96];
1777
- style.dx = -86;
1778
- style.dy = -48;
1927
+ style.size = size;
1928
+ style.dx = -size[0] / 2;
1929
+ style.dy = -size[1] / 2;
1779
1930
  style.innerHTML = build_node_innerHTML(
1780
1931
  desc.color, priv.theme, record.icon, node_label(desc, record),
1781
- desc.topic_name, false, record.id
1932
+ desc.topic_name, false, record.id, false, false, false, pills_html
1782
1933
  );
1783
1934
  }
1784
1935
 
@@ -1875,40 +2026,9 @@ function update_topic_node(gobj, desc, node_name, record)
1875
2026
  // Update record data
1876
2027
  nodedata.data.record = record;
1877
2028
 
1878
- // Regenerate the card/chip innerHTML for the new record
1879
- if(desc.node_treedb_type === 'child') {
1880
- graph.updateNodeData([{
1881
- id: node_name,
1882
- style: {
1883
- innerHTML: build_chip_innerHTML(
1884
- desc.color, priv.theme, record.icon,
1885
- node_label(desc, record), record.id
1886
- ),
1887
- }
1888
- }]);
1889
- } else if(desc.node_treedb_type === 'extended') {
1890
- graph.updateNodeData([{
1891
- id: node_name,
1892
- style: {
1893
- innerHTML: build_node_innerHTML(
1894
- desc.color, priv.theme, record.icon,
1895
- node_label(desc, record),
1896
- desc.topic_name, true, record.id
1897
- ),
1898
- }
1899
- }]);
1900
- } else if(desc.node_treedb_type === 'hierarchical') {
1901
- graph.updateNodeData([{
1902
- id: node_name,
1903
- style: {
1904
- innerHTML: build_node_innerHTML(
1905
- desc.color, priv.theme, record.icon,
1906
- node_label(desc, record),
1907
- desc.topic_name, false, record.id
1908
- ),
1909
- }
1910
- }]);
1911
- }
2029
+ /* Regenerate the card for the new record, with the flags it
2030
+ * carries right now (repaint_cards reads them where they live). */
2031
+ repaint_cards(gobj, new Set([node_name]));
1912
2032
  } catch(e) {
1913
2033
  log_error(e.message);
1914
2034
  }
@@ -1972,22 +2092,6 @@ function remove_local_node(gobj, topic_name, node)
1972
2092
  }
1973
2093
  }
1974
2094
 
1975
- /************************************************************
1976
- * Create all links from records, initial load from parent
1977
- ************************************************************/
1978
- function create_links(gobj)
1979
- {
1980
- let priv = gobj.priv;
1981
-
1982
- for(const [topic_name, records] of Object.entries(priv.records)) {
1983
- let desc = priv.descs[topic_name];
1984
- for(let i = 0; i < records.length; i++) {
1985
- let record = records[i];
1986
- draw_links(gobj, desc, record, true);
1987
- }
1988
- }
1989
- }
1990
-
1991
2095
  /************************************************************
1992
2096
  * Collect all fkey references from a record as a Set.
1993
2097
  *
@@ -2115,9 +2219,30 @@ function draw_link(
2115
2219
  }
2116
2220
 
2117
2221
  /*
2118
- * Child node (me)
2222
+ * Child node (me) -- on screen too, or there is nothing to draw
2223
+ * to: a folded child's parent is visible and the child is not.
2119
2224
  */
2120
2225
  let child_node = build_node_name(gobj, child_topic, child_id);
2226
+ let child_cell = null;
2227
+ try {
2228
+ child_cell = graph.getNodeData(child_node);
2229
+ } catch(e) {
2230
+ child_cell = null;
2231
+ }
2232
+ if(!child_cell) {
2233
+ if(verbose) {
2234
+ log_error(`${gobj_short_name(gobj)}: child node NOT FOUND: ${child_node}`);
2235
+ }
2236
+ return;
2237
+ }
2238
+
2239
+ /* Once. The reconcile after a fold redraws the links of every
2240
+ * visible node, and an edge id is a counter: without this every
2241
+ * reconcile laid a second line over the first. */
2242
+ if(get_edge_by_link(gobj, parent_topic, parent_id, hook_name,
2243
+ child_topic, child_id, fkey_name)) {
2244
+ return;
2245
+ }
2121
2246
 
2122
2247
  /*
2123
2248
  * Create the edge with independent id and semantic data
@@ -2177,7 +2302,7 @@ function draw_link(
2177
2302
 
2178
2303
  let edge = {
2179
2304
  id: build_edge_id(gobj),
2180
- type: 'cubic',
2305
+ type: edge_type_for(gobj),
2181
2306
  source: parent_node,
2182
2307
  target: child_node,
2183
2308
  style: {
@@ -2389,9 +2514,14 @@ function save_geometry(gobj)
2389
2514
  let priv = gobj.priv;
2390
2515
  let graph = priv.graph;
2391
2516
 
2392
- // Collect geometry for all nodes into _graph_properties
2517
+ /* Collect geometry for the nodes ON SCREEN into _graph_properties.
2518
+ * A folded node keeps the entry it had: nothing here touches it.
2519
+ * A `+N` chip is not a record and has no entry to keep. */
2393
2520
  const nodes = graph.getData().nodes;
2394
2521
  for(let i = 0; i < nodes.length; i++) {
2522
+ if(!nodes[i].data || !nodes[i].data.desc) {
2523
+ continue;
2524
+ }
2395
2525
  update_geometry(gobj, nodes[i].id);
2396
2526
  }
2397
2527
 
@@ -2724,56 +2854,110 @@ function graph_focus_topic(gobj, topic)
2724
2854
  {
2725
2855
  let priv = gobj.priv;
2726
2856
  let graph = priv.graph;
2727
- if(!graph || !priv.graph_rendered) {
2857
+ if(!graph || !priv.graph_rendered || !priv._fold_model) {
2728
2858
  priv._pending_focus_topic = topic;
2729
2859
  return;
2730
2860
  }
2731
2861
 
2732
- let data = {};
2733
- try {
2734
- data = graph.getData() || {};
2735
- } catch(e) {
2736
- log_error(`${gobj_short_name(gobj)}: graph.getData() failed: ${e}`);
2862
+ let model = priv._fold_model;
2863
+ let state = priv._fold_state;
2864
+
2865
+ /* "Show me these": the ones on screen are highlighted, and the
2866
+ * hidden ones are opened up to -- ONE PAGE of them, the same cap
2867
+ * the find has. The graph's per-topic route lands here on every
2868
+ * load (`.../graph/devices` is a focus on `devices`), and
2869
+ * revealing a whole topic on landing opened 563 groups of a
2870
+ * 6400-record treedb: the pile, back, before anybody touched
2871
+ * anything. */
2872
+ let keys = [];
2873
+ if(!empty_string(topic)) {
2874
+ for(let node of model.nodes.values()) {
2875
+ if(node.topic_name === topic) {
2876
+ keys.push(node.key);
2877
+ }
2878
+ }
2879
+ if(keys.length === 0) {
2880
+ log_warning(`${gobj_short_name(gobj)}: focus topic '${topic}' has no nodes`);
2881
+ }
2882
+ let visible = fold_visible_set(model, state);
2883
+ let revealed = 0;
2884
+ for(let key of keys) {
2885
+ if(visible.has(key)) {
2886
+ continue;
2887
+ }
2888
+ if(revealed >= state.page_size) {
2889
+ break;
2890
+ }
2891
+ fold_reveal(model, state, key);
2892
+ revealed++;
2893
+ }
2894
+ }
2895
+ priv._focus_topic = topic || null;
2896
+
2897
+ reconcile_fold(gobj, {}).then(() => {
2898
+ apply_focus_ids(gobj, keys, "focus topic");
2899
+ });
2900
+ }
2901
+
2902
+ /************************************************************
2903
+ * Paint the highlight on these records (by model key) and put
2904
+ * the first of them in the middle. Shared by the find and the
2905
+ * topic focus, after the reconcile that brought them on screen.
2906
+ * Keys that are still off screen are skipped: a match beyond the
2907
+ * page a reveal opened is counted, not painted.
2908
+ ************************************************************/
2909
+ function apply_focus_ids(gobj, keys, what)
2910
+ {
2911
+ let priv = gobj.priv;
2912
+ let graph = priv.graph;
2913
+ if(!graph) {
2737
2914
  return;
2738
2915
  }
2739
- let nodes = is_array(data.nodes) ? data.nodes : [];
2740
2916
 
2741
- /* Clear the previous focus highlight, then set the new one. */
2742
2917
  let states = {};
2743
2918
  for(let id of (priv._focus_ids || [])) {
2744
2919
  states[id] = [];
2745
2920
  }
2746
2921
  let ids = [];
2747
- if(!empty_string(topic)) {
2748
- for(let n of nodes) {
2749
- if(n && n.data && n.data.topic_name === topic) {
2750
- ids.push(n.id);
2751
- }
2752
- }
2753
- if(ids.length === 0) {
2754
- log_warning(`${gobj_short_name(gobj)}: focus topic '${topic}' has no nodes`);
2922
+ for(let key of keys) {
2923
+ let id = node_id_of_key(gobj, key);
2924
+ let nd = null;
2925
+ try {
2926
+ nd = graph.getNodeData(id);
2927
+ } catch(e) {
2928
+ nd = null;
2755
2929
  }
2756
- for(let id of ids) {
2930
+ if(nd) {
2931
+ ids.push(id);
2757
2932
  states[id] = ['active'];
2758
2933
  }
2759
2934
  }
2760
2935
  let prev_ids = priv._focus_ids || [];
2761
2936
  priv._focus_ids = ids;
2762
- priv._focus_topic = topic || null;
2763
2937
 
2764
2938
  try {
2765
2939
  if(typeof graph.setElementState === "function") {
2766
2940
  graph.setElementState(states);
2767
2941
  }
2768
2942
  apply_node_highlight(gobj, prev_ids, ids);
2769
- if(ids.length && typeof graph.focusElement === "function") {
2770
- graph.focusElement(ids);
2943
+ if(ids.length) {
2944
+ yui_graph_center_on(graph, ids[0]);
2771
2945
  }
2772
2946
  } catch(e) {
2773
- log_error(`${gobj_short_name(gobj)}: focus topic apply failed: ${e}`);
2947
+ log_error(`${gobj_short_name(gobj)}: ${what} apply failed: ${e}`);
2774
2948
  }
2775
2949
  }
2776
2950
 
2951
+ /* "topic^id" -> the G6 node id. */
2952
+ function node_id_of_key(gobj, key)
2953
+ {
2954
+ let i = key.indexOf("^");
2955
+ if(i < 0) {
2956
+ return "";
2957
+ }
2958
+ return build_node_name(gobj, key.slice(0, i), key.slice(i + 1));
2959
+ }
2960
+
2777
2961
  /************************************************************
2778
2962
  * Find the nodes whose name, id or topic contains `term`, highlight
2779
2963
  * them with the same amber 'active' state the topic focus uses, and
@@ -2792,71 +2976,64 @@ function graph_find_nodes(gobj, term)
2792
2976
  {
2793
2977
  let priv = gobj.priv;
2794
2978
  let graph = priv.graph;
2795
- if(!graph || !priv.graph_rendered) {
2979
+ if(!graph || !priv.graph_rendered || !priv._fold_model) {
2796
2980
  priv._pending_find = term;
2797
2981
  return 0;
2798
2982
  }
2799
2983
 
2800
- let data = {};
2801
- try {
2802
- data = graph.getData() || {};
2803
- } catch(e) {
2804
- log_error(`${gobj_short_name(gobj)}: graph.getData() failed: ${e}`);
2805
- return 0;
2806
- }
2807
- let nodes = is_array(data.nodes) ? data.nodes : [];
2808
-
2809
- let states = {};
2810
- for(let id of (priv._focus_ids || [])) {
2811
- states[id] = [];
2812
- }
2984
+ let model = priv._fold_model;
2985
+ let state = priv._fold_state;
2813
2986
 
2814
- let ids = [];
2987
+ /* Searched over the RECORDS, not the cards: most of the treedb is
2988
+ * folded away, and a find that only read what was on screen would
2989
+ * answer "nothing" for a device three levels down. */
2990
+ let keys = [];
2815
2991
  if(!empty_string(term)) {
2816
2992
  let needle = String(term).toLowerCase();
2817
- for(let n of nodes) {
2818
- if(!n || !n.data) {
2819
- continue;
2820
- }
2821
- let record = n.data.record || {};
2993
+ for(let node of model.nodes.values()) {
2994
+ let record = node.record || {};
2822
2995
  let label = "";
2823
2996
  try {
2824
- label = node_label(n.data.desc || {}, record) || "";
2997
+ label = node_label(priv.descs[node.topic_name] || {}, record) || "";
2825
2998
  } catch(e) {
2826
2999
  label = "";
2827
3000
  }
2828
3001
  let haystack = [
2829
3002
  label,
2830
3003
  record.id,
2831
- n.data.topic_name
3004
+ node.topic_name
2832
3005
  ].filter((v) => typeof v === "string").join(" ").toLowerCase();
2833
3006
 
2834
3007
  if(haystack.includes(needle)) {
2835
- ids.push(n.id);
3008
+ keys.push(node.key);
2836
3009
  }
2837
3010
  }
2838
- for(let id of ids) {
2839
- states[id] = ['active'];
2840
- }
2841
3011
  }
2842
3012
 
2843
- let prev_ids = priv._focus_ids || [];
2844
- priv._focus_ids = ids;
2845
- priv._focus_topic = null; /* a find takes over the highlight */
2846
-
2847
- try {
2848
- if(typeof graph.setElementState === "function") {
2849
- graph.setElementState(states);
3013
+ /* Hidden matches are opened up to, one page of them at most: a
3014
+ * single letter matches half the treedb, and unfolding half the
3015
+ * treedb on a keystroke is the pile this whole thing exists to
3016
+ * avoid. The count reported is of all the matches. */
3017
+ let visible = fold_visible_set(model, state);
3018
+ let revealed = 0;
3019
+ for(let key of keys) {
3020
+ if(visible.has(key)) {
3021
+ continue;
2850
3022
  }
2851
- apply_node_highlight(gobj, prev_ids, ids);
2852
- if(ids.length && typeof graph.focusElement === "function") {
2853
- graph.focusElement(ids);
3023
+ if(revealed >= state.page_size) {
3024
+ break;
2854
3025
  }
2855
- } catch(e) {
2856
- log_error(`${gobj_short_name(gobj)}: find apply failed: ${e}`);
3026
+ fold_reveal(model, state, key);
3027
+ revealed++;
2857
3028
  }
2858
3029
 
2859
- return ids.length;
3030
+ priv._focus_topic = null; /* a find takes over the highlight */
3031
+
3032
+ reconcile_fold(gobj, {}).then(() => {
3033
+ apply_focus_ids(gobj, keys, "find");
3034
+ });
3035
+
3036
+ return keys.length;
2860
3037
  }
2861
3038
 
2862
3039
  async function graph_zoom_in(gobj)
@@ -3067,21 +3244,39 @@ class LightNode extends Circle
3067
3244
  class ManualLayout extends BaseLayout
3068
3245
  {
3069
3246
  async execute(data, options) {
3070
- const { nodes = [] } = data;
3071
- return {
3072
- nodes: nodes.map((node) => {
3073
- let gp = node.data.graph_props;
3074
- let geo = node.data.record._geometry;
3075
- let x = (gp && gp.x != null) ? gp.x :
3076
- (geo && geo.x != null) ? geo.x : 0;
3077
- let y = (gp && gp.y != null) ? gp.y :
3078
- (geo && geo.y != null) ? geo.y : 0;
3079
- return {
3080
- id: node.id,
3081
- style: { x: x, y: y },
3082
- };
3083
- }),
3084
- };
3247
+ const { nodes = [], edges = [] } = data;
3248
+
3249
+ let placed = {};
3250
+ let out = [];
3251
+ let chips = [];
3252
+ for(let node of nodes) {
3253
+ let d = node.data || {};
3254
+ if(d.more) {
3255
+ chips.push(node); /* a `+N` chip: placed by its parent below */
3256
+ continue;
3257
+ }
3258
+ let gp = d.graph_props;
3259
+ let geo = d.record? d.record._geometry : null;
3260
+ let x = (gp && gp.x != null) ? gp.x :
3261
+ (geo && geo.x != null) ? geo.x : 0;
3262
+ let y = (gp && gp.y != null) ? gp.y :
3263
+ (geo && geo.y != null) ? geo.y : 0;
3264
+ placed[node.id] = [x, y];
3265
+ out.push({id: node.id, style: {x: x, y: y}});
3266
+ }
3267
+
3268
+ /* A chip has no saved place of its own and never will: it sits
3269
+ * under the card it continues, or where it was born if it is
3270
+ * a root group's. */
3271
+ for(let node of chips) {
3272
+ let edge = edges.find((e) => e.target === node.id);
3273
+ let p = edge? placed[edge.source] : null;
3274
+ let x = p? p[0] : ((node.style && node.style.x) || 0);
3275
+ let y = p? p[1] + 80 : ((node.style && node.style.y) || 0);
3276
+ out.push({id: node.id, style: {x: x, y: y}});
3277
+ }
3278
+
3279
+ return {nodes: out};
3085
3280
  }
3086
3281
  }
3087
3282
 
@@ -5386,7 +5581,8 @@ function show_node_popover(gobj)
5386
5581
  updateStyle.innerHTML = build_node_innerHTML(
5387
5582
  fill, priv.theme, record.icon,
5388
5583
  node_label(nodeData.data.desc, record),
5389
- nodeData.data.desc.topic_name, false, record.id
5584
+ nodeData.data.desc.topic_name, false, record.id,
5585
+ false, false, false, pills_html_of(gobj, nodeData)
5390
5586
  );
5391
5587
  }
5392
5588
  graph.updateNodeData([{ id: node_id, style: updateStyle }]);
@@ -5424,7 +5620,8 @@ function show_node_popover(gobj)
5424
5620
  restoreStyle.innerHTML = build_node_innerHTML(
5425
5621
  origFill, priv.theme, record.icon,
5426
5622
  node_label(nodeData.data.desc, record),
5427
- nodeData.data.desc.topic_name, false, record.id
5623
+ nodeData.data.desc.topic_name, false, record.id,
5624
+ false, false, false, pills_html_of(gobj, nodeData)
5428
5625
  );
5429
5626
  }
5430
5627
  graph.updateNodeData([{ id: node_id, style: restoreStyle }]);
@@ -5746,6 +5943,11 @@ function refresh_minimap(gobj)
5746
5943
  let size = (nd && nd.style && nd.style.size) || [120, 60];
5747
5944
  let color = (nd && nd.data && nd.data.desc && nd.data.desc.color)
5748
5945
  || "#94a3b8";
5946
+ if(is_more_node(nd)) {
5947
+ /* A `+N` chip is a cut, not a card: a faint
5948
+ * mark at that scale. */
5949
+ color = "rgba(148, 163, 184, 0.35)";
5950
+ }
5749
5951
  return new RectGeometry({
5750
5952
  style: {
5751
5953
  x: -size[0] / 2,
@@ -5772,7 +5974,7 @@ function refresh_minimap(gobj)
5772
5974
  * same three-way choice — it lived inline in the theme refresh and is
5773
5975
  * shared now. Returns null for a node that carries no desc.
5774
5976
  ************************************************************/
5775
- function node_innerHTML_of(nd, theme, highlight, selected, anchored)
5977
+ function node_innerHTML_of(nd, theme, highlight, selected, anchored, pills_html)
5776
5978
  {
5777
5979
  if(!nd || !nd.data || !nd.data.desc) {
5778
5980
  return null;
@@ -5790,12 +5992,14 @@ function node_innerHTML_of(nd, theme, highlight, selected, anchored)
5790
5992
  case 'extended':
5791
5993
  return build_node_innerHTML(
5792
5994
  desc.color, theme, record.icon, label,
5793
- desc.topic_name, true, record.id, highlight, selected, anchored
5995
+ desc.topic_name, true, record.id, highlight, selected, anchored,
5996
+ pills_html
5794
5997
  );
5795
5998
  case 'hierarchical':
5796
5999
  return build_node_innerHTML(
5797
6000
  desc.color, theme, record.icon, label,
5798
- desc.topic_name, false, record.id, highlight, selected, anchored
6001
+ desc.topic_name, false, record.id, highlight, selected, anchored,
6002
+ pills_html
5799
6003
  );
5800
6004
  }
5801
6005
  return null;
@@ -5824,9 +6028,16 @@ function repaint_cards(gobj, ids)
5824
6028
 
5825
6029
  let updates = [];
5826
6030
  for(let id of ids) {
6031
+ let nd;
6032
+ try {
6033
+ nd = graph.getNodeData(id);
6034
+ } catch(e) {
6035
+ continue; /* gone since it was listed */
6036
+ }
5827
6037
  let html = node_innerHTML_of(
5828
- graph.getNodeData(id), priv.theme, focus.has(id), selected.has(id),
5829
- priv.anchor_state === "on" && id === priv.anchor_id
6038
+ nd, priv.theme, focus.has(id), selected.has(id),
6039
+ priv.anchor_state === "on" && id === priv.anchor_id,
6040
+ pills_html_of(gobj, nd)
5830
6041
  );
5831
6042
  if(html !== null) {
5832
6043
  updates.push({id: id, style: {innerHTML: html}});
@@ -5882,9 +6093,15 @@ function refresh_html_nodes_theme(gobj, theme)
5882
6093
  let updates = [];
5883
6094
  for(let i = 0; i < nodes.length; i++) {
5884
6095
  let id = nodes[i].id;
6096
+ let nd = graph.getNodeData(id);
5885
6097
  let html = node_innerHTML_of(
5886
- graph.getNodeData(id), theme, highlighted.has(id), selected.has(id)
6098
+ nd, theme, highlighted.has(id), selected.has(id),
6099
+ priv.anchor_state === "on" && id === priv.anchor_id,
6100
+ pills_html_of(gobj, nd)
5887
6101
  );
6102
+ if(html === null) {
6103
+ html = more_innerHTML_of(gobj, nd, theme); /* a `+N` chip, or null */
6104
+ }
5888
6105
  if(html !== null) {
5889
6106
  updates.push({id: id, style: {innerHTML: html}});
5890
6107
  }
@@ -6036,7 +6253,7 @@ function build_chip_innerHTML(color, theme, icon, label, key, highlight, selecte
6036
6253
  * colour is kept (per-topic differentiation) but softened via
6037
6254
  * color-mix instead of a harsh saturated fill. Theme-aware.
6038
6255
  ************************************************************/
6039
- function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected, anchored)
6256
+ function build_node_innerHTML(color, theme, icon, label, topic_name, structural, key, highlight, selected, anchored, pills_html)
6040
6257
  {
6041
6258
  let title = key || label;
6042
6259
  let dark = (theme === "dark");
@@ -6091,6 +6308,18 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
6091
6308
  ">${escapeHtml(topic_name)}</div>`;
6092
6309
  }
6093
6310
 
6311
+ /* The fold pills, one per hook with children: a row under the
6312
+ * subtitle, built by pills_html_of(). Empty for a card with
6313
+ * nothing to fold. */
6314
+ let pills_row = "";
6315
+ if(pills_html) {
6316
+ pills_row = `
6317
+ <div class="TREEDB_PILLS" style="
6318
+ display: flex; flex-wrap: nowrap; gap: 4px; justify-content: center;
6319
+ margin-top: 6px; max-width: 100%; overflow: hidden;
6320
+ ">${pills_html}</div>`;
6321
+ }
6322
+
6094
6323
  return `
6095
6324
  <div class="TREEDB_CARD" title="${escapeHtml(title)}" style="
6096
6325
  box-sizing: border-box;
@@ -6116,7 +6345,7 @@ function build_node_innerHTML(color, theme, icon, label, topic_name, structural,
6116
6345
  max-width: 100%; overflow: hidden; text-overflow: ellipsis;
6117
6346
  display: -webkit-box; -webkit-line-clamp: 2;
6118
6347
  -webkit-box-orient: vertical; word-break: break-word;
6119
- ">${escapeHtml(label)}</div>${sub_html}
6348
+ ">${escapeHtml(label)}</div>${sub_html}${pills_row}
6120
6349
  </div>
6121
6350
  `;
6122
6351
  }
@@ -6172,7 +6401,7 @@ function select_all_nodes(gobj)
6172
6401
  }
6173
6402
 
6174
6403
  let nodes = (graph.getData() || {}).nodes || [];
6175
- set_selection(gobj, nodes.map((nd) => nd.id));
6404
+ set_selection(gobj, nodes.filter((nd) => nd.data && nd.data.desc).map((nd) => nd.id));
6176
6405
  }
6177
6406
 
6178
6407
  /************************************************************
@@ -6612,7 +6841,8 @@ function apply_node_properties(gobj, node_id, fill, stroke, lineWidth, scope)
6612
6841
  updateStyle.innerHTML = build_node_innerHTML(
6613
6842
  fill, priv.theme, record.icon,
6614
6843
  node_label(nd.data.desc, record),
6615
- nd.data.desc.topic_name, false, record.id
6844
+ nd.data.desc.topic_name, false, record.id,
6845
+ false, false, false, pills_html_of(gobj, nd)
6616
6846
  );
6617
6847
  }
6618
6848
  updates.push({ id: nodes[i].id, style: updateStyle });
@@ -6706,8 +6936,8 @@ function update_edge_geometry(gobj, edge_id)
6706
6936
  let graph = priv.graph;
6707
6937
 
6708
6938
  let edgeData = graph.getEdgeData(edge_id);
6709
- if(!edgeData || !edgeData.data) {
6710
- return;
6939
+ if(!edgeData || !edgeData.data || !edgeData.data.parent_topic) {
6940
+ return; /* a `+N` chip's line: not a link, nothing to save */
6711
6941
  }
6712
6942
 
6713
6943
  let style = edgeData.style || {};
@@ -6931,8 +7161,8 @@ function copy_size_to_nodes(gobj, same_topic_only)
6931
7161
  const nodes = graph.getData().nodes;
6932
7162
  for(let i = 0; i < nodes.length; i++) {
6933
7163
  let nd = graph.getNodeData(nodes[i].id);
6934
- if(!nd || !nd.data) {
6935
- continue;
7164
+ if(!nd || !nd.data || !nd.data.desc) {
7165
+ continue; /* a `+N` chip keeps its own size */
6936
7166
  }
6937
7167
  if(same_topic_only && nd.data.topic_name !== source_topic) {
6938
7168
  continue;
@@ -7063,6 +7293,581 @@ function copy_size_to_ports(gobj, same_topic_only)
7063
7293
  }
7064
7294
 
7065
7295
 
7296
+ /***************************
7297
+ * Folding
7298
+ ***************************/
7299
+
7300
+
7301
+
7302
+
7303
+ /* A card with pills is this much taller; the row sits under the subtitle. */
7304
+ const PILL_ROW_HEIGHT = 24;
7305
+ const MORE_CHIP_SIZE = [76, 26];
7306
+
7307
+ /************************************************************
7308
+ * The three card sizes, with room for the pill row when there
7309
+ * is one. A chip (pure child) never has pills: it has no hooks.
7310
+ ************************************************************/
7311
+ function card_size_for(node_treedb_type, has_pills)
7312
+ {
7313
+ let extra = has_pills? PILL_ROW_HEIGHT : 0;
7314
+ switch(node_treedb_type) {
7315
+ case 'child':
7316
+ return [116, 40];
7317
+ case 'extended':
7318
+ return [144, 66 + extra];
7319
+ default:
7320
+ return [172, 96 + extra];
7321
+ }
7322
+ }
7323
+
7324
+ /************************************************************
7325
+ * The model's key of a G6 record node, or "" for anything else.
7326
+ ************************************************************/
7327
+ function node_key_of(nd)
7328
+ {
7329
+ if(!nd || !nd.data || !nd.data.desc || !nd.data.record) {
7330
+ return "";
7331
+ }
7332
+ return fold_node_key(nd.data.desc.topic_name, String(nd.data.record.id));
7333
+ }
7334
+
7335
+ function is_more_node(nd)
7336
+ {
7337
+ return !!(nd && nd.data && nd.data.more);
7338
+ }
7339
+
7340
+ function more_node_id(gobj, group_key)
7341
+ {
7342
+ return sprintf("more-%s-%s", gobj.priv.treedb_name, group_key);
7343
+ }
7344
+
7345
+ function more_edge_id(gobj, group_key)
7346
+ {
7347
+ return sprintf("more-edge-%s-%s", gobj.priv.treedb_name, group_key);
7348
+ }
7349
+
7350
+ /************************************************************
7351
+ * The colours of a pill or a chip, from the topic colour it
7352
+ * stands for -- the CHILD topic, the same colour its hook port
7353
+ * wears, so the pill and the port say the same thing.
7354
+ ************************************************************/
7355
+ function pill_colors(color, theme)
7356
+ {
7357
+ let dark = (theme === "dark");
7358
+ return {
7359
+ bg: dark
7360
+ ? `color-mix(in srgb, ${color} 45%, #1b2230)`
7361
+ : `color-mix(in srgb, ${color} 22%, #ffffff)`,
7362
+ fg: dark
7363
+ ? `color-mix(in srgb, ${color} 35%, #ffffff)`
7364
+ : `color-mix(in srgb, ${color} 55%, #0f172a)`,
7365
+ border: dark
7366
+ ? `color-mix(in srgb, ${color} 80%, #ffffff)`
7367
+ : color,
7368
+ };
7369
+ }
7370
+
7371
+ /************************************************************
7372
+ * One pill: `▸ devices 142` folded, `▾ devices 24/142` open on a
7373
+ * page, `▾ devices 142` open whole. A glyph and numbers on
7374
+ * purpose: the card is an innerHTML string where a WORD could
7375
+ * carry no i18n key, and the hook name is a schema identifier,
7376
+ * like the topic subtitle above it.
7377
+ ************************************************************/
7378
+ function build_pill_html(pill, color, theme)
7379
+ {
7380
+ let c = pill_colors(color, theme);
7381
+ let glyph = pill.open? "&#9662;" : "&#9656;"; /* ▾ / ▸ */
7382
+ let count = (pill.open && pill.shown < pill.total)
7383
+ ? `${pill.shown}/${pill.total}`
7384
+ : `${pill.total}`;
7385
+
7386
+ return `<span class="TREEDB_PILL" data-fold-group="${escapeHtml(pill.group_key)}"
7387
+ title="${escapeHtml(pill.hook)}" style="
7388
+ display: inline-flex; align-items: center; gap: 3px; flex: 0 1 auto;
7389
+ height: 16px; padding: 0 6px; border-radius: 8px;
7390
+ background: ${c.bg}; color: ${c.fg}; border: 1px solid ${c.border};
7391
+ font-size: 10px; font-weight: 700; line-height: 1; white-space: nowrap;
7392
+ overflow: hidden; text-overflow: ellipsis;
7393
+ cursor: pointer; user-select: none;
7394
+ "><span style="font-size: 9px;">${glyph}</span>${escapeHtml(pill.hook)}<span style="opacity: .75; font-weight: 600;">${count}</span></span>`;
7395
+ }
7396
+
7397
+ /************************************************************
7398
+ * The pill row of one node, as html; "" when it has nothing to
7399
+ * fold or the model is not built yet.
7400
+ ************************************************************/
7401
+ function pills_html_of_key(gobj, node_key)
7402
+ {
7403
+ let priv = gobj.priv;
7404
+
7405
+ if(!priv._fold_model || !priv._fold_state || !node_key) {
7406
+ return "";
7407
+ }
7408
+ let pills = fold_pills_of(priv._fold_model, priv._fold_state, node_key);
7409
+ if(!pills.length) {
7410
+ return "";
7411
+ }
7412
+ let out = [];
7413
+ for(let pill of pills) {
7414
+ let child_desc = priv.descs? priv.descs[pill.child_topic] : null;
7415
+ let color = (child_desc && child_desc.color) || "#94a3b8";
7416
+ out.push(build_pill_html(pill, color, priv.theme));
7417
+ }
7418
+ return out.join("");
7419
+ }
7420
+
7421
+ function pills_html_of(gobj, nd)
7422
+ {
7423
+ return pills_html_of_key(gobj, node_key_of(nd));
7424
+ }
7425
+
7426
+ /* What a card's pills say, as one string -- to repaint only the
7427
+ * cards whose pills changed. */
7428
+ function pills_signature(gobj, node_key)
7429
+ {
7430
+ let priv = gobj.priv;
7431
+
7432
+ if(!priv._fold_model || !priv._fold_state) {
7433
+ return "";
7434
+ }
7435
+ let pills = fold_pills_of(priv._fold_model, priv._fold_state, node_key);
7436
+ return pills.map((p) => `${p.hook}:${p.open? p.shown : "-"}/${p.total}`).join(",");
7437
+ }
7438
+
7439
+ /************************************************************
7440
+ * The `+N` chip at the end of a page: the rest of the group,
7441
+ * in the child topic's colour, dashed like a cut. Its title is
7442
+ * the one WORD of the folding, and it is translated at build
7443
+ * time: the chips are rebuilt on a language change.
7444
+ ************************************************************/
7445
+ function build_more_innerHTML(color, theme, rest)
7446
+ {
7447
+ let c = pill_colors(color, theme);
7448
+ return `
7449
+ <div class="TREEDB_MORE" title="${escapeHtml(t('show more'))}" style="
7450
+ box-sizing: border-box; width: 100%; height: 100%;
7451
+ display: flex; align-items: center; justify-content: center; gap: 4px;
7452
+ background: ${c.bg}; color: ${c.fg}; border: 1px dashed ${c.border};
7453
+ border-radius: 13px;
7454
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
7455
+ font-size: 12px; font-weight: 700; line-height: 1;
7456
+ cursor: pointer; user-select: none;
7457
+ ">+${rest}<span style="font-size: 10px; opacity: .8;">&#8230;</span></div>
7458
+ `;
7459
+ }
7460
+
7461
+ function more_innerHTML_of(gobj, nd, theme)
7462
+ {
7463
+ if(!is_more_node(nd)) {
7464
+ return null;
7465
+ }
7466
+ let priv = gobj.priv;
7467
+ let child_desc = priv.descs? priv.descs[nd.data.child_topic] : null;
7468
+ let color = (child_desc && child_desc.color) || "#94a3b8";
7469
+ return build_more_innerHTML(color, theme, nd.data.rest);
7470
+ }
7471
+
7472
+ /************************************************************
7473
+ * The pills live inside an `innerHTML` string, so no handler can
7474
+ * be attached to them: the click is delegated from the container
7475
+ * in the CAPTURE phase over four event types -- the same door the
7476
+ * JSON graph opens for its fold handles, for the same reasons: G6
7477
+ * binds on the html node element, a descendant, so only capture
7478
+ * runs first; and G6 never reads the DOM `click`, it builds its
7479
+ * own from the pointer pair, so `pointerdown`/`pointerup` are the
7480
+ * ones that have to be cut or the card is ALSO clicked.
7481
+ ************************************************************/
7482
+ const FOLD_SWALLOWED_EVENTS = ["pointerdown", "pointerup", "mousedown", "click"];
7483
+
7484
+ function install_fold_listener(gobj)
7485
+ {
7486
+ let priv = gobj.priv;
7487
+
7488
+ if(!priv.$container) {
7489
+ log_error(`${gobj_short_name(gobj)}: no container, the fold pills are dead`);
7490
+ return;
7491
+ }
7492
+ priv._fold_listener = function(evt) {
7493
+ let $pill = (evt.target && evt.target.closest)
7494
+ ? evt.target.closest('[data-fold-group]')
7495
+ : null;
7496
+ if(!$pill) {
7497
+ return;
7498
+ }
7499
+ evt.stopPropagation();
7500
+ evt.preventDefault();
7501
+ if(evt.type !== "click") {
7502
+ return; /* swallowed so G6 never sees a node click */
7503
+ }
7504
+ gobj_send_event(gobj, "EV_TOGGLE_FOLD", {
7505
+ group: $pill.getAttribute('data-fold-group'),
7506
+ }, gobj);
7507
+ };
7508
+ for(let type of FOLD_SWALLOWED_EVENTS) {
7509
+ priv.$container.addEventListener(type, priv._fold_listener, true);
7510
+ }
7511
+ }
7512
+
7513
+ function uninstall_fold_listener(gobj)
7514
+ {
7515
+ let priv = gobj.priv;
7516
+
7517
+ if(priv._fold_listener && priv.$container) {
7518
+ for(let type of FOLD_SWALLOWED_EVENTS) {
7519
+ priv.$container.removeEventListener(type, priv._fold_listener, true);
7520
+ }
7521
+ }
7522
+ priv._fold_listener = null;
7523
+ }
7524
+
7525
+ /************************************************************
7526
+ * (Re)build the tree of the records. Cheap -- one pass over the
7527
+ * records -- so it runs again on every create/update/delete
7528
+ * rather than being patched. The fold state survives: it is
7529
+ * keyed by group, and a group that is gone is simply never read.
7530
+ ************************************************************/
7531
+ function rebuild_fold_model(gobj)
7532
+ {
7533
+ let priv = gobj.priv;
7534
+
7535
+ priv._fold_model = fold_build_model(priv.descs || {}, priv.records || {});
7536
+ if(!priv._fold_state) {
7537
+ priv._fold_state = fold_new_state(gobj_read_integer_attr(gobj, "fold_page_size"));
7538
+ }
7539
+ if(!priv._pill_sig) {
7540
+ priv._pill_sig = new Map();
7541
+ }
7542
+ }
7543
+
7544
+ /************************************************************
7545
+ * How many records there are, and how many carry a saved
7546
+ * position -- what auto_layout decides on. Counted over the
7547
+ * RECORDS, because the nodes on screen are a fraction of them.
7548
+ ************************************************************/
7549
+ function count_placed_records(gobj)
7550
+ {
7551
+ let priv = gobj.priv;
7552
+
7553
+ priv._nodes_total = 0;
7554
+ priv._nodes_placed = 0;
7555
+ for(const [topic_name, records] of Object.entries(priv.records || {})) {
7556
+ if(topic_name.substring(0, 2) === "__" || !is_array(records)) {
7557
+ continue;
7558
+ }
7559
+ for(let record of records) {
7560
+ priv._nodes_total++;
7561
+ let geometry = get_node_graph_props(gobj, topic_name, record.id, record);
7562
+ if(geometry_has_position(geometry)) {
7563
+ priv._nodes_placed++;
7564
+ }
7565
+ }
7566
+ }
7567
+ }
7568
+
7569
+ /************************************************************
7570
+ * Make the G6 data say what the fold model says is visible.
7571
+ *
7572
+ * Record nodes not visible any more are removed (their edges go
7573
+ * with them), the visible ones missing are created, the links
7574
+ * between visible nodes are drawn, the `+N` chips are placed or
7575
+ * taken away, and every card whose pills changed is repainted.
7576
+ * Then a draw, a layout if the set of nodes changed, and the
7577
+ * camera: fit the whole thing on the first load, or put `keep`
7578
+ * back where it was on screen before the change.
7579
+ *
7580
+ * Serialised: one reconcile at a time, in order. Two pills tapped
7581
+ * quickly would otherwise interleave their draws.
7582
+ *
7583
+ * opts.relayout run the layout even if no node came or went
7584
+ * opts.fit fit the viewport afterwards
7585
+ * opts.keep node id to hold still on screen
7586
+ * opts.keep_vp where it was ([x, y] viewport px)
7587
+ ************************************************************/
7588
+ function reconcile_fold(gobj, opts)
7589
+ {
7590
+ let priv = gobj.priv;
7591
+
7592
+ let run = () => reconcile_fold_now(gobj, opts || {}).catch((e) => {
7593
+ log_error(`${gobj_short_name(gobj)}: reconcile failed: ${e}`);
7594
+ });
7595
+ priv._fold_chain = (priv._fold_chain || Promise.resolve()).then(run, run);
7596
+ return priv._fold_chain;
7597
+ }
7598
+
7599
+ async function reconcile_fold_now(gobj, opts)
7600
+ {
7601
+ let priv = gobj.priv;
7602
+ let graph = priv.graph;
7603
+ let model = priv._fold_model;
7604
+ let state = priv._fold_state;
7605
+
7606
+ if(!graph || !model || !state || gobj_is_destroying(gobj)) {
7607
+ return;
7608
+ }
7609
+
7610
+ let visible = fold_visible_set(model, state);
7611
+ let changed = false;
7612
+
7613
+ /* Paused for the whole of it: every add and remove below is the
7614
+ * fold's, not the user's, and none of it is a command to undo. */
7615
+ history_pause(gobj);
7616
+ try {
7617
+ changed = await reconcile_fold_apply(gobj, opts, visible);
7618
+ } finally {
7619
+ history_resume(gobj);
7620
+ }
7621
+ return changed;
7622
+ }
7623
+
7624
+ async function reconcile_fold_apply(gobj, opts, visible)
7625
+ {
7626
+ let priv = gobj.priv;
7627
+ let graph = priv.graph;
7628
+ let model = priv._fold_model;
7629
+ let state = priv._fold_state;
7630
+ let changed = false;
7631
+
7632
+ /* What is on screen now. */
7633
+ let present = new Map(); /* node_key -> node id */
7634
+ let more_present = new Map(); /* group_key -> node id */
7635
+ for(let nd of (graph.getNodeData() || [])) {
7636
+ if(is_more_node(nd)) {
7637
+ more_present.set(nd.data.more, nd.id);
7638
+ continue;
7639
+ }
7640
+ let key = node_key_of(nd);
7641
+ if(key) {
7642
+ present.set(key, nd.id);
7643
+ }
7644
+ }
7645
+
7646
+ /* Gone. */
7647
+ let to_remove = [];
7648
+ for(let [key, id] of present) {
7649
+ if(!visible.has(key)) {
7650
+ to_remove.push(id);
7651
+ }
7652
+ }
7653
+ if(to_remove.length) {
7654
+ let gone = new Set(to_remove);
7655
+ if(priv._selected_node_id && gone.has(priv._selected_node_id)) {
7656
+ deselect_node(gobj);
7657
+ }
7658
+ if(priv._detail_node_id && gone.has(priv._detail_node_id)) {
7659
+ hide_node_detail(gobj);
7660
+ }
7661
+ priv._focus_ids = (priv._focus_ids || []).filter((id) => !gone.has(id));
7662
+ priv._selected_paint_ids = (priv._selected_paint_ids || []).filter((id) => !gone.has(id));
7663
+ if(priv.anchor_state === "on" && gone.has(priv.anchor_id)) {
7664
+ priv.anchor_id = "";
7665
+ priv.anchor_state = "off";
7666
+ update_toolbar(gobj);
7667
+ }
7668
+ for(let id of to_remove) {
7669
+ priv._pill_sig.delete(id);
7670
+ }
7671
+ graph.removeNodeData(to_remove);
7672
+ changed = true;
7673
+ }
7674
+
7675
+ /* New. */
7676
+ for(let key of visible) {
7677
+ if(present.has(key)) {
7678
+ continue;
7679
+ }
7680
+ let node = model.nodes.get(key);
7681
+ let desc = priv.descs[node.topic_name];
7682
+ if(!desc) {
7683
+ log_error(`${gobj_short_name(gobj)}: no desc for ${key}`);
7684
+ continue;
7685
+ }
7686
+ create_topic_node(gobj, desc, node.record);
7687
+ priv._pill_sig.set(
7688
+ build_node_name(gobj, node.topic_name, node.id),
7689
+ pills_signature(gobj, key)
7690
+ );
7691
+ changed = true;
7692
+ }
7693
+
7694
+ /* The links between what is visible. draw_link draws each once
7695
+ * and skips the ones whose other end is off screen. */
7696
+ for(let key of visible) {
7697
+ let node = model.nodes.get(key);
7698
+ let desc = priv.descs[node.topic_name];
7699
+ if(desc) {
7700
+ draw_links(gobj, desc, node.record, false);
7701
+ }
7702
+ }
7703
+
7704
+ /* The `+N` chips. */
7705
+ let pending = fold_pending_groups(model, state, visible);
7706
+ let wanted = new Map();
7707
+ for(let p of pending) {
7708
+ wanted.set(p.group_key, p);
7709
+ }
7710
+ let more_gone = [];
7711
+ for(let [group_key, id] of more_present) {
7712
+ if(!wanted.has(group_key)) {
7713
+ more_gone.push(id);
7714
+ }
7715
+ }
7716
+ if(more_gone.length) {
7717
+ graph.removeNodeData(more_gone);
7718
+ changed = true;
7719
+ }
7720
+ for(let p of pending) {
7721
+ let first = model.nodes.get((model.groups.get(p.group_key) || [])[0]);
7722
+ let child_topic = first? first.topic_name : "";
7723
+ let child_desc = priv.descs[child_topic];
7724
+ let color = (child_desc && child_desc.color) || "#94a3b8";
7725
+ let id = more_node_id(gobj, p.group_key);
7726
+ if(more_present.has(p.group_key)) {
7727
+ let nd = graph.getNodeData(id);
7728
+ if(nd && nd.data.rest !== p.rest) {
7729
+ nd.data.rest = p.rest;
7730
+ graph.updateNodeData([{
7731
+ id: id,
7732
+ style: {innerHTML: build_more_innerHTML(color, priv.theme, p.rest)},
7733
+ }]);
7734
+ }
7735
+ continue;
7736
+ }
7737
+ let xy = get_default_ne_xy(gobj);
7738
+ graph.addNodeData([{
7739
+ id: id,
7740
+ type: 'html',
7741
+ style: {
7742
+ x: xy, y: xy,
7743
+ size: [...MORE_CHIP_SIZE],
7744
+ dx: -MORE_CHIP_SIZE[0] / 2,
7745
+ dy: -MORE_CHIP_SIZE[1] / 2,
7746
+ innerHTML: build_more_innerHTML(color, priv.theme, p.rest),
7747
+ },
7748
+ data: {
7749
+ more: p.group_key,
7750
+ child_topic: child_topic,
7751
+ rest: p.rest,
7752
+ },
7753
+ }]);
7754
+ if(p.node_key) {
7755
+ let parent = model.nodes.get(p.node_key);
7756
+ let dark = (priv.theme === "dark");
7757
+ graph.addEdgeData([{
7758
+ id: more_edge_id(gobj, p.group_key),
7759
+ type: edge_type_for(gobj),
7760
+ source: build_node_name(gobj, parent.topic_name, parent.id),
7761
+ target: id,
7762
+ style: {
7763
+ sourcePort: p.hook,
7764
+ lineWidth: 1,
7765
+ lineDash: [4, 3],
7766
+ stroke: dark? '#8b94a3' : '#94a3b8',
7767
+ startArrow: false,
7768
+ endArrow: false,
7769
+ },
7770
+ data: {more: p.group_key},
7771
+ }]);
7772
+ }
7773
+ changed = true;
7774
+ }
7775
+
7776
+ /* The cards whose pills changed. */
7777
+ let repaint = new Set();
7778
+ let resize = [];
7779
+ for(let key of visible) {
7780
+ let node = model.nodes.get(key);
7781
+ let id = build_node_name(gobj, node.topic_name, node.id);
7782
+ let sig = pills_signature(gobj, key);
7783
+ if(priv._pill_sig.get(id) === sig) {
7784
+ continue;
7785
+ }
7786
+ let had = !!priv._pill_sig.get(id);
7787
+ priv._pill_sig.set(id, sig);
7788
+ repaint.add(id);
7789
+ if(had !== !!sig) {
7790
+ let nd = graph.getNodeData(id);
7791
+ let desc = nd && nd.data && nd.data.desc;
7792
+ let geometry = (nd && nd.data && nd.data.graph_props) || {};
7793
+ /* A saved size is the owner's: only a card on the default
7794
+ * size grows and shrinks with its pills. */
7795
+ if(desc && !is_array(geometry.size)) {
7796
+ let size = card_size_for(desc.node_treedb_type, !!sig);
7797
+ resize.push({id: id, style: {
7798
+ size: size, dx: -size[0] / 2, dy: -size[1] / 2,
7799
+ }});
7800
+ }
7801
+ }
7802
+ }
7803
+ if(resize.length) {
7804
+ graph.updateNodeData(resize);
7805
+ }
7806
+ if(repaint.size) {
7807
+ repaint_cards(gobj, repaint);
7808
+ }
7809
+
7810
+ /* Decided BEFORE the drawing, not after it: the minimap builds its
7811
+ * canvas on its FIRST render, off the graph's draw events, so one
7812
+ * added after the last draw is bound and never painted. The count
7813
+ * it decides on is the nodes just reconciled. */
7814
+ try {
7815
+ refresh_minimap(gobj);
7816
+ } catch(e) {
7817
+ log_error(`${gobj_short_name(gobj)}: refresh_minimap failed: ${e}`);
7818
+ }
7819
+
7820
+ await graph_draw(gobj);
7821
+ if(changed || opts.relayout) {
7822
+ await graph_layout(gobj);
7823
+ }
7824
+
7825
+ if(opts.fit) {
7826
+ await graph_fit_readable(gobj);
7827
+ } else if(opts.keep && opts.keep_vp) {
7828
+ await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
7829
+ }
7830
+
7831
+ update_resize_handles_position(gobj);
7832
+ update_port_resize_handles_position(gobj);
7833
+ update_edge_icon_position(gobj);
7834
+ update_node_icon_position(gobj);
7835
+ update_link_icon_position(gobj);
7836
+
7837
+ return changed;
7838
+ }
7839
+
7840
+ /************************************************************
7841
+ * Repaint the `+N` chips (theme or language changed).
7842
+ ************************************************************/
7843
+ function repaint_more_chips(gobj)
7844
+ {
7845
+ let priv = gobj.priv;
7846
+ let graph = priv.graph;
7847
+
7848
+ if(!graph) {
7849
+ return;
7850
+ }
7851
+ let updates = [];
7852
+ for(let nd of (graph.getNodeData() || [])) {
7853
+ let html = more_innerHTML_of(gobj, nd, priv.theme);
7854
+ if(html !== null) {
7855
+ updates.push({id: nd.id, style: {innerHTML: html}});
7856
+ }
7857
+ }
7858
+ if(updates.length) {
7859
+ try {
7860
+ graph.updateNodeData(updates);
7861
+ graph.draw();
7862
+ } catch(e) {
7863
+ log_error(`${gobj_short_name(gobj)}: cannot repaint the chips: ${e}`);
7864
+ }
7865
+ }
7866
+ }
7867
+
7868
+
7869
+
7870
+
7066
7871
  /***************************
7067
7872
  * Actions
7068
7873
  ***************************/
@@ -7116,6 +7921,11 @@ function ac_clear_data(gobj, event, kw, src)
7116
7921
  priv._nodes_total = 0;
7117
7922
  priv._nodes_placed = 0;
7118
7923
 
7924
+ /* So does the fold: a reload opens at `expand_depth` again. */
7925
+ priv._fold_model = null;
7926
+ priv._fold_state = null;
7927
+ priv._pill_sig = null;
7928
+
7119
7929
  graph_remove_plugin(gobj, "history");
7120
7930
  update_history_buttons(gobj);
7121
7931
  graph_clear(gobj);
@@ -7164,81 +7974,65 @@ function ac_load_data(gobj, event, kw, src)
7164
7974
  priv.records[topic_name] = data;
7165
7975
  priv.descs[topic_name].loaded = true;
7166
7976
 
7167
- /*--------------------------------------------------*
7168
- * Creating and loading topic cells from backend
7169
- *--------------------------------------------------*/
7170
- for(let i=0; i<data.length; i++) {
7171
- let record = data[i];
7172
- create_topic_node(gobj, desc, record);
7173
- }
7174
-
7175
7977
  /*----------------------------------------------------*
7176
- * Check if all topics are loaded to make the links
7978
+ * Nothing is drawn until every topic is in: the tree
7979
+ * needs the parents to exist before it can hang the
7980
+ * children from them.
7177
7981
  *----------------------------------------------------*/
7178
- let do_links = true;
7982
+ let all_loaded = true;
7179
7983
  for (const [topic_name, desc] of Object.entries(priv.descs)) {
7180
7984
  if (topic_name.substring(0, 2) === "__") {
7181
7985
  continue; // ignore system topics
7182
7986
  }
7183
7987
  if(!desc.loaded) {
7184
- do_links = false;
7988
+ all_loaded = false;
7185
7989
  break;
7186
7990
  }
7187
7991
  }
7188
7992
 
7189
- if(do_links && priv.graph) {
7190
- /* Decided BEFORE the drawing, not after it.
7191
- *
7192
- * The plugin builds its canvas on its FIRST render, and it renders
7193
- * off the graph's draw events — so a minimap added after the last
7194
- * draw is instantiated, bound, and never painted: no container in
7195
- * the DOM at all, and nothing anywhere says so. Added here it rides
7196
- * the two draws and the layout that follow. The node count it needs
7197
- * is already known: the records were turned into nodes above. */
7993
+ if(all_loaded && priv.graph) {
7994
+ /* The tree of the records, opened `expand_depth` levels: the
7995
+ * roots and their children by default. Only that much becomes
7996
+ * G6 nodes -- a treedb of a thousand records opens as a
7997
+ * handful of cards with a count on each cut. */
7998
+ rebuild_fold_model(gobj);
7999
+ fold_expand_to_depth(
8000
+ priv._fold_model, priv._fold_state,
8001
+ gobj_read_integer_attr(gobj, "expand_depth")
8002
+ );
8003
+
8004
+ /* Before the layout runs, and only now: whether anybody has
8005
+ * ever placed a node of this treedb is not known until its
8006
+ * records and __graphs__ are in. Counted over the records,
8007
+ * not the cards: most of them are folded away. */
8008
+ count_placed_records(gobj);
8009
+ let auto_laid = false;
7198
8010
  try {
7199
- refresh_minimap(gobj);
8011
+ auto_laid = auto_layout(gobj);
7200
8012
  } catch(e) {
7201
- log_error(`${gobj_short_name(gobj)}: refresh_minimap failed: ${e}`);
7202
- }
7203
- graph_draw(gobj).then(() => { // draw nodes, else the link fails
7204
- create_links(gobj);
7205
- graph_draw(gobj).then(() => {
7206
- /* Before the layout runs, and only now: whether anybody has
7207
- * ever placed a node of this treedb is not known until its
7208
- * records and __graphs__ are in. */
7209
- let auto_laid = false;
7210
- try {
7211
- auto_laid = auto_layout(gobj);
7212
- } catch(e) {
7213
- log_error(`${gobj_short_name(gobj)}: auto_layout failed: ${e}`);
7214
- }
7215
- graph_layout(gobj).then(() => {
7216
- /* A graph laid out by us opens WHOLE. dagre spreads a
7217
- * 126-node treedb over some 19000px, and the camera is
7218
- * wherever it was: without this you get eight cards in
7219
- * the top-left corner and no reason to think there are
7220
- * 118 more. Only when WE laid it out — a saved
7221
- * arrangement includes where its owner was looking. */
7222
- if(auto_laid) {
7223
- graph_fit_readable(gobj).catch((e) => {
7224
- log_error(`${gobj_short_name(gobj)}: cannot fit the graph: ${e}`);
7225
- });
7226
- }
7227
- sync_history_plugin(gobj);
7228
- /* Nodes exist and are positioned now: apply a focus
7229
- * requested before the data was ready (deep link). */
7230
- if(priv._pending_focus_topic !== null) {
7231
- let ft = priv._pending_focus_topic;
7232
- priv._pending_focus_topic = null;
7233
- graph_focus_topic(gobj, ft);
7234
- }
7235
- if(priv._pending_find !== null) {
7236
- let term = priv._pending_find;
7237
- priv._pending_find = null;
7238
- publish_find_result(gobj, term, graph_find_nodes(gobj, term));
7239
- }
7240
- });
7241
- });
8013
+ log_error(`${gobj_short_name(gobj)}: auto_layout failed: ${e}`);
8014
+ }
8015
+ /* The ports and edges follow the layout's direction, and the
8016
+ * nodes are about to be built: settle it first. */
8017
+ apply_layout_direction(gobj);
8018
+
8019
+ /* A graph laid out by us opens WHOLE (fit). Only when WE laid
8020
+ * it out — a saved arrangement includes where its owner was
8021
+ * looking. */
8022
+ reconcile_fold(gobj, {relayout: true, fit: auto_laid}).then(() => {
8023
+ sync_history_plugin(gobj);
8024
+ /* Nodes exist and are positioned now: apply a focus
8025
+ * requested before the data was ready (deep link). */
8026
+ if(priv._pending_focus_topic !== null) {
8027
+ let ft = priv._pending_focus_topic;
8028
+ priv._pending_focus_topic = null;
8029
+ graph_focus_topic(gobj, ft);
8030
+ }
8031
+ if(priv._pending_find !== null) {
8032
+ let term = priv._pending_find;
8033
+ priv._pending_find = null;
8034
+ publish_find_result(gobj, term, graph_find_nodes(gobj, term));
8035
+ }
7242
8036
  });
7243
8037
  }
7244
8038
 
@@ -7306,14 +8100,12 @@ function ac_node_created(gobj, event, kw, src)
7306
8100
  priv.records[topic_name].push(node);
7307
8101
 
7308
8102
  /*
7309
- * Create graph node and draw its links
8103
+ * Into the tree, and on screen: whoever created it wants to see
8104
+ * it, so the path down to it is opened.
7310
8105
  */
7311
- history_pause(gobj);
7312
- create_topic_node(gobj, desc, node);
7313
- draw_links(gobj, desc, node, false);
7314
- graph_draw(gobj).then(() => {
7315
- history_resume(gobj);
7316
- });
8106
+ rebuild_fold_model(gobj);
8107
+ fold_reveal(priv._fold_model, priv._fold_state, fold_node_key(topic_name, String(node.id)));
8108
+ reconcile_fold(gobj, {});
7317
8109
 
7318
8110
  return 0;
7319
8111
  }
@@ -7394,12 +8186,22 @@ function ac_node_updated(gobj, event, kw, src)
7394
8186
  }
7395
8187
 
7396
8188
  /*
7397
- * Update node data and local record
8189
+ * Update node data and local record. The card may be off screen
8190
+ * (folded): then only the record moves, and the reconcile below
8191
+ * repaints the count on whatever pill reaches it.
7398
8192
  */
7399
- update_topic_node(gobj, desc, node_name, node);
7400
8193
  update_local_node(gobj, topic_name, node);
8194
+ let on_screen = false;
8195
+ try {
8196
+ on_screen = !!graph.getNodeData(node_name);
8197
+ } catch(e) {
8198
+ on_screen = false;
8199
+ }
8200
+ if(on_screen) {
8201
+ update_topic_node(gobj, desc, node_name, node);
8202
+ }
7401
8203
 
7402
- // Draw edges for refs that appeared
8204
+ // Draw edges for refs that appeared (draw_link skips an end off screen)
7403
8205
  for(let ref of new_refs) {
7404
8206
  if(!old_refs.has(ref)) {
7405
8207
  let [col_idx, fkey_value] = ref.split("\t");
@@ -7411,6 +8213,11 @@ function ac_node_updated(gobj, event, kw, src)
7411
8213
  history_resume(gobj);
7412
8214
  });
7413
8215
 
8216
+ /* The links are the tree: a moved fkey moves the record from one
8217
+ * parent's pill to another's. */
8218
+ rebuild_fold_model(gobj);
8219
+ reconcile_fold(gobj, {});
8220
+
7414
8221
  return 0;
7415
8222
  }
7416
8223
 
@@ -7450,11 +8257,17 @@ function ac_node_deleted(gobj, event, kw, src)
7450
8257
  clear_links(gobj, desc, node, false);
7451
8258
  remove_topic_node(gobj, node_name);
7452
8259
  remove_local_node(gobj, topic_name, node);
8260
+ if(priv._pill_sig) {
8261
+ priv._pill_sig.delete(node_name);
8262
+ }
7453
8263
 
7454
8264
  graph_draw(gobj).then(() => {
7455
8265
  history_resume(gobj);
7456
8266
  });
7457
8267
 
8268
+ rebuild_fold_model(gobj);
8269
+ reconcile_fold(gobj, {});
8270
+
7458
8271
  return 0;
7459
8272
  }
7460
8273
 
@@ -7544,6 +8357,7 @@ function ac_theme(gobj, event, kw, src)
7544
8357
 
7545
8358
  refresh_html_nodes_theme(gobj, theme);
7546
8359
  refresh_default_edges_theme(gobj, theme);
8360
+ repaint_more_chips(gobj);
7547
8361
 
7548
8362
  graph_draw(gobj).then(() => {
7549
8363
  // Restore toolbar icon states lost when G6 re-renders the DOM
@@ -7716,6 +8530,97 @@ function ac_focus_topic(gobj, event, kw, src)
7716
8530
  return 0;
7717
8531
  }
7718
8532
 
8533
+ /************************************************************
8534
+ * A pill was tapped: open that hook on its first page, or fold
8535
+ * it. The card the pill is on stays where it was on screen --
8536
+ * the layout moves everything else around it.
8537
+ ************************************************************/
8538
+ function ac_toggle_fold(gobj, event, kw, src)
8539
+ {
8540
+ let priv = gobj.priv;
8541
+ let group = (kw && kw.group) || "";
8542
+
8543
+ if(!priv._fold_model || !group) {
8544
+ log_error(`${gobj_short_name(gobj)}: fold of '${group}' with no tree loaded`);
8545
+ return -1;
8546
+ }
8547
+ let g = fold_split_group_key(group);
8548
+ if(!g) {
8549
+ log_error(`${gobj_short_name(gobj)}: bad fold group '${group}'`);
8550
+ return -1;
8551
+ }
8552
+ let node_id = g.node_key? node_id_of_key(gobj, g.node_key) : "";
8553
+ let vp = node_id? yui_graph_viewport_of(priv.graph, node_id) : null;
8554
+
8555
+ fold_toggle(priv._fold_model, priv._fold_state, group);
8556
+ reconcile_fold(gobj, {keep: node_id, keep_vp: vp});
8557
+
8558
+ return 0;
8559
+ }
8560
+
8561
+ /************************************************************
8562
+ * A `+N` chip was tapped: the next page of its group. The
8563
+ * parent holds still; the chip itself moves down the column.
8564
+ ************************************************************/
8565
+ function ac_show_more(gobj, event, kw, src)
8566
+ {
8567
+ let priv = gobj.priv;
8568
+ let group = (kw && kw.group) || "";
8569
+
8570
+ if(!priv._fold_model || !group) {
8571
+ log_error(`${gobj_short_name(gobj)}: show more of '${group}' with no tree loaded`);
8572
+ return -1;
8573
+ }
8574
+ let g = fold_split_group_key(group);
8575
+ if(!g) {
8576
+ log_error(`${gobj_short_name(gobj)}: bad fold group '${group}'`);
8577
+ return -1;
8578
+ }
8579
+ let node_id = g.node_key? node_id_of_key(gobj, g.node_key) : "";
8580
+ let vp = node_id? yui_graph_viewport_of(priv.graph, node_id) : null;
8581
+
8582
+ fold_show_more(priv._fold_model, priv._fold_state, group);
8583
+ reconcile_fold(gobj, {keep: node_id, keep_vp: vp});
8584
+
8585
+ return 0;
8586
+ }
8587
+
8588
+ /************************************************************
8589
+ * The whole treedb, every hook open on all its children. This
8590
+ * is the reader asking for the pile on purpose; the pages are
8591
+ * the other way in.
8592
+ ************************************************************/
8593
+ function ac_expand_all(gobj, event, kw, src)
8594
+ {
8595
+ let priv = gobj.priv;
8596
+
8597
+ if(!priv._fold_model) {
8598
+ log_error(`${gobj_short_name(gobj)}: expand all with no tree loaded`);
8599
+ return -1;
8600
+ }
8601
+ fold_expand_all(priv._fold_model, priv._fold_state);
8602
+ reconcile_fold(gobj, {fit: true});
8603
+
8604
+ return 0;
8605
+ }
8606
+
8607
+ /************************************************************
8608
+ * The roots alone.
8609
+ ************************************************************/
8610
+ function ac_collapse_all(gobj, event, kw, src)
8611
+ {
8612
+ let priv = gobj.priv;
8613
+
8614
+ if(!priv._fold_model) {
8615
+ log_error(`${gobj_short_name(gobj)}: collapse all with no tree loaded`);
8616
+ return -1;
8617
+ }
8618
+ fold_collapse_all(priv._fold_model, priv._fold_state);
8619
+ reconcile_fold(gobj, {fit: true});
8620
+
8621
+ return 0;
8622
+ }
8623
+
7719
8624
  /************************************************************
7720
8625
  * Tell whoever asked how many nodes the term matched. The count is
7721
8626
  * the answer to the question the box asks; without it "nothing moved"
@@ -7755,6 +8660,9 @@ function ac_set_layout(gobj, event, kw, src)
7755
8660
  {
7756
8661
  let priv = gobj.priv;
7757
8662
  let layout = select_layout(gobj, kw.layout);
8663
+ /* The ports turn with the reading direction, before the lines
8664
+ * are laid out from them. */
8665
+ apply_layout_direction(gobj);
7758
8666
  graph_set_layout(gobj, layout).then(() => {
7759
8667
  configure_behaviour(gobj);
7760
8668
  update_toolbar(gobj);
@@ -7913,6 +8821,21 @@ function ac_node_click(gobj, event, kw, src)
7913
8821
  let graph = priv.graph;
7914
8822
  let node_id = kw.evt.target.id;
7915
8823
 
8824
+ /*
8825
+ * A `+N` chip is one thing only: the next page of its group. It
8826
+ * is not a record -- nothing to select, anchor, detail or link.
8827
+ */
8828
+ let more_nd = null;
8829
+ try {
8830
+ more_nd = graph.getNodeData(node_id);
8831
+ } catch(e) {
8832
+ more_nd = null;
8833
+ }
8834
+ if(is_more_node(more_nd)) {
8835
+ gobj_send_event(gobj, "EV_SHOW_MORE", {group: more_nd.data.more}, gobj);
8836
+ return 0;
8837
+ }
8838
+
7916
8839
  /*
7917
8840
  * A waiting anchor TAKES the click, before selection, before
7918
8841
  * ports, before linking: it is what the reader armed the button
@@ -8251,6 +9174,10 @@ function create_gclass(gclass_name)
8251
9174
  ["EV_ZOOM_SELECTION", ac_zoom_selection, null],
8252
9175
  ["EV_FOCUS_TOPIC", ac_focus_topic, null],
8253
9176
  ["EV_FIND_NODES", ac_find_nodes, null],
9177
+ ["EV_TOGGLE_FOLD", ac_toggle_fold, null],
9178
+ ["EV_SHOW_MORE", ac_show_more, null],
9179
+ ["EV_EXPAND_ALL", ac_expand_all, null],
9180
+ ["EV_COLLAPSE_ALL", ac_collapse_all, null],
8254
9181
  ["EV_CENTER", ac_center, null],
8255
9182
  ["EV_FULLSCREEN", ac_fullscreen, null],
8256
9183
  ["EV_SET_LAYOUT", ac_set_layout, null],
@@ -8304,6 +9231,10 @@ function create_gclass(gclass_name)
8304
9231
  ["EV_FIND_NODES", 0],
8305
9232
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
8306
9233
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
9234
+ ["EV_TOGGLE_FOLD", 0],
9235
+ ["EV_SHOW_MORE", 0],
9236
+ ["EV_EXPAND_ALL", 0],
9237
+ ["EV_COLLAPSE_ALL", 0],
8307
9238
  ["EV_CENTER", 0],
8308
9239
  ["EV_FULLSCREEN", 0],
8309
9240
  ["EV_SET_LAYOUT", 0],