@yuneta/gobj-ui 7.23.102 → 7.23.104

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.
package/README.md CHANGED
@@ -947,6 +947,38 @@ Two things a host does not have to think about but a maintainer does:
947
947
  **New keys for consumers: `anchor view`, `click the element to centre on`,
948
948
  `centred: click to release`.**
949
949
 
950
+ ### The camera belongs to the reader
951
+
952
+ Once somebody has chosen a zoom, nothing changes it but a camera command. Not a
953
+ **refresh**, not a change of **node mode**, not a new **main topic**: those
954
+ rebuild the CONTENT, and content moving is no reason to move the reader. The
955
+ opening frame is the only one the graph picks — a fit, and only for a graph
956
+ nobody has arranged.
957
+
958
+ Two mechanisms, because the question is not the same in the two cases:
959
+
960
+ - **Across a rebuild in the same view**, a NODE is held at the pixel it was on:
961
+ `yui_graph_viewport_of()` before, `yui_graph_place_at()` after — the pair the
962
+ folds have always used. It keeps the zoom because it only translates, and it
963
+ survives a relayout that moves everything, which restoring raw coordinates
964
+ would not.
965
+ - **Across a RELOAD** there is no node to hold on to yet, so the viewport itself
966
+ is restored: `{zoom, x, y}` as G6 reports them (`getZoom()` + `getPosition()`,
967
+ the documented pair of `zoomTo()` + `translateTo()`).
968
+
969
+ Wiring: `C_G6_NODES_TREE` takes a `camera` attr — the viewport to restore on its
970
+ FIRST draw, empty meaning *open fitted* — and publishes **`EV_CAMERA_CHANGED
971
+ {zoom, x, y}`** once a move has settled. The settle is a real time (700 ms of
972
+ the browser's `setTimeout`, not a `C_TIMER`): a wheel notch, a pinch and a drag
973
+ each fire `aftertransform` many times, and what is worth saving is where the
974
+ gesture ENDED. It fires for a PAN as well as a zoom — a pan is the reader's just
975
+ as much. `C_YUI_TREEDB_GRAPH` holds the other half: `camera` is one more
976
+ `SDF_PERSIST` attr of the view, saved under its name like `main_topic`, so the
977
+ graph opens where that treedb was left.
978
+
979
+ A host that does not care persists nothing and loses nothing: the event carries
980
+ `EVF_NO_WARN_SUBS`, and the attr defaults to empty.
981
+
950
982
  ### Moving the cards, and folding without losing your place
951
983
 
952
984
  The JSON graph and the gobj tree take `drag-element`: a card can be dragged.
@@ -13420,12 +13420,11 @@ function icon_button$1(gobj, icon, event_name, label_key) {
13420
13420
  * aimed — reaching the graph from the tree would mean passing
13421
13421
  * through the text and rebuilding it on the way — so each view gets
13422
13422
  * its own button and says where it goes. apply_view_mode() moves
13423
- * the `pressed_state` mark -- the library's one way of saying a
13424
- * toggle is ON, the same the graph toolbars and the treedb legend
13425
- * use. It used to be `is-active` with a blue fill of its own: a
13426
- * STATE wearing a palette colour, which is what the palette does
13427
- * NOT mean (it names kinds of ACTION), and a third spelling of a
13428
- * thing the library already had a name for.
13423
+ * the `selected_state` mark -- the library's one way of saying
13424
+ * WHICH segment of a selector is chosen, filled with the link
13425
+ * colour. It used to be `is-active` with a fill written here: the
13426
+ * right idea, spelled a third time. A toggle, which has no "which
13427
+ * one" to answer, wears the neutral `pressed_state` pill instead.
13429
13428
  ************************************************************/
13430
13429
  function view_mode_switch$1(gobj) {
13431
13430
  return [
@@ -13522,7 +13521,7 @@ function apply_view_mode$1(gobj) {
13522
13521
  if ($el) $el.classList.toggle("is-hidden", mode !== "tree");
13523
13522
  });
13524
13523
  if (priv.$mode_btns) for (let [m, $btn] of priv.$mode_btns) {
13525
- $btn.classList.toggle("pressed_state", m === mode);
13524
+ $btn.classList.toggle("selected_state", m === mode);
13526
13525
  $btn.setAttribute("aria-pressed", m === mode ? "true" : "false");
13527
13526
  }
13528
13527
  }
@@ -15829,7 +15828,7 @@ function apply_view_mode(gobj) {
15829
15828
  };
15830
15829
  for (let [m, $el] of Object.entries(bodies)) if ($el) $el.classList.toggle("is-hidden", m !== mode);
15831
15830
  if (priv.$mode_btns) for (let [m, $btn] of priv.$mode_btns) {
15832
- $btn.classList.toggle("pressed_state", m === mode);
15831
+ $btn.classList.toggle("selected_state", m === mode);
15833
15832
  $btn.setAttribute("aria-pressed", m === mode ? "true" : "false");
15834
15833
  }
15835
15834
  }
@@ -16582,7 +16581,7 @@ function machine_view_switch(gobj) {
16582
16581
  return [
16583
16582
  "button",
16584
16583
  {
16585
- class: `button is-small GCLASS_MACHINE_VIEW GCLASS_MACHINE_VIEW_${v.mode.toUpperCase()}` + (on ? " pressed_state" : ""),
16584
+ class: `button is-small GCLASS_MACHINE_VIEW GCLASS_MACHINE_VIEW_${v.mode.toUpperCase()}` + (on ? " selected_state" : ""),
16586
16585
  type: "button",
16587
16586
  "aria-pressed": on ? "true" : "false",
16588
16587
  title: (0, i18next.t)(v.key),
@@ -54826,6 +54825,7 @@ var attrs_table$4 = [
54826
54825
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "node_labels", 0, true, "A `shape` node says its name under the figure. Off, it is a coloured figure and nothing else"),
54827
54826
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
54828
54827
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
54828
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_DICT, "camera", 0, "{}", "Where the reader left the viewport: {zoom, x, y}. Empty = the graph opens fitted. Restored on the FIRST draw and published back as EV_CAMERA_CHANGED once a move settles, so a host can persist it"),
54829
54829
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "wide", 0, "40px", "Height of header"),
54830
54830
  (0, _yuneta_gobj_js.SDATA_END)()
54831
54831
  ];
@@ -54833,6 +54833,9 @@ var PRIVATE_DATA$4 = {
54833
54833
  anchor_id: "",
54834
54834
  anchor_state: "off",
54835
54835
  last_zoom: 1,
54836
+ _camera_placed: false,
54837
+ _camera_restoring: false,
54838
+ _camera_timer: null,
54836
54839
  _xy: 100,
54837
54840
  _edge_seq: 0,
54838
54841
  _history_paused: false,
@@ -54964,8 +54967,13 @@ function mt_start$4(gobj) {
54964
54967
  * Framework Method: Stop
54965
54968
  ***************************************************************/
54966
54969
  function mt_stop$4(gobj) {
54970
+ let priv = gobj.priv;
54967
54971
  let shell = yui_shell_of(gobj);
54968
54972
  if (shell) (0, _yuneta_gobj_js.gobj_unsubscribe_event)(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
54973
+ if (priv._camera_timer) {
54974
+ clearTimeout(priv._camera_timer);
54975
+ priv._camera_timer = null;
54976
+ }
54969
54977
  return 0;
54970
54978
  }
54971
54979
  /***************************************************************
@@ -55157,6 +55165,7 @@ function configure_events(gobj) {
55157
55165
  update_link_icon_position(gobj);
55158
55166
  update_port_icon_position(gobj);
55159
55167
  update_zoom_readout(gobj);
55168
+ publish_camera_later(gobj);
55160
55169
  let z = priv.last_zoom;
55161
55170
  try {
55162
55171
  z = priv.graph.getZoom();
@@ -56682,6 +56691,80 @@ async function graph_fitview(gobj) {
56682
56691
  }
56683
56692
  var MIN_READABLE_ZOOM = .5;
56684
56693
  /************************************************************
56694
+ * THE CAMERA IS THE READER'S.
56695
+ *
56696
+ * Once somebody has chosen a zoom, nothing but a camera
56697
+ * command may change it -- not a refresh, not a change of node
56698
+ * mode, not a new main topic. Those rebuild the CONTENT, and
56699
+ * the content moving is no reason to move the reader.
56700
+ *
56701
+ * Two mechanisms, because they answer two different questions:
56702
+ *
56703
+ * - across a rebuild in the same view, the anchor pair
56704
+ * (`yui_graph_viewport_of` / `yui_graph_place_at`) keeps a
56705
+ * NODE at the pixel it was on, which survives a relayout
56706
+ * that moves everything;
56707
+ * - across a RELOAD there is no node to hold on to yet, so
56708
+ * the viewport itself is restored -- zoom and position, as
56709
+ * G6 reports them.
56710
+ ************************************************************/
56711
+ var CAMERA_SETTLE_MS = 700;
56712
+ function read_camera(gobj) {
56713
+ let graph = gobj.priv.graph;
56714
+ if (!graph || typeof graph.getZoom !== "function" || typeof graph.getPosition !== "function") return null;
56715
+ try {
56716
+ let z = graph.getZoom();
56717
+ let p = graph.getPosition();
56718
+ if (!(0, _yuneta_gobj_js.is_number)(z) || !p || !(0, _yuneta_gobj_js.is_number)(p[0]) || !(0, _yuneta_gobj_js.is_number)(p[1])) return null;
56719
+ return {
56720
+ zoom: z,
56721
+ x: p[0],
56722
+ y: p[1]
56723
+ };
56724
+ } catch (e) {
56725
+ return null;
56726
+ }
56727
+ }
56728
+ async function restore_camera(gobj) {
56729
+ let priv = gobj.priv;
56730
+ let graph = priv.graph;
56731
+ let cam = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "camera");
56732
+ if (!graph || (0, _yuneta_gobj_js.empty_json)(cam) || !(0, _yuneta_gobj_js.is_number)(cam.zoom)) return false;
56733
+ priv._camera_restoring = true;
56734
+ try {
56735
+ await graph.zoomTo(cam.zoom);
56736
+ if ((0, _yuneta_gobj_js.is_number)(cam.x) && (0, _yuneta_gobj_js.is_number)(cam.y)) await graph.translateTo([cam.x, cam.y]);
56737
+ return true;
56738
+ } catch (e) {
56739
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot restore the camera: ${e}`);
56740
+ return false;
56741
+ } finally {
56742
+ priv._camera_restoring = false;
56743
+ }
56744
+ }
56745
+ /************************************************************
56746
+ * A move settled: tell whoever persists it.
56747
+ *
56748
+ * Debounced on a REAL time and with the browser's timer: a
56749
+ * wheel notch, a pinch and a drag each fire `aftertransform`
56750
+ * many times, and what is worth saving is where the gesture
56751
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
56752
+ * the global -- this module imports neither.)
56753
+ ************************************************************/
56754
+ function publish_camera_later(gobj) {
56755
+ let priv = gobj.priv;
56756
+ if (priv._camera_restoring || !priv._camera_placed) return;
56757
+ if (priv._camera_timer) clearTimeout(priv._camera_timer);
56758
+ priv._camera_timer = setTimeout(() => {
56759
+ priv._camera_timer = null;
56760
+ if ((0, _yuneta_gobj_js.gobj_is_destroying)(gobj)) return;
56761
+ let cam = read_camera(gobj);
56762
+ if (!cam) return;
56763
+ (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "camera", cam);
56764
+ (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_CAMERA_CHANGED", cam);
56765
+ }, CAMERA_SETTLE_MS);
56766
+ }
56767
+ /************************************************************
56685
56768
  * Fit the graph in the viewport, but never zoom out past the point
56686
56769
  * where nothing on a card can be read.
56687
56770
  *
@@ -60625,8 +60708,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60625
60708
  }
60626
60709
  await graph_draw(gobj);
60627
60710
  if (changed || opts.relayout) await graph_layout(gobj);
60628
- if (opts.fit) await graph_fit_readable(gobj);
60629
- else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60711
+ if (!(opts.restore && await restore_camera(gobj))) {
60712
+ if (opts.fit) await graph_fit_readable(gobj);
60713
+ else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60714
+ }
60715
+ priv._camera_placed = true;
60630
60716
  update_resize_handles_position(gobj);
60631
60717
  update_port_resize_handles_position(gobj);
60632
60718
  update_edge_icon_position(gobj);
@@ -60778,10 +60864,19 @@ function ac_load_data(gobj, event, kw, src) {
60778
60864
  (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: auto_layout failed: ${e}`);
60779
60865
  }
60780
60866
  apply_layout_direction(gobj);
60781
- reconcile_fold(gobj, {
60782
- relayout: true,
60867
+ let vp_opts;
60868
+ if (!priv._camera_placed) vp_opts = {
60869
+ restore: true,
60783
60870
  fit: auto_laid
60784
- }).then(() => {
60871
+ };
60872
+ else {
60873
+ let keep = fold_keep_node(gobj);
60874
+ vp_opts = {
60875
+ keep,
60876
+ keep_vp: yui_graph_viewport_of(priv.graph, keep)
60877
+ };
60878
+ }
60879
+ reconcile_fold(gobj, Object.assign({ relayout: true }, vp_opts)).then(() => {
60785
60880
  sync_history_plugin(gobj);
60786
60881
  if (priv._pending_focus_topic !== null) {
60787
60882
  let ft = priv._pending_focus_topic;
@@ -61241,11 +61336,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61241
61336
  let priv = gobj.priv;
61242
61337
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "main_topic", kw && kw.topic || "");
61243
61338
  if (!priv._fold_model) return 0;
61339
+ let keep = fold_keep_node(gobj);
61340
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61244
61341
  rebuild_fold_model(gobj);
61245
61342
  fold_expand_to_depth(priv._fold_model, priv._fold_state, (0, _yuneta_gobj_js.gobj_read_integer_attr)(gobj, "expand_depth"));
61246
61343
  reconcile_fold(gobj, {
61247
61344
  relayout: true,
61248
- fit: true
61345
+ keep,
61346
+ keep_vp: vp
61249
61347
  });
61250
61348
  return 0;
61251
61349
  }
@@ -61271,11 +61369,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61271
61369
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "node_mode", mode);
61272
61370
  priv._open_nodes = /* @__PURE__ */ new Set();
61273
61371
  if (!priv.graph || !priv._fold_model) return 0;
61372
+ let keep = fold_keep_node(gobj);
61373
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61274
61374
  reshape_nodes(gobj, all_record_node_ids(gobj));
61275
61375
  reshape_more_chips(gobj);
61276
61376
  reconcile_fold(gobj, {
61277
61377
  relayout: true,
61278
- fit: true
61378
+ keep,
61379
+ keep_vp: vp
61279
61380
  });
61280
61381
  return 0;
61281
61382
  }
@@ -61945,6 +62046,7 @@ function create_gclass$4(gclass_name) {
61945
62046
  ["EV_FIND_NODES", 0],
61946
62047
  ["EV_FIND_RESULT", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
61947
62048
  ["EV_LAYOUT_AUTOSET", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
62049
+ ["EV_CAMERA_CHANGED", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
61948
62050
  ["EV_TOGGLE_FOLD", 0],
61949
62051
  ["EV_SHOW_MORE", 0],
61950
62052
  ["EV_EXPAND_ALL", 0],
@@ -62031,6 +62133,7 @@ var attrs_table$3 = [
62031
62133
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_LIST, "loose_topics", _yuneta_gobj_js.sdata_flag_t.SDF_PERSIST, "[]", "Topics whose LOOSE records (no parent) are shown. User preference, per treedb"),
62032
62134
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "node_mode", _yuneta_gobj_js.sdata_flag_t.SDF_PERSIST, "expanded", "How the records are drawn: `expanded` = the cards with their pills and ports, `compact` = one-line pills with the name, `shape` = coloured figures, the topology alone. User preference"),
62033
62135
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "node_labels", _yuneta_gobj_js.sdata_flag_t.SDF_PERSIST, true, "A `shape` node says its name under its figure. User preference"),
62136
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_DICT, "camera", _yuneta_gobj_js.sdata_flag_t.SDF_PERSIST, "{}", "Where the reader left the viewport: {zoom, x, y}. Handed to the engine on creation and written back when a move settles, so the graph opens where it was left. User preference, per treedb"),
62034
62137
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62035
62138
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62036
62139
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62118,7 +62221,8 @@ function mt_create$3(gobj) {
62118
62221
  main_topic: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "main_topic") || "",
62119
62222
  loose_topics: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "loose_topics") || [],
62120
62223
  node_mode: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "node_mode") || "expanded",
62121
- node_labels: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "node_labels")
62224
+ node_labels: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "node_labels"),
62225
+ camera: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "camera") || {}
62122
62226
  }, gobj);
