@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.
@@ -9345,6 +9345,123 @@ function yui_toolbar(attrs = {}, items = []) {
9345
9345
  return $toolbar;
9346
9346
  }
9347
9347
  //#endregion
9348
+ //#region src/lib_graph.js
9349
+ /************************************************************
9350
+ * Add class to elements selected
9351
+ ************************************************************/
9352
+ function addClasses($container, selector, ...classNames) {
9353
+ $container.querySelectorAll(selector).forEach((el) => el.classList.add(...classNames));
9354
+ }
9355
+ /************************************************************
9356
+ * Remove class from elements selected
9357
+ ************************************************************/
9358
+ function removeClasses($container, selector, ...classNames) {
9359
+ $container.querySelectorAll(selector).forEach((el) => el.classList.remove(...classNames));
9360
+ }
9361
+ /************************************************************
9362
+ * Toggle class to elements selected
9363
+ ************************************************************/
9364
+ function toggleClasses($container, selector, ...classNames) {
9365
+ $container.querySelectorAll(selector).forEach((el) => classNames.forEach((cls) => el.classList.toggle(cls)));
9366
+ }
9367
+ /************************************************************
9368
+ * Remove child elements
9369
+ ************************************************************/
9370
+ function removeChildElements($element) {
9371
+ $element.innerHTML = "";
9372
+ }
9373
+ /************************************************************
9374
+ * Disable/Enable elements
9375
+ ************************************************************/
9376
+ function disableElements($container, selector) {
9377
+ $container.querySelectorAll(selector).forEach((el) => {
9378
+ el.setAttribute("disabled", "");
9379
+ });
9380
+ }
9381
+ function enableElements($container, selector) {
9382
+ $container.querySelectorAll(selector).forEach((el) => {
9383
+ if (el.hasAttribute("disabled")) el.removeAttribute("disabled");
9384
+ });
9385
+ }
9386
+ /************************************************************
9387
+ * Set or reset the color of 'enable submit action' state
9388
+ * Color green
9389
+ ************************************************************/
9390
+ function set_submit_state($container, selector, set) {
9391
+ if (set) addClasses($container, selector, "color_submit_state");
9392
+ else removeClasses($container, selector, "color_submit_state");
9393
+ }
9394
+ /************************************************************
9395
+ * Set or reset the color of 'enable cancel action' state
9396
+ * Color red
9397
+ ************************************************************/
9398
+ function set_cancel_state($container, selector, set) {
9399
+ if (set) addClasses($container, selector, "color_cancel_state");
9400
+ else removeClasses($container, selector, "color_cancel_state");
9401
+ }
9402
+ /************************************************************
9403
+ * Set or reset the color of 'enable actived' state
9404
+ * Color orange
9405
+ ************************************************************/
9406
+ function set_active_state($container, selector, set) {
9407
+ if (set) addClasses($container, selector, "color_active_state");
9408
+ else removeClasses($container, selector, "color_active_state");
9409
+ }
9410
+ /************************************************************
9411
+ * Set or reset the 'pressed' state of a TOGGLE.
9412
+ *
9413
+ * Not one of the colours above: each of those names a KIND of
9414
+ * action (create, pending, history, destructive), and a toggle
9415
+ * is not an action -- it is a state the control is IN. So it
9416
+ * looks pressed instead of changing category, which also keeps
9417
+ * it out of the way of a neighbour wearing the same hue.
9418
+ ************************************************************/
9419
+ function set_pressed_state($container, selector, set) {
9420
+ if (set) addClasses($container, selector, "pressed_state");
9421
+ else removeClasses($container, selector, "pressed_state");
9422
+ }
9423
+ /**
9424
+ * Returns a smart stroke color in `rgba()` format based on:
9425
+ * - The given fill color (hex or rgb[a])
9426
+ * - The current theme ('light' or 'dark')
9427
+ * - The adjustment factor (default 0.2)
9428
+ *
9429
+ * @param {string} fillColor - Fill color string
9430
+ * @param {string} theme - 'light' or 'dark'
9431
+ * @param {number} factor - Lighten/darken factor (default 0.2)
9432
+ * @returns {string} rgba(r, g, b, a)
9433
+ */
9434
+ function getStrokeColor(fillColor, theme = "light", factor = .2) {
9435
+ let r, g, b, a = 1;
9436
+ if (fillColor.startsWith("#")) {
9437
+ let hex = fillColor.slice(1);
9438
+ if (hex.length === 3 || hex.length === 4) hex = hex.split("").map((c) => c + c).join("");
9439
+ if (hex.length === 6) {
9440
+ r = parseInt(hex.slice(0, 2), 16);
9441
+ g = parseInt(hex.slice(2, 4), 16);
9442
+ b = parseInt(hex.slice(4, 6), 16);
9443
+ } else if (hex.length === 8) {
9444
+ r = parseInt(hex.slice(0, 2), 16);
9445
+ g = parseInt(hex.slice(2, 4), 16);
9446
+ b = parseInt(hex.slice(4, 6), 16);
9447
+ a = parseInt(hex.slice(6, 8), 16) / 255;
9448
+ } else throw new Error("Unsupported hex format");
9449
+ } else if (fillColor.startsWith("rgb")) {
9450
+ const match = fillColor.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
9451
+ if (!match) throw new Error("Invalid rgb()/rgba() format.");
9452
+ r = parseInt(match[1], 10);
9453
+ g = parseInt(match[2], 10);
9454
+ b = parseInt(match[3], 10);
9455
+ if (match[4] !== void 0) a = parseFloat(match[4]);
9456
+ } else throw new Error("Unsupported color format.");
9457
+ const luminance = (.299 * r + .587 * g + .114 * b) / 255;
9458
+ let lighten;
9459
+ if (theme === "dark") lighten = luminance < .7;
9460
+ else lighten = luminance < .3;
9461
+ const adjust = (v) => lighten ? Math.min(255, Math.floor(v + (255 - v) * factor)) : Math.max(0, Math.floor(v * (1 - factor)));
9462
+ return `rgba(${adjust(r)}, ${adjust(g)}, ${adjust(b)}, ${a.toFixed(3)})`;
9463
+ }
9464
+ //#endregion
9348
9465
  //#region src/yui_graph_camera.js
9349
9466
  /***********************************************************************
9350
9467
  * yui_graph_camera.js
@@ -9493,6 +9610,79 @@ function yui_graph_fold_items(gobj, wide) {
9493
9610
  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)];
9494
9611
  }
9495
9612
  /************************************************************
9613
+ * The ANCHOR toggle: pick one element and every camera move
9614
+ * from then on leaves it in the MIDDLE.
9615
+ *
9616
+ * Why it exists: a graph that fits on screen is unreadable at
9617
+ * the zoom that makes it fit, and the zoom that makes it
9618
+ * readable does not fit -- so the useful view is always a
9619
+ * fraction of the document, and which fraction is the whole
9620
+ * question. Without an anchor, `1:1` lands wherever the
9621
+ * layout's origin happens to be and the reader hunts for the
9622
+ * node they were looking at.
9623
+ *
9624
+ * Crosshairs and not a thumbtack. A tack says "do not move
9625
+ * this", which is what a pinned NODE would mean in a graph you
9626
+ * can drag nodes around in; this pins the CAMERA to a target,
9627
+ * which is what a crosshair says and what every map that
9628
+ * offers it draws. It is also in the icon set already, and a
9629
+ * `yi-*` class that is not is a black square.
9630
+ *
9631
+ * Three states, because two would not say what a press does:
9632
+ *
9633
+ * off no target; the camera behaves as it always did
9634
+ * arming waiting for you to click the element you mean
9635
+ * on locked; every zoom re-centres on it
9636
+ ************************************************************/
9637
+ function yui_graph_anchor_item(gobj, wide) {
9638
+ return camera_button(gobj, "yi-location-crosshairs", "EV_TOGGLE_ANCHOR", "anchor view", wide);
9639
+ }
9640
+ /************************************************************
9641
+ * Paint the anchor button. `state` is "off" | "arming" | "on".
9642
+ *
9643
+ * Both live states look PRESSED, because both are states the
9644
+ * control is IN rather than actions; what separates them is the
9645
+ * colour, and only the waiting one takes a palette colour --
9646
+ * it is the one that needs the reader to do something next.
9647
+ ************************************************************/
9648
+ function yui_graph_update_anchor($container, state) {
9649
+ if (!$container) return;
9650
+ let $btn = $container.querySelector(".EV_TOGGLE_ANCHOR");
9651
+ if (!$btn) return;
9652
+ set_pressed_state($container, ".EV_TOGGLE_ANCHOR", state !== "off");
9653
+ $btn.classList.toggle("color_pending_state", state === "arming");
9654
+ $btn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
9655
+ let key = state === "arming" ? "click the element to centre on" : state === "on" ? "centred: click to release" : "anchor view";
9656
+ $btn.setAttribute("title", t(key));
9657
+ $btn.setAttribute("data-i18n-title", key);
9658
+ $btn.setAttribute("aria-label", t(key));
9659
+ $btn.setAttribute("data-i18n-aria-label", key);
9660
+ }
9661
+ /************************************************************
9662
+ * Put `node_id` back in the middle of the viewport.
9663
+ *
9664
+ * Called AFTER the camera moved, never instead of moving it:
9665
+ * the zoom is what the reader asked for, the centring is what
9666
+ * keeps the thing they are reading under their eyes.
9667
+ *
9668
+ * Guarded on every side because it runs on every wheel notch:
9669
+ * a graph mid-rebuild, an anchor whose node went away with a
9670
+ * fold, a G6 version without `focusElement` -- none of those is
9671
+ * an error worth a log line per notch, they are all "there is
9672
+ * nothing to centre right now".
9673
+ ************************************************************/
9674
+ function yui_graph_center_on(graph, node_id) {
9675
+ if (!graph || !node_id) return false;
9676
+ try {
9677
+ if (typeof graph.getElementData === "function" && !graph.getElementData(node_id)) return false;
9678
+ if (typeof graph.focusElement !== "function") return false;
9679
+ graph.focusElement(node_id);
9680
+ return true;
9681
+ } catch (e) {
9682
+ return false;
9683
+ }
9684
+ }
9685
+ /************************************************************
9496
9686
  * A refresh button, for the graphs that offer one.
9497
9687
  ************************************************************/
