@yuneta/gobj-ui 7.23.18 → 7.23.19

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,
@@ -190,6 +193,12 @@ let PRIVATE_DATA = {
190
193
  $find_input: null,
191
194
  $find_result: null,
192
195
  $find_count: null,
196
+
197
+ /*---------------- camera anchor ----------------*/
198
+ anchor_id: "", // the node the camera keeps in the middle
199
+ anchor_path: "", // its path, so a rebuild can find it again
200
+ anchor_state: "off", // "off" | "arming" | "on"
201
+ last_zoom: 1, // tells a wheel zoom from a drag in aftertransform
193
202
  };
194
203
 
195
204
  let __gclass__ = null;
@@ -592,6 +601,7 @@ function make_toolbar(gobj)
592
601
  * the same console and a copied toolbar drifts.
593
602
  */
594
603
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
604
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
595
605
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
596
606
 
597
607
  /*
@@ -697,6 +707,30 @@ function build_graph(gobj)
697
707
  * after every notch. */
698
708
  graph.on('aftertransform', () => {
699
709
  yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
710
+
711
+ /*
712
+ * A ZOOM re-centres on the anchor; a PAN does not.
713
+ * `aftertransform` fires for both, and an anchor that also
714
+ * answered a drag would make the graph impossible to move
715
+ * while it is set. The zoom LEVEL is what tells them apart:
716
+ * it changes on a wheel notch and never on a drag.
717
+ *
718
+ * No recursion: the centring translates without zooming, so
719
+ * the level it fires back with is the one just stored.
720
+ */
721
+ let z = priv.last_zoom;
722
+ try {
723
+ z = priv.graph.getZoom();
724
+ } catch(e) {
725
+ return;
726
+ }
727
+ if(z === priv.last_zoom) {
728
+ return;
729
+ }
730
+ priv.last_zoom = z;
731
+ if(priv.anchor_state === "on" && priv.anchor_id) {
732
+ yui_graph_center_on(priv.graph, priv.anchor_id);
733
+ }
700
734
  });
701
735
 
702
736
  graph.on(CanvasEvent.CLICK, (evt) => {
@@ -960,16 +994,33 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id)
960
994
  card_matched = true;
961
995
  priv.match_count++;
962
996
  }
963
- let html = build_cell_html(key, value, type, dark, matched);
964
- lines.push(html);
965
997
 
998
+ /*
999
+ * A non-empty container gets a CARD OF ITS OWN, so a row for it
1000
+ * here would be the same thing drawn twice -- and the second
1001
+ * drawing is the one that does not scale. An array of 14 dicts
1002
+ * printed fourteen `0: {13}` rows above fourteen cards saying
1003
+ * the same; an array of a thousand prints a thousand-row card
1004
+ * nobody can read, beside the thousand cards it duplicates.
1005
+ *
1006
+ * What is left in the body is exactly the SCALARS: what this
1007
+ * node IS. What it CONTAINS is the edges, and the count now
1008
+ * rides in the header for the case where the body comes out
1009
+ * empty.
1010
+ */
966
1011
  if((is_object(value) && json_object_size(value) > 0) ||
967
1012
  (is_array(value) && value.length > 0)) {
968
1013
  pending_complex.push({key: key, value: value});
1014
+ continue;
969
1015
  }
1016
+
1017
+ lines.push(build_cell_html(key, value, type, dark, matched));
970
1018
  }
971
1019
 
972
1020
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
1021
+ if(pending_complex.length > 0) {
1022
+ label += is_dict? ` {${pending_complex.length}}`: ` [${pending_complex.length}]`;
1023
+ }
973
1024
 
974
1025
  /*
975
1026
  * Folded: keep the card, drop the branch, and SAY how much went
@@ -1103,12 +1154,36 @@ function load_json(gobj)
1103
1154
  priv.pending_preserve_view = false;
1104
1155
 
1105
1156
  if(nodes.length > 0) {
1157
+ let root_id = nodes[0].id; /* built before its children */
1106
1158
  graph.setData({nodes: nodes, edges: edges});
