@yuneta/gobj-ui 7.23.28 → 7.23.30

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.
@@ -22,24 +22,37 @@ import {
22
22
  gobj_write_attr,
23
23
  gobj_send_event,
24
24
  gobj_post_event,
25
+ kw_get_local_storage_value,
26
+ kw_set_local_storage_value,
25
27
  gobj_write_str_attr,
26
28
  gobj_read_str_attr,
27
- gobj_publish_event,
28
29
  gobj_yuno,
29
- gobj_gclass_name,
30
+ gobj_yuno_name,
30
31
  gobj_short_name,
31
- gobj_full_name,
32
- gobj_current_state,
33
- gobj_is_running,
34
- gobj_is_playing,
35
- gobj_is_service,
36
- gobj_is_pure_child,
37
- gobj_is_volatil,
32
+ gobj_is_destroying,
33
+ gobj_unsubscribe_event,
38
34
  createElement2,
39
35
  escapeHtml,
36
+ log_warning,
40
37
  refresh_language,
41
38
  } from "@yuneta/gobj-js";
42
39
 
40
+ import {
41
+ describe_js_gobj,
42
+ node_badge_keys,
43
+ node_info_rows,
44
+ node_labels,
45
+ node_role,
46
+ node_status,
47
+ node_status_key,
48
+ } from "./gobj_tree_model.js";
49
+ import {
50
+ close_gclass_view,
51
+ gclass_view_available,
52
+ open_gclass_view,
53
+ } from "./yui_gclass_view.js";
54
+ import {yui_shell_of} from "./c_yui_shell.js";
55
+
43
56
  import {yui_toolbar} from "./yui_toolbar.js";
