@yuneta/gobj-ui 7.23.138 → 7.23.140

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.
@@ -911,6 +911,7 @@ var PRIVATE_DATA$21 = {
911
911
  navs: [],
912
912
  item_index: {},
913
913
  section_pos: {},
914
+ route_mru: [],
914
915
  sub_routes: {},
915
916
  event_handlers: {},
916
917
  hash_handler: null,
@@ -1619,6 +1620,7 @@ function navigate_to(gobj, route, depth, no_drain) {
1619
1620
  stage.active_route = matched_route;
1620
1621
  (0, _yuneta_gobj_js.gobj_write_attr)(gobj, "current_route", route);
1621
1622
  remember_position(gobj, route);
1623
+ remember_route(gobj, route);
1622
1624
  update_secondary_nav_visibility(gobj, entry);
1623
1625
  close_all_drawers(gobj);
1624
1626
  close_toolbar_dropdown(gobj);
@@ -2645,6 +2647,40 @@ function remembered_position(gobj, route) {
2645
2647
  return last;
2646
2648
  }
2647
2649
  /************************************************************
2650
+ * Every route the shell lands on, newest last, a route once.
2651
+ * Bounded: it answers "where was the reader last under X", and
2652
+ * a page that has been open all day has no business growing it.
2653
+ ************************************************************/
2654
+ var ROUTE_MRU_MAX = 64;
2655
+ function remember_route(gobj, route) {
2656
+ let mru = gobj.priv.route_mru;
2657
+ let i = mru.indexOf(route);
2658
+ if (i >= 0) mru.splice(i, 1);
2659
+ mru.push(route);
2660
+ if (mru.length > ROUTE_MRU_MAX) mru.splice(0, mru.length - ROUTE_MRU_MAX);
2661
+ }
2662
+ /************************************************************
2663
+ * Where the reader last was UNDER `route`: the most recent
2664
+ * visited route that is `route` itself or lies below it, or
2665
+ * `route` when none does. A mirror of the url for the page, like
2666
+ * the section memory -- never stored, never applied on its own:
2667
+ * a control that wants to go back to a view AS IT WAS LEFT asks,
2668
+ * and navigates there itself.
2669
+ *
2670
+ * What it is for: a treedb graph's focus lives in the url
2671
+ * (`<graph>/<topic>`), so a button that opened the bare route
2672
+ * took the focus away every time it was used.
2673
+ ************************************************************/
2674
+ function yui_shell_last_route_under(shell_gobj, route) {
2675
+ let priv = shell_gobj && shell_gobj.priv;
2676
+ if (!priv || !(0, _yuneta_gobj_js.is_array)(priv.route_mru) || !route) return route;
2677
+ for (let i = priv.route_mru.length - 1; i >= 0; i--) {
2678
+ let r = priv.route_mru[i];
2679
+ if (r === route || r.indexOf(route + "/") === 0) return r;
2680
+ }
2681
+ return route;
2682
+ }
2683
+ /************************************************************
2648
2684
  * Resolve the shell that governs `gobj`: the nearest
2649
2685
  * C_YUI_SHELL ancestor, else the last shell created on the
2650
2686
  * page (apps have exactly one). Null when no shell exists —
@@ -21798,7 +21834,7 @@ var attrs_table$6 = [
21798
21834
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
21799
21835
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_cards_landing", 0, false, "Land on a grid of topic cards (list->detail): a card opens its table, with the tabs bar + a back-to-grid button. Off = tabs only (legacy)."),
21800
21836
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "readonly", 0, false, "The whole treedb is read-only: every topic opens without its write affordances and the write events are refused. Set it when this yuno is not the MASTER of the treedb's tranger -- only the master can write, and the yuno answers 'READ-ONLY' to a write since SDK 7.13.0 (ask `treedb-info`)"),
21801
- (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "card_action_routes", 0, null, "Per-card hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show 3 icon actions; absent ⇒ a single card that opens the table."),
21837
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "card_action_routes", 0, null, "Hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show the info and table icons, and `graph` without its `/{topic}` is the toolbar's graph button (the card has no graph icon: its topic segment is a focus); absent ⇒ a single card that opens the table."),
21802
21838
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "landing_routes", 0, null, "Host-supplied hashes for the two landing sub-views {cards, schema}; the toggle navigates to them so the landing is URL-addressable (ROUTING.md). Absent ⇒ toggle flips in-view only (legacy)."),
21803
21839
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "base_route", 0, "", "This view's base route (host-supplied); used to declare its sub-routes (topics / info / schema) to the site map (ROUTING.md contributor)."),
21804
21840
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_STRING, "source_url", 0, "", "The backend this view browses (host-supplied, typically the wss url of the connection), shown in the toolbar. A tab label names the TREEDB, and the same treedb name lives on more than one node, so the node has to be readable somewhere -- and a tab is too narrow to carry a url. Empty ⇒ nothing is shown."),
@@ -21908,15 +21944,12 @@ function cmd_get_topic_data$1(gobj, cmd, kw, src) {
21908
21944
  ************************************************************/
21909
21945
  function build_ui$6(gobj) {
21910
21946
  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\}$/, "") : "";
21947
+ let graph_href = graph_route_of(gobj);
21914
21948
  let graph_items = [];
21915
21949
  if (graph_href) graph_items.push([
21916
- "a",
21950
+ "button",
21917
21951
  {
21918
21952
  class: "button TREEDB_GRAPH_BTN",
21919
- href: graph_href,
21920
21953
  style: "margin-left:auto;",
21921
21954
  title: (0, i18next.t)("graph"),
21922
21955
  "aria-label": (0, i18next.t)("graph"),
@@ -21931,7 +21964,11 @@ function build_ui$6(gobj) {
21931
21964
  "span",
21932
21965
  { i18n: "graph" },
21933
21966
  "graph"
21934
- ]]
21967
+ ]],
21968
+ { click: (evt) => {
21969
+ evt.stopPropagation();
21970
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_OPEN_GRAPH", {}, gobj);
21971
+ } }
21935
21972
  ]);