1107
1159
  graph.render().then(() => {
1108
- if(!preserve_view) {
1109
- graph.fitView();
1160
+ /* Ids are generated per build: point the anchor at its path
1161
+ * again before anybody asks the camera to go there. */
1162
+ reanchor(gobj);
1163
+
1164
+ if(preserve_view) {
1165
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1166
+ return;
1110
1167
  }
1111
- yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1168
+
1169
+ /*
1170
+ * ACTUAL SIZE, not fit. A JSON graph of anything real does
1171
+ * not fit at a zoom anybody can read: the schema of one
1172
+ * topic fits at 37%, where every card is grey texture.
1173
+ * Fitting answered "where is everything" when the question
1174
+ * on opening is "what does this say".
1175
+ *
1176
+ * Centred on the ANCHOR if there is one, else on the ROOT
1177
+ * -- never parked at the layout's origin, which is a corner
1178
+ * with nothing in it and is where `1:1` used to land.
1179
+ */
1180
+ Promise.resolve(graph.zoomTo(1)).then(() => {
1181
+ if(!yui_graph_center_on(graph, priv.anchor_id)) {
1182
+ yui_graph_center_on(graph, root_id);
1183
+ }
1184
+ priv.last_zoom = 1;
1185
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
1186
+ });
1112
1187
  });
1113
1188
  }
1114
1189
  }
@@ -1490,6 +1565,93 @@ function ac_theme(gobj, event, kw, src)
1490
1565
  return 0;
1491
1566
  }
1492
1567
 
1568
+ /************************************************************
1569
+ * Every camera action ends the same way once an anchor is
1570
+ * set: the thing you were reading goes back to the middle.
1571
+ *
1572
+ * Chained on the promise G6 returns rather than run beside it
1573
+ * -- centring before the zoom has finished centres the OLD
1574
+ * view, and the reader watches the node slide away and come
1575
+ * back.
1576
+ ************************************************************/
1577
+ function after_camera(gobj, moved)
1578
+ {
1579
+ let priv = gobj.priv;
1580
+
1581
+ if(priv.anchor_state !== "on" || !priv.anchor_id) {
1582
+ return;
1583
+ }
1584
+ Promise.resolve(moved).then(function() {
1585
+ yui_graph_center_on(priv.graph, priv.anchor_id);
1586
+ }).catch(function() {
1587
+ /* a camera move that never finished has nothing to centre */
1588
+ });
1589
+ }
1590
+
1591
+ /************************************************************
1592
+ * Point the anchor back at its node after a rebuild.
1593
+ *
1594
+ * Node ids are generated per build, so the id the reader
1595
+ * chose does not survive a refresh, a fold or a layout
1596
+ * change -- the PATH does, and it is what the anchor really
1597
+ * means: "this place in the document", not "this card".
1598
+ *
1599
+ * A path that is no longer drawn (its parent got folded)
1600
+ * releases the anchor rather than leaving a button that says
1601
+ * it is locked onto something invisible.
1602
+ ************************************************************/
1603
+ function reanchor(gobj)
1604
+ {
1605
+ let priv = gobj.priv;
1606
+
1607
+ if(priv.anchor_state !== "on" || !priv.anchor_path) {
1608
+ return;
1609
+ }
1610
+
1611
+ let found = "";
1612
+ try {
1613
+ for(let node of priv.graph.getNodeData()) {
1614
+ if(node && node.data && node.data.path === priv.anchor_path) {
1615
+ found = node.id;
1616
+ break;
1617
+ }
1618
+ }
1619
+ } catch(e) {
1620
+ return; /* between renders: keep what we have and try later */
1621
+ }
1622
+
1623
+ priv.anchor_id = found;
1624
+ if(!found) {
1625
+ priv.anchor_state = "off";
1626
+ priv.anchor_path = "";
1627
+ }
1628
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1629
+ }
1630
+
1631
+ /************************************************************
1632
+ * Arm the anchor, or let it go.
1633
+ *
1634
+ * Three states and one button, so a press ADVANCES: with no
1635
+ * target it starts waiting for one, and with a target it
1636
+ * releases. Pressing it while it waits is a cancel, which is
1637
+ * what somebody who pressed it by mistake will try.
1638
+ ************************************************************/
1639
+ function ac_toggle_anchor(gobj, event, kw, src)
1640
+ {
1641
+ let priv = gobj.priv;
1642
+
1643
+ if(priv.anchor_state === "off") {
1644
+ priv.anchor_state = "arming";
1645
+ } else {
1646
+ priv.anchor_state = "off";
1647
+ priv.anchor_id = "";
1648
+ priv.anchor_path = "";
1649
+ }
1650
+
1651
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1652
+ return 0;
1653
+ }
1654
+
1493
1655
  /************************************************************
1494
1656
  *
1495
1657
  ************************************************************/