62123
62227
  populate_nodes_tree_options(gobj);
62124
62228
  if (priv.with_treedb_tables) {}
@@ -62402,7 +62506,7 @@ function refresh_node_shape_buttons(gobj, $root) {
62402
62506
  let labels = (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "node_labels");
62403
62507
  for (let $btn of $root.querySelectorAll(".GRAPH_NODE_MODE_BTN")) {
62404
62508
  let on = $btn.getAttribute("data-mode") === mode;
62405
- $btn.classList.toggle("pressed_state", on);
62509
+ $btn.classList.toggle("selected_state", on);
62406
62510
  $btn.setAttribute("aria-pressed", on ? "true" : "false");
62407
62511
  }
62408
62512
  set_pressed_state($root, ".GRAPH_NODE_LABELS", labels);
@@ -63802,6 +63906,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63802
63906
  return 0;
63803
63907
  }
63804
63908
  /************************************************************
63909
+ * The reader moved the camera, and it has settled.
63910
+ *
63911
+ * Persisted like every other choice of this toolbar, and for the
63912
+ * same reason: a zoom is a decision, and a decision that dies with
63913
+ * the tab has to be taken again every morning. The engine debounces,
63914
+ * so this runs once per gesture and not once per wheel notch.
63915
+ ************************************************************/
63916
+ function ac_camera_changed(gobj, event, kw, src) {
63917
+ (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "camera", {
63918
+ zoom: kw.zoom,
63919
+ x: kw.x,
63920
+ y: kw.y
63921
+ });
63922
+ (0, _yuneta_gobj_js.gobj_save_persistent_attrs)(gobj, "camera");
63923
+ return 0;
63924
+ }
63925
+ /************************************************************
63805
63926
  * Forward the find down to the graph child.
63806
63927
  ************************************************************/
