@yuneta/gobj-ui 7.23.103 → 7.23.105

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,43 @@ 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** the same thing is saved and replayed: the zoom, a NODE,
966
+ and the viewport pixel that node was on. **Not G6's own position** — an
967
+ absolute `translateTo()` leaves the camera at `canvasCentre - T/zoom`, so
968
+ `getPosition()` and `translateTo()` are each other's inverse only at zoom 1,
969
+ and restoring one with the other brings the zoom back and puts the graph
970
+ somewhere else. That is the whole reason `yui_graph_place_at()` exists.
971
+
972
+ Wiring: `C_G6_NODES_TREE` takes a `camera` attr — the viewport to restore on its
973
+ FIRST draw, empty meaning *open fitted* — and publishes **`EV_CAMERA_CHANGED
974
+ {zoom, node, x, y}`** once a move has settled. A saved node this load does not
975
+ have leaves the graph to its opening fit, checked before the zoom is touched:
976
+ a zoom with no framing to go with it is worse than a fit. The settle is a real time (700 ms of
977
+ the browser's `setTimeout`, not a `C_TIMER`): a wheel notch, a pinch and a drag
978
+ each fire `aftertransform` many times, and what is worth saving is where the
979
+ gesture ENDED. It fires for a PAN as well as a zoom — a pan is the reader's just
980
+ as much. `C_YUI_TREEDB_GRAPH` holds the other half: `camera` is one more
981
+ `SDF_PERSIST` attr of the view, saved under its name like `main_topic`, so the
982
+ graph opens where that treedb was left.
983
+
984
+ A host that does not care persists nothing and loses nothing: the event carries
985
+ `EVF_NO_WARN_SUBS`, and the attr defaults to empty.
986
+
950
987
  ### Moving the cards, and folding without losing your place
951
988
 
952
989
  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, node, x, y} -- a NODE at a viewport pixel, not G6's own position, which does not restore. 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,83 @@ 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") return null;
56715
+ try {
56716
+ let z = graph.getZoom();
56717
+ let node = fold_keep_node(gobj);
56718
+ let vp = node ? yui_graph_viewport_of(graph, node) : null;
56719
+ if (!(0, _yuneta_gobj_js.is_number)(z) || !node || !vp || !(0, _yuneta_gobj_js.is_number)(vp[0]) || !(0, _yuneta_gobj_js.is_number)(vp[1])) return null;
56720
+ return {
56721
+ zoom: z,
56722
+ node,
56723
+ x: vp[0],
56724
+ y: vp[1]
56725
+ };
56726
+ } catch (e) {
56727
+ return null;
56728
+ }
56729
+ }
56730
+ async function restore_camera(gobj) {
56731
+ let priv = gobj.priv;
56732
+ let graph = priv.graph;
56733
+ let cam = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "camera");
56734
+ if (!graph || (0, _yuneta_gobj_js.empty_json)(cam) || !(0, _yuneta_gobj_js.is_number)(cam.zoom) || !cam.node || !(0, _yuneta_gobj_js.is_number)(cam.x) || !(0, _yuneta_gobj_js.is_number)(cam.y)) return false;
56735
+ if (!yui_graph_viewport_of(graph, cam.node)) return false;
56736
+ priv._camera_restoring = true;
56737
+ try {
56738
+ await graph.zoomTo(cam.zoom);
56739
+ await yui_graph_place_at(graph, cam.node, [cam.x, cam.y]);
56740
+ return true;
56741
+ } catch (e) {
56742
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot restore the camera: ${e}`);
56743
+ return false;
56744
+ } finally {
56745
+ priv._camera_restoring = false;
56746
+ }
56747
+ }
56748
+ /************************************************************
56749
+ * A move settled: tell whoever persists it.
56750
+ *
56751
+ * Debounced on a REAL time and with the browser's timer: a
56752
+ * wheel notch, a pinch and a drag each fire `aftertransform`
56753
+ * many times, and what is worth saving is where the gesture
56754
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
56755
+ * the global -- this module imports neither.)
56756
+ ************************************************************/
56757
+ function publish_camera_later(gobj) {
56758
+ let priv = gobj.priv;
56759
+ if (priv._camera_restoring || !priv._camera_placed) return;
56760
+ if (priv._camera_timer) clearTimeout(priv._camera_timer);
56761
+ priv._camera_timer = setTimeout(() => {
56762
+ priv._camera_timer = null;
56763
+ if ((0, _yuneta_gobj_js.gobj_is_destroying)(gobj)) return;
56764
+ let cam = read_camera(gobj);
56765
+ if (!cam) return;
56766
+ (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "camera", cam);
56767
+ (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_CAMERA_CHANGED", cam);
56768
+ }, CAMERA_SETTLE_MS);
56769
+ }
56770
+ /************************************************************
56684
56771
  * Fit the graph in the viewport, but never zoom out past the point
56685
56772
  * where nothing on a card can be read.
56686
56773
  *
@@ -60624,8 +60711,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60624
60711
  }
60625
60712
  await graph_draw(gobj);
60626
60713
  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);
60714
+ if (!(opts.restore && await restore_camera(gobj))) {
60715
+ if (opts.fit) await graph_fit_readable(gobj);
60716
+ else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60717
+ }
60718
+ priv._camera_placed = true;
60629
60719
  update_resize_handles_position(gobj);
60630
60720
  update_port_resize_handles_position(gobj);
60631
60721
  update_edge_icon_position(gobj);
@@ -60777,10 +60867,19 @@ function ac_load_data(gobj, event, kw, src) {
60777
60867
  (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: auto_layout failed: ${e}`);
