@yuneta/gobj-ui 7.23.74 → 7.23.75

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.
@@ -52,6 +52,7 @@ import {
52
52
  gobj_read_integer_attr,
53
53
  gobj_write_attr,
54
54
  gobj_write_str_attr,
55
+ gobj_write_bool_attr,
55
56
  gobj_parent,
56
57
  gobj_name,
57
58
  gobj_short_name,
@@ -124,7 +125,7 @@ import {
124
125
  fold_node_key,
125
126
  fold_split_group_key,
126
127
  } from "./treedb_fold_model.js";
127
- import {layout_tree, layout_outline, layout_radial} from "./treedb_layout.js";
128
+ import {layout_tree, layout_radial} from "./treedb_layout.js";
128
129
 
129
130
  import {
130
131
  BaseLayout,
@@ -247,6 +248,28 @@ const HIGHLIGHT_HALO = "rgba(240,160,32,0.35)";
247
248
  */
248
249
  const SELECT_RING = "rgba(59,130,246,0.95)";
249
250
 
251
+ /*
252
+ * The ports of an open card. They are what a link is DRAWN from
253
+ * and what a resize takes hold of, and at a radius of 6 (2 on a
254
+ * chip) with a hairline stroke nobody could tell they were either:
255
+ * a port has to look like a handle.
256
+ */
257
+ const PORT_R_ENTITY = 10;
258
+ const PORT_R_CHILD = 5;
259
+ const PORT_LINE_WIDTH = 2;
260
+
261
+ /*
262
+ * The CLOSED shape of a record: a rounded square of the topic's
263
+ * colour, no ports, no text -- topology and nothing else. The
264
+ * three tiers keep their order of size, so a root is still bigger
265
+ * than a leaf when nothing says which is which.
266
+ */
267
+ const COMPACT_SIZE = {
268
+ hierarchical: 32,
269
+ extended: 28,
270
+ child: 22,
271
+ };
272
+
250
273
  /***************************************************************
251
274
  * Internal layout and operation mode definitions
252
275
  ***************************************************************/
@@ -262,21 +285,20 @@ const _layouts = {
262
285
  * for whoever wants it -- the two are the same algorithm read two
263
286
  * ways, so the layout picker is also the direction picker. */
264
287
  /* OURS, made for a treedb (treedb_layout.js): a tidy tree read
265
- * left to right, and an outline of one row per node. Both take
266
- * the spanning tree of what is on screen -- the roots, the first
267
- * parent that reaches a node, the hooks in schema order, the
268
- * records in their order -- so opening a hook moves nothing that
269
- * is not under or beside it. The default for a treedb nobody has
270
- * arranged (see auto_layout). */
288
+ * TOP TO BOTTOM. It takes the spanning tree of what is on screen
289
+ * -- the roots, the first parent that reaches a node, the hooks
290
+ * in schema order, the records in their order -- so opening a
291
+ * hook moves nothing that is not under or beside it. Down and not
292
+ * right, because down is where a tree has room: a hall with a
293
+ * hundred devices is a wide row, not a column beside a card --
294
+ * and read right it was `dagre` with the siblings held still,
295
+ * which nobody could tell apart. The default for a treedb nobody
296
+ * has arranged (see auto_layout). */
271
297
  "treedb-tree": {
272
298
  type: 'treedb-tree',
299
+ direction: 'TB',
273
300
  nodesep: 18,
274
- ranksep: 110,
275
- },
276
- "treedb-outline": {
277
- type: 'treedb-outline',
278
- nodesep: 12,
279
- indent: 48,
301
+ ranksep: 90,
280
302
  },
281
303
  "dagre": {
282
304
  type: 'dagre',
@@ -370,6 +392,10 @@ SDATA(data_type_t.DTP_INTEGER, "fold_page_size", 0, 24, "Children of
370
392
  SDATA(data_type_t.DTP_LIST, "hidden_topics", 0, "[]", "Topics left out of the tree: no card, no pill counts them, no edge reaches them"),
371
393
  SDATA(data_type_t.DTP_STRING, "main_topic", 0, "", "The topic the tree hangs from; empty = deduced (the one whose hooks reach the most others). Its parentless records are the roots; a parentless record of a topic linked to it is LOOSE and shown only on request"),
372
394
  SDATA(data_type_t.DTP_LIST, "loose_topics", 0, "[]", "Topics whose loose records (no parent, in a topic the schema hangs from the main one) are shown as roots"),
395
+
396
+ /*---------------- Node shape ----------------*/
397
+ SDATA(data_type_t.DTP_STRING, "node_mode", 0, "expanded", "How a record is drawn: `expanded` = the card with its ports (the only shape a link can be edited on); `compact` = a rounded square of the topic's colour, no ports, no text -- the topology alone. One node can be toggled against it (EV_TOGGLE_NODE_MODE)"),
398
+ SDATA(data_type_t.DTP_BOOLEAN, "node_labels", 0, true, "A compact node says its name under the square. Off, it is a coloured square and nothing else"),
373
399
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
374
400
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
375
401
 
@@ -466,6 +492,7 @@ let PRIVATE_DATA = {
466
492
  _fold_state: null, // ...and which groups of it are open
467
493
  _fold_listener: null, // delegated click on the card pills
468
494
  _pill_sig: null, // Map<node_id, string>: the pills a card was painted with
495
+ _open_nodes: null, // Set<node_id>: the nodes toggled AGAINST `node_mode`
469
496
  _fold_busy: false, // a reconcile is in flight
470
497
  };
471
498
 
@@ -807,7 +834,6 @@ function register_layouts(gobj)
807
834
  _g6_extensions_registered = true;
808
835
  register(ExtensionCategory.LAYOUT, 'manual', ManualLayout);
809
836
  register(ExtensionCategory.LAYOUT, 'treedb-tree', TreedbTreeLayout);
810
- register(ExtensionCategory.LAYOUT, 'treedb-outline', TreedbOutlineLayout);
811
837
  register(ExtensionCategory.LAYOUT, 'treedb-radial', TreedbRadialLayout);
812
838
  register(ExtensionCategory.NODE, 'light', LightNode);
813
839
  }
@@ -928,6 +954,10 @@ function configure_events(gobj)
928
954
  gobj_send_event(gobj, "EV_NODE_CONTEXT_MENU", {evt: evt}, gobj);
929
955
  });
930
956
 
957
+ graph.on(NodeEvent.DBLCLICK, (evt) => {
958
+ gobj_send_event(gobj, "EV_NODE_DBLCLICK", {evt: evt}, gobj);
959
+ });
960
+
931
961
  graph.on(EdgeEvent.CLICK, (evt) => {
932
962
  if(consume_long_press_click(graph)) {
933
963
  return;
@@ -1598,7 +1628,7 @@ function configure_behaviour(gobj)
1598
1628
  priv.edit_mode = false;
1599
1629
  behaviors = [
1600
1630
  "drag-canvas",
1601
- "zoom-canvas",
1631
+ ...camera_behaviors(),
1602
1632
  ];
1603
1633
  break;
1604
1634
  case "edition":
@@ -1630,7 +1660,7 @@ function configure_behaviour(gobj)
1630
1660
  return true; /* a key, not a pointer */
1631
1661
  },
1632
1662
  },
1633
- "zoom-canvas",
1663
+ ...camera_behaviors(),
1634
1664
  /* Moves EVERY node in the `selected` state, not just
1635
1665
  * the one under the pointer, and wraps the whole move
1636
1666
  * in one history batch -- so a group move is one drag
@@ -1689,7 +1719,7 @@ function configure_behaviour(gobj)
1689
1719
  * where the gestures ARE the camera, the graph froze. */
1690
1720
  behaviors = [
1691
1721
  "drag-canvas",
1692
- "zoom-canvas",
1722
+ ...camera_behaviors(),
1693
1723
  ];
1694
1724
  break;
1695
1725
  default:
@@ -1792,9 +1822,38 @@ function build_ports(gobj, desc)
1792
1822
  * Which way the graph reads. `dagre` is the left-to-right
1793
1823
  * layout; everything else keeps the top-down reading, `manual`
1794
1824
  * included, because the saved arrangements were made with the
1795
- * ports on the top and bottom edges.
1825
+ * ports on the top and bottom edges. The treedb tree reads down
1826
+ * too, since 7.23.75: read right it was dagre's twin.
1796
1827
  ************************************************************/
1797
- const LR_LAYOUTS = ["dagre", "treedb-tree", "treedb-outline"];
1828
+ const LR_LAYOUTS = ["dagre"];
1829
+
1830
+ /************************************************************
1831
+ * The camera behaviors every operation mode shares.
1832
+ *
1833
+ * The wheel SCROLLS, as it does on a map and on every page: a
1834
+ * graph of many nodes is taller than the screen, and a wheel
1835
+ * that zoomed instead made it a thing to be looked at from afar
1836
+ * or read through a keyhole -- never scrolled. Zoom is Ctrl +
1837
+ * wheel (which is also what a trackpad pinch arrives as), the
1838
+ * toolbar's +/-, and the two-finger pinch of g6_touch_gestures.
1839
+ * `enable` on the scroll keeps the two apart: a Ctrl wheel is
1840
+ * the zoom's, so the scroll stands aside for it.
1841
+ ************************************************************/
1842
+ function camera_behaviors()
1843
+ {
1844
+ return [
1845
+ {
1846
+ type: "scroll-canvas",
1847
+ key: "scroll-canvas",
1848
+ enable: (event) => !(event && (event.ctrlKey || event.metaKey)),
1849
+ },
1850
+ {
1851
+ type: "zoom-canvas",
1852
+ key: "zoom-canvas",
1853
+ trigger: ["Control"],
1854
+ },
1855
+ ];
1856
+ }
1798
1857
 
1799
1858
  function layout_direction(gobj)
1800
1859
  {
@@ -1916,58 +1975,105 @@ function create_topic_node(gobj, desc, record)
1916
1975
 
1917
1976
  //log_warning(`create node ==> ${node_name}`);
1918
1977
 
1919
- let ports = build_ports(gobj, desc);
1978
+ let pills_html = pills_html_of_key(gobj, fold_node_key(desc.topic_name, String(record.id)));
1979
+ let shape = node_shape_of(gobj, node_name, desc, record, geometry, pills_html);
1980
+
1981
+ let node_def = {
1982
+ id: node_name,
1983
+ type: shape.type,
1984
+ style: Object.assign({x: x, y: y}, shape.style),
1985
+ data: {
1986
+ // This 4 keys are available in user `data` of G6 Node.
1987
+ topic_name: desc.topic_name,
1988
+ desc: desc,
1989
+ record: record,
1990
+ graph_props: geometry, // from __graphs__ (or _geometry fallback)
1991
+ /* The ports in schema order, whatever shape the node
1992
+ * wears: the tree layout ranks the children by them, and
1993
+ * a closed node carries no ports on its style. */
1994
+ port_keys: build_ports(gobj, desc).map((p) => p.key),
1995
+ }
1996
+ };
1997
+
1998
+ graph.addNodeData([node_def]);
1999
+ }
2000
+
2001
+ /************************************************************
2002
+ * The shape of a record node: OPEN (the card, with its ports)
2003
+ * or CLOSED (a coloured square), by `node_mode` and the nodes
2004
+ * toggled against it.
2005
+ *
2006
+ * Both answer {type, style} WITHOUT x/y, which belong to the node
2007
+ * and not to its shape, and a node moves between the two on the
2008
+ * fly: G6 rebuilds the element when `type` changes, and the
2009
+ * style is MERGED, which is why each shape also writes the keys
2010
+ * the other one would leave behind (`labelText`, `ports`).
2011
+ ************************************************************/
2012
+ function node_shape_of(gobj, node_id, desc, record, geometry, pills_html)
2013
+ {
2014
+ if(node_is_compact(gobj, node_id)) {
2015
+ return compact_shape_of(gobj, desc, record, {});
2016
+ }
2017
+ return card_shape_of(gobj, desc, record, geometry, pills_html);
2018
+ }
2019
+
2020
+ function node_is_compact(gobj, node_id)
2021
+ {
2022
+ let priv = gobj.priv;
2023
+ if(!priv._open_nodes) {
2024
+ priv._open_nodes = new Set();
2025
+ }
2026
+ let compact = (gobj_read_str_attr(gobj, "node_mode") === "compact");
2027
+ return priv._open_nodes.has(node_id)? !compact : compact;
2028
+ }
1920
2029
 
1921
- let node_graph_type = null;
2030
+ /************************************************************
2031
+ * The CARD: an html node, the tier's size, the ports, the
2032
+ * saved geometry on top. The flags (focus, selection, anchor)
2033
+ * are not painted here: repaint_cards() reads them where they
2034
+ * live and rebuilds the html.
2035
+ ************************************************************/
2036
+ function card_shape_of(gobj, desc, record, geometry, pills_html)
2037
+ {
2038
+ let priv = gobj.priv;
1922
2039
  let node_treedb_type = desc.node_treedb_type;
2040
+ let label = node_label(desc, record);
2041
+ let ports = build_ports(gobj, desc);
1923
2042
 
1924
2043
  let style = {
1925
- x: x,
1926
- y: y,
1927
- fill: desc.color, // Fill color
1928
- stroke: getStrokeColor(desc.color), // Stroke color
1929
- lineWidth: 1, // Stroke width
1930
- //labelText: desc.topic_name + "^" + record.id,
2044
+ fill: desc.color,
2045
+ stroke: getStrokeColor(desc.color),
2046
+ lineWidth: 1,
2047
+ lineDash: [],
2048
+ halo: false,
2049
+ labelText: "", /* a card says its name inside; the label is the square's */
1931
2050
  };
1932
2051
 
1933
2052
  /* The pills are part of the card, so the card is taller when it
1934
2053
  * has any: a row of them on a 96px card sat on the subtitle. */
1935
- let pills_html = pills_html_of_key(gobj, fold_node_key(desc.topic_name, String(record.id)));
1936
2054
  let size = card_size_for(node_treedb_type, !!pills_html);
2055
+ style.size = size;
2056
+ style.dx = -size[0] / 2;
2057
+ style.dy = -size[1] / 2;
1937
2058
 
1938
2059
  if(node_treedb_type === 'child') {
1939
2060
  // Pure child (LEAF): smallest tier. Rounded-rect chip,
1940
2061
  // same card language as entities, lighter.
1941
- node_graph_type = 'html';
1942
- style.size = size;
1943
- style.dx = -size[0] / 2;
1944
- style.dy = -size[1] / 2;
1945
2062
  style.innerHTML = build_chip_innerHTML(
1946
- desc.color, priv.theme, record.icon,
1947
- node_label(desc, record), record.id
2063
+ desc.color, priv.theme, record.icon, label, record.id
1948
2064
  );
1949
-
1950
2065
  } else if(node_treedb_type === 'extended') {
1951
2066
  // Extended (structural / INTERMEDIATE): middle tier. Card
1952
2067
  // with name; "structural" style (neutral grey, dashed
1953
2068
  // border) to read as a container/junction.
1954
- node_graph_type = 'html';
1955
- style.size = size;
1956
- style.dx = -size[0] / 2;
1957
- style.dy = -size[1] / 2;
1958
2069
  style.innerHTML = build_node_innerHTML(
1959
- desc.color, priv.theme, record.icon, node_label(desc, record),
2070
+ desc.color, priv.theme, record.icon, label,
1960
2071
  desc.topic_name, true, record.id, false, false, false, pills_html
1961
2072
  );
1962
-
1963
2073
  } else {
1964
2074
  // Hierarchical entity (ROOT / container): largest tier.
1965
- node_graph_type = 'html';
1966
- style.size = size;
1967
- style.dx = -size[0] / 2;
1968
- style.dy = -size[1] / 2;
1969
2075
  style.innerHTML = build_node_innerHTML(
1970
- desc.color, priv.theme, record.icon, node_label(desc, record),
2076
+ desc.color, priv.theme, record.icon, label,
1971
2077
  desc.topic_name, false, record.id, false, false, false, pills_html
1972
2078
  );
1973
2079
  }
@@ -1980,10 +2086,8 @@ function create_topic_node(gobj, desc, record)
1980
2086
  let def_size = topic_defaults.size;
1981
2087
  if(!geometry.size && Array.isArray(def_size) && def_size.length > 0) {
1982
2088
  style.size = [...def_size];
1983
- if(node_graph_type === 'html') {
1984
- style.dx = -def_size[0] / 2;
1985
- style.dy = -(def_size.length > 1 ? def_size[1] : def_size[0]) / 2;
1986
- }
2089
+ style.dx = -def_size[0] / 2;
2090
+ style.dy = -(def_size.length > 1 ? def_size[1] : def_size[0]) / 2;
1987
2091
  }
1988
2092
  if(!geometry.portR && topic_defaults.portR > 0) {
1989
2093
  style.portR = topic_defaults.portR;
@@ -1994,35 +2098,20 @@ function create_topic_node(gobj, desc, record)
1994
2098
  let saved_size = geometry.size;
1995
2099
  if(Array.isArray(saved_size) && saved_size.length > 0) {
1996
2100
  style.size = saved_size;
1997
- // Recalculate dx/dy for HTML nodes to keep content centered
1998
- if(node_graph_type === 'html') {
1999
- style.dx = -saved_size[0] / 2;
2000
- style.dy = -(saved_size.length > 1 ? saved_size[1] : saved_size[0]) / 2;
2001
- }
2101
+ // Recalculate dx/dy to keep content centered
2102
+ style.dx = -saved_size[0] / 2;
2103
+ style.dy = -(saved_size.length > 1 ? saved_size[1] : saved_size[0]) / 2;
2002
2104
  }
2003
2105
  if(geometry.portR > 0) {
2004
2106
  style.portR = geometry.portR;
2005
2107
  }
2006
2108
 
2007
- let node_def = {
2008
- id: node_name,
2009
- type: node_graph_type,
2010
- style: style,
2011
- data: {
2012
- // This 4 keys are available in user `data` of G6 Node.
2013
- topic_name: desc.topic_name,
2014
- desc: desc,
2015
- record: record,
2016
- graph_props: geometry // from __graphs__ (or _geometry fallback)
2017
- }
2018
- };
2019
-
2020
2109
  if(json_size(ports)) {
2021
2110
  json_object_update_missing(style, {
2022
2111
  port: true,
2023
2112
  ports: ports,
2024
- portR: node_treedb_type === 'child' ? 2 : 6,
2025
- portLineWidth: 1,
2113
+ portR: node_treedb_type === 'child' ? PORT_R_CHILD : PORT_R_ENTITY,
2114
+ portLineWidth: PORT_LINE_WIDTH,
2026
2115
  });
2027
2116
 
2028
2117
  // Restore per-port radius: saved geometry first, then topic defaults
@@ -2040,11 +2129,135 @@ function create_topic_node(gobj, desc, record)
2040
2129
  }
2041
2130
  }
2042
2131
  }
2132
+ } else {
2133
+ style.port = false;
2134
+ style.ports = [];
2135
+ }
2136
+
2137
+ return {type: 'html', style: style};
2138
+ }
2139
+
2140
+ /************************************************************
2141
+ * The SQUARE: a G6 `rect` of the topic's colour, no ports, the
2142
+ * name as a label under it when `node_labels` says so. A native
2143
+ * shape and not html on purpose: G6's own stroke and halo paint
2144
+ * on it, so the focus, the selection and the anchor are three
2145
+ * keys of the style instead of a rebuilt innerHTML.
2146
+ *
2147
+ * flags {highlight, selected, anchored}
2148
+ ************************************************************/
2149
+ function compact_shape_of(gobj, desc, record, flags)
2150
+ {
2151
+ let priv = gobj.priv;
2152
+ let dark = (priv.theme === "dark");
2153
+ let f = flags || {};
2154
+ let tier = desc.node_treedb_type;
2155
+ let side = COMPACT_SIZE[tier] || COMPACT_SIZE.hierarchical;
2156
+ let labels = gobj_read_bool_attr(gobj, "node_labels");
2157
+
2158
+ let stroke = getStrokeColor(desc.color);
2159
+ let line_width = 1.5;
2160
+ if(f.selected) {
2161
+ stroke = SELECT_RING;
2162
+ line_width = 3;
2163
+ }
2164
+ if(f.highlight) {
2165
+ stroke = HIGHLIGHT_COLOR;
2166
+ line_width = 3;
2167
+ }
2168
+ /* Dashed says "structural", as the extended card does; the
2169
+ * anchor's mark is a tighter dash, as on the card. */
2170
+ let dash = [];
2171
+ if(tier === 'extended') {
2172
+ dash = [4, 3];
2173
+ }
2174
+ if(f.anchored) {
2175
+ dash = [2, 2];
2176
+ line_width = Math.max(line_width, 2.5);
2177
+ }
2178
+
2179
+ let style = {
2180
+ size: [side, side],
2181
+ radius: Math.round(side / 5),
2182
+ fill: desc.color,
2183
+ stroke: stroke,
2184
+ lineWidth: line_width,
2185
+ lineDash: dash,
2186
+ halo: !!f.highlight,
2187
+ haloStroke: HIGHLIGHT_HALO,
2188
+ haloLineWidth: 8,
2189
+ port: false,
2190
+ ports: [],
2191
+ labelText: labels? node_label(desc, record) : "",
2192
+ labelPlacement: 'bottom',
2193
+ labelFontSize: 11,
2194
+ labelFill: dark? '#e6e9ef' : '#2e333d',
2195
+ labelMaxWidth: 150,
2196
+ labelWordWrap: true,
2197
+ labelMaxLines: 1,
2198
+ labelTextOverflow: 'ellipsis',
2199
+ labelBackground: true,
2200
+ labelBackgroundFill: dark? '#1b2230' : '#ffffff',
2201
+ labelBackgroundOpacity: 0.85,
2202
+ labelBackgroundRadius: 3,
2203
+ labelPadding: [1, 3],
2204
+ };
2205
+ return {type: 'rect', style: style};
2206
+ }
2207
+
2208
+ /************************************************************
2209
+ * Give these record nodes the shape their mode says, then
2210
+ * repaint them with the flags they carry. A `+N` chip is not a
2211
+ * record and keeps its own shape.
2212
+ ************************************************************/
2213
+ function reshape_nodes(gobj, ids)
2214
+ {
2215
+ let priv = gobj.priv;
2216
+ let graph = priv.graph;
2217
+ if(!graph) {
2218
+ return;
2219
+ }
2220
+ let updates = [];
2221
+ let touched = new Set();
2222
+ for(let id of ids) {
2223
+ let nd;
2224
+ try {
2225
+ nd = graph.getNodeData(id);
2226
+ } catch(e) {
2227
+ continue;
2228
+ }
2229
+ if(!nd || !nd.data || !nd.data.desc || is_more_node(nd)) {
2230
+ continue;
2231
+ }
2232
+ let geometry = nd.data.graph_props || {};
2233
+ let shape = node_shape_of(
2234
+ gobj, id, nd.data.desc, nd.data.record || {}, geometry, pills_html_of(gobj, nd)
2235
+ );
2236
+ updates.push({id: id, type: shape.type, style: shape.style});
2237
+ touched.add(id);
2238
+ }
2239
+ if(!updates.length) {
2240
+ return;
2241
+ }
2242
+ try {
2243
+ graph.updateNodeData(updates);
2244
+ } catch(e) {
2245
+ log_error(`${gobj_short_name(gobj)}: cannot reshape the nodes: ${e}`);
2246
+ return;
2043
2247
  }
2248
+ repaint_cards(gobj, touched);
2249
+ }
2044
2250
 
2045
- if(node_graph_type) {
2046
- graph.addNodeData([node_def]);
2251
+ function all_record_node_ids(gobj)
2252
+ {
2253
+ let graph = gobj.priv.graph;
2254
+ let ids = [];
2255
+ for(let nd of ((graph && graph.getNodeData()) || [])) {
2256
+ if(nd && nd.data && nd.data.desc && !is_more_node(nd)) {
2257
+ ids.push(nd.id);
2258
+ }
2047
2259
  }
2260
+ return ids;
2048
2261
  }
2049
2262
 
2050
2263
  /************************************************************
@@ -3336,7 +3549,7 @@ class ManualLayout extends BaseLayout
3336
3549
  }
3337
3550
 
3338
3551
  /************************************************************
3339
- * Custom G6 layouts: TreedbTreeLayout / TreedbOutlineLayout.
3552
+ * Custom G6 layouts: TreedbTreeLayout / TreedbRadialLayout.
3340
3553
  *
3341
3554
  * Thin adapters over treedb_layout.js: the nodes with their sizes,
3342
3555
  * the edges with the RANK of the hook they leave by (the position
@@ -3354,8 +3567,13 @@ function treedb_layout_input(data)
3354
3567
  let size = (node.style && node.style.size) || [172, 96];
3355
3568
  let w = is_array(size)? size[0] : size;
3356
3569
  let h = is_array(size)? (size.length > 1? size[1] : size[0]) : size;
3570
+ /* The hook order comes from `data.port_keys`, written when
3571
+ * the node is born: a CLOSED node has no ports on its style,
3572
+ * and its children still have to sit in schema order. */
3357
3573
  let ports = (node.style && is_array(node.style.ports))? node.style.ports : [];
3358
- ports_of.set(node.id, ports.map((p) => p.key));
3574
+ let keys = (node.data && is_array(node.data.port_keys))
3575
+ ? node.data.port_keys : ports.map((p) => p.key);
3576
+ ports_of.set(node.id, keys);
3359
3577
  return {id: node.id, w: w, h: h};
3360
3578
  });
