@yuneta/gobj-ui 7.23.103 → 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.
@@ -54825,6 +54825,7 @@ var attrs_table$4 = [
54825
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"),
54826
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"),
54827
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"),
54828
54829
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "wide", 0, "40px", "Height of header"),
54829
54830
  (0, _yuneta_gobj_js.SDATA_END)()
54830
54831
  ];
@@ -54832,6 +54833,9 @@ var PRIVATE_DATA$4 = {
54832
54833
  anchor_id: "",
54833
54834
  anchor_state: "off",
54834
54835
  last_zoom: 1,
54836
+ _camera_placed: false,
54837
+ _camera_restoring: false,
54838
+ _camera_timer: null,
54835
54839
  _xy: 100,
54836
54840
  _edge_seq: 0,
54837
54841
  _history_paused: false,
@@ -54963,8 +54967,13 @@ function mt_start$4(gobj) {
54963
54967
  * Framework Method: Stop
54964
54968
  ***************************************************************/
54965
54969
  function mt_stop$4(gobj) {
54970
+ let priv = gobj.priv;
54966
54971
  let shell = yui_shell_of(gobj);
54967
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
+ }
54968
54977
  return 0;
54969
54978
  }
54970
54979
  /***************************************************************
@@ -55156,6 +55165,7 @@ function configure_events(gobj) {
55156
55165
  update_link_icon_position(gobj);
55157
55166
  update_port_icon_position(gobj);
55158
55167
  update_zoom_readout(gobj);
55168
+ publish_camera_later(gobj);
55159
55169
  let z = priv.last_zoom;
55160
55170
  try {
55161
55171
  z = priv.graph.getZoom();
@@ -56681,6 +56691,80 @@ async function graph_fitview(gobj) {
56681
56691
  }
56682
56692
  var MIN_READABLE_ZOOM = .5;
56683
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
+ /************************************************************
56684
56768
  * Fit the graph in the viewport, but never zoom out past the point
56685
56769
  * where nothing on a card can be read.
56686
56770
  *
@@ -60624,8 +60708,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60624
60708
  }
60625
60709
  await graph_draw(gobj);
60626
60710
  if (changed || opts.relayout) await graph_layout(gobj);
60627
- if (opts.fit) await graph_fit_readable(gobj);
60628
- 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;
60629
60716
  update_resize_handles_position(gobj);
60630
60717
  update_port_resize_handles_position(gobj);
60631
60718
  update_edge_icon_position(gobj);
@@ -60777,10 +60864,19 @@ function ac_load_data(gobj, event, kw, src) {
60777
60864
  (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: auto_layout failed: ${e}`);
60778
60865
  }
60779
60866
  apply_layout_direction(gobj);
60780
- reconcile_fold(gobj, {
60781
- relayout: true,
60867
+ let vp_opts;
60868
+ if (!priv._camera_placed) vp_opts = {
60869
+ restore: true,
60782
60870
  fit: auto_laid
60783
- }).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(() => {
60784
60880
  sync_history_plugin(gobj);
60785
60881
  if (priv._pending_focus_topic !== null) {
60786
60882
  let ft = priv._pending_focus_topic;
@@ -61240,11 +61336,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61240
61336
  let priv = gobj.priv;
61241
61337
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "main_topic", kw && kw.topic || "");
61242
61338
  if (!priv._fold_model) return 0;
61339
+ let keep = fold_keep_node(gobj);
61340
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61243
61341
  rebuild_fold_model(gobj);
61244
61342
  fold_expand_to_depth(priv._fold_model, priv._fold_state, (0, _yuneta_gobj_js.gobj_read_integer_attr)(gobj, "expand_depth"));
61245
61343
  reconcile_fold(gobj, {
61246
61344
  relayout: true,
61247
- fit: true
61345
+ keep,
61346
+ keep_vp: vp
61248
61347
  });
61249
61348
  return 0;
61250
61349
  }
@@ -61270,11 +61369,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61270
61369
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "node_mode", mode);
61271
61370
  priv._open_nodes = /* @__PURE__ */ new Set();
61272
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);
61273
61374
  reshape_nodes(gobj, all_record_node_ids(gobj));
61274
61375
  reshape_more_chips(gobj);
61275
61376
  reconcile_fold(gobj, {
61276
61377
  relayout: true,
61277
- fit: true
61378
+ keep,
61379
+ keep_vp: vp
61278
61380
  });
61279
61381
  return 0;
61280
61382
  }
@@ -61944,6 +62046,7 @@ function create_gclass$4(gclass_name) {
61944
62046
  ["EV_FIND_NODES", 0],
61945
62047
  ["EV_FIND_RESULT", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
61946
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],
61947
62050
  ["EV_TOGGLE_FOLD", 0],
61948
62051
  ["EV_SHOW_MORE", 0],
61949
62052
  ["EV_EXPAND_ALL", 0],
@@ -62030,6 +62133,7 @@ var attrs_table$3 = [
62030
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"),
62031
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"),
62032
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"),
62033
62137
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62034
62138
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62035
62139
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62117,7 +62221,8 @@ function mt_create$3(gobj) {
62117
62221
  main_topic: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "main_topic") || "",
62118
62222
  loose_topics: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "loose_topics") || [],
62119
62223
  node_mode: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "node_mode") || "expanded",