60778
60868
  }
60779
60869
  apply_layout_direction(gobj);
60780
- reconcile_fold(gobj, {
60781
- relayout: true,
60870
+ let vp_opts;
60871
+ if (!priv._camera_placed) vp_opts = {
60872
+ restore: true,
60782
60873
  fit: auto_laid
60783
- }).then(() => {
60874
+ };
60875
+ else {
60876
+ let keep = fold_keep_node(gobj);
60877
+ vp_opts = {
60878
+ keep,
60879
+ keep_vp: yui_graph_viewport_of(priv.graph, keep)
60880
+ };
60881
+ }
60882
+ reconcile_fold(gobj, Object.assign({ relayout: true }, vp_opts)).then(() => {
60784
60883
  sync_history_plugin(gobj);
60785
60884
  if (priv._pending_focus_topic !== null) {
60786
60885
  let ft = priv._pending_focus_topic;
@@ -61240,11 +61339,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61240
61339
  let priv = gobj.priv;
61241
61340
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "main_topic", kw && kw.topic || "");
61242
61341
  if (!priv._fold_model) return 0;
61342
+ let keep = fold_keep_node(gobj);
61343
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61243
61344
  rebuild_fold_model(gobj);
61244
61345
  fold_expand_to_depth(priv._fold_model, priv._fold_state, (0, _yuneta_gobj_js.gobj_read_integer_attr)(gobj, "expand_depth"));
61245
61346
  reconcile_fold(gobj, {
61246
61347
  relayout: true,
61247
- fit: true
61348
+ keep,
61349
+ keep_vp: vp
61248
61350
  });
61249
61351
  return 0;
61250
61352
  }
@@ -61270,11 +61372,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61270
61372
  (0, _yuneta_gobj_js.gobj_write_str_attr)(gobj, "node_mode", mode);
61271
61373
  priv._open_nodes = /* @__PURE__ */ new Set();
61272
61374
  if (!priv.graph || !priv._fold_model) return 0;
61375
+ let keep = fold_keep_node(gobj);
61376
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61273
61377
  reshape_nodes(gobj, all_record_node_ids(gobj));
61274
61378
  reshape_more_chips(gobj);
61275
61379
  reconcile_fold(gobj, {
61276
61380
  relayout: true,
61277
- fit: true
61381
+ keep,
61382
+ keep_vp: vp
61278
61383
  });
61279
61384
  return 0;
61280
61385
  }
@@ -61944,6 +62049,7 @@ function create_gclass$4(gclass_name) {
61944
62049
  ["EV_FIND_NODES", 0],
61945
62050
  ["EV_FIND_RESULT", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
61946
62051
  ["EV_LAYOUT_AUTOSET", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT],
62052
+ ["EV_CAMERA_CHANGED", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
61947
62053
  ["EV_TOGGLE_FOLD", 0],
61948
62054
  ["EV_SHOW_MORE", 0],
61949
62055
  ["EV_EXPAND_ALL", 0],
@@ -62030,6 +62136,7 @@ var attrs_table$3 = [
62030
62136
  (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
62137
  (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
62138
  (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"),
62139
+ (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
62140
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62034
62141
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62035
62142
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62117,7 +62224,8 @@ function mt_create$3(gobj) {
62117
62224
  main_topic: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "main_topic") || "",
62118
62225
  loose_topics: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "loose_topics") || [],
62119
62226
  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")
62227
+ node_labels: (0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "node_labels"),
62228
+ camera: (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "camera") || {}
62121
62229
  }, gobj);
62122
62230
  populate_nodes_tree_options(gobj);
62123
62231
  if (priv.with_treedb_tables) {}
@@ -63801,6 +63909,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63801
63909
  return 0;
63802
63910
  }
63803
63911
  /************************************************************
63912
+ * The reader moved the camera, and it has settled.
63913
+ *
63914
+ * Persisted like every other choice of this toolbar, and for the
63915
+ * same reason: a zoom is a decision, and a decision that dies with
63916
+ * the tab has to be taken again every morning. The engine debounces,
63917
+ * so this runs once per gesture and not once per wheel notch.
63918
+ ************************************************************/
63919
+ function ac_camera_changed(gobj, event, kw, src) {
63920
+ (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "camera", {
63921
+ zoom: kw.zoom,
63922
+ x: kw.x,
63923
+ y: kw.y
63924
+ });
63925
+ (0, _yuneta_gobj_js.gobj_save_persistent_attrs)(gobj, "camera");
63926
+ return 0;
63927
+ }
63928
+ /************************************************************
63804
63929
  * Forward the find down to the graph child.
63805
63930
  ************************************************************/
63806
63931
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64080,6 +64205,11 @@ function create_gclass$3(gclass_name) {
64080
64205
  ac_layout_autoset,
64081
64206
  null
64082
64207
  ],
64208
+ [
64209
+ "EV_CAMERA_CHANGED",
64210
+ ac_camera_changed,
64211
+ null
64212
+ ],
64083
64213
  [
64084
64214
  "EV_LEGEND_TOPIC",
64085
64215
  ac_legend_topic,
@@ -64148,6 +64278,7 @@ function create_gclass$3(gclass_name) {
64148
64278
  ["EV_TOGGLE_NODE_LABELS", 0],
64149
64279
  ["EV_FIND_RESULT", 0],
64150
64280
  ["EV_LAYOUT_AUTOSET", 0],
64281
+ ["EV_CAMERA_CHANGED", 0],
64151
64282
  ["EV_LEGEND_TOPIC", 0],
64152
64283
  ["EV_LEGEND_STATE", 0],
64153
64284
  ["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, node, x, y} -- a NODE at a viewport pixel, not G6's own position, which does not restore. 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,83 @@ 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") return null;
56710
+ try {
56711
+ let z = graph.getZoom();
56712
+ let node = fold_keep_node(gobj);
56713
+ let vp = node ? yui_graph_viewport_of(graph, node) : null;
56714
+ if (!is_number(z) || !node || !vp || !is_number(vp[0]) || !is_number(vp[1])) return null;
56715
+ return {
56716
+ zoom: z,
56717
+ node,
56718
+ x: vp[0],
56719
+ y: vp[1]
56720
+ };
56721
+ } catch (e) {
56722
+ return null;
56723
+ }
56724
+ }
56725
+ async function restore_camera(gobj) {
56726
+ let priv = gobj.priv;
56727
+ let graph = priv.graph;
56728
+ let cam = gobj_read_attr(gobj, "camera");
56729
+ if (!graph || empty_json(cam) || !is_number(cam.zoom) || !cam.node || !is_number(cam.x) || !is_number(cam.y)) return false;
56730
+ if (!yui_graph_viewport_of(graph, cam.node)) return false;
56731
+ priv._camera_restoring = true;
56732
+ try {
56733
+ await graph.zoomTo(cam.zoom);
56734
+ await yui_graph_place_at(graph, cam.node, [cam.x, cam.y]);
56735
+ return true;
56736
+ } catch (e) {
56737
+ log_error(`${gobj_short_name(gobj)}: cannot restore the camera: ${e}`);
56738
+ return false;
56739
+ } finally {
56740
+ priv._camera_restoring = false;
56741
+ }
56742
+ }
56743
+ /************************************************************
56744
+ * A move settled: tell whoever persists it.
56745
+ *
56746
+ * Debounced on a REAL time and with the browser's timer: a
56747
+ * wheel notch, a pinch and a drag each fire `aftertransform`
56748
+ * many times, and what is worth saving is where the gesture
56749
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
56750
+ * the global -- this module imports neither.)
56751
+ ************************************************************/
56752
+ function publish_camera_later(gobj) {
56753
+ let priv = gobj.priv;
56754
+ if (priv._camera_restoring || !priv._camera_placed) return;
56755
+ if (priv._camera_timer) clearTimeout(priv._camera_timer);
56756
+ priv._camera_timer = setTimeout(() => {
56757
+ priv._camera_timer = null;
56758
+ if (gobj_is_destroying(gobj)) return;
56759
+ let cam = read_camera(gobj);
56760
+ if (!cam) return;
56761
+ gobj_write_attr(gobj, "camera", cam);
56762
+ gobj_publish_event(gobj, "EV_CAMERA_CHANGED", cam);
56763
+ }, CAMERA_SETTLE_MS);
56764
+ }
56765
+ /************************************************************
56679
56766
  * Fit the graph in the viewport, but never zoom out past the point
56680
56767
  * where nothing on a card can be read.
56681
56768
  *
@@ -60619,8 +60706,11 @@ async function reconcile_fold_apply(gobj, opts, visible) {
60619
60706
  }
60620
60707
  await graph_draw(gobj);
60621
60708
  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);
60709
+ if (!(opts.restore && await restore_camera(gobj))) {
60710
+ if (opts.fit) await graph_fit_readable(gobj);
60711
+ else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
60712
+ }
60713
+ priv._camera_placed = true;
60624
60714
  update_resize_handles_position(gobj);
60625
60715
  update_port_resize_handles_position(gobj);
60626
60716
  update_edge_icon_position(gobj);
@@ -60772,10 +60862,19 @@ function ac_load_data(gobj, event, kw, src) {
60772
60862
  log_error(`${gobj_short_name(gobj)}: auto_layout failed: ${e}`);
60773
60863
  }
60774
60864
  apply_layout_direction(gobj);
60775
- reconcile_fold(gobj, {
60776
- relayout: true,
60865
+ let vp_opts;
60866
+ if (!priv._camera_placed) vp_opts = {
60867
+ restore: true,
60777
60868
  fit: auto_laid
60778
- }).then(() => {
60869
+ };
60870
+ else {
60871
+ let keep = fold_keep_node(gobj);
60872
+ vp_opts = {
60873
+ keep,
60874
+ keep_vp: yui_graph_viewport_of(priv.graph, keep)
60875
+ };
60876
+ }
60877
+ reconcile_fold(gobj, Object.assign({ relayout: true }, vp_opts)).then(() => {
60779
60878
  sync_history_plugin(gobj);
60780
60879
  if (priv._pending_focus_topic !== null) {
60781
60880
  let ft = priv._pending_focus_topic;
@@ -61235,11 +61334,14 @@ function ac_set_main_topic(gobj, event, kw, src) {
61235
61334
  let priv = gobj.priv;
61236
61335
  gobj_write_str_attr(gobj, "main_topic", kw && kw.topic || "");
61237
61336
  if (!priv._fold_model) return 0;
61337
+ let keep = fold_keep_node(gobj);
61338
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61238
61339
  rebuild_fold_model(gobj);
61239
61340
  fold_expand_to_depth(priv._fold_model, priv._fold_state, gobj_read_integer_attr(gobj, "expand_depth"));
61240
61341
  reconcile_fold(gobj, {
61241
61342
  relayout: true,
61242
- fit: true
61343
+ keep,
61344
+ keep_vp: vp
61243
61345
  });
61244
61346
  return 0;
61245
61347
  }
@@ -61265,11 +61367,14 @@ function ac_set_node_mode$1(gobj, event, kw, src) {
61265
61367
  gobj_write_str_attr(gobj, "node_mode", mode);
61266
61368
  priv._open_nodes = /* @__PURE__ */ new Set();
61267
61369
  if (!priv.graph || !priv._fold_model) return 0;
61370
+ let keep = fold_keep_node(gobj);
61371
+ let vp = yui_graph_viewport_of(priv.graph, keep);
61268
61372
  reshape_nodes(gobj, all_record_node_ids(gobj));
61269
61373
  reshape_more_chips(gobj);
61270
61374
  reconcile_fold(gobj, {
61271
61375
  relayout: true,
61272
- fit: true
61376
+ keep,
61377
+ keep_vp: vp
61273
61378
  });
61274
61379
  return 0;
61275
61380
  }
@@ -61939,6 +62044,7 @@ function create_gclass$4(gclass_name) {
61939
62044
  ["EV_FIND_NODES", 0],
61940
62045
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
61941
62046
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
62047
+ ["EV_CAMERA_CHANGED", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
61942
62048
  ["EV_TOGGLE_FOLD", 0],
61943
62049
  ["EV_SHOW_MORE", 0],
61944
62050
  ["EV_EXPAND_ALL", 0],
@@ -62025,6 +62131,7 @@ var attrs_table$3 = [
62025
62131
  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
62132
  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
62133
  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"),
62134
+ 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
62135
  SDATA(data_type_t.DTP_POINTER, "gobj_remote_yuno", 0, null, "Remote Yuno to request data"),
62029
62136
  SDATA(data_type_t.DTP_STRING, "treedb_name", 0, null, "Remote service treedb name"),
62030
62137
  SDATA(data_type_t.DTP_DICT, "descs", 0, null, "Descriptions of topics obtained"),
@@ -62112,7 +62219,8 @@ function mt_create$3(gobj) {
62112
62219
  main_topic: gobj_read_str_attr(gobj, "main_topic") || "",
62113
62220
  loose_topics: gobj_read_attr(gobj, "loose_topics") || [],
62114
62221
  node_mode: gobj_read_str_attr(gobj, "node_mode") || "expanded",
62115
- node_labels: gobj_read_bool_attr(gobj, "node_labels")
62222
+ node_labels: gobj_read_bool_attr(gobj, "node_labels"),
62223
+ camera: gobj_read_attr(gobj, "camera") || {}
62116
62224
  }, gobj);
62117
62225
  populate_nodes_tree_options(gobj);
62118
62226
  if (priv.with_treedb_tables) {}
@@ -63796,6 +63904,23 @@ function ac_layout_autoset(gobj, event, kw, src) {
63796
63904
  return 0;
63797
63905
  }
63798
63906
  /************************************************************
63907
+ * The reader moved the camera, and it has settled.
63908
+ *
63909
+ * Persisted like every other choice of this toolbar, and for the
63910
+ * same reason: a zoom is a decision, and a decision that dies with
63911
+ * the tab has to be taken again every morning. The engine debounces,
63912
+ * so this runs once per gesture and not once per wheel notch.
63913
+ ************************************************************/
63914
+ function ac_camera_changed(gobj, event, kw, src) {
63915
+ gobj_write_attr(gobj, "camera", {
63916
+ zoom: kw.zoom,
63917
+ x: kw.x,
63918
+ y: kw.y
63919
+ });
63920
+ gobj_save_persistent_attrs(gobj, "camera");
63921
+ return 0;
63922
+ }
63923
+ /************************************************************
63799
63924
  * Forward the find down to the graph child.
63800
63925
  ************************************************************/
63801
63926
  function ac_find_nodes(gobj, event, kw, src) {
@@ -64075,6 +64200,11 @@ function create_gclass$3(gclass_name) {
64075
64200
  ac_layout_autoset,
64076
64201
  null
64077
64202
  ],
64203
+ [
64204
+ "EV_CAMERA_CHANGED",
64205
+ ac_camera_changed,
64206
+ null
64207
+ ],
64078
64208
  [
64079
64209
  "EV_LEGEND_TOPIC",
64080
64210
  ac_legend_topic,
@@ -64143,6 +64273,7 @@ function create_gclass$3(gclass_name) {
64143
64273
  ["EV_TOGGLE_NODE_LABELS", 0],
64144
64274
  ["EV_FIND_RESULT", 0],
64145
64275
  ["EV_LAYOUT_AUTOSET", 0],
64276
+ ["EV_CAMERA_CHANGED", 0],
64146
64277
  ["EV_LEGEND_TOPIC", 0],
64147
64278
  ["EV_LEGEND_STATE", 0],
64148
64279
  ["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.105",
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, node, x, y} -- a NODE at a viewport pixel, not G6's own position, which does not restore. 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,126 @@ 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
+ /* A camera is saved as a NODE at a VIEWPORT PIXEL, plus the zoom --
3374
+ * never as G6's own position.
3375
+ *
3376
+ * `translateTo()` does not take viewport pixels: an absolute translate
3377
+ * leaves the camera at `canvasCentre - T/zoom`, so `getPosition()` and
3378
+ * `translateTo()` are only each other's inverse at zoom 1. Restoring
3379
+ * one with the other brought the zoom back and put the graph somewhere
3380
+ * else, which is exactly what it looks like -- and the reason to write
3381
+ * the pair down (`yui_graph_place_at`, gobj-ui 7.23.29) was that it
3382
+ * had already cost fifteen iterations once.
3383
+ *
3384
+ * A node and a pixel is also the SAME thing the folds keep across a
3385
+ * rebuild. One idea, used twice. */
3386
+ function read_camera(gobj)
3387
+ {
3388
+ let priv = gobj.priv;
3389
+ let graph = priv.graph;
3390
+
3391
+ if(!graph || typeof graph.getZoom !== "function") {
3392
+ return null;
3393
+ }
3394
+ try {
3395
+ let z = graph.getZoom();
3396
+ let node = fold_keep_node(gobj);
3397
+ let vp = node? yui_graph_viewport_of(graph, node) : null;
3398
+ if(!is_number(z) || !node || !vp || !is_number(vp[0]) || !is_number(vp[1])) {
3399
+ return null;
3400
+ }
3401
+ return {zoom: z, node: node, x: vp[0], y: vp[1]};
3402
+ } catch(e) {
3403
+ return null; /* between renders: there is no camera to read */
3404
+ }
3405
+ }
3406
+
3407
+ /* Returns true when it placed the viewport, so the caller knows
3408
+ * not to fit. */
3409
+ async function restore_camera(gobj)
3410
+ {
3411
+ let priv = gobj.priv;
3412
+ let graph = priv.graph;
3413
+ let cam = gobj_read_attr(gobj, "camera");
3414
+
3415
+ if(!graph || empty_json(cam) || !is_number(cam.zoom) ||
3416
+ !cam.node || !is_number(cam.x) || !is_number(cam.y)) {
3417
+ return false; /* nothing saved, or saved by a version that wrote
3418
+ G6's own position -- which never restored */
3419
+ }
3420
+ /* Checked BEFORE the zoom is touched: a saved node that this load
3421
+ * does not have (the treedb changed under it) must leave the graph
3422
+ * to its opening fit, not at a zoom with no framing to go with it. */
3423
+ if(!yui_graph_viewport_of(graph, cam.node)) {
3424
+ return false;
3425
+ }
3426
+ priv._camera_restoring = true;
3427
+ try {
3428
+ await graph.zoomTo(cam.zoom);
3429
+ await yui_graph_place_at(graph, cam.node, [cam.x, cam.y]);
3430
+ return true;
3431
+ } catch(e) {
3432
+ log_error(`${gobj_short_name(gobj)}: cannot restore the camera: ${e}`);
3433
+ return false;
3434
+ } finally {
3435
+ priv._camera_restoring = false;
3436
+ }
3437
+ }
3438
+
3439
+ /************************************************************
3440
+ * A move settled: tell whoever persists it.
3441
+ *
3442
+ * Debounced on a REAL time and with the browser's timer: a
3443
+ * wheel notch, a pinch and a drag each fire `aftertransform`
3444
+ * many times, and what is worth saving is where the gesture
3445
+ * ENDED. (`set_timeout` from gobj-js is a C_TIMER and shadows
3446
+ * the global -- this module imports neither.)
3447
+ ************************************************************/
3448
+ function publish_camera_later(gobj)
3449
+ {
3450
+ let priv = gobj.priv;
3451
+
3452
+ if(priv._camera_restoring || !priv._camera_placed) {
3453
+ return; /* our own move, or the opening one */
3454
+ }
3455
+ if(priv._camera_timer) {
3456
+ clearTimeout(priv._camera_timer);
3457
+ }
3458
+ priv._camera_timer = setTimeout(() => {
3459
+ priv._camera_timer = null;
3460
+ if(gobj_is_destroying(gobj)) {
3461
+ return;
3462
+ }
3463
+ let cam = read_camera(gobj);
3464
+ if(!cam) {
3465
+ return; /* Error already logged, or nothing to read */
3466
+ }
3467
+ gobj_write_attr(gobj, "camera", cam);
3468
+ gobj_publish_event(gobj, "EV_CAMERA_CHANGED", cam);
3469
+ }, CAMERA_SETTLE_MS);
3470
+ }
3471
+
3332
3472
  /************************************************************
3333
3473
  * Fit the graph in the viewport, but never zoom out past the point
3334
3474
  * where nothing on a card can be read.
@@ -9241,11 +9381,26 @@ async function reconcile_fold_apply(gobj, opts, visible)
9241
9381
  await graph_layout(gobj);
9242
9382
  }
9243
9383
 
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
- }
9384
+ /* THE VIEWPORT, and only here.
9385
+ *
9386
+ * `restore` is the reader's saved camera coming back on the
9387
+ * first draw after a reload; `fit` is the opening frame of a
9388
+ * graph nobody has looked at yet; `keep` holds a node at the
9389
+ * pixel it was on across a rebuild. Anything else leaves the
9390
+ * camera alone, which is the default on purpose: a rebuild of
9391
+ * the CONTENT is not a reason to move the reader. */
9392
+ if(!(opts.restore && await restore_camera(gobj))) {
9393
+ if(opts.fit) {
9394
+ await graph_fit_readable(gobj);
9395
+ } else if(opts.keep && opts.keep_vp) {
9396
+ await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
9397
+ }
9398
+ }
9399
+ /* Whichever branch ran -- including none of them, which is a
9400
+ * graph somebody had arranged and left where it was -- the
9401
+ * opening viewport is decided from here on, and every move after
9402
+ * it is the reader's and worth remembering. */
9403
+ priv._camera_placed = true;
9249
9404
 
9250
9405
  update_resize_handles_position(gobj);
9251
9406
  update_port_resize_handles_position(gobj);
@@ -9484,10 +9639,23 @@ function ac_load_data(gobj, event, kw, src)
9484
9639
  * nodes are about to be built: settle it first. */
9485
9640
  apply_layout_direction(gobj);
9486
9641
 
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(() => {
9642
+ /* THE FIRST load decides the viewport: the reader's saved
9643
+ * camera if there is one, else the opening fit -- and only
9644
+ * when WE laid the graph out, since a saved arrangement
9645
+ * includes where its owner was looking.
9646
+ *
9647
+ * Every load AFTER that is a REFRESH, and a refresh must not
9648
+ * move the camera: the reader asked for fresh data, not for a
9649
+ * new viewport. It holds a node instead, so the graph does not
9650
+ * jump even if the layout changed under it. */
9651
+ let vp_opts;
9652
+ if(!priv._camera_placed) {
9653
+ vp_opts = {restore: true, fit: auto_laid};
9654
+ } else {
9655
+ let keep = fold_keep_node(gobj);
9656
+ vp_opts = {keep: keep, keep_vp: yui_graph_viewport_of(priv.graph, keep)};
9657
+ }
9658
+ reconcile_fold(gobj, Object.assign({relayout: true}, vp_opts)).then(() => {
9491
9659
  sync_history_plugin(gobj);
9492
9660
  /* Nodes exist and are positioned now: apply a focus
9493
9661
  * requested before the data was ready (deep link). */
@@ -10160,12 +10328,18 @@ function ac_set_main_topic(gobj, event, kw, src)
10160
10328
  if(!priv._fold_model) {
10161
10329
  return 0;
10162
10330
  }
10331
+ /* Read BEFORE the model is rebuilt: the node is looked up in the
10332
+ * tree that is on screen. If the new trunk leaves it out,
10333
+ * `yui_graph_place_at` finds nothing and the camera simply stays
10334
+ * where it was -- which is still not a refit. */
10335
+ let keep = fold_keep_node(gobj);
10336
+ let vp = yui_graph_viewport_of(priv.graph, keep);
10163
10337
  rebuild_fold_model(gobj);
10164
10338
  fold_expand_to_depth(
10165
10339
  priv._fold_model, priv._fold_state,
10166
10340
  gobj_read_integer_attr(gobj, "expand_depth")
10167
10341
  );
10168
- reconcile_fold(gobj, {relayout: true, fit: true});
10342
+ reconcile_fold(gobj, {relayout: true, keep: keep, keep_vp: vp});
10169
10343
  return 0;
10170
10344
  }
10171
10345
 
@@ -10200,9 +10374,15 @@ function ac_set_node_mode(gobj, event, kw, src)
10200
10374
  if(!priv.graph || !priv._fold_model) {
10201
10375
  return 0; /* applied when the records arrive */
10202
10376
  }
10377
+ /* The camera is the reader's: a square and a card do not occupy
10378
+ * the same room, so the graph is laid out again -- and the node
10379
+ * being read stays at its pixel, at the zoom it was chosen at,
10380
+ * instead of the whole graph being fitted from scratch. */
10381
+ let keep = fold_keep_node(gobj);
10382
+ let vp = yui_graph_viewport_of(priv.graph, keep);
10203
10383
  reshape_nodes(gobj, all_record_node_ids(gobj));
10204
10384
  reshape_more_chips(gobj);
10205
- reconcile_fold(gobj, {relayout: true, fit: true});
10385
+ reconcile_fold(gobj, {relayout: true, keep: keep, keep_vp: vp});
10206
10386
  return 0;
10207
10387
  }
10208
10388
 
@@ -10898,6 +11078,9 @@ function create_gclass(gclass_name)
10898
11078
  ["EV_FIND_NODES", 0],
10899
11079
  ["EV_FIND_RESULT", event_flag_t.EVF_OUTPUT_EVENT],
10900
11080
  ["EV_LAYOUT_AUTOSET", event_flag_t.EVF_OUTPUT_EVENT],
11081
+ /* Optional: a host that does not persist the viewport is not
11082
+ * doing anything wrong, so no "publish without subscribers". */
11083
+ ["EV_CAMERA_CHANGED", event_flag_t.EVF_OUTPUT_EVENT|event_flag_t.EVF_NO_WARN_SUBS],
10901
11084
  ["EV_TOGGLE_FOLD", 0],
10902
11085
  ["EV_SHOW_MORE", 0],
10903
11086
  ["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",