@yuneta/gobj-ui 7.23.136 → 7.23.138

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.
@@ -21908,6 +21908,31 @@ function cmd_get_topic_data$1(gobj, cmd, kw, src) {
21908
21908
  ************************************************************/
21909
21909
  function build_ui$6(gobj) {
21910
21910
  let source_url = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "source_url") || "";
21911
+ let routes = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "card_action_routes");
21912
+ let graph_tpl = (0, _yuneta_gobj_js.is_object)(routes) && typeof routes.graph === "string" ? routes.graph : "";
21913
+ let graph_href = /\/\{topic\}$/.test(graph_tpl) ? graph_tpl.replace(/\/\{topic\}$/, "") : "";
21914
+ let graph_items = [];
21915
+ if (graph_href) graph_items.push([
21916
+ "a",
21917
+ {
21918
+ class: "button TREEDB_GRAPH_BTN",
21919
+ href: graph_href,
21920
+ style: "margin-left:auto;",
21921
+ title: (0, i18next.t)("graph"),
21922
+ "aria-label": (0, i18next.t)("graph"),
21923
+ "data-i18n-title": "graph",
21924
+ "data-i18n-aria-label": "graph"
21925
+ },
21926
+ [[
21927
+ "span",
21928
+ { class: "icon" },
21929
+ [yui_toolbar_icon("yi-hexagon-nodes")]
21930
+ ], [
21931
+ "span",
21932
+ { i18n: "graph" },
21933
+ "graph"
21934
+ ]]
21935
+ ]);
21911
21936
  let $container = (0, _yuneta_gobj_js.createElement2)([
21912
21937
  "div",
21913
21938
  {
@@ -21988,11 +22013,12 @@ function build_ui$6(gobj) {
21988
22013
  source_url
21989
22014
  ]]
21990
22015
  ],
22016
+ ...graph_items,
21991
22017
  [
21992
22018
  "button",
21993
22019
  {
21994
22020
  class: "button TREEDB_JSON_BTN",
21995
- style: "margin-left:auto;",
22021
+ style: graph_href ? "" : "margin-left:auto;",
21996
22022
  title: (0, i18next.t)("raw json"),
21997
22023
  "aria-label": (0, i18next.t)("raw json"),
21998
22024
  "data-i18n-title": "raw json",
@@ -57017,12 +57043,41 @@ var MIN_READABLE_ZOOM = .5;
57017
57043
  * G6 reports them.
57018
57044
  ************************************************************/
57019
57045
  var CAMERA_SETTLE_MS = 700;
57046
+ function node_nearest_view_centre(gobj) {
57047
+ let graph = gobj.priv.graph;
57048
+ let size;
57049
+ try {
57050
+ size = graph.getSize();
57051
+ } catch (e) {
57052
+ return "";
57053
+ }
57054
+ if (!(0, _yuneta_gobj_js.is_array)(size) || !(size[0] > 0) || !(size[1] > 0)) return "";
57055
+ let [w, h] = size;
57056
+ let best = "";
57057
+ let best_d = Infinity;
57058
+ for (let nd of graph.getNodeData() || []) {
57059
+ let vp = yui_graph_viewport_of(graph, nd.id);
57060
+ if (!vp || !(0, _yuneta_gobj_js.is_number)(vp[0]) || !(0, _yuneta_gobj_js.is_number)(vp[1])) continue;
57061
+ if (vp[0] < 0 || vp[1] < 0 || vp[0] > w || vp[1] > h) continue;
57062
+ let d = (vp[0] - w / 2) ** 2 + (vp[1] - h / 2) ** 2;
57063
+ if (d < best_d) {
57064
+ best_d = d;
57065
+ best = nd.id;
57066
+ }
57067
+ }
57068
+ return best;
57069
+ }
57070
+ function camera_anchor_node(gobj) {
57071
+ let priv = gobj.priv;
57072
+ if (priv.anchor_state === "on" && priv.anchor_id) return priv.anchor_id;
57073
+ return node_nearest_view_centre(gobj) || fold_keep_node(gobj);
57074
+ }
57020
57075
  function read_camera(gobj) {
57021
57076
  let graph = gobj.priv.graph;
57022
57077
  if (!graph || typeof graph.getZoom !== "function") return null;
57023
57078
  try {
57024
57079
  let z = graph.getZoom();
57025
- let node = fold_keep_node(gobj);
57080
+ let node = camera_anchor_node(gobj);
57026
57081
  let vp = node ? yui_graph_viewport_of(graph, node) : null;
57027
57082
  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;
57028
57083
  return {
@@ -57041,6 +57096,8 @@ async function restore_camera(gobj) {
57041
57096
  let cam = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "camera");
57042
57097
  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;
57043
57098
  if (!yui_graph_viewport_of(graph, cam.node)) return false;
57099
+ let size = graph.getSize() || [0, 0];
57100
+ if (cam.x < 0 || cam.y < 0 || cam.x > size[0] || cam.y > size[1]) return false;
57044
57101
  priv._camera_restoring = true;
57045
57102
  try {
57046
57103
  await graph.zoomTo(cam.zoom);
@@ -61262,6 +61319,7 @@ async function reconcile_fold_apply(gobj, opts, visible) {
61262
61319
  if (opts.fit) await graph_fit_readable(gobj);
61263
61320
  else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
61264
61321
  }
61322
+ if ((graph.getNodeData() || []).length > 0 && !node_nearest_view_centre(gobj)) await graph_fit_readable(gobj);
61265
61323
  priv._camera_placed = true;
61266
61324
  update_resize_handles_position(gobj);
61267
61325
  update_port_resize_handles_position(gobj);
@@ -63130,56 +63188,54 @@ function make_toolbar(gobj) {
63130
63188
  evt.stopPropagation();
63131
63189
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_SET_OPERATION_MODE", { operation_mode: evt.target.value }, gobj);
63132
63190
  } }
63133
- ],
63134
- [
63135
- "button",
63191
+ ]
63192
+ ];
63193
+ let center_items = [...yui_graph_fold_items(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "wide")), ...node_shape_items(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "wide"))];
63194
+ let backend_items = [[
63195
+ "button",
63196
+ {
63197
+ class: "GRAPH_REFRESH button",
63198
+ title: (0, i18next.t)("refresh"),
63199
+ "data-i18n-title": "refresh",
63200
+ "aria-label": (0, i18next.t)("refresh"),
63201
+ "data-i18n-aria-label": "refresh"
63202
+ },
63203
+ [yui_toolbar_icon("yi-arrows-rotate"), [
63204
+ "span",
63136
63205
  {
63137
- class: "GRAPH_REFRESH button",
63138
- title: (0, i18next.t)("refresh"),
63139
- "data-i18n-title": "refresh",
63140
- "aria-label": (0, i18next.t)("refresh"),
63141
- "data-i18n-aria-label": "refresh"
63206
+ class: "is-hidden-mobile",
63207
+ style: "padding-left:5px;",
63208
+ i18n: "refresh"
63142
63209
  },
63143
- [yui_toolbar_icon("yi-arrows-rotate"), [
63144
- "span",
63145
- {
63146
- class: "is-hidden-mobile",
63147
- style: "padding-left:5px;",
63148
- i18n: "refresh"
63149
- },
63150
- "refresh"
63151
- ]],
63152
- { click: (evt) => {
63153
- evt.stopPropagation();
63154
- (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_REFRESH_TREEDB", { evt }, gobj);
63155
- } }
63156
- ],
63157
- ...yui_graph_fold_items(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "wide")),
63158
- ...node_shape_items(gobj, (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "wide")),
63159
- [
63160
- "button",
63210
+ "refresh"
63211
+ ]],
63212
+ { click: (evt) => {
63213
+ evt.stopPropagation();
63214
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_REFRESH_TREEDB", { evt }, gobj);
63215
+ } }
63216
+ ], [
63217
+ "button",
63218
+ {
63219
+ class: "button ml-2 TREEDB_JSON_BTN",
63220
+ title: (0, i18next.t)("raw json"),
63221
+ "aria-label": (0, i18next.t)("raw json"),
63222
+ "data-i18n-title": "raw json",
63223
+ "data-i18n-aria-label": "raw json"
63224
+ },
63225
+ [yui_toolbar_icon("yi-eye"), [
63226
+ "span",
63161
63227
  {
63162
- class: "button ml-2 TREEDB_JSON_BTN",
63163
- title: (0, i18next.t)("raw json"),
63164
- "aria-label": (0, i18next.t)("raw json"),
63165
- "data-i18n-title": "raw json",
63166
- "data-i18n-aria-label": "raw json"
63228
+ class: "is-hidden-mobile",
63229
+ style: "padding-left:5px;",
63230
+ i18n: "raw json"
63167
63231
  },
63168
- [yui_toolbar_icon("yi-eye"), [
63169
- "span",
63170
- {
63171
- class: "is-hidden-mobile",
63172
- style: "padding-left:5px;",
63173
- i18n: "raw json"
63174
- },
63175
- "raw json"
63176
- ]],
63177
- { click: (evt) => {
63178
- evt.stopPropagation();
63179
- (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_OPEN_JSON", {}, gobj);
63180
- } }
63181
- ]
63182
- ];
63232
+ "raw json"
63233
+ ]],
63234
+ { click: (evt) => {
63235
+ evt.stopPropagation();
63236
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_OPEN_JSON", {}, gobj);
63237
+ } }
63238
+ ]];
63183
63239
  let $find_input = (0, _yuneta_gobj_js.createElement2)([
63184
63240
  "input",
63185
63241
  {
@@ -63214,6 +63270,18 @@ function make_toolbar(gobj) {
63214
63270
  ]]
63215
63271
  ]);
63216
63272
  attach_clear($find_control, $find_input);
63273
+ let right_items = [...[$find_control, [
63274
+ "div",
63275
+ {
63276
+ class: "GRAPH_FIND_RESULT is-hidden",
63277
+ style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
63278
+ },
63279
+ [[
63280
+ "span",
63281
+ { class: "GRAPH_FIND_COUNT" },
63282
+ ""
63283
+ ]]
63284
+ ]], ...backend_items];
63217
63285
  const $toolbar_header = yui_toolbar({}, [
63218
63286
  [
63219
63287
  "div",
@@ -63223,23 +63291,12 @@ function make_toolbar(gobj) {
63223
63291
  [
63224
63292
  "div",
63225
63293
  { class: "yui-horizontal-toolbar-section center" },
63226
- [$find_control, [
63227
- "div",
63228
- {
63229
- class: "GRAPH_FIND_RESULT is-hidden",
63230
- style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
63231
- },
63232
- [[
63233
- "span",
63234
- { class: "GRAPH_FIND_COUNT" },
63235
- ""
63236
- ]]
63237
- ]]
63294
+ center_items
63238
63295
  ],
63239
63296
  [
63240
63297
  "div",
63241
63298
  { class: "GRAPH_MODE_BUTTONS yui-horizontal-toolbar-section right" },
63242
- []
63299
+ right_items
63243
63300
  ]
63244
63301
  ]);
63245
63302
  (0, _yuneta_gobj_js.refresh_language)($toolbar_header, i18next.t);
@@ -21903,6 +21903,31 @@ function cmd_get_topic_data$1(gobj, cmd, kw, src) {
21903
21903
  ************************************************************/
21904
21904
  function build_ui$6(gobj) {
21905
21905
  let source_url = gobj_read_str_attr(gobj, "source_url") || "";
21906
+ let routes = gobj_read_attr(gobj, "card_action_routes");
21907
+ let graph_tpl = is_object(routes) && typeof routes.graph === "string" ? routes.graph : "";
21908
+ let graph_href = /\/\{topic\}$/.test(graph_tpl) ? graph_tpl.replace(/\/\{topic\}$/, "") : "";
21909
+ let graph_items = [];
21910
+ if (graph_href) graph_items.push([
21911
+ "a",
21912
+ {
21913
+ class: "button TREEDB_GRAPH_BTN",
21914
+ href: graph_href,
21915
+ style: "margin-left:auto;",
21916
+ title: t("graph"),
21917
+ "aria-label": t("graph"),
21918
+ "data-i18n-title": "graph",
21919
+ "data-i18n-aria-label": "graph"
21920
+ },
21921
+ [[
21922
+ "span",
21923
+ { class: "icon" },
21924
+ [yui_toolbar_icon("yi-hexagon-nodes")]
21925
+ ], [
21926
+ "span",
21927
+ { i18n: "graph" },
21928
+ "graph"
21929
+ ]]
21930
+ ]);
21906
21931
  let $container = createElement2([
21907
21932
  "div",
21908
21933
  {
@@ -21983,11 +22008,12 @@ function build_ui$6(gobj) {
21983
22008
  source_url
21984
22009
  ]]
21985
22010
  ],
22011
+ ...graph_items,
21986
22012
  [
21987
22013
  "button",
21988
22014
  {
21989
22015
  class: "button TREEDB_JSON_BTN",
21990
- style: "margin-left:auto;",
22016
+ style: graph_href ? "" : "margin-left:auto;",
21991
22017
  title: t("raw json"),
21992
22018
  "aria-label": t("raw json"),
21993
22019
  "data-i18n-title": "raw json",
@@ -57012,12 +57038,41 @@ var MIN_READABLE_ZOOM = .5;
57012
57038
  * G6 reports them.
57013
57039
  ************************************************************/
57014
57040
  var CAMERA_SETTLE_MS = 700;
57041
+ function node_nearest_view_centre(gobj) {
57042
+ let graph = gobj.priv.graph;
57043
+ let size;
57044
+ try {
57045
+ size = graph.getSize();
57046
+ } catch (e) {
57047
+ return "";
57048
+ }
57049
+ if (!is_array(size) || !(size[0] > 0) || !(size[1] > 0)) return "";
57050
+ let [w, h] = size;
57051
+ let best = "";
57052
+ let best_d = Infinity;
57053
+ for (let nd of graph.getNodeData() || []) {
57054
+ let vp = yui_graph_viewport_of(graph, nd.id);
57055
+ if (!vp || !is_number(vp[0]) || !is_number(vp[1])) continue;
57056
+ if (vp[0] < 0 || vp[1] < 0 || vp[0] > w || vp[1] > h) continue;
57057
+ let d = (vp[0] - w / 2) ** 2 + (vp[1] - h / 2) ** 2;
57058
+ if (d < best_d) {
57059
+ best_d = d;
57060
+ best = nd.id;
57061
+ }
57062
+ }
57063
+ return best;
57064
+ }
57065
+ function camera_anchor_node(gobj) {
57066
+ let priv = gobj.priv;
57067
+ if (priv.anchor_state === "on" && priv.anchor_id) return priv.anchor_id;
57068
+ return node_nearest_view_centre(gobj) || fold_keep_node(gobj);
57069
+ }
57015
57070
  function read_camera(gobj) {
57016
57071
  let graph = gobj.priv.graph;
57017
57072
  if (!graph || typeof graph.getZoom !== "function") return null;
57018
57073
  try {
57019
57074
  let z = graph.getZoom();
57020
- let node = fold_keep_node(gobj);
57075
+ let node = camera_anchor_node(gobj);
57021
57076
  let vp = node ? yui_graph_viewport_of(graph, node) : null;
57022
57077
  if (!is_number(z) || !node || !vp || !is_number(vp[0]) || !is_number(vp[1])) return null;
57023
57078
  return {
@@ -57036,6 +57091,8 @@ async function restore_camera(gobj) {
57036
57091
  let cam = gobj_read_attr(gobj, "camera");
57037
57092
  if (!graph || empty_json(cam) || !is_number(cam.zoom) || !cam.node || !is_number(cam.x) || !is_number(cam.y)) return false;
57038
57093
  if (!yui_graph_viewport_of(graph, cam.node)) return false;
57094
+ let size = graph.getSize() || [0, 0];
57095
+ if (cam.x < 0 || cam.y < 0 || cam.x > size[0] || cam.y > size[1]) return false;
57039
57096
  priv._camera_restoring = true;
57040
57097
  try {
57041
57098
  await graph.zoomTo(cam.zoom);
@@ -61257,6 +61314,7 @@ async function reconcile_fold_apply(gobj, opts, visible) {
61257
61314
  if (opts.fit) await graph_fit_readable(gobj);
61258
61315
  else if (opts.keep && opts.keep_vp) await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
61259
61316
  }
61317
+ if ((graph.getNodeData() || []).length > 0 && !node_nearest_view_centre(gobj)) await graph_fit_readable(gobj);
61260
61318
  priv._camera_placed = true;
61261
61319
  update_resize_handles_position(gobj);
61262
61320
  update_port_resize_handles_position(gobj);
@@ -63125,56 +63183,54 @@ function make_toolbar(gobj) {
63125
63183
  evt.stopPropagation();
63126
63184
  gobj_send_event(gobj, "EV_SET_OPERATION_MODE", { operation_mode: evt.target.value }, gobj);
63127
63185
  } }
63128
- ],
63129
- [
63130
- "button",
63186
+ ]
63187
+ ];
63188
+ let center_items = [...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")), ...node_shape_items(gobj, gobj_read_str_attr(gobj, "wide"))];
63189
+ let backend_items = [[
63190
+ "button",
63191
+ {
63192
+ class: "GRAPH_REFRESH button",
63193
+ title: t("refresh"),
63194
+ "data-i18n-title": "refresh",
63195
+ "aria-label": t("refresh"),
63196
+ "data-i18n-aria-label": "refresh"
63197
+ },
63198
+ [yui_toolbar_icon("yi-arrows-rotate"), [
63199
+ "span",
63131
63200
  {
63132
- class: "GRAPH_REFRESH button",
63133
- title: t("refresh"),
63134
- "data-i18n-title": "refresh",
63135
- "aria-label": t("refresh"),
63136
- "data-i18n-aria-label": "refresh"
63201
+ class: "is-hidden-mobile",
63202
+ style: "padding-left:5px;",
63203
+ i18n: "refresh"
63137
63204
  },
63138
- [yui_toolbar_icon("yi-arrows-rotate"), [
63139
- "span",
63140
- {
63141
- class: "is-hidden-mobile",
63142
- style: "padding-left:5px;",
63143
- i18n: "refresh"
63144
- },
63145
- "refresh"
63146
- ]],
63147
- { click: (evt) => {
63148
- evt.stopPropagation();
63149
- gobj_send_event(gobj, "EV_REFRESH_TREEDB", { evt }, gobj);
63150
- } }
63151
- ],
63152
- ...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")),
63153
- ...node_shape_items(gobj, gobj_read_str_attr(gobj, "wide")),
63154
- [
63155
- "button",
63205
+ "refresh"
63206
+ ]],
63207
+ { click: (evt) => {
63208
+ evt.stopPropagation();
63209
+ gobj_send_event(gobj, "EV_REFRESH_TREEDB", { evt }, gobj);
63210
+ } }
63211
+ ], [
63212
+ "button",
63213
+ {
63214
+ class: "button ml-2 TREEDB_JSON_BTN",
63215
+ title: t("raw json"),
63216
+ "aria-label": t("raw json"),
63217
+ "data-i18n-title": "raw json",
63218
+ "data-i18n-aria-label": "raw json"
63219
+ },
63220
+ [yui_toolbar_icon("yi-eye"), [
63221
+ "span",
63156
63222
  {
63157
- class: "button ml-2 TREEDB_JSON_BTN",
63158
- title: t("raw json"),
63159
- "aria-label": t("raw json"),
63160
- "data-i18n-title": "raw json",
63161
- "data-i18n-aria-label": "raw json"
63223
+ class: "is-hidden-mobile",
63224
+ style: "padding-left:5px;",
63225
+ i18n: "raw json"
63162
63226
  },
63163
- [yui_toolbar_icon("yi-eye"), [
63164
- "span",
63165
- {
63166
- class: "is-hidden-mobile",
63167
- style: "padding-left:5px;",
63168
- i18n: "raw json"
63169
- },
63170
- "raw json"
63171
- ]],
63172
- { click: (evt) => {
63173
- evt.stopPropagation();
63174
- gobj_send_event(gobj, "EV_OPEN_JSON", {}, gobj);
63175
- } }
63176
- ]
63177
- ];
63227
+ "raw json"
63228
+ ]],
63229
+ { click: (evt) => {
63230
+ evt.stopPropagation();
63231
+ gobj_send_event(gobj, "EV_OPEN_JSON", {}, gobj);
63232
+ } }
63233
+ ]];
63178
63234
  let $find_input = createElement2([
63179
63235
  "input",
63180
63236
  {
@@ -63209,6 +63265,18 @@ function make_toolbar(gobj) {
63209
63265
  ]]
63210
63266
  ]);
63211
63267
  attach_clear($find_control, $find_input);
63268
+ let right_items = [...[$find_control, [
63269
+ "div",
63270
+ {
63271
+ class: "GRAPH_FIND_RESULT is-hidden",
63272
+ style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
63273
+ },
63274
+ [[
63275
+ "span",
63276
+ { class: "GRAPH_FIND_COUNT" },
63277
+ ""
63278
+ ]]
63279
+ ]], ...backend_items];
63212
63280
  const $toolbar_header = yui_toolbar({}, [
63213
63281
  [
63214
63282
  "div",
@@ -63218,23 +63286,12 @@ function make_toolbar(gobj) {
63218
63286
  [
63219
63287
  "div",
63220
63288
  { class: "yui-horizontal-toolbar-section center" },
63221
- [$find_control, [
63222
- "div",
63223
- {
63224
- class: "GRAPH_FIND_RESULT is-hidden",
63225
- style: "display:flex; align-items:center; gap:.3rem; margin-right:.5rem; font-size:.85rem;"
63226
- },
63227
- [[
63228
- "span",
63229
- { class: "GRAPH_FIND_COUNT" },
63230
- ""
63231
- ]]
63232
- ]]
63289
+ center_items
63233
63290
  ],
63234
63291
  [
63235
63292
  "div",
63236
63293
  { class: "GRAPH_MODE_BUTTONS yui-horizontal-toolbar-section right" },
63237
- []
63294
+ right_items
63238
63295
  ]
63239
63296
  ]);
63240
63297
  refresh_language($toolbar_header, t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.136",
3
+ "version": "7.23.138",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -3382,6 +3382,58 @@ const CAMERA_SETTLE_MS = 700;
3382
3382
  *
3383
3383
  * A node and a pixel is also the SAME thing the folds keep across a
3384
3384
  * rebuild. One idea, used twice. */
3385
+ /* The drawn node nearest the middle of the VIEWPORT, among the ones
3386
+ * inside it; "" when there is none -- which is a blank view. */
3387
+ function node_nearest_view_centre(gobj)
3388
+ {
3389
+ let graph = gobj.priv.graph;
3390
+ let size;
3391
+ try {
3392
+ size = graph.getSize();
3393
+ } catch(e) {
3394
+ return ""; /* between renders: nothing is drawn */
3395
+ }
3396
+ if(!is_array(size) || !(size[0] > 0) || !(size[1] > 0)) {
3397
+ return "";
3398
+ }
3399
+ let [w, h] = size;
3400
+ let best = "";
3401
+ let best_d = Infinity;
3402
+ for(let nd of (graph.getNodeData() || [])) {
3403
+ let vp = yui_graph_viewport_of(graph, nd.id);
3404
+ if(!vp || !is_number(vp[0]) || !is_number(vp[1])) {
3405
+ continue;
3406
+ }
3407
+ if(vp[0] < 0 || vp[1] < 0 || vp[0] > w || vp[1] > h) {
3408
+ continue;
3409
+ }
3410
+ let d = (vp[0] - w / 2) ** 2 + (vp[1] - h / 2) ** 2;
3411
+ if(d < best_d) {
3412
+ best_d = d;
3413
+ best = nd.id;
3414
+ }
3415
+ }
3416
+ return best;
3417
+ }
3418
+
3419
+ /* The node a camera is SAVED by: the anchor when one is set, else the
3420
+ * node nearest the middle of the viewport.
3421
+ *
3422
+ * It used to be fold_keep_node() -- the first root of the tree,
3423
+ * wherever it was. A reader who had panned away saved a node OFF
3424
+ * screen at a pixel off screen, and a reload whose layout had moved
3425
+ * by a hair put that one node back at that pixel and nothing else in
3426
+ * view: a blank graph, with the whole tree sitting in the minimap. */
3427
+ function camera_anchor_node(gobj)
3428
+ {
3429
+ let priv = gobj.priv;
3430
+
3431
+ if(priv.anchor_state === "on" && priv.anchor_id) {
3432
+ return priv.anchor_id;
3433
+ }
3434
+ return node_nearest_view_centre(gobj) || fold_keep_node(gobj);
3435
+ }
3436
+
3385
3437
  function read_camera(gobj)
3386
3438
  {
3387
3439
  let priv = gobj.priv;
@@ -3392,7 +3444,7 @@ function read_camera(gobj)
3392
3444
  }
3393
3445
  try {
3394
3446
  let z = graph.getZoom();
3395
- let node = fold_keep_node(gobj);
3447
+ let node = camera_anchor_node(gobj);
3396
3448
  let vp = node? yui_graph_viewport_of(graph, node) : null;
3397
3449
  if(!is_number(z) || !node || !vp || !is_number(vp[0]) || !is_number(vp[1])) {
3398
3450
  return null;
@@ -3422,6 +3474,13 @@ async function restore_camera(gobj)
3422
3474
  if(!yui_graph_viewport_of(graph, cam.node)) {
3423
3475
  return false;
3424
3476
  }
3477
+ /* A camera saved by a node OFF screen (before 7.23.137 the node was
3478
+ * the first root, wherever it was) frames nothing it can promise:
3479
+ * the opening fit instead. */
3480
+ let size = graph.getSize() || [0, 0];
3481
+ if(cam.x < 0 || cam.y < 0 || cam.x > size[0] || cam.y > size[1]) {
3482
+ return false;
3483
+ }
3425
3484
  priv._camera_restoring = true;
3426
3485
  try {
3427
3486
  await graph.zoomTo(cam.zoom);
@@ -9709,6 +9768,15 @@ async function reconcile_fold_apply(gobj, opts, visible)
9709
9768
  await yui_graph_place_at(graph, opts.keep, opts.keep_vp);
9710
9769
  }
9711
9770
  }
9771
+ /* And never a BLANK viewport. Every branch above can leave one: a
9772
+ * restore by a node whose surroundings moved, a refresh or a global
9773
+ * fold holding the first root still at a pixel off screen, a saved
9774
+ * arrangement opened with no camera (no fit: its owner's view is
9775
+ * part of it) far from where G6 starts looking. A view with nothing
9776
+ * in it is not a place anybody chose to be. */
9777
+ if((graph.getNodeData() || []).length > 0 && !node_nearest_view_centre(gobj)) {
9778
+ await graph_fit_readable(gobj);
9779
+ }
9712
9780
  /* Whichever branch ran -- including none of them, which is a
9713
9781
  * graph somebody had arranged and left where it was -- the
9714
9782
  * opening viewport is decided from here on, and every move after
@@ -694,6 +694,11 @@ function make_toolbar(gobj)
694
694
  );
695
695
 
696
696
  /*
697
+ * Three sections, by what the control is ABOUT:
698
+ * left how the graph is built: layout and operation mode
699
+ * center how it is SHOWN: the fold pair and the node views
700
+ * right what it is asked: find, refresh, the raw json
701
+ *
697
702
  * Left: layout and mode selectors
698
703
  * Layout options are empty — populated after child creation
699
704
  * via populate_nodes_tree_options()
@@ -730,6 +735,28 @@ function make_toolbar(gobj)
730
735
  }
731
736
  }],
732
737
 
738
+ ];
739
+
740
+ /*
741
+ * Center: how the graph is SHOWN.
742
+ */
743
+ let center_items = [
744
+ /* The fold pair, the same two chevrons the JSON graph and the
745
+ * lazy tree use: the graph opens folded (see the G6 child), and
746
+ * these are the whole thing and the roots alone. Refresh is the
747
+ * way back to the default depth. */
748
+ ...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")),
749
+
750
+ /* The view of the nodes -- full cards, one-line pills or
751
+ * figures -- and the name under a figure. */
752
+ ...node_shape_items(gobj, gobj_read_str_attr(gobj, "wide")),
753
+ ];
754
+
755
+ /*
756
+ * Right, after the find box: ask the backend again, and read its
757
+ * raw json.
758
+ */
759
+ let backend_items = [
733
760
  /* Icon + label, and the label is `is-hidden-mobile`: on a phone
734
761
  * this is a bare icon, so the name has to be on the button. */
735
762
  ['button', {class: 'GRAPH_REFRESH button',
@@ -744,16 +771,6 @@ function make_toolbar(gobj)
744
771
  }
745
772
  }],
746
773
 
747
- /* The fold pair, the same two chevrons the JSON graph and the
748
- * lazy tree use: the graph opens folded (see the G6 child), and
749
- * these are the whole thing and the roots alone. Refresh is the
750
- * way back to the default depth. */
751
- ...yui_graph_fold_items(gobj, gobj_read_str_attr(gobj, "wide")),
752
-
753
- /* The view of the nodes -- full cards, one-line pills or
754
- * figures -- and the name under a figure. */
755
- ...node_shape_items(gobj, gobj_read_str_attr(gobj, "wide")),
756
-
757
774
  /* Inspect the treedb's raw tranger json in the lazy tree viewer
758
775
  * (print-tranger on the C_NODE service). A treedb can be huge, so
759
776
  * the viewer drills in on demand — see open_json_viewer. */
@@ -771,7 +788,7 @@ function make_toolbar(gobj)
771
788
  ];
772
789
 
773
790
  /*
774
- * Center: find a node.
791
+ * Right: find a node.
775
792
  *
776
793
  * A graph of a few hundred records has no other way in: the only way
777
794
  * to locate one was to read every card. The box highlights every
@@ -821,7 +838,7 @@ function make_toolbar(gobj)
821
838
  ]]);
822
839
  attach_clear($find_control, $find_input);
823
840
 
824
- let center_items = [
841
+ let find_items = [
825
842
  $find_control,
826
843
  /* ONE counted span, not a number beside a word: "1 matches" is
827
844
  * what two spans always produce, because nothing there can see
@@ -844,10 +861,9 @@ function make_toolbar(gobj)
844
861
  ]]
845
862
  ];
846
863
 
847
- /*
848
- * Right, fill in set_mode
849
- */
850
864
  let right_items = [
865
+ ...find_items,
866
+ ...backend_items,
851
867
  ];
852
868
 
853
869
  const $toolbar_header = yui_toolbar({}, [
@@ -254,6 +254,29 @@ function build_ui(gobj)
254
254
  {
255
255
  let source_url = gobj_read_str_attr(gobj, "source_url") || "";
256
256
 
257
+ /* The whole treedb as a graph, with NO topic focused. A card's graph
258
+ * icon lands on `<graph route>/<topic>`, and that segment is a
259
+ * FOCUS: going through a card was the only way in, and it always
260
+ * arrived with that topic highlighted. The route is the card's
261
+ * template without its `/{topic}`; a host whose template does not
262
+ * end in it, or gives none, gets no button. */
263
+ let routes = gobj_read_attr(gobj, "card_action_routes");
264
+ let graph_tpl = is_object(routes) && typeof routes.graph === "string"? routes.graph : "";
265
+ let graph_href = /\/\{topic\}$/.test(graph_tpl)? graph_tpl.replace(/\/\{topic\}$/, "") : "";
266
+ let graph_items = [];
267
+ if(graph_href) {
268
+ graph_items.push(
269
+ ['a', {class: 'button TREEDB_GRAPH_BTN',
270
+ href: graph_href,
271
+ style: 'margin-left:auto;',
272
+ title: t('graph'), 'aria-label': t('graph'),
273
+ 'data-i18n-title': 'graph', 'data-i18n-aria-label': 'graph'}, [
274
+ ['span', {class: 'icon'}, [yui_toolbar_icon('yi-hexagon-nodes')]],
275
+ ['span', {i18n: 'graph'}, 'graph']
276
+ ]]
277
+ );
278
+ }
279
+
257
280
  /*----------------------------------------------*
258
281
  * Layout Schema
259
282
  *----------------------------------------------*/
@@ -262,12 +285,15 @@ function build_ui(gobj)
262
285
  ['div', {class: 'is-flex-grow-0'}, [
263
286
  ['div', {class: 'is-flex is-align-items-center TREEDB_TOPICS_TOOLBAR',
264
287
  style: 'gap:.25rem; padding:.25rem .25rem;'}, [
265
- /* This toolbar never holds more than TWO buttons at once
266
- * (back|toggle on the left, json on the right), so the
267
- * labels stay on mobile — a deliberate exception to the
268
- * icon-only-on-mobile rule: two bare squares side by side
269
- * read as the same control. The graph's toolbar has many
270
- * more, so it keeps is-hidden-mobile. */
288
+ /* This toolbar never holds more than THREE buttons at once
289
+ * (back|toggle on the left, graph and json on the right),
290
+ * so the labels stay on mobile — a deliberate exception
291
+ * to the icon-only-on-mobile rule: bare squares side by
292
+ * side read as the same control, and the toggle and the
293
+ * graph wear the same icon. They fit at 360px because the
294
+ * source url between them gives way down to its icon. The
295
+ * graph's toolbar has many more, so it keeps
296
+ * is-hidden-mobile. */
271
297
 
272
298
  /* Back to the topic-cards grid (cards-landing mode only);
273
299
  * hidden until a topic is open. */
@@ -309,11 +335,14 @@ function build_ui(gobj)
309
335
  ['span', {class: 'icon'}, [yui_toolbar_icon('yi-cloudversify')]],
310
336
  ['span', {class: 'TREEDB_TOPICS_SOURCE_URL'}, source_url]
311
337
  ]],
338
+ ...graph_items,
312
339
  /* Inspect the treedb's raw tranger json (whole service,
313
- * print-tranger, lazy drill). Last + margin-left:auto:
314
- * flush right, away from the back arrow it sat next to. */
340
+ * print-tranger, lazy drill). Last and flush right, away
341
+ * from the back arrow it sat next to: the `margin-left:
342
+ * auto` goes on the first button of the right group,
343
+ * which is the graph one when there is one. */
315
344
  ['button', {class: 'button TREEDB_JSON_BTN',
316
- style: 'margin-left:auto;',
345
+ style: graph_href? '' : 'margin-left:auto;',
317
346
  title: t('raw json'), 'aria-label': t('raw json'),
318
347
  'data-i18n-title': 'raw json', 'data-i18n-aria-label': 'raw json'}, [
319
348
  ['span', {class: 'icon'}, [yui_toolbar_icon('yi-eye')]],