@yuneta/gobj-ui 7.23.18 → 7.23.20

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.
@@ -9350,6 +9350,131 @@ function yui_toolbar(attrs = {}, items = []) {
9350
9350
  return $toolbar;
9351
9351
  }
9352
9352
  //#endregion
9353
+ //#region src/lib_graph.js
9354
+ /***********************************************************************
9355
+ * lib_graph.js
9356
+ *
9357
+ * Utilities for graph, using Bulma
9358
+ *
9359
+ * Copyright (c) 2025, ArtGins.
9360
+ * All Rights Reserved.
9361
+ ***********************************************************************/
9362
+ /************************************************************
9363
+ * Add class to elements selected
9364
+ ************************************************************/
9365
+ function addClasses($container, selector, ...classNames) {
9366
+ $container.querySelectorAll(selector).forEach((el) => el.classList.add(...classNames));
9367
+ }
9368
+ /************************************************************
9369
+ * Remove class from elements selected
9370
+ ************************************************************/
9371
+ function removeClasses($container, selector, ...classNames) {
9372
+ $container.querySelectorAll(selector).forEach((el) => el.classList.remove(...classNames));
9373
+ }
9374
+ /************************************************************
9375
+ * Toggle class to elements selected
9376
+ ************************************************************/
9377
+ function toggleClasses($container, selector, ...classNames) {
9378
+ $container.querySelectorAll(selector).forEach((el) => classNames.forEach((cls) => el.classList.toggle(cls)));
9379
+ }
9380
+ /************************************************************
9381
+ * Remove child elements
9382
+ ************************************************************/
9383
+ function removeChildElements($element) {
9384
+ $element.innerHTML = "";
9385
+ }
9386
+ /************************************************************
9387
+ * Disable/Enable elements
9388
+ ************************************************************/
9389
+ function disableElements($container, selector) {
9390
+ $container.querySelectorAll(selector).forEach((el) => {
9391
+ el.setAttribute("disabled", "");
9392
+ });
9393
+ }
9394
+ function enableElements($container, selector) {
9395
+ $container.querySelectorAll(selector).forEach((el) => {
9396
+ if (el.hasAttribute("disabled")) el.removeAttribute("disabled");
9397
+ });
9398
+ }
9399
+ /************************************************************
9400
+ * Set or reset the color of 'enable submit action' state
9401
+ * Color green
9402
+ ************************************************************/
9403
+ function set_submit_state($container, selector, set) {
9404
+ if (set) addClasses($container, selector, "color_submit_state");
9405
+ else removeClasses($container, selector, "color_submit_state");
9406
+ }
9407
+ /************************************************************
9408
+ * Set or reset the color of 'enable cancel action' state
9409
+ * Color red
9410
+ ************************************************************/
9411
+ function set_cancel_state($container, selector, set) {
9412
+ if (set) addClasses($container, selector, "color_cancel_state");
9413
+ else removeClasses($container, selector, "color_cancel_state");
9414
+ }
9415
+ /************************************************************
9416
+ * Set or reset the color of 'enable actived' state
9417
+ * Color orange
9418
+ ************************************************************/
9419
+ function set_active_state($container, selector, set) {
9420
+ if (set) addClasses($container, selector, "color_active_state");
9421
+ else removeClasses($container, selector, "color_active_state");
9422
+ }
9423
+ /************************************************************
9424
+ * Set or reset the 'pressed' state of a TOGGLE.
9425
+ *
9426
+ * Not one of the colours above: each of those names a KIND of
9427
+ * action (create, pending, history, destructive), and a toggle
9428
+ * is not an action -- it is a state the control is IN. So it
9429
+ * looks pressed instead of changing category, which also keeps
9430
+ * it out of the way of a neighbour wearing the same hue.
9431
+ ************************************************************/
9432
+ function set_pressed_state($container, selector, set) {
9433
+ if (set) addClasses($container, selector, "pressed_state");
9434
+ else removeClasses($container, selector, "pressed_state");
9435
+ }
9436
+ /**
9437
+ * Returns a smart stroke color in `rgba()` format based on:
9438
+ * - The given fill color (hex or rgb[a])
9439
+ * - The current theme ('light' or 'dark')
9440
+ * - The adjustment factor (default 0.2)
9441
+ *
9442
+ * @param {string} fillColor - Fill color string
9443
+ * @param {string} theme - 'light' or 'dark'
9444
+ * @param {number} factor - Lighten/darken factor (default 0.2)
9445
+ * @returns {string} rgba(r, g, b, a)
9446
+ */
9447
+ function getStrokeColor(fillColor, theme = "light", factor = .2) {
9448
+ let r, g, b, a = 1;
9449
+ if (fillColor.startsWith("#")) {
9450
+ let hex = fillColor.slice(1);
9451
+ if (hex.length === 3 || hex.length === 4) hex = hex.split("").map((c) => c + c).join("");
9452
+ if (hex.length === 6) {
9453
+ r = parseInt(hex.slice(0, 2), 16);
9454
+ g = parseInt(hex.slice(2, 4), 16);
9455
+ b = parseInt(hex.slice(4, 6), 16);
9456
+ } else if (hex.length === 8) {
9457
+ r = parseInt(hex.slice(0, 2), 16);
9458
+ g = parseInt(hex.slice(2, 4), 16);
9459
+ b = parseInt(hex.slice(4, 6), 16);
9460
+ a = parseInt(hex.slice(6, 8), 16) / 255;
9461
+ } else throw new Error("Unsupported hex format");
9462
+ } else if (fillColor.startsWith("rgb")) {
9463
+ const match = fillColor.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
9464
+ if (!match) throw new Error("Invalid rgb()/rgba() format.");
9465
+ r = parseInt(match[1], 10);
9466
+ g = parseInt(match[2], 10);
9467
+ b = parseInt(match[3], 10);
9468
+ if (match[4] !== void 0) a = parseFloat(match[4]);
9469
+ } else throw new Error("Unsupported color format.");
9470
+ const luminance = (.299 * r + .587 * g + .114 * b) / 255;
9471
+ let lighten;
9472
+ if (theme === "dark") lighten = luminance < .7;
9473
+ else lighten = luminance < .3;
9474
+ const adjust = (v) => lighten ? Math.min(255, Math.floor(v + (255 - v) * factor)) : Math.max(0, Math.floor(v * (1 - factor)));
9475
+ return `rgba(${adjust(r)}, ${adjust(g)}, ${adjust(b)}, ${a.toFixed(3)})`;
9476
+ }
9477
+ //#endregion
9353
9478
  //#region src/yui_graph_camera.js
9354
9479
  /***********************************************************************
9355
9480
  * yui_graph_camera.js
@@ -9498,6 +9623,79 @@ function yui_graph_fold_items(gobj, wide) {
9498
9623
  return [camera_button(gobj, "yi-chevron-right", "EV_EXPAND_ALL", "expand all", wide, " transform: rotate(90deg);"), camera_button(gobj, "yi-chevron-right", "EV_COLLAPSE_ALL", "collapse all", wide)];
9499
9624
  }
9500
9625
  /************************************************************
9626
+ * The ANCHOR toggle: pick one element and every camera move
9627
+ * from then on leaves it in the MIDDLE.
9628
+ *
9629
+ * Why it exists: a graph that fits on screen is unreadable at
9630
+ * the zoom that makes it fit, and the zoom that makes it
9631
+ * readable does not fit -- so the useful view is always a
9632
+ * fraction of the document, and which fraction is the whole
9633
+ * question. Without an anchor, `1:1` lands wherever the
9634
+ * layout's origin happens to be and the reader hunts for the
9635
+ * node they were looking at.
9636
+ *
9637
+ * Crosshairs and not a thumbtack. A tack says "do not move
9638
+ * this", which is what a pinned NODE would mean in a graph you
9639
+ * can drag nodes around in; this pins the CAMERA to a target,
9640
+ * which is what a crosshair says and what every map that
9641
+ * offers it draws. It is also in the icon set already, and a
9642
+ * `yi-*` class that is not is a black square.
9643
+ *
9644
+ * Three states, because two would not say what a press does:
9645
+ *
9646
+ * off no target; the camera behaves as it always did
9647
+ * arming waiting for you to click the element you mean
9648
+ * on locked; every zoom re-centres on it
9649
+ ************************************************************/
9650
+ function yui_graph_anchor_item(gobj, wide) {
9651
+ return camera_button(gobj, "yi-location-crosshairs", "EV_TOGGLE_ANCHOR", "anchor view", wide);
9652
+ }
9653
+ /************************************************************
9654
+ * Paint the anchor button. `state` is "off" | "arming" | "on".
9655
+ *
9656
+ * Both live states look PRESSED, because both are states the
9657
+ * control is IN rather than actions; what separates them is the
9658
+ * colour, and only the waiting one takes a palette colour --
9659
+ * it is the one that needs the reader to do something next.
9660
+ ************************************************************/
9661
+ function yui_graph_update_anchor($container, state) {
9662
+ if (!$container) return;
9663
+ let $btn = $container.querySelector(".EV_TOGGLE_ANCHOR");
9664
+ if (!$btn) return;
9665
+ set_pressed_state($container, ".EV_TOGGLE_ANCHOR", state !== "off");
9666
+ $btn.classList.toggle("color_pending_state", state === "arming");
9667
+ $btn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
9668
+ let key = state === "arming" ? "click the element to centre on" : state === "on" ? "centred: click to release" : "anchor view";
9669
+ $btn.setAttribute("title", (0, i18next.t)(key));
9670
+ $btn.setAttribute("data-i18n-title", key);
9671
+ $btn.setAttribute("aria-label", (0, i18next.t)(key));
9672
+ $btn.setAttribute("data-i18n-aria-label", key);
9673
+ }
9674
+ /************************************************************
9675
+ * Put `node_id` back in the middle of the viewport.
9676
+ *
9677
+ * Called AFTER the camera moved, never instead of moving it:
9678
+ * the zoom is what the reader asked for, the centring is what
9679
+ * keeps the thing they are reading under their eyes.
9680
+ *
9681
+ * Guarded on every side because it runs on every wheel notch:
9682
+ * a graph mid-rebuild, an anchor whose node went away with a
9683
+ * fold, a G6 version without `focusElement` -- none of those is
9684
+ * an error worth a log line per notch, they are all "there is
9685
+ * nothing to centre right now".
9686
+ ************************************************************/
9687
+ function yui_graph_center_on(graph, node_id) {
9688
+ if (!graph || !node_id) return false;
9689
+ try {
9690
+ if (typeof graph.getElementData === "function" && !graph.getElementData(node_id)) return false;
9691
+ if (typeof graph.focusElement !== "function") return false;
9692
+ graph.focusElement(node_id);
9693
+ return true;
9694
+ } catch (e) {
9695
+ return false;
9696
+ }
9697
+ }
9698
+ /************************************************************
9501
9699
  * A refresh button, for the graphs that offer one.
9502
9700
  ************************************************************/