3361
3579
 
@@ -3386,14 +3604,6 @@ class TreedbTreeLayout extends BaseLayout
3386
3604
  }
3387
3605
  }
3388
3606
 
3389
- class TreedbOutlineLayout extends BaseLayout
3390
- {
3391
- async execute(data, options) {
3392
- let input = treedb_layout_input(data);
3393
- return treedb_layout_output(layout_outline(input.nodes, input.edges, options));
3394
- }
3395
- }
3396
-
3397
3607
  class TreedbRadialLayout extends BaseLayout
3398
3608
  {
3399
3609
  async execute(data, options) {
@@ -4521,7 +4731,7 @@ function get_port_radius(gobj, node_id, port_key)
4521
4731
  break;
4522
4732
  }
4523
4733
  }
4524
- return style.portR || 6;
4734
+ return style.portR || PORT_R_ENTITY;
4525
4735
  }
4526
4736
 
4527
4737
  /************************************************************
@@ -4560,7 +4770,7 @@ function detect_port_click(gobj, node_id, canvasX, canvasY)
4560
4770
  const style = nodeData.style || {};
4561
4771
  const {w, h} = extract_size(style);
4562
4772
  const ports = style.ports || [];
4563
- const defaultR = style.portR || 6;
4773
+ const defaultR = style.portR || PORT_R_ENTITY;
4564
4774
 
4565
4775
  let best_key = null;
4566
4776
  let best_dist = Infinity;
@@ -4812,7 +5022,7 @@ function enter_linking_mode(gobj)
4812
5022
  if(p.r != null) {
4813
5023
  p.r = p.r + 4;
4814
5024
  } else {
4815
- p.r = (style.portR || 6) + 4;
5025
+ p.r = (style.portR || PORT_R_ENTITY) + 4;
4816
5026
  }
4817
5027
  }
4818
5028
  }
@@ -5040,7 +5250,7 @@ function find_hook_at_point(gobj, canvasX, canvasY)
5040
5250
  let pl = p.placement || [0.5, 0.5];
5041
5251
  let px = pos[0] + (pl[0] - 0.5) * w;
5042
5252
  let py = pos[1] + (pl[1] - 0.5) * h;
5043
- let r = p.r != null ? p.r : (style.portR || 6);
5253
+ let r = p.r != null ? p.r : (style.portR || PORT_R_ENTITY);
5044
5254
  let dx = canvasX - px;
5045
5255
  let dy = canvasY - py;
5046
5256
  if(Math.sqrt(dx * dx + dy * dy) <= r + 6) {
@@ -6159,9 +6369,17 @@ function repaint_cards(gobj, ids)
6159
6369
  } catch(e) {
6160
6370
  continue; /* gone since it was listed */
