@yuneta/gobj-ui 7.23.18 → 7.23.20

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.
@@ -50,6 +50,9 @@ import {yui_toolbar} from "./yui_toolbar.js";
50
50
  import {attach_clear} from "./yui_inputs.js";
51
51
  import {
52
52
  yui_graph_camera_items,
53
+ yui_graph_anchor_item,
54
+ yui_graph_update_anchor,
55
+ yui_graph_center_on,
53
56
  yui_graph_fold_items,
54
57
  yui_graph_refresh_item,
55
58
  yui_graph_update_zoom,
@@ -69,6 +72,11 @@ import {
69
72
  import { ensure_drag_canvas_patch } from "./g6_drag_canvas_touch.js";
70
73
  import { ensure_pinch_zoom_patch } from "./g6_touch_gestures.js";
71
74
  import { yui_is_dark, yui_theme_now, yui_watch_theme } from "./yui_theme.js";
75
+ import {
76
+ is_pure_collection,
77
+ has_branch,
78
+ count_branches,
79
+ } from "./json_view_helpers.js";
72
80
 
73
81
  /***************************************************************
74
82
  * Constants
@@ -190,6 +198,12 @@ let PRIVATE_DATA = {
190
198
  $find_input: null,
191
199
  $find_result: null,
192
200
  $find_count: null,
201
+
202
+ /*---------------- camera anchor ----------------*/
203
+ anchor_id: "", // the node the camera keeps in the middle
204
+ anchor_path: "", // its path, so a rebuild can find it again
205
+ anchor_state: "off", // "off" | "arming" | "on"
206
+ last_zoom: 1, // tells a wheel zoom from a drag in aftertransform
193
207
  };
194
208
 
195
209
  let __gclass__ = null;
@@ -592,6 +606,7 @@ function make_toolbar(gobj)
592
606
  * the same console and a copied toolbar drifts.
593
607
  */
594
608
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
609
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
595
610
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
596
611
 
597
612
  /*
@@ -697,6 +712,30 @@ function build_graph(gobj)
697
712
  * after every notch. */
698
713
  graph.on('aftertransform', () => {
699
714
  yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
715
+
716
+ /*
717
+ * A ZOOM re-centres on the anchor; a PAN does not.
718
+ * `aftertransform` fires for both, and an anchor that also
719
+ * answered a drag would make the graph impossible to move
720
+ * while it is set. The zoom LEVEL is what tells them apart:
721
+ * it changes on a wheel notch and never on a drag.
722
+ *
723
+ * No recursion: the centring translates without zooming, so
724
+ * the level it fires back with is the one just stored.
725
+ */
726
+ let z = priv.last_zoom;
727
+ try {
728
+ z = priv.graph.getZoom();
729
+ } catch(e) {
730
+ return;
731
+ }
732
+ if(z === priv.last_zoom) {
733
+ return;
734
+ }
735
+ priv.last_zoom = z;
736
+ if(priv.anchor_state === "on" && priv.anchor_id) {
737
+ yui_graph_center_on(priv.graph, priv.anchor_id);
738
+ }
700
739
  });
701
740
 
702
741
  graph.on(CanvasEvent.CLICK, (evt) => {
@@ -736,7 +775,7 @@ function build_graph(gobj)
736
775
  /************************************************************
737
776
  * Build cell value HTML (matching old mx_json_viewer colors)
738
777
  ************************************************************/
739
- function build_cell_html(key, value, type, dark, matched)
778
+ function build_cell_html(key, value, type, dark, matched, fold)
740
779
  {
741
780
  /*
742
781
  * A match is a LIGHT chip on BOTH themes, so what sits on it is
@@ -779,6 +818,19 @@ function build_cell_html(key, value, type, dark, matched)
779
818
  let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span>` +
780
819
  `<span style="color:${color}">${display_value}</span>`;
781
820
 
821
+ /*
822
+ * A fold chip ON THE ROW, for a container that gets no card of its
823
+ * own: the control belongs next to the key it folds, which is
824
+ * where a tree puts it, and it is the only handle those children
825
+ * have -- their parent's header chip folds every branch it has,
826
+ * not this one.
827
+ */
828
+ if(fold) {
829
+ row += `<span style="margin-left:8px;">` +
830
+ fold_toggle_html(fold.path, true, fold.folded, fold.branches, fold.colors) +
831
+ `</span>`;
832
+ }
833
+
782
834
  if(matched) {
783
835
  /* The highlight is baked into the card's MARKUP and not set as
784
836
  * a G6 node state: the key shape of an `html` node is a DOM
@@ -951,6 +1003,10 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
951
1003
  let pending_complex = [];
952
1004
  let card_matched = false;
953
1005
 
1006
+ let colors = json_card_style(
1007
+ is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
1008
+ );
1009
+
954
1010
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
955
1011
 
956
1012
  for(let [key, value] of entries) {
@@ -960,13 +1016,62 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
960
1016
  card_matched = true;
961
1017
  priv.match_count++;
962
1018
  }
963
- let html = build_cell_html(key, value, type, dark, matched);
964
- lines.push(html);
965
1019
 
966
- if((is_object(value) && json_object_size(value) > 0) ||
967
- (is_array(value) && value.length > 0)) {
1020
+ /*
1021
+ * EVERY key is a row, containers included: `cols` is one key of
1022
+ * the topic dict exactly like `pkey` and `tkey`, and a drawing
1023
+ * that leaves it out of the card does not say what the document
1024
+ * says.
1025
+ *
1026
+ * What a container key does NOT get is its contents repeated
1027
+ * underneath it -- the row says `[14]` and the fourteen cards
1028
+ * say the rest. That is the part which does not scale: an array
1029
+ * of a thousand dicts would otherwise print a thousand-row card
1030
+ * beside the thousand cards it duplicates.
1031
+ */
1032
+ let fold = null;
1033
+ if(has_branch(value)) {
968
1034
  pending_complex.push({key: key, value: value});
1035
+
1036
+ /*
1037
+ * A child that gets no card of its own is folded from HERE
1038
+ * or from nowhere.
1039
+ */
1040
+ if(is_pure_collection(value)) {
1041
+ let child_path = add_segment(path, key);
1042
+ fold = {
1043
+ path: child_path,
1044
+ folded: priv.collapsed.has(child_path),
1045
+ branches: count_branches(value),
1046
+ colors: colors,
1047
+ };
1048
+ }
1049
+ }
1050
+
1051
+ lines.push(build_cell_html(key, value, type, dark, matched, fold));
1052
+ }
1053
+
1054
+ /*
1055
+ * A pure collection is not drawn: its row above names it and its
1056
+ * children hang from THIS card's parent. The root always gets a
1057
+ * card, or a document that is a plain list of records would have
1058
+ * nothing to hang anything from.
1059
+ */
1060
+ if(parent_id && is_pure_collection(kw)) {
1061
+ if(priv.collapsed.has(path)) {
1062
+ return parent_id;
1063
+ }
1064
+ for(let pending of pending_complex) {
1065
+ build_json_nodes(
1066
+ gobj,
1067
+ add_segment(path, pending.key),
1068
+ pending.value,
1069
+ nodes,
1070
+ edges,
1071
+ parent_id
1072
+ );
969
1073
  }
1074
+ return parent_id;
970
1075
  }
971
1076
 
972
1077
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
@@ -981,9 +1086,6 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
981
1086
  */
982
1087
  let foldable = pending_complex.length > 0;
983
1088
  let folded = foldable && priv.collapsed.has(path);
984
- let colors = json_card_style(
985
- is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark
986
- );
987
1089
  let border_color = card_matched? (dark? "#ffb300": "#ff8f00"): colors.border;
988
1090
  let border_width = card_matched? 2: 1;
989
1091
 
@@ -1103,20 +1205,47 @@ function load_json(gobj)
1103
1205
  priv.pending_preserve_view = false;
1104
1206
 
1105
1207
  if(nodes.length > 0) {
1208
+ let root_id = nodes[0].id; /* built before its children */
1106
1209
  graph.setData({nodes: nodes, edges: edges});
1107
1210
  graph.render().then(() => {
1108
- if(!preserve_view) {
1109
- graph.fitView();
1211
+ /* Ids are generated per build: point the anchor at its path
1212
+ * again before anybody asks the camera to go there. */
1213
+ reanchor(gobj);
1214
+
1215
+ if(preserve_view) {
1216
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1217
+ return;
1110
1218
  }
1111
- yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1219
+
1220
+ /*
1221
+ * ACTUAL SIZE, not fit. A JSON graph of anything real does
1222
+ * not fit at a zoom anybody can read: the schema of one
1223
+ * topic fits at 37%, where every card is grey texture.
1224
+ * Fitting answered "where is everything" when the question
1225
+ * on opening is "what does this say".
1226
+ *
1227
+ * Centred on the ANCHOR if there is one, else on the ROOT
1228
+ * -- never parked at the layout's origin, which is a corner
1229
+ * with nothing in it and is where `1:1` used to land.
1230
+ */
1231
+ Promise.resolve(graph.zoomTo(1)).then(() => {
1232
+ if(!yui_graph_center_on(graph, priv.anchor_id)) {
1233
+ yui_graph_center_on(graph, root_id);
1234
+ }
1235
+ priv.last_zoom = 1;
1236
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1237
+ });
1112
1238
  });
1113
1239
  }
1114
1240
  }
1115
1241
 
1116
1242
  /************************************************************
1117
1243
  * Every path that HAS a branch to fold, in the same recursion
1118
- * build_json_nodes uses — so "collapse all" can never name a
1119
- * card that does not exist. The root is excluded: folding it
1244
+ * build_json_nodes uses — so "collapse all" can never name
1245
+ * something with no handle. The handle is a card's header chip
1246
+ * for a container that gets a card, and the chip on its ROW in
1247
+ * the parent for a pure collection, which gets none; both are
1248
+ * keyed by this same path. The root is excluded: folding it
1120
1249
  * would leave an empty canvas.
1121
1250
  ************************************************************/
1122
1251
  function collect_foldable_paths(value, path, out, is_root)
@@ -1127,20 +1256,21 @@ function collect_foldable_paths(value, path, out, is_root)
1127
1256
  return false;
1128
1257
  }
1129
1258
 
1130
- let has_branch = false;
1259
+ /* `found_branch` and not `has_branch`: that name belongs to the
1260
+ * helper above, and a local would shadow it here. */
1261
+ let found_branch = false;
1131
1262
  let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
1132
1263
  for(let [key, child] of entries) {
1133
- if((is_object(child) && json_object_size(child) > 0) ||
1134
- (is_array(child) && child.length > 0)) {
1135
- has_branch = true;
1264
+ if(has_branch(child)) {
1265
+ found_branch = true;
1136
1266
  collect_foldable_paths(child, add_segment(path, key), out, false);
1137
1267
  }
1138
1268
  }
1139
1269
 
1140
- if(has_branch && !is_root) {
1270
+ if(found_branch && !is_root) {
1141
1271
  out.push(path);
1142
1272
  }
1143
- return has_branch;
1273
+ return found_branch;
1144
1274
  }
1145
1275
 
1146
1276
  /************************************************************
@@ -1490,6 +1620,93 @@ function ac_theme(gobj, event, kw, src)
1490
1620
  return 0;
1491
1621
  }
1492
1622
 
1623
+ /************************************************************
1624
+ * Every camera action ends the same way once an anchor is
1625
+ * set: the thing you were reading goes back to the middle.
1626
+ *
1627
+ * Chained on the promise G6 returns rather than run beside it
1628
+ * -- centring before the zoom has finished centres the OLD
1629
+ * view, and the reader watches the node slide away and come
1630
+ * back.
1631
+ ************************************************************/
1632
+ function after_camera(gobj, moved)
1633
+ {
1634
+ let priv = gobj.priv;
1635
+
1636
+ if(priv.anchor_state !== "on" || !priv.anchor_id) {
1637
+ return;
1638
+ }
1639
+ Promise.resolve(moved).then(function() {
1640
+ yui_graph_center_on(priv.graph, priv.anchor_id);
1641
+ }).catch(function() {
1642
+ /* a camera move that never finished has nothing to centre */
1643
+ });
1644
+ }
1645
+
1646
+ /************************************************************
1647
+ * Point the anchor back at its node after a rebuild.
1648
+ *
1649
+ * Node ids are generated per build, so the id the reader
1650
+ * chose does not survive a refresh, a fold or a layout
1651
+ * change -- the PATH does, and it is what the anchor really
1652
+ * means: "this place in the document", not "this card".
1653
+ *
1654
+ * A path that is no longer drawn (its parent got folded)
1655
+ * releases the anchor rather than leaving a button that says
1656
+ * it is locked onto something invisible.
1657
+ ************************************************************/
1658
+ function reanchor(gobj)
1659
+ {
1660
+ let priv = gobj.priv;
1661
+
1662
+ if(priv.anchor_state !== "on" || !priv.anchor_path) {
1663
+ return;
1664
+ }
1665
+
1666
+ let found = "";
1667
+ try {
1668
+ for(let node of priv.graph.getNodeData()) {
1669
+ if(node && node.data && node.data.path === priv.anchor_path) {
1670
+ found = node.id;
1671
+ break;
1672
+ }
1673
+ }
1674
+ } catch(e) {
1675
+ return; /* between renders: keep what we have and try later */
1676
+ }
1677
+
1678
+ priv.anchor_id = found;
1679
+ if(!found) {
1680
+ priv.anchor_state = "off";
1681
+ priv.anchor_path = "";
1682
+ }
1683
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1684
+ }
1685
+
1686
+ /************************************************************
1687
+ * Arm the anchor, or let it go.
1688
+ *
1689
+ * Three states and one button, so a press ADVANCES: with no
1690
+ * target it starts waiting for one, and with a target it
1691
+ * releases. Pressing it while it waits is a cancel, which is
1692
+ * what somebody who pressed it by mistake will try.
1693
+ ************************************************************/
1694
+ function ac_toggle_anchor(gobj, event, kw, src)
1695
+ {
1696
+ let priv = gobj.priv;
1697
+
1698
+ if(priv.anchor_state === "off") {
1699
+ priv.anchor_state = "arming";
1700
+ } else {
1701
+ priv.anchor_state = "off";
1702
+ priv.anchor_id = "";
1703
+ priv.anchor_path = "";
1704
+ }
1705
+
1706
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1707
+ return 0;
1708
+ }
1709
+
1493
1710
  /************************************************************
1494
1711
  *
1495
1712
  ************************************************************/
@@ -1498,7 +1715,7 @@ function ac_zoom_in(gobj, event, kw, src)
1498
1715
  let graph = gobj.priv.graph;
1499
1716
  if(graph) {
1500
1717
  let z = graph.getZoom();
1501
- graph.zoomTo(z * 1.2);
1718
+ after_camera(gobj, graph.zoomTo(z * 1.2));
1502
1719
  }
1503
1720
  return 0;
1504
1721
  }
@@ -1511,7 +1728,7 @@ function ac_zoom_out(gobj, event, kw, src)
1511
1728
  let graph = gobj.priv.graph;
1512
1729
  if(graph) {
1513
1730
  let z = graph.getZoom();
1514
- graph.zoomTo(z * 0.8);
1731
+ after_camera(gobj, graph.zoomTo(z * 0.8));
1515
1732
  }
1516
1733
  return 0;
1517
1734
  }
@@ -1521,10 +1738,21 @@ function ac_zoom_out(gobj, event, kw, src)
1521
1738
  ************************************************************/
1522
1739
  function ac_zoom_reset(gobj, event, kw, src)
1523
1740
  {
1524
- let graph = gobj.priv.graph;
1741
+ let priv = gobj.priv;
1742
+ let graph = priv.graph;
1743
+
1525
1744
  if(graph && graph.rendered) {
1526
- graph.zoomTo(1);
1527
- graph.translateTo([0, 0]);
1745
+ /*
1746
+ * With an anchor, actual size means "this node, life size".
1747
+ * Without one it means the layout's origin, which is where
1748
+ * `1:1` always landed -- a corner nobody was looking at.
1749
+ */
1750
+ if(priv.anchor_state === "on" && priv.anchor_id) {
1751
+ after_camera(gobj, graph.zoomTo(1));
1752
+ } else {
1753
+ graph.zoomTo(1);
1754
+ graph.translateTo([0, 0]);
1755
+ }
1528
1756
  }
1529
1757
  return 0;
1530
1758
  }
@@ -1556,6 +1784,21 @@ function ac_node_click(gobj, event, kw, src)
1556
1784
  nodedata = graph.getNodeData(node_id);
1557
1785
  } catch(e) {}
1558
1786
 
1787
+ /*
1788
+ * A waiting anchor TAKES the click: it is what the reader armed
1789
+ * the button for, and letting the same press also open the item
1790
+ * would make picking a target a side effect of doing something
1791
+ * else.
1792
+ */
1793
+ if(priv.anchor_state === "arming") {
1794
+ priv.anchor_id = node_id;
1795
+ priv.anchor_path = (nodedata && nodedata.data)? nodedata.data.path: "";
1796
+ priv.anchor_state = "on";
1797
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1798
+ yui_graph_center_on(graph, node_id);
1799
+ return 0;
1800
+ }
1801
+
1559
1802
  if(nodedata && nodedata.data) {
1560
1803
  gobj_publish_event(gobj, "EV_JSON_ITEM_CLICKED", {
1561
1804
  path: nodedata.data.path,
@@ -1677,6 +1920,7 @@ function create_gclass(gclass_name)
1677
1920
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
1678
1921
  ["EV_CENTER", ac_center, null],
1679
1922
  ["EV_NODE_CLICK", ac_node_click, null],
1923
+ ["EV_TOGGLE_ANCHOR", ac_toggle_anchor, null],
1680
1924
  ["EV_RESIZE", ac_resize, null],
1681
1925
  ["EV_SHOW", ac_show, null],
1682
1926
  ["EV_HIDE", ac_hide, null],
@@ -1700,6 +1944,7 @@ function create_gclass(gclass_name)
1700
1944
  ["EV_ZOOM_RESET", 0],
1701
1945
  ["EV_CENTER", 0],
1702
1946
  ["EV_NODE_CLICK", 0],
1947
+ ["EV_TOGGLE_ANCHOR", 0],
1703
1948
  ["EV_RESIZE", 0],
1704
1949
  ["EV_SHOW", 0],
1705
1950
  ["EV_HIDE", 0],
@@ -314,3 +314,70 @@ export function container_label(value, max_chars)
314
314
  }
315
315
  return text;
316
316
  }
317
+
318
+
319
+ /************************************************************
320
+ * A dict, in the JSON sense: not an array and not null.
321
+ ************************************************************/
322
+ function is_plain_object(v)
323
+ {
324
+ return v !== null && typeof v === "object" && !Array.isArray(v);
325
+ }
326
+
327
+ /************************************************************
328
+ * Does this container hold ONLY other containers?
329
+ *
330
+ * Such a value is not a thing, it is a LIST of things -- and a
331
+ * list is fully described by the row that names it in its
332
+ * parent, plus the edges to what it holds. Drawing it as a
333
+ * card of its own put a node in the middle of the graph with no
334
+ * data in it, saying nothing the parent's row did not already
335
+ * say, and pushed everything below it one level down.
336
+ *
337
+ * An EMPTY container inside counts as content: `{0}` is
338
+ * something the card has to show, and there is no child card
339
+ * to show it instead.
340
+ ************************************************************/
341
+ export function is_pure_collection(value)
342
+ {
343
+ let entries = is_plain_object(value)? Object.entries(value)
344
+ : Array.isArray(value)? value.map((v, i) => [i, v])
345
+ : [];
346
+
347
+ if(entries.length === 0) {
348
+ return false;
349
+ }
350
+ for(let [, v] of entries) {
351
+ if(!has_branch(v)) {
352
+ return false;
353
+ }
354
+ }
355
+ return true;
356
+ }
357
+
358
+ /************************************************************
359
+ * A value that gets a card, or children, of its own.
360
+ ************************************************************/
361
+ export function has_branch(value)
362
+ {
363
+ return (is_plain_object(value) && Object.keys(value).length > 0) ||
364
+ (Array.isArray(value) && value.length > 0);
365
+ }
366
+
367
+ /************************************************************
368
+ * How many branches a container has, which is what the fold
369
+ * chip counts.
370
+ ************************************************************/
371
+ export function count_branches(value)
372
+ {
373
+ let entries = is_plain_object(value)? Object.values(value)
374
+ : Array.isArray(value)? value
375
+ : [];
376
+ let n = 0;
377
+ for(let v of entries) {
378
+ if(has_branch(v)) {
379
+ n++;
380
+ }
381
+ }
382
+ return n;
383
+ }
@@ -18,6 +18,9 @@ import {
18
18
  json_text_dump,
19
19
  pick_view_mode,
20
20
  container_label,
21
+ is_pure_collection,
22
+ has_branch,
23
+ count_branches,
21
24
  } from "./json_view_helpers.js";
22
25
 
23
26
 
@@ -243,3 +246,67 @@ test("container_label: cut at max_chars, and said with an ellipsis", () => {
243
246
  expect(container_label({id: uuid}, 0)).toBe(uuid);
244
247
  expect(container_label({id: uuid}, undefined)).toBe(uuid);
245
248
  });
249
+
250
+ /***************************************************************
251
+ * has_branch() / count_branches() / is_pure_collection()
252
+ ***************************************************************/
253
+ test("has_branch: only a NON-EMPTY container branches", () => {
254
+ expect(has_branch({a: 1})).toBe(true);
255
+ expect(has_branch([1])).toBe(true);
256
+ expect(has_branch({})).toBe(false);
257
+ expect(has_branch([])).toBe(false);
258
+ expect(has_branch("x")).toBe(false);
259
+ expect(has_branch(0)).toBe(false);
260
+ expect(has_branch(null)).toBe(false);
261
+ });
262
+
263
+ test("count_branches: counts what gets a card, not what is there", () => {
264
+ expect(count_branches({a: 1, b: {}, c: {x: 1}, d: [1]})).toBe(2);
265
+ expect(count_branches([{}, [], {a: 1}])).toBe(1);
266
+ expect(count_branches({a: 1})).toBe(0);
267
+ expect(count_branches("x")).toBe(0);
268
+ });
269
+
270
+ test("is_pure_collection: a LIST of things, not a thing", () => {
271
+ /* Every entry a container with something in it. */
272
+ expect(is_pure_collection([{a: 1}, {b: 2}])).toBe(true);
273
+ expect(is_pure_collection({x: {a: 1}, y: [1]})).toBe(true);
274
+
275
+ /* One scalar is enough to make it a thing with content of its own,
276
+ * and so is an EMPTY container: `{0}` has no child card to show it. */
277
+ expect(is_pure_collection([{a: 1}, 2])).toBe(false);
278
+ expect(is_pure_collection({x: {a: 1}, n: 3})).toBe(false);
279
+ expect(is_pure_collection([{a: 1}, {}])).toBe(false);
280
+
281
+ expect(is_pure_collection([])).toBe(false);
282
+ expect(is_pure_collection({})).toBe(false);
283
+ expect(is_pure_collection("x")).toBe(false);
284
+ });
285
+
286
+ test("is_pure_collection: the schema this came from", () => {
287
+ /* The `devices` topic descriptor, cut to shape: `cols` is one key of
288
+ * the dict like `pkey` is, and the only one that branches. */
289
+ let col = {id: "id", header: "Device", type: "string", flag: [], enum: {}};
290
+ let topic = {
291
+ topic_name: "devices",
292
+ pkey: "id",
293
+ pkey2s: [], /* empty: a row, not a branch */
294
+ tkey: "tm",
295
+ system_flag: 1,
296
+ topic_version: 3,
297
+ cols: [col, col, col],
298
+ };
299
+
300
+ /* The topic is NOT a pure collection: it has six scalar rows, so it
301
+ * keeps its card. */
302
+ expect(is_pure_collection(topic)).toBe(false);
303
+ expect(count_branches(topic)).toBe(1);
304
+
305
+ /* `cols` IS one: nothing but columns. It gets no card of its own --
306
+ * its row in the topic names it, and the columns hang from the topic. */
307
+ expect(is_pure_collection(topic.cols)).toBe(true);
308
+ expect(count_branches(topic.cols)).toBe(3);
309
+
310
+ /* A column has content, so it keeps its card. */
311
+ expect(is_pure_collection(col)).toBe(false);
312
+ });
package/src/lib_icons.js CHANGED
@@ -140,6 +140,13 @@ const ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
140
140
  stroke="currentColor" stroke-width="40"
141
141
  stroke-dasharray="59 36"/>
142
142
  </symbol>
143
+ <!-- location-crosshairs (anchor the camera on one element).
144
+ The SAME path as the DOM toolbars and their yi-location-crosshairs
145
+ mask: the two toolbars sit in one console, and a control that
146
+ does one thing has to be drawn one way. -->
147
+ <symbol id="g6-icon-anchor" viewBox="0 0 576 512">
148
+ <path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"/>
149
+ </symbol>
143
150
  <!-- floppy-disk (save) -->
144
151
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
145
152
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>