@@ -1498,7 +1660,7 @@ function ac_zoom_in(gobj, event, kw, src)
1498
1660
  let graph = gobj.priv.graph;
1499
1661
  if(graph) {
1500
1662
  let z = graph.getZoom();
1501
- graph.zoomTo(z * 1.2);
1663
+ after_camera(gobj, graph.zoomTo(z * 1.2));
1502
1664
  }
1503
1665
  return 0;
1504
1666
  }
@@ -1511,7 +1673,7 @@ function ac_zoom_out(gobj, event, kw, src)
1511
1673
  let graph = gobj.priv.graph;
1512
1674
  if(graph) {
1513
1675
  let z = graph.getZoom();
1514
- graph.zoomTo(z * 0.8);
1676
+ after_camera(gobj, graph.zoomTo(z * 0.8));
1515
1677
  }
1516
1678
  return 0;
1517
1679
  }
@@ -1521,10 +1683,21 @@ function ac_zoom_out(gobj, event, kw, src)
1521
1683
  ************************************************************/
1522
1684
  function ac_zoom_reset(gobj, event, kw, src)
1523
1685
  {
1524
- let graph = gobj.priv.graph;
1686
+ let priv = gobj.priv;
1687
+ let graph = priv.graph;
1688
+
1525
1689
  if(graph && graph.rendered) {
1526
- graph.zoomTo(1);
1527
- graph.translateTo([0, 0]);
1690
+ /*
1691
+ * With an anchor, actual size means "this node, life size".
1692
+ * Without one it means the layout's origin, which is where
1693
+ * `1:1` always landed -- a corner nobody was looking at.
1694
+ */
1695
+ if(priv.anchor_state === "on" && priv.anchor_id) {
1696
+ after_camera(gobj, graph.zoomTo(1));
1697
+ } else {
1698
+ graph.zoomTo(1);
1699
+ graph.translateTo([0, 0]);
1700
+ }
1528
1701
  }
1529
1702
  return 0;
1530
1703
  }
@@ -1556,6 +1729,21 @@ function ac_node_click(gobj, event, kw, src)
1556
1729
  nodedata = graph.getNodeData(node_id);
1557
1730
  } catch(e) {}
1558
1731
 