9503
9701
  function yui_graph_refresh_item(gobj, wide) {
@@ -9974,6 +10172,268 @@ function ensure_drag_canvas_patch() {
9974
10172
  (0, _antv_g6.register)(_antv_g6.ExtensionCategory.BEHAVIOR, "drag-canvas", DragCanvasTouch);
9975
10173
  __drag_canvas_patched__ = true;
9976
10174
  }
10175
+ /************************************************************
10176
+ * JSON type discriminator (viewer vocabulary)
10177
+ ************************************************************/
10178
+ function json_type(value) {
10179
+ if (value === null) return "null";
10180
+ if (Array.isArray(value)) return "array";
10181
+ switch (typeof value) {
10182
+ case "string": return "string";
10183
+ case "number": return "number";
10184
+ case "boolean": return "boolean";
10185
+ case "object": return "object";
10186
+ default: return "unknown";
10187
+ }
10188
+ }
10189
+ /************************************************************
10190
+ * Detect a kw_collapse() sentinel.
10191
+ *
10192
+ * The kernel replaces an over-limit dict with:
10193
+ * { "__collapsed__": { "path": ..., "size": N } }
10194
+ * and an over-limit array with:
10195
+ * [ { "__collapsed__": { "path": ..., "size": N } } ]
10196
+ *
10197
+ * Returns { size, path, is_array } or null.
10198
+ ************************************************************/
10199
+ function is_collapsed(value) {
10200
+ if (value && typeof value === "object" && !Array.isArray(value)) {
10201
+ let keys = Object.keys(value);
10202
+ if (keys.length === 1 && keys[0] === "__collapsed__") {
10203
+ let c = value.__collapsed__ || {};
10204
+ return {
10205
+ size: c.size,
10206
+ path: c.path,
10207
+ is_array: false
10208
+ };
10209
+ }
10210
+ return null;
10211
+ }
10212
+ if (Array.isArray(value) && value.length === 1) {
10213
+ let e = value[0];
10214
+ if (e && typeof e === "object" && !Array.isArray(e) && Object.keys(e).length === 1 && e.__collapsed__) {
10215
+ let c = e.__collapsed__ || {};
10216
+ return {
10217
+ size: c.size,
10218
+ path: c.path,
10219
+ is_array: true
10220
+ };
10221
+ }
10222
+ }
10223
+ return null;
10224
+ }
10225
+ /************************************************************
10226
+ * Join / split absolute path segments (kernel convention)
10227
+ ************************************************************/
10228
+ function seg_join(segments) {
10229
+ return segments.map(function(s) {
10230
+ return String(s);
10231
+ }).join("`");
10232
+ }
10233
+ function seg_split(path) {
10234
+ if (path === "" || path === null || path === void 0) return [];
10235
+ return path.split("`");
10236
+ }
10237
+ /************************************************************
10238
+ * Walk a JSON tree by absolute segments; undefined if the
10239
+ * path does not resolve.
10240
+ ************************************************************/
10241
+ function get_by_segments(root, segments) {
10242
+ let v = root;
10243
+ for (let seg of segments) {
10244
+ if (v === null || v === void 0) return;
10245
+ if (Array.isArray(v)) v = v[Number(seg)];
10246
+ else if (typeof v === "object") v = v[seg];
10247
+ else return;
10248
+ }
10249
+ return v;
10250
+ }
10251
+ /************************************************************
10252
+ * Set a value at absolute segments (mutates root).
10253
+ *
10254
+ * segments == [] means "replace the whole root" — the caller
10255
+ * must use the RETURNED value in that case (a primitive/array
10256
+ * root cannot be mutated in place).
10257
+ ************************************************************/
10258
+ function set_by_segments(root, segments, new_value) {
10259
+ if (segments.length === 0) return new_value;
10260
+ let parent = get_by_segments(root, segments.slice(0, -1));
10261
+ if (parent === null || parent === void 0 || typeof parent !== "object") return root;
10262
+ let last = segments[segments.length - 1];
10263
+ if (Array.isArray(parent)) parent[Number(last)] = new_value;
10264
+ else parent[last] = new_value;
10265
+ return root;
10266
+ }
10267
+ /************************************************************
10268
+ * Does `value` (or any loaded descendant / its own key)
10269
+ * contain the lower-cased search term?
10270
+ *
10271
+ * Collapsed (not-yet-loaded) subtrees can't be searched, so
10272
+ * they never match on content — only their key can match,
10273
+ * which the renderer checks separately.
10274
+ ************************************************************/
10275
+ function subtree_matches(value, term) {
10276
+ if (!term) return true;
10277
+ if (is_collapsed(value)) return false;
10278
+ let type = json_type(value);
10279
+ if (type === "object") {
10280
+ for (let [k, v] of Object.entries(value)) {
10281
+ if (String(k).toLowerCase().includes(term)) return true;
10282
+ if (subtree_matches(v, term)) return true;
10283
+ }
10284
+ return false;
10285
+ }
10286
+ if (type === "array") {
10287
+ for (let v of value) if (subtree_matches(v, term)) return true;
10288
+ return false;
10289
+ }
10290
+ return String(value).toLowerCase().includes(term);
10291
+ }
10292
+ /************************************************************
10293
+ * Fields whose numeric value is an epoch timestamp.
10294
+ * Mirrors the timestampTag list wired into C_YUI_FORM's
10295
+ * jsoneditor control, so the two viewers agree.
10296
+ ************************************************************/
10297
+ function is_time_field(field) {
10298
+ return field === "__t__" || field === "__tm__" || field === "tm" || field === "t" || field === "time" || field === "from_t" || field === "to_t" || field === "t_input" || field === "t_output" || field === "from_tm" || field === "to_tm";
10299
+ }
10300
+ /************************************************************
10301
+ * Format an epoch value as a local wall-clock string.
10302
+ * Accepts seconds or milliseconds; returns null when the
10303
+ * value is not a usable positive timestamp (0 == "unset").
10304
+ ************************************************************/
10305
+ function format_epoch(value) {
10306
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) return null;
10307
+ let ms = Math.abs(value) < 0xe8d4a51000 ? value * 1e3 : value;
10308
+ let d = new Date(ms);
10309
+ if (isNaN(d.getTime())) return null;
10310
+ return d.toLocaleString();
10311
+ }
10312
+ /************************************************************
10313
+ * The text view's payload: the document as text, indented
10314
+ * four characters, cut at `max_chars`.
10315
+ *
10316
+ * Returns {text, capped}. `capped` is what the caller
10317
+ * announces under the dump — a cut that is not announced
10318
+ * reads as a document that ends there.
10319
+ ************************************************************/
10320
+ function json_text_dump(root, max_chars) {
10321
+ let text;
10322
+ try {
10323
+ text = JSON.stringify(root, null, 4);
10324
+ } catch (e) {
10325
+ return {
10326
+ text: "",
10327
+ capped: false,
10328
+ error: String(e)
10329
+ };
10330
+ }
10331
+ if (text === void 0) return {
10332
+ text: "",
10333
+ capped: false,
10334
+ error: "not representable as JSON"
10335
+ };
10336
+ if (typeof max_chars === "number" && max_chars > 0 && text.length > max_chars) return {
10337
+ text: text.slice(0, max_chars),
10338
+ capped: true
10339
+ };
10340
+ return {
10341
+ text,
10342
+ capped: false
10343
+ };
10344
+ }
10345
+ /************************************************************
10346
+ * Which view a freshly mounted viewer opens on.
10347
+ *
10348
+ * Three answers, in order of authority: what the HOST asked
10349
+ * for, what the READER chose last time, and `fallback`.
10350
+ *
10351
+ * The host wins because a viewer mounted to show a graph has
10352
+ * to show one. The memory comes next, because reopening a
10353
+ * document in the view you just left is the whole point of
10354
+ * remembering it. `fallback` is the floor.
10355
+ *
10356
+ * Anything not in `modes` is treated as unsaid, which covers
10357
+ * an absent value, an empty one, and a stored string from an
10358
+ * older release that named a view no longer offered.
10359
+ ************************************************************/
10360
+ function pick_view_mode(asked, remembered, modes, fallback) {
10361
+ let known = (m) => Array.isArray(modes) && modes.indexOf(m) >= 0;
10362
+ if (known(asked)) return asked;
10363
+ if (known(remembered)) return remembered;
10364
+ return fallback;
10365
+ }
10366
+ /************************************************************
10367
+ * The short label a container row carries beside its size.
10368
+ *
10369
+ * An array of dicts that carry an `id` is a list of RECORDS,
10370
+ * and a row that says only `2: {15}` makes you open all
10371
+ * fifteen fields of every one of them to find out WHICH
10372
+ * record it is. The id is the one field that answers that,
10373
+ * so the row says it without being opened.
10374
+ *
10375
+ * Only a SCALAR id, and only when it is really there: an `id`
10376
+ * that is itself a dict or a list is not a name, it is more
10377
+ * document, and printing `[object Object]` beside the size
10378
+ * would be worse than printing nothing.
10379
+ *
10380
+ * Cut at `max_chars`, because this rides on a row whose job
10381
+ * is to stay one line.
10382
+ ************************************************************/
10383
+ function container_label(value, max_chars) {
10384
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
10385
+ if (!Object.prototype.hasOwnProperty.call(value, "id")) return "";
10386
+ let id = value.id;
10387
+ let t = typeof id;
10388
+ if (t !== "string" && t !== "number" && t !== "boolean") return "";
10389
+ let text = String(id).trim();
10390
+ if (text === "") return "";
10391
+ let cap = typeof max_chars === "number" && max_chars > 0 ? max_chars : 0;
10392
+ if (cap && text.length > cap) return text.slice(0, cap) + "…";
10393
+ return text;
10394
+ }
10395
+ /************************************************************
10396
+ * A dict, in the JSON sense: not an array and not null.
10397
+ ************************************************************/
10398
+ function is_plain_object(v) {
10399
+ return v !== null && typeof v === "object" && !Array.isArray(v);
10400
+ }
10401
+ /************************************************************
10402
+ * Does this container hold ONLY other containers?
10403
+ *
10404
+ * Such a value is not a thing, it is a LIST of things -- and a
10405
+ * list is fully described by the row that names it in its
10406
+ * parent, plus the edges to what it holds. Drawing it as a
10407
+ * card of its own put a node in the middle of the graph with no
10408
+ * data in it, saying nothing the parent's row did not already
10409
+ * say, and pushed everything below it one level down.
10410
+ *
10411
+ * An EMPTY container inside counts as content: `{0}` is
10412
+ * something the card has to show, and there is no child card
10413
+ * to show it instead.
10414
+ ************************************************************/
10415
+ function is_pure_collection(value) {
10416
+ let entries = is_plain_object(value) ? Object.entries(value) : Array.isArray(value) ? value.map((v, i) => [i, v]) : [];
10417
+ if (entries.length === 0) return false;
10418
+ for (let [, v] of entries) if (!has_branch(v)) return false;
10419
+ return true;
10420
+ }
10421
+ /************************************************************
10422
+ * A value that gets a card, or children, of its own.
10423
+ ************************************************************/
10424
+ function has_branch(value) {
10425
+ return is_plain_object(value) && Object.keys(value).length > 0 || Array.isArray(value) && value.length > 0;
10426
+ }
10427
+ /************************************************************
10428
+ * How many branches a container has, which is what the fold
10429
+ * chip counts.
10430
+ ************************************************************/
10431
+ function count_branches(value) {
10432
+ let entries = is_plain_object(value) ? Object.values(value) : Array.isArray(value) ? value : [];
10433
+ let n = 0;
10434
+ for (let v of entries) if (has_branch(v)) n++;
10435
+ return n;
10436
+ }
9977
10437
  //#endregion