63807
63928
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64081,6 +64202,11 @@ function create_gclass$3(gclass_name) {
64081
64202
  ac_layout_autoset,
64082
64203
  null
64083
64204
  ],
64205
+ [
64206
+ "EV_CAMERA_CHANGED",
64207
+ ac_camera_changed,
64208
+ null
64209
+ ],
64084
64210
  [
64085
64211
  "EV_LEGEND_TOPIC",
64086
64212
  ac_legend_topic,
@@ -64149,6 +64275,7 @@ function create_gclass$3(gclass_name) {
64149
64275
  ["EV_TOGGLE_NODE_LABELS", 0],
64150
64276
  ["EV_FIND_RESULT", 0],
64151
64277
  ["EV_LAYOUT_AUTOSET", 0],
64278
+ ["EV_CAMERA_CHANGED", 0],
64152
64279
  ["EV_LEGEND_TOPIC", 0],
64153
64280
  ["EV_LEGEND_STATE", 0],
64154
64281
  ["EV_TOPIC_SELECTED", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
@@ -71061,12 +71188,12 @@ function repaint(gobj) {
71061
71188
  if (on) m.$btn.setAttribute("aria-current", "true");
71062
71189
  else m.$btn.removeAttribute("aria-current");
71063
71190
  } else {
71064
- m.$btn.classList.toggle("pressed_state", on);
71191
+ m.$btn.classList.toggle("selected_state", on);
71065
71192
  m.$btn.setAttribute("aria-pressed", on ? "true" : "false");
71066
71193
  }
71067
71194
  }
71068
71195
  if (mode && mode.$btn) scroll_mode_into_view(gobj, mode.$btn);
71069
- if (priv.$more) priv.$more.classList.toggle("pressed_state", !!(mode && mode.overflow));
71196
+ if (priv.$more) priv.$more.classList.toggle("selected_state", !!(mode && mode.overflow));
71070
71197
  let is_bucket = !!(mode && mode.kind === "bucket");
71071
71198
  priv.$nav.classList.toggle("yui-period-nav-off", !is_bucket);
71072
71199
  priv.$label.disabled = !is_bucket;
@@ -71257,7 +71384,7 @@ function cell_button(gobj, label, anchor_ms, selected, logical, aria) {
71257
71384
  let $btn = (0, _yuneta_gobj_js.createElement2)([
71258
71385
  "button",
71259
71386
  {
71260
- class: `button is-small ${selected ? "pressed_state" : "is-ghost"} ${logical}`,
71387
+ class: `button is-small ${selected ? "selected_state" : "is-ghost"} ${logical}`,
71261
71388
  type: "button",
71262
71389
  style: "width:100%;",
71263
71390
  ...selected ? { "aria-current": "date" } : {},
@@ -1,5 +1,5 @@
1
1
  import * as gobj_js from "@yuneta/gobj-js";
2
- import { SDATA, SDATA_END, clean_name, createElement2, createOneHtml, create_template_record, data_type_t, debounce, delete_from_list, duplicate_objects, empty_string, escapeHtml, event_flag_t, gclass_create, gclass_find_by_name, gclass_flag_t, getPositionRelativeToBody, gobj_bottom_gobj, gobj_change_state, gobj_command, gobj_create, gobj_create_pure_child, gobj_create_service, gobj_current_state, gobj_destroy, gobj_find_child, gobj_find_service, gobj_full_name, gobj_gclass_name, gobj_is_destroying, gobj_is_playing, gobj_is_pure_child, gobj_is_running, gobj_is_service, gobj_is_volatil, gobj_match_children, gobj_name, gobj_parent, gobj_post_event, gobj_publish_event, gobj_read_attr, gobj_read_bool_attr, gobj_read_integer_attr, gobj_read_pointer_attr, gobj_read_str_attr, gobj_save_persistent_attrs, gobj_send_event, gobj_set_trace_machine_format, gobj_short_name, gobj_start, gobj_start_tree, gobj_stop, gobj_stop_children, gobj_subscribe_event, gobj_unsubscribe_event, gobj_write_attr, gobj_write_bool_attr, gobj_write_integer_attr, gobj_write_str_attr, gobj_yuno, gobj_yuno_name, is_array, is_boolean, is_date, is_gobj, is_null, is_number, is_object, is_string, json_deep_copy, json_object_size, json_object_update, json_object_update_missing, json_size, kw_clone_by_keys, kw_flag_t, kw_get_dict, kw_get_dict_value, kw_get_int, kw_get_local_storage_value, kw_get_str, kw_has_key, kw_set_dict_value, kw_set_local_storage_value, kwid_find_one_record, kwid_get_ids, kwid_new_dict, log_error, log_warning, msg_iev_get_stack, parseBoolean, parseSVG, refresh_language, safeSrc, sdata_flag_t, set_console_log_enabled, set_log_callback, sprintf, str_in_list, template_get_field_desc, trace_msg, treedb_decoder_fkey, treedb_encoder_fkey, treedb_get_field_desc, treedb_hook_data_size } from "@yuneta/gobj-js";
2
+ import { SDATA, SDATA_END, clean_name, createElement2, createOneHtml, create_template_record, data_type_t, debounce, delete_from_list, duplicate_objects, empty_json, empty_string, escapeHtml, event_flag_t, gclass_create, gclass_find_by_name, gclass_flag_t, getPositionRelativeToBody, gobj_bottom_gobj, gobj_change_state, gobj_command, gobj_create, gobj_create_pure_child, gobj_create_service, gobj_current_state, gobj_destroy, gobj_find_child, gobj_find_service, gobj_full_name, gobj_gclass_name, gobj_is_destroying, gobj_is_playing, gobj_is_pure_child, gobj_is_running, gobj_is_service, gobj_is_volatil, gobj_match_children, gobj_name, gobj_parent, gobj_post_event, gobj_publish_event, gobj_read_attr, gobj_read_bool_attr, gobj_read_integer_attr, gobj_read_pointer_attr, gobj_read_str_attr, gobj_save_persistent_attrs, gobj_send_event, gobj_set_trace_machine_format, gobj_short_name, gobj_start, gobj_start_tree, gobj_stop, gobj_stop_children, gobj_subscribe_event, gobj_unsubscribe_event, gobj_write_attr, gobj_write_bool_attr, gobj_write_integer_attr, gobj_write_str_attr, gobj_yuno, gobj_yuno_name, is_array, is_boolean, is_date, is_gobj, is_null, is_number, is_object, is_string, json_deep_copy, json_object_size, json_object_update, json_object_update_missing, json_size, kw_clone_by_keys, kw_flag_t, kw_get_dict, kw_get_dict_value, kw_get_int, kw_get_local_storage_value, kw_get_str, kw_has_key, kw_set_dict_value, kw_set_local_storage_value, kwid_find_one_record, kwid_get_ids, kwid_new_dict, log_error, log_warning, msg_iev_get_stack, parseBoolean, parseSVG, refresh_language, safeSrc, sdata_flag_t, set_console_log_enabled, set_log_callback, sprintf, str_in_list, template_get_field_desc, trace_msg, treedb_decoder_fkey, treedb_encoder_fkey, treedb_get_field_desc, treedb_hook_data_size } from "@yuneta/gobj-js";
3
3
  import i18next, { t } from "i18next";
4
4
  import TomSelect from "tom-select";
5
5
  import { createJSONEditor } from "vanilla-jsoneditor";
@@ -13415,12 +13415,11 @@ function icon_button$1(gobj, icon, event_name, label_key) {
13415
13415
  * aimed — reaching the graph from the tree would mean passing
13416
13416
  * through the text and rebuilding it on the way — so each view gets
13417
13417
  * its own button and says where it goes. apply_view_mode() moves
13418
- * the `pressed_state` mark -- the library's one way of saying a
13419
- * toggle is ON, the same the graph toolbars and the treedb legend
13420
- * use. It used to be `is-active` with a blue fill of its own: a
13421
- * STATE wearing a palette colour, which is what the palette does
13422
- * NOT mean (it names kinds of ACTION), and a third spelling of a
13423
- * thing the library already had a name for.
13418
+ * the `selected_state` mark -- the library's one way of saying
13419
+ * WHICH segment of a selector is chosen, filled with the link
13420
+ * colour. It used to be `is-active` with a fill written here: the
13421
+ * right idea, spelled a third time. A toggle, which has no "which
13422
+ * one" to answer, wears the neutral `pressed_state` pill instead.
13424
13423
  ************************************************************/
13425
13424
  function view_mode_switch$1(gobj) {
13426
13425
  return [
@@ -13517,7 +13516,7 @@ function apply_view_mode$1(gobj) {
13517
13516
  if ($el) $el.classList.toggle("is-hidden", mode !== "tree");
13518
13517
  });
13519
13518
  if (priv.$mode_btns) for (let [m, $btn] of priv.$mode_btns) {
13520
- $btn.classList.toggle("pressed_state", m === mode);
13519
+ $btn.classList.toggle("selected_state", m === mode);
13521
13520
  $btn.setAttribute("aria-pressed", m === mode ? "true" : "false");
13522
13521
  }
13523
13522
  }
@@ -15824,7 +15823,7 @@ function apply_view_mode(gobj) {
15824
15823
  };
15825
15824
  for (let [m, $el] of Object.entries(bodies)) if ($el) $el.classList.toggle("is-hidden", m !== mode);
15826
15825
  if (priv.$mode_btns) for (let [m, $btn] of priv.$mode_btns) {
15827
- $btn.classList.toggle("pressed_state", m === mode);
15826
+ $btn.classList.toggle("selected_state", m === mode);
15828
15827
  $btn.setAttribute("aria-pressed", m === mode ? "true" : "false");
15829
15828
  }
15830
15829
  }
@@ -16577,7 +16576,7 @@ function machine_view_switch(gobj) {
16577
16576
  return [
16578
16577
  "button",
16579
16578
  {
16580
- class: `button is-small GCLASS_MACHINE_VIEW GCLASS_MACHINE_VIEW_${v.mode.toUpperCase()}` + (on ? " pressed_state" : ""),
16579
+ class: `button is-small GCLASS_MACHINE_VIEW GCLASS_MACHINE_VIEW_${v.mode.toUpperCase()}` + (on ? " selected_state" : ""),
16581
16580
  type: "button",
16582
16581
  "aria-pressed": on ? "true" : "false",
16583
16582
  title: t(v.key),
@@ -54821,6 +54820,7 @@ var attrs_table$4 = [
54821
54820
  SDATA(data_type_t.DTP_BOOLEAN, "node_labels", 0, true, "A `shape` node says its name under the figure. Off, it is a coloured figure and nothing else"),
54822
54821
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
54823
54822
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
54823
+ SDATA(data_type_t.DTP_DICT, "camera", 0, "{}", "Where the reader left the viewport: {zoom, x, y}. Empty = the graph opens fitted. Restored on the FIRST draw and published back as EV_CAMERA_CHANGED once a move settles, so a host can persist it"),
54824
54824
  SDATA(data_type_t.DTP_STRING, "wide", 0, "40px", "Height of header"),
54825
54825
  SDATA_END()
54826
54826
  ];
@@ -54828,6 +54828,9 @@ var PRIVATE_DATA$4 = {
54828
54828
  anchor_id: "",
54829
54829
  anchor_state: "off",
54830
54830
  last_zoom: 1,
54831
+ _camera_placed: false,
54832
+ _camera_restoring: false,
54833
+ _camera_timer: null,
54831
54834
  _xy: 100,
54832
54835
  _edge_seq: 0,
54833
54836
  _history_paused: false,
@@ -54959,8 +54962,13 @@ function mt_start$4(gobj) {
54959
54962
  * Framework Method: Stop
54960
54963
  ***************************************************************/
54961
54964
  function mt_stop$4(gobj) {
54965
+ let priv = gobj.priv;
54962
54966
  let shell = yui_shell_of(gobj);
54963
54967
  if (shell) gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
54968
+ if (priv._camera_timer) {
54969
+ clearTimeout(priv._camera_timer);
54970
+ priv._camera_timer = null;
54971
+ }
54964
54972
  return 0;
54965
54973
  }
54966
54974
  /***************************************************************
@@ -55152,6 +55160,7 @@ function configure_events(gobj) {
55152
55160
  update_link_icon_position(gobj);
55153
55161
  update_port_icon_position(gobj);
55154
55162
  update_zoom_readout(gobj);
55163
+ publish_camera_later(gobj);
55155
55164
  let z = priv.last_zoom;
55156
55165
  try {
55157
55166
  z = priv.graph.getZoom();
@@ -56677,6 +56686,80 @@ async function graph_fitview(gobj) {
56677
56686
  }
56678
56687
  var MIN_READABLE_ZOOM = .5;
56679
56688
  /************************************************************
56689
+ * THE CAMERA IS THE READER'S.
56690
+ *
56691
+ * Once somebody has chosen a zoom, nothing but a camera
56692
+ * command may change it -- not a refresh, not a change of node
56693
+ * mode, not a new main topic. Those rebuild the CONTENT, and
56694
+ * the content moving is no reason to move the reader.
56695
+ *
56696
+ * Two mechanisms, because they answer two different questions:
56697
+ *
56698
+ * - across a rebuild in the same view, the anchor pair
56699
+ * (`yui_graph_viewport_of` / `yui_graph_place_at`) keeps a
56700
+ * NODE at the pixel it was on, which survives a relayout
56701
+ * that moves everything;
56702
+ * - across a RELOAD there is no node to hold on to yet, so
56703
+ * the viewport itself is restored -- zoom and position, as
56704
+ * G6 reports them.
56705
+ ************************************************************/
56706
+ var CAMERA_SETTLE_MS = 700;
56707
+ function read_camera(gobj) {
56708
+ let graph = gobj.priv.graph;
56709
+ if (!graph || typeof graph.getZoom !== "function" || typeof graph.getPosition !== "function") return null;
56710
+ try {
56711
+ let z = graph.getZoom();
56712
+ let p = graph.getPosition();
56713
+ if (!is_number(z) || !p || !is_number(p[0]) || !is_number(p[1])) return null;
56714
+ return {
56715
+ zoom: z,
56716
+ x: p[0],
56717
+ y: p[1]
56718
+ };
56719
+ } catch (e) {
56720
+ return null;
56721
+ }
56722
+ }
56723
+ async function restore_camera(gobj) {
56724
+ let priv = gobj.priv;
56725
+ let graph = priv.graph;
56726
+ let cam = gobj_read_attr(gobj, "camera");
56727
+ if (!graph || empty_json(cam) || !is_number(cam.zoom)) return false;
56728
+ priv._camera_restoring = true;
56729
+ try {
56730
+ await graph.zoomTo(cam.zoom);
56731
+ if (is_number(cam.x) && is_number(cam.y)) await graph.translateTo([cam.x, cam.y]);
56732
+ return true;
56733
+ } catch (e) {
56734
+ log_error(`${gobj_short_name(gobj)}: cannot restore the camera: ${e}`);
56735
+ return false;
56736
+ } finally {
56737
+ priv._camera_restoring = false;
56738
+ }
56739
+ }
56740
+ /************************************************************
56741
+ * A move settled: tell whoever persists it.
56742
+ *
56743
+ * Debounced on a REAL time and with the browser's timer: a
56744
+ * wheel notch, a pinch and a drag each fire `aftertransform`
56745
+ * many times, and what is worth saving is where the gesture
56746
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
56747
+ * the global -- this module imports neither.)
56748
+ ************************************************************/
56749
+ function publish_camera_later(gobj) {
56750
+ let priv = gobj.priv;
56751
+ if (priv._camera_restoring || !priv._camera_placed) return;
56752
+ if (priv._camera_timer) clearTimeout(priv._camera_timer);
56753
+ priv._camera_timer = setTimeout(() => {
56754
+ priv._camera_timer = null;
56755
+ if (gobj_is_destroying(gobj)) return;
56756
+ let cam = read_camera(gobj);
56757
+ if (!cam) return;
56758
+ gobj_write_attr(gobj, "camera", cam);
56759
+ gobj_publish_event(gobj, "EV_CAMERA_CHANGED", cam);
56760
+ }, CAMERA_SETTLE_MS);
56761
+ }
56762
+ /************************************************************
56680
56763
  * Fit the graph in the viewport, but never zoom out past the point
56681
56764
  * where nothing on a card can be read.
56682
56765
  *
@@ -60620,8 +60703,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60620
60703
  }
60621
60704
  await graph_draw(gobj);
60622
60705
  if (changed || opts.relayout) await graph_layout(gobj);
60623
- if (opts.fit) await graph_fit_readable(gobj);
60624
- else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60706
+ if (!(opts.restore && await restore_camera(gobj))) {
60707
+ if (opts.fit) await graph_fit_readable(gobj);
60708
+ else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60709
+ }
60710
+ priv._camera_placed = true;
60625
60711
  update_resize_handles_position(gobj);
60626
60712
  update_port_resize_handles_position(gobj);
60627
60713
  update_edge_icon_position(gobj);
@@ -60773,10 +60859,19 @@ function ac_load_data(gobj, event, kw, src) {
60773
60859
  log_error(`${gobj_short_name(gobj)}: auto_layout failed: ${e}`);
60774
60860
  }
60775
60861
  apply_layout_direction(gobj);
60776
- reconcile_fold(gobj, {
60777
- relayout: true,
60862
+ let vp_opts;
60863
+ if (!priv._camera_placed) vp_opts = {
60864
+ restore: true,
60778
60865
  fit: auto_laid
60779
- }).then(() => {
60866
+ };
60867
+ else {
60868
+ let keep = fold_keep_node(gobj);
60869
+ vp_opts = {
60870
+ keep,
60871
+ keep_vp: yui_graph_viewport_of(priv.graph, keep)
60872
+ };
60873
+ }
60874
+ reconcile_fold(gobj, Object.assign({ relayout: true }, vp_opts)).then(() => {
60780
60875
  sync_history_plugin(gobj);
60781
60876
  if (priv._pending_focus_topic !== null) {
60782
60877
  let ft = priv._pending_focus_topic;
@@ -61236,11 +61331,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61236
61331
  let priv = gobj.priv;
61237
61332
  gobj_write_str_attr(gobj, "main_topic", kw && kw.topic || "");
61238
61333
  if (!priv._fold_model) return 0;
61334
+ let keep = fold_keep_node(gobj);
61335
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61239
61336
  rebuild_fold_model(gobj);
61240
61337
  fold_expand_to_depth(priv._fold_model, priv._fold_state, gobj_read_integer_attr(gobj, "expand_depth"));
61241
61338
  reconcile_fold(gobj, {
61242
61339
  relayout: true,
61243
- fit: true
61340
+ keep,
61341
+ keep_vp: vp
61244
61342
  });
61245
61343
  return 0;
61246
61344
  }
@@ -61266,11 +61364,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61266
61364
  gobj_write_str_attr(gobj, "node_mode", mode);
61267
61365
  priv._open_nodes = /* @__PURE__ */ new Set();
61268
61366
  if (!priv.graph || !priv._fold_model) return 0;
61367
+ let keep = fold_keep_node(gobj);
61368
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61269
61369
  reshape_nodes(gobj, all_record_node_ids(gobj));
61270
61370
  reshape_more_chips(gobj);
61271
61371
  reconcile_fold(gobj, {
61272
61372
  relayout: true,
61273
- fit: true
61373
+ keep,
61374
+ keep_vp: vp
61274
61375
  });
61275
61376
  return 0;
61276
61377
  }
@@ -61940,6 +62041,7 @@ function create_gclass$4(gclass_name) {
61940
62041
  ["EV_FIND_NODES", 0],
61941
62042
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
61942
62043
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
62044
+ ["EV_CAMERA_CHANGED", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
61943
62045
  ["EV_TOGGLE_FOLD", 0],
61944
62046
  ["EV_SHOW_MORE", 0],
61945
62047
  ["EV_EXPAND_ALL", 0],
@@ -62026,6 +62128,7 @@ var attrs_table$3 = [
62026
62128
  SDATA(data_type_t.DTP_LIST, "loose_topics", sdata_flag_t.SDF_PERSIST, "[]", "Topics whose LOOSE records (no parent) are shown. User preference, per treedb"),
62027
62129
  SDATA(data_type_t.DTP_STRING, "node_mode", sdata_flag_t.SDF_PERSIST, "expanded", "How the records are drawn: `expanded` = the cards with their pills and ports, `compact` = one-line pills with the name, `shape` = coloured figures, the topology alone. User preference"),
62028
62130
  SDATA(data_type_t.DTP_BOOLEAN, "node_labels", sdata_flag_t.SDF_PERSIST, true, "A `shape` node says its name under its figure. User preference"),
62131
+ SDATA(data_type_t.DTP_DICT, "camera", sdata_flag_t.SDF_PERSIST, "{}", "Where the reader left the viewport: {zoom, x, y}. Handed to the engine on creation and written back when a move settles, so the graph opens where it was left. User preference, per treedb"),
62029
62132
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62030
62133
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62031
62134
  SDATA(data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62113,7 +62216,8 @@ function mt_create$3(gobj) {
62113
62216
  main_topic: gobj_read_str_attr(gobj, "main_topic") || "",
62114
62217
  loose_topics: gobj_read_attr(gobj, "loose_topics") || [],
62115
62218
  node_mode: gobj_read_str_attr(gobj, "node_mode") || "expanded",
62116
- node_labels: gobj_read_bool_attr(gobj, "node_labels")
62219
+ node_labels: gobj_read_bool_attr(gobj, "node_labels"),
62220
+ camera: gobj_read_attr(gobj, "camera") || {}
62117
62221
  }, gobj);
62118
62222
  populate_nodes_tree_options(gobj);
62119
62223
  if (priv.with_treedb_tables) {}
@@ -62397,7 +62501,7 @@ function refresh_node_shape_buttons(gobj, $root) {
62397
62501
  let labels = gobj_read_bool_attr(gobj, "node_labels");
62398
62502
  for (let $btn of $root.querySelectorAll(".GRAPH_NODE_MODE_BTN")) {
62399
62503
  let on = $btn.getAttribute("data-mode") === mode;
62400
- $btn.classList.toggle("pressed_state", on);
62504
+ $btn.classList.toggle("selected_state", on);
62401
62505
  $btn.setAttribute("aria-pressed", on ? "true" : "false");
62402
62506
  }
62403
62507
  set_pressed_state($root, ".GRAPH_NODE_LABELS", labels);
@@ -63797,6 +63901,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63797
63901
  return 0;
63798
63902
  }
63799
63903
  /************************************************************
63904
+ * The reader moved the camera, and it has settled.
63905
+ *
63906
+ * Persisted like every other choice of this toolbar, and for the
63907
+ * same reason: a zoom is a decision, and a decision that dies with
63908
+ * the tab has to be taken again every morning. The engine debounces,
63909
+ * so this runs once per gesture and not once per wheel notch.
63910
+ ************************************************************/
63911
+ function ac_camera_changed(gobj, event, kw, src) {
63912
+ gobj_write_attr(gobj, "camera", {
63913
+ zoom: kw.zoom,
63914
+ x: kw.x,
63915
+ y: kw.y
63916
+ });
63917
+ gobj_save_persistent_attrs(gobj, "camera");
63918
+ return 0;
63919
+ }
63920
+ /************************************************************
63800
63921
  * Forward the find down to the graph child.
63801
63922
  ************************************************************/
63802
63923
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64076,6 +64197,11 @@ function create_gclass$3(gclass_name) {
64076
64197
  ac_layout_autoset,
64077
64198
  null
64078
64199
  ],
64200
+ [
64201
+ "EV_CAMERA_CHANGED",
64202
+ ac_camera_changed,
64203
+ null
64204
+ ],
64079
64205
  [
64080
64206
  "EV_LEGEND_TOPIC",
64081
64207
  ac_legend_topic,
@@ -64144,6 +64270,7 @@ function create_gclass$3(gclass_name) {
64144
64270
  ["EV_TOGGLE_NODE_LABELS", 0],
64145
64271
  ["EV_FIND_RESULT", 0],
64146
64272
  ["EV_LAYOUT_AUTOSET", 0],
64273
+ ["EV_CAMERA_CHANGED", 0],
64147
64274
  ["EV_LEGEND_TOPIC", 0],
64148
64275
  ["EV_LEGEND_STATE", 0],
64149
64276
  ["EV_TOPIC_SELECTED", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
@@ -71056,12 +71183,12 @@ function repaint(gobj) {
71056
71183
  if (on) m.$btn.setAttribute("aria-current", "true");
71057
71184
  else m.$btn.removeAttribute("aria-current");
71058
71185
  } else {
71059
- m.$btn.classList.toggle("pressed_state", on);
71186
+ m.$btn.classList.toggle("selected_state", on);
71060
71187
  m.$btn.setAttribute("aria-pressed", on ? "true" : "false");
71061
71188
  }
71062
71189
  }
71063
71190
  if (mode && mode.$btn) scroll_mode_into_view(gobj, mode.$btn);
71064
- if (priv.$more) priv.$more.classList.toggle("pressed_state", !!(mode && mode.overflow));
71191
+ if (priv.$more) priv.$more.classList.toggle("selected_state", !!(mode && mode.overflow));
71065
71192
  let is_bucket = !!(mode && mode.kind === "bucket");
71066
71193
  priv.$nav.classList.toggle("yui-period-nav-off", !is_bucket);
71067
71194
  priv.$label.disabled = !is_bucket;
@@ -71252,7 +71379,7 @@ function cell_button(gobj, label, anchor_ms, selected, logical, aria) {
71252
71379
  let $btn = createElement2([
71253
71380
  "button",
71254
71381
  {
71255
- class: `button is-small ${selected ? "pressed_state" : "is-ghost"} ${logical}`,
71382
+ class: `button is-small ${selected ? "selected_state" : "is-ghost"} ${logical}`,
71256
71383
  type: "button",
71257
71384
  style: "width:100%;",
71258
71385
  ...selected ? { "aria-current": "date" } : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.102",
3
+ "version": "7.23.104",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -24,6 +24,7 @@
24
24
  "scripts": {
25
25
  "build": "vite build",
26
26
  "test": "vitest run",
27
+ "deploy-round": "node scripts/deploy-round.mjs",
27
28
  "prepublishOnly": "npm run build"
28
29
  },
29
30
  "devDependencies": {
@@ -70,6 +70,7 @@ import {
70
70
  empty_string,
71
71
  is_object,
72
72
  is_number,
73
+ empty_json,
73
74
  kw_get_int,
74
75
  kw_get_str,
75
76
  kw_get_dict_value,
@@ -442,6 +443,9 @@ SDATA(data_type_t.DTP_BOOLEAN, "node_labels", 0, true, "A `shape` n
442
443
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
443
444
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_unlink_edge", 0, true, "Ask confirmation before unlinking an edge"),
444
445
 
446
+ /*---------------- Camera ----------------*/
447
+ SDATA(data_type_t.DTP_DICT, "camera", 0, "{}", "Where the reader left the viewport: {zoom, x, y}. Empty = the graph opens fitted. Restored on the FIRST draw and published back as EV_CAMERA_CHANGED once a move settles, so a host can persist it"),
448
+
445
449
  SDATA(data_type_t.DTP_STRING, "wide", 0, "40px", "Height of header"),
446
450
 
447
451
  SDATA_END()
@@ -452,6 +456,9 @@ let PRIVATE_DATA = {
452
456
  anchor_id: "", // the node the camera keeps in the middle
453
457
  anchor_state: "off", // "off" | "arming" | "on"
454
458
  last_zoom: 1, // tells a wheel zoom from a drag in aftertransform
459
+ _camera_placed: false, // the first draw has decided the viewport
460
+ _camera_restoring: false, // a move WE made: not the reader's, do not publish it
461
+ _camera_timer: null, // browser timer: the gesture has to END before it is saved
455
462
 
456
463
  _xy: 100,
457
464
  _edge_seq: 0,
@@ -657,11 +664,19 @@ function mt_start(gobj)
657
664
  ***************************************************************/
658
665
  function mt_stop(gobj)
659
666
  {
667
+ let priv = gobj.priv;
660
668
  let shell = yui_shell_of(gobj);
661
669
  if(shell) {
662
670
  gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
663
671
  }
664
672
 
673
+ /* A camera move that settles after the view is gone has nobody
674
+ * to tell and a graph that is no longer there to read. */
675
+ if(priv._camera_timer) {
676
+ clearTimeout(priv._camera_timer);
677
+ priv._camera_timer = null;
678
+ }
679
+
665
680
  return 0;
666
681
  }
667
682
 
@@ -1036,6 +1051,11 @@ function configure_events(gobj)
1036
1051
  update_port_icon_position(gobj);
1037
1052
  update_zoom_readout(gobj);
1038
1053
 
1054
+ /* Before the zoom/pan test below and not inside it: a PAN is
1055
+ * a camera move too, and it is the reader's just as much as
1056
+ * a zoom is. */
1057
+ publish_camera_later(gobj);
1058
+
1039
1059
  /*
1040
1060
  * A ZOOM re-centres on the anchor; a PAN does not.
1041
1061
  * `aftertransform` fires for both, and an anchor that also
@@ -3329,6 +3349,106 @@ async function graph_fitview(gobj)
3329
3349
  /* Below this the cards carry no readable text at all. */
3330
3350
  const MIN_READABLE_ZOOM = 0.5;
3331
3351
 
3352
+
3353
+ /************************************************************
3354
+ * THE CAMERA IS THE READER'S.
3355
+ *
3356
+ * Once somebody has chosen a zoom, nothing but a camera
3357
+ * command may change it -- not a refresh, not a change of node
3358
+ * mode, not a new main topic. Those rebuild the CONTENT, and
3359
+ * the content moving is no reason to move the reader.
3360
+ *
3361
+ * Two mechanisms, because they answer two different questions:
3362
+ *
3363
+ * - across a rebuild in the same view, the anchor pair
3364
+ * (`yui_graph_viewport_of` / `yui_graph_place_at`) keeps a
3365
+ * NODE at the pixel it was on, which survives a relayout
3366
+ * that moves everything;
3367
+ * - across a RELOAD there is no node to hold on to yet, so
3368
+ * the viewport itself is restored -- zoom and position, as
3369
+ * G6 reports them.
3370
+ ************************************************************/
3371
+ const CAMERA_SETTLE_MS = 700;
3372
+
3373
+ function read_camera(gobj)
3374
+ {
3375
+ let priv = gobj.priv;
3376
+ let graph = priv.graph;
3377
+
3378
+ if(!graph || typeof graph.getZoom !== "function" || typeof graph.getPosition !== "function") {
3379
+ return null;
3380
+ }
3381
+ try {
3382
+ let z = graph.getZoom();
3383
+ let p = graph.getPosition();
3384
+ if(!is_number(z) || !p || !is_number(p[0]) || !is_number(p[1])) {
3385
+ return null;
3386
+ }
3387
+ return {zoom: z, x: p[0], y: p[1]};
3388
+ } catch(e) {
3389
+ return null; /* between renders: there is no camera to read */
3390
+ }
3391
+ }
3392
+
3393
+ /* Returns true when it placed the viewport, so the caller knows
3394
+ * not to fit. */
3395
+ async function restore_camera(gobj)
3396
+ {
3397
+ let priv = gobj.priv;
3398
+ let graph = priv.graph;
3399
+ let cam = gobj_read_attr(gobj, "camera");
3400
+
3401
+ if(!graph || empty_json(cam) || !is_number(cam.zoom)) {
3402
+ return false;
3403
+ }
3404
+ priv._camera_restoring = true;
3405
+ try {
3406
+ await graph.zoomTo(cam.zoom);
3407
+ if(is_number(cam.x) && is_number(cam.y)) {
3408
+ await graph.translateTo([cam.x, cam.y]);
3409
+ }
3410
+ return true;
3411
+ } catch(e) {
3412
+ log_error(`${gobj_short_name(gobj)}: cannot restore the camera: ${e}`);
3413
+ return false;
3414
+ } finally {
3415
+ priv._camera_restoring = false;
3416
+ }
3417
+ }
3418
+
3419
+ /************************************************************
3420
+ * A move settled: tell whoever persists it.
3421
+ *
3422
+ * Debounced on a REAL time and with the browser's timer: a
3423
+ * wheel notch, a pinch and a drag each fire `aftertransform`
3424
+ * many times, and what is worth saving is where the gesture
3425
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
3426
+ * the global -- this module imports neither.)
3427
+ ************************************************************/
3428
+ function publish_camera_later(gobj)
3429
+ {
3430
+ let priv = gobj.priv;
3431
+
3432
+ if(priv._camera_restoring || !priv._camera_placed) {
3433
+ return; /* our own move, or the opening one */
3434
+ }
3435
+ if(priv._camera_timer) {
3436
+ clearTimeout(priv._camera_timer);
3437
+ }
3438
+ priv._camera_timer = setTimeout(() => {
3439
+ priv._camera_timer = null;
3440
+ if(gobj_is_destroying(gobj)) {
3441
+ return;
3442
+ }
3443
+ let cam = read_camera(gobj);
3444
+ if(!cam) {
3445
+ return; /* Error already logged, or nothing to read */
3446
+ }
3447
+ gobj_write_attr(gobj, "camera", cam);
3448
+ gobj_publish_event(gobj, "EV_CAMERA_CHANGED", cam);
3449
+ }, CAMERA_SETTLE_MS);
3450
+ }
3451
+
3332
3452
  /************************************************************
3333
3453
  * Fit the graph in the viewport, but never zoom out past the point
3334
3454
  * where nothing on a card can be read.
@@ -9241,11 +9361,26 @@ async function reconcile_fold_apply(gobj, opts, visible)
9241
9361
  await graph_layout(gobj);
9242
9362
  }
9243
9363
 
9244
- if(opts.fit) {
9245
- await graph_fit_readable(gobj);
9246
- } else if(opts.keep && opts.keep_vp) {
9247
- await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
9248
- }
9364
+ /* THE VIEWPORT, and only here.
9365
+ *
9366
+ * `restore` is the reader's saved camera coming back on the
9367
+ * first draw after a reload; `fit` is the opening frame of a
9368
+ * graph nobody has looked at yet; `keep` holds a node at the
9369
+ * pixel it was on across a rebuild. Anything else leaves the
9370
+ * camera alone, which is the default on purpose: a rebuild of
9371
+ * the CONTENT is not a reason to move the reader. */
9372
+ if(!(opts.restore && await restore_camera(gobj))) {
9373
+ if(opts.fit) {
9374
+ await graph_fit_readable(gobj);
9375
+ } else if(opts.keep && opts.keep_vp) {
9376
+ await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
9377
+ }
9378
+ }
9379
+ /* Whichever branch ran -- including none of them, which is a
9380
+ * graph somebody had arranged and left where it was -- the
9381
+ * opening viewport is decided from here on, and every move after
9382
+ * it is the reader's and worth remembering. */
9383
+ priv._camera_placed = true;
9249
9384
 
9250
9385
  update_resize_handles_position(gobj);
9251
9386
  update_port_resize_handles_position(gobj);
@@ -9484,10 +9619,23 @@ function ac_load_data(gobj, event, kw, src)
9484
9619
  * nodes are about to be built: settle it first. */
9485
9620
  apply_layout_direction(gobj);
9486
9621
 
9487
- /* A graph laid out by us opens WHOLE (fit). Only when WE laid
9488
- * it out — a saved arrangement includes where its owner was
9489
- * looking. */
9490
- reconcile_fold(gobj, {relayout: true, fit: auto_laid}).then(() => {
9622
+ /* THE FIRST load decides the viewport: the reader's saved
9623
+ * camera if there is one, else the opening fit -- and only
9624
+ * when WE laid the graph out, since a saved arrangement
9625
+ * includes where its owner was looking.
9626
+ *
9627
+ * Every load AFTER that is a REFRESH, and a refresh must not
9628
+ * move the camera: the reader asked for fresh data, not for a
9629
+ * new viewport. It holds a node instead, so the graph does not
9630
+ * jump even if the layout changed under it. */
9631
+ let vp_opts;
9632
+ if(!priv._camera_placed) {
9633
+ vp_opts = {restore: true, fit: auto_laid};
9634
+ } else {
9635
+ let keep = fold_keep_node(gobj);
9636
+ vp_opts = {keep: keep, keep_vp: yui_graph_viewport_of(priv.graph, keep)};
9637
+ }
9638
+ reconcile_fold(gobj, Object.assign({relayout: true}, vp_opts)).then(() => {
9491
9639
  sync_history_plugin(gobj);
9492
9640
  /* Nodes exist and are positioned now: apply a focus
9493
9641
  * requested before the data was ready (deep link). */
@@ -10160,12 +10308,18 @@ function ac_set_main_topic(gobj, event, kw, src)
10160
10308
  if(!priv._fold_model) {
10161
10309
  return 0;
10162
10310
  }
10311
+ /* Read BEFORE the model is rebuilt: the node is looked up in the
10312
+ * tree that is on screen. If the new trunk leaves it out,
10313
+ * `yui_graph_place_at` finds nothing and the camera simply stays
10314
+ * where it was -- which is still not a refit. */
10315
+ let keep = fold_keep_node(gobj);
10316
+ let vp = yui_graph_viewport_of(priv.graph, keep);
10163
10317
  rebuild_fold_model(gobj);
10164
10318
  fold_expand_to_depth(
10165
10319
  priv._fold_model, priv._fold_state,
10166
10320
  gobj_read_integer_attr(gobj, "expand_depth")
10167
10321
  );
10168
- reconcile_fold(gobj, {relayout: true, fit: true});
10322
+ reconcile_fold(gobj, {relayout: true, keep: keep, keep_vp: vp});
10169
10323
  return 0;
10170
10324
  }
10171
10325
 
@@ -10200,9 +10354,15 @@ function ac_set_node_mode(gobj, event, kw, src)
10200
10354
  if(!priv.graph || !priv._fold_model) {
10201
10355
  return 0; /* applied when the records arrive */
10202
10356
  }
10357
+ /* The camera is the reader's: a square and a card do not occupy
10358
+ * the same room, so the graph is laid out again -- and the node
10359
+ * being read stays at its pixel, at the zoom it was chosen at,
10360
+ * instead of the whole graph being fitted from scratch. */
10361
+ let keep = fold_keep_node(gobj);
10362
+ let vp = yui_graph_viewport_of(priv.graph, keep);
10203
10363
  reshape_nodes(gobj, all_record_node_ids(gobj));
10204
10364
  reshape_more_chips(gobj);
10205
- reconcile_fold(gobj, {relayout: true, fit: true});
10365
+ reconcile_fold(gobj, {relayout: true, keep: keep, keep_vp: vp});
10206
10366
  return 0;
10207
10367
  }
10208
10368
 
@@ -10898,6 +11058,9 @@ function create_gclass(gclass_name)
10898
11058
  ["EV_FIND_NODES", 0],
10899
11059
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
10900
11060
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
11061
+ /* Optional: a host that does not persist the viewport is not
11062
+ * doing anything wrong, so no "publish without subscribers". */
11063
+ ["EV_CAMERA_CHANGED", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_NO_WARN_SUBS],
10901
11064
  ["EV_TOGGLE_FOLD", 0],
10902
11065
  ["EV_SHOW_MORE", 0],
10903
11066
  ["EV_EXPAND_ALL", 0],
@@ -404,7 +404,7 @@ function apply_view_mode(gobj)
404
404
 
405
405
  if(priv.$mode_btns) {
406
406
  for(let [m, $btn] of priv.$mode_btns) {
407
- $btn.classList.toggle('pressed_state', m === mode);
407
+ $btn.classList.toggle('selected_state', m === mode);
408
408
  $btn.setAttribute("aria-pressed", (m === mode)? "true": "false");
409
409
  }
410
410
  }
@@ -983,7 +983,7 @@ function machine_view_switch(gobj)
983
983
  let on = (priv.machine_view === v.mode);
984
984
  return ['button', {class: 'button is-small GCLASS_MACHINE_VIEW ' +
985
985
  `GCLASS_MACHINE_VIEW_${v.mode.toUpperCase()}` +
986
- (on? ' pressed_state': ''),
986
+ (on? ' selected_state': ''),
987
987
  type: 'button',
988
988
  'aria-pressed': on? 'true': 'false',
989
989
  title: t(v.key), 'data-i18n-title': v.key,
@@ -283,14 +283,14 @@
283
283
  margin-bottom: 0;
284
284
  }
285
285
 
286
- /* The current view is marked with `pressed_state` (`yui_toolbar.css`),
287
- * which is the library's one way of saying a toggle is ON. This switch
288
- * used to fill itself with `--bulma-link`: right about Bulma's own
289
- * `is-active` being too weak to say WHICH view you are looking at, and
290
- * wrong about the answer -- a colour names a kind of ACTION, and this
291
- * is a STATE. `z-index` is all that is left, so the marked button's
292
- * border wins over its neighbours' in the `has-addons` group. */
293
- .C_YUI_JSON .JSON_VIEW_MODE.pressed_state {
286
+ /* The current view is marked with `selected_state` (`yui_toolbar.css`):
287
+ * the library's one way of saying WHICH segment of a selector is
288
+ * chosen. This switch used to write that fill here itself -- the right
289
+ * idea in the wrong place, since the same question is answered by the
290
+ * graph's node modes, the map's modes and the period's granularities.
291
+ * `z-index` is all that is left, so the marked button's border wins
292
+ * over its neighbours' in the `has-addons` group. */
293
+ .C_YUI_JSON .JSON_VIEW_MODE.selected_state {
294
294
  z-index: 1;
295
295
  }
296
296
 
package/src/c_yui_json.js CHANGED
@@ -442,12 +442,11 @@ function icon_button(gobj, icon, event_name, label_key)
442
442
  * aimed — reaching the graph from the tree would mean passing
443
443
  * through the text and rebuilding it on the way — so each view gets
444
444
  * its own button and says where it goes. apply_view_mode() moves
445
- * the `pressed_state` mark -- the library's one way of saying a
446
- * toggle is ON, the same the graph toolbars and the treedb legend
447
- * use. It used to be `is-active` with a blue fill of its own: a
448
- * STATE wearing a palette colour, which is what the palette does
449
- * NOT mean (it names kinds of ACTION), and a third spelling of a
450
- * thing the library already had a name for.
445
+ * the `selected_state` mark -- the library's one way of saying
446
+ * WHICH segment of a selector is chosen, filled with the link
447
+ * colour. It used to be `is-active` with a fill written here: the
448
+ * right idea, spelled a third time. A toggle, which has no "which
449
+ * one" to answer, wears the neutral `pressed_state` pill instead.
451
450
  ************************************************************/
452
451
  function view_mode_switch(gobj)
453
452
  {
@@ -558,7 +557,7 @@ function apply_view_mode(gobj)
558
557
 
559
558
  if(priv.$mode_btns) {
560
559
  for(let [m, $btn] of priv.$mode_btns) {
561
- $btn.classList.toggle('pressed_state', m === mode);
560
+ $btn.classList.toggle('selected_state', m === mode);
562
561
  $btn.setAttribute("aria-pressed", (m === mode)? "true": "false");
563
562
  }
564
563
  }
@@ -96,8 +96,9 @@ import {
96
96
 
97
97
  import i18next, {t} from "i18next";
98
98
 
99
- /* `yui_toolbar.css` for `pressed_state`: the mark of a toggle that
100
- * is ON lives with the toolbar, and a stylesheet rides its import. */
99
+ /* `yui_toolbar.css` for `selected_state`: the mark of the chosen
100
+ * segment of a selector lives with the toolbar, and a stylesheet
101
+ * rides its import. */
101
102
  import "./yui_toolbar.css";
102
103
  import "./c_yui_period.css";
103
104
 
@@ -702,13 +703,12 @@ function repaint(gobj)
702
703
  let priv = gobj.priv;
703
704
  let mode = cur_mode(gobj);
704
705
 
705
- /* The granularity in use looks PRESSED, like every other toggle of
706
- * the library (`pressed_state`, `yui_toolbar.css`). It used to be
707
- * `is-active` plus an `is-link` fill: a STATE painted with a colour,
708
- * and colours here name kinds of ACTION. An overflow granularity is
709
- * a `dropdown-item` and not a button, and there `is-active` is
710
- * Bulma's own mark for the current item of a menu -- which is
711
- * exactly what it is. */
706
+ /* This is a SELECTOR -- one granularity of several -- so the
707
+ * chosen segment is FILLED (`selected_state`, `yui_toolbar.css`),
708
+ * the same mark the graph's node modes and the map's modes wear.
709
+ * An overflow granularity is a `dropdown-item` and not a button,
710
+ * and there `is-active` is Bulma's own mark for the current item
711
+ * of a menu -- which is exactly what it is. */
712
712
  for(let m of priv.modes) {
713
713
  if(!m.$btn) {
714
714
  continue;
@@ -724,7 +724,7 @@ function repaint(gobj)
724
724
  m.$btn.removeAttribute("aria-current");
725
725
  }
726
726
  } else {
727
- m.$btn.classList.toggle("pressed_state", on);
727
+ m.$btn.classList.toggle("selected_state", on);
728
728
  m.$btn.setAttribute("aria-pressed", on? "true" : "false");
729
729
  }
730
730
  }
@@ -735,7 +735,7 @@ function repaint(gobj)
735
735
  /* An overflow granularity in use must SAY so on the trigger: the
736
736
  * control shows no active segment otherwise, and the user cannot
737
737
  * tell a quarter from a month by the arrows alone. */
738
- priv.$more.classList.toggle("pressed_state", !!(mode && mode.overflow));
738
+ priv.$more.classList.toggle("selected_state", !!(mode && mode.overflow));
739
739
  }
740
740
 
741
741
  /* The navigator STAYS, disabled, in the modes with nothing to walk: it
@@ -1013,10 +1013,10 @@ function cell_button(gobj, label, anchor_ms, selected, logical, aria)
1013
1013
  * year around it. */
1014
1014
  let $btn = createElement2(
1015
1015
  /* The ONE place `is-small` is earned: 42 cells in a popover. */
1016
- /* The picked instant is PRESSED, like every other mark of the
1017
- * library -- it used to be an `is-link` fill, a colour on a
1018
- * state. `aria-current="date"` is the same fact for a reader. */
1019
- ["button", {class: `button is-small ${selected? "pressed_state" : "is-ghost"} ${logical}`,
1016
+ /* The picked instant is the chosen one of a grid of 42, which
1017
+ * is the selector's question and not a toggle's, so it takes
1018
+ * the same fill. `aria-current="date"` says it to a reader. */
1019
+ ["button", {class: `button is-small ${selected? "selected_state" : "is-ghost"} ${logical}`,
1020
1020
  type: "button", style: "width:100%;",
1021
1021
  ...(selected? {"aria-current": "date"} : {}),
1022
1022
  title: aria || "", "aria-label": aria || ""}, String(label)]);
@@ -136,6 +136,7 @@ SDATA(data_type_t.DTP_STRING, "main_topic", sdata_flag_t.SDF_PERSIST, ""
136
136
  SDATA(data_type_t.DTP_LIST, "loose_topics", sdata_flag_t.SDF_PERSIST, "[]", "Topics whose LOOSE records (no parent) are shown. User preference, per treedb"),
137
137
  SDATA(data_type_t.DTP_STRING, "node_mode", sdata_flag_t.SDF_PERSIST, "expanded", "How the records are drawn: `expanded` = the cards with their pills and ports, `compact` = one-line pills with the name, `shape` = coloured figures, the topology alone. User preference"),
138
138
  SDATA(data_type_t.DTP_BOOLEAN, "node_labels", sdata_flag_t.SDF_PERSIST, true, "A `shape` node says its name under its figure. User preference"),
139
+ SDATA(data_type_t.DTP_DICT, "camera", sdata_flag_t.SDF_PERSIST, "{}", "Where the reader left the viewport: {zoom, x, y}. Handed to the engine on creation and written back when a move settles, so the graph opens where it was left. User preference, per treedb"),
139
140
 
140
141
  /*---------------- Remote Connection ----------------*/
141
142
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
@@ -281,6 +282,7 @@ function mt_create(gobj)
281
282
  loose_topics: gobj_read_attr(gobj, "loose_topics") || [],
282
283
  node_mode: gobj_read_str_attr(gobj, "node_mode") || "expanded",
283
284
  node_labels: gobj_read_bool_attr(gobj, "node_labels"),
285
+ camera: gobj_read_attr(gobj, "camera") || {},
284
286
  },
285
287
  gobj
286
288
  );
@@ -652,9 +654,13 @@ function refresh_node_shape_buttons(gobj, $root)
652
654
  mode = 'expanded';
653
655
  }
654
656
  let labels = gobj_read_bool_attr(gobj, "node_labels");
657
+ /* A SELECTOR, not a toggle: it picks one of three views, so the
658
+ * chosen segment is FILLED (`selected_state`) and not drawn as a
659
+ * pressed pill. The labels button below IS a toggle -- on or off,
660
+ * no "which one" -- and keeps the pill. */
655
661
  for(let $btn of $root.querySelectorAll('.GRAPH_NODE_MODE_BTN')) {
656
662
  let on = ($btn.getAttribute('data-mode') === mode);
657
- $btn.classList.toggle('pressed_state', on);
663
+ $btn.classList.toggle('selected_state', on);
658
664
  $btn.setAttribute('aria-pressed', on? 'true' : 'false');
659
665
  }
660
666
  set_pressed_state($root, '.GRAPH_NODE_LABELS', labels);
@@ -2884,6 +2890,23 @@ function ac_layout_autoset(gobj, event, kw, src)
2884
2890
  return 0;
2885
2891
  }
2886
2892
 
2893
+ /************************************************************
2894
+ * The reader moved the camera, and it has settled.
2895
+ *
2896
+ * Persisted like every other choice of this toolbar, and for the
2897
+ * same reason: a zoom is a decision, and a decision that dies with
2898
+ * the tab has to be taken again every morning. The engine debounces,
2899
+ * so this runs once per gesture and not once per wheel notch.
2900
+ ************************************************************/
2901
+ function ac_camera_changed(gobj, event, kw, src)
2902
+ {
2903
+ gobj_write_attr(gobj, "camera", {
2904
+ zoom: kw.zoom, x: kw.x, y: kw.y
2905
+ });
2906
+ gobj_save_persistent_attrs(gobj, "camera");
2907
+ return 0;
2908
+ }
2909
+
2887
2910
  /************************************************************
2888
2911
  * Forward the find down to the graph child.
2889
2912
  ************************************************************/
@@ -3124,6 +3147,7 @@ function create_gclass(gclass_name)
3124
3147
  ["EV_SET_NODE_MODE", ac_set_node_mode, null],
3125
3148
  ["EV_TOGGLE_NODE_LABELS", ac_toggle_node_labels, null],
3126
3149
  ["EV_LAYOUT_AUTOSET", ac_layout_autoset, null],
3150
+ ["EV_CAMERA_CHANGED", ac_camera_changed, null],
3127
3151
  ["EV_LEGEND_TOPIC", ac_legend_topic, null],
3128
3152
  ["EV_LEGEND_STATE", ac_legend_state, null],
3129
3153
  ["EV_FIND_RESULT", ac_find_result, null],
@@ -3169,6 +3193,7 @@ function create_gclass(gclass_name)
3169
3193
  ["EV_TOGGLE_NODE_LABELS", 0],
3170
3194
  ["EV_FIND_RESULT", 0],
3171
3195
  ["EV_LAYOUT_AUTOSET", 0],
3196
+ ["EV_CAMERA_CHANGED", 0],
3172
3197
  ["EV_LEGEND_TOPIC", 0],
3173
3198
  ["EV_LEGEND_STATE", 0],
3174
3199
  ["EV_TOPIC_SELECTED",
@@ -110,3 +110,36 @@ a.flex-horizontal-section { /* To make flex any <a> */
110
110
  color: var(--bulma-background, #ffffff);
111
111
  border-color: var(--bulma-text, #2e333d);
112
112
  }
113
+
114
+ /*********************************************************
115
+ * The CHOSEN segment of a selector is FILLED
116
+ *
117
+ * Two different controls, two different marks, and telling them apart
118
+ * is the whole point:
119
+ *
120
+ * - a TOGGLE is on or off, and looks PRESSED (above) -- a neutral
121
+ * pill, no hue, because there is no "which one" to answer;
122
+ * - a SELECTOR picks ONE of N, and the chosen segment is FILLED with
123
+ * the link colour. A hue is what the eye finds in a row of
124
+ * identical grey buttons: measured against the button's own ground
125
+ * the fill is 5.14:1 in light and 3.53:1 in dark -- less luminance
126
+ * than the pressed pill (9.44 / 8.46) and a different HUE, which
127
+ * is what says "this one, of these".
128
+ *
129
+ * The raw `--bulma-link` and not `-on-scheme`: this is a FILL. The
130
+ * `-on-scheme` variants exist for a brand colour used as INK -- they
131
+ * darken it on a light scheme so text can sit on the page -- and a
132
+ * darkened fill under `link-invert` text is a button nobody asked for.
133
+ * Ink takes `-on-scheme`; a background takes the raw token and pairs
134
+ * it with `-invert`.
135
+ *
136
+ * `:hover` for the same reason as the pressed rule: without it, Bulma's
137
+ * `.button:hover` takes the fill back and the chosen segment appears to
138
+ * come unchosen under the pointer.
139
+ *********************************************************/
140
+ .button.selected_state,
141
+ .button.selected_state:hover {
142
+ background-color: var(--bulma-link, #3273dc);
143
+ border-color: var(--bulma-link, #3273dc);
144
+ color: var(--bulma-link-invert, #fff);
145
+ }
@@ -186,4 +186,25 @@ function set_pressed_state($container, selector, set)
186
186
  });
187
187
  }
188
188
 
189
- export {yui_toolbar, yui_toolbar_icon, YUI_TOOLBAR_ICON_SIZE, set_pressed_state};
189
+ /************************************************************
190
+ * Set or reset the CHOSEN state of one segment of a selector.
191
+ *
192
+ * Not the same control as a toggle, and not the same mark: a
193
+ * toggle is on or off and looks pressed (a neutral pill, no
194
+ * hue, because there is no "which one" to answer); a selector
195
+ * picks ONE of N and fills the chosen segment with the link
196
+ * colour, which is what the eye finds in a row of identical
197
+ * grey buttons. See `yui_toolbar.css` for the measurements.
198
+ ************************************************************/
199
+ function set_selected_state($container, selector, set)
200
+ {
201
+ if(!$container) {
202
+ return;
203
+ }
204
+ $container.querySelectorAll(selector).forEach(($el) => {
205
+ $el.classList.toggle("selected_state", !!set);
206
+ });
207
+ }
208
+
209
+ export {yui_toolbar, yui_toolbar_icon, YUI_TOOLBAR_ICON_SIZE,
210
+ set_pressed_state, set_selected_state};