1732
+ /*
1733
+ * A waiting anchor TAKES the click: it is what the reader armed
1734
+ * the button for, and letting the same press also open the item
1735
+ * would make picking a target a side effect of doing something
1736
+ * else.
1737
+ */
1738
+ if(priv.anchor_state === "arming") {
1739
+ priv.anchor_id = node_id;
1740
+ priv.anchor_path = (nodedata && nodedata.data)? nodedata.data.path: "";
1741
+ priv.anchor_state = "on";
1742
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
1743
+ yui_graph_center_on(graph, node_id);
1744
+ return 0;
1745
+ }
1746
+
1559
1747
  if(nodedata && nodedata.data) {
1560
1748
  gobj_publish_event(gobj, "EV_JSON_ITEM_CLICKED", {
1561
1749
  path: nodedata.data.path,
@@ -1677,6 +1865,7 @@ function create_gclass(gclass_name)
1677
1865
  ["EV_ZOOM_RESET", ac_zoom_reset, null],
1678
1866
  ["EV_CENTER", ac_center, null],
1679
1867
  ["EV_NODE_CLICK", ac_node_click, null],
1868
+ ["EV_TOGGLE_ANCHOR", ac_toggle_anchor, null],
1680
1869
  ["EV_RESIZE", ac_resize, null],
1681
1870
  ["EV_SHOW", ac_show, null],
1682
1871
  ["EV_HIDE", ac_hide, null],
@@ -1700,6 +1889,7 @@ function create_gclass(gclass_name)
1700
1889
  ["EV_ZOOM_RESET", 0],
1701
1890
  ["EV_CENTER", 0],
1702
1891
  ["EV_NODE_CLICK", 0],
1892
+ ["EV_TOGGLE_ANCHOR", 0],
1703
1893
  ["EV_RESIZE", 0],
1704
1894
  ["EV_SHOW", 0],
1705
1895
  ["EV_HIDE", 0],
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"/>
@@ -39,6 +39,8 @@ import {createElement2, gobj_send_event} from "@yuneta/gobj-js";
39
39
 
40
40
  import {t} from "i18next";
41
41
 
42
+ import {set_pressed_state} from "./lib_graph.js";
43
+
42
44
 
43
45
  /************************************************************
44
46
  * One icon button that fires `event_name`.
@@ -141,6 +143,99 @@ export function yui_graph_fold_items(gobj, wide)
141
143
  ];
142
144
  }
143
145
 
146
+ /************************************************************
147
+ * The ANCHOR toggle: pick one element and every camera move
148
+ * from then on leaves it in the MIDDLE.
149
+ *
150
+ * Why it exists: a graph that fits on screen is unreadable at
151
+ * the zoom that makes it fit, and the zoom that makes it
152
+ * readable does not fit -- so the useful view is always a
153
+ * fraction of the document, and which fraction is the whole
154
+ * question. Without an anchor, `1:1` lands wherever the
155
+ * layout's origin happens to be and the reader hunts for the
156
+ * node they were looking at.
157
+ *
158
+ * Crosshairs and not a thumbtack. A tack says "do not move
159
+ * this", which is what a pinned NODE would mean in a graph you
160
+ * can drag nodes around in; this pins the CAMERA to a target,
161
+ * which is what a crosshair says and what every map that
162
+ * offers it draws. It is also in the icon set already, and a
163
+ * `yi-*` class that is not is a black square.
164
+ *
165
+ * Three states, because two would not say what a press does:
166
+ *
167
+ * off no target; the camera behaves as it always did
168
+ * arming waiting for you to click the element you mean
169
+ * on locked; every zoom re-centres on it
170
+ ************************************************************/
171
+ export function yui_graph_anchor_item(gobj, wide)
172
+ {
173
+ return camera_button(gobj, "yi-location-crosshairs", "EV_TOGGLE_ANCHOR",
174
+ "anchor view", wide);
175
+ }
176
+
177
+ /************************************************************
178
+ * Paint the anchor button. `state` is "off" | "arming" | "on".
179
+ *
180
+ * Both live states look PRESSED, because both are states the
181
+ * control is IN rather than actions; what separates them is the
182
+ * colour, and only the waiting one takes a palette colour --
183
+ * it is the one that needs the reader to do something next.
184
+ ************************************************************/
185
+ export function yui_graph_update_anchor($container, state)
186
+ {
187
+ if(!$container) {
188
+ return;
189
+ }
190
+ let $btn = $container.querySelector('.EV_TOGGLE_ANCHOR');
191
+ if(!$btn) {
192
+ return; /* a toolbar without the control: nothing to paint */
193
+ }
194
+ set_pressed_state($container, '.EV_TOGGLE_ANCHOR', state !== "off");
195
+ $btn.classList.toggle('color_pending_state', state === "arming");
196
+ $btn.setAttribute("aria-pressed", (state === "on")? "true": "false");
197
+
198
+ let key = (state === "arming")? "click the element to centre on"
199
+ : (state === "on")? "centred: click to release"
200
+ : "anchor view";
201
+ $btn.setAttribute("title", t(key));
202
+ $btn.setAttribute("data-i18n-title", key);
203
+ $btn.setAttribute("aria-label", t(key));
204
+ $btn.setAttribute("data-i18n-aria-label", key);
205
+ }
206
+
207
+ /************************************************************
208
+ * Put `node_id` back in the middle of the viewport.
209
+ *
210
+ * Called AFTER the camera moved, never instead of moving it:
211
+ * the zoom is what the reader asked for, the centring is what
212
+ * keeps the thing they are reading under their eyes.
213
+ *
214
+ * Guarded on every side because it runs on every wheel notch:
215
+ * a graph mid-rebuild, an anchor whose node went away with a
216
+ * fold, a G6 version without `focusElement` -- none of those is
217
+ * an error worth a log line per notch, they are all "there is
218
+ * nothing to centre right now".
219
+ ************************************************************/
220
+ export function yui_graph_center_on(graph, node_id)
221
+ {
222
+ if(!graph || !node_id) {
223
+ return false;
224
+ }
225
+ try {
226
+ if(typeof graph.getElementData === "function" && !graph.getElementData(node_id)) {
227
+ return false;
228
+ }
229
+ if(typeof graph.focusElement !== "function") {
230
+ return false;
231
+ }
232
+ graph.focusElement(node_id);
233
+ return true;
234
+ } catch(e) {
235
+ return false; /* the element is gone, or the graph is between renders */
236
+ }
237
+ }
238
+
144
239
  /************************************************************
145
240
  * A refresh button, for the graphs that offer one.
146
241
  ************************************************************/