9978
10438
  //#region src/c_yui_json_graph.js
9979
10439
  /***********************************************************************
@@ -10075,7 +10535,11 @@ var PRIVATE_DATA$11 = {
10075
10535
  $layout_select: null,
10076
10536
  $find_input: null,
10077
10537
  $find_result: null,
10078
- $find_count: null
10538
+ $find_count: null,
10539
+ anchor_id: "",
10540
+ anchor_path: "",
10541
+ anchor_state: "off",
10542
+ last_zoom: 1
10079
10543
  };
10080
10544
  var __gclass__$11 = null;
10081
10545
  var __layout_registered__$1 = false;
@@ -10368,6 +10832,7 @@ function make_toolbar$3(gobj) {
10368
10832
  ]]);
10369
10833
  let center_items = [];
10370
10834
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
10835
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
10371
10836
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
10372
10837
  let current_layout = (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "layout") || DEFAULT_LAYOUT;
10373
10838
  let options = Object.keys(LAYOUTS$1).map(function(key) {
@@ -10450,6 +10915,15 @@ function build_graph$3(gobj) {
10450
10915
  });
10451
10916
  graph.on("aftertransform", () => {
10452
10917
  yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
10918
+ let z = priv.last_zoom;
10919
+ try {
10920
+ z = priv.graph.getZoom();
10921
+ } catch (e) {
10922
+ return;
10923
+ }
10924
+ if (z === priv.last_zoom) return;
10925
+ priv.last_zoom = z;
10926
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
10453
10927
  });
10454
10928
  graph.on(_antv_g6.CanvasEvent.CLICK, (evt) => {});
10455
10929
  if (typeof ResizeObserver !== "undefined") {
@@ -10470,7 +10944,7 @@ function build_graph$3(gobj) {
10470
10944
  /************************************************************
10471
10945
  * Build cell value HTML (matching old mx_json_viewer colors)
10472
10946
  ************************************************************/
