capdag 1.439.69 → 1.464.31

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.
@@ -144,6 +144,84 @@ function getCssVar(name) {
144
144
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
145
145
  }
146
146
 
147
+ // The graph's typeface: the host's (--graph-font-family) where it gives one. Labels are
148
+ // measured in it as they are drawn in it.
149
+ const DEFAULT_GRAPH_FONT = '"JetBrains Mono", ui-monospace, monospace';
150
+ function graphFontFamily() {
151
+ if (typeof document === 'undefined') return DEFAULT_GRAPH_FONT;
152
+ return getCssVar('--graph-font-family') || DEFAULT_GRAPH_FONT;
153
+ }
154
+
155
+ // An ELK edge section as cytoscape draws a 'segments' edge: its points as weights along, and
156
+ // distances across, the line between the two node centres, and its two ends as offsets from
157
+ // those centres. A spline's control points (start, then three per cubic) are sampled into a
158
+ // polyline fine enough to read as the curve.
159
+ function routeFromSection(section, source, target) {
160
+ const control = [section.startPoint, ...(section.bendPoints || []), section.endPoint];
161
+ let points = control;
162
+ if (control.length >= 4 && (control.length - 1) % 3 === 0) {
163
+ points = [control[0]];
164
+ for (let i = 0; i + 3 < control.length; i += 3) {
165
+ const [p0, p1, p2, p3] = control.slice(i, i + 4);
166
+ // a straight run needs no points between its ends; a bend, one every 12 units of the
167
+ // control polygon (its length bounds the curve's), so a curve reads smooth at any zoom
168
+ const hull = Math.hypot(p1.x - p0.x, p1.y - p0.y) + Math.hypot(p2.x - p1.x, p2.y - p1.y) + Math.hypot(p3.x - p2.x, p3.y - p2.y);
169
+ const chord = Math.hypot(p3.x - p0.x, p3.y - p0.y);
170
+ const steps = hull - chord < 0.5 ? 1 : Math.min(16, Math.max(4, Math.ceil((hull - chord) / 2)));
171
+ for (let k = 1; k <= steps; k++) {
172
+ const t = k / steps, u = 1 - t;
173
+ points.push({
174
+ x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
175
+ y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ const dx = target.x - source.x, dy = target.y - source.y;
181
+ const lengthSquared = dx * dx + dy * dy || 1;
182
+ const length = Math.sqrt(lengthSquared);
183
+ const weightsOf = (pts) => pts.map((p) => ((p.x - source.x) * dx + (p.y - source.y) * dy) / lengthSquared);
184
+ const distancesOf = (pts) => pts.map((p) => ((p.y - source.y) * dx - (p.x - source.x) * dy) / length);
185
+ const inner = points.slice(1, -1);
186
+ const start = points[0], end = points[points.length - 1];
187
+
188
+ // In the overview a node is a dot at its box's centre: each end of the route is carried on
189
+ // from the box's side to the dot by a curve that leaves the route in its own direction and
190
+ // meets the dot level, rather than by a straight piece at another angle
191
+ const blend = (from, along, to, fromSide) => {
192
+ const reach = Math.hypot(to.x - from.x, to.y - from.y) / 3;
193
+ const norm = Math.hypot(along.x, along.y) || 1;
194
+ const c1 = { x: from.x + (along.x / norm) * reach, y: from.y + (along.y / norm) * reach };
195
+ const c2 = { x: to.x + (fromSide ? -reach : reach), y: to.y };
196
+ const out = [];
197
+ for (let k = 1; k < 8; k++) {
198
+ const t = k / 8, u = 1 - t;
199
+ out.push({
200
+ x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
201
+ y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
202
+ });
203
+ }
204
+ return out;
205
+ };
206
+ let overviewInner = inner;
207
+ if (points.length >= 2) {
208
+ const first = points[0], second = points[1];
209
+ const last = points[points.length - 1], before = points[points.length - 2];
210
+ // from the target's side back toward the target's centre, and from the source's centre out
211
+ const tail = blend(last, { x: last.x - before.x, y: last.y - before.y }, target, true);
212
+ const head = blend(first, { x: first.x - second.x, y: first.y - second.y }, source, false).reverse();
213
+ overviewInner = [...head, first, ...inner, last, ...tail];
214
+ }
215
+ return {
216
+ weights: weightsOf(inner),
217
+ distances: distancesOf(inner),
218
+ overviewWeights: weightsOf(overviewInner),
219
+ overviewDistances: distancesOf(overviewInner),
220
+ source: { dx: start.x - source.x, dy: start.y - source.y },
221
+ target: { dx: end.x - target.x, dy: end.y - target.y },
222
+ };
223
+ }
224
+
147
225
  function cssVarNumber(name, fallback) {
148
226
  const raw = getCssVar(name);
149
227
  if (raw === '') return fallback;
@@ -319,8 +397,9 @@ function shapeLabelsInElements(elements) {
319
397
  // style at render time; we shape against the renderer's static node
320
398
  // font (defined in `buildStylesheet`). Kept in sync manually — bump
321
399
  // both together.
322
- const nodeFont = '500 9px "JetBrains Mono", ui-monospace, monospace';
323
- const edgeFont = '500 8px "JetBrains Mono", ui-monospace, monospace';
400
+ const family = graphFontFamily();
401
+ const nodeFont = `500 9px ${family}`;
402
+ const edgeFont = `500 8px ${family}`;
324
403
  let maxEdgeLabelPx = 0;
325
404
  let maxNodeLabelPx = 0;
326
405
  const reshaped = new Array(elements.length);
@@ -442,11 +521,17 @@ function layoutForMode(mode) {
442
521
  // =============================================================================
443
522
 
444
523
  function buildStylesheet() {
524
+ const fontFamily = graphFontFamily();
445
525
  const nodeText = getCssVar('--graph-node-text');
446
526
  const nodeBg = getCssVar('--graph-node-bg');
447
527
  const nodeBorder = getCssVar('--graph-node-border');
448
528
  const nodeBorderHighlighted = getCssVar('--graph-node-border-highlighted');
449
529
  const nodeBorderActive = getCssVar('--graph-node-border-active');
530
+ // The solid fill of a node in the browse overview (see `_applyLevelOfDetail`); a host
531
+ // that does not set it gets its highlighted border colour.
532
+ const nodeOverview = getCssVar('--graph-node-overview') || nodeBorderHighlighted;
533
+ // and of a chosen one there (a filled dot, where the labelled graph draws a border)
534
+ const nodeOverviewActive = getCssVar('--graph-node-overview-active') || nodeBorderActive;
450
535
  const edgeTextBg = getCssVar('--graph-edge-text-bg');
451
536
  const edgeTextBgOpacity = cssVarNumber('--graph-edge-text-bg-opacity', 0.9);
452
537
  const fadedOpacity = cssVarNumber('--graph-faded-opacity', 0.15);
@@ -481,7 +566,7 @@ function buildStylesheet() {
481
566
  // node fit its actual shaped text.
482
567
  'text-wrap': 'wrap',
483
568
  'line-height': 1.3,
484
- 'font-family': '"JetBrains Mono", ui-monospace, monospace',
569
+ 'font-family': fontFamily,
485
570
  'font-size': '9px',
486
571
  'font-weight': '500',
487
572
  'color': nodeText,
@@ -497,6 +582,22 @@ function buildStylesheet() {
497
582
  'transition-duration': '0.2s',
498
583
  },
499
584
  },
585
+ // Browse overview: below the zoom at which labels can be read, a node is a solid dot
586
+ // and an edge a plain line in its capability's colour, both of a constant size on
587
+ // screen (`lodSize`, `lodWidth`: screen pixels divided by the zoom, kept current by
588
+ // `_applyLevelOfDetail`). The graph reads as structure; labels return on zooming in.
589
+ {
590
+ selector: 'node.lod-overview',
591
+ style: {
592
+ 'label': '',
593
+ 'shape': 'ellipse',
594
+ 'width': 'data(lodSize)',
595
+ 'height': 'data(lodSize)',
596
+ 'padding': 0,
597
+ 'background-color': nodeOverview,
598
+ 'border-width': 0,
599
+ },
600
+ },
500
601
  {
501
602
  selector: 'node.highlighted',
502
603
  style: { 'border-width': '3px', 'border-color': nodeBorderHighlighted },
@@ -505,9 +606,11 @@ function buildStylesheet() {
505
606
  selector: 'node.active',
506
607
  style: { 'border-width': '3px', 'border-color': nodeBorderActive, 'z-index': 999 },
507
608
  },
609
+ // A faded element says where the rest of the graph is; its words would only be noise
610
+ // behind the chosen one's
508
611
  {
509
612
  selector: 'node.faded',
510
- style: { 'opacity': fadedOpacity },
613
+ style: { 'opacity': fadedOpacity, 'text-opacity': 0 },
511
614
  },
512
615
  {
513
616
  selector: 'node.body-success',
@@ -534,7 +637,7 @@ function buildStylesheet() {
534
637
  selector: 'edge',
535
638
  style: {
536
639
  'label': 'data(label)',
537
- 'font-family': '"JetBrains Mono", ui-monospace, monospace',
640
+ 'font-family': fontFamily,
538
641
  // Slightly smaller than the node label font (9px) so edge
539
642
  // labels read as secondary metadata rather than primary
540
643
  // identity. Kept in sync with the `edgeFont` constant in
@@ -570,10 +673,143 @@ function buildStylesheet() {
570
673
  'control-point-weights': 'data(controlPointWeights)',
571
674
  },
572
675
  },
676
+ // A capability whose output is its input (a format conversion, a filter) is a loop. Its
677
+ // loops stand straight up from the node, nested, unnamed: a loop's name would lie across
678
+ // the loops around it. It is in the tooltip and the panel, and a chosen loop is named,
679
+ // level, on its background (browse mode; its routes do not draw loops)
680
+ {
681
+ selector: 'edge.browse-loop',
682
+ style: {
683
+ 'curve-style': 'bezier',
684
+ 'loop-direction': '0deg',
685
+ 'loop-sweep': '-30deg',
686
+ // the first loop clears the node's box; each next one stands a little above it
687
+ 'control-point-step-size': 26,
688
+ 'text-opacity': 0,
689
+ 'text-background-opacity': 0,
690
+ },
691
+ },
692
+ {
693
+ selector: 'edge.browse-loop.active',
694
+ style: {
695
+ 'text-opacity': 1,
696
+ 'text-background-opacity': 1,
697
+ 'text-rotation': 'none',
698
+ 'text-margin-y': -14,
699
+ },
700
+ },
701
+ {
702
+ selector: 'edge.browse-loop.lod-overview',
703
+ style: {
704
+ 'control-point-step-size': 'data(lodLoopStep)',
705
+ 'loop-sweep': '-30deg',
706
+ },
707
+ },
708
+ // A capability's name that would overlap another's or a media type's is not written
709
+ // (`_placeEdgeLabels`); it is in the tooltip, and the panel, and comes back when what
710
+ // covered it is faded or the view moves
711
+ {
712
+ selector: 'edge.label-crowded',
713
+ style: { 'text-opacity': 0, 'text-background-opacity': 0 },
714
+ },
715
+ {
716
+ selector: 'edge.lod-overview',
717
+ style: {
718
+ 'label': '',
719
+ 'width': 'data(lodWidth)',
720
+ 'target-arrow-shape': 'none',
721
+ 'curve-style': 'bezier',
722
+ },
723
+ },
724
+ // An edge laid along its ELK route (browse mode): a polyline through the route's points,
725
+ // from where it leaves its source's box to where it enters its target's; in the overview,
726
+ // where a node is a dot at the box's centre, carried on to the dot. The route is the edge's
727
+ // data (`_routeWithElk`), so a change of detail is a change of class, not of each edge's style.
728
+ {
729
+ selector: 'edge.routed',
730
+ style: {
731
+ 'curve-style': 'segments',
732
+ 'edge-distances': 'node-position',
733
+ 'segment-weights': 'data(routeWeights)',
734
+ 'segment-distances': 'data(routeDistances)',
735
+ 'source-endpoint': 'data(routeSource)',
736
+ 'target-endpoint': 'data(routeTarget)',
737
+ },
738
+ },
739
+ {
740
+ selector: 'edge.routed.lod-overview',
741
+ style: {
742
+ 'segment-weights': 'data(routeWeightsOverview)',
743
+ 'segment-distances': 'data(routeDistancesOverview)',
744
+ 'source-endpoint': '0px 0px',
745
+ 'target-endpoint': '0px 0px',
746
+ },
747
+ },
573
748
  {
574
749
  selector: 'edge.highlighted',
575
750
  style: { 'width': 2.5, 'z-index': 999 },
576
751
  },
752
+ // A chosen capability stands out in the overview too: its nodes in the active colour and
753
+ // named, at a constant size on screen, its edges twice as wide, so that where the view is
754
+ // too small for the labelled graph (a phone) the choice still reads. The capability
755
+ // itself is named by the host's panel, not along its edge, where a long name would not
756
+ // fit a small view.
757
+ {
758
+ selector: 'node.lod-overview.highlighted, node.lod-overview.active, node.lod-overview.path-highlighted',
759
+ style: {
760
+ 'background-color': nodeOverviewActive,
761
+ 'z-index': 999,
762
+ },
763
+ },
764
+ // ... and named where its name has room (`_placeOverviewNames`)
765
+ {
766
+ selector: 'node.lod-overview.lod-named',
767
+ style: {
768
+ 'label': 'data(label)',
769
+ 'font-size': 'data(lodFont)',
770
+ 'text-valign': 'bottom',
771
+ 'text-margin-y': 'data(lodGap)',
772
+ 'color': nodeText,
773
+ 'text-background-color': edgeTextBg,
774
+ 'text-background-opacity': edgeTextBgOpacity,
775
+ 'text-background-padding': 'data(lodGap)',
776
+ 'text-background-shape': 'roundrectangle',
777
+ 'z-index': 1000,
778
+ },
779
+ },
780
+ {
781
+ selector: 'edge.lod-overview.highlighted, edge.lod-overview.active, edge.lod-overview.path-highlighted',
782
+ style: {
783
+ 'width': 'data(lodWidthStrong)',
784
+ 'z-index': 998,
785
+ },
786
+ },
787
+ // ... and a chosen capability named along the middle of its route, level, where it has
788
+ // room (`_placeOverviewNames`)
789
+ // a name that would run past the view's edge is set to the side of its dot instead
790
+ {
791
+ selector: 'node.lod-overview.lod-named.lod-name-above',
792
+ style: { 'text-valign': 'top', 'text-margin-y': 'data(lodGapNeg)' },
793
+ },
794
+ {
795
+ selector: 'node.lod-overview.lod-named.lod-name-left',
796
+ style: { 'text-halign': 'left', 'text-margin-x': 'data(lodGap)' },
797
+ },
798
+ {
799
+ selector: 'node.lod-overview.lod-named.lod-name-right',
800
+ style: { 'text-halign': 'right', 'text-margin-x': 'data(lodGapNeg)' },
801
+ },
802
+ {
803
+ selector: 'edge.lod-overview.lod-named',
804
+ style: {
805
+ 'label': 'data(label)',
806
+ 'font-size': 'data(lodFontEdge)',
807
+ 'text-rotation': 'none',
808
+ 'text-margin-y': 'data(lodLift)',
809
+ 'text-background-padding': 'data(lodGap)',
810
+ 'z-index': 1000,
811
+ },
812
+ },
577
813
  {
578
814
  selector: 'edge.active',
579
815
  style: { 'width': 3, 'z-index': 1000 },
@@ -612,7 +848,7 @@ function buildStylesheet() {
612
848
  },
613
849
  {
614
850
  selector: 'edge.faded',
615
- style: { 'opacity': fadedEdgeOpacity },
851
+ style: { 'opacity': fadedEdgeOpacity, 'text-opacity': 0, 'text-background-opacity': 0 },
616
852
  },
617
853
  {
618
854
  selector: 'edge.body-success',
@@ -640,6 +876,15 @@ function buildStylesheet() {
640
876
  selector: 'edge.path-highlighted',
641
877
  style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
642
878
  },
879
+ // in the overview a path is drawn as a chosen capability is: strong lines, filled dots
880
+ {
881
+ selector: 'edge.lod-overview.path-highlighted',
882
+ style: { 'width': 'data(lodWidthStrong)' },
883
+ },
884
+ {
885
+ selector: 'node.lod-overview.path-highlighted',
886
+ style: { 'border-width': 0 },
887
+ },
643
888
 
644
889
  // -------- Machine / editor-graph mode ------------------------------
645
890
  // Shared between the resolved-machine view (`mode: 'machine'`,
@@ -717,21 +962,23 @@ function buildStylesheet() {
717
962
  function createTooltipElement() {
718
963
  const el = document.createElement('div');
719
964
  el.className = 'graph-tooltip';
965
+ // Its look is the host's where the host gives one (--graph-tooltip-*), these otherwise
720
966
  el.style.cssText = [
721
967
  'position: fixed',
722
968
  'display: none',
723
- 'background: var(--bg-elevated)',
724
- 'border: 1px solid var(--border-primary)',
725
- 'border-radius: 6px',
726
- 'padding: 6px 10px',
969
+ 'background: var(--graph-tooltip-bg, var(--bg-elevated))',
970
+ 'border: 1px solid var(--graph-tooltip-border, var(--border-primary))',
971
+ 'border-radius: var(--graph-tooltip-radius, 6px)',
972
+ 'padding: var(--graph-tooltip-padding, 6px 10px)',
727
973
  'font-family: var(--font-mono, ui-monospace, monospace)',
728
- 'font-size: 11px',
974
+ 'font-size: var(--graph-tooltip-font-size, 11px)',
975
+ 'line-height: var(--graph-tooltip-line-height, normal)',
729
976
  'color: var(--text-secondary)',
730
977
  'max-width: 400px',
731
978
  'word-break: break-all',
732
979
  'z-index: 10000',
733
980
  'pointer-events: none',
734
- 'box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3)',
981
+ 'box-shadow: var(--graph-tooltip-shadow, 0 4px 12px rgba(0, 0, 0, 0.3))',
735
982
  ].join('; ') + ';';
736
983
  document.body.appendChild(el);
737
984
  return el;
@@ -2994,6 +3241,13 @@ class CapFabRenderer {
2994
3241
  this.bottomExcludedRegion = typeof options.bottomExcludedRegion === 'function'
2995
3242
  ? options.bottomExcludedRegion
2996
3243
  : () => 0;
3244
+ // How a capability's name is written (browse mode): the host may set it in its own case
3245
+ // (the website writes titles in sentence case); by default as the registry gives it
3246
+ this.elkWorkerUrl = typeof options.elkWorkerUrl === 'string' ? options.elkWorkerUrl : null;
3247
+ this.onReady = typeof options.onReady === 'function' ? options.onReady : null;
3248
+ this.formatCapTitle = typeof options.formatCapTitle === 'function'
3249
+ ? options.formatCapTitle
3250
+ : (title) => title;
2997
3251
 
2998
3252
  // State — shared fields.
2999
3253
  this.cy = null;
@@ -3011,6 +3265,7 @@ class CapFabRenderer {
3011
3265
  this.capabilitiesByEdgeId = new Map();
3012
3266
  this._mediaTitles = new Map();
3013
3267
  this._pendingFocusCap = null;
3268
+ this._pendingFocusCaps = null;
3014
3269
  this.pathMode = null;
3015
3270
 
3016
3271
  // Strand/run state.
@@ -3135,7 +3390,7 @@ class CapFabRenderer {
3135
3390
  // labels. Both the per-node label width and the per-graph edge
3136
3391
  // label width are recorded on the renderer so the zoom-backstop
3137
3392
  // logic can reason about them after layout-stop.
3138
- const labelMetrics = shapeLabelsInElements(rawElements);
3393
+ const labelMetrics = shapeLabelsInElements(this.mode === 'browse' ? this._formatCapTitles(rawElements) : rawElements);
3139
3394
  const elements = labelMetrics.elements;
3140
3395
  this._labelMaxNodeWidthPx = labelMetrics.maxNodeLabelPx;
3141
3396
  this._labelMaxEdgeWidthPx = labelMetrics.maxEdgeLabelPx;
@@ -3153,62 +3408,19 @@ class CapFabRenderer {
3153
3408
  this.cy = null;
3154
3409
  }
3155
3410
 
3411
+ // Browse mode lays the graph out itself when the host has loaded ELK (`_routeWithElk`):
3412
+ // ELK places the nodes AND routes the edges, leaving room for their names, and the routes
3413
+ // are drawn as ELK gives them. Otherwise, and in every other mode, cytoscape-elk places the
3414
+ // nodes and cytoscape draws its own curves between them.
3415
+ const routed = this.mode === 'browse' && typeof globalThis.ELK === 'function';
3156
3416
  this.cy = this._cytoscape({
3157
3417
  container: this.container,
3158
3418
  elements,
3159
- layout: Object.assign(
3160
- { name: 'elk', elk: layoutForMode(this.mode) },
3419
+ layout: routed ? { name: 'null' } : Object.assign(
3420
+ { name: 'elk', elk: this._layoutOptions() },
3161
3421
  {
3162
3422
  stop: function () {
3163
- self.cy.resize();
3164
- self._layoutReady = true;
3165
- // Per-edge stretch FIRST (mutates node x-coordinates so
3166
- // each edge has the horizontal room its own label needs),
3167
- // THEN lock the cytoscape wheel-zoom limits to the
3168
- // dynamic per-graph values, THEN fit the (possibly
3169
- // stretched) bounding box to the viewport so the user
3170
- // always opens at a fully-visible padded fit. Order
3171
- // matters: `_recomputeZoomLimits` needs the final
3172
- // post-stretch bounding box; `fitToVisibleViewport` needs
3173
- // the relaxed minZoom (it sits above the tight-fit zoom)
3174
- // so its padding actually shows.
3175
- self._stretchLayersForEdgeLabels();
3176
- self._recomputeZoomLimits();
3177
- const hadPendingFocus = !!self._pendingFocusCap;
3178
- if (hadPendingFocus) {
3179
- const pending = self._pendingFocusCap;
3180
- self._pendingFocusCap = null;
3181
- self.highlightCapability(pending);
3182
- }
3183
- // First paint with no preselected focus: snap (no
3184
- // animation) to a centered fit of the full, post-stretch
3185
- // graph. Going through `_centerOnGraphInitial` rather
3186
- // than `refitCurrentSelection` guarantees three things
3187
- // on the bootstrap pass that the selection-aware refit
3188
- // can't:
3189
- // - never animate, so the user never sees a transient
3190
- // un-centered state on the way to the final fit;
3191
- // - always operate on the entire element set, so
3192
- // per-edge stretching can't push the centered focus
3193
- // off-viewport;
3194
- // - reapply on every post-paint resize tick (see
3195
- // `resizeAndRefit` below) until the user first
3196
- // interacts, absorbing late WebKit container-size
3197
- // settling without surprising a user who's already
3198
- // scrolled or zoomed.
3199
- //
3200
- // If a focus cap WAS pending (browse-mode deep link from
3201
- // capdag-dot-com), defer to the selection-aware refit so
3202
- // the linked element lands centered instead.
3203
- if (hadPendingFocus) {
3204
- self.refitCurrentSelection();
3205
- // Treat the deep-link landing as the user's chosen
3206
- // viewport — late resize ticks shouldn't yank them
3207
- // back to a fit-of-all.
3208
- self._markInitialFitDone();
3209
- } else {
3210
- self._centerOnGraphInitial();
3211
- }
3423
+ self._afterLayout({ stretch: true });
3212
3424
  },
3213
3425
  }
3214
3426
  ),
@@ -3227,6 +3439,9 @@ class CapFabRenderer {
3227
3439
  const resizeAndRefit = () => {
3228
3440
  if (!this.cy) return;
3229
3441
  this.cy.resize();
3442
+ // The registry (browse) is laid out in a worker: until it is, there is no graph to measure
3443
+ // or to centre, and _afterLayout does both when it lands
3444
+ if (this.mode === 'browse' && !this._layoutReady) return;
3230
3445
  // Container size may have changed — recompute the zoom limits
3231
3446
  // so "fit the entire graph" still corresponds to actual pixel
3232
3447
  // capacity, not the size we had at first paint.
@@ -3249,16 +3464,210 @@ class CapFabRenderer {
3249
3464
  }
3250
3465
  };
3251
3466
  this.cy.on('ready', resizeAndRefit);
3252
- this.cy.on('resize', () => this._recomputeZoomLimits());
3467
+ this.cy.on('resize', () => {
3468
+ if (this.mode === 'browse' && !this._layoutReady) return;
3469
+ this._recomputeZoomLimits();
3470
+ });
3253
3471
  requestAnimationFrame(resizeAndRefit);
3254
3472
  setTimeout(resizeAndRefit, 100);
3255
3473
  setTimeout(resizeAndRefit, 300);
3256
3474
 
3257
3475
  this._setupEventHandlers();
3258
3476
  this._installZoomBackstop();
3477
+ if (this.mode === 'browse') this.cy.edges().filter((edge) => edge.source().same(edge.target())).addClass('browse-loop');
3478
+ // until ELK returns, every node of a routed graph stands at the origin: the canvas is
3479
+ // hidden meanwhile (the host's loading state shows), and shown laid out
3480
+ // Until ELK returns, every node of a routed graph stands at the origin: cytoscape's own
3481
+ // drawing layer is hidden meanwhile (the container, and any loading state the host draws
3482
+ // on it, stay), and shown laid out
3483
+ if (routed) {
3484
+ // (laid over the whole container meanwhile, so it neither takes a host's loading
3485
+ // layout from it nor is measured at another size than the one it is shown at)
3486
+ const layer = this.container.firstElementChild;
3487
+ if (layer) Object.assign(layer.style, { visibility: 'hidden', position: 'absolute', inset: '0' });
3488
+ this._routeWithElk().finally(() => {
3489
+ if (layer) Object.assign(layer.style, { visibility: '', position: '', inset: '' });
3490
+ });
3491
+ }
3259
3492
  return this;
3260
3493
  }
3261
3494
 
3495
+ // ELK's layered layout with spline routing for the registry: names are given to ELK as edge
3496
+ // labels, so each layer gap is as wide as the names that cross it and every name has a
3497
+ // straight stretch of its edge to itself.
3498
+ _routedLayoutOptions() {
3499
+ const options = {
3500
+ 'elk.algorithm': 'layered',
3501
+ 'elk.direction': 'RIGHT',
3502
+ 'elk.edgeRouting': 'SPLINES',
3503
+ 'elk.edgeLabels.placement': 'CENTER',
3504
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '100',
3505
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '20',
3506
+ 'elk.layered.spacing.edgeEdgeBetweenLayers': '12',
3507
+ 'elk.spacing.nodeNode': '60',
3508
+ 'elk.spacing.edgeNode': '20',
3509
+ 'elk.spacing.edgeEdge': '10',
3510
+ 'elk.spacing.edgeLabel': '4',
3511
+ 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
3512
+ 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
3513
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
3514
+ };
3515
+ const aspect = this._layoutOptions()['elk.aspectRatio'];
3516
+ if (aspect) options['elk.aspectRatio'] = String(aspect);
3517
+ return options;
3518
+ }
3519
+
3520
+ // ELK, in a worker where the host names ELK's worker script (`elkWorkerUrl`): laying out
3521
+ // the registry takes a second or two, which on the page's own thread would freeze it. A
3522
+ // cross-origin script cannot start a worker itself, so the worker is a small one of the
3523
+ // page's origin that imports it. Without the option, or where a worker cannot start, ELK
3524
+ // runs on the page's thread.
3525
+ _createElk() {
3526
+ const ELK = globalThis.ELK;
3527
+ if (this.elkWorkerUrl && typeof Worker === 'function' && typeof Blob === 'function') {
3528
+ try {
3529
+ // the factory alone, not the URL too: given a URL, elkjs's bundle warns that a worker was
3530
+ // asked for but cannot be made, although it then uses this factory
3531
+ const script = this.elkWorkerUrl;
3532
+ return new ELK({
3533
+ workerFactory: () => new Worker(URL.createObjectURL(
3534
+ new Blob([`importScripts(${JSON.stringify(script)});`], { type: 'application/javascript' })
3535
+ )),
3536
+ });
3537
+ } catch (error) {
3538
+ console.error('CapFabRenderer: ELK worker could not start; laying out on the page', error);
3539
+ }
3540
+ }
3541
+ return new ELK();
3542
+ }
3543
+
3544
+ async _routeWithElk() {
3545
+ const cy = this.cy;
3546
+ const graph = {
3547
+ id: 'registry',
3548
+ layoutOptions: this._routedLayoutOptions(),
3549
+ children: cy.nodes().map((node) => ({ id: node.id(), width: node.outerWidth(), height: node.outerHeight() })),
3550
+ edges: cy.edges().map((edge) => {
3551
+ const label = edge.data('label');
3552
+ return {
3553
+ id: edge.id(),
3554
+ sources: [edge.source().id()],
3555
+ targets: [edge.target().id()],
3556
+ // the name's box as cytoscape draws it: its text and background padding (4px)
3557
+ labels: label ? [{ id: `${edge.id()}:label`, text: label, width: (edge.data('_labelPx') || 0) + 8, height: 8 * 1.3 + 8 }] : [],
3558
+ };
3559
+ }),
3560
+ };
3561
+ let laid;
3562
+ try {
3563
+ this._elk = this._elk || this._createElk();
3564
+ laid = await this._elk.layout(graph);
3565
+ } catch (error) {
3566
+ console.error('CapFabRenderer: ELK routing failed; laying out without routes', error);
3567
+ if (this.cy !== cy) return;
3568
+ cy.layout({ name: 'elk', elk: this._layoutOptions(), stop: () => this._afterLayout({ stretch: true }) }).run();
3569
+ return;
3570
+ }
3571
+ // a newer render replaced this graph while ELK was working
3572
+ if (this.cy !== cy) return;
3573
+
3574
+ const centres = new Map(laid.children.map((c) => [c.id, { x: c.x + c.width / 2, y: c.y + c.height / 2 }]));
3575
+ // marking the routed edges is not a change the names need placing again for
3576
+ this._placingNames = true;
3577
+ cy.batch(() => {
3578
+ cy.nodes().forEach((node) => node.position(centres.get(node.id())));
3579
+ (laid.edges || []).forEach((laidEdge) => {
3580
+ const section = laidEdge.sections && laidEdge.sections[0];
3581
+ if (!section) return;
3582
+ const edge = cy.getElementById(laidEdge.id);
3583
+ // a self-loop's route is cytoscape's own loop: its points would all lie on one node
3584
+ if (edge.source().same(edge.target())) return;
3585
+ const route = routeFromSection(section, centres.get(edge.source().id()), centres.get(edge.target().id()));
3586
+ // a route with no bend is one point on the straight line between the ends
3587
+ const weightsOf = (weights) => (weights.length ? weights.join(' ') : '0.5');
3588
+ const distancesOf = (distances) => (distances.length ? distances.join(' ') : '0');
3589
+ edge.data({
3590
+ routeWeights: weightsOf(route.weights),
3591
+ routeDistances: distancesOf(route.distances),
3592
+ routeWeightsOverview: weightsOf(route.overviewWeights),
3593
+ routeDistancesOverview: distancesOf(route.overviewDistances),
3594
+ routeSource: `${route.source.dx}px ${route.source.dy}px`,
3595
+ routeTarget: `${route.target.dx}px ${route.target.dy}px`,
3596
+ });
3597
+ edge.addClass('routed');
3598
+ });
3599
+ });
3600
+ this._placingNames = false;
3601
+ this._afterLayout({ stretch: false });
3602
+ }
3603
+
3604
+ // What follows a layout, however it was made: the per-edge stretch (cytoscape-elk's
3605
+ // layout only: a routed layout has left room for the names already), the zoom limits for
3606
+ // the final box, a pending focus, the first fit, and the level of detail.
3607
+ _afterLayout({ stretch }) {
3608
+ if (!this.cy) return;
3609
+ if (!this._readyReported) {
3610
+ this._readyReported = true;
3611
+ // the host learns the graph is laid out (`onReady`) before it is fitted: a loading
3612
+ // state it ends there may change the container's size, which the fit then measures
3613
+ if (typeof this.onReady === 'function') this.onReady();
3614
+ }
3615
+ this.cy.resize();
3616
+ this._layoutReady = true;
3617
+ // Per-edge stretch FIRST (mutates node x-coordinates so
3618
+ // each edge has the horizontal room its own label needs),
3619
+ // THEN lock the cytoscape wheel-zoom limits to the
3620
+ // dynamic per-graph values, THEN fit the (possibly
3621
+ // stretched) bounding box to the viewport so the user
3622
+ // always opens at a fully-visible padded fit. Order
3623
+ // matters: `_recomputeZoomLimits` needs the final
3624
+ // post-stretch bounding box; `fitToVisibleViewport` needs
3625
+ // the relaxed minZoom (it sits above the tight-fit zoom)
3626
+ // so its padding actually shows.
3627
+ if (stretch) this._stretchLayersForEdgeLabels();
3628
+ this._recomputeZoomLimits();
3629
+ const hadPendingFocus = !!this._pendingFocusCap;
3630
+ if (hadPendingFocus) {
3631
+ const pending = this._pendingFocusCap;
3632
+ this._pendingFocusCap = null;
3633
+ this.highlightCapability(pending);
3634
+ } else if (this._pendingFocusCaps) {
3635
+ const pending = this._pendingFocusCaps;
3636
+ this._pendingFocusCaps = null;
3637
+ this.highlightCapabilities(pending);
3638
+ }
3639
+ // First paint with no preselected focus: snap (no
3640
+ // animation) to a centered fit of the full, post-stretch
3641
+ // graph. Going through `_centerOnGraphInitial` rather
3642
+ // than `refitCurrentSelection` guarantees three things
3643
+ // on the bootstrap pass that the selection-aware refit
3644
+ // can't:
3645
+ // - never animate, so the user never sees a transient
3646
+ // un-centered state on the way to the final fit;
3647
+ // - always operate on the entire element set, so
3648
+ // per-edge stretching can't push the centered focus
3649
+ // off-viewport;
3650
+ // - reapply on every post-paint resize tick (see
3651
+ // `resizeAndRefit` below) until the user first
3652
+ // interacts, absorbing late WebKit container-size
3653
+ // settling without surprising a user who's already
3654
+ // scrolled or zoomed.
3655
+ //
3656
+ // If a focus cap WAS pending (browse-mode deep link from
3657
+ // capdag-dot-com), defer to the selection-aware refit so
3658
+ // the linked element lands centered instead.
3659
+ if (hadPendingFocus) {
3660
+ this.refitCurrentSelection();
3661
+ // Treat the deep-link landing as the user's chosen
3662
+ // viewport — late resize ticks shouldn't yank them
3663
+ // back to a fit-of-all.
3664
+ this._markInitialFitDone();
3665
+ } else {
3666
+ this._centerOnGraphInitial();
3667
+ }
3668
+ this._applyLevelOfDetail();
3669
+ }
3670
+
3262
3671
  // ===========================================================================
3263
3672
  // Zoom backstop — dynamic per-graph minimum and maximum zoom levels.
3264
3673
  //
@@ -3287,6 +3696,264 @@ class CapFabRenderer {
3287
3696
  // viewport (≈15% slack at 800×600).
3288
3697
  static get ZOOM_OUT_FIT_SLACK() { return 0.15; }
3289
3698
 
3699
+ // Browse mode draws the labelled graph only where its labels can be read: the 9px node
3700
+ // labels at no less than 10px on screen. Below that zoom it draws the overview (see the
3701
+ // `lod-overview` styles): a dot per media type, sized by its connections, and a line per
3702
+ // capability, at constant on-screen sizes.
3703
+ static get LOD_LABEL_ZOOM() { return 10 / 9; }
3704
+
3705
+ // The mode's layout, and in browse mode the shape of the part of the canvas that can be
3706
+ // seen: ELK packs the registry's separate pieces side by side to that aspect ratio, so a
3707
+ // wide, short view is filled across rather than with a tall column in its middle.
3708
+ _layoutOptions() {
3709
+ const options = layoutForMode(this.mode);
3710
+ if (this.mode !== 'browse') return options;
3711
+ const w = this.container.clientWidth;
3712
+ const h = this.container.clientHeight - Math.max(0, this.bottomExcludedRegion() | 0);
3713
+ if (w > 0 && h > 0) options['elk.aspectRatio'] = w / h;
3714
+ return options;
3715
+ }
3716
+
3717
+ // In the overview, the chosen elements' names are written where they have room: in order
3718
+ // of importance (the chosen node, then the most connected), a name is placed under its dot
3719
+ // unless it would overlap one already placed or another chosen dot. A name left out is one
3720
+ // hover or a closer zoom away.
3721
+ _placeOverviewNames() {
3722
+ if (this.mode !== 'browse' || !this.cy) return;
3723
+ const cy = this.cy;
3724
+ const named = cy.elements('.lod-named');
3725
+ if (!this._lodOverview) {
3726
+ this._placingNames = true;
3727
+ named.removeClass('lod-named');
3728
+ this._placingNames = false;
3729
+ return;
3730
+ }
3731
+ const chosen = cy.nodes('.highlighted, .active, .path-highlighted');
3732
+ const font = `500 11px ${graphFontFamily()}`;
3733
+ const lineHeight = 11 * 1.3;
3734
+ const gap = 3;
3735
+ const placed = [];
3736
+ const overlaps = (a, b) => a.x1 < b.x2 && b.x1 < a.x2 && a.y1 < b.y2 && b.y1 < a.y2;
3737
+ // every chosen dot is an obstacle a name must not cover
3738
+ chosen.forEach((node) => {
3739
+ const p = node.renderedPosition();
3740
+ const r = node.renderedWidth() / 2 + 1;
3741
+ placed.push({ x1: p.x - r, x2: p.x + r, y1: p.y - r, y2: p.y + r });
3742
+ });
3743
+ const order = chosen.sort((a, b) => {
3744
+ const rank = (n) => (n.hasClass('active') ? 2 : 0) + (this.selectedElement && this.selectedElement.element && this.selectedElement.element.id && this.selectedElement.element.id() === n.id() ? 2 : 0);
3745
+ return (rank(b) - rank(a)) || (b.degree() - a.degree());
3746
+ });
3747
+ // a name is written only where it can be read whole: inside the visible view
3748
+ const view = { x1: 0, y1: 0, x2: cy.width(), y2: cy.height() - Math.max(0, this.bottomExcludedRegion() | 0) };
3749
+ const inside = (b) => b.x1 >= view.x1 && b.x2 <= view.x2 && b.y1 >= view.y1 && b.y2 <= view.y2;
3750
+ const keep = new Set();
3751
+ const sides = new Map();
3752
+ // the chosen capability's name first: centred on its route's middle, above the line
3753
+ cy.edges('.active').forEach((edge) => {
3754
+ const label = String(edge.data('label') || '');
3755
+ if (!label) return;
3756
+ const mid = edge.renderedMidpoint();
3757
+ const width = measureTextWidth(label, font) + gap * 2;
3758
+ const height = lineHeight + gap * 2;
3759
+ const box = { x1: mid.x - width / 2, x2: mid.x + width / 2, y1: mid.y - 12 - height / 2, y2: mid.y - 12 + height / 2 };
3760
+ if (!inside(box) || placed.some((other) => overlaps(box, other))) return;
3761
+ placed.push(box);
3762
+ keep.add(edge.id());
3763
+ });
3764
+ order.forEach((node) => {
3765
+ const lines = String(node.data('label') || '').split('\n');
3766
+ const width = Math.max(...lines.map((line) => measureTextWidth(line, font))) + gap * 2;
3767
+ const height = lines.length * lineHeight + gap * 2;
3768
+ const p = node.renderedPosition();
3769
+ const r = node.renderedWidth() / 2;
3770
+ // under the dot, then over it; each centred, else ending at the dot (to its left), else
3771
+ // starting at it
3772
+ const options = [];
3773
+ for (const above of [false, true]) {
3774
+ const y1 = above ? p.y - r - gap - height : p.y + r + gap;
3775
+ const y2 = y1 + height;
3776
+ options.push(
3777
+ { above, side: '', box: { x1: p.x - width / 2, x2: p.x + width / 2, y1, y2 } },
3778
+ { above, side: 'left', box: { x1: p.x + r - width, x2: p.x + r, y1, y2 } },
3779
+ { above, side: 'right', box: { x1: p.x - r, x2: p.x - r + width, y1, y2 } },
3780
+ );
3781
+ }
3782
+ const fit = options.find((o) => inside(o.box) && !placed.some((other) => overlaps(o.box, other)));
3783
+ if (!fit) return;
3784
+ placed.push(fit.box);
3785
+ keep.add(node.id());
3786
+ sides.set(node.id(), fit);
3787
+ });
3788
+ // its own class changes must not schedule it again
3789
+ this._placingNames = true;
3790
+ cy.batch(() => {
3791
+ named.forEach((node) => { if (!keep.has(node.id())) node.removeClass('lod-named'); });
3792
+ keep.forEach((id) => {
3793
+ const element = cy.getElementById(id);
3794
+ if (!element.hasClass('lod-named')) element.addClass('lod-named');
3795
+ const fit = sides.get(id) || { side: '', above: false };
3796
+ element.toggleClass('lod-name-left', fit.side === 'left');
3797
+ element.toggleClass('lod-name-right', fit.side === 'right');
3798
+ element.toggleClass('lod-name-above', !!fit.above);
3799
+ });
3800
+ });
3801
+ this._placingNames = false;
3802
+ }
3803
+
3804
+ _scheduleOverviewNames() {
3805
+ if (this._namesQueued) return;
3806
+ this._namesQueued = true;
3807
+ requestAnimationFrame(() => {
3808
+ this._namesQueued = false;
3809
+ this._placeOverviewNames();
3810
+ this._placeEdgeLabels();
3811
+ });
3812
+ }
3813
+
3814
+ // In the labelled graph, a capability's name is written where it has room: in order of
3815
+ // importance (the chosen one, the highlighted, then the rest in the view, nearest its
3816
+ // centre first), a name is drawn unless its rotated box would overlap a name already drawn
3817
+ // or a media type. The geometry is cytoscape's own (the label's centre, angle and size,
3818
+ // in graph units, so the choice holds at any zoom); without it every name is drawn.
3819
+ _placeEdgeLabels() {
3820
+ if (this.mode !== 'browse' || !this.cy) return;
3821
+ const cy = this.cy;
3822
+ const crowded = cy.edges('.label-crowded');
3823
+ const clear = () => {
3824
+ this._placingNames = true;
3825
+ crowded.removeClass('label-crowded');
3826
+ this._placingNames = false;
3827
+ };
3828
+ if (this._lodOverview) { clear(); return; }
3829
+
3830
+ const ext = cy.extent();
3831
+ const centre = { x: (ext.x1 + ext.x2) / 2, y: (ext.y1 + ext.y2) / 2 };
3832
+ const pad = 4;
3833
+ const boxOf = (edge) => {
3834
+ const r = edge._private && edge._private.rscratch;
3835
+ if (!r || !Number.isFinite(r.labelX) || !Number.isFinite(r.labelWidth) || !r.labelWidth) return null;
3836
+ return {
3837
+ x: r.labelX, y: r.labelY, angle: r.labelAngle || 0,
3838
+ hw: r.labelWidth / 2 + pad, hh: (r.labelHeight || 8) / 2 + pad,
3839
+ };
3840
+ };
3841
+ const corners = (b) => {
3842
+ const c = Math.cos(b.angle), s = Math.sin(b.angle);
3843
+ return [[-b.hw, -b.hh], [b.hw, -b.hh], [b.hw, b.hh], [-b.hw, b.hh]]
3844
+ .map(([x, y]) => [b.x + x * c - y * s, b.y + x * s + y * c]);
3845
+ };
3846
+ // two convex boxes overlap unless an edge of one separates them (separating axes)
3847
+ const separated = (pa, pb) => [pa, pb].some((poly) => poly.some((p, i) => {
3848
+ const q = poly[(i + 1) % poly.length];
3849
+ const nx = q[1] - p[1], ny = p[0] - q[0];
3850
+ const project = (pts) => pts.map(([x, y]) => x * nx + y * ny);
3851
+ const a = project(pa), b = project(pb);
3852
+ return Math.max(...a) < Math.min(...b) || Math.max(...b) < Math.min(...a);
3853
+ }));
3854
+
3855
+ const placed = [];
3856
+ cy.nodes().forEach((node) => {
3857
+ if (node.hasClass('faded')) return;
3858
+ const bb = node.boundingBox({ includeLabels: false });
3859
+ placed.push([[bb.x1, bb.y1], [bb.x2, bb.y1], [bb.x2, bb.y2], [bb.x1, bb.y2]]);
3860
+ });
3861
+
3862
+ const inView = (b) => b.x >= ext.x1 && b.x <= ext.x2 && b.y >= ext.y1 && b.y <= ext.y2;
3863
+ const rank = (edge) => (edge.hasClass('active') ? 2 : 0) + (edge.hasClass('highlighted') ? 1 : 0);
3864
+ const candidates = [];
3865
+ let measured = true;
3866
+ cy.edges().forEach((edge) => {
3867
+ if (edge.hasClass('faded') || !edge.data('label')) return;
3868
+ // a loop is unnamed unless chosen (see the browse-loop styles)
3869
+ if (edge.hasClass('browse-loop') && !edge.hasClass('active')) return;
3870
+ const b = boxOf(edge);
3871
+ if (!b) { measured = false; return; }
3872
+ candidates.push({ edge, b, rank: rank(edge), view: inView(b) ? 1 : 0, d: Math.hypot(b.x - centre.x, b.y - centre.y) });
3873
+ });
3874
+ if (!measured) { clear(); return; }
3875
+ candidates.sort((a, b) => (b.rank - a.rank) || (b.view - a.view) || (a.d - b.d));
3876
+
3877
+ const hide = new Set();
3878
+ candidates.forEach(({ edge, b }) => {
3879
+ const poly = corners(b);
3880
+ if (placed.some((other) => !separated(poly, other))) { hide.add(edge.id()); return; }
3881
+ placed.push(poly);
3882
+ });
3883
+
3884
+ this._placingNames = true;
3885
+ cy.batch(() => {
3886
+ crowded.forEach((edge) => { if (!hide.has(edge.id())) edge.removeClass('label-crowded'); });
3887
+ hide.forEach((id) => {
3888
+ const edge = cy.getElementById(id);
3889
+ if (!edge.hasClass('label-crowded')) edge.addClass('label-crowded');
3890
+ });
3891
+ });
3892
+ this._placingNames = false;
3893
+ }
3894
+
3895
+ // The capabilities' names (the edges' labels and titles) in the host's case
3896
+ _formatCapTitles(elements) {
3897
+ return elements.map((element) => {
3898
+ const data = element && element.data;
3899
+ if (!data || !data.source || !data.target) return element;
3900
+ const next = Object.assign({}, data);
3901
+ if (typeof data.label === 'string') next.label = this.formatCapTitle(data.label);
3902
+ if (typeof data.title === 'string') next.title = this.formatCapTitle(data.title);
3903
+ return Object.assign({}, element, { data: next });
3904
+ });
3905
+ }
3906
+
3907
+ // The box a fit measures: the elements with their labels, except in the overview, whose
3908
+ // labels are a constant size on screen (larger in the graph the further out it is) and
3909
+ // would make every fit zoom out further than the one before
3910
+ _fitBox(eles) {
3911
+ return eles.boundingBox({ includeLabels: !this._lodOverview, includeOverlays: false });
3912
+ }
3913
+
3914
+ _applyLevelOfDetail() {
3915
+ if (this.mode !== 'browse' || !this.cy) return;
3916
+ const cy = this.cy;
3917
+ const zoom = cy.zoom();
3918
+ const overview = zoom < CapFabRenderer.LOD_LABEL_ZOOM;
3919
+ if (!overview && !this._lodOverview) return;
3920
+ // Drawn very small (the whole registry on a phone), the dots would run into each other:
3921
+ // they shrink with the zoom below a tenth, to no less than half their size
3922
+ const scale = Math.min(1, Math.max(0.5, zoom / 0.1));
3923
+ cy.batch(() => {
3924
+ if (overview) {
3925
+ cy.nodes().forEach((node) => {
3926
+ // 8px for a leaf, growing with the square root of its connections (UTF-8, the
3927
+ // hub every text capability meets, about 20px)
3928
+ node.data({
3929
+ lodSize: scale * (6 + 2.2 * Math.sqrt(node.degree())) / zoom,
3930
+ lodFont: 11 / zoom,
3931
+ lodGap: 3 / zoom,
3932
+ lodGapNeg: -3 / zoom,
3933
+ });
3934
+ });
3935
+ cy.edges().data({
3936
+ lodWidth: scale * 1.25 / zoom,
3937
+ lodWidthStrong: 2.5 / zoom,
3938
+ lodFontEdge: 11 / zoom,
3939
+ lodGap: 3 / zoom,
3940
+ lodLift: -12 / zoom,
3941
+ lodLoopStep: 3 / zoom,
3942
+ });
3943
+ // a loop stands clear of its dot: cytoscape cannot draw one whose step lies inside the
3944
+ // node (most of the overview's loops went undrawn), so a loop's step is half its dot's
3945
+ // size and a little more, the loops on a hub nested low under the dots above it
3946
+ cy.edges('.browse-loop').forEach((edge) => {
3947
+ edge.data('lodLoopStep', edge.source().data('lodSize') * 0.5 + 3 / zoom);
3948
+ });
3949
+ cy.elements().addClass('lod-overview');
3950
+ } else {
3951
+ cy.elements().removeClass('lod-overview');
3952
+ }
3953
+ });
3954
+ this._lodOverview = overview;
3955
+ }
3956
+
3290
3957
  // Fit padding must scale down in very short viewports. The compact
3291
3958
  // machine-select graph pane is only ~100px tall; a fixed 50px inset
3292
3959
  // leaves no vertical room, so the bootstrap fit degenerates and the
@@ -3325,7 +3992,7 @@ class CapFabRenderer {
3325
3992
  if (containerWidth <= 0 || containerHeight <= 0) return;
3326
3993
  const elements = cy.elements();
3327
3994
  if (elements.length === 0) return;
3328
- const bb = elements.boundingBox({ includeLabels: true, includeOverlays: false });
3995
+ const bb = this._fitBox(elements);
3329
3996
  if (bb.w === 0 && bb.h === 0) return;
3330
3997
 
3331
3998
  const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
@@ -3397,10 +4064,15 @@ class CapFabRenderer {
3397
4064
  // that sits comfortably above this relaxed minimum, so opening
3398
4065
  // the view shows the graph centred with margin rather than
3399
4066
  // bleeding to all four edges.
3400
- const bb = this.cy.elements().boundingBox({ includeLabels: true, includeOverlays: false });
4067
+ const bb = this._fitBox(this.cy.elements());
4068
+ // The graph has to fit what can be seen: the canvas less the region the host covers
4069
+ // at its bottom (`bottomExcludedRegion`), as the fits measure it. Measured against the
4070
+ // whole canvas, the floor sat above the zoom a fit needs, and a graph taller than the
4071
+ // visible part could never be fitted into it.
4072
+ const visibleH = Math.max(1, h - Math.max(0, this.bottomExcludedRegion() | 0));
3401
4073
  let zoomMin;
3402
4074
  if (bb.w > 0 && bb.h > 0) {
3403
- const strictFit = Math.min(w / bb.w, h / bb.h);
4075
+ const strictFit = Math.min(w / bb.w, visibleH / bb.h);
3404
4076
  zoomMin = strictFit * (1 - CapFabRenderer.ZOOM_OUT_FIT_SLACK);
3405
4077
  } else {
3406
4078
  // Empty / degenerate graph — leave the min loose; there's
@@ -3628,6 +4300,11 @@ class CapFabRenderer {
3628
4300
  this.cy.on('zoom pan', function () {
3629
4301
  if (!self._internalPanZoom) self._markInitialFitDone();
3630
4302
  });
4303
+ this.cy.on('zoom', function () { self._applyLevelOfDetail(); });
4304
+ // names follow the view and the choice: every zoom or pan, and every change of what is
4305
+ // highlighted, places them again (once a frame)
4306
+ this.cy.on('viewport', function () { self._scheduleOverviewNames(); });
4307
+ this.cy.on('class', function () { if (!self._placingNames) self._scheduleOverviewNames(); });
3631
4308
 
3632
4309
  this.cy.on('tap', 'node', function (evt) {
3633
4310
  evt.stopPropagation();
@@ -3701,13 +4378,20 @@ class CapFabRenderer {
3701
4378
  const bodyTitle = node.data('bodyTitle');
3702
4379
  if (bodyTitle) return `${bodyTitle}: ${node.data('fullUrn') || node.id()}`;
3703
4380
  }
3704
- return node.data('fullUrn') || node.id();
4381
+ const urn = node.data('fullUrn') || node.id();
4382
+ const title = node.data('mediaTitle');
4383
+ if (this.mode === 'browse' && title && title !== urn) return { title, detail: urn };
4384
+ return urn;
3705
4385
  }
3706
4386
 
3707
4387
  _tooltipTextForEdge(edge) {
3708
4388
  const full = edge.data('fullUrn');
3709
- if (typeof full === 'string' && full.length > 0) return full;
3710
- return edge.data('title') || '';
4389
+ const title = edge.data('title');
4390
+ if (typeof full === 'string' && full.length > 0) {
4391
+ if (this.mode === 'browse' && title && title !== full) return { title, detail: full };
4392
+ return full;
4393
+ }
4394
+ return title || '';
3711
4395
  }
3712
4396
 
3713
4397
  _handleNodeTap(node) {
@@ -3790,6 +4474,37 @@ class CapFabRenderer {
3790
4474
  }
3791
4475
  }
3792
4476
 
4477
+ // A set of capabilities picked out of the registry (the host's choice of tags: coerce, its 22):
4478
+ // their edges and the types they join stand out, the rest is faded. Nothing is selected and
4479
+ // the view stays where it is: the choice is a filter, not a destination. An empty set clears
4480
+ // the emphasis. Capabilities are matched by their canonical URN, as the registry writes it.
4481
+ highlightCapabilities(caps) {
4482
+ if (this.mode !== 'browse') {
4483
+ throw new Error(`CapFabRenderer: highlightCapabilities is only valid in browse mode (current: ${this.mode})`);
4484
+ }
4485
+ if (!Array.isArray(caps)) {
4486
+ throw new Error('CapFabRenderer: highlightCapabilities takes an array of capabilities');
4487
+ }
4488
+ if (!this.cy || !this._layoutReady) {
4489
+ this._pendingFocusCaps = caps;
4490
+ return;
4491
+ }
4492
+ const urns = new Set(caps.map((cap) => this._capUrnString(cap)));
4493
+ const cy = this.cy;
4494
+ cy.batch(() => {
4495
+ cy.elements().removeClass('highlighted active faded');
4496
+ if (urns.size === 0) return;
4497
+ const chosen = cy.edges().filter((edge) => {
4498
+ const edgeCap = this.capabilitiesByEdgeId.get(edge.id());
4499
+ return !!edgeCap && urns.has(edgeCap.urn);
4500
+ });
4501
+ if (chosen.length === 0) return;
4502
+ cy.elements().addClass('faded');
4503
+ chosen.removeClass('faded').addClass('highlighted');
4504
+ chosen.connectedNodes().removeClass('faded').addClass('highlighted');
4505
+ });
4506
+ }
4507
+
3793
4508
  _capUrnString(cap) {
3794
4509
  if (!cap || typeof cap !== 'object') {
3795
4510
  throw new Error('CapFabRenderer: cap must be an object');
@@ -4099,15 +4814,19 @@ class CapFabRenderer {
4099
4814
  this.cy.stop(true);
4100
4815
  if (!eles || eles.length === 0) eles = this.cy.elements();
4101
4816
 
4102
- const bb = eles.boundingBox({ includeLabels: true, includeOverlays: false });
4817
+ const bb = this._fitBox(eles);
4103
4818
  if (bb.w === 0 && bb.h === 0) return;
4104
4819
 
4105
4820
  const containerWidth = this.cy.width();
4106
4821
  const containerHeight = this.cy.height();
4107
4822
  const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
4108
4823
  padding = this._resolvedFitPadding(padding, containerWidth, containerHeight, excluded);
4824
+ // The overview's names are not in the measured box (see `_fitBox`) and sit centred under
4825
+ // their dots: room is kept for half a typical name at either side, whatever the height
4826
+ // allows the padding to be
4827
+ const paddingX = this._lodOverview ? Math.max(padding, Math.min(containerWidth / 4, 90)) : padding;
4109
4828
 
4110
- const visibleWidth = containerWidth - padding * 2;
4829
+ const visibleWidth = containerWidth - paddingX * 2;
4111
4830
  const visibleHeight = containerHeight - excluded - padding * 2;
4112
4831
  if (visibleWidth <= 0 || visibleHeight <= 0) return;
4113
4832
 
@@ -4146,10 +4865,21 @@ class CapFabRenderer {
4146
4865
  // Tooltip helpers.
4147
4866
  // ===========================================================================
4148
4867
 
4868
+ // `text` is a string, or { title, detail }: a name over the URN it stands for (browse mode,
4869
+ // where a crowded name may not be drawn in the graph and the tooltip is where it is read)
4149
4870
  _showTooltip(text, mouseEvent) {
4150
4871
  if (!this.tooltip) return;
4151
4872
  if (!text) return;
4152
- this.tooltip.textContent = text;
4873
+ if (typeof text === 'string') {
4874
+ this.tooltip.textContent = text;
4875
+ } else {
4876
+ const title = document.createElement('div');
4877
+ title.textContent = text.title;
4878
+ title.style.cssText = 'color: var(--text-primary); font-family: var(--font-family, inherit); font-size: var(--graph-tooltip-title-size, 12px); font-weight: 600; word-break: normal; margin-bottom: 2px;';
4879
+ const detail = document.createElement('div');
4880
+ detail.textContent = text.detail;
4881
+ this.tooltip.replaceChildren(title, detail);
4882
+ }
4153
4883
  this.tooltip.style.display = 'block';
4154
4884
  const x = mouseEvent ? mouseEvent.clientX : 0;
4155
4885
  const y = mouseEvent ? mouseEvent.clientY : 0;