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