44
57
  import {
45
58
  yui_graph_camera_items,
@@ -120,6 +133,123 @@ const ZOOM_CANVAS = {type: 'zoom-canvas', animation: false};
120
133
  const BEHAVIORS_READING = ['drag-canvas', ZOOM_CANVAS, 'drag-element'];
121
134
  const BEHAVIORS_PICKING = ['drag-canvas', ZOOM_CANVAS];
122
135
 
136
+ /*
137
+ * Where this view is remembered between visits.
138
+ *
139
+ * In localStorage and not in a persistent attr, for the reason the
140
+ * framework gives itself: only a SERVICE can load or save persistent
141
+ * attrs, and this gclass is hosted as a child. It is also the right
142
+ * place on its own terms -- how somebody left THEIR tree arranged is
143
+ * a fact about that browser, not about the yuno.
144
+ *
145
+ * Keyed by the gobj's name so two trees in one app do not overwrite
146
+ * each other's arrangement.
147
+ */
148
+ const VIEW_STORE_PREFIX = "yui_gobj_tree_view:";
149
+
150
+ /* What is worth carrying back: the layout, where the camera was, and
151
+ * which branches were folded. The anchor goes too -- it is a camera
152
+ * state like the other two, and coming back to a tree still holding
153
+ * the node you left it on is the point of holding it. */
154
+ function view_store_key(gobj)
155
+ {
156
+ return VIEW_STORE_PREFIX + gobj_name(gobj);
157
+ }
158
+
159
+ function restore_view_shape(gobj)
160
+ {
161
+ let priv = gobj.priv;
162
+ let state = load_view_state(gobj);
163
+
164
+ if(!state) {
165
+ return;
166
+ }
167
+ if(state.layout && LAYOUTS[state.layout]) {
168
+ gobj_write_str_attr(gobj, "layout", state.layout);
169
+ /* And the picker with it: the toolbar was built from the
170
+ * attr's default a moment ago, so without this the graph draws
171
+ * the remembered layout while the control names another. */
172
+ if(priv.$layout_select) {
173
+ priv.$layout_select.value = state.layout;
174
+ }
175
+ }
176
+ if(state.collapsed && typeof state.collapsed === "object") {
177
+ priv.collapse_state = Object.assign({}, state.collapsed);
178
+ }
179
+ }
180
+
181
+ function restore_view_state(gobj)
182
+ {
183
+ let priv = gobj.priv;
184
+ let graph = priv.graph;
185
+ let state = load_view_state(gobj);
186
+
187
+ if(!graph || !state) {
188
+ return false;
189
+ }
190
+
191
+ let done = false;
192
+ priv.restoring = true;
193
+ try {
194
+ if(typeof state.zoom === "number" && state.zoom > 0) {
195
+ graph.zoomTo(state.zoom);
196
+ priv.last_zoom = state.zoom;
197
+ done = true;
198
+ }
199
+ if(Array.isArray(state.position) && state.position.length >= 2) {
200
+ graph.translateTo([state.position[0], state.position[1]]);
201
+ done = true;
202
+ }
203
+ if(state.anchor) {
204
+ /* By NAME, never by node id: ids are generated per build,
205
+ * and this one was written in another session. */
206
+ priv.anchor_name = state.anchor;
207
+ priv.anchor_state = "on";
208
+ reanchor(gobj);
209
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
210
+ paint_anchor_mark(gobj);
211
+ }
212
+ } catch(e) {
213
+ log_error(`${gobj_short_name(gobj)}: cannot restore the saved view: ${e}`);
214
+ } finally {
215
+ priv.restoring = false;
216
+ }
217
+
218
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
219
+ return done;
220
+ }
221
+
222
+ function save_view_state(gobj)
223
+ {
224
+ let priv = gobj.priv;
225
+ let graph = priv.graph;
226
+
227
+ if(!graph || priv.restoring) {
228
+ return; /* mid-restore: that is our own writing coming back */
229
+ }
230
+
231
+ let state = {
232
+ layout: gobj_read_str_attr(gobj, "layout"),
233
+ collapsed: priv.collapse_state || {},
234
+ anchor: (priv.anchor_state === "on")? priv.anchor_name: "",
235
+ };
236
+ try {
237
+ state.zoom = graph.getZoom();
238
+ state.position = graph.getPosition();
239
+ } catch(e) {
240
+ /* between renders: keep the rest, the camera is not readable */
241
+ }
242
+
243
+ kw_set_local_storage_value(view_store_key(gobj), state);
244
+ }
245
+
246
+ function load_view_state(gobj)
247
+ {
248
+ let state = kw_get_local_storage_value(view_store_key(gobj), null, false);
249
+
250
+ return (state && typeof state === "object")? state: null;
251
+ }
252
+
123
253
  let PRIVATE_DATA = {
124
254
  canvas_id: "",
125
255
  graph: null,
@@ -130,6 +260,9 @@ let PRIVATE_DATA = {
130
260
  $popover: null,
131
261
  $popover_title: null,
132
262
  $popover_body: null,
263
+ $popover_gclass: null, // the "gclass" button, or null when unavailable
264
+ popover_node: null, // the descriptor the popover is showing
265
+ gclass_view: null, // handle of the open gclass window (or null)
133
266
  node_by_id: null, // map of node_id -> node_data (for popover)
134
267
  collapse_state: null, // map of full_name -> "collapsed" | "expanded"
135
268
  resize_observer: null, // ResizeObserver on the canvas mount element
@@ -146,6 +279,8 @@ let PRIVATE_DATA = {
146
279
  anchor_id: "", // the node the camera keeps in the middle
147
280
  anchor_name: "", // its full_name, which survives a rebuild
148
281
  anchor_state: "off", // "off" | "arming" | "on"
282
+ restoring: false, // guards the store against its own restore
283
+ restored: false, // the saved arrangement is applied ONCE
149
284
  last_zoom: 1, // tells a wheel zoom from a drag in aftertransform
150
285
  };
151
286
 
@@ -153,20 +288,46 @@ let __gclass__ = null;
153
288
  let __layout_registered__ = false;
154
289
 
155
290
  /***************************************************************
156
- * Node colors by role
291
+ * Node colours by role.
292
+ *
293
+ * Saturated hues, one family apart from the next, and the same
294
+ * five everywhere the reader meets a gobj. `stroke` draws the
295
+ * border and the edge; `accent` is the brighter sibling used
296
+ * for the bar down the left of the card and the toggle badge,
297
+ * so a card carries its colour twice at two intensities and is
298
+ * readable at the zoom where the text is not.
157
299
  ***************************************************************/
158
300
  const ROLE_COLORS = {
159
- yuno: {fill: "#E0F2FE", stroke: "#075985"}, // blue
160
- service: {fill: "#DCFCE7", stroke: "#166534"}, // green
161
- pure: {fill: "#FEF9C3", stroke: "#854D0E"}, // yellow
162
- volatil: {fill: "#FCE7F3", stroke: "#9D174D"}, // pink
163
- default: {fill: "#F3F4F6", stroke: "#374151"}, // gray
301
+ yuno: {stroke: "#0284c7", accent: "#38bdf8"}, // sky
302
+ service: {stroke: "#059669", accent: "#34d399"}, // emerald
303
+ pure: {stroke: "#d97706", accent: "#fbbf24"}, // amber
304
+ volatil: {stroke: "#db2777", accent: "#f472b6"}, // pink
305
+ child: {stroke: "#7c3aed", accent: "#a78bfa"}, // violet
164
306
  };
165
307
 
166
- const STATE_COLORS = {
167
- running_playing: "#15803D", // green
168
- running: "#B45309", // amber
169
- stopped: "#B91C1C", // red
308
+ /***************************************************************
309
+ * Status colours: what the gobj is DOING.
310
+ *
311
+ * A pill and not a word: three states of one gobj are told
312
+ * apart across a whole tree by colour long before anybody
313
+ * reads them, and the dot alone never said which was which.
314
+ * `disabled` is a fourth entry and not a status -- a disabled
315
+ * gobj is still stopped or running -- but it takes the pill,
316
+ * because it is the fact that explains the other one.
317
+ ***************************************************************/
318
+ const STATUS_COLORS = {
319
+ light: {
320
+ playing: {fg: "#065f46", bg: "#a7f3d0", dot: "#10b981"},
321
+ running: {fg: "#92400e", bg: "#fde68a", dot: "#f59e0b"},
322
+ stopped: {fg: "#991b1b", bg: "#fecaca", dot: "#ef4444"},
323
+ disabled: {fg: "#334155", bg: "#e2e8f0", dot: "#94a3b8"},
324
+ },
325
+ dark: {
326
+ playing: {fg: "#6ee7b7", bg: "rgba(16,185,129,0.28)", dot: "#34d399"},
327
+ running: {fg: "#fcd34d", bg: "rgba(245,158,11,0.28)", dot: "#fbbf24"},
328
+ stopped: {fg: "#fca5a5", bg: "rgba(239,68,68,0.28)", dot: "#f87171"},
329
+ disabled: {fg: "#cbd5e1", bg: "rgba(148,163,184,0.28)", dot: "#94a3b8"},
330
+ },
170
331
  };
171
332
 
172
333
  /***************************************************************
@@ -176,9 +337,9 @@ const STATE_COLORS = {
176
337
  ***************************************************************/
177
338
  const NODE_SIZES = {
178
339
  full: {
179
- yuno: {w: 240, h: 72},
180
- service: {w: 210, h: 68},
181
- child: {w: 160, h: 60},
340
+ yuno: {w: 260, h: 96},
341
+ service: {w: 232, h: 92},
342
+ child: {w: 196, h: 86},
182
343
  },
183
344
  compact: {
184
345
  yuno: {w: 210, h: 30},
@@ -195,45 +356,64 @@ const GT_FONT =
195
356
  "Helvetica, Arial, sans-serif";
196
357
 
197
358
  /***************************************************************
198
- * Soft, theme-aware card palette derived from the role colour
199
- * (same visual language as the treedb graph cards): light tint
200
- * fill + role-colour border, brightened on dark.
359
+ * Theme-aware card palette derived from the role colour.
360
+ *
361
+ * Brighter than a tint: at 9% of the role colour every card
362
+ * was the same near-white rectangle and the tree read as a
363
+ * wireframe. The fill carries enough of the hue to be named
364
+ * from across the canvas, and the border and the accent bar
365
+ * carry the rest.
201
366
  ***************************************************************/
202
- function role_card_style(stroke, dark)
367
+ function role_card_style(colors, dark)
203
368
  {
204
- // On dark, lift the card off the near-black canvas with a
205
- // clearly-lighter slate base + vivid border (a faint tint on
206
- // the canvas colour makes the cards invisible).
369
+ let stroke = colors.stroke;
370
+ let accent = colors.accent;
371
+
207
372
  return {
208
373
  bg: dark
209
- ? `color-mix(in srgb, ${stroke} 30%, #2c3542)`
210
- : `color-mix(in srgb, ${stroke} 9%, #ffffff)`,
211
- border: dark
212
- ? `color-mix(in srgb, ${stroke} 85%, #ffffff)`
213
- : stroke,
214
- title: dark ? "#e8eaed" : "#0f172a",
215
- sub: dark ? "#9aa4b2" : "#64748b",
374
+ ? `color-mix(in srgb, ${stroke} 34%, #253044)`
375
+ : `color-mix(in srgb, ${stroke} 13%, #ffffff)`,
376
+ border: dark? accent: stroke,
377
+ accent: dark? accent: stroke,
378
+ title: dark ? "#f1f5f9" : "#0f172a",
379
+ sub: dark ? "#a8b3c2" : "#475569",
216
380
  tagbg: dark
217
- ? `color-mix(in srgb, ${stroke} 42%, #2c3542)`
218
- : `color-mix(in srgb, ${stroke} 14%, #ffffff)`,
381
+ ? `color-mix(in srgb, ${stroke} 48%, #253044)`
382
+ : `color-mix(in srgb, ${stroke} 20%, #ffffff)`,
383
+ tagfg: dark? "#f1f5f9": stroke,
219
384
  shadow: dark
220
- ? "0 1px 3px rgba(0,0,0,0.45)"
221
- : "0 1px 3px rgba(15,23,42,0.12)",
385
+ ? "0 2px 6px rgba(0,0,0,0.50)"
386
+ : "0 2px 5px rgba(15,23,42,0.16)",
222
387
  };
223
388
  }
224
389
 
225
390
  /***************************************************************
226
- * Map a gobj to its visual category.
391
+ * The pill colours of a node, for the theme in force.
227
392
  ***************************************************************/
228
- function get_node_category(target_gobj, is_root)
393
+ function status_card_style(d, dark)
229
394
  {
230
- if(is_root) {
231
- return "yuno";
395
+ let table = dark? STATUS_COLORS.dark: STATUS_COLORS.light;
396
+ if(d.disabled) {
397
+ return table.disabled;
232
398
  }
233
- if(gobj_is_service(target_gobj)) {
234
- return "service";
399
+ return table[node_status(d)] || table.stopped;
400
+ }
401
+
402
+ /***************************************************************
403
+ * Map a node to its visual size class. Services are drawn
404
+ * bigger than plain children so the service boundary of the
405
+ * hierarchy is visible before anything is read.
406
+ ***************************************************************/
407
+ function get_node_category(d)
408
+ {
409
+ switch(node_role(d)) {
410
+ case "yuno":
411
+ return "yuno";
412
+ case "service":
413
+ return "service";
414
+ default:
415
+ return "child";
235
416
  }
236
- return "child";
237
417
  }
238
418
 
239
419
  /***************************************************************
@@ -369,6 +549,25 @@ function mt_create(gobj)
369
549
  ***************************************************************/
370
550
  function mt_start(gobj)
371
551
  {
552
+ /*
553
+ * The layout and the folds go back BEFORE the first build: they
554
+ * decide what is built, so restoring them afterwards would mean
555
+ * building the tree twice and showing the wrong one first.
556
+ * The camera goes back after, once there is something to point at.
557
+ */
558
+ restore_view_shape(gobj);
559
+
560
+ /* The cards carry translated text now (the status pill, the
561
+ * badges), and G6 draws them as innerHTML inside its own
562
+ * canvas: refresh_language() cannot reach in there, so a
563
+ * language switch is a REBUILD. Subscribe to the shell
564
+ * directly, like C_YUI_PERIOD -- a host that mounts a bare
565
+ * tree gets the repaint without having to forward anything. */
566
+ let shell = yui_shell_of(gobj);
567
+ if(shell) {
568
+ gobj_subscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
569
+ }
570
+
372
571
  build_graph(gobj);
373
572
  load_tree(gobj);
374
573
  }
@@ -378,6 +577,18 @@ function mt_start(gobj)
378
577
  ***************************************************************/
379
578
  function mt_stop(gobj)
380
579
  {
580
+ let priv = gobj.priv;
581
+
582
+ let shell = yui_shell_of(gobj);
583
+ if(shell) {
584
+ gobj_unsubscribe_event(shell, "EV_LANGUAGE_CHANGED", {}, gobj);
585
+ }
586
+
587
+ /* In mt_stop and NOT in mt_destroy: gobj_destroy() destroys the
588
+ * children BEFORE calling mt_destroy, so a hosted gobj retired
589
+ * there is retired after the framework already took it down. */
590
+ close_gclass_view(priv.gclass_view);
591
+ priv.gclass_view = null;
381
592
  }
382
593
 
383
594
  /***************************************************************
@@ -410,6 +621,8 @@ function mt_destroy(gobj)
410
621
  priv.$popover = null;
411
622
  priv.$popover_title = null;
412
623
  priv.$popover_body = null;
624
+ priv.$popover_gclass = null;
625
+ priv.popover_node = null;
413
626
  priv.$layout_select = null;
414
627
  priv.node_by_id = null;
415
628
  priv.collapse_state = null;
@@ -528,10 +741,53 @@ function build_popover(gobj, $container)
528
741
  `;
529
742
  $header.appendChild($title);
530
743
 
744
+ /* What this gobj IS: its gclass, opened in the gclass viewer.
745
+ *
746
+ * Built only where the app registers C_YUI_JSON -- the viewer
747
+ * the window hosts. An app that does not carry it gets no
748
+ * button rather than a button that fails, and the reason is
749
+ * logged once, at build time. */
750
+ if(gclass_view_available()) {
751
+ let $gclass = document.createElement("button");
752
+ $gclass.type = "button";
753
+ $gclass.className = "GOBJ_TREE_POPOVER_GCLASS button is-small";
754
+ $gclass.style.cssText = `
755
+ margin-right: 6px;
756
+ padding: 0 8px;
757
+ height: 22px;
758
+ font-size: 11px;
759
+ white-space: nowrap;
760
+ `;
761
+ $gclass.textContent = t("gclass");
762
+ $gclass.setAttribute("i18n", "gclass");
763
+ $gclass.title = t("view gclass");
764
+ $gclass.setAttribute("data-i18n-title", "view gclass");
765
+ $gclass.setAttribute("aria-label", t("view gclass"));
766
+ $gclass.setAttribute("data-i18n-aria-label", "view gclass");
767
+ $gclass.addEventListener("click", (evt) => {
768
+ evt.stopPropagation();
769
+ gobj_send_event(gobj, "EV_OPEN_GCLASS", {}, gobj);
770
+ });
771
+ $header.appendChild($gclass);
772
+ priv.$popover_gclass = $gclass;
773
+ } else {
774
+ log_warning(
775
+ `${gobj_short_name(gobj)}: no C_YUI_JSON registered, ` +
776
+ `the gclass viewer is not offered`
777
+ );
778
+ }
779
+
531
780
  let $close = document.createElement("button");
532
781
  $close.type = "button";
782
+ /* Named so a stylesheet can reach it: the size a FINGER needs is a
783
+ * media query, and a media query cannot beat an inline style
784
+ * without one to hang off. See `lib_graph.css`. */
785
+ $close.className = "GOBJ_TREE_POPOVER_CLOSE";
533
786
  $close.textContent = "×";
534
787
  $close.title = t("close");
788
+ $close.setAttribute("data-i18n-title", "close");
789
+ $close.setAttribute("aria-label", t("close"));
790
+ $close.setAttribute("data-i18n-aria-label", "close");
535
791
  $close.style.cssText = `
536
792
  background: transparent;
537
793
  border: none;
@@ -710,6 +966,10 @@ function build_graph(gobj)
710
966
  if(priv.anchor_state === "on" && priv.anchor_id) {
711
967
  yui_graph_center_on(priv.graph, priv.anchor_id);
712
968
  }
969
+ /* Where the reader left the camera. One hook covers the wheel,
970
+ * the drag and every zoom button, which is why it is not
971
+ * written into each of them. */
972
+ save_view_state(gobj);
713
973
  });
714
974
 
715
975
  graph.on(NodeEvent.CLICK, (evt) => {
@@ -777,37 +1037,26 @@ function apply_layout(gobj)
777
1037
  }
778
1038
 
779
1039
  /************************************************************
780
- * Resolve role colors for a gobj
781
- ************************************************************/
782
- function get_role_colors(target_gobj, is_root)
783
- {
784
- if(is_root) {
785
- return ROLE_COLORS.yuno;
786
- }
787
- if(gobj_is_volatil(target_gobj)) {
788
- return ROLE_COLORS.volatil;
789
- }
790
- if(gobj_is_service(target_gobj)) {
791
- return ROLE_COLORS.service;
792
- }
793
- if(gobj_is_pure_child(target_gobj)) {
794
- return ROLE_COLORS.pure;
795
- }
796
- return ROLE_COLORS.default;
797
- }
798
-
799
- /************************************************************
800
- * Resolve state color
1040
+ * The role colours of a node.
1041
+ *
1042
+ * Note the ORDER: volatil before service. A volatil gobj that
1043
+ * is also a service is drawn volatil, because that is the fact
1044
+ * that decides how long it lives.
801
1045
  ************************************************************/
802
- function get_state_color(target_gobj)
1046
+ function get_role_colors(d)
803
1047
  {
804
- if(!gobj_is_running(target_gobj)) {
805
- return STATE_COLORS.stopped;
1048
+ switch(node_role(d)) {
1049
+ case "yuno":
1050
+ return ROLE_COLORS.yuno;
1051
+ case "volatil":
1052
+ return ROLE_COLORS.volatil;
1053
+ case "service":
1054
+ return ROLE_COLORS.service;
1055
+ case "pure":
1056
+ return ROLE_COLORS.pure;
1057
+ default:
1058
+ return ROLE_COLORS.child;
806
1059
  }
807
- if(gobj_is_playing(target_gobj)) {
808
- return STATE_COLORS.running_playing;
809
- }
810
- return STATE_COLORS.running;
811
1060
  }
812
1061
 
813
1062
  /************************************************************
@@ -854,27 +1103,27 @@ function is_node_collapsed(gobj, full_name, num_children)
854
1103
  ************************************************************/
855
1104
  function render_toggle_html(node_id, collapsed, num_children, cs)
856
1105
  {
857
- let label = collapsed ? ("+" + num_children) : "−";
858
- let title = collapsed ? "Expand children" : "Collapse children";
1106
+ let label = collapsed ? ("+" + num_children) : "\u2212";
1107
+ let title = collapsed ? t("expand children") : t("collapse children");
859
1108
  return `<span
860
1109
  data-gobj-toggle="true"
861
1110
  data-node-id="${node_id}"
862
- title="${title}"
1111
+ title="${escapeHtml(title)}"
863
1112
  style="
864
1113
  flex: 0 0 auto;
865
1114
  display: inline-flex;
866
1115
  align-items: center;
867
1116
  justify-content: center;
868
- min-width: 22px;
869
- height: 16px;
870
- padding: 0 5px;
1117
+ min-width: 24px;
1118
+ height: 18px;
1119
+ padding: 0 6px;
871
1120
  margin-left: 4px;
872
- background: ${cs.tagbg};
873
- color: ${cs.border};
874
- border: 1px solid ${cs.border};
875
- border-radius: 6px;
1121
+ background: ${cs.accent};
1122
+ color: #ffffff;
1123
+ border: 1px solid ${cs.accent};
1124
+ border-radius: 9px;
876
1125
  font-size: 10px;
877
- font-weight: 600;
1126
+ font-weight: 700;
878
1127
  line-height: 1;
879
1128
  cursor: pointer;
880
1129
  user-select: none;
@@ -882,27 +1131,92 @@ function render_toggle_html(node_id, collapsed, num_children, cs)
882
1131
  }
883
1132
 
884
1133
  /************************************************************
885
- * Recursively build nodes and edges from the gobj tree
1134
+ * HTML for the status pill: what the gobj is DOING.
1135
+ *
1136
+ * A dot plus a word, and the word is dropped in the compact
1137
+ * card where there is no room for it -- the colour still
1138
+ * carries it, and the `title` says it in full either way.
1139
+ ************************************************************/
1140
+ function render_status_html(d, ss, labels, with_label)
1141
+ {
1142
+ let full = d.disabled?
1143
+ labels.field.disabled:
1144
+ (labels.status[node_status_key(d)] || node_status_key(d));
1145
+ let label = d.disabled?
1146
+ labels.field.disabled:
1147
+ (labels.status_short[node_status(d)] || node_status(d));
1148
+
1149
+ if(!with_label) {
1150
+ return `<span title="${escapeHtml(full)}" style="
1151
+ flex: 0 0 auto;
1152
+ width: 10px; height: 10px; border-radius: 50%;
1153
+ background: ${ss.dot};
1154
+ box-shadow: 0 0 0 2px ${ss.bg};
1155
+ margin-left: 6px;
1156
+ "></span>`;
1157
+ }
1158
+
1159
+ return `<span title="${escapeHtml(full)}" style="
1160
+ flex: 0 0 auto;
1161
+ display: inline-flex;
1162
+ align-items: center;
1163
+ gap: 4px;
1164
+ padding: 1px 7px 1px 5px;
1165
+ background: ${ss.bg};
1166
+ color: ${ss.fg};
1167
+ border-radius: 9px;
1168
+ font-size: 10px;
1169
+ font-weight: 700;
1170
+ line-height: 1.45;
1171
+ white-space: nowrap;
1172
+ "><span style="width:7px; height:7px; border-radius:50%; background:${ss.dot};"></span>${escapeHtml(label)}</span>`;
1173
+ }
1174
+
1175
+ /************************************************************
1176
+ * HTML for the badges: what the gobj IS, besides its colour.
1177
+ ************************************************************/
1178
+ function render_badges_html(d, cs, labels)
1179
+ {
1180
+ let html = "";
1181
+ for(let key of node_badge_keys(d)) {
1182
+ let label = labels.badge[key] || key;
1183
+ html += `<span style="
1184
+ font-size: 10px;
1185
+ font-weight: 600;
1186
+ padding: 0 6px;
1187
+ margin-right: 4px;
1188
+ background: ${cs.tagbg};
1189
+ color: ${cs.tagfg};
1190
+ border: 1px solid ${cs.accent};
1191
+ border-radius: 9px;
1192
+ white-space: nowrap;
1193
+ ">${escapeHtml(label)}</span>`;
1194
+ }
1195
+ return html;
1196
+ }
1197
+
1198
+ /************************************************************
1199
+ * Recursively build nodes and edges from a NODE DESCRIPTOR
1200
+ * tree (gobj_tree_model.js), never from gobjs: the same
1201
+ * builder draws the browser yuno and a backend one.
1202
+ *
1203
+ * The descriptor is also what the popover reads, so the view
1204
+ * fields it needs are written onto it here (`full_name`,
1205
+ * `short_name`, `num_children`, `is_collapsed`,
1206
+ * `direct_children`) instead of copied into a second object
1207
+ * that can then disagree with the first.
886
1208
  ************************************************************/
887
- function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, compact)
1209
+ function build_gobj_nodes(gobj, d, nodes, edges, parent_id, compact, labels)
888
1210
  {
889
1211
  let priv = gobj.priv;
890
1212
  let node_id = gen_node_id(gobj);
891
1213
 
892
- let gclass_name = gobj_gclass_name(target_gobj) || "";
893
- let instance_name = gobj_name(target_gobj) || "";
894
- let state = gobj_current_state(target_gobj) || "";
895
- let full_name = gobj_full_name(target_gobj);
896
- let colors = get_role_colors(target_gobj, is_root);
897
1214
  let dark = yui_is_dark();
898
- let cs = role_card_style(colors.stroke, dark);
899
- let state_color = get_state_color(target_gobj);
1215
+ let colors = get_role_colors(d);
1216
+ let cs = role_card_style(colors, dark);
1217
+ let ss = status_card_style(d, dark);
900
1218
 
901
- let running = gobj_is_running(target_gobj);
902
- let playing = gobj_is_playing(target_gobj);
903
- let status_text = !running ? "stopped" : (playing ? "playing" : "running");
904
-
905
- let category = get_node_category(target_gobj, is_root);
1219
+ let category = get_node_category(d);
906
1220
  let size = NODE_SIZES[compact ? "compact" : "full"][category];
907
1221
  let width = size.w;
908
1222
  let height = size.h;
@@ -913,10 +1227,15 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
913
1227
  */
914
1228
  let border_width = (category === "yuno" || category === "service") ? 2 : 1;
915
1229
 
916
- let children = target_gobj.dl_children || [];
1230
+ /* A disabled gobj is drawn as one: a dashed border says
1231
+ * "this branch is out of the game" at any zoom. */
1232
+ let border_style = d.disabled? "dashed": "solid";
1233
+
1234
+ let children = d.children || [];
917
1235
  let num_children = children.length;
918
1236
  let has_children = num_children > 0;
919
- let collapsed = has_children && is_node_collapsed(gobj, full_name, num_children);
1237
+ let collapsed = has_children &&
1238
+ is_node_collapsed(gobj, d.fullname, num_children);
920
1239
 
921
1240
  /*
922
1241
  * Direct-children index (collected even when this node is
@@ -926,33 +1245,41 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
926
1245
  let direct_children = [];
927
1246
  for(let child of children) {
928
1247
  direct_children.push({
929
- full_name: gobj_full_name(child),
930
- has_grandchildren: (child.dl_children || []).length > 0,
1248
+ full_name: child.fullname,
1249
+ has_grandchildren: (child.children || []).length > 0,
931
1250
  });
932
1251
  }
933
1252
 
1253
+ /* What the VIEW needs on top of the descriptor. Written onto
1254
+ * it, because the popover reads the same object. */
1255
+ d.full_name = d.fullname;
1256
+ d.short_name = d.shortname;
1257
+ d.num_children = num_children;
1258
+ d.is_collapsed = collapsed;
1259
+ d.direct_children = direct_children;
1260
+ d.node_id = node_id;
1261
+
934
1262
  let toggle_html = has_children
935
1263
  ? render_toggle_html(node_id, collapsed, num_children, cs)
936
1264
  : "";
937
1265
 
1266
+ let card_title = d.gclass + (d.name? ("^" + d.name): "");
938
1267
  let node_html;
939
1268
 
940
1269
  if(compact) {
941
1270
  /*
942
- * Compact one-line node: "gclass^name" with a left color bar,
943
- * state dot and optional +/- toggle. Name truncates with "...".
1271
+ * Compact one-line node: "gclass^name" with a left colour bar
1272
+ * and the status dot. Name truncates with "...".
944
1273
  */
945
- let compact_label = escapeHtml(
946
- instance_name ? (gclass_name + "^" + instance_name) : gclass_name
947
- );
1274
+ let compact_label = escapeHtml(card_title);
948
1275
 
949
1276
  node_html = `
950
- <div style="
1277
+ <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(d.fullname)}" style="
951
1278
  width: ${width}px;
952
1279
  height: ${height}px;
953
1280
  background: ${cs.bg};
954
- border: ${border_width}px solid ${cs.border};
955
- border-left: 5px solid ${cs.border};
1281
+ border: ${border_width}px ${border_style} ${cs.border};
1282
+ border-left: 6px solid ${cs.accent};
956
1283
  border-radius: 7px;
957
1284
  box-shadow: ${cs.shadow};
958
1285
  font-family: ${GT_FONT};
@@ -961,49 +1288,30 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
961
1288
  padding: 0 8px;
962
1289
  box-sizing: border-box;
963
1290
  overflow: hidden;
1291
+ opacity: ${d.disabled? "0.72": "1"};
964
1292
  cursor: pointer;
965
1293
  " title="${compact_label}">
966
1294
  <span style="flex:1 1 auto; min-width:0; font-size:11px; font-weight:600; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${compact_label}</span>
967
- <span title="${escapeHtml(state || status_text)}" style="
968
- flex: 0 0 auto;
969
- width: 8px; height: 8px; border-radius: 50%;
970
- background: ${state_color}; margin-left: 6px;
971
- "></span>
1295
+ ${render_status_html(d, ss, labels, false)}
972
1296
  ${toggle_html}
973
1297
  </div>`;
974
1298
  } else {
975
1299
  /*
976
- * Full node: header (gclass + optional toggle), name, badges + state.
977
- * All rows truncate with "..." — width is fixed.
1300
+ * Full node: header (gclass + optional toggle), name, state,
1301
+ * badges + status pill. All rows truncate with "..." -- the
1302
+ * width is fixed.
978
1303
  */
979
- let header_label = escapeHtml(gclass_name);
980
- let name_label = escapeHtml(instance_name);
981
- let state_label = escapeHtml(state);
982
-
983
- let badges = [];
984
- if(is_root) {
985
- badges.push("yuno");
986
- } else {
987
- if(gobj_is_service(target_gobj)) {
988
- badges.push("service");
989
- }
990
- if(gobj_is_pure_child(target_gobj)) {
991
- badges.push("pure");
992
- }
993
- if(gobj_is_volatil(target_gobj)) {
994
- badges.push("volatil");
995
- }
996
- }
997
- let badges_html = badges.map(b =>
998
- `<span style="font-size:10px; padding:0 5px; margin-right:4px; background:${cs.tagbg}; color:${cs.border}; border:1px solid ${cs.border}; border-radius:6px; white-space:nowrap;">${b}</span>`
999
- ).join("");
1304
+ let header_label = escapeHtml(d.gclass);
1305
+ let name_label = escapeHtml(d.name);
1306
+ let state_label = escapeHtml(d.state);
1000
1307
 
1001
1308
  node_html = `
1002
- <div style="
1309
+ <div class="GOBJ_CARD" data-gobj-name="${escapeHtml(d.fullname)}" style="
1003
1310
  width: ${width}px;
1004
1311
  height: ${height}px;
1005
1312
  background: ${cs.bg};
1006
- border: ${border_width}px solid ${cs.border};
1313
+ border: ${border_width}px ${border_style} ${cs.border};
1314
+ border-left: 6px solid ${cs.accent};
1007
1315
  border-radius: 9px;
1008
1316
  box-shadow: ${cs.shadow};
1009
1317
  overflow: hidden;
@@ -1012,59 +1320,33 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1012
1320
  display: flex;
1013
1321
  flex-direction: column;
1014
1322
  justify-content: center;
1015
- padding: 5px 9px;
1016
- gap: 1px;
1323
+ padding: 6px 9px;
1324
+ gap: 2px;
1325
+ opacity: ${d.disabled? "0.72": "1"};
1017
1326
  cursor: pointer;
1018
- " title="${escapeHtml(gclass_name + (instance_name ? "^" + instance_name : ""))}">
1327
+ " title="${escapeHtml(card_title)}">
1019
1328
  <div style="flex:0 0 auto; display:flex; align-items:center; gap:6px; overflow:hidden;">
1020
- <span style="flex:1 1 auto; min-width:0; font-size:12px; line-height:1.25; font-weight:600; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${header_label}</span>
1329
+ <span style="flex:1 1 auto; min-width:0; font-size:12px; line-height:1.25; font-weight:700; color:${cs.title}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${header_label}</span>
1021
1330
  ${toggle_html}
1022
1331
  </div>
1023
1332
  <div style="flex:0 0 auto; font-size:12px; line-height:1.25; color:${cs.sub}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${name_label || "&nbsp;"}</div>
1024
1333
  <div style="flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:6px; overflow:hidden;">
1025
- <span style="flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${badges_html}</span>
1026
- <span style="flex:0 0 auto; display:flex; align-items:center; gap:4px; color:${state_color}; font-weight:600; font-size:11px; white-space:nowrap;">
1027
- <span style="width:7px; height:7px; border-radius:50%; background:${state_color};"></span>${state_label || status_text}
1028
- </span>
1334
+ <span style="flex:1 1 auto; min-width:0; font-size:10px; line-height:1.25; color:${cs.sub}; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${state_label || "&nbsp;"}</span>
1335
+ ${render_status_html(d, ss, labels, true)}
1029
1336
  </div>
1337
+ <div style="flex:0 0 auto; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">${render_badges_html(d, cs, labels)}</div>
1030
1338
  </div>`;
1031
1339
  }
1032
1340
 
1033
- let parent_gobj = gobj_parent(target_gobj);
1034
-
1035
- /*
1036
- * Node data kept for the popover. Extend this object when
1037
- * you want to surface more attributes — then add a row in
1038
- * get_node_info_rows() with the same key.
1039
- */
1040
- let node_data = {
1041
- gclass: gclass_name,
1042
- name: instance_name,
1043
- short_name: gobj_short_name(target_gobj),
1044
- full_name: full_name,
1045
- parent_short_name: parent_gobj ? gobj_short_name(parent_gobj) : "",
1046
- state: state,
1047
- category: category,
1048
- is_yuno: is_root,
1049
- is_service: gobj_is_service(target_gobj),
1050
- is_pure_child: gobj_is_pure_child(target_gobj),
1051
- is_volatil: gobj_is_volatil(target_gobj),
1052
- is_running: running,
1053
- is_playing: playing,
1054
- num_children: num_children,
1055
- is_collapsed: collapsed,
1056
- direct_children: direct_children,
1057
- };
1058
-
1059
1341
  if(!priv.node_by_id) {
1060
1342
  priv.node_by_id = {};
1061
1343
  }
1062
- priv.node_by_id[node_id] = node_data;
1344
+ priv.node_by_id[node_id] = d;
1063
1345
 
1064
1346
  let node = {
1065
1347
  id: node_id,
1066
1348
  type: 'html',
1067
- data: node_data,
1349
+ data: d,
1068
1350
  style: {
1069
1351
  innerHTML: node_html,
1070
1352
  size: [width, height],
@@ -1079,8 +1361,8 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1079
1361
  source: parent_id,
1080
1362
  target: node_id,
1081
1363
  style: {
1082
- stroke: colors.stroke,
1083
- lineWidth: 1,
1364
+ stroke: colors.accent,
1365
+ lineWidth: 1.4,
1084
1366
  }
1085
1367
  });
1086
1368
  }
@@ -1090,7 +1372,7 @@ function build_gobj_nodes(gobj, target_gobj, nodes, edges, parent_id, is_root, c
1090
1372
  */
1091
1373
  if(!collapsed) {
1092
1374
  for(let child of children) {
1093
- build_gobj_nodes(gobj, child, nodes, edges, node_id, false, compact);
1375
+ build_gobj_nodes(gobj, child, nodes, edges, node_id, compact, labels);
1094
1376
  }
1095
1377
  }
1096
1378
 
@@ -1116,12 +1398,23 @@ function load_tree(gobj)
1116
1398
  return;
1117
1399
  }
1118
1400
 
1119
- let yuno = gobj_yuno();
1120
- if(!yuno) {
1401
+ /*
1402
+ * The tree, as data.
1403
+ *
1404
+ * The graph draws DESCRIPTORS, so this is the one line that
1405
+ * says which yuno is on screen. A backend yuno enters here:
1406
+ * describe_backend_tree(<the `view-gobj-tree` answer>) in
1407
+ * place of describe_js_gobj(gobj_yuno(), true), and nothing
1408
+ * below this point changes.
1409
+ */
1410
+ let root = describe_js_gobj(gobj_yuno(), true);
1411
+ if(!root) {
1412
+ log_error(`${gobj_short_name(gobj)}: no yuno to draw`);
1121
1413
  return;
1122
1414
  }
1123
1415
 
1124
1416
  let layout_cfg = get_layout_cfg(gobj_read_str_attr(gobj, "layout"));
1417
+ let labels = node_labels(t);
1125
1418
 
1126
1419
  let anchor = priv.pending_anchor || null;
1127
1420
  let preserve_view = !!priv.pending_preserve_view;
@@ -1134,7 +1427,7 @@ function load_tree(gobj)
1134
1427
  let nodes = [];
1135
1428
  let edges = [];
1136
1429
 
1137
- build_gobj_nodes(gobj, yuno, nodes, edges, null, true, layout_cfg.compact);
1430
+ build_gobj_nodes(gobj, root, nodes, edges, null, layout_cfg.compact, labels);
1138
1431
 
1139
1432
  if(nodes.length > 0) {
1140
1433
  let root_id = nodes[0].id; /* built before its children */
@@ -1145,6 +1438,7 @@ function load_tree(gobj)
1145
1438
  * there. (`anchor` below is the OTHER one -- see the
1146
1439
  * note in PRIVATE_DATA.) */
1147
1440
  reanchor(gobj);
1441
+ paint_anchor_mark(gobj);
1148
1442
 
1149
1443
  if(anchor) {
1150
1444
  restore_view_anchored(gobj, anchor);
@@ -1154,6 +1448,19 @@ function load_tree(gobj)
1154
1448
  return;
1155
1449
  }
1156
1450
 
1451
+ /*
1452
+ * The arrangement this browser was left in, once: after
1453
+ * that the reader's own camera wins, and a restore on
1454
+ * every rebuild would drag them back every time they
1455
+ * folded something.
1456
+ */
1457
+ if(!priv.restored) {
1458
+ priv.restored = true;
1459
+ if(restore_view_state(gobj)) {
1460
+ return;
1461
+ }
1462
+ }
1463
+
1157
1464
  /*
1158
1465
  * ACTUAL SIZE, not fit. A tree of anything real does not
1159
1466
  * fit at a zoom anybody can read, so fitting answered
@@ -1280,67 +1587,12 @@ function refresh_tree(gobj, opts)
1280
1587
  }
1281
1588
  }
1282
1589
 
1283
- /************************************************************
1284
- * Rows to show in the popover for a given node data.
1285
- *
1286
- * This is the extension point: to surface a new attribute,
1287
- * collect it into `node_data` in build_gobj_nodes(), then
1288
- * append a `[label, value]` pair here.
1289
- ************************************************************/
1290
- function get_node_info_rows(node_data)
1291
- {
1292
- let rows = [];
1293
-
1294
- rows.push([t("gclass"), node_data.gclass || "—"]);
1295
- rows.push([t("name"), node_data.name || "—"]);
1296
-
1297
- if(node_data.full_name && node_data.full_name !== node_data.short_name) {
1298
- rows.push([t("full name"), node_data.full_name]);
1299
- }
1300
-
1301
- let role;
1302
- if(node_data.is_yuno) {
1303
- role = "yuno";
1304
- } else if(node_data.is_service) {
1305
- role = "service";
1306
- } else if(node_data.is_volatil) {
1307
- role = "volatil child";
1308
- } else if(node_data.is_pure_child) {
1309
- role = "pure child";
1310
- } else {
1311
- role = "child";
1312
- }
1313
- rows.push([t("role"), role]);
1314
-
1315
- let status;
1316
- if(!node_data.is_running) {
1317
- status = "stopped";
1318
- } else if(node_data.is_playing) {
1319
- status = "running + playing";
1320
- } else {
1321
- status = "running";
1322
- }
1323
- rows.push([t("status"), status]);
1324
- rows.push([t("state"), node_data.state || "—"]);
1325
-
1326
- if(node_data.parent_short_name) {
1327
- rows.push([t("parent"), node_data.parent_short_name]);
1328
- }
1329
-
1330
- if(typeof node_data.num_children === "number") {
1331
- let kids = node_data.num_children;
1332
- if(kids > 0 && node_data.is_collapsed) {
1333
- rows.push([t("children"), kids + " " + t("(collapsed)")]);
1334
- } else if(kids > 0) {
1335
- rows.push([t("children"), String(kids)]);
1336
- }
1337
- }
1338
-
1339
- return rows;
1340
- }
1341
-
1342
1590
  /************************************************************
1343
1591
  * Render popover body from the info rows of a node.
1592
+ *
1593
+ * The rows come from gobj_tree_model.js, so a node of the
1594
+ * browser yuno and a node of a backend one are read out with
1595
+ * the same words in the same order.
1344
1596
  ************************************************************/
1345
1597
  function render_popover_body(gobj, node_data)
1346
1598
  {
@@ -1350,14 +1602,14 @@ function render_popover_body(gobj, node_data)
1350
1602
  return;
1351
1603
  }
1352
1604
 
1353
- let rows = get_node_info_rows(node_data);
1605
+ let rows = node_info_rows(node_data, t);
1354
1606
 
1355
1607
  /* Bulma scheme vars, like the popover chrome around it (build_popover):
1356
1608
  * they flip with <html data-theme>, so the popover follows the theme
1357
1609
  * with no redraw. Hardcoding them is what made the value near-black
1358
1610
  * (#1A1A1A) on the near-black dark card background — invisible. */
1359
1611
  let rows_html = rows.map(([label, value]) => `
1360
- <div style="display:grid; grid-template-columns: 90px 1fr; column-gap:10px; padding: 2px 0;">
1612
+ <div style="display:grid; grid-template-columns: 96px 1fr; column-gap:10px; padding: 2px 0;">
1361
1613
  <span style="color:var(--bulma-text-weak, #6B7280); font-weight:500;">${escapeHtml(String(label))}</span>
1362
1614
  <span style="color:var(--bulma-text-strong, #1A1A1A); word-break:break-all;">${escapeHtml(String(value))}</span>
1363
1615
  </div>
@@ -1381,6 +1633,8 @@ function show_popover(gobj, node_data, client_x, client_y)
1381
1633
  return;
1382
1634
  }
1383
1635
 
1636
+ priv.popover_node = node_data;
1637
+
1384
1638
  $title.textContent = node_data.short_name ||
1385
1639
  (node_data.gclass + (node_data.name ? "^" + node_data.name : ""));
1386
1640
  $title.title = $title.textContent;
@@ -1434,6 +1688,7 @@ function hide_popover(gobj)
1434
1688
  if(priv.$popover_title) {
1435
1689
  priv.$popover_title.textContent = "";
1436
1690
  }
1691
+ priv.popover_node = null;
1437
1692
  }
1438
1693
 
1439
1694
  /************************************************************
@@ -1795,6 +2050,7 @@ class GobjLanesHLayout extends BaseLayout {
1795
2050
  ************************************************************/
1796
2051
  function ac_refresh(gobj, event, kw, src)
1797
2052
  {
2053
+ save_view_state(gobj);
1798
2054
  refresh_tree(gobj);
1799
2055
  return 0;
1800
2056
  }
@@ -1841,10 +2097,12 @@ function ac_change_layout(gobj, event, kw, src)
1841
2097
  }
1842
2098
 
1843
2099
  apply_layout(gobj);
2100
+ save_view_state(gobj);
1844
2101
 
1845
2102
  /*
1846
2103
  * Rebuild nodes because compact/full HTML differs per layout
1847
2104
  */
2105
+ save_view_state(gobj);
1848
2106
  refresh_tree(gobj);
1849
2107
 
1850
2108
  return 0;
@@ -1902,6 +2160,7 @@ function ac_toggle_collapse(gobj, event, kw, src)
1902
2160
  priv.collapse_state[node_data.full_name] = "collapsed";
1903
2161
  }
1904
2162
 
2163
+ save_view_state(gobj);
1905
2164
  refresh_tree(gobj, {anchor: anchor});
1906
2165
  return 0;
1907
2166
  }
@@ -2022,12 +2281,46 @@ function ac_center_anchor(gobj, event, kw, src)
2022
2281
  {
2023
2282
  let priv = gobj.priv;
2024
2283
 
2284
+ paint_anchor_mark(gobj);
2285
+ save_view_state(gobj);
2025
2286
  if(priv.anchor_state === "on" && priv.anchor_id) {
2026
2287
  yui_graph_center_on(priv.graph, priv.anchor_id);
2027
2288
  }
2028
2289
  return 0;
2029
2290
  }
2030
2291
 
2292
+ /************************************************************
2293
+ * Mark the card the camera is holding.
2294
+ *
2295
+ * A class on the card's own element and not a G6 node state:
2296
+ * G6 paints no state style on an html node.
2297
+ *
2298
+ * Re-applied after every rebuild, because the cards are new
2299
+ * DOM each time and the mark would otherwise last until the
2300
+ * next expand.
2301
+ ************************************************************/
2302
+ function paint_anchor_mark(gobj)
2303
+ {
2304
+ let priv = gobj.priv;
2305
+ let $container = gobj_read_attr(gobj, "$container");
2306
+
2307
+ if(!$container) {
2308
+ return;
2309
+ }
2310
+ for(let $card of $container.querySelectorAll('.GOBJ_CARD_ANCHORED')) {
2311
+ $card.classList.remove('GOBJ_CARD_ANCHORED');
2312
+ }
2313
+ if(priv.anchor_state !== "on" || !priv.anchor_name) {
2314
+ return;
2315
+ }
2316
+ let $it = $container.querySelector(
2317
+ '.GOBJ_CARD[data-gobj-name="' + CSS.escape(priv.anchor_name) + '"]'
2318
+ );
2319
+ if($it) {
2320
+ $it.classList.add('GOBJ_CARD_ANCHORED');
2321
+ }
2322
+ }
2323
+
2031
2324
  /************************************************************
2032
2325
  * Reading, or picking the node to anchor.
2033
2326
  ************************************************************/
@@ -2068,6 +2361,8 @@ function ac_toggle_anchor(gobj, event, kw, src)
2068
2361
 
2069
2362
  yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
2070
2363
  set_picking_mode(gobj, priv.anchor_state === "arming");
2364
+ paint_anchor_mark(gobj);
2365
+ save_view_state(gobj);
2071
2366
  return 0;
2072
2367
  }
2073
2368
 
@@ -2202,6 +2497,7 @@ function ac_show(gobj, event, kw, src)
2202
2497
  /*
2203
2498
  * Always refresh on show: the tree may have changed while hidden
2204
2499
  */
2500
+ save_view_state(gobj);
2205
2501
  refresh_tree(gobj);
2206
2502
 
2207
2503
  if(graph) {
@@ -2260,6 +2556,87 @@ function ac_hide(gobj, event, kw, src)
2260
2556
  return 0;
2261
2557
  }
2262
2558
 
2559
+ /************************************************************
2560
+ * Open the gclass of the node the popover is showing.
2561
+ *
2562
+ * One window at a time: opening a second gclass replaces the
2563
+ * first rather than stacking windows nobody closes.
2564
+ ************************************************************/
2565
+ function ac_open_gclass(gobj, event, kw, src)
2566
+ {
2567
+ let priv = gobj.priv;
2568
+ let node_data = priv.popover_node;
2569
+
2570
+ if(!node_data || !node_data.gclass) {
2571
+ log_error(`${gobj_short_name(gobj)}: EV_OPEN_GCLASS with no node`);
2572
+ return -1;
2573
+ }
2574
+
2575
+ close_gclass_view(priv.gclass_view);
2576
+ priv.gclass_view = null;
2577
+
2578
+ priv.gclass_view = open_gclass_view(gobj, node_data.gclass, {
2579
+ title_prefix: gobj_yuno_name() || "",
2580
+ on_close: () => {
2581
+ if(gobj_is_destroying(gobj)) {
2582
+ return;
2583
+ }
2584
+ gobj_send_event(gobj, "EV_GCLASS_CLOSED", {}, gobj);
2585
+ },
2586
+ });
2587
+
2588
+ return 0;
2589
+ }
2590
+
2591
+ /************************************************************
2592
+ * The reader dismissed the gclass window (its own ✕).
2593
+ *
2594
+ * C_YUI_WINDOW.close_window() calls on_close and THEN destroys
2595
+ * itself, so here we only drop our reference -- destroying it
2596
+ * again would destroy it twice.
2597
+ ************************************************************/
2598
+ function ac_gclass_closed(gobj, event, kw, src)
2599
+ {
2600
+ gobj.priv.gclass_view = null;
2601
+ return 0;
2602
+ }
2603
+
2604
+ /************************************************************
2605
+ * The gclass viewer asked for a subtree.
2606
+ *
2607
+ * It cannot: the gclass description is a COMPLETE document,
2608
+ * it carries no `__collapsed__` stub, so nothing in it is
2609
+ * fetchable. The event is declared because the viewer is
2610
+ * hosted as a child and subscribes its host to everything it
2611
+ * publishes -- and it is logged, because arriving here means
2612
+ * the document was not the one we built.
2613
+ ************************************************************/
2614
+ function ac_gclass_expand_path(gobj, event, kw, src)
2615
+ {
2616
+ log_error(
2617
+ `${gobj_short_name(gobj)}: EV_EXPAND_PATH on a complete document: ` +
2618
+ `${kw && kw.path}`
2619
+ );
2620
+ return -1;
2621
+ }
2622
+
2623
+ /************************************************************
2624
+ * The app switched language.
2625
+ *
2626
+ * A rebuild, not a re-translation: the words live inside the
2627
+ * innerHTML of G6 nodes, where refresh_language() cannot go.
2628
+ ************************************************************/
2629
+ function ac_language_changed(gobj, event, kw, src)
2630
+ {
2631
+ let $container = gobj_read_attr(gobj, "$container");
2632
+ if($container) {
2633
+ refresh_language($container, t);
2634
+ }
2635
+ hide_popover(gobj);
2636
+ refresh_tree(gobj);
2637
+ return 0;
2638
+ }
2639
+
2263
2640
 
2264
2641
 
2265
2642
 
@@ -2311,6 +2688,10 @@ function create_gclass(gclass_name)
2311
2688
  ["EV_RESIZE", ac_resize, null],
2312
2689
  ["EV_SHOW", ac_show, null],
2313
2690
  ["EV_HIDE", ac_hide, null],
2691
+ ["EV_OPEN_GCLASS", ac_open_gclass, null],
2692
+ ["EV_GCLASS_CLOSED", ac_gclass_closed, null],
2693
+ ["EV_EXPAND_PATH", ac_gclass_expand_path, null],
2694
+ ["EV_LANGUAGE_CHANGED", ac_language_changed, null],
2314
2695
  ]]
2315
2696
  ];
2316
2697
 
@@ -2334,6 +2715,10 @@ function create_gclass(gclass_name)
2334
2715
  ["EV_RESIZE", 0],
2335
2716
  ["EV_SHOW", 0],
2336
2717
  ["EV_HIDE", 0],
2718
+ ["EV_OPEN_GCLASS", 0],
2719
+ ["EV_GCLASS_CLOSED", 0],
2720
+ ["EV_EXPAND_PATH", 0],
2721
+ ["EV_LANGUAGE_CHANGED", 0],
2337
2722
  ];
2338
2723
 
2339
2724
  __gclass__ = gclass_create(