6161
6371
  }
6372
+ let anchored = (priv.anchor_state === "on" && id === priv.anchor_id);
6373
+ /* A closed node paints its flags on its own stroke. */
6374
+ if(nd && nd.data && nd.data.desc && node_is_compact(gobj, id)) {
6375
+ updates.push({id: id, style: compact_shape_of(
6376
+ gobj, nd.data.desc, nd.data.record || {},
6377
+ {highlight: focus.has(id), selected: selected.has(id), anchored: anchored}
6378
+ ).style});
6379
+ continue;
6380
+ }
6162
6381
  let html = node_innerHTML_of(
6163
- nd, priv.theme, focus.has(id), selected.has(id),
6164
- priv.anchor_state === "on" && id === priv.anchor_id,
6382
+ nd, priv.theme, focus.has(id), selected.has(id), anchored,
6165
6383
  pills_html_of(gobj, nd)
6166
6384
  );
6167
6385
  if(html !== null) {
@@ -6219,9 +6437,16 @@ function refresh_html_nodes_theme(gobj, theme)
6219
6437
  for(let i = 0; i < nodes.length; i++) {
6220
6438
  let id = nodes[i].id;
6221
6439
  let nd = graph.getNodeData(id);
6440
+ let anchored = (priv.anchor_state === "on" && id === priv.anchor_id);
6441
+ if(nd && nd.data && nd.data.desc && node_is_compact(gobj, id)) {
6442
+ updates.push({id: id, style: compact_shape_of(
6443
+ gobj, nd.data.desc, nd.data.record || {},
6444
+ {highlight: highlighted.has(id), selected: selected.has(id), anchored: anchored}
6445
+ ).style});
6446
+ continue;
6447
+ }
6222
6448
  let html = node_innerHTML_of(
6223
- nd, theme, highlighted.has(id), selected.has(id),
6224
- priv.anchor_state === "on" && id === priv.anchor_id,
6449
+ nd, theme, highlighted.has(id), selected.has(id), anchored,
6225
6450
  pills_html_of(gobj, nd)
6226
6451
  );
6227
6452
  if(html === null) {
@@ -7170,8 +7395,16 @@ function build_node_context_menu(gobj, node_id)
7170
7395
  {
7171
7396
  let items = [];
7172
7397
 
7173
- if(gobj.priv.edit_mode) {
7174
- inject_svg_icons();
7398
+ inject_svg_icons();
7399
+ /* Open or close THIS node against the mode: the door a finger
7400
+ * has to it (a double click is the mouse's). */
7401
+ if(node_is_compact(gobj, node_id)) {
7402
+ items.push(ctx_item('g6-icon-fullscreen', t('open node'), 'toggle_node_mode'));
7403
+ } else {
7404
+ items.push(ctx_item('g6-icon-fullscreen-exit', t('close node'), 'toggle_node_mode'));
7405
+ }
7406
+
7407
+ if(gobj.priv.edit_mode && !node_is_compact(gobj, node_id)) {
7175
7408
  items.push(ctx_item('g6-icon-resize', t('resize all'), 'resize_all_nodes'));
7176
7409
  items.push(ctx_item('g6-icon-resize', t('resize topic nodes'), 'resize_topic_nodes'));
7177
7410
  }
@@ -7213,6 +7446,11 @@ function handle_context_menu_click(gobj, value)
7213
7446
  let priv = gobj.priv;
7214
7447
 
7215
7448
  switch(value) {
7449
+ case 'toggle_node_mode':
7450
+ if(priv._context_node_id) {
7451
+ gobj_send_event(gobj, "EV_TOGGLE_NODE_MODE", {id: priv._context_node_id}, gobj);
7452
+ }
7453
+ break;
7216
7454
  case 'resize_all_nodes':
7217
7455
  copy_size_to_nodes(gobj, false);
7218
7456
  break;
@@ -7971,8 +8209,9 @@ async function reconcile_fold_apply(gobj, opts, visible)
7971
8209
  let desc = nd && nd.data && nd.data.desc;
7972
8210
  let geometry = (nd && nd.data && nd.data.graph_props) || {};
7973
8211
  /* A saved size is the owner's: only a card on the default
7974
- * size grows and shrinks with its pills. */
7975
- if(desc && !is_array(geometry.size)) {
8212
+ * size grows and shrinks with its pills. A closed node has
8213
+ * no pills to grow for. */
8214
+ if(desc && !is_array(geometry.size) && !node_is_compact(gobj, id)) {
7976
8215
  let size = card_size_for(desc.node_treedb_type, !!sig);
7977
8216
  resize.push({id: id, style: {
7978
8217
  size: size, dx: -size[0] / 2, dy: -size[1] / 2,
@@ -8105,6 +8344,7 @@ function ac_clear_data(gobj, event, kw, src)
8105
8344
  priv._fold_model = null;
8106
8345
  priv._fold_state = null;
8107
8346
  priv._pill_sig = null;
8347
+ priv._open_nodes = null;
8108
8348
 
8109
8349
  graph_remove_plugin(gobj, "history");
8110
8350
  update_history_buttons(gobj);
@@ -8855,6 +9095,97 @@ function ac_set_loose_topics(gobj, event, kw, src)
8855
9095
  return 0;
8856
9096
  }
8857
9097
 
9098
+ /************************************************************
9099
+ * The shape of every record: open cards or closed squares.
9100
+ * A change of mode forgets the nodes toggled against the old
9101
+ * one -- they were exceptions to a rule that no longer holds --
9102
+ * and lays the graph out again, because a square and a card
9103
+ * do not occupy the same room.
9104
+ ************************************************************/
9105
+ function ac_set_node_mode(gobj, event, kw, src)
9106
+ {
9107
+ let priv = gobj.priv;
9108
+ let mode = (kw && kw.node_mode === "compact")? "compact" : "expanded";
9109
+
9110
+ gobj_write_str_attr(gobj, "node_mode", mode);
9111
+ priv._open_nodes = new Set();
9112
+ if(!priv.graph || !priv._fold_model) {
9113
+ return 0; /* applied when the records arrive */
9114
+ }
9115
+ reshape_nodes(gobj, all_record_node_ids(gobj));
9116
+ reconcile_fold(gobj, {relayout: true, fit: true});
9117
+ return 0;
9118
+ }
9119
+
9120
+ /************************************************************
9121
+ * The name under a closed square, on or off. Only the closed
9122
+ * nodes change, and nothing moves: the label hangs outside the
9123
+ * square and the layout measures the square.
9124
+ ************************************************************/
9125
+ function ac_set_node_labels(gobj, event, kw, src)
9126
+ {
9127
+ let priv = gobj.priv;
9128
+
9129
+ gobj_write_bool_attr(gobj, "node_labels", !!(kw && kw.node_labels));
9130
+ if(!priv.graph) {
9131
+ return 0;
9132
+ }
9133
+ let ids = all_record_node_ids(gobj).filter((id) => node_is_compact(gobj, id));
9134
+ reshape_nodes(gobj, ids);
9135
+ return 0;
9136
+ }
9137
+
9138
+ /************************************************************
9139
+ * ONE node opened or closed against the mode (a double click,
9140
+ * or the context menu). It holds still on screen while the
9141
+ * rest of the graph makes room, as a fold does.
9142
+ ************************************************************/
9143
+ function ac_toggle_node_mode(gobj, event, kw, src)
9144
+ {
9145
+ let priv = gobj.priv;
9146
+ let node_id = (kw && kw.id) || "";
9147
+
9148
+ if(!priv.graph || !node_id) {
9149
+ log_error(`${gobj_short_name(gobj)}: toggle node mode without a node`);
9150
+ return -1;
9151
+ }
9152
+ if(!priv._open_nodes) {
9153
+ priv._open_nodes = new Set();
9154
+ }
9155
+ if(priv._open_nodes.has(node_id)) {
9156
+ priv._open_nodes.delete(node_id);
9157
+ } else {
9158
+ priv._open_nodes.add(node_id);
9159
+ }
9160
+ let vp = yui_graph_viewport_of(priv.graph, node_id);
9161
+ reshape_nodes(gobj, [node_id]);
9162
+ if(priv._fold_model) {
9163
+ reconcile_fold(gobj, {relayout: true, keep: node_id, keep_vp: vp});
9164
+ }
9165
+ return 0;
9166
+ }
9167
+
9168
+ /************************************************************
9169
+ * A double click on a node: open it if closed, close it if open.
9170
+ * A `+N` chip is not a record and has no shape to toggle.
9171
+ ************************************************************/
9172
+ function ac_node_dblclick(gobj, event, kw, src)
9173
+ {
9174
+ let priv = gobj.priv;
9175
+ let node_id = kw && kw.evt && kw.evt.target? kw.evt.target.id : "";
9176
+ let nd = null;
9177
+ try {
9178
+ nd = priv.graph.getNodeData(node_id);
9179
+ } catch(e) {
9180
+ nd = null;
9181
+ }
9182
+ if(!nd || !nd.data || !nd.data.desc || is_more_node(nd)) {
9183
+ return 0;
9184
+ }
9185
+ gobj_send_event(gobj, "EV_TOGGLE_NODE_MODE", {id: node_id}, gobj);
9186
+ return 0;
9187
+ }
9188
+
8858
9189
  /************************************************************
8859
9190
  * Tell whoever asked how many nodes the term matched. The count is
8860
9191
  * the answer to the question the box asks; without it "nothing moved"
@@ -9419,6 +9750,10 @@ function create_gclass(gclass_name)
9419
9750
  ["EV_SET_HIDDEN_TOPICS", ac_set_hidden_topics, null],
9420
9751
  ["EV_SET_MAIN_TOPIC", ac_set_main_topic, null],
9421
9752
  ["EV_SET_LOOSE_TOPICS", ac_set_loose_topics, null],
9753
+ ["EV_SET_NODE_MODE", ac_set_node_mode, null],
9754
+ ["EV_SET_NODE_LABELS", ac_set_node_labels, null],
9755
+ ["EV_TOGGLE_NODE_MODE", ac_toggle_node_mode, null],
9756
+ ["EV_NODE_DBLCLICK", ac_node_dblclick, null],
9422
9757
  ["EV_CENTER", ac_center, null],
9423
9758
  ["EV_FULLSCREEN", ac_fullscreen, null],
9424
9759
  ["EV_SET_LAYOUT", ac_set_layout, null],
@@ -9479,6 +9814,10 @@ function create_gclass(gclass_name)
9479
9814
  ["EV_SET_HIDDEN_TOPICS", 0],
9480
9815
  ["EV_SET_MAIN_TOPIC", 0],
9481
9816
  ["EV_SET_LOOSE_TOPICS", 0],
9817
+ ["EV_SET_NODE_MODE", 0],
9818
+ ["EV_SET_NODE_LABELS", 0],
9819
+ ["EV_TOGGLE_NODE_MODE", 0],
9820
+ ["EV_NODE_DBLCLICK", 0],
9482
9821
  ["EV_LEGEND_STATE", event_flag_t.EVF_OUTPUT_EVENT],
9483
9822
  ["EV_CENTER", 0],
9484
9823
  ["EV_FULLSCREEN", 0],