9498
9688
  function yui_graph_refresh_item(gobj, wide) {
@@ -9969,6 +10159,268 @@ function ensure_drag_canvas_patch() {
9969
10159
  register(ExtensionCategory.BEHAVIOR, "drag-canvas", DragCanvasTouch);
9970
10160
  __drag_canvas_patched__ = true;
9971
10161
  }
10162
+ /************************************************************
10163
+ * JSON type discriminator (viewer vocabulary)
10164
+ ************************************************************/
10165
+ function json_type(value) {
10166
+ if (value === null) return "null";
10167
+ if (Array.isArray(value)) return "array";
10168
+ switch (typeof value) {
10169
+ case "string": return "string";
10170
+ case "number": return "number";
10171
+ case "boolean": return "boolean";
10172
+ case "object": return "object";
10173
+ default: return "unknown";
10174
+ }
10175
+ }
10176
+ /************************************************************
10177
+ * Detect a kw_collapse() sentinel.
10178
+ *
10179
+ * The kernel replaces an over-limit dict with:
10180
+ * { "__collapsed__": { "path": ..., "size": N } }
10181
+ * and an over-limit array with:
10182
+ * [ { "__collapsed__": { "path": ..., "size": N } } ]
10183
+ *
10184
+ * Returns { size, path, is_array } or null.
10185
+ ************************************************************/
10186
+ function is_collapsed(value) {
10187
+ if (value && typeof value === "object" && !Array.isArray(value)) {
10188
+ let keys = Object.keys(value);
10189
+ if (keys.length === 1 && keys[0] === "__collapsed__") {
10190
+ let c = value.__collapsed__ || {};
10191
+ return {
10192
+ size: c.size,
10193
+ path: c.path,
10194
+ is_array: false
10195
+ };
10196
+ }
10197
+ return null;
10198
+ }
10199
+ if (Array.isArray(value) && value.length === 1) {
10200
+ let e = value[0];
10201
+ if (e && typeof e === "object" && !Array.isArray(e) && Object.keys(e).length === 1 && e.__collapsed__) {
10202
+ let c = e.__collapsed__ || {};
10203
+ return {
10204
+ size: c.size,
10205
+ path: c.path,
10206
+ is_array: true
10207
+ };
10208
+ }
10209
+ }
10210
+ return null;
10211
+ }
10212
+ /************************************************************
10213
+ * Join / split absolute path segments (kernel convention)
10214
+ ************************************************************/
10215
+ function seg_join(segments) {
10216
+ return segments.map(function(s) {
10217
+ return String(s);
10218
+ }).join("`");
10219
+ }
10220
+ function seg_split(path) {
10221
+ if (path === "" || path === null || path === void 0) return [];
10222
+ return path.split("`");
10223
+ }
10224
+ /************************************************************
10225
+ * Walk a JSON tree by absolute segments; undefined if the
10226
+ * path does not resolve.
10227
+ ************************************************************/
10228
+ function get_by_segments(root, segments) {
10229
+ let v = root;
10230
+ for (let seg of segments) {
10231
+ if (v === null || v === void 0) return;
10232
+ if (Array.isArray(v)) v = v[Number(seg)];
10233
+ else if (typeof v === "object") v = v[seg];
10234
+ else return;
10235
+ }
10236
+ return v;
10237
+ }
10238
+ /************************************************************
10239
+ * Set a value at absolute segments (mutates root).
10240
+ *
10241
+ * segments == [] means "replace the whole root" — the caller
10242
+ * must use the RETURNED value in that case (a primitive/array
10243
+ * root cannot be mutated in place).
10244
+ ************************************************************/
10245
+ function set_by_segments(root, segments, new_value) {
10246
+ if (segments.length === 0) return new_value;
10247
+ let parent = get_by_segments(root, segments.slice(0, -1));
10248
+ if (parent === null || parent === void 0 || typeof parent !== "object") return root;
10249
+ let last = segments[segments.length - 1];
10250
+ if (Array.isArray(parent)) parent[Number(last)] = new_value;
10251
+ else parent[last] = new_value;
10252
+ return root;
10253
+ }
10254
+ /************************************************************
10255
+ * Does `value` (or any loaded descendant / its own key)
10256
+ * contain the lower-cased search term?
10257
+ *
10258
+ * Collapsed (not-yet-loaded) subtrees can't be searched, so
10259
+ * they never match on content — only their key can match,
10260
+ * which the renderer checks separately.
10261
+ ************************************************************/
10262
+ function subtree_matches(value, term) {
10263
+ if (!term) return true;
10264
+ if (is_collapsed(value)) return false;
10265
+ let type = json_type(value);
10266
+ if (type === "object") {
10267
+ for (let [k, v] of Object.entries(value)) {
10268
+ if (String(k).toLowerCase().includes(term)) return true;
10269
+ if (subtree_matches(v, term)) return true;
10270
+ }
10271
+ return false;
10272
+ }
10273
+ if (type === "array") {
10274
+ for (let v of value) if (subtree_matches(v, term)) return true;
10275
+ return false;
10276
+ }
10277
+ return String(value).toLowerCase().includes(term);
10278
+ }
10279
+ /************************************************************
10280
+ * Fields whose numeric value is an epoch timestamp.
10281
+ * Mirrors the timestampTag list wired into C_YUI_FORM's
10282
+ * jsoneditor control, so the two viewers agree.
10283
+ ************************************************************/
10284
+ function is_time_field(field) {
10285
+ 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";
10286
+ }
10287
+ /************************************************************
10288
+ * Format an epoch value as a local wall-clock string.
10289
+ * Accepts seconds or milliseconds; returns null when the
10290
+ * value is not a usable positive timestamp (0 == "unset").
10291
+ ************************************************************/
10292
+ function format_epoch(value) {
10293
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) return null;
10294
+ let ms = Math.abs(value) < 0xe8d4a51000 ? value * 1e3 : value;
10295
+ let d = new Date(ms);
10296
+ if (isNaN(d.getTime())) return null;
10297
+ return d.toLocaleString();
10298
+ }
10299
+ /************************************************************
10300
+ * The text view's payload: the document as text, indented
10301
+ * four characters, cut at `max_chars`.
10302
+ *
10303
+ * Returns {text, capped}. `capped` is what the caller
10304
+ * announces under the dump — a cut that is not announced
10305
+ * reads as a document that ends there.
10306
+ ************************************************************/
10307
+ function json_text_dump(root, max_chars) {
10308
+ let text;
10309
+ try {
10310
+ text = JSON.stringify(root, null, 4);
10311
+ } catch (e) {
10312
+ return {
10313
+ text: "",
10314
+ capped: false,
10315
+ error: String(e)
10316
+ };
10317
+ }
10318
+ if (text === void 0) return {
10319
+ text: "",
10320
+ capped: false,
10321
+ error: "not representable as JSON"
10322
+ };
10323
+ if (typeof max_chars === "number" && max_chars > 0 && text.length > max_chars) return {
10324
+ text: text.slice(0, max_chars),
10325
+ capped: true
10326
+ };
10327
+ return {
10328
+ text,
10329
+ capped: false
10330
+ };
10331
+ }
10332
+ /************************************************************
10333
+ * Which view a freshly mounted viewer opens on.
10334
+ *
10335
+ * Three answers, in order of authority: what the HOST asked
10336
+ * for, what the READER chose last time, and `fallback`.
10337
+ *
10338
+ * The host wins because a viewer mounted to show a graph has
10339
+ * to show one. The memory comes next, because reopening a
10340
+ * document in the view you just left is the whole point of
10341
+ * remembering it. `fallback` is the floor.
10342
+ *
10343
+ * Anything not in `modes` is treated as unsaid, which covers
10344
+ * an absent value, an empty one, and a stored string from an
10345
+ * older release that named a view no longer offered.
10346
+ ************************************************************/
10347
+ function pick_view_mode(asked, remembered, modes, fallback) {
10348
+ let known = (m) => Array.isArray(modes) && modes.indexOf(m) >= 0;
10349
+ if (known(asked)) return asked;
10350
+ if (known(remembered)) return remembered;
10351
+ return fallback;
10352
+ }
10353
+ /************************************************************
10354
+ * The short label a container row carries beside its size.
10355
+ *
10356
+ * An array of dicts that carry an `id` is a list of RECORDS,
10357
+ * and a row that says only `2: {15}` makes you open all
10358
+ * fifteen fields of every one of them to find out WHICH
10359
+ * record it is. The id is the one field that answers that,
10360
+ * so the row says it without being opened.
10361
+ *
10362
+ * Only a SCALAR id, and only when it is really there: an `id`
10363
+ * that is itself a dict or a list is not a name, it is more
10364
+ * document, and printing `[object Object]` beside the size
10365
+ * would be worse than printing nothing.
10366
+ *
10367
+ * Cut at `max_chars`, because this rides on a row whose job
10368
+ * is to stay one line.
10369
+ ************************************************************/
10370
+ function container_label(value, max_chars) {
10371
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
10372
+ if (!Object.prototype.hasOwnProperty.call(value, "id")) return "";
10373
+ let id = value.id;
10374
+ let t = typeof id;
10375
+ if (t !== "string" && t !== "number" && t !== "boolean") return "";
10376
+ let text = String(id).trim();
10377
+ if (text === "") return "";
10378
+ let cap = typeof max_chars === "number" && max_chars > 0 ? max_chars : 0;
10379
+ if (cap && text.length > cap) return text.slice(0, cap) + "…";
10380
+ return text;
10381
+ }
10382
+ /************************************************************
10383
+ * A dict, in the JSON sense: not an array and not null.
10384
+ ************************************************************/
10385
+ function is_plain_object(v) {
10386
+ return v !== null && typeof v === "object" && !Array.isArray(v);
10387
+ }
10388
+ /************************************************************
10389
+ * Does this container hold ONLY other containers?
10390
+ *
10391
+ * Such a value is not a thing, it is a LIST of things -- and a
10392
+ * list is fully described by the row that names it in its
10393
+ * parent, plus the edges to what it holds. Drawing it as a
10394
+ * card of its own put a node in the middle of the graph with no
10395
+ * data in it, saying nothing the parent's row did not already
10396
+ * say, and pushed everything below it one level down.
10397
+ *
10398
+ * An EMPTY container inside counts as content: `{0}` is
10399
+ * something the card has to show, and there is no child card
10400
+ * to show it instead.
10401
+ ************************************************************/
10402
+ function is_pure_collection(value) {
10403
+ let entries = is_plain_object(value) ? Object.entries(value) : Array.isArray(value) ? value.map((v, i) => [i, v]) : [];
10404
+ if (entries.length === 0) return false;
10405
+ for (let [, v] of entries) if (!has_branch(v)) return false;
10406
+ return true;
10407
+ }
10408
+ /************************************************************
10409
+ * A value that gets a card, or children, of its own.
10410
+ ************************************************************/
10411
+ function has_branch(value) {
10412
+ return is_plain_object(value) && Object.keys(value).length > 0 || Array.isArray(value) && value.length > 0;
10413
+ }
10414
+ /************************************************************
10415
+ * How many branches a container has, which is what the fold
10416
+ * chip counts.
10417
+ ************************************************************/
10418
+ function count_branches(value) {
10419
+ let entries = is_plain_object(value) ? Object.values(value) : Array.isArray(value) ? value : [];
10420
+ let n = 0;
10421
+ for (let v of entries) if (has_branch(v)) n++;
10422
+ return n;
10423
+ }
9972
10424
  //#endregion