10473
- function build_cell_html(key, value, type, dark, matched) {
10947
+ function build_cell_html(key, value, type, dark, matched, fold) {
10474
10948
  let on_light = matched ? false : dark;
10475
10949
  let color = type_color(type, on_light);
10476
10950
  let key_color = json_card_style(GROUP_COLORS.dict, on_light).key;
@@ -10497,6 +10971,7 @@ function build_cell_html(key, value, type, dark, matched) {
10497
10971
  default: display_value = String(value);
10498
10972
  }
10499
10973
  let row = `<span style="color:${key_color}">• ${(0, _yuneta_gobj_js.escapeHtml)(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
10974
+ if (fold) row += `<span style="margin-left:8px;">` + fold_toggle_html(fold.path, true, fold.folded, fold.branches, fold.colors) + `</span>`;
10500
10975
  if (matched) row = `<span style="background:#ffe082; border-radius:2px; padding:0 2px;">${row}</span>`;
10501
10976
  return row;
10502
10977
  }
@@ -10620,6 +11095,7 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
10620
11095
  let lines = [];
10621
11096
  let pending_complex = [];
10622
11097
  let card_matched = false;
11098
+ let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
10623
11099
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
10624
11100
  for (let [key, value] of entries) {
10625
11101
  let type = get_json_type(value);
@@ -10628,17 +11104,32 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
10628
11104
  card_matched = true;
10629
11105
  priv.match_count++;
10630
11106
  }
10631
- let html = build_cell_html(key, value, type, dark, matched);
10632
- lines.push(html);
10633
- if ((0, _yuneta_gobj_js.is_object)(value) && (0, _yuneta_gobj_js.json_object_size)(value) > 0 || (0, _yuneta_gobj_js.is_array)(value) && value.length > 0) pending_complex.push({
10634
- key,
10635
- value
10636
- });
11107
+ let fold = null;
11108
+ if (has_branch(value)) {
11109
+ pending_complex.push({
11110
+ key,
11111
+ value
11112
+ });
11113
+ if (is_pure_collection(value)) {
11114
+ let child_path = add_segment(path, key);
11115
+ fold = {
11116
+ path: child_path,
11117
+ folded: priv.collapsed.has(child_path),
11118
+ branches: count_branches(value),
11119
+ colors
11120
+ };
11121
+ }
11122
+ }
11123
+ lines.push(build_cell_html(key, value, type, dark, matched, fold));
11124
+ }
11125
+ if (parent_id && is_pure_collection(kw)) {
11126
+ if (priv.collapsed.has(path)) return parent_id;
11127
+ for (let pending of pending_complex) build_json_nodes(gobj, add_segment(path, pending.key), pending.value, nodes, edges, parent_id);
11128
+ return parent_id;
10637
11129
  }
10638
11130
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
10639
11131
  let foldable = pending_complex.length > 0;
10640
11132
  let folded = foldable && priv.collapsed.has(path);
10641
- let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
10642
11133
  let border_color = card_matched ? dark ? "#ffb300" : "#ff8f00" : colors.border;
10643
11134
  let border_width = card_matched ? 2 : 1;
10644
11135
  let content_html = lines.map((line) => `<div style="font-family:monospace; font-size:13px; padding:1px 6px; white-space:nowrap;">${line}</div>`).join("");
@@ -10711,34 +11202,46 @@ function load_json(gobj) {
10711
11202
  let preserve_view = !!priv.pending_preserve_view;
10712
11203
  priv.pending_preserve_view = false;
10713
11204
  if (nodes.length > 0) {
11205
+ let root_id = nodes[0].id;
10714
11206
  graph.setData({
10715
11207
  nodes,
10716
11208
  edges
10717
11209
  });
10718
11210
  graph.render().then(() => {
10719
- if (!preserve_view) graph.fitView();
10720
- yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
11211
+ reanchor$1(gobj);
11212
+ if (preserve_view) {
11213
+ yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
11214
+ return;
11215
+ }
11216
+ Promise.resolve(graph.zoomTo(1)).then(() => {
11217
+ if (!yui_graph_center_on(graph, priv.anchor_id)) yui_graph_center_on(graph, root_id);
11218
+ priv.last_zoom = 1;
11219
+ yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
11220
+ });
10721
11221
  });
10722
11222
  }
10723
11223
  }
10724
11224
  /************************************************************
10725
11225
  * Every path that HAS a branch to fold, in the same recursion
10726
- * build_json_nodes uses — so "collapse all" can never name a
10727
- * card that does not exist. The root is excluded: folding it
11226
+ * build_json_nodes uses — so "collapse all" can never name
11227
+ * something with no handle. The handle is a card's header chip
11228
+ * for a container that gets a card, and the chip on its ROW in
11229
+ * the parent for a pure collection, which gets none; both are
11230
+ * keyed by this same path. The root is excluded: folding it
10728
11231
  * would leave an empty canvas.
10729
11232
  ************************************************************/
10730
11233
  function collect_foldable_paths(value, path, out, is_root) {
10731
11234
  let is_dict = (0, _yuneta_gobj_js.is_object)(value);
10732
11235
  let is_list = (0, _yuneta_gobj_js.is_array)(value);
10733
11236
  if (!is_dict && !is_list) return false;
10734
- let has_branch = false;
11237
+ let found_branch = false;
10735
11238
  let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
10736
- for (let [key, child] of entries) if ((0, _yuneta_gobj_js.is_object)(child) && (0, _yuneta_gobj_js.json_object_size)(child) > 0 || (0, _yuneta_gobj_js.is_array)(child) && child.length > 0) {
10737
- has_branch = true;
11239
+ for (let [key, child] of entries) if (has_branch(child)) {
11240
+ found_branch = true;
10738
11241
  collect_foldable_paths(child, add_segment(path, key), out, false);
10739
11242
  }
10740
- if (has_branch && !is_root) out.push(path);
10741
- return has_branch;
11243
+ if (found_branch && !is_root) out.push(path);
11244
+ return found_branch;
10742
11245
  }
10743
11246
  /************************************************************
10744
11247
  * Show the find count, or hide the chip when there is no
@@ -10980,13 +11483,79 @@ function ac_theme$3(gobj, event, kw, src) {
10980
11483
  return 0;
10981
11484
  }
10982
11485
  /************************************************************
11486
+ * Every camera action ends the same way once an anchor is
11487
+ * set: the thing you were reading goes back to the middle.
11488
+ *
11489
+ * Chained on the promise G6 returns rather than run beside it
11490
+ * -- centring before the zoom has finished centres the OLD
11491
+ * view, and the reader watches the node slide away and come
11492
+ * back.
11493
+ ************************************************************/
11494
+ function after_camera$2(gobj, moved) {
11495
+ let priv = gobj.priv;
11496
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
11497
+ Promise.resolve(moved).then(function() {
11498
+ yui_graph_center_on(priv.graph, priv.anchor_id);
11499
+ }).catch(function() {});
11500
+ }
11501
+ /************************************************************
11502
+ * Point the anchor back at its node after a rebuild.
11503
+ *
11504
+ * Node ids are generated per build, so the id the reader
11505
+ * chose does not survive a refresh, a fold or a layout
11506
+ * change -- the PATH does, and it is what the anchor really
11507
+ * means: "this place in the document", not "this card".
11508
+ *
11509
+ * A path that is no longer drawn (its parent got folded)
11510
+ * releases the anchor rather than leaving a button that says
11511
+ * it is locked onto something invisible.
11512
+ ************************************************************/
11513
+ function reanchor$1(gobj) {
11514
+ let priv = gobj.priv;
11515
+ if (priv.anchor_state !== "on" || !priv.anchor_path) return;
11516
+ let found = "";
11517
+ try {
11518
+ for (let node of priv.graph.getNodeData()) if (node && node.data && node.data.path === priv.anchor_path) {
11519
+ found = node.id;
11520
+ break;
11521
+ }
11522
+ } catch (e) {
11523
+ return;
11524
+ }
11525
+ priv.anchor_id = found;
11526
+ if (!found) {
11527
+ priv.anchor_state = "off";
11528
+ priv.anchor_path = "";
11529
+ }
11530
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
11531
+ }
11532
+ /************************************************************
11533
+ * Arm the anchor, or let it go.
11534
+ *
11535
+ * Three states and one button, so a press ADVANCES: with no
11536
+ * target it starts waiting for one, and with a target it
11537
+ * releases. Pressing it while it waits is a cancel, which is
11538
+ * what somebody who pressed it by mistake will try.
11539
+ ************************************************************/
11540
+ function ac_toggle_anchor$2(gobj, event, kw, src) {
11541
+ let priv = gobj.priv;
11542
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
11543
+ else {
11544
+ priv.anchor_state = "off";
11545
+ priv.anchor_id = "";
11546
+ priv.anchor_path = "";
11547
+ }
11548
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
11549
+ return 0;
11550
+ }
11551
+ /************************************************************
10983
11552
  *
10984
11553
  ************************************************************/
10985
11554
  function ac_zoom_in$2(gobj, event, kw, src) {
10986
11555
  let graph = gobj.priv.graph;
10987
11556
  if (graph) {
10988
11557
  let z = graph.getZoom();
10989
- graph.zoomTo(z * 1.2);
11558
+ after_camera$2(gobj, graph.zoomTo(z * 1.2));
10990
11559
  }
10991
11560
  return 0;
10992
11561
  }
@@ -10997,7 +11566,7 @@ function ac_zoom_out$2(gobj, event, kw, src) {
10997
11566
  let graph = gobj.priv.graph;
10998
11567
  if (graph) {
10999
11568
  let z = graph.getZoom();
11000
- graph.zoomTo(z * .8);
11569
+ after_camera$2(gobj, graph.zoomTo(z * .8));
11001
11570
  }
11002
11571
  return 0;
11003
11572
  }
@@ -11005,10 +11574,14 @@ function ac_zoom_out$2(gobj, event, kw, src) {
11005
11574
  *
11006
11575
  ************************************************************/
11007
11576
  function ac_zoom_reset$2(gobj, event, kw, src) {
11008
- let graph = gobj.priv.graph;
11577
+ let priv = gobj.priv;
11578
+ let graph = priv.graph;
11009
11579
  if (graph && graph.rendered) {
11010
- graph.zoomTo(1);
11011
- graph.translateTo([0, 0]);
11580
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera$2(gobj, graph.zoomTo(1));
11581
+ else {
11582
+ graph.zoomTo(1);
11583
+ graph.translateTo([0, 0]);
11584
+ }
11012
11585
  }
11013
11586
  return 0;
11014
11587
  }
@@ -11027,12 +11600,21 @@ function ac_center$2(gobj, event, kw, src) {
11027
11600
  *
11028
11601
  ************************************************************/
11029
11602
  function ac_node_click$4(gobj, event, kw, src) {
11030
- let graph = gobj.priv.graph;
11603
+ let priv = gobj.priv;
11604
+ let graph = priv.graph;
11031
11605
  let node_id = kw.evt.target.id;
11032
11606
  let nodedata;
11033
11607
  try {
11034
11608
  nodedata = graph.getNodeData(node_id);
11035
11609
  } catch (e) {}
11610
+ if (priv.anchor_state === "arming") {
11611
+ priv.anchor_id = node_id;
11612
+ priv.anchor_path = nodedata && nodedata.data ? nodedata.data.path : "";
11613
+ priv.anchor_state = "on";
11614
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
11615
+ yui_graph_center_on(graph, node_id);
11616
+ return 0;
11617
+ }
11036
11618
  if (nodedata && nodedata.data) (0, _yuneta_gobj_js.gobj_publish_event)(gobj, "EV_JSON_ITEM_CLICKED", {
11037
11619
  path: nodedata.data.path,
11038
11620
  id: node_id
@@ -11166,6 +11748,11 @@ function create_gclass$11(gclass_name) {
11166
11748
  ac_node_click$4,
11167
11749
  null
11168
11750
  ],
11751
+ [
11752
+ "EV_TOGGLE_ANCHOR",
11753
+ ac_toggle_anchor$2,
11754
+ null
11755
+ ],
11169
11756
  [
11170
11757
  "EV_RESIZE",
11171
11758
  ac_resize$2,
@@ -11196,6 +11783,7 @@ function create_gclass$11(gclass_name) {
11196
11783
  ["EV_ZOOM_RESET", 0],
11197
11784
  ["EV_CENTER", 0],
11198
11785
  ["EV_NODE_CLICK", 0],
11786
+ ["EV_TOGGLE_ANCHOR", 0],
11199
11787
  ["EV_RESIZE", 0],
11200
11788
  ["EV_SHOW", 0],
11201
11789
  ["EV_HIDE", 0],
@@ -11212,226 +11800,6 @@ function register_c_yui_json_graph() {
11212
11800
  if ((0, _yuneta_gobj_js.gclass_find_by_name)(GCLASS_NAME$11, false)) return 0;
11213
11801
  return create_gclass$11(GCLASS_NAME$11);
11214
11802
  }
11215
- /************************************************************
11216
- * JSON type discriminator (viewer vocabulary)
11217
- ************************************************************/
11218
- function json_type(value) {
11219
- if (value === null) return "null";
11220
- if (Array.isArray(value)) return "array";
11221
- switch (typeof value) {
11222
- case "string": return "string";
11223
- case "number": return "number";
11224
- case "boolean": return "boolean";
11225
- case "object": return "object";
11226
- default: return "unknown";
11227
- }
11228
- }
11229
- /************************************************************
11230
- * Detect a kw_collapse() sentinel.
11231
- *
11232
- * The kernel replaces an over-limit dict with:
11233
- * { "__collapsed__": { "path": ..., "size": N } }
11234
- * and an over-limit array with:
11235
- * [ { "__collapsed__": { "path": ..., "size": N } } ]
11236
- *
11237
- * Returns { size, path, is_array } or null.
11238
- ************************************************************/
11239
- function is_collapsed(value) {
11240
- if (value && typeof value === "object" && !Array.isArray(value)) {
11241
- let keys = Object.keys(value);
11242
- if (keys.length === 1 && keys[0] === "__collapsed__") {
11243
- let c = value.__collapsed__ || {};
11244
- return {
11245
- size: c.size,
11246
- path: c.path,
11247
- is_array: false
11248
- };
11249
- }
11250
- return null;
11251
- }
11252
- if (Array.isArray(value) && value.length === 1) {
11253
- let e = value[0];
11254
- if (e && typeof e === "object" && !Array.isArray(e) && Object.keys(e).length === 1 && e.__collapsed__) {
11255
- let c = e.__collapsed__ || {};
11256
- return {
11257
- size: c.size,
11258
- path: c.path,
11259
- is_array: true
11260
- };
11261
- }
11262
- }
11263
- return null;
11264
- }
11265
- /************************************************************
11266
- * Join / split absolute path segments (kernel convention)
11267
- ************************************************************/
11268
- function seg_join(segments) {
11269
- return segments.map(function(s) {
11270
- return String(s);
11271
- }).join("`");
11272
- }
11273
- function seg_split(path) {
11274
- if (path === "" || path === null || path === void 0) return [];
11275
- return path.split("`");
11276
- }
11277
- /************************************************************
11278
- * Walk a JSON tree by absolute segments; undefined if the
11279
- * path does not resolve.
11280
- ************************************************************/
11281
- function get_by_segments(root, segments) {
11282
- let v = root;
11283
- for (let seg of segments) {
11284
- if (v === null || v === void 0) return;
11285
- if (Array.isArray(v)) v = v[Number(seg)];
11286
- else if (typeof v === "object") v = v[seg];
11287
- else return;
11288
- }
11289
- return v;
11290
- }
11291
- /************************************************************
11292
- * Set a value at absolute segments (mutates root).
11293
- *
11294
- * segments == [] means "replace the whole root" — the caller
11295
- * must use the RETURNED value in that case (a primitive/array
11296
- * root cannot be mutated in place).
11297
- ************************************************************/
11298
- function set_by_segments(root, segments, new_value) {
11299
- if (segments.length === 0) return new_value;
11300
- let parent = get_by_segments(root, segments.slice(0, -1));
11301
- if (parent === null || parent === void 0 || typeof parent !== "object") return root;
11302
- let last = segments[segments.length - 1];
11303
- if (Array.isArray(parent)) parent[Number(last)] = new_value;
11304
- else parent[last] = new_value;
11305
- return root;
11306
- }
11307
- /************************************************************
11308
- * Does `value` (or any loaded descendant / its own key)
11309
- * contain the lower-cased search term?
11310
- *
11311
- * Collapsed (not-yet-loaded) subtrees can't be searched, so
11312
- * they never match on content — only their key can match,
11313
- * which the renderer checks separately.
11314
- ************************************************************/
11315
- function subtree_matches(value, term) {
11316
- if (!term) return true;
11317
- if (is_collapsed(value)) return false;
11318
- let type = json_type(value);
11319
- if (type === "object") {
11320
- for (let [k, v] of Object.entries(value)) {
11321
- if (String(k).toLowerCase().includes(term)) return true;
11322
- if (subtree_matches(v, term)) return true;
11323
- }
11324
- return false;
11325
- }
11326
- if (type === "array") {
11327
- for (let v of value) if (subtree_matches(v, term)) return true;
11328
- return false;
11329
- }
11330
- return String(value).toLowerCase().includes(term);
11331
- }
11332
- /************************************************************
11333
- * Fields whose numeric value is an epoch timestamp.
11334
- * Mirrors the timestampTag list wired into C_YUI_FORM's
11335
- * jsoneditor control, so the two viewers agree.
11336
- ************************************************************/
11337
- function is_time_field(field) {
11338
- return field === "__t__" || field === "__tm__" || field === "tm" || field === "t" || field === "time" || field === "from_t" || field === "to_t" || field === "t_input" || field === "t_output" || field === "from_tm" || field === "to_tm";
11339
- }
11340
- /************************************************************
11341
- * Format an epoch value as a local wall-clock string.
11342
- * Accepts seconds or milliseconds; returns null when the
11343
- * value is not a usable positive timestamp (0 == "unset").
11344
- ************************************************************/
11345
- function format_epoch(value) {
11346
- if (typeof value !== "number" || !isFinite(value) || value <= 0) return null;
11347
- let ms = Math.abs(value) < 0xe8d4a51000 ? value * 1e3 : value;
11348
- let d = new Date(ms);
11349
- if (isNaN(d.getTime())) return null;
11350
- return d.toLocaleString();
11351
- }
11352
- /************************************************************
11353
- * The text view's payload: the document as text, indented
11354
- * four characters, cut at `max_chars`.
11355
- *
11356
- * Returns {text, capped}. `capped` is what the caller
11357
- * announces under the dump — a cut that is not announced
11358
- * reads as a document that ends there.
11359
- ************************************************************/
11360
- function json_text_dump(root, max_chars) {
11361
- let text;
11362
- try {
11363
- text = JSON.stringify(root, null, 4);
11364
- } catch (e) {
11365
- return {
11366
- text: "",
11367
- capped: false,
11368
- error: String(e)
11369
- };
11370
- }
11371
- if (text === void 0) return {
11372
- text: "",
11373
- capped: false,
11374
- error: "not representable as JSON"
11375
- };
11376
- if (typeof max_chars === "number" && max_chars > 0 && text.length > max_chars) return {
11377
- text: text.slice(0, max_chars),
11378
- capped: true
11379
- };
11380
- return {
11381
- text,
11382
- capped: false
11383
- };
11384
- }
11385
- /************************************************************
11386
- * Which view a freshly mounted viewer opens on.
11387
- *
11388
- * Three answers, in order of authority: what the HOST asked
11389
- * for, what the READER chose last time, and `fallback`.
11390
- *
11391
- * The host wins because a viewer mounted to show a graph has
11392
- * to show one. The memory comes next, because reopening a
11393
- * document in the view you just left is the whole point of
11394
- * remembering it. `fallback` is the floor.
11395
- *
11396
- * Anything not in `modes` is treated as unsaid, which covers
11397
- * an absent value, an empty one, and a stored string from an
11398
- * older release that named a view no longer offered.
11399
- ************************************************************/
11400
- function pick_view_mode(asked, remembered, modes, fallback) {
11401
- let known = (m) => Array.isArray(modes) && modes.indexOf(m) >= 0;
11402
- if (known(asked)) return asked;
11403
- if (known(remembered)) return remembered;
11404
- return fallback;
11405
- }
11406
- /************************************************************
11407
- * The short label a container row carries beside its size.
11408
- *
11409
- * An array of dicts that carry an `id` is a list of RECORDS,
11410
- * and a row that says only `2: {15}` makes you open all
11411
- * fifteen fields of every one of them to find out WHICH
11412
- * record it is. The id is the one field that answers that,
11413
- * so the row says it without being opened.
11414
- *
11415
- * Only a SCALAR id, and only when it is really there: an `id`
11416
- * that is itself a dict or a list is not a name, it is more
11417
- * document, and printing `[object Object]` beside the size
11418
- * would be worse than printing nothing.
11419
- *
11420
- * Cut at `max_chars`, because this rides on a row whose job
11421
- * is to stay one line.
11422
- ************************************************************/
11423
- function container_label(value, max_chars) {
11424
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
11425
- if (!Object.prototype.hasOwnProperty.call(value, "id")) return "";
11426
- let id = value.id;
11427
- let t = typeof id;
11428
- if (t !== "string" && t !== "number" && t !== "boolean") return "";
11429
- let text = String(id).trim();
11430
- if (text === "") return "";
11431
- let cap = typeof max_chars === "number" && max_chars > 0 ? max_chars : 0;
11432
- if (cap && text.length > cap) return text.slice(0, cap) + "…";
11433
- return text;
11434
- }
11435
11803
  //#endregion
11436
11804
  //#region src/c_yui_json.js
11437
11805
  /***********************************************************************
@@ -12717,7 +13085,11 @@ var PRIVATE_DATA$9 = {
12717
13085
  node_by_id: null,
12718
13086
  collapse_state: null,
12719
13087
  resize_observer: null,
12720
- _resize_raf: 0
13088
+ _resize_raf: 0,
13089
+ anchor_id: "",
13090
+ anchor_name: "",
13091
+ anchor_state: "off",
13092
+ last_zoom: 1
12721
13093
  };
12722
13094
  var __gclass__$9 = null;
12723
13095
  var __layout_registered__ = false;
@@ -13110,6 +13482,7 @@ function make_toolbar$1(gobj) {
13110
13482
  let center_items = [];
13111
13483
  let right_items = [];
13112
13484
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
13485
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
13113
13486
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
13114
13487
  let options = [];
13115
13488
  for (let key of Object.keys(LAYOUTS)) {
@@ -13185,6 +13558,15 @@ function build_graph$2(gobj) {
13185
13558
  if (priv.theme) graph.setTheme(priv.theme);
13186
13559
  graph.on("aftertransform", () => {
13187
13560
  yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
13561
+ let z = priv.last_zoom;
13562
+ try {
13563
+ z = priv.graph.getZoom();
13564
+ } catch (e) {
13565
+ return;
13566
+ }
13567
+ if (z === priv.last_zoom) return;
13568
+ priv.last_zoom = z;
13569
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
13188
13570
  });
13189
13571
  graph.on(_antv_g6.NodeEvent.CLICK, (evt) => {
13190
13572
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_NODE_CLICK", { evt }, gobj);
@@ -13472,13 +13854,23 @@ function load_tree(gobj) {
13472
13854
  let edges = [];
13473
13855
  build_gobj_nodes(gobj, yuno, nodes, edges, null, true, layout_cfg.compact);
13474
13856
  if (nodes.length > 0) {
13857
+ let root_id = nodes[0].id;
13475
13858
  graph.setData({
13476
13859
  nodes,
13477
13860
  edges
13478
13861
  });
13479
13862
  graph.render().then(() => {
13480
- if (anchor) restore_view_anchored(gobj, anchor);
13481
- else if (!preserve_view) graph.fitView();
13863
+ reanchor(gobj);
13864
+ if (anchor) {
13865
+ restore_view_anchored(gobj, anchor);
13866
+ return;
13867
+ }
13868
+ if (preserve_view) return;
13869
+ Promise.resolve(graph.zoomTo(1)).then(() => {
13870
+ if (!yui_graph_center_on(graph, priv.anchor_id)) yui_graph_center_on(graph, root_id);
13871
+ priv.last_zoom = 1;
13872
+ yui_graph_update_zoom((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.graph);
13873
+ });
13482
13874
  });
13483
13875
  }
13484
13876
  }
@@ -13980,13 +14372,77 @@ function ac_collapse_all(gobj, event, kw, src) {
13980
14372
  return 0;
13981
14373
  }
13982
14374
  /************************************************************
14375
+ * Every camera action ends the same way once an anchor is
14376
+ * set: the thing you were reading goes back to the middle.
14377
+ *
14378
+ * Chained on the promise G6 returns rather than run beside it
14379
+ * -- centring before the zoom has finished centres the OLD
14380
+ * view, and the reader watches the node slide away and come
14381
+ * back.
14382
+ ************************************************************/
14383
+ function after_camera$1(gobj, moved) {
14384
+ let priv = gobj.priv;
14385
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
14386
+ Promise.resolve(moved).then(function() {
14387
+ yui_graph_center_on(priv.graph, priv.anchor_id);
14388
+ }).catch(function() {});
14389
+ }
14390
+ /************************************************************
14391
+ * Point the anchor back at its node after a rebuild.
14392
+ *
14393
+ * Node ids are generated per build, so the id the reader chose
14394
+ * does not survive an expand, a collapse or a refresh -- the
14395
+ * `full_name` does, and it is what the anchor really means:
14396
+ * "this gobj", not "this card".
14397
+ *
14398
+ * A gobj no longer drawn (its branch got collapsed, or it went
14399
+ * away) releases the anchor rather than leaving a button that
14400
+ * claims to be locked onto something invisible.
14401
+ ************************************************************/
14402
+ function reanchor(gobj) {
14403
+ let priv = gobj.priv;
14404
+ if (priv.anchor_state !== "on" || !priv.anchor_name) return;
14405
+ let found = "";
14406
+ if (priv.node_by_id) {
14407
+ for (let id of Object.keys(priv.node_by_id)) if (priv.node_by_id[id].full_name === priv.anchor_name) {
14408
+ found = id;
14409
+ break;
14410
+ }
14411
+ }
14412
+ priv.anchor_id = found;
14413
+ if (!found) {
14414
+ priv.anchor_state = "off";
14415
+ priv.anchor_name = "";
14416
+ }
14417
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
14418
+ }
14419
+ /************************************************************
14420
+ * Arm the anchor, or let it go.
14421
+ *
14422
+ * Three states and one button, so a press ADVANCES: with no
14423
+ * target it starts waiting for one, and with a target it
14424
+ * releases. Pressing it while it waits is a cancel, which is
14425
+ * what somebody who pressed it by mistake will try.
14426
+ ************************************************************/
14427
+ function ac_toggle_anchor$1(gobj, event, kw, src) {
14428
+ let priv = gobj.priv;
14429
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
14430
+ else {
14431
+ priv.anchor_state = "off";
14432
+ priv.anchor_id = "";
14433
+ priv.anchor_name = "";
14434
+ }
14435
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
14436
+ return 0;
14437
+ }
14438
+ /************************************************************
13983
14439
  *
13984
14440
  ************************************************************/
13985
14441
  function ac_zoom_in$1(gobj, event, kw, src) {
13986
14442
  let graph = gobj.priv.graph;
13987
14443
  if (graph) {
13988
14444
  let z = graph.getZoom();
13989
- graph.zoomTo(z * 1.2);
14445
+ after_camera$1(gobj, graph.zoomTo(z * 1.2));
13990
14446
  }
13991
14447
  return 0;
13992
14448
  }
@@ -13997,7 +14453,7 @@ function ac_zoom_out$1(gobj, event, kw, src) {
13997
14453
  let graph = gobj.priv.graph;
13998
14454
  if (graph) {
13999
14455
  let z = graph.getZoom();
14000
- graph.zoomTo(z * .8);
14456
+ after_camera$1(gobj, graph.zoomTo(z * .8));
14001
14457
  }
14002
14458
  return 0;
14003
14459
  }
@@ -14005,10 +14461,14 @@ function ac_zoom_out$1(gobj, event, kw, src) {
14005
14461
  *
14006
14462
  ************************************************************/
14007
14463
  function ac_zoom_reset$1(gobj, event, kw, src) {
14008
- let graph = gobj.priv.graph;
14464
+ let priv = gobj.priv;
14465
+ let graph = priv.graph;
14009
14466
  if (graph && graph.rendered) {
14010
- graph.zoomTo(1);
14011
- graph.translateTo([0, 0]);
14467
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera$1(gobj, graph.zoomTo(1));
14468
+ else {
14469
+ graph.zoomTo(1);
14470
+ graph.translateTo([0, 0]);
14471
+ }
14012
14472
  }
14013
14473
  return 0;
14014
14474
  }
@@ -14036,6 +14496,14 @@ function ac_node_click$3(gobj, event, kw, src) {
14036
14496
  if (nd && nd.data) node_data = nd.data;
14037
14497
  } catch (e) {}
14038
14498
  if (!node_data) return 0;
14499
+ if (priv.anchor_state === "arming") {
14500
+ priv.anchor_id = node_id;
14501
+ priv.anchor_name = node_data.full_name || "";
14502
+ priv.anchor_state = "on";
14503
+ yui_graph_update_anchor((0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container"), priv.anchor_state);
14504
+ yui_graph_center_on(graph, node_id);
14505
+ return 0;
14506
+ }
14039
14507
  let client_x = kw.evt && kw.evt.client ? kw.evt.client.x : 0;
14040
14508
  let client_y = kw.evt && kw.evt.client ? kw.evt.client.y : 0;
14041
14509
  show_popover(gobj, node_data, client_x, client_y);
@@ -14116,6 +14584,7 @@ function create_gclass$9(gclass_name) {
14116
14584
  ["EV_ZOOM_RESET", 0],
14117
14585
  ["EV_CENTER", 0],
14118
14586
  ["EV_NODE_CLICK", 0],
14587
+ ["EV_TOGGLE_ANCHOR", 0],
14119
14588
  ["EV_RESIZE", 0],
14120
14589
  ["EV_SHOW", 0],
14121
14590
  ["EV_HIDE", 0]
@@ -14175,6 +14644,11 @@ function create_gclass$9(gclass_name) {
14175
14644
  ac_node_click$3,
14176
14645
  null
14177
14646
  ],
14647
+ [
14648
+ "EV_TOGGLE_ANCHOR",
14649
+ ac_toggle_anchor$1,
14650
+ null
14651
+ ],
14178
14652
  [
14179
14653
  "EV_RESIZE",
14180
14654
  ac_resize$1,
@@ -20737,131 +21211,6 @@ function register_c_yui_treedb_topic_with_form() {
20737
21211
  return create_gclass$5(GCLASS_NAME$5);
20738
21212
  }
20739
21213
  //#endregion
20740
- //#region src/lib_graph.js
20741
- /***********************************************************************
20742
- * lib_graph.js
20743
- *
20744
- * Utilities for graph, using Bulma
20745
- *
20746
- * Copyright (c) 2025, ArtGins.
20747
- * All Rights Reserved.
20748
- ***********************************************************************/
20749
- /************************************************************
20750
- * Add class to elements selected
20751
- ************************************************************/
20752
- function addClasses($container, selector, ...classNames) {
20753
- $container.querySelectorAll(selector).forEach((el) => el.classList.add(...classNames));
20754
- }
20755
- /************************************************************
20756
- * Remove class from elements selected
20757
- ************************************************************/
20758
- function removeClasses($container, selector, ...classNames) {
20759
- $container.querySelectorAll(selector).forEach((el) => el.classList.remove(...classNames));
20760
- }
20761
- /************************************************************
20762
- * Toggle class to elements selected
20763
- ************************************************************/
20764
- function toggleClasses($container, selector, ...classNames) {
20765
- $container.querySelectorAll(selector).forEach((el) => classNames.forEach((cls) => el.classList.toggle(cls)));
20766
- }
20767
- /************************************************************
20768
- * Remove child elements
20769
- ************************************************************/
20770
- function removeChildElements($element) {
20771
- $element.innerHTML = "";
20772
- }
20773
- /************************************************************
20774
- * Disable/Enable elements
20775
- ************************************************************/
20776
- function disableElements($container, selector) {
20777
- $container.querySelectorAll(selector).forEach((el) => {
20778
- el.setAttribute("disabled", "");
20779
- });
20780
- }
20781
- function enableElements($container, selector) {
20782
- $container.querySelectorAll(selector).forEach((el) => {
20783
- if (el.hasAttribute("disabled")) el.removeAttribute("disabled");
20784
- });
20785
- }
20786
- /************************************************************
20787
- * Set or reset the color of 'enable submit action' state
20788
- * Color green
20789
- ************************************************************/
20790
- function set_submit_state($container, selector, set) {
20791
- if (set) addClasses($container, selector, "color_submit_state");
20792
- else removeClasses($container, selector, "color_submit_state");
20793
- }
20794
- /************************************************************
20795
- * Set or reset the color of 'enable cancel action' state
20796
- * Color red
20797
- ************************************************************/
20798
- function set_cancel_state($container, selector, set) {
20799
- if (set) addClasses($container, selector, "color_cancel_state");
20800
- else removeClasses($container, selector, "color_cancel_state");
20801
- }
20802
- /************************************************************
20803
- * Set or reset the color of 'enable actived' state
20804
- * Color orange
20805
- ************************************************************/
20806
- function set_active_state($container, selector, set) {
20807
- if (set) addClasses($container, selector, "color_active_state");
20808
- else removeClasses($container, selector, "color_active_state");
20809
- }
20810
- /************************************************************
20811
- * Set or reset the 'pressed' state of a TOGGLE.
20812
- *
20813
- * Not one of the colours above: each of those names a KIND of
20814
- * action (create, pending, history, destructive), and a toggle
20815
- * is not an action -- it is a state the control is IN. So it
20816
- * looks pressed instead of changing category, which also keeps
20817
- * it out of the way of a neighbour wearing the same hue.
20818
- ************************************************************/
20819
- function set_pressed_state($container, selector, set) {
20820
- if (set) addClasses($container, selector, "pressed_state");
20821
- else removeClasses($container, selector, "pressed_state");
20822
- }
20823
- /**
20824
- * Returns a smart stroke color in `rgba()` format based on:
20825
- * - The given fill color (hex or rgb[a])
20826
- * - The current theme ('light' or 'dark')
20827
- * - The adjustment factor (default 0.2)
20828
- *
20829
- * @param {string} fillColor - Fill color string
20830
- * @param {string} theme - 'light' or 'dark'
20831
- * @param {number} factor - Lighten/darken factor (default 0.2)
20832
- * @returns {string} rgba(r, g, b, a)
20833
- */
20834
- function getStrokeColor(fillColor, theme = "light", factor = .2) {
20835
- let r, g, b, a = 1;
20836
- if (fillColor.startsWith("#")) {
20837
- let hex = fillColor.slice(1);
20838
- if (hex.length === 3 || hex.length === 4) hex = hex.split("").map((c) => c + c).join("");
20839
- if (hex.length === 6) {
20840
- r = parseInt(hex.slice(0, 2), 16);
20841
- g = parseInt(hex.slice(2, 4), 16);
20842
- b = parseInt(hex.slice(4, 6), 16);
20843
- } else if (hex.length === 8) {
20844
- r = parseInt(hex.slice(0, 2), 16);
20845
- g = parseInt(hex.slice(2, 4), 16);
20846
- b = parseInt(hex.slice(4, 6), 16);
20847
- a = parseInt(hex.slice(6, 8), 16) / 255;
20848
- } else throw new Error("Unsupported hex format");
20849
- } else if (fillColor.startsWith("rgb")) {
20850
- const match = fillColor.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
20851
- if (!match) throw new Error("Invalid rgb()/rgba() format.");
20852
- r = parseInt(match[1], 10);
20853
- g = parseInt(match[2], 10);
20854
- b = parseInt(match[3], 10);
20855
- if (match[4] !== void 0) a = parseFloat(match[4]);
20856
- } else throw new Error("Unsupported color format.");
20857
- const luminance = (.299 * r + .587 * g + .114 * b) / 255;
20858
- let lighten;
20859
- if (theme === "dark") lighten = luminance < .7;
20860
- else lighten = luminance < .3;
20861
- const adjust = (v) => lighten ? Math.min(255, Math.floor(v + (255 - v) * factor)) : Math.max(0, Math.floor(v * (1 - factor)));
20862
- return `rgba(${adjust(r)}, ${adjust(g)}, ${adjust(b)}, ${a.toFixed(3)})`;
20863
- }
20864
- //#endregion
20865
21214
  //#region src/treedb_node_label.js
20866
21215
  /***********************************************************************
20867
21216
  * treedb_node_label.js
@@ -47729,6 +48078,13 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
47729
48078
  stroke="currentColor" stroke-width="40"
47730
48079
  stroke-dasharray="59 36"/>
47731
48080
  </symbol>
48081
+ <!-- location-crosshairs (anchor the camera on one element).
48082
+ The SAME path as the DOM toolbars and their yi-location-crosshairs
48083
+ mask: the two toolbars sit in one console, and a control that
48084
+ does one thing has to be drawn one way. -->
48085
+ <symbol id="g6-icon-anchor" viewBox="0 0 576 512">
48086
+ <path fill="currentColor" d="M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"/>
48087
+ </symbol>
47732
48088
  <!-- floppy-disk (save) -->
47733
48089
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
47734
48090
  <path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>
@@ -47922,6 +48278,9 @@ var attrs_table$4 = [
47922
48278
  (0, _yuneta_gobj_js.SDATA_END)()
47923
48279
  ];
47924
48280
  var PRIVATE_DATA$4 = {
48281
+ anchor_id: "",
48282
+ anchor_state: "off",
48283
+ last_zoom: 1,
47925
48284
  _xy: 100,
47926
48285
  _edge_seq: 0,
47927
48286
  _history_paused: false,
@@ -48218,6 +48577,15 @@ function configure_events(gobj) {
48218
48577
  update_node_icon_position(gobj);
48219
48578
  update_link_icon_position(gobj);
48220
48579
  update_zoom_readout(gobj);
48580
+ let z = priv.last_zoom;
48581
+ try {
48582
+ z = priv.graph.getZoom();
48583
+ } catch (e) {
48584
+ return;
48585
+ }
48586
+ if (z === priv.last_zoom) return;
48587
+ priv.last_zoom = z;
48588
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
48221
48589
  });
48222
48590
  priv._on_language_changed = () => {
48223
48591
  update_toolbar(gobj);
@@ -48455,6 +48823,12 @@ function configure_toolbar(gobj) {
48455
48823
  value: "reset",
48456
48824
  className: "EV_ZOOM_RESET",
48457
48825
  title: (0, i18next.t)("actual size")
48826
+ },
48827
+ {
48828
+ id: "g6-icon-anchor",
48829
+ value: "anchor",
48830
+ className: "EV_TOGGLE_ANCHOR" + (priv.anchor_state !== "off" ? " pressed_state" : "") + (priv.anchor_state === "arming" ? " color_pending_state" : ""),
48831
+ title: anchor_title(priv.anchor_state)
48458
48832
  }
48459
48833
  ];
48460
48834
  if (priv.edit_mode) items.push({
@@ -48496,6 +48870,9 @@ function configure_toolbar(gobj) {
48496
48870
  case "reset":
48497
48871
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_ZOOM_RESET", {}, gobj);
48498
48872
  break;
48873
+ case "anchor":
48874
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_TOGGLE_ANCHOR", {}, gobj);
48875
+ break;
48499
48876
  case "auto-fit":
48500
48877
  (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_AUTO_FIT", {}, gobj);
48501
48878
  break;
@@ -49506,12 +49883,12 @@ function graph_find_nodes(gobj, term) {
49506
49883
  async function graph_zoom_in(gobj) {
49507
49884
  let graph = gobj.priv.graph;
49508
49885
  const z = graph.getZoom();
49509
- await graph.zoomTo(z * 1.1);
49886
+ after_camera(gobj, graph.zoomTo(z * 1.1));
49510
49887
  }
49511
49888
  async function graph_zoom_out(gobj) {
49512
49889
  let graph = gobj.priv.graph;
49513
49890
  const z = graph.getZoom();
49514
- await graph.zoomTo(z * .9);
49891
+ after_camera(gobj, graph.zoomTo(z * .9));
49515
49892
  }
49516
49893
  async function graph_render(gobj) {
49517
49894
  await gobj.priv.graph.render();
@@ -52512,7 +52889,56 @@ function ac_zoom_out(gobj, event, kw, src) {
52512
52889
  return 0;
52513
52890
  }
52514
52891
  function ac_zoom_reset(gobj, event, kw, src) {
52515
- gobj.priv.graph.zoomTo(1);
52892
+ let priv = gobj.priv;
52893
+ let graph = priv.graph;
52894
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera(gobj, graph.zoomTo(1));
52895
+ else graph.zoomTo(1);
52896
+ return 0;
52897
+ }
52898
+ /************************************************************
52899
+ * What the anchor button says it will do next.
52900
+ ************************************************************/
52901
+ function anchor_title(state) {
52902
+ if (state === "arming") return (0, i18next.t)("click the element to centre on");
52903
+ if (state === "on") return (0, i18next.t)("centred: click to release");
52904
+ return (0, i18next.t)("anchor view");
52905
+ }
52906
+ /************************************************************
52907
+ * Every camera action ends the same way once an anchor is
52908
+ * set: the thing you were reading goes back to the middle.
52909
+ *
52910
+ * Chained on the promise G6 returns rather than run beside it
52911
+ * -- centring before the zoom has finished centres the OLD
52912
+ * view, and the reader watches the node slide away and come
52913
+ * back.
52914
+ ************************************************************/
52915
+ function after_camera(gobj, moved) {
52916
+ let priv = gobj.priv;
52917
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
52918
+ Promise.resolve(moved).then(function() {
52919
+ yui_graph_center_on(priv.graph, priv.anchor_id);
52920
+ }).catch(function() {});
52921
+ }
52922
+ /************************************************************
52923
+ * Arm the anchor, or let it go.
52924
+ *
52925
+ * Three states and one button, so a press ADVANCES: with no
52926
+ * target it starts waiting for one, and with a target it
52927
+ * releases. Pressing it while it waits is a cancel, which is
52928
+ * what somebody who pressed it by mistake will try.
52929
+ *
52930
+ * Unlike the DOM toolbars, the button is not repainted in
52931
+ * place: this toolbar is a G6 plugin that rebuilds its whole
52932
+ * item list, and the pressed classes are baked into that list.
52933
+ ************************************************************/
52934
+ function ac_toggle_anchor(gobj, event, kw, src) {
52935
+ let priv = gobj.priv;
52936
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
52937
+ else {
52938
+ priv.anchor_state = "off";
52939
+ priv.anchor_id = "";
52940
+ }
52941
+ update_toolbar(gobj);
52516
52942
  return 0;
52517
52943
  }
52518
52944
  /************************************************************
@@ -52686,6 +53112,13 @@ function ac_node_click$2(gobj, event, kw, src) {
52686
53112
  let priv = gobj.priv;
52687
53113
  let graph = priv.graph;
52688
53114
  let node_id = kw.evt.target.id;
53115
+ if (priv.anchor_state === "arming") {
53116
+ priv.anchor_id = node_id;
53117
+ priv.anchor_state = "on";
53118
+ update_toolbar(gobj);
53119
+ yui_graph_center_on(graph, node_id);
53120
+ return 0;
53121
+ }
52689
53122
  try {
52690
53123
  let nodedata = graph.getNodeData(node_id);
52691
53124
  if (nodedata && nodedata.data && nodedata.data.desc) {
@@ -52947,6 +53380,11 @@ function create_gclass$4(gclass_name) {
52947
53380
  ac_zoom_reset,
52948
53381
  null
52949
53382
  ],
53383
+ [
53384
+ "EV_TOGGLE_ANCHOR",
53385
+ ac_toggle_anchor,
53386
+ null
53387
+ ],
52950
53388
  [
52951
53389
  "EV_TOGGLE_TOOLBARS",
52952
53390
  ac_toggle_toolbars,
@@ -53060,6 +53498,7 @@ function create_gclass$4(gclass_name) {
53060
53498
  ["EV_ZOOM_IN", 0],
53061
53499
  ["EV_ZOOM_OUT", 0],
53062
53500
  ["EV_ZOOM_RESET", 0],
53501
+ ["EV_TOGGLE_ANCHOR", 0],
53063
53502
  ["EV_TOGGLE_TOOLBARS", 0],
53064
53503
  ["EV_AUTO_FIT", 0],
53065
53504
  ["EV_ZOOM_SELECTION", 0],