62120
- 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") || {}
62121
62226
  }, gobj);
62122
62227
  populate_nodes_tree_options(gobj);
62123
62228
  if (priv.with_treedb_tables) {}
@@ -63801,6 +63906,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63801
63906
  return 0;
63802
63907
  }
63803
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
+ /************************************************************
63804
63926
  * Forward the find down to the graph child.
63805
63927
  ************************************************************/
63806
63928
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64080,6 +64202,11 @@ function create_gclass$3(gclass_name) {
64080
64202
  ac_layout_autoset,
64081
64203
  null
64082
64204
  ],
64205
+ [
64206
+ "EV_CAMERA_CHANGED",
64207
+ ac_camera_changed,
64208
+ null
64209
+ ],
64083
64210
  [
64084
64211
  "EV_LEGEND_TOPIC",
64085
64212
  ac_legend_topic,
@@ -64148,6 +64275,7 @@ function create_gclass$3(gclass_name) {
64148
64275
  ["EV_TOGGLE_NODE_LABELS", 0],
64149
64276
  ["EV_FIND_RESULT", 0],
64150
64277
  ["EV_LAYOUT_AUTOSET", 0],
64278
+ ["EV_CAMERA_CHANGED", 0],
64151
64279
  ["EV_LEGEND_TOPIC", 0],
64152
64280
  ["EV_LEGEND_STATE", 0],
64153
64281
  ["EV_TOPIC_SELECTED", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
@@ -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";
@@ -54820,6 +54820,7 @@ var attrs_table$4 = [
54820
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"),
54821
54821
  SDATA(data_type_t.DTP_BOOLEAN, "confirm_delete_node", 0, true, "Ask confirmation before deleting a node"),
54822
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"),
54823
54824
  SDATA(data_type_t.DTP_STRING, "wide", 0, "40px", "Height of header"),
54824
54825
  SDATA_END()
54825
54826
  ];
@@ -54827,6 +54828,9 @@ var PRIVATE_DATA$4 = {
54827
54828
  anchor_id: "",
54828
54829
  anchor_state: "off",
54829
54830
  last_zoom: 1,
54831
+ _camera_placed: false,
54832
+ _camera_restoring: false,
54833
+ _camera_timer: null,
54830
54834
  _xy: 100,
54831
54835
  _edge_seq: 0,
54832
54836
  _history_paused: false,
@@ -54958,8 +54962,13 @@ function mt_start$4(gobj) {
54958
54962
  * Framework Method: Stop
54959
54963
  ***************************************************************/
54960
54964
  function mt_stop$4(gobj) {
54965
+ let priv = gobj.priv;
54961
54966
  let shell = yui_shell_of(gobj);
54962
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
+ }
54963
54972
  return 0;
54964
54973
  }
54965
54974
  /***************************************************************
@@ -55151,6 +55160,7 @@ function configure_events(gobj) {
55151
55160
  update_link_icon_position(gobj);
55152
55161
  update_port_icon_position(gobj);
55153
55162
  update_zoom_readout(gobj);
55163
+ publish_camera_later(gobj);
55154
55164
  let z = priv.last_zoom;
55155
55165
  try {
55156
55166
  z = priv.graph.getZoom();
@@ -56676,6 +56686,80 @@ async function graph_fitview(gobj) {
56676
56686
  }
56677
56687
  var MIN_READABLE_ZOOM = .5;
56678
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
+ /************************************************************
56679
56763
  * Fit the graph in the viewport, but never zoom out past the point
56680
56764
  * where nothing on a card can be read.
56681
56765
  *
@@ -60619,8 +60703,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60619
60703
  }
60620
60704
  await graph_draw(gobj);
60621
60705
  if (changed || opts.relayout) await graph_layout(gobj);
60622
- if (opts.fit) await graph_fit_readable(gobj);
60623
- 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;
60624
60711
  update_resize_handles_position(gobj);
60625
60712
  update_port_resize_handles_position(gobj);
60626
60713
  update_edge_icon_position(gobj);
@@ -60772,10 +60859,19 @@ function ac_load_data(gobj, event, kw, src) {
60772
60859
  log_error(`${gobj_short_name(gobj)}: auto_layout failed: ${e}`);
60773
60860
  }
60774
60861
  apply_layout_direction(gobj);
60775
- reconcile_fold(gobj, {
60776
- relayout: true,
60862
+ let vp_opts;
60863
+ if (!priv._camera_placed) vp_opts = {
60864
+ restore: true,
60777
60865
  fit: auto_laid
60778
- }).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(() => {
60779
60875
  sync_history_plugin(gobj);
60780
60876
  if (priv._pending_focus_topic !== null) {
60781
60877
  let ft = priv._pending_focus_topic;
@@ -61235,11 +61331,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61235
61331
  let priv = gobj.priv;
61236
61332
  gobj_write_str_attr(gobj, "main_topic", kw && kw.topic || "");
61237
61333
  if (!priv._fold_model) return 0;
61334
+ let keep = fold_keep_node(gobj);
61335
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61238
61336
  rebuild_fold_model(gobj);
61239
61337
  fold_expand_to_depth(priv._fold_model, priv._fold_state, gobj_read_integer_attr(gobj, "expand_depth"));
61240
61338
  reconcile_fold(gobj, {
61241
61339
  relayout: true,
61242
- fit: true
61340
+ keep,
61341
+ keep_vp: vp
61243
61342
  });
61244
61343
  return 0;
61245
61344
  }
@@ -61265,11 +61364,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61265
61364
  gobj_write_str_attr(gobj, "node_mode", mode);
61266
61365
  priv._open_nodes = /* @__PURE__ */ new Set();
61267
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);
61268
61369
  reshape_nodes(gobj, all_record_node_ids(gobj));
61269
61370
  reshape_more_chips(gobj);
61270
61371
  reconcile_fold(gobj, {
61271
61372
  relayout: true,
61272
- fit: true
61373
+ keep,
61374
+ keep_vp: vp
61273
61375
  });
61274
61376
  return 0;
61275
61377
  }
@@ -61939,6 +62041,7 @@ function create_gclass$4(gclass_name) {
61939
62041
  ["EV_FIND_NODES", 0],
61940
62042
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
61941
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],
61942
62045
  ["EV_TOGGLE_FOLD", 0],
61943
62046
  ["EV_SHOW_MORE", 0],
61944
62047
  ["EV_EXPAND_ALL", 0],
@@ -62025,6 +62128,7 @@ var attrs_table$3 = [
62025
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"),
62026
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"),
62027
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"),
62028
62132
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62029
62133
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62030
62134
  SDATA(data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62112,7 +62216,8 @@ function mt_create$3(gobj) {
62112
62216
  main_topic: gobj_read_str_attr(gobj, "main_topic") || "",
62113
62217
  loose_topics: gobj_read_attr(gobj, "loose_topics") || [],
62114
62218
  node_mode: gobj_read_str_attr(gobj, "node_mode") || "expanded",
62115
- 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") || {}
62116
62221
  }, gobj);
62117
62222
  populate_nodes_tree_options(gobj);
62118
62223
  if (priv.with_treedb_tables) {}
@@ -63796,6 +63901,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63796
63901
  return 0;
63797
63902
  }
63798
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
+ /************************************************************
63799
63921
  * Forward the find down to the graph child.
63800
63922
  ************************************************************/
63801
63923
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64075,6 +64197,11 @@ function create_gclass$3(gclass_name) {
64075
64197
  ac_layout_autoset,
64076
64198
  null
64077
64199
  ],
64200
+ [
64201
+ "EV_CAMERA_CHANGED",
64202
+ ac_camera_changed,
64203
+ null
64204
+ ],
64078
64205
  [
64079
64206
  "EV_LEGEND_TOPIC",
64080
64207
  ac_legend_topic,
@@ -64143,6 +64270,7 @@ function create_gclass$3(gclass_name) {
64143
64270
  ["EV_TOGGLE_NODE_LABELS", 0],
64144
64271
  ["EV_FIND_RESULT", 0],
64145
64272
  ["EV_LAYOUT_AUTOSET", 0],
64273
+ ["EV_CAMERA_CHANGED", 0],
64146
64274
  ["EV_LEGEND_TOPIC", 0],
64147
64275
  ["EV_LEGEND_STATE", 0],
64148
64276
  ["EV_TOPIC_SELECTED", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.103",
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],
@@ -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
  );
@@ -2888,6 +2890,23 @@ function ac_layout_autoset(gobj, event, kw, src)
2888
2890
  return 0;
2889
2891
  }
2890
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
+
2891
2910
  /************************************************************
2892
2911
  * Forward the find down to the graph child.
2893
2912
  ************************************************************/
@@ -3128,6 +3147,7 @@ function create_gclass(gclass_name)
3128
3147
  ["EV_SET_NODE_MODE", ac_set_node_mode, null],
3129
3148
  ["EV_TOGGLE_NODE_LABELS", ac_toggle_node_labels, null],
3130
3149
  ["EV_LAYOUT_AUTOSET", ac_layout_autoset, null],
3150
+ ["EV_CAMERA_CHANGED", ac_camera_changed, null],
3131
3151
  ["EV_LEGEND_TOPIC", ac_legend_topic, null],
3132
3152
  ["EV_LEGEND_STATE", ac_legend_state, null],
3133
3153
  ["EV_FIND_RESULT", ac_find_result, null],
@@ -3173,6 +3193,7 @@ function create_gclass(gclass_name)
3173
3193
  ["EV_TOGGLE_NODE_LABELS", 0],
3174
3194
  ["EV_FIND_RESULT", 0],
3175
3195
  ["EV_LAYOUT_AUTOSET", 0],
3196
+ ["EV_CAMERA_CHANGED", 0],
3176
3197
  ["EV_LEGEND_TOPIC", 0],
3177
3198
  ["EV_LEGEND_STATE", 0],
3178
3199
  ["EV_TOPIC_SELECTED",