9973
10425
  //#region src/c_yui_json_graph.js
9974
10426
  /***********************************************************************
@@ -10070,7 +10522,11 @@ var PRIVATE_DATA$11 = {
10070
10522
  $layout_select: null,
10071
10523
  $find_input: null,
10072
10524
  $find_result: null,
10073
- $find_count: null
10525
+ $find_count: null,
10526
+ anchor_id: "",
10527
+ anchor_path: "",
10528
+ anchor_state: "off",
10529
+ last_zoom: 1
10074
10530
  };
10075
10531
  var __gclass__$11 = null;
10076
10532
  var __layout_registered__$1 = false;
@@ -10363,6 +10819,7 @@ function make_toolbar$3(gobj) {
10363
10819
  ]]);
10364
10820
  let center_items = [];
10365
10821
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
10822
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
10366
10823
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
10367
10824
  let current_layout = gobj_read_str_attr(gobj, "layout") || DEFAULT_LAYOUT;
10368
10825
  let options = Object.keys(LAYOUTS$1).map(function(key) {
@@ -10445,6 +10902,15 @@ function build_graph$3(gobj) {
10445
10902
  });
10446
10903
  graph.on("aftertransform", () => {
10447
10904
  yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
10905
+ let z = priv.last_zoom;
10906
+ try {
10907
+ z = priv.graph.getZoom();
10908
+ } catch (e) {
10909
+ return;
10910
+ }
10911
+ if (z === priv.last_zoom) return;
10912
+ priv.last_zoom = z;
10913
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
10448
10914
  });
10449
10915
  graph.on(CanvasEvent.CLICK, (evt) => {});
10450
10916
  if (typeof ResizeObserver !== "undefined") {
@@ -10465,7 +10931,7 @@ function build_graph$3(gobj) {
10465
10931
  /************************************************************
10466
10932
  * Build cell value HTML (matching old mx_json_viewer colors)
10467
10933
  ************************************************************/