21936
21973
  let $container = (0, _yuneta_gobj_js.createElement2)([
21937
21974
  "div",
@@ -22092,6 +22129,17 @@ function build_ui$6(gobj) {
22092
22129
  if ((0, _yuneta_gobj_js.gobj_read_bool_attr)(gobj, "with_cards_landing")) show_topics_landing(gobj);
22093
22130
  }
22094
22131
  /************************************************************
22132
+ * The graph's route with no topic: the host's card template
22133
+ * `card_action_routes.graph` without its trailing `/{topic}`,
22134
+ * as a ROUTE (no `#`). "" when the host gives no such template.
22135
+ ************************************************************/
22136
+ function graph_route_of(gobj) {
22137
+ let routes = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "card_action_routes");
22138
+ let tpl = (0, _yuneta_gobj_js.is_object)(routes) && typeof routes.graph === "string" ? routes.graph : "";
22139
+ if (!/\/\{topic\}$/.test(tpl)) return "";
22140
+ return tpl.replace(/\/\{topic\}$/, "").replace(/^#/, "");
22141
+ }
22142
+ /************************************************************
22095
22143
  * Is the backend session live? The Raw JSON viewer issues a remote
22096
22144
  * print-tranger, so it only makes sense with a session — the remote
22097
22145
  * (C_IEVENT_CLI) is in ST_SESSION exactly while connected.
@@ -22221,13 +22269,103 @@ function card_action_anchor(logical_class, icon, i18n_key, href) {
22221
22269
  ];
22222
22270
  }
22223
22271
  /************************************************************
22272
+ * What a topic card says about its topic, from its desc alone
22273
+ * (no request): its version and how many columns it has, the
22274
+ * topics it hangs FROM (its fkeys) and the ones that hang from
22275
+ * it (its hooks). The names are DATA and are not translated;
22276
+ * each label carries its key. Lists are cut with an ellipsis,
22277
+ * whole in the title.
22278
+ ************************************************************/
22279
+ function topic_card_meta(desc) {
22280
+ if (!(0, _yuneta_gobj_js.is_object)(desc)) return [];
22281
+ let cols = (0, _yuneta_gobj_js.is_array)(desc.cols) ? desc.cols : [];
22282
+ let n_cols = 0;
22283
+ let parents = [];
22284
+ let children = [];
22285
+ for (let col of cols) {
22286
+ if (!col || !col.id || col.id.charAt(0) === "_") continue;
22287
+ n_cols++;
22288
+ if ((0, _yuneta_gobj_js.is_object)(col.fkey)) {
22289
+ for (let t_name of Object.keys(col.fkey)) if (!parents.includes(t_name)) parents.push(t_name);
22290
+ }
22291
+ if ((0, _yuneta_gobj_js.is_object)(col.hook)) {
22292
+ for (let t_name of Object.keys(col.hook)) if (!children.includes(t_name)) children.push(t_name);
22293
+ }
22294
+ }
22295
+ let meta = [];
22296
+ let head = [];
22297
+ if (desc.topic_version !== void 0 && desc.topic_version !== null && desc.topic_version !== "") head.push([
22298
+ "span",
22299
+ {
22300
+ class: "TREEDB_TOPIC_CARD_VERSION",
22301
+ title: (0, i18next.t)("version"),
22302
+ "data-i18n-title": "version"
22303
+ },
22304
+ `v${desc.topic_version}`
22305
+ ]);
22306
+ head.push([
22307
+ "span",
22308
+ { class: "TREEDB_TOPIC_CARD_COLS" },
22309
+ [[
22310
+ "span",
22311
+ {},
22312
+ `${n_cols}`
22313
+ ], [
22314
+ "span",
22315
+ { i18n: "columns" },
22316
+ "columns"
22317
+ ]]
22318
+ ]);
22319
+ meta.push([
22320
+ "div",
22321
+ { class: "TREEDB_TOPIC_CARD_META" },
22322
+ head
22323
+ ]);
22324
+ let relation = (cls, key, arrow, names) => {
22325
+ let list = names.join(", ");
22326
+ return [
22327
+ "div",
22328
+ {
22329
+ class: `TREEDB_TOPIC_CARD_REL ${cls}`,
22330
+ title: list
22331
+ },
22332
+ [
22333
+ [
22334
+ "span",
22335
+ {
22336
+ class: "TREEDB_TOPIC_CARD_REL_ARROW",
22337
+ "aria-hidden": "true"
22338
+ },
22339
+ arrow
22340
+ ],
22341
+ [
22342
+ "span",
22343
+ {
22344
+ class: "TREEDB_TOPIC_CARD_REL_LABEL",
22345
+ i18n: key
22346
+ },
22347
+ key
22348
+ ],
22349
+ [
22350
+ "span",
22351
+ { class: "TREEDB_TOPIC_CARD_REL_LIST" },
22352
+ list
22353
+ ]
22354
+ ]
22355
+ ];
22356
+ };
22357
+ if (parents.length) meta.push(relation("TREEDB_TOPIC_CARD_PARENTS", "parents", "↑", parents));
22358
+ if (children.length) meta.push(relation("TREEDB_TOPIC_CARD_CHILDREN", "children", "↓", children));
22359
+ return meta;
22360
+ }
22361
+ /************************************************************
22224
22362
  * Add one topic card to the cards-landing grid (list->detail).
22225
22363
  * With `card_action_routes` (host-supplied templates) the card
22226
22364
  * shows three hash-routed icons — info / table / graph. Without,
22227
22365
  * it falls back to a single card that opens the table (same id
22228
22366
  * contract as the tab, `<gobj>?<topic>`).
22229
22367
  ************************************************************/
22230
- function add_topic_card(gobj, id, text, icon) {
22368
+ function add_topic_card(gobj, id, text, icon, desc) {
22231
22369
  let $grid = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container").querySelector(".TREEDB_TOPICS_LANDING .yui-nav-cards");
22232
22370
  if (!$grid) return;
22233
22371
  let routes = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "card_action_routes");
@@ -22270,25 +22408,28 @@ function add_topic_card(gobj, id, text, icon) {
22270
22408
  let actions = [];
22271
22409
  if (routes.info) actions.push(card_action_anchor("TREEDB_CARD_INFO", "yi-circle-info", "info", fill(routes.info)));
22272
22410
  if (routes.table) actions.push(card_action_anchor("TREEDB_CARD_TABLE", "yi-table", "table", fill(routes.table)));
22273
- if (routes.graph) actions.push(card_action_anchor("TREEDB_CARD_GRAPH", "yi-hexagon-nodes", "graph", fill(routes.graph)));
22274
22411
  let $card = (0, _yuneta_gobj_js.createElement2)([
22275
22412
  "div",
22276
22413
  {
22277
22414
  class: "TREEDB_TOPIC_CARD",
22278
22415
  "data-topic": topic
22279
22416
  },
22280
- [[
22281
- "span",
22282
- {
22283
- class: "TREEDB_TOPIC_CARD_NAME",
22284
- i18n: text
22285
- },
22286
- text
22287
- ], [
22288
- "div",
22289
- { class: "TREEDB_TOPIC_CARD_ACTIONS" },
22290
- actions
22291
- ]]
22417
+ [
22418
+ [
22419
+ "span",
22420
+ {
22421
+ class: "TREEDB_TOPIC_CARD_NAME",
22422
+ i18n: text
22423
+ },
22424
+ text
22425
+ ],
22426
+ ...topic_card_meta(desc),
22427
+ [
22428
+ "div",
22429
+ { class: "TREEDB_TOPIC_CARD_ACTIONS" },
22430
+ actions
22431
+ ]
22432
+ ]
22292
22433
  ]);
22293
22434
  if (gobj.priv._selected_card_topic === topic) $card.classList.add("is-selected");
22294
22435
  $card.addEventListener("click", function() {
@@ -22641,7 +22782,7 @@ function process_treedb_descs$1(gobj) {
22641
22782
  let id = `${(0, _yuneta_gobj_js.gobj_name)(gobj)}?${desc.topic_name}`;
22642
22783
  let gobj_topic_form = (0, _yuneta_gobj_js.gobj_create_service)(id, "C_YUI_TREEDB_TOPIC_WITH_FORM", kw_topic_form, gobj);
22643
22784
  add_tab(gobj, gobj_topic_form, id, key, "yi-table");
22644
- add_topic_card(gobj, id, key, "yi-table");
22785
+ add_topic_card(gobj, id, key, "yi-table", desc);
22645
22786
  (0, _yuneta_gobj_js.gobj_start)(gobj_topic_form);
22646
22787
  }
22647
22788
  let href = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "last_selection");
@@ -23367,6 +23508,26 @@ function ac_open_json$1(gobj, event, kw, src) {
23367
23508
  return 0;
23368
23509
  }
23369
23510
  /********************************************
23511
+ * Open the graph where the reader left it: its last visited
23512
+ * route under the graph's base -- `<graph>/<topic>` when a topic
23513
+ * was focused there -- or the bare base when the graph was never
23514
+ * opened in this page. A user move: pushed, so Back returns.
23515
+ ********************************************/
23516
+ function ac_open_graph(gobj, event, kw, src) {
23517
+ let base = graph_route_of(gobj);
23518
+ if (!base) {
23519
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: open graph with no graph route`);
23520
+ return -1;
23521
+ }
23522
+ let shell = yui_shell_of(gobj);
23523
+ if (!shell) {
23524
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: open graph with no shell`);
23525
+ return -1;
23526
+ }
23527
+ yui_shell_navigate(shell, yui_shell_last_route_under(shell, base), { push: true });
23528
+ return 0;
23529
+ }
23530
+ /********************************************
23370
23531
  * The viewer asked to load a collapsed subtree (tranger mode only):
23371
23532
  * re-issue print-tranger for that path.
23372
23533
  ********************************************/
@@ -23465,6 +23626,11 @@ function create_gclass$6(gclass_name) {
23465
23626
  ac_open_json$1,
23466
23627
  null
23467
23628
  ],
23629
+ [
23630
+ "EV_OPEN_GRAPH",
23631
+ ac_open_graph,
23632
+ null
23633
+ ],
23468
23634
  [
23469
23635
  "EV_EXPAND_PATH",
23470
23636
  ac_json_expand_path$1,
@@ -23529,6 +23695,7 @@ function create_gclass$6(gclass_name) {
23529
23695
  ["EV_RECORD_WRITTEN", _yuneta_gobj_js.event_flag_t.EVF_OUTPUT_EVENT | _yuneta_gobj_js.event_flag_t.EVF_NO_WARN_SUBS],
23530
23696
  ["EV_REFRESH_TOPIC", 0],
23531
23697
  ["EV_OPEN_JSON", 0],
23698
+ ["EV_OPEN_GRAPH", 0],
23532
23699
  ["EV_EXPAND_PATH", 0],
23533
23700
  ["EV_JSON_CLOSED", 0],
23534
23701
  ["EV_SHOW", 0],
@@ -906,6 +906,7 @@ var PRIVATE_DATA$21 = {
906
906
  navs: [],
907
907
  item_index: {},
908
908
  section_pos: {},
909
+ route_mru: [],
909
910
  sub_routes: {},
910
911
  event_handlers: {},
911
912
  hash_handler: null,
@@ -1614,6 +1615,7 @@ function navigate_to(gobj, route, depth, no_drain) {
1614
1615
  stage.active_route = matched_route;
1615
1616
  gobj_write_attr(gobj, "current_route", route);
1616
1617
  remember_position(gobj, route);
1618
+ remember_route(gobj, route);
1617
1619
  update_secondary_nav_visibility(gobj, entry);
1618
1620
  close_all_drawers(gobj);
1619
1621
  close_toolbar_dropdown(gobj);
@@ -2640,6 +2642,40 @@ function remembered_position(gobj, route) {
2640
2642
  return last;
2641
2643
  }
2642
2644
  /************************************************************
2645
+ * Every route the shell lands on, newest last, a route once.
2646
+ * Bounded: it answers "where was the reader last under X", and
2647
+ * a page that has been open all day has no business growing it.
2648
+ ************************************************************/
2649
+ var ROUTE_MRU_MAX = 64;
2650
+ function remember_route(gobj, route) {
2651
+ let mru = gobj.priv.route_mru;
2652
+ let i = mru.indexOf(route);
2653
+ if (i >= 0) mru.splice(i, 1);
2654
+ mru.push(route);
2655
+ if (mru.length > ROUTE_MRU_MAX) mru.splice(0, mru.length - ROUTE_MRU_MAX);
2656
+ }
2657
+ /************************************************************
2658
+ * Where the reader last was UNDER `route`: the most recent
2659
+ * visited route that is `route` itself or lies below it, or
2660
+ * `route` when none does. A mirror of the url for the page, like
2661
+ * the section memory -- never stored, never applied on its own:
2662
+ * a control that wants to go back to a view AS IT WAS LEFT asks,
2663
+ * and navigates there itself.
2664
+ *
2665
+ * What it is for: a treedb graph's focus lives in the url
2666
+ * (`<graph>/<topic>`), so a button that opened the bare route
2667
+ * took the focus away every time it was used.
2668
+ ************************************************************/
2669
+ function yui_shell_last_route_under(shell_gobj, route) {
2670
+ let priv = shell_gobj && shell_gobj.priv;
2671
+ if (!priv || !is_array(priv.route_mru) || !route) return route;
2672
+ for (let i = priv.route_mru.length - 1; i >= 0; i--) {
2673
+ let r = priv.route_mru[i];
2674
+ if (r === route || r.indexOf(route + "/") === 0) return r;
2675
+ }
2676
+ return route;
2677
+ }
2678
+ /************************************************************
2643
2679
  * Resolve the shell that governs `gobj`: the nearest
2644
2680
  * C_YUI_SHELL ancestor, else the last shell created on the
2645
2681
  * page (apps have exactly one). Null when no shell exists —
@@ -21793,7 +21829,7 @@ var attrs_table$6 = [
21793
21829
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
21794
21830
  SDATA(data_type_t.DTP_BOOLEAN, "with_cards_landing", 0, false, "Land on a grid of topic cards (list->detail): a card opens its table, with the tabs bar + a back-to-grid button. Off = tabs only (legacy)."),
21795
21831
  SDATA(data_type_t.DTP_BOOLEAN, "readonly", 0, false, "The whole treedb is read-only: every topic opens without its write affordances and the write events are refused. Set it when this yuno is not the MASTER of the treedb's tranger -- only the master can write, and the yuno answers 'READ-ONLY' to a write since SDK 7.13.0 (ask `treedb-info`)"),
21796
- SDATA(data_type_t.DTP_JSON, "card_action_routes", 0, null, "Per-card hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show 3 icon actions; absent ⇒ a single card that opens the table."),
21832
+ SDATA(data_type_t.DTP_JSON, "card_action_routes", 0, null, "Hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show the info and table icons, and `graph` without its `/{topic}` is the toolbar's graph button (the card has no graph icon: its topic segment is a focus); absent ⇒ a single card that opens the table."),
21797
21833
  SDATA(data_type_t.DTP_JSON, "landing_routes", 0, null, "Host-supplied hashes for the two landing sub-views {cards, schema}; the toggle navigates to them so the landing is URL-addressable (ROUTING.md). Absent ⇒ toggle flips in-view only (legacy)."),
21798
21834
  SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "This view's base route (host-supplied); used to declare its sub-routes (topics / info / schema) to the site map (ROUTING.md contributor)."),
21799
21835
  SDATA(data_type_t.DTP_STRING, "source_url", 0, "", "The backend this view browses (host-supplied, typically the wss url of the connection), shown in the toolbar. A tab label names the TREEDB, and the same treedb name lives on more than one node, so the node has to be readable somewhere -- and a tab is too narrow to carry a url. Empty ⇒ nothing is shown."),
@@ -21903,15 +21939,12 @@ function cmd_get_topic_data$1(gobj, cmd, kw, src) {
21903
21939
  ************************************************************/
21904
21940
  function build_ui$6(gobj) {
21905
21941
  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\}$/, "") : "";
21942
+ let graph_href = graph_route_of(gobj);
21909
21943
  let graph_items = [];
21910
21944
  if (graph_href) graph_items.push([
21911
- "a",
21945
+ "button",
21912
21946
  {
21913
21947
  class: "button TREEDB_GRAPH_BTN",
21914
- href: graph_href,
21915
21948
  style: "margin-left:auto;",
21916
21949
  title: t("graph"),
21917
21950
  "aria-label": t("graph"),
@@ -21926,7 +21959,11 @@ function build_ui$6(gobj) {
21926
21959
  "span",
21927
21960
  { i18n: "graph" },
21928
21961
  "graph"
21929
- ]]
21962
+ ]],
21963
+ { click: (evt) => {
21964
+ evt.stopPropagation();
21965
+ gobj_send_event(gobj, "EV_OPEN_GRAPH", {}, gobj);
21966
+ } }
21930
21967
  ]);
21931
21968
  let $container = createElement2([
21932
21969
  "div",
@@ -22087,6 +22124,17 @@ function build_ui$6(gobj) {
22087
22124
  if (gobj_read_bool_attr(gobj, "with_cards_landing")) show_topics_landing(gobj);
22088
22125
  }
22089
22126
  /************************************************************
22127
+ * The graph's route with no topic: the host's card template
22128
+ * `card_action_routes.graph` without its trailing `/{topic}`,
22129
+ * as a ROUTE (no `#`). "" when the host gives no such template.
22130
+ ************************************************************/
22131
+ function graph_route_of(gobj) {
22132
+ let routes = gobj_read_attr(gobj, "card_action_routes");
22133
+ let tpl = is_object(routes) && typeof routes.graph === "string" ? routes.graph : "";
22134
+ if (!/\/\{topic\}$/.test(tpl)) return "";
22135
+ return tpl.replace(/\/\{topic\}$/, "").replace(/^#/, "");
22136
+ }
22137
+ /************************************************************
22090
22138
  * Is the backend session live? The Raw JSON viewer issues a remote
22091
22139
  * print-tranger, so it only makes sense with a session — the remote
22092
22140
  * (C_IEVENT_CLI) is in ST_SESSION exactly while connected.
@@ -22216,13 +22264,103 @@ function card_action_anchor(logical_class, icon, i18n_key, href) {
22216
22264
  ];
22217
22265
  }
22218
22266
  /************************************************************
22267
+ * What a topic card says about its topic, from its desc alone
22268
+ * (no request): its version and how many columns it has, the
22269
+ * topics it hangs FROM (its fkeys) and the ones that hang from
22270
+ * it (its hooks). The names are DATA and are not translated;
22271
+ * each label carries its key. Lists are cut with an ellipsis,
22272
+ * whole in the title.
22273
+ ************************************************************/
22274
+ function topic_card_meta(desc) {
22275
+ if (!is_object(desc)) return [];
22276
+ let cols = is_array(desc.cols) ? desc.cols : [];
22277
+ let n_cols = 0;
22278
+ let parents = [];
22279
+ let children = [];
22280
+ for (let col of cols) {
22281
+ if (!col || !col.id || col.id.charAt(0) === "_") continue;
22282
+ n_cols++;
22283
+ if (is_object(col.fkey)) {
22284
+ for (let t_name of Object.keys(col.fkey)) if (!parents.includes(t_name)) parents.push(t_name);
22285
+ }
22286
+ if (is_object(col.hook)) {
22287
+ for (let t_name of Object.keys(col.hook)) if (!children.includes(t_name)) children.push(t_name);
22288
+ }
22289
+ }
22290
+ let meta = [];
22291
+ let head = [];
22292
+ if (desc.topic_version !== void 0 && desc.topic_version !== null && desc.topic_version !== "") head.push([
22293
+ "span",
22294
+ {
22295
+ class: "TREEDB_TOPIC_CARD_VERSION",
22296
+ title: t("version"),
22297
+ "data-i18n-title": "version"
22298
+ },
22299
+ `v${desc.topic_version}`
22300
+ ]);
22301
+ head.push([
22302
+ "span",
22303
+ { class: "TREEDB_TOPIC_CARD_COLS" },
22304
+ [[
22305
+ "span",
22306
+ {},
22307
+ `${n_cols}`
22308
+ ], [
22309
+ "span",
22310
+ { i18n: "columns" },
22311
+ "columns"
22312
+ ]]
22313
+ ]);
22314
+ meta.push([
22315
+ "div",
22316
+ { class: "TREEDB_TOPIC_CARD_META" },
22317
+ head
22318
+ ]);
22319
+ let relation = (cls, key, arrow, names) => {
22320
+ let list = names.join(", ");
22321
+ return [
22322
+ "div",
22323
+ {
22324
+ class: `TREEDB_TOPIC_CARD_REL ${cls}`,
22325
+ title: list
22326
+ },
22327
+ [
22328
+ [
22329
+ "span",
22330
+ {
22331
+ class: "TREEDB_TOPIC_CARD_REL_ARROW",
22332
+ "aria-hidden": "true"
22333
+ },
22334
+ arrow
22335
+ ],
22336
+ [
22337
+ "span",
22338
+ {
22339
+ class: "TREEDB_TOPIC_CARD_REL_LABEL",
22340
+ i18n: key
22341
+ },
22342
+ key
22343
+ ],
22344
+ [
22345
+ "span",
22346
+ { class: "TREEDB_TOPIC_CARD_REL_LIST" },
22347
+ list
22348
+ ]
22349
+ ]
22350
+ ];
22351
+ };
22352
+ if (parents.length) meta.push(relation("TREEDB_TOPIC_CARD_PARENTS", "parents", "↑", parents));
22353
+ if (children.length) meta.push(relation("TREEDB_TOPIC_CARD_CHILDREN", "children", "↓", children));
22354
+ return meta;
22355
+ }
22356
+ /************************************************************
22219
22357
  * Add one topic card to the cards-landing grid (list->detail).
22220
22358
  * With `card_action_routes` (host-supplied templates) the card
22221
22359
  * shows three hash-routed icons — info / table / graph. Without,
22222
22360
  * it falls back to a single card that opens the table (same id
22223
22361
  * contract as the tab, `<gobj>?<topic>`).
22224
22362
  ************************************************************/
22225
- function add_topic_card(gobj, id, text, icon) {
22363
+ function add_topic_card(gobj, id, text, icon, desc) {
22226
22364
  let $grid = gobj_read_attr(gobj, "$container").querySelector(".TREEDB_TOPICS_LANDING .yui-nav-cards");
22227
22365
  if (!$grid) return;
22228
22366
  let routes = gobj_read_attr(gobj, "card_action_routes");
@@ -22265,25 +22403,28 @@ function add_topic_card(gobj, id, text, icon) {
22265
22403
  let actions = [];
22266
22404
  if (routes.info) actions.push(card_action_anchor("TREEDB_CARD_INFO", "yi-circle-info", "info", fill(routes.info)));
22267
22405
  if (routes.table) actions.push(card_action_anchor("TREEDB_CARD_TABLE", "yi-table", "table", fill(routes.table)));
22268
- if (routes.graph) actions.push(card_action_anchor("TREEDB_CARD_GRAPH", "yi-hexagon-nodes", "graph", fill(routes.graph)));
22269
22406
  let $card = createElement2([
22270
22407
  "div",
22271
22408
  {
22272
22409
  class: "TREEDB_TOPIC_CARD",
22273
22410
  "data-topic": topic
22274
22411
  },
22275
- [[
22276
- "span",
22277
- {
22278
- class: "TREEDB_TOPIC_CARD_NAME",
22279
- i18n: text
22280
- },
22281
- text
22282
- ], [
22283
- "div",
22284
- { class: "TREEDB_TOPIC_CARD_ACTIONS" },
22285
- actions
22286
- ]]
22412
+ [
22413
+ [
22414
+ "span",
22415
+ {
22416
+ class: "TREEDB_TOPIC_CARD_NAME",
22417
+ i18n: text
22418
+ },
22419
+ text
22420
+ ],
22421
+ ...topic_card_meta(desc),
22422
+ [
22423
+ "div",
22424
+ { class: "TREEDB_TOPIC_CARD_ACTIONS" },
22425
+ actions
22426
+ ]
22427
+ ]
22287
22428
  ]);
22288
22429
  if (gobj.priv._selected_card_topic === topic) $card.classList.add("is-selected");
22289
22430
  $card.addEventListener("click", function() {
@@ -22636,7 +22777,7 @@ function process_treedb_descs$1(gobj) {
22636
22777
  let id = `${gobj_name(gobj)}?${desc.topic_name}`;
22637
22778
  let gobj_topic_form = gobj_create_service(id, "C_YUI_TREEDB_TOPIC_WITH_FORM", kw_topic_form, gobj);
22638
22779
  add_tab(gobj, gobj_topic_form, id, key, "yi-table");
22639
- add_topic_card(gobj, id, key, "yi-table");
22780
+ add_topic_card(gobj, id, key, "yi-table", desc);
22640
22781
  gobj_start(gobj_topic_form);
22641
22782
  }
22642
22783
  let href = gobj_read_str_attr(gobj, "last_selection");
@@ -23362,6 +23503,26 @@ function ac_open_json$1(gobj, event, kw, src) {
23362
23503
  return 0;
23363
23504
  }
23364
23505
  /********************************************
23506
+ * Open the graph where the reader left it: its last visited
23507
+ * route under the graph's base -- `<graph>/<topic>` when a topic
23508
+ * was focused there -- or the bare base when the graph was never
23509
+ * opened in this page. A user move: pushed, so Back returns.
23510
+ ********************************************/
23511
+ function ac_open_graph(gobj, event, kw, src) {
23512
+ let base = graph_route_of(gobj);
23513
+ if (!base) {
23514
+ log_error(`${gobj_short_name(gobj)}: open graph with no graph route`);
23515
+ return -1;
23516
+ }
23517
+ let shell = yui_shell_of(gobj);
23518
+ if (!shell) {
23519
+ log_error(`${gobj_short_name(gobj)}: open graph with no shell`);
23520
+ return -1;
23521
+ }
23522
+ yui_shell_navigate(shell, yui_shell_last_route_under(shell, base), { push: true });
23523
+ return 0;
23524
+ }
23525
+ /********************************************
23365
23526
  * The viewer asked to load a collapsed subtree (tranger mode only):
23366
23527
  * re-issue print-tranger for that path.
23367
23528
  ********************************************/
@@ -23460,6 +23621,11 @@ function create_gclass$6(gclass_name) {
23460
23621
  ac_open_json$1,
23461
23622
  null
23462
23623
  ],
23624
+ [
23625
+ "EV_OPEN_GRAPH",
23626
+ ac_open_graph,
23627
+ null
23628
+ ],
23463
23629
  [
23464
23630
  "EV_EXPAND_PATH",
23465
23631
  ac_json_expand_path$1,
@@ -23524,6 +23690,7 @@ function create_gclass$6(gclass_name) {
23524
23690
  ["EV_RECORD_WRITTEN", event_flag_t.EVF_OUTPUT_EVENT | event_flag_t.EVF_NO_WARN_SUBS],
23525
23691
  ["EV_REFRESH_TOPIC", 0],
23526
23692
  ["EV_OPEN_JSON", 0],
23693
+ ["EV_OPEN_GRAPH", 0],
23527
23694
  ["EV_EXPAND_PATH", 0],
23528
23695
  ["EV_JSON_CLOSED", 0],
23529
23696
  ["EV_SHOW", 0],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.138",
3
+ "version": "7.23.140",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
package/src/c_yui_node.js CHANGED
@@ -1324,6 +1324,12 @@ function ac_add_node(gobj, event, kw, src)
1324
1324
  if(spec.link) {
1325
1325
  kw_child.link = spec.link;
1326
1326
  }
1327
+ /* A declared attr of this gclass that the spec could not reach: a
1328
+ * node whose children are workspaces (a treedb: its topics, its
1329
+ * graph) is declared in the config like every other one. */
1330
+ if(spec.remember_position) {
1331
+ kw_child.remember_position = true;
1332
+ }
1327
1333
  let child = gobj_create_pure_child(spec.id, GCLASS_NAME, kw_child, gobj);
1328
1334
 
1329
1335
  let index = (typeof kw.index === "number") ? kw.index : priv.children.length;
@@ -117,6 +117,10 @@ let PRIVATE_DATA = {
117
117
  /* Section route -> last position visited inside it. Mirrors the
118
118
  * url for the duration of the page; see remember_position(). */
119
119
  section_pos: {},
120
+ /* The routes visited, newest last, bounded -- for "where did the
121
+ * reader leave this subtree" (yui_shell_last_route_under). Same
122
+ * nature as section_pos: a mirror of the url, for the page. */
123
+ route_mru: [],
120
124
  /* Sub-route contributor registry (ROUTING.md): a mounted view
121
125
  * declares the deep, view-owned children of its base route
122
126
  * (topics, /info, /schema, focus topics — subpaths that are NOT
@@ -1465,6 +1469,7 @@ function navigate_to(gobj, route, depth, no_drain)
1465
1469
  stage.active_route = matched_route;
1466
1470
  gobj_write_attr(gobj, "current_route", route);
1467
1471
  remember_position(gobj, route);
1472
+ remember_route(gobj, route);
1468
1473
 
1469
1474
  /* Show/hide secondary navs according to parent item */
1470
1475
  update_secondary_nav_visibility(gobj, entry);
@@ -3187,6 +3192,54 @@ function remembered_position(gobj, route)
3187
3192
  return last;
3188
3193
  }
3189
3194
 
3195
+ /************************************************************
3196
+ * Every route the shell lands on, newest last, a route once.
3197
+ * Bounded: it answers "where was the reader last under X", and
3198
+ * a page that has been open all day has no business growing it.
3199
+ ************************************************************/
3200
+ const ROUTE_MRU_MAX = 64;
3201
+
3202
+ function remember_route(gobj, route)
3203
+ {
3204
+ let priv = gobj.priv;
3205
+ let mru = priv.route_mru;
3206
+ let i = mru.indexOf(route);
3207
+ if(i >= 0) {
3208
+ mru.splice(i, 1);
3209
+ }
3210
+ mru.push(route);
3211
+ if(mru.length > ROUTE_MRU_MAX) {
3212
+ mru.splice(0, mru.length - ROUTE_MRU_MAX);
3213
+ }
3214
+ }
3215
+
3216
+ /************************************************************
3217
+ * Where the reader last was UNDER `route`: the most recent
3218
+ * visited route that is `route` itself or lies below it, or
3219
+ * `route` when none does. A mirror of the url for the page, like
3220
+ * the section memory -- never stored, never applied on its own:
3221
+ * a control that wants to go back to a view AS IT WAS LEFT asks,
3222
+ * and navigates there itself.
3223
+ *
3224
+ * What it is for: a treedb graph's focus lives in the url
3225
+ * (`<graph>/<topic>`), so a button that opened the bare route
3226
+ * took the focus away every time it was used.
3227
+ ************************************************************/
3228
+ function yui_shell_last_route_under(shell_gobj, route)
3229
+ {
3230
+ let priv = shell_gobj && shell_gobj.priv;
3231
+ if(!priv || !is_array(priv.route_mru) || !route) {
3232
+ return route;
3233
+ }
3234
+ for(let i = priv.route_mru.length - 1; i >= 0; i--) {
3235
+ let r = priv.route_mru[i];
3236
+ if(r === route || r.indexOf(route + "/") === 0) {
3237
+ return r;
3238
+ }
3239
+ }
3240
+ return route;
3241
+ }
3242
+
3190
3243
  /************************************************************
3191
3244
  * Resolve the shell that governs `gobj`: the nearest
3192
3245
  * C_YUI_SHELL ancestor, else the last shell created on the
@@ -3609,6 +3662,7 @@ export {
3609
3662
  yui_shell_of,
3610
3663
  yui_shell_zone,
3611
3664
  yui_shell_navigate,
3665
+ yui_shell_last_route_under,
3612
3666
  yui_shell_nav_map,
3613
3667
  yui_shell_set_sub_routes,
3614
3668
  yui_shell_register_event_handler,
@@ -36,6 +36,31 @@
36
36
  line-height: 1.2;
37
37
  word-break: break-word;
38
38
  }
39
+ /* What the card says about its topic: version + columns, then the
40
+ * topics it hangs from and the ones hanging from it. Muted and small,
41
+ * under the name; a long list is cut, whole in the title. The spans
42
+ * are spaced by `gap` because createElement2 trims text nodes. */
43
+ .TREEDB_TOPIC_CARD_META,
44
+ .TREEDB_TOPIC_CARD_REL {
45
+ display: flex;
46
+ align-items: baseline;
47
+ justify-content: center;
48
+ gap: 0.3rem;
49
+ max-width: 100%;
50
+ font-size: 0.75rem;
51
+ color: var(--bulma-text-weak, #7a7a7a);
52
+ white-space: nowrap;
53
+ }
54
+ .TREEDB_TOPIC_CARD_COLS {
55
+ display: inline-flex;
56
+ gap: 0.25rem;
57
+ }
58
+ .TREEDB_TOPIC_CARD_REL_LIST {
59
+ min-width: 0;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ color: var(--bulma-text, inherit);
63
+ }
39
64
  .TREEDB_TOPIC_CARD_ACTIONS {
40
65
  display: flex;
41
66
  justify-content: center;
@@ -58,7 +58,12 @@ import {
58
58
  import "./c_yui_treedb_topics.css";
59
59
 
60
60
  import {yui_shell_show_error, yui_shell_show_modal, yui_shell_popup_layer} from "./shell_modals.js";
61
- import {yui_shell_of, yui_shell_set_sub_routes} from "./c_yui_shell.js";
61
+ import {
62
+ yui_shell_of,
63
+ yui_shell_set_sub_routes,
64
+ yui_shell_navigate,
65
+ yui_shell_last_route_under,
66
+ } from "./c_yui_shell.js";
62
67
  import {nodes_answer} from "./nodes_answer.js";
63
68
  import {yui_toolbar_icon} from "./yui_toolbar.js";
64
69
 
@@ -84,7 +89,7 @@ SDATA(data_type_t.DTP_BOOLEAN, "system", 0, false, "Manage system t
84
89
  SDATA(data_type_t.DTP_STRING, "tabs_style", 0, "is-toggle is-fullwidth", "Bulma tab styling"),
85
90
  SDATA(data_type_t.DTP_BOOLEAN, "with_cards_landing",0, false, "Land on a grid of topic cards (list->detail): a card opens its table, with the tabs bar + a back-to-grid button. Off = tabs only (legacy)."),
86
91
  SDATA(data_type_t.DTP_BOOLEAN, "readonly", 0, false, "The whole treedb is read-only: every topic opens without its write affordances and the write events are refused. Set it when this yuno is not the MASTER of the treedb's tranger -- only the master can write, and the yuno answers 'READ-ONLY' to a write since SDK 7.13.0 (ask `treedb-info`)"),
87
- SDATA(data_type_t.DTP_JSON, "card_action_routes",0, null, "Per-card hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show 3 icon actions; absent ⇒ a single card that opens the table."),
92
+ SDATA(data_type_t.DTP_JSON, "card_action_routes",0, null, "Hash-route templates {info, table, graph} with a {topic} placeholder (host-supplied, route-agnostic). Present ⇒ cards show the info and table icons, and `graph` without its `/{topic}` is the toolbar's graph button (the card has no graph icon: its topic segment is a focus); absent ⇒ a single card that opens the table."),
88
93
  SDATA(data_type_t.DTP_JSON, "landing_routes", 0, null, "Host-supplied hashes for the two landing sub-views {cards, schema}; the toggle navigates to them so the landing is URL-addressable (ROUTING.md). Absent ⇒ toggle flips in-view only (legacy)."),
89
94
  SDATA(data_type_t.DTP_STRING, "base_route", 0, "", "This view's base route (host-supplied); used to declare its sub-routes (topics / info / schema) to the site map (ROUTING.md contributor)."),
90
95
  SDATA(data_type_t.DTP_STRING, "source_url", 0, "", "The backend this view browses (host-supplied, typically the wss url of the connection), shown in the toolbar. A tab label names the TREEDB, and the same treedb name lives on more than one node, so the node has to be readable somewhere -- and a tab is too narrow to carry a url. Empty ⇒ nothing is shown."),
@@ -254,26 +259,28 @@ function build_ui(gobj)
254
259
  {
255
260
  let source_url = gobj_read_str_attr(gobj, "source_url") || "";
256
261
 
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\}$/, "") : "";
262
+ /* The treedb as a graph, AS THE READER LEFT IT. The route is the
263
+ * card template without its `/{topic}`; a host whose template does
264
+ * not end in it, or gives none, gets no button. Not a fixed link:
265
+ * the graph's focus lives in its url (`<graph>/<topic>`), so the
266
+ * bare route took it away every time -- the click asks the shell
267
+ * where the reader last was under that route (ac_open_graph). */
268
+ let graph_href = graph_route_of(gobj);
266
269
  let graph_items = [];
267
270
  if(graph_href) {
268
271
  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'}, [
272
+ ['button', {class: 'button TREEDB_GRAPH_BTN',
273
+ style: 'margin-left:auto;',
274
+ title: t('graph'), 'aria-label': t('graph'),
275
+ 'data-i18n-title': 'graph', 'data-i18n-aria-label': 'graph'}, [
274
276
  ['span', {class: 'icon'}, [yui_toolbar_icon('yi-hexagon-nodes')]],
275
277
  ['span', {i18n: 'graph'}, 'graph']
276
- ]]
278
+ ], {
279
+ click: (evt) => {
280
+ evt.stopPropagation();
281
+ gobj_send_event(gobj, "EV_OPEN_GRAPH", {}, gobj);
282
+ }
283
+ }]
277
284
  );
278
285
  }
279
286
 
@@ -389,6 +396,21 @@ function build_ui(gobj)
389
396
  }
390
397
  }
391
398
 
399
+ /************************************************************
400
+ * The graph's route with no topic: the host's card template
401
+ * `card_action_routes.graph` without its trailing `/{topic}`,
402
+ * as a ROUTE (no `#`). "" when the host gives no such template.
403
+ ************************************************************/
404
+ function graph_route_of(gobj)
405
+ {
406
+ let routes = gobj_read_attr(gobj, "card_action_routes");
407
+ let tpl = (is_object(routes) && typeof routes.graph === "string")? routes.graph : "";
408
+ if(!/\/\{topic\}$/.test(tpl)) {
409
+ return "";
410
+ }
411
+ return tpl.replace(/\/\{topic\}$/, "").replace(/^#/, "");
412
+ }
413
+
392
414
  /************************************************************
393
415
  * Is the backend session live? The Raw JSON viewer issues a remote
394
416
  * print-tranger, so it only makes sense with a session — the remote
@@ -599,6 +621,74 @@ function card_action_anchor(logical_class, icon, i18n_key, href)
599
621
  ]];
600
622
  }
601
623
 
624
+ /************************************************************
625
+ * What a topic card says about its topic, from its desc alone
626
+ * (no request): its version and how many columns it has, the
627
+ * topics it hangs FROM (its fkeys) and the ones that hang from
628
+ * it (its hooks). The names are DATA and are not translated;
629
+ * each label carries its key. Lists are cut with an ellipsis,
630
+ * whole in the title.
631
+ ************************************************************/
632
+ function topic_card_meta(desc)
633
+ {
634
+ if(!is_object(desc)) {
635
+ return [];
636
+ }
637
+ let cols = is_array(desc.cols)? desc.cols : [];
638
+ let n_cols = 0;
639
+ let parents = [];
640
+ let children = [];
641
+ for(let col of cols) {
642
+ if(!col || !col.id || col.id.charAt(0) === "_") {
643
+ continue;
644
+ }
645
+ n_cols++;
646
+ if(is_object(col.fkey)) {
647
+ for(let t_name of Object.keys(col.fkey)) {
648
+ if(!parents.includes(t_name)) {
649
+ parents.push(t_name);
650
+ }
651
+ }
652
+ }
653
+ if(is_object(col.hook)) {
654
+ for(let t_name of Object.keys(col.hook)) {
655
+ if(!children.includes(t_name)) {
656
+ children.push(t_name);
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ let meta = [];
663
+ let head = [];
664
+ if(desc.topic_version !== undefined && desc.topic_version !== null && desc.topic_version !== "") {
665
+ head.push(['span', {class: 'TREEDB_TOPIC_CARD_VERSION',
666
+ title: t('version'), 'data-i18n-title': 'version'},
667
+ `v${desc.topic_version}`]);
668
+ }
669
+ head.push(['span', {class: 'TREEDB_TOPIC_CARD_COLS'}, [
670
+ ['span', {}, `${n_cols}`],
671
+ ['span', {i18n: 'columns'}, 'columns']
672
+ ]]);
673
+ meta.push(['div', {class: 'TREEDB_TOPIC_CARD_META'}, head]);
674
+
675
+ let relation = (cls, key, arrow, names) => {
676
+ let list = names.join(", ");
677
+ return ['div', {class: `TREEDB_TOPIC_CARD_REL ${cls}`, title: list}, [
678
+ ['span', {class: 'TREEDB_TOPIC_CARD_REL_ARROW', 'aria-hidden': 'true'}, arrow],
679
+ ['span', {class: 'TREEDB_TOPIC_CARD_REL_LABEL', i18n: key}, key],
680
+ ['span', {class: 'TREEDB_TOPIC_CARD_REL_LIST'}, list]
681
+ ]];
682
+ };
683
+ if(parents.length) {
684
+ meta.push(relation('TREEDB_TOPIC_CARD_PARENTS', 'parents', '↑', parents));
685
+ }
686
+ if(children.length) {
687
+ meta.push(relation('TREEDB_TOPIC_CARD_CHILDREN', 'children', '↓', children));
688
+ }
689
+ return meta;
690
+ }
691
+
602
692
  /************************************************************
603
693
  * Add one topic card to the cards-landing grid (list->detail).
604
694
  * With `card_action_routes` (host-supplied templates) the card
@@ -606,7 +696,7 @@ function card_action_anchor(logical_class, icon, i18n_key, href)
606
696
  * it falls back to a single card that opens the table (same id
607
697
  * contract as the tab, `<gobj>?<topic>`).
608
698
  ************************************************************/
609
- function add_topic_card(gobj, id, text, icon)
699
+ function add_topic_card(gobj, id, text, icon, desc)
610
700
  {
611
701
  let $container = gobj_read_attr(gobj, "$container");
612
702
  let $grid = $container.querySelector(".TREEDB_TOPICS_LANDING .yui-nav-cards");
@@ -635,9 +725,16 @@ function add_topic_card(gobj, id, text, icon)
635
725
  return;
636
726
  }
637
727
 
638
- /* Three-icon card: info / table / graph, all real hash anchors.
639
- * {topic} in each template is the raw topic name (identifier-safe,
640
- * matching the tab/host `<base_route>/<topic>` convention). */
728
+ /* Two-icon card: info / table, real hash anchors. {topic} in each
729
+ * template is the raw topic name (identifier-safe, matching the
730
+ * tab/host `<base_route>/<topic>` convention).
731
+ *
732
+ * NO graph icon any more. Its route was `<graph>/<topic>`, and that
733
+ * segment is a FOCUS: every way into the graph from a card arrived
734
+ * with that topic highlighted, over whatever the reader had left
735
+ * there. The graph is one button in the toolbar now, with no topic
736
+ * (routes.graph still feeds it), and the room goes to what the card
737
+ * can say about its topic. */
641
738
  let topic = id.indexOf("?") >= 0 ? id.split("?")[1] : id;
642
739
  let fill = (tpl) => String(tpl || "").replace("{topic}", topic);
643
740
 
@@ -650,14 +747,11 @@ function add_topic_card(gobj, id, text, icon)
650
747
  actions.push(card_action_anchor(
651
748
  "TREEDB_CARD_TABLE", "yi-table", "table", fill(routes.table)));
652
749
  }
653
- if(routes.graph) {
654
- actions.push(card_action_anchor(
655
- "TREEDB_CARD_GRAPH", "yi-hexagon-nodes", "graph", fill(routes.graph)));
656
- }
657
750
 
658
751
  let $card = createElement2(
659
752
  ['div', {class: 'TREEDB_TOPIC_CARD', 'data-topic': topic}, [
660
753
  ['span', {class: 'TREEDB_TOPIC_CARD_NAME', i18n: text}, text],
754
+ ...topic_card_meta(desc),
661
755
  ['div', {class: 'TREEDB_TOPIC_CARD_ACTIONS'}, actions]
662
756
  ]]
663
757
  );
@@ -1087,7 +1181,7 @@ function process_treedb_descs(gobj)
1087
1181
 
1088
1182
  // TODO get icon from remote config
1089
1183
  add_tab(gobj, gobj_topic_form, id, key, "yi-table");
1090
- add_topic_card(gobj, id, key, "yi-table");
1184
+ add_topic_card(gobj, id, key, "yi-table", desc);
1091
1185
 
1092
1186
  gobj_start(gobj_topic_form);
1093
1187
  }
@@ -2404,6 +2498,28 @@ function ac_open_json(gobj, event, kw, src)
2404
2498
  return 0;
2405
2499
  }
2406
2500
 
2501
+ /********************************************
2502
+ * Open the graph where the reader left it: its last visited
2503
+ * route under the graph's base -- `<graph>/<topic>` when a topic
2504
+ * was focused there -- or the bare base when the graph was never
2505
+ * opened in this page. A user move: pushed, so Back returns.
2506
+ ********************************************/
2507
+ function ac_open_graph(gobj, event, kw, src)
2508
+ {
2509
+ let base = graph_route_of(gobj);
2510
+ if(!base) {
2511
+ log_error(`${gobj_short_name(gobj)}: open graph with no graph route`);
2512
+ return -1;
2513
+ }
2514
+ let shell = yui_shell_of(gobj);
2515
+ if(!shell) {
2516
+ log_error(`${gobj_short_name(gobj)}: open graph with no shell`);
2517
+ return -1;
2518
+ }
2519
+ yui_shell_navigate(shell, yui_shell_last_route_under(shell, base), {push: true});
2520
+ return 0;
2521
+ }
2522
+
2407
2523
  /********************************************
2408
2524
  * The viewer asked to load a collapsed subtree (tranger mode only):
2409
2525
  * re-issue print-tranger for that path.
@@ -2484,6 +2600,7 @@ function create_gclass(gclass_name)
2484
2600
  ["EV_DELETE_RECORD", ac_delete_record, null],
2485
2601
  ["EV_REFRESH_TOPIC", ac_refresh_topic, null],
2486
2602
  ["EV_OPEN_JSON", ac_open_json, null],
2603
+ ["EV_OPEN_GRAPH", ac_open_graph, null],
2487
2604
  ["EV_EXPAND_PATH", ac_json_expand_path, null],
2488
2605
  ["EV_JSON_CLOSED", ac_json_closed, null],
2489
2606
  ["EV_SHOW", ac_show, null],
@@ -2514,6 +2631,7 @@ function create_gclass(gclass_name)
2514
2631
  event_flag_t.EVF_NO_WARN_SUBS],
2515
2632
  ["EV_REFRESH_TOPIC", 0],
2516
2633
  ["EV_OPEN_JSON", 0],
2634
+ ["EV_OPEN_GRAPH", 0],
2517
2635
  ["EV_EXPAND_PATH", 0],
2518
2636
  ["EV_JSON_CLOSED", 0],
2519
2637
  ["EV_SHOW", 0],