10468
- function build_cell_html(key, value, type, dark, matched) {
10934
+ function build_cell_html(key, value, type, dark, matched, fold) {
10469
10935
  let on_light = matched ? false : dark;
10470
10936
  let color = type_color(type, on_light);
10471
10937
  let key_color = json_card_style(GROUP_COLORS.dict, on_light).key;
@@ -10492,6 +10958,7 @@ function build_cell_html(key, value, type, dark, matched) {
10492
10958
  default: display_value = String(value);
10493
10959
  }
10494
10960
  let row = `<span style="color:${key_color}">• ${escapeHtml(String(key))}: </span><span style="color:${color}">${display_value}</span>`;
10961
+ if (fold) row += `<span style="margin-left:8px;">` + fold_toggle_html(fold.path, true, fold.folded, fold.branches, fold.colors) + `</span>`;
10495
10962
  if (matched) row = `<span style="background:#ffe082; border-radius:2px; padding:0 2px;">${row}</span>`;
10496
10963
  return row;
10497
10964
  }
@@ -10615,6 +11082,7 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
10615
11082
  let lines = [];
10616
11083
  let pending_complex = [];
10617
11084
  let card_matched = false;
11085
+ let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
10618
11086
  let entries = is_dict ? Object.entries(kw) : kw.map((v, i) => [i, v]);
10619
11087
  for (let [key, value] of entries) {
10620
11088
  let type = get_json_type(value);
@@ -10623,17 +11091,32 @@ function build_json_nodes(gobj, path, kw, nodes, edges, parent_id) {
10623
11091
  card_matched = true;
10624
11092
  priv.match_count++;
10625
11093
  }
10626
- let html = build_cell_html(key, value, type, dark, matched);
10627
- lines.push(html);
10628
- if (is_object(value) && json_object_size(value) > 0 || is_array(value) && value.length > 0) pending_complex.push({
10629
- key,
10630
- value
10631
- });
11094
+ let fold = null;
11095
+ if (has_branch(value)) {
11096
+ pending_complex.push({
11097
+ key,
11098
+ value
11099
+ });
11100
+ if (is_pure_collection(value)) {
11101
+ let child_path = add_segment(path, key);
11102
+ fold = {
11103
+ path: child_path,
11104
+ folded: priv.collapsed.has(child_path),
11105
+ branches: count_branches(value),
11106
+ colors
11107
+ };
11108
+ }
11109
+ }
11110
+ lines.push(build_cell_html(key, value, type, dark, matched, fold));
11111
+ }
11112
+ if (parent_id && is_pure_collection(kw)) {
11113
+ if (priv.collapsed.has(path)) return parent_id;
11114
+ for (let pending of pending_complex) build_json_nodes(gobj, add_segment(path, pending.key), pending.value, nodes, edges, parent_id);
11115
+ return parent_id;
10632
11116
  }
10633
11117
  let label = get_group_label(path) || (is_dict ? "{}" : "[]");
10634
11118
  let foldable = pending_complex.length > 0;
10635
11119
  let folded = foldable && priv.collapsed.has(path);
10636
- let colors = json_card_style(is_dict ? GROUP_COLORS.dict : GROUP_COLORS.list, dark);
10637
11120
  let border_color = card_matched ? dark ? "#ffb300" : "#ff8f00" : colors.border;
10638
11121
  let border_width = card_matched ? 2 : 1;
10639
11122
  let content_html = lines.map((line) => `<div style="font-family:monospace; font-size:13px; padding:1px 6px; white-space:nowrap;">${line}</div>`).join("");
@@ -10706,34 +11189,46 @@ function load_json(gobj) {
10706
11189
  let preserve_view = !!priv.pending_preserve_view;
10707
11190
  priv.pending_preserve_view = false;
10708
11191
  if (nodes.length > 0) {
11192
+ let root_id = nodes[0].id;
10709
11193
  graph.setData({
10710
11194
  nodes,
10711
11195
  edges
10712
11196
  });
10713
11197
  graph.render().then(() => {
10714
- if (!preserve_view) graph.fitView();
10715
- yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
11198
+ reanchor$1(gobj);
11199
+ if (preserve_view) {
11200
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
11201
+ return;
11202
+ }
11203
+ Promise.resolve(graph.zoomTo(1)).then(() => {
11204
+ if (!yui_graph_center_on(graph, priv.anchor_id)) yui_graph_center_on(graph, root_id);
11205
+ priv.last_zoom = 1;
11206
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
11207
+ });
10716
11208
  });
10717
11209
  }
10718
11210
  }
10719
11211
  /************************************************************
10720
11212
  * Every path that HAS a branch to fold, in the same recursion
10721
- * build_json_nodes uses — so "collapse all" can never name a
10722
- * card that does not exist. The root is excluded: folding it
11213
+ * build_json_nodes uses — so "collapse all" can never name
11214
+ * something with no handle. The handle is a card's header chip
11215
+ * for a container that gets a card, and the chip on its ROW in
11216
+ * the parent for a pure collection, which gets none; both are
11217
+ * keyed by this same path. The root is excluded: folding it
10723
11218
  * would leave an empty canvas.
10724
11219
  ************************************************************/
10725
11220
  function collect_foldable_paths(value, path, out, is_root) {
10726
11221
  let is_dict = is_object(value);
10727
11222
  let is_list = is_array(value);
10728
11223
  if (!is_dict && !is_list) return false;
10729
- let has_branch = false;
11224
+ let found_branch = false;
10730
11225
  let entries = is_dict ? Object.entries(value) : value.map((v, i) => [i, v]);
10731
- for (let [key, child] of entries) if (is_object(child) && json_object_size(child) > 0 || is_array(child) && child.length > 0) {
10732
- has_branch = true;
11226
+ for (let [key, child] of entries) if (has_branch(child)) {
11227
+ found_branch = true;
10733
11228
  collect_foldable_paths(child, add_segment(path, key), out, false);
10734
11229
  }
10735
- if (has_branch && !is_root) out.push(path);
10736
- return has_branch;
11230
+ if (found_branch && !is_root) out.push(path);
11231
+ return found_branch;
10737
11232
  }
10738
11233
  /************************************************************
10739
11234
  * Show the find count, or hide the chip when there is no
@@ -10975,13 +11470,79 @@ function ac_theme$3(gobj, event, kw, src) {
10975
11470
  return 0;
10976
11471
  }
10977
11472
  /************************************************************
11473
+ * Every camera action ends the same way once an anchor is
11474
+ * set: the thing you were reading goes back to the middle.
11475
+ *
11476
+ * Chained on the promise G6 returns rather than run beside it
11477
+ * -- centring before the zoom has finished centres the OLD
11478
+ * view, and the reader watches the node slide away and come
11479
+ * back.
11480
+ ************************************************************/
11481
+ function after_camera$2(gobj, moved) {
11482
+ let priv = gobj.priv;
11483
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
11484
+ Promise.resolve(moved).then(function() {
11485
+ yui_graph_center_on(priv.graph, priv.anchor_id);
11486
+ }).catch(function() {});
11487
+ }
11488
+ /************************************************************
11489
+ * Point the anchor back at its node after a rebuild.
11490
+ *
11491
+ * Node ids are generated per build, so the id the reader
11492
+ * chose does not survive a refresh, a fold or a layout
11493
+ * change -- the PATH does, and it is what the anchor really
11494
+ * means: "this place in the document", not "this card".
11495
+ *
11496
+ * A path that is no longer drawn (its parent got folded)
11497
+ * releases the anchor rather than leaving a button that says
11498
+ * it is locked onto something invisible.
11499
+ ************************************************************/
11500
+ function reanchor$1(gobj) {
11501
+ let priv = gobj.priv;
11502
+ if (priv.anchor_state !== "on" || !priv.anchor_path) return;
11503
+ let found = "";
11504
+ try {
11505
+ for (let node of priv.graph.getNodeData()) if (node && node.data && node.data.path === priv.anchor_path) {
11506
+ found = node.id;
11507
+ break;
11508
+ }
11509
+ } catch (e) {
11510
+ return;
11511
+ }
11512
+ priv.anchor_id = found;
11513
+ if (!found) {
11514
+ priv.anchor_state = "off";
11515
+ priv.anchor_path = "";
11516
+ }
11517
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
11518
+ }
11519
+ /************************************************************
11520
+ * Arm the anchor, or let it go.
11521
+ *
11522
+ * Three states and one button, so a press ADVANCES: with no
11523
+ * target it starts waiting for one, and with a target it
11524
+ * releases. Pressing it while it waits is a cancel, which is
11525
+ * what somebody who pressed it by mistake will try.
11526
+ ************************************************************/
11527
+ function ac_toggle_anchor$2(gobj, event, kw, src) {
11528
+ let priv = gobj.priv;
11529
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
11530
+ else {
11531
+ priv.anchor_state = "off";
11532
+ priv.anchor_id = "";
11533
+ priv.anchor_path = "";
11534
+ }
11535
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
11536
+ return 0;
11537
+ }
11538
+ /************************************************************
10978
11539
  *
10979
11540
  ************************************************************/
10980
11541
  function ac_zoom_in$2(gobj, event, kw, src) {
10981
11542
  let graph = gobj.priv.graph;
10982
11543
  if (graph) {
10983
11544
  let z = graph.getZoom();
10984
- graph.zoomTo(z * 1.2);
11545
+ after_camera$2(gobj, graph.zoomTo(z * 1.2));
10985
11546
  }
10986
11547
  return 0;
10987
11548
  }
@@ -10992,7 +11553,7 @@ function ac_zoom_out$2(gobj, event, kw, src) {
10992
11553
  let graph = gobj.priv.graph;
10993
11554
  if (graph) {
10994
11555
  let z = graph.getZoom();
10995
- graph.zoomTo(z * .8);
11556
+ after_camera$2(gobj, graph.zoomTo(z * .8));
10996
11557
  }
10997
11558
  return 0;
10998
11559
  }
@@ -11000,10 +11561,14 @@ function ac_zoom_out$2(gobj, event, kw, src) {
11000
11561
  *
11001
11562
  ************************************************************/
11002
11563
  function ac_zoom_reset$2(gobj, event, kw, src) {
11003
- let graph = gobj.priv.graph;
11564
+ let priv = gobj.priv;
11565
+ let graph = priv.graph;
11004
11566
  if (graph && graph.rendered) {
11005
- graph.zoomTo(1);
11006
- graph.translateTo([0, 0]);
11567
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera$2(gobj, graph.zoomTo(1));
11568
+ else {
11569
+ graph.zoomTo(1);
11570
+ graph.translateTo([0, 0]);
11571
+ }
11007
11572
  }
11008
11573
  return 0;
11009
11574
  }
@@ -11022,12 +11587,21 @@ function ac_center$2(gobj, event, kw, src) {
11022
11587
  *
11023
11588
  ************************************************************/
11024
11589
  function ac_node_click$4(gobj, event, kw, src) {
11025
- let graph = gobj.priv.graph;
11590
+ let priv = gobj.priv;
11591
+ let graph = priv.graph;
11026
11592
  let node_id = kw.evt.target.id;
11027
11593
  let nodedata;
11028
11594
  try {
11029
11595
  nodedata = graph.getNodeData(node_id);
11030
11596
  } catch (e) {}
11597
+ if (priv.anchor_state === "arming") {
11598
+ priv.anchor_id = node_id;
11599
+ priv.anchor_path = nodedata && nodedata.data ? nodedata.data.path : "";
11600
+ priv.anchor_state = "on";
11601
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
11602
+ yui_graph_center_on(graph, node_id);
11603
+ return 0;
11604
+ }
11031
11605
  if (nodedata && nodedata.data) gobj_publish_event(gobj, "EV_JSON_ITEM_CLICKED", {
11032
11606
  path: nodedata.data.path,
11033
11607
  id: node_id
@@ -11161,6 +11735,11 @@ function create_gclass$11(gclass_name) {
11161
11735
  ac_node_click$4,
11162
11736
  null
11163
11737
  ],
11738
+ [
11739
+ "EV_TOGGLE_ANCHOR",
11740
+ ac_toggle_anchor$2,
11741
+ null
11742
+ ],
11164
11743
  [
11165
11744
  "EV_RESIZE",
11166
11745
  ac_resize$2,
@@ -11191,6 +11770,7 @@ function create_gclass$11(gclass_name) {
11191
11770
  ["EV_ZOOM_RESET", 0],
11192
11771
  ["EV_CENTER", 0],
11193
11772
  ["EV_NODE_CLICK", 0],
11773
+ ["EV_TOGGLE_ANCHOR", 0],
11194
11774
  ["EV_RESIZE", 0],
11195
11775
  ["EV_SHOW", 0],
11196
11776
  ["EV_HIDE", 0],
@@ -11207,226 +11787,6 @@ function register_c_yui_json_graph() {
11207
11787
  if (gclass_find_by_name(GCLASS_NAME$11, false)) return 0;
11208
11788
  return create_gclass$11(GCLASS_NAME$11);
11209
11789
  }
11210
- /************************************************************
11211
- * JSON type discriminator (viewer vocabulary)
11212
- ************************************************************/
11213
- function json_type(value) {
11214
- if (value === null) return "null";
11215
- if (Array.isArray(value)) return "array";
11216
- switch (typeof value) {
11217
- case "string": return "string";
11218
- case "number": return "number";
11219
- case "boolean": return "boolean";
11220
- case "object": return "object";
11221
- default: return "unknown";
11222
- }
11223
- }
11224
- /************************************************************
11225
- * Detect a kw_collapse() sentinel.
11226
- *
11227
- * The kernel replaces an over-limit dict with:
11228
- * { "__collapsed__": { "path": ..., "size": N } }
11229
- * and an over-limit array with:
11230
- * [ { "__collapsed__": { "path": ..., "size": N } } ]
11231
- *
11232
- * Returns { size, path, is_array } or null.
11233
- ************************************************************/
11234
- function is_collapsed(value) {
11235
- if (value && typeof value === "object" && !Array.isArray(value)) {
11236
- let keys = Object.keys(value);
11237
- if (keys.length === 1 && keys[0] === "__collapsed__") {
11238
- let c = value.__collapsed__ || {};
11239
- return {
11240
- size: c.size,
11241
- path: c.path,
11242
- is_array: false
11243
- };
11244
- }
11245
- return null;
11246
- }
11247
- if (Array.isArray(value) && value.length === 1) {
11248
- let e = value[0];
11249
- if (e && typeof e === "object" && !Array.isArray(e) && Object.keys(e).length === 1 && e.__collapsed__) {
11250
- let c = e.__collapsed__ || {};
11251
- return {
11252
- size: c.size,
11253
- path: c.path,
11254
- is_array: true
11255
- };
11256
- }
11257
- }
11258
- return null;
11259
- }
11260
- /************************************************************
11261
- * Join / split absolute path segments (kernel convention)
11262
- ************************************************************/
11263
- function seg_join(segments) {
11264
- return segments.map(function(s) {
11265
- return String(s);
11266
- }).join("`");
11267
- }
11268
- function seg_split(path) {
11269
- if (path === "" || path === null || path === void 0) return [];
11270
- return path.split("`");
11271
- }
11272
- /************************************************************
11273
- * Walk a JSON tree by absolute segments; undefined if the
11274
- * path does not resolve.
11275
- ************************************************************/
11276
- function get_by_segments(root, segments) {
11277
- let v = root;
11278
- for (let seg of segments) {
11279
- if (v === null || v === void 0) return;
11280
- if (Array.isArray(v)) v = v[Number(seg)];
11281
- else if (typeof v === "object") v = v[seg];
11282
- else return;
11283
- }
11284
- return v;
11285
- }
11286
- /************************************************************
11287
- * Set a value at absolute segments (mutates root).
11288
- *
11289
- * segments == [] means "replace the whole root" — the caller
11290
- * must use the RETURNED value in that case (a primitive/array
11291
- * root cannot be mutated in place).
11292
- ************************************************************/
11293
- function set_by_segments(root, segments, new_value) {
11294
- if (segments.length === 0) return new_value;
11295
- let parent = get_by_segments(root, segments.slice(0, -1));
11296
- if (parent === null || parent === void 0 || typeof parent !== "object") return root;
11297
- let last = segments[segments.length - 1];
11298
- if (Array.isArray(parent)) parent[Number(last)] = new_value;
11299
- else parent[last] = new_value;
11300
- return root;
11301
- }
11302
- /************************************************************
11303
- * Does `value` (or any loaded descendant / its own key)
11304
- * contain the lower-cased search term?
11305
- *
11306
- * Collapsed (not-yet-loaded) subtrees can't be searched, so
11307
- * they never match on content — only their key can match,
11308
- * which the renderer checks separately.
11309
- ************************************************************/
11310
- function subtree_matches(value, term) {
11311
- if (!term) return true;
11312
- if (is_collapsed(value)) return false;
11313
- let type = json_type(value);
11314
- if (type === "object") {
11315
- for (let [k, v] of Object.entries(value)) {
11316
- if (String(k).toLowerCase().includes(term)) return true;
11317
- if (subtree_matches(v, term)) return true;
11318
- }
11319
- return false;
11320
- }
11321
- if (type === "array") {
11322
- for (let v of value) if (subtree_matches(v, term)) return true;
11323
- return false;
11324
- }
11325
- return String(value).toLowerCase().includes(term);
11326
- }
11327
- /************************************************************
11328
- * Fields whose numeric value is an epoch timestamp.
11329
- * Mirrors the timestampTag list wired into C_YUI_FORM's
11330
- * jsoneditor control, so the two viewers agree.
11331
- ************************************************************/
11332
- function is_time_field(field) {
11333
- 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";
11334
- }
11335
- /************************************************************
11336
- * Format an epoch value as a local wall-clock string.
11337
- * Accepts seconds or milliseconds; returns null when the
11338
- * value is not a usable positive timestamp (0 == "unset").
11339
- ************************************************************/
11340
- function format_epoch(value) {
11341
- if (typeof value !== "number" || !isFinite(value) || value <= 0) return null;
11342
- let ms = Math.abs(value) < 0xe8d4a51000 ? value * 1e3 : value;
11343
- let d = new Date(ms);
11344
- if (isNaN(d.getTime())) return null;
11345
- return d.toLocaleString();
11346
- }
11347
- /************************************************************
11348
- * The text view's payload: the document as text, indented
11349
- * four characters, cut at `max_chars`.
11350
- *
11351
- * Returns {text, capped}. `capped` is what the caller
11352
- * announces under the dump — a cut that is not announced
11353
- * reads as a document that ends there.
11354
- ************************************************************/
11355
- function json_text_dump(root, max_chars) {
11356
- let text;
11357
- try {
11358
- text = JSON.stringify(root, null, 4);
11359
- } catch (e) {
11360
- return {
11361
- text: "",
11362
- capped: false,
11363
- error: String(e)
11364
- };
11365
- }
11366
- if (text === void 0) return {
11367
- text: "",
11368
- capped: false,
11369
- error: "not representable as JSON"
11370
- };
11371
- if (typeof max_chars === "number" && max_chars > 0 && text.length > max_chars) return {
11372
- text: text.slice(0, max_chars),
11373
- capped: true
11374
- };
11375
- return {
11376
- text,
11377
- capped: false
11378
- };
11379
- }
11380
- /************************************************************
11381
- * Which view a freshly mounted viewer opens on.
11382
- *
11383
- * Three answers, in order of authority: what the HOST asked
11384
- * for, what the READER chose last time, and `fallback`.
11385
- *
11386
- * The host wins because a viewer mounted to show a graph has
11387
- * to show one. The memory comes next, because reopening a
11388
- * document in the view you just left is the whole point of
11389
- * remembering it. `fallback` is the floor.
11390
- *
11391
- * Anything not in `modes` is treated as unsaid, which covers
11392
- * an absent value, an empty one, and a stored string from an
11393
- * older release that named a view no longer offered.
11394
- ************************************************************/
11395
- function pick_view_mode(asked, remembered, modes, fallback) {
11396
- let known = (m) => Array.isArray(modes) && modes.indexOf(m) >= 0;
11397
- if (known(asked)) return asked;
11398
- if (known(remembered)) return remembered;
11399
- return fallback;
11400
- }
11401
- /************************************************************
11402
- * The short label a container row carries beside its size.
11403
- *
11404
- * An array of dicts that carry an `id` is a list of RECORDS,
11405
- * and a row that says only `2: {15}` makes you open all
11406
- * fifteen fields of every one of them to find out WHICH
11407
- * record it is. The id is the one field that answers that,
11408
- * so the row says it without being opened.
11409
- *
11410
- * Only a SCALAR id, and only when it is really there: an `id`
11411
- * that is itself a dict or a list is not a name, it is more
11412
- * document, and printing `[object Object]` beside the size
11413
- * would be worse than printing nothing.
11414
- *
11415
- * Cut at `max_chars`, because this rides on a row whose job
11416
- * is to stay one line.
11417
- ************************************************************/
11418
- function container_label(value, max_chars) {
11419
- if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
11420
- if (!Object.prototype.hasOwnProperty.call(value, "id")) return "";
11421
- let id = value.id;
11422
- let t = typeof id;
11423
- if (t !== "string" && t !== "number" && t !== "boolean") return "";
11424
- let text = String(id).trim();
11425
- if (text === "") return "";
11426
- let cap = typeof max_chars === "number" && max_chars > 0 ? max_chars : 0;
11427
- if (cap && text.length > cap) return text.slice(0, cap) + "…";
11428
- return text;
11429
- }
11430
11790
  //#endregion
11431
11791
  //#region src/c_yui_json.js
11432
11792
  /***********************************************************************
@@ -12712,7 +13072,11 @@ var PRIVATE_DATA$9 = {
12712
13072
  node_by_id: null,
12713
13073
  collapse_state: null,
12714
13074
  resize_observer: null,
12715
- _resize_raf: 0
13075
+ _resize_raf: 0,
13076
+ anchor_id: "",
13077
+ anchor_name: "",
13078
+ anchor_state: "off",
13079
+ last_zoom: 1
12716
13080
  };
12717
13081
  var __gclass__$9 = null;
12718
13082
  var __layout_registered__ = false;
@@ -13105,6 +13469,7 @@ function make_toolbar$1(gobj) {
13105
13469
  let center_items = [];
13106
13470
  let right_items = [];
13107
13471
  center_items = yui_graph_camera_items(gobj, priv.graph, toolbar_wide);
13472
+ center_items.push(yui_graph_anchor_item(gobj, toolbar_wide));
13108
13473
  center_items.push(yui_graph_refresh_item(gobj, toolbar_wide));
13109
13474
  let options = [];
13110
13475
  for (let key of Object.keys(LAYOUTS)) {
@@ -13180,6 +13545,15 @@ function build_graph$2(gobj) {
13180
13545
  if (priv.theme) graph.setTheme(priv.theme);
13181
13546
  graph.on("aftertransform", () => {
13182
13547
  yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
13548
+ let z = priv.last_zoom;
13549
+ try {
13550
+ z = priv.graph.getZoom();
13551
+ } catch (e) {
13552
+ return;
13553
+ }
13554
+ if (z === priv.last_zoom) return;
13555
+ priv.last_zoom = z;
13556
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
13183
13557
  });
13184
13558
  graph.on(NodeEvent.CLICK, (evt) => {
13185
13559
  gobj_send_event(gobj, "EV_NODE_CLICK", { evt }, gobj);
@@ -13467,13 +13841,23 @@ function load_tree(gobj) {
13467
13841
  let edges = [];
13468
13842
  build_gobj_nodes(gobj, yuno, nodes, edges, null, true, layout_cfg.compact);
13469
13843
  if (nodes.length > 0) {
13844
+ let root_id = nodes[0].id;
13470
13845
  graph.setData({
13471
13846
  nodes,
13472
13847
  edges
13473
13848
  });
13474
13849
  graph.render().then(() => {
13475
- if (anchor) restore_view_anchored(gobj, anchor);
13476
- else if (!preserve_view) graph.fitView();
13850
+ reanchor(gobj);
13851
+ if (anchor) {
13852
+ restore_view_anchored(gobj, anchor);
13853
+ return;
13854
+ }
13855
+ if (preserve_view) return;
13856
+ Promise.resolve(graph.zoomTo(1)).then(() => {
13857
+ if (!yui_graph_center_on(graph, priv.anchor_id)) yui_graph_center_on(graph, root_id);
13858
+ priv.last_zoom = 1;
13859
+ yui_graph_update_zoom(gobj_read_attr(gobj, "$container"), priv.graph);
13860
+ });
13477
13861
  });
13478
13862
  }
13479
13863
  }
@@ -13975,13 +14359,77 @@ function ac_collapse_all(gobj, event, kw, src) {
13975
14359
  return 0;
13976
14360
  }
13977
14361
  /************************************************************
14362
+ * Every camera action ends the same way once an anchor is
14363
+ * set: the thing you were reading goes back to the middle.
14364
+ *
14365
+ * Chained on the promise G6 returns rather than run beside it
14366
+ * -- centring before the zoom has finished centres the OLD
14367
+ * view, and the reader watches the node slide away and come
14368
+ * back.
14369
+ ************************************************************/
14370
+ function after_camera$1(gobj, moved) {
14371
+ let priv = gobj.priv;
14372
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
14373
+ Promise.resolve(moved).then(function() {
14374
+ yui_graph_center_on(priv.graph, priv.anchor_id);
14375
+ }).catch(function() {});
14376
+ }
14377
+ /************************************************************
14378
+ * Point the anchor back at its node after a rebuild.
14379
+ *
14380
+ * Node ids are generated per build, so the id the reader chose
14381
+ * does not survive an expand, a collapse or a refresh -- the
14382
+ * `full_name` does, and it is what the anchor really means:
14383
+ * "this gobj", not "this card".
14384
+ *
14385
+ * A gobj no longer drawn (its branch got collapsed, or it went
14386
+ * away) releases the anchor rather than leaving a button that
14387
+ * claims to be locked onto something invisible.
14388
+ ************************************************************/
14389
+ function reanchor(gobj) {
14390
+ let priv = gobj.priv;
14391
+ if (priv.anchor_state !== "on" || !priv.anchor_name) return;
14392
+ let found = "";
14393
+ if (priv.node_by_id) {
14394
+ for (let id of Object.keys(priv.node_by_id)) if (priv.node_by_id[id].full_name === priv.anchor_name) {
14395
+ found = id;
14396
+ break;
14397
+ }
14398
+ }
14399
+ priv.anchor_id = found;
14400
+ if (!found) {
14401
+ priv.anchor_state = "off";
14402
+ priv.anchor_name = "";
14403
+ }
14404
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
14405
+ }
14406
+ /************************************************************
14407
+ * Arm the anchor, or let it go.
14408
+ *
14409
+ * Three states and one button, so a press ADVANCES: with no
14410
+ * target it starts waiting for one, and with a target it
14411
+ * releases. Pressing it while it waits is a cancel, which is
14412
+ * what somebody who pressed it by mistake will try.
14413
+ ************************************************************/
14414
+ function ac_toggle_anchor$1(gobj, event, kw, src) {
14415
+ let priv = gobj.priv;
14416
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
14417
+ else {
14418
+ priv.anchor_state = "off";
14419
+ priv.anchor_id = "";
14420
+ priv.anchor_name = "";
14421
+ }
14422
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
14423
+ return 0;
14424
+ }
14425
+ /************************************************************
13978
14426
  *
13979
14427
  ************************************************************/
13980
14428
  function ac_zoom_in$1(gobj, event, kw, src) {
13981
14429
  let graph = gobj.priv.graph;
13982
14430
  if (graph) {
13983
14431
  let z = graph.getZoom();
13984
- graph.zoomTo(z * 1.2);
14432
+ after_camera$1(gobj, graph.zoomTo(z * 1.2));
13985
14433
  }
13986
14434
  return 0;
13987
14435
  }
@@ -13992,7 +14440,7 @@ function ac_zoom_out$1(gobj, event, kw, src) {
13992
14440
  let graph = gobj.priv.graph;
13993
14441
  if (graph) {
13994
14442
  let z = graph.getZoom();
13995
- graph.zoomTo(z * .8);
14443
+ after_camera$1(gobj, graph.zoomTo(z * .8));
13996
14444
  }
13997
14445
  return 0;
13998
14446
  }
@@ -14000,10 +14448,14 @@ function ac_zoom_out$1(gobj, event, kw, src) {
14000
14448
  *
14001
14449
  ************************************************************/
14002
14450
  function ac_zoom_reset$1(gobj, event, kw, src) {
14003
- let graph = gobj.priv.graph;
14451
+ let priv = gobj.priv;
14452
+ let graph = priv.graph;
14004
14453
  if (graph && graph.rendered) {
14005
- graph.zoomTo(1);
14006
- graph.translateTo([0, 0]);
14454
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera$1(gobj, graph.zoomTo(1));
14455
+ else {
14456
+ graph.zoomTo(1);
14457
+ graph.translateTo([0, 0]);
14458
+ }
14007
14459
  }
14008
14460
  return 0;
14009
14461
  }
@@ -14031,6 +14483,14 @@ function ac_node_click$3(gobj, event, kw, src) {
14031
14483
  if (nd && nd.data) node_data = nd.data;
14032
14484
  } catch (e) {}
14033
14485
  if (!node_data) return 0;
14486
+ if (priv.anchor_state === "arming") {
14487
+ priv.anchor_id = node_id;
14488
+ priv.anchor_name = node_data.full_name || "";
14489
+ priv.anchor_state = "on";
14490
+ yui_graph_update_anchor(gobj_read_attr(gobj, "$container"), priv.anchor_state);
14491
+ yui_graph_center_on(graph, node_id);
14492
+ return 0;
14493
+ }
14034
14494
  let client_x = kw.evt && kw.evt.client ? kw.evt.client.x : 0;
14035
14495
  let client_y = kw.evt && kw.evt.client ? kw.evt.client.y : 0;
14036
14496
  show_popover(gobj, node_data, client_x, client_y);
@@ -14111,6 +14571,7 @@ function create_gclass$9(gclass_name) {
14111
14571
  ["EV_ZOOM_RESET", 0],
14112
14572
  ["EV_CENTER", 0],
14113
14573
  ["EV_NODE_CLICK", 0],
14574
+ ["EV_TOGGLE_ANCHOR", 0],
14114
14575
  ["EV_RESIZE", 0],
14115
14576
  ["EV_SHOW", 0],
14116
14577
  ["EV_HIDE", 0]
@@ -14170,6 +14631,11 @@ function create_gclass$9(gclass_name) {
14170
14631
  ac_node_click$3,
14171
14632
  null
14172
14633
  ],
14634
+ [
14635
+ "EV_TOGGLE_ANCHOR",
14636
+ ac_toggle_anchor$1,
14637
+ null
14638
+ ],
14173
14639
  [
14174
14640
  "EV_RESIZE",
14175
14641
  ac_resize$1,
@@ -20732,123 +21198,6 @@ function register_c_yui_treedb_topic_with_form() {
20732
21198
  return create_gclass$5(GCLASS_NAME$5);
20733
21199
  }
20734
21200
  //#endregion
20735
- //#region src/lib_graph.js
20736
- /************************************************************
20737
- * Add class to elements selected
20738
- ************************************************************/
20739
- function addClasses($container, selector, ...classNames) {
20740
- $container.querySelectorAll(selector).forEach((el) => el.classList.add(...classNames));
20741
- }
20742
- /************************************************************
20743
- * Remove class from elements selected
20744
- ************************************************************/
20745
- function removeClasses($container, selector, ...classNames) {
20746
- $container.querySelectorAll(selector).forEach((el) => el.classList.remove(...classNames));
20747
- }
20748
- /************************************************************
20749
- * Toggle class to elements selected
20750
- ************************************************************/
20751
- function toggleClasses($container, selector, ...classNames) {
20752
- $container.querySelectorAll(selector).forEach((el) => classNames.forEach((cls) => el.classList.toggle(cls)));
20753
- }
20754
- /************************************************************
20755
- * Remove child elements
20756
- ************************************************************/
20757
- function removeChildElements($element) {
20758
- $element.innerHTML = "";
20759
- }
20760
- /************************************************************
20761
- * Disable/Enable elements
20762
- ************************************************************/
20763
- function disableElements($container, selector) {
20764
- $container.querySelectorAll(selector).forEach((el) => {
20765
- el.setAttribute("disabled", "");
20766
- });
20767
- }
20768
- function enableElements($container, selector) {
20769
- $container.querySelectorAll(selector).forEach((el) => {
20770
- if (el.hasAttribute("disabled")) el.removeAttribute("disabled");
20771
- });
20772
- }
20773
- /************************************************************
20774
- * Set or reset the color of 'enable submit action' state
20775
- * Color green
20776
- ************************************************************/
20777
- function set_submit_state($container, selector, set) {
20778
- if (set) addClasses($container, selector, "color_submit_state");
20779
- else removeClasses($container, selector, "color_submit_state");
20780
- }
20781
- /************************************************************
20782
- * Set or reset the color of 'enable cancel action' state
20783
- * Color red
20784
- ************************************************************/
20785
- function set_cancel_state($container, selector, set) {
20786
- if (set) addClasses($container, selector, "color_cancel_state");
20787
- else removeClasses($container, selector, "color_cancel_state");
20788
- }
20789
- /************************************************************
20790
- * Set or reset the color of 'enable actived' state
20791
- * Color orange
20792
- ************************************************************/
20793
- function set_active_state($container, selector, set) {
20794
- if (set) addClasses($container, selector, "color_active_state");
20795
- else removeClasses($container, selector, "color_active_state");
20796
- }
20797
- /************************************************************
20798
- * Set or reset the 'pressed' state of a TOGGLE.
20799
- *
20800
- * Not one of the colours above: each of those names a KIND of
20801
- * action (create, pending, history, destructive), and a toggle
20802
- * is not an action -- it is a state the control is IN. So it
20803
- * looks pressed instead of changing category, which also keeps
20804
- * it out of the way of a neighbour wearing the same hue.
20805
- ************************************************************/
20806
- function set_pressed_state($container, selector, set) {
20807
- if (set) addClasses($container, selector, "pressed_state");
20808
- else removeClasses($container, selector, "pressed_state");
20809
- }
20810
- /**
20811
- * Returns a smart stroke color in `rgba()` format based on:
20812
- * - The given fill color (hex or rgb[a])
20813
- * - The current theme ('light' or 'dark')
20814
- * - The adjustment factor (default 0.2)
20815
- *
20816
- * @param {string} fillColor - Fill color string
20817
- * @param {string} theme - 'light' or 'dark'
20818
- * @param {number} factor - Lighten/darken factor (default 0.2)
20819
- * @returns {string} rgba(r, g, b, a)
20820
- */
20821
- function getStrokeColor(fillColor, theme = "light", factor = .2) {
20822
- let r, g, b, a = 1;
20823
- if (fillColor.startsWith("#")) {
20824
- let hex = fillColor.slice(1);
20825
- if (hex.length === 3 || hex.length === 4) hex = hex.split("").map((c) => c + c).join("");
20826
- if (hex.length === 6) {
20827
- r = parseInt(hex.slice(0, 2), 16);
20828
- g = parseInt(hex.slice(2, 4), 16);
20829
- b = parseInt(hex.slice(4, 6), 16);
20830
- } else if (hex.length === 8) {
20831
- r = parseInt(hex.slice(0, 2), 16);
20832
- g = parseInt(hex.slice(2, 4), 16);
20833
- b = parseInt(hex.slice(4, 6), 16);
20834
- a = parseInt(hex.slice(6, 8), 16) / 255;
20835
- } else throw new Error("Unsupported hex format");
20836
- } else if (fillColor.startsWith("rgb")) {
20837
- const match = fillColor.match(/rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)/);
20838
- if (!match) throw new Error("Invalid rgb()/rgba() format.");
20839
- r = parseInt(match[1], 10);
20840
- g = parseInt(match[2], 10);
20841
- b = parseInt(match[3], 10);
20842
- if (match[4] !== void 0) a = parseFloat(match[4]);
20843
- } else throw new Error("Unsupported color format.");
20844
- const luminance = (.299 * r + .587 * g + .114 * b) / 255;
20845
- let lighten;
20846
- if (theme === "dark") lighten = luminance < .7;
20847
- else lighten = luminance < .3;
20848
- const adjust = (v) => lighten ? Math.min(255, Math.floor(v + (255 - v) * factor)) : Math.max(0, Math.floor(v * (1 - factor)));
20849
- return `rgba(${adjust(r)}, ${adjust(g)}, ${adjust(b)}, ${a.toFixed(3)})`;
20850
- }
20851
- //#endregion
20852
21201
  //#region src/treedb_node_label.js
20853
21202
  /***********************************************************************
20854
21203
  * treedb_node_label.js
@@ -47716,6 +48065,13 @@ var ICONS = `<svg xmlns="http://www.w3.org/2000/svg">
47716
48065
  stroke="currentColor" stroke-width="40"
47717
48066
  stroke-dasharray="59 36"/>
47718
48067
  </symbol>
48068
+ <!-- location-crosshairs (anchor the camera on one element).
48069
+ The SAME path as the DOM toolbars and their yi-location-crosshairs
48070
+ mask: the two toolbars sit in one console, and a control that
48071
+ does one thing has to be drawn one way. -->
48072
+ <symbol id="g6-icon-anchor" viewBox="0 0 576 512">
48073
+ <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"/>
48074
+ </symbol>
47719
48075
  <!-- floppy-disk (save) -->
47720
48076
  <symbol id="g6-icon-save" viewBox="0 0 448 512">
47721
48077
  <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"/>
@@ -47909,6 +48265,9 @@ var attrs_table$4 = [
47909
48265
  SDATA_END()
47910
48266
  ];
47911
48267
  var PRIVATE_DATA$4 = {
48268
+ anchor_id: "",
48269
+ anchor_state: "off",
48270
+ last_zoom: 1,
47912
48271
  _xy: 100,
47913
48272
  _edge_seq: 0,
47914
48273
  _history_paused: false,
@@ -48205,6 +48564,15 @@ function configure_events(gobj) {
48205
48564
  update_node_icon_position(gobj);
48206
48565
  update_link_icon_position(gobj);
48207
48566
  update_zoom_readout(gobj);
48567
+ let z = priv.last_zoom;
48568
+ try {
48569
+ z = priv.graph.getZoom();
48570
+ } catch (e) {
48571
+ return;
48572
+ }
48573
+ if (z === priv.last_zoom) return;
48574
+ priv.last_zoom = z;
48575
+ if (priv.anchor_state === "on" && priv.anchor_id) yui_graph_center_on(priv.graph, priv.anchor_id);
48208
48576
  });
48209
48577
  priv._on_language_changed = () => {
48210
48578
  update_toolbar(gobj);
@@ -48442,6 +48810,12 @@ function configure_toolbar(gobj) {
48442
48810
  value: "reset",
48443
48811
  className: "EV_ZOOM_RESET",
48444
48812
  title: t("actual size")
48813
+ },
48814
+ {
48815
+ id: "g6-icon-anchor",
48816
+ value: "anchor",
48817
+ className: "EV_TOGGLE_ANCHOR" + (priv.anchor_state !== "off" ? " pressed_state" : "") + (priv.anchor_state === "arming" ? " color_pending_state" : ""),
48818
+ title: anchor_title(priv.anchor_state)
48445
48819
  }
48446
48820
  ];
48447
48821
  if (priv.edit_mode) items.push({
@@ -48483,6 +48857,9 @@ function configure_toolbar(gobj) {
48483
48857
  case "reset":
48484
48858
  gobj_send_event(gobj, "EV_ZOOM_RESET", {}, gobj);
48485
48859
  break;
48860
+ case "anchor":
48861
+ gobj_send_event(gobj, "EV_TOGGLE_ANCHOR", {}, gobj);
48862
+ break;
48486
48863
  case "auto-fit":
48487
48864
  gobj_send_event(gobj, "EV_AUTO_FIT", {}, gobj);
48488
48865
  break;
@@ -49493,12 +49870,12 @@ function graph_find_nodes(gobj, term) {
49493
49870
  async function graph_zoom_in(gobj) {
49494
49871
  let graph = gobj.priv.graph;
49495
49872
  const z = graph.getZoom();
49496
- await graph.zoomTo(z * 1.1);
49873
+ after_camera(gobj, graph.zoomTo(z * 1.1));
49497
49874
  }
49498
49875
  async function graph_zoom_out(gobj) {
49499
49876
  let graph = gobj.priv.graph;
49500
49877
  const z = graph.getZoom();
49501
- await graph.zoomTo(z * .9);
49878
+ after_camera(gobj, graph.zoomTo(z * .9));
49502
49879
  }
49503
49880
  async function graph_render(gobj) {
49504
49881
  await gobj.priv.graph.render();
@@ -52499,7 +52876,56 @@ function ac_zoom_out(gobj, event, kw, src) {
52499
52876
  return 0;
52500
52877
  }
52501
52878
  function ac_zoom_reset(gobj, event, kw, src) {
52502
- gobj.priv.graph.zoomTo(1);
52879
+ let priv = gobj.priv;
52880
+ let graph = priv.graph;
52881
+ if (priv.anchor_state === "on" && priv.anchor_id) after_camera(gobj, graph.zoomTo(1));
52882
+ else graph.zoomTo(1);
52883
+ return 0;
52884
+ }
52885
+ /************************************************************
52886
+ * What the anchor button says it will do next.
52887
+ ************************************************************/
52888
+ function anchor_title(state) {
52889
+ if (state === "arming") return t("click the element to centre on");
52890
+ if (state === "on") return t("centred: click to release");
52891
+ return t("anchor view");
52892
+ }
52893
+ /************************************************************
52894
+ * Every camera action ends the same way once an anchor is
52895
+ * set: the thing you were reading goes back to the middle.
52896
+ *
52897
+ * Chained on the promise G6 returns rather than run beside it
52898
+ * -- centring before the zoom has finished centres the OLD
52899
+ * view, and the reader watches the node slide away and come
52900
+ * back.
52901
+ ************************************************************/
52902
+ function after_camera(gobj, moved) {
52903
+ let priv = gobj.priv;
52904
+ if (priv.anchor_state !== "on" || !priv.anchor_id) return;
52905
+ Promise.resolve(moved).then(function() {
52906
+ yui_graph_center_on(priv.graph, priv.anchor_id);
52907
+ }).catch(function() {});
52908
+ }
52909
+ /************************************************************
52910
+ * Arm the anchor, or let it go.
52911
+ *
52912
+ * Three states and one button, so a press ADVANCES: with no
52913
+ * target it starts waiting for one, and with a target it
52914
+ * releases. Pressing it while it waits is a cancel, which is
52915
+ * what somebody who pressed it by mistake will try.
52916
+ *
52917
+ * Unlike the DOM toolbars, the button is not repainted in
52918
+ * place: this toolbar is a G6 plugin that rebuilds its whole
52919
+ * item list, and the pressed classes are baked into that list.
52920
+ ************************************************************/
52921
+ function ac_toggle_anchor(gobj, event, kw, src) {
52922
+ let priv = gobj.priv;
52923
+ if (priv.anchor_state === "off") priv.anchor_state = "arming";
52924
+ else {
52925
+ priv.anchor_state = "off";
52926
+ priv.anchor_id = "";
52927
+ }
52928
+ update_toolbar(gobj);
52503
52929
  return 0;
52504
52930
  }
52505
52931
  /************************************************************
@@ -52673,6 +53099,13 @@ function ac_node_click$2(gobj, event, kw, src) {
52673
53099
  let priv = gobj.priv;
52674
53100
  let graph = priv.graph;
52675
53101
  let node_id = kw.evt.target.id;
53102
+ if (priv.anchor_state === "arming") {
53103
+ priv.anchor_id = node_id;
53104
+ priv.anchor_state = "on";
53105
+ update_toolbar(gobj);
53106
+ yui_graph_center_on(graph, node_id);
53107
+ return 0;
53108
+ }
52676
53109
  try {
52677
53110
  let nodedata = graph.getNodeData(node_id);
52678
53111
  if (nodedata && nodedata.data && nodedata.data.desc) {
@@ -52934,6 +53367,11 @@ function create_gclass$4(gclass_name) {
52934
53367
  ac_zoom_reset,
52935
53368
  null
52936
53369
  ],
53370
+ [
53371
+ "EV_TOGGLE_ANCHOR",
53372
+ ac_toggle_anchor,
53373
+ null
53374
+ ],
52937
53375
  [
52938
53376
  "EV_TOGGLE_TOOLBARS",
52939
53377
  ac_toggle_toolbars,
@@ -53047,6 +53485,7 @@ function create_gclass$4(gclass_name) {
53047
53485
  ["EV_ZOOM_IN", 0],
53048
53486
  ["EV_ZOOM_OUT", 0],
53049
53487
  ["EV_ZOOM_RESET", 0],
53488
+ ["EV_TOGGLE_ANCHOR", 0],
53050
53489
  ["EV_TOGGLE_TOOLBARS", 0],
53051
53490
  ["EV_AUTO_FIT", 0],
53052
53491
  ["EV_ZOOM_SELECTION", 0],