capdag 1.426.27 → 1.457.27
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.
- package/README.md +56 -4
- package/cap-fab-renderer.js +1020 -148
- package/capdag.js +4240 -1115
- package/formal/index.d.ts +2 -1
- package/formal/index.js +45 -41
- package/formal/program.wasm +0 -0
- package/machine-parser.js +1547 -234
- package/machine.pegjs +112 -17
- package/package.json +4 -4
package/cap-fab-renderer.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// * cytoscape-elk extension (registers itself on `cytoscape`)
|
|
29
29
|
// * elkjs (via cytoscape-elk)
|
|
30
30
|
// * TaggedUrn (from tagged-urn browser build)
|
|
31
|
-
// * CapUrn, MediaUrn, Cap,
|
|
31
|
+
// * CapUrn, MediaUrn, Cap, CapFab (from capdag.js)
|
|
32
32
|
//
|
|
33
33
|
// The renderer owns its own theme observer (<html data-theme>) so hosts do
|
|
34
34
|
// nothing to drive theme sync. It owns its own tooltip element and its own
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// uses a different name for the same concept, the fix is at the producer,
|
|
42
42
|
// not here.
|
|
43
43
|
|
|
44
|
-
import { CapUrn, CapFab, MediaUrn
|
|
44
|
+
import { Cap, CapUrn, CapFab, MediaUrn } from './capdag.js';
|
|
45
45
|
|
|
46
46
|
// =============================================================================
|
|
47
47
|
// Host dependencies — the page's: cytoscape and cytoscape-elk, loaded as
|
|
@@ -72,10 +72,27 @@ function cardinalityLabel(input_is_sequence, output_is_sequence) {
|
|
|
72
72
|
return `${lhs}\u2192${rhs}`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// The main output of a cap as it appears in the /api/capabilities JSON: the
|
|
76
|
+
// one of its `outputs` whose media URN is the cap URN's `out=`, compared as
|
|
77
|
+
// media URNs. Null for a cap that declares none (its `out=` is media:void).
|
|
78
|
+
function mainOutputOf(cap) {
|
|
79
|
+
const outputs = cap.outputs || [];
|
|
80
|
+
if (!Array.isArray(outputs)) {
|
|
81
|
+
throw new Error('CapFabRenderer: a cap\'s outputs are a list');
|
|
82
|
+
}
|
|
83
|
+
if (outputs.length === 0) return null;
|
|
84
|
+
const outSpec = MediaUrn.fromString(CapUrn.fromString(cap.urn).getOutSpec());
|
|
85
|
+
const main = outputs.find(output =>
|
|
86
|
+
output && typeof output.media_urn === 'string' &&
|
|
87
|
+
MediaUrn.fromString(output.media_urn).isEquivalent(outSpec)
|
|
88
|
+
);
|
|
89
|
+
return main || null;
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
// Compute the cardinality marker for a cap as it appears in the
|
|
76
93
|
// /api/capabilities JSON. The main input arg is the one whose sources
|
|
77
|
-
// include a stdin source
|
|
78
|
-
// `CapOutput.is_sequence` names exactly.
|
|
94
|
+
// include a stdin source; the output is the cap's MAIN output. Matches core
|
|
95
|
+
// Rust `CapArg.is_sequence` and `CapOutput.is_sequence` names exactly.
|
|
79
96
|
function cardinalityFromCap(cap) {
|
|
80
97
|
if (!cap || typeof cap !== 'object') {
|
|
81
98
|
throw new Error('CapFabRenderer: cardinalityFromCap requires a cap object');
|
|
@@ -85,7 +102,8 @@ function cardinalityFromCap(cap) {
|
|
|
85
102
|
arg && arg.sources && arg.sources.some(src => src && src.stdin !== undefined)
|
|
86
103
|
);
|
|
87
104
|
const input_is_sequence = mainArg ? (mainArg.is_sequence === true) : false;
|
|
88
|
-
const
|
|
105
|
+
const mainOutput = mainOutputOf(cap);
|
|
106
|
+
const output_is_sequence = mainOutput !== null && mainOutput.is_sequence === true;
|
|
89
107
|
return cardinalityLabel(input_is_sequence, output_is_sequence);
|
|
90
108
|
}
|
|
91
109
|
|
|
@@ -126,6 +144,84 @@ function getCssVar(name) {
|
|
|
126
144
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
127
145
|
}
|
|
128
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
|
+
|
|
129
225
|
function cssVarNumber(name, fallback) {
|
|
130
226
|
const raw = getCssVar(name);
|
|
131
227
|
if (raw === '') return fallback;
|
|
@@ -301,8 +397,9 @@ function shapeLabelsInElements(elements) {
|
|
|
301
397
|
// style at render time; we shape against the renderer's static node
|
|
302
398
|
// font (defined in `buildStylesheet`). Kept in sync manually — bump
|
|
303
399
|
// both together.
|
|
304
|
-
const
|
|
305
|
-
const
|
|
400
|
+
const family = graphFontFamily();
|
|
401
|
+
const nodeFont = `500 9px ${family}`;
|
|
402
|
+
const edgeFont = `500 8px ${family}`;
|
|
306
403
|
let maxEdgeLabelPx = 0;
|
|
307
404
|
let maxNodeLabelPx = 0;
|
|
308
405
|
const reshaped = new Array(elements.length);
|
|
@@ -424,11 +521,17 @@ function layoutForMode(mode) {
|
|
|
424
521
|
// =============================================================================
|
|
425
522
|
|
|
426
523
|
function buildStylesheet() {
|
|
524
|
+
const fontFamily = graphFontFamily();
|
|
427
525
|
const nodeText = getCssVar('--graph-node-text');
|
|
428
526
|
const nodeBg = getCssVar('--graph-node-bg');
|
|
429
527
|
const nodeBorder = getCssVar('--graph-node-border');
|
|
430
528
|
const nodeBorderHighlighted = getCssVar('--graph-node-border-highlighted');
|
|
431
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;
|
|
432
535
|
const edgeTextBg = getCssVar('--graph-edge-text-bg');
|
|
433
536
|
const edgeTextBgOpacity = cssVarNumber('--graph-edge-text-bg-opacity', 0.9);
|
|
434
537
|
const fadedOpacity = cssVarNumber('--graph-faded-opacity', 0.15);
|
|
@@ -440,8 +543,8 @@ function buildStylesheet() {
|
|
|
440
543
|
|
|
441
544
|
// Machine mode uses a dedicated palette exposed by the notation
|
|
442
545
|
// editor's CSS (`--graph-node-color`, `--graph-loop-color`,
|
|
443
|
-
// `--graph-edge-color`, `--graph-text`, `--graph-muted`).
|
|
444
|
-
//
|
|
546
|
+
// `--graph-edge-color`, `--graph-text`, `--graph-muted`). A step
|
|
547
|
+
// node of the resolved machine is outlined in the edge color.
|
|
445
548
|
const machineNodeColor = getCssVar('--graph-node-color');
|
|
446
549
|
const machineLoopColor = getCssVar('--graph-loop-color');
|
|
447
550
|
const machineEdgeColor = getCssVar('--graph-edge-color');
|
|
@@ -463,7 +566,7 @@ function buildStylesheet() {
|
|
|
463
566
|
// node fit its actual shaped text.
|
|
464
567
|
'text-wrap': 'wrap',
|
|
465
568
|
'line-height': 1.3,
|
|
466
|
-
'font-family':
|
|
569
|
+
'font-family': fontFamily,
|
|
467
570
|
'font-size': '9px',
|
|
468
571
|
'font-weight': '500',
|
|
469
572
|
'color': nodeText,
|
|
@@ -479,6 +582,22 @@ function buildStylesheet() {
|
|
|
479
582
|
'transition-duration': '0.2s',
|
|
480
583
|
},
|
|
481
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
|
+
},
|
|
482
601
|
{
|
|
483
602
|
selector: 'node.highlighted',
|
|
484
603
|
style: { 'border-width': '3px', 'border-color': nodeBorderHighlighted },
|
|
@@ -487,9 +606,11 @@ function buildStylesheet() {
|
|
|
487
606
|
selector: 'node.active',
|
|
488
607
|
style: { 'border-width': '3px', 'border-color': nodeBorderActive, 'z-index': 999 },
|
|
489
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
|
|
490
611
|
{
|
|
491
612
|
selector: 'node.faded',
|
|
492
|
-
style: { 'opacity': fadedOpacity },
|
|
613
|
+
style: { 'opacity': fadedOpacity, 'text-opacity': 0 },
|
|
493
614
|
},
|
|
494
615
|
{
|
|
495
616
|
selector: 'node.body-success',
|
|
@@ -516,7 +637,7 @@ function buildStylesheet() {
|
|
|
516
637
|
selector: 'edge',
|
|
517
638
|
style: {
|
|
518
639
|
'label': 'data(label)',
|
|
519
|
-
'font-family':
|
|
640
|
+
'font-family': fontFamily,
|
|
520
641
|
// Slightly smaller than the node label font (9px) so edge
|
|
521
642
|
// labels read as secondary metadata rather than primary
|
|
522
643
|
// identity. Kept in sync with the `edgeFont` constant in
|
|
@@ -552,10 +673,143 @@ function buildStylesheet() {
|
|
|
552
673
|
'control-point-weights': 'data(controlPointWeights)',
|
|
553
674
|
},
|
|
554
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
|
+
},
|
|
555
748
|
{
|
|
556
749
|
selector: 'edge.highlighted',
|
|
557
750
|
style: { 'width': 2.5, 'z-index': 999 },
|
|
558
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
|
+
},
|
|
559
813
|
{
|
|
560
814
|
selector: 'edge.active',
|
|
561
815
|
style: { 'width': 3, 'z-index': 1000 },
|
|
@@ -594,7 +848,7 @@ function buildStylesheet() {
|
|
|
594
848
|
},
|
|
595
849
|
{
|
|
596
850
|
selector: 'edge.faded',
|
|
597
|
-
style: { 'opacity': fadedEdgeOpacity },
|
|
851
|
+
style: { 'opacity': fadedEdgeOpacity, 'text-opacity': 0, 'text-background-opacity': 0 },
|
|
598
852
|
},
|
|
599
853
|
{
|
|
600
854
|
selector: 'edge.body-success',
|
|
@@ -622,15 +876,25 @@ function buildStylesheet() {
|
|
|
622
876
|
selector: 'edge.path-highlighted',
|
|
623
877
|
style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
|
|
624
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
|
+
},
|
|
625
888
|
|
|
626
889
|
// -------- Machine / editor-graph mode ------------------------------
|
|
627
890
|
// Shared between the resolved-machine view (`mode: 'machine'`,
|
|
628
891
|
// canonical `Machine::to_render_payload_json`) and the notation
|
|
629
|
-
// editor's live graph (`mode: 'editor-graph'`, ast-driven).
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
892
|
+
// editor's live graph (`mode: 'editor-graph'`, ast-driven). Data
|
|
893
|
+
// slots are `machine-node`s. The editor graph collapses each cap
|
|
894
|
+
// application into labeled edges between its data slots; the
|
|
895
|
+
// resolved machine draws each step as a `machine-step` node, and a
|
|
896
|
+
// constant it is given as a `machine-constant` node, with an edge
|
|
897
|
+
// per binding and per output taken.
|
|
634
898
|
//
|
|
635
899
|
// Reads the editor's dedicated palette so nodes, edges and
|
|
636
900
|
// loop markers match the text theme.
|
|
@@ -660,6 +924,34 @@ function buildStylesheet() {
|
|
|
660
924
|
'line-style': 'dashed',
|
|
661
925
|
},
|
|
662
926
|
},
|
|
927
|
+
{
|
|
928
|
+
selector: 'node.machine-step',
|
|
929
|
+
style: {
|
|
930
|
+
'shape': 'rectangle',
|
|
931
|
+
'background-color': nodeBg,
|
|
932
|
+
'border-color': machineEdgeColor || 'data(color)',
|
|
933
|
+
'color': machineText || nodeText,
|
|
934
|
+
'border-opacity': 1,
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
selector: 'node.machine-step-loop',
|
|
939
|
+
style: {
|
|
940
|
+
'border-color': machineLoopColor || nodeBorderActive,
|
|
941
|
+
'border-style': 'dashed',
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
selector: 'node.machine-constant',
|
|
946
|
+
style: {
|
|
947
|
+
'shape': 'tag',
|
|
948
|
+
'padding': '6px',
|
|
949
|
+
'border-width': '1px',
|
|
950
|
+
'background-color': nodeBg,
|
|
951
|
+
'border-color': machineMuted || nodeBorder,
|
|
952
|
+
'color': machineMuted || nodeText,
|
|
953
|
+
},
|
|
954
|
+
},
|
|
663
955
|
];
|
|
664
956
|
}
|
|
665
957
|
|
|
@@ -670,21 +962,23 @@ function buildStylesheet() {
|
|
|
670
962
|
function createTooltipElement() {
|
|
671
963
|
const el = document.createElement('div');
|
|
672
964
|
el.className = 'graph-tooltip';
|
|
965
|
+
// Its look is the host's where the host gives one (--graph-tooltip-*), these otherwise
|
|
673
966
|
el.style.cssText = [
|
|
674
967
|
'position: fixed',
|
|
675
968
|
'display: none',
|
|
676
|
-
'background: var(--bg-elevated)',
|
|
677
|
-
'border: 1px solid var(--border-primary)',
|
|
678
|
-
'border-radius: 6px',
|
|
679
|
-
'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)',
|
|
680
973
|
'font-family: var(--font-mono, ui-monospace, monospace)',
|
|
681
|
-
'font-size: 11px',
|
|
974
|
+
'font-size: var(--graph-tooltip-font-size, 11px)',
|
|
975
|
+
'line-height: var(--graph-tooltip-line-height, normal)',
|
|
682
976
|
'color: var(--text-secondary)',
|
|
683
977
|
'max-width: 400px',
|
|
684
978
|
'word-break: break-all',
|
|
685
979
|
'z-index: 10000',
|
|
686
980
|
'pointer-events: none',
|
|
687
|
-
'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))',
|
|
688
982
|
].join('; ') + ';';
|
|
689
983
|
document.body.appendChild(el);
|
|
690
984
|
return el;
|
|
@@ -974,73 +1268,115 @@ function validateEditorGraphPayload(data) {
|
|
|
974
1268
|
});
|
|
975
1269
|
}
|
|
976
1270
|
|
|
977
|
-
// `validateResolvedMachinePayload` checks the canonical machine
|
|
978
|
-
//
|
|
1271
|
+
// `validateResolvedMachinePayload` checks the canonical machine render
|
|
1272
|
+
// payload every capdag library writes (`Machine.to_render_payload_json`).
|
|
979
1273
|
// Shape:
|
|
980
1274
|
// {
|
|
981
1275
|
// "strands": [
|
|
982
1276
|
// {
|
|
983
|
-
// "nodes": [{"id": "n0", "urn": "media:ext=pdf"}, ...],
|
|
1277
|
+
// "nodes": [{"id": "n0", "urn": "media:document;ext=pdf", "title": "PDF"}, ...],
|
|
984
1278
|
// "edges": [{
|
|
985
1279
|
// "alias": "edge_0",
|
|
986
1280
|
// "cap_urn": "...",
|
|
1281
|
+
// "title": "Extract Text",
|
|
987
1282
|
// "is_loop": false,
|
|
988
|
-
// "
|
|
989
|
-
// "
|
|
1283
|
+
// "retry": 0,
|
|
1284
|
+
// "on_error": "fail",
|
|
1285
|
+
// "included_as": [],
|
|
1286
|
+
// "assignment": [
|
|
1287
|
+
// {"cap_arg_media_urn": "media:document;ext=pdf", "arg_name": "input", "source_node": "n0"},
|
|
1288
|
+
// {"cap_arg_media_urn": "media:integer;window", "arg_name": "window", "constant": 20}
|
|
1289
|
+
// ],
|
|
1290
|
+
// "outputs": [{"output_media_urn": "media:...", "output_name": "output", "node": "n1"}]
|
|
990
1291
|
// }, ...],
|
|
991
1292
|
// "input_anchor_nodes": ["n0"],
|
|
992
|
-
// "output_anchor_nodes": ["
|
|
1293
|
+
// "output_anchor_nodes": ["n1"]
|
|
993
1294
|
// },
|
|
994
1295
|
// ...
|
|
995
1296
|
// ]
|
|
996
1297
|
// }
|
|
1298
|
+
//
|
|
1299
|
+
// A binding is fed by a node of its strand or by a constant, never both and
|
|
1300
|
+
// never neither. A step with no binding is a source, one with no output a
|
|
1301
|
+
// sink. Every node a binding, an output or an anchor names is a node of the
|
|
1302
|
+
// strand.
|
|
997
1303
|
function validateResolvedMachinePayload(data) {
|
|
998
1304
|
if (!data || typeof data !== 'object') {
|
|
999
1305
|
throw new Error('CapFabRenderer machine mode: data must be an object');
|
|
1000
1306
|
}
|
|
1001
1307
|
assertArray(data.strands, 'machine mode data.strands');
|
|
1002
1308
|
data.strands.forEach((strand, sIdx) => {
|
|
1309
|
+
const at = `machine mode data.strands[${sIdx}]`;
|
|
1003
1310
|
if (!strand || typeof strand !== 'object') {
|
|
1004
|
-
throw new Error(`CapFabRenderer
|
|
1311
|
+
throw new Error(`CapFabRenderer ${at} is not an object`);
|
|
1005
1312
|
}
|
|
1006
|
-
assertArray(strand.nodes,
|
|
1313
|
+
assertArray(strand.nodes, `${at}.nodes`);
|
|
1314
|
+
const nodeIds = new Set();
|
|
1007
1315
|
strand.nodes.forEach((n, nIdx) => {
|
|
1008
1316
|
if (!n || typeof n !== 'object') {
|
|
1009
|
-
throw new Error(`CapFabRenderer
|
|
1317
|
+
throw new Error(`CapFabRenderer ${at}.nodes[${nIdx}] is not an object`);
|
|
1010
1318
|
}
|
|
1011
|
-
assertString(n.id,
|
|
1012
|
-
assertString(n.urn,
|
|
1013
|
-
assertString(n.title,
|
|
1319
|
+
assertString(n.id, `${at}.nodes[${nIdx}].id`);
|
|
1320
|
+
assertString(n.urn, `${at}.nodes[${nIdx}].urn`);
|
|
1321
|
+
assertString(n.title, `${at}.nodes[${nIdx}].title`);
|
|
1322
|
+
nodeIds.add(n.id);
|
|
1014
1323
|
});
|
|
1015
|
-
|
|
1324
|
+
const assertNode = (id, path) => {
|
|
1325
|
+
assertString(id, path);
|
|
1326
|
+
if (!nodeIds.has(id)) {
|
|
1327
|
+
throw new Error(`CapFabRenderer ${path} names "${id}", which is no node of the strand`);
|
|
1328
|
+
}
|
|
1329
|
+
};
|
|
1330
|
+
assertArray(strand.edges, `${at}.edges`);
|
|
1016
1331
|
strand.edges.forEach((e, eIdx) => {
|
|
1332
|
+
const edgeAt = `${at}.edges[${eIdx}]`;
|
|
1017
1333
|
if (!e || typeof e !== 'object') {
|
|
1018
|
-
throw new Error(`CapFabRenderer
|
|
1334
|
+
throw new Error(`CapFabRenderer ${edgeAt} is not an object`);
|
|
1019
1335
|
}
|
|
1020
|
-
assertString(e.alias,
|
|
1021
|
-
assertString(e.cap_urn,
|
|
1022
|
-
assertString(e.title,
|
|
1336
|
+
assertString(e.alias, `${edgeAt}.alias`);
|
|
1337
|
+
assertString(e.cap_urn, `${edgeAt}.cap_urn`);
|
|
1338
|
+
assertString(e.title, `${edgeAt}.title`);
|
|
1023
1339
|
if (typeof e.is_loop !== 'boolean') {
|
|
1024
|
-
throw new Error(`CapFabRenderer
|
|
1340
|
+
throw new Error(`CapFabRenderer ${edgeAt}.is_loop must be boolean`);
|
|
1341
|
+
}
|
|
1342
|
+
if (!Number.isInteger(e.retry) || e.retry < 0) {
|
|
1343
|
+
throw new Error(`CapFabRenderer ${edgeAt}.retry must be a whole number of further attempts`);
|
|
1025
1344
|
}
|
|
1026
|
-
|
|
1345
|
+
if (e.on_error !== 'fail' && e.on_error !== 'absent') {
|
|
1346
|
+
throw new Error(`CapFabRenderer ${edgeAt}.on_error must be "fail" or "absent"`);
|
|
1347
|
+
}
|
|
1348
|
+
assertArray(e.included_as, `${edgeAt}.included_as`);
|
|
1349
|
+
e.included_as.forEach((step, iIdx) => assertString(step, `${edgeAt}.included_as[${iIdx}]`));
|
|
1350
|
+
assertArray(e.assignment, `${edgeAt}.assignment`);
|
|
1027
1351
|
e.assignment.forEach((b, bIdx) => {
|
|
1352
|
+
const bindingAt = `${edgeAt}.assignment[${bIdx}]`;
|
|
1028
1353
|
if (!b || typeof b !== 'object') {
|
|
1029
|
-
throw new Error(`CapFabRenderer
|
|
1354
|
+
throw new Error(`CapFabRenderer ${bindingAt} is not an object`);
|
|
1355
|
+
}
|
|
1356
|
+
assertString(b.cap_arg_media_urn, `${bindingAt}.cap_arg_media_urn`);
|
|
1357
|
+
assertString(b.arg_name, `${bindingAt}.arg_name`);
|
|
1358
|
+
const byNode = Object.prototype.hasOwnProperty.call(b, 'source_node');
|
|
1359
|
+
const byConstant = Object.prototype.hasOwnProperty.call(b, 'constant');
|
|
1360
|
+
if (byNode === byConstant) {
|
|
1361
|
+
throw new Error(`CapFabRenderer ${bindingAt} must have exactly one of source_node and constant`);
|
|
1030
1362
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1363
|
+
if (byNode) assertNode(b.source_node, `${bindingAt}.source_node`);
|
|
1364
|
+
});
|
|
1365
|
+
assertArray(e.outputs, `${edgeAt}.outputs`);
|
|
1366
|
+
e.outputs.forEach((o, oIdx) => {
|
|
1367
|
+
const outputAt = `${edgeAt}.outputs[${oIdx}]`;
|
|
1368
|
+
if (!o || typeof o !== 'object') {
|
|
1369
|
+
throw new Error(`CapFabRenderer ${outputAt} is not an object`);
|
|
1370
|
+
}
|
|
1371
|
+
assertString(o.output_media_urn, `${outputAt}.output_media_urn`);
|
|
1372
|
+
assertString(o.output_name, `${outputAt}.output_name`);
|
|
1373
|
+
assertNode(o.node, `${outputAt}.node`);
|
|
1033
1374
|
});
|
|
1034
|
-
assertString(e.target_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].target_node`);
|
|
1035
|
-
});
|
|
1036
|
-
assertArray(strand.input_anchor_nodes, `machine mode data.strands[${sIdx}].input_anchor_nodes`);
|
|
1037
|
-
strand.input_anchor_nodes.forEach((id, iIdx) => {
|
|
1038
|
-
assertString(id, `machine mode data.strands[${sIdx}].input_anchor_nodes[${iIdx}]`);
|
|
1039
|
-
});
|
|
1040
|
-
assertArray(strand.output_anchor_nodes, `machine mode data.strands[${sIdx}].output_anchor_nodes`);
|
|
1041
|
-
strand.output_anchor_nodes.forEach((id, oIdx) => {
|
|
1042
|
-
assertString(id, `machine mode data.strands[${sIdx}].output_anchor_nodes[${oIdx}]`);
|
|
1043
1375
|
});
|
|
1376
|
+
assertArray(strand.input_anchor_nodes, `${at}.input_anchor_nodes`);
|
|
1377
|
+
strand.input_anchor_nodes.forEach((id, iIdx) => assertNode(id, `${at}.input_anchor_nodes[${iIdx}]`));
|
|
1378
|
+
assertArray(strand.output_anchor_nodes, `${at}.output_anchor_nodes`);
|
|
1379
|
+
strand.output_anchor_nodes.forEach((id, oIdx) => assertNode(id, `${at}.output_anchor_nodes[${oIdx}]`));
|
|
1044
1380
|
});
|
|
1045
1381
|
}
|
|
1046
1382
|
|
|
@@ -1173,10 +1509,11 @@ function buildBrowseGraphData(capabilities) {
|
|
|
1173
1509
|
const edgeId = `edge-${edges.length}`;
|
|
1174
1510
|
const title = capData.title || '';
|
|
1175
1511
|
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1512
|
+
// The cap as the fabric defines it — its args and outputs with their
|
|
1513
|
+
// names — so a path through the graph is a machine its caps resolve.
|
|
1514
|
+
// Reading it validates the URN and the record: malformed registry data
|
|
1515
|
+
// fails hard.
|
|
1516
|
+
const cap = Cap.fromJSON(capData);
|
|
1180
1517
|
const capFabEdgeIndex = capFab.edges.length;
|
|
1181
1518
|
capFab.addCap(cap, 'registry');
|
|
1182
1519
|
|
|
@@ -2239,7 +2576,7 @@ function buildRunGraphData(data) {
|
|
|
2239
2576
|
// labels the anchor→entry edge on the first body.
|
|
2240
2577
|
//
|
|
2241
2578
|
// Without such a preceding sequence cap, the source itself is
|
|
2242
|
-
// already a list (e.g. `media:ext=pdf;list` source_media_urn) and the
|
|
2579
|
+
// already a list (e.g. `media:document;ext=pdf;list` source_media_urn) and the
|
|
2243
2580
|
// ForEach iterates it directly.
|
|
2244
2581
|
let seqProducerStepIdx = -1;
|
|
2245
2582
|
let seqProducerStep = null;
|
|
@@ -2707,17 +3044,21 @@ function editorGraphSignature(data) {
|
|
|
2707
3044
|
// so we can pass them straight through to cytoscape without name
|
|
2708
3045
|
// collisions.
|
|
2709
3046
|
//
|
|
2710
|
-
// Each
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
3047
|
+
// Each step is a node of its own, between the data it is given and the
|
|
3048
|
+
// data it makes: an edge into it per binding, labelled with the arg's name,
|
|
3049
|
+
// and an edge out of it per output it is taken from, labelled with the
|
|
3050
|
+
// output's name. A step may be given nothing (a source) or have nothing taken
|
|
3051
|
+
// (a sink), and a binding may be a constant, which is drawn as a small node of
|
|
3052
|
+
// its own: none of these has a single input-to-output edge to be drawn on. A
|
|
3053
|
+
// step that runs once per item draws its node and its edges in the loop style.
|
|
2715
3054
|
function buildResolvedMachineGraphData(data) {
|
|
2716
3055
|
validateResolvedMachinePayload(data);
|
|
2717
3056
|
|
|
2718
3057
|
const nodes = [];
|
|
2719
3058
|
const edges = [];
|
|
2720
3059
|
const seenNodeIds = new Set();
|
|
3060
|
+
let stepCounter = 0;
|
|
3061
|
+
let constantCounter = 0;
|
|
2721
3062
|
let edgeCounter = 0;
|
|
2722
3063
|
|
|
2723
3064
|
data.strands.forEach((strand, strandIdx) => {
|
|
@@ -2725,10 +3066,8 @@ function buildResolvedMachineGraphData(data) {
|
|
|
2725
3066
|
const outputAnchorSet = new Set(strand.output_anchor_nodes);
|
|
2726
3067
|
|
|
2727
3068
|
for (const node of strand.nodes) {
|
|
2728
|
-
//
|
|
2729
|
-
//
|
|
2730
|
-
// duplicate appears it indicates a malformed payload, so
|
|
2731
|
-
// fail hard rather than silently dropping.
|
|
3069
|
+
// Node ids are unique across the whole machine: every library names
|
|
3070
|
+
// them from one counter. A duplicate is a malformed payload.
|
|
2732
3071
|
if (seenNodeIds.has(node.id)) {
|
|
2733
3072
|
throw new Error(
|
|
2734
3073
|
`CapFabRenderer machine mode: duplicate node id "${node.id}" in strand ${strandIdx}`
|
|
@@ -2753,36 +3092,86 @@ function buildResolvedMachineGraphData(data) {
|
|
|
2753
3092
|
}
|
|
2754
3093
|
|
|
2755
3094
|
for (const edge of strand.edges) {
|
|
2756
|
-
const
|
|
2757
|
-
|
|
2758
|
-
const
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
: `${edge.alias} (${cardinality})`;
|
|
2762
|
-
|
|
3095
|
+
const stepId = `machine-step-${stepCounter}`;
|
|
3096
|
+
stepCounter++;
|
|
3097
|
+
const policy = [];
|
|
3098
|
+
if (edge.retry > 0) policy.push(`retry=${edge.retry}`);
|
|
3099
|
+
if (edge.on_error === 'absent') policy.push('on_error=absent');
|
|
2763
3100
|
const color = edge.is_loop
|
|
2764
3101
|
? 'var(--graph-loop-color)'
|
|
2765
|
-
: edgeHueColor(
|
|
2766
|
-
const
|
|
2767
|
-
|
|
2768
|
-
|
|
3102
|
+
: edgeHueColor(stepCounter - 1);
|
|
3103
|
+
const edgeClasses = edge.is_loop ? 'machine-edge machine-loop' : 'machine-edge';
|
|
3104
|
+
|
|
3105
|
+
nodes.push({
|
|
3106
|
+
group: 'nodes',
|
|
3107
|
+
data: {
|
|
3108
|
+
id: stepId,
|
|
3109
|
+
label: policy.length > 0 ? `${edge.title}\n{${policy.join(', ')}}` : edge.title,
|
|
3110
|
+
fullUrn: edge.cap_urn,
|
|
3111
|
+
alias: edge.alias,
|
|
3112
|
+
strandIndex: strandIdx,
|
|
3113
|
+
isLoop: edge.is_loop,
|
|
3114
|
+
retry: edge.retry,
|
|
3115
|
+
onError: edge.on_error,
|
|
3116
|
+
includedAs: edge.included_as.slice(),
|
|
3117
|
+
color,
|
|
3118
|
+
},
|
|
3119
|
+
classes: edge.is_loop ? 'machine-step machine-step-loop' : 'machine-step',
|
|
3120
|
+
});
|
|
2769
3121
|
|
|
2770
3122
|
for (const binding of edge.assignment) {
|
|
3123
|
+
let source = binding.source_node;
|
|
3124
|
+
if (source === undefined) {
|
|
3125
|
+
source = `machine-constant-${constantCounter}`;
|
|
3126
|
+
constantCounter++;
|
|
3127
|
+
nodes.push({
|
|
3128
|
+
group: 'nodes',
|
|
3129
|
+
data: {
|
|
3130
|
+
id: source,
|
|
3131
|
+
label: JSON.stringify(binding.constant),
|
|
3132
|
+
fullUrn: binding.cap_arg_media_urn,
|
|
3133
|
+
strandIndex: strandIdx,
|
|
3134
|
+
},
|
|
3135
|
+
classes: 'machine-constant',
|
|
3136
|
+
});
|
|
3137
|
+
}
|
|
2771
3138
|
edges.push({
|
|
2772
3139
|
group: 'edges',
|
|
2773
3140
|
data: {
|
|
2774
3141
|
id: `machine-edge-${edgeCounter}`,
|
|
2775
|
-
source
|
|
2776
|
-
target:
|
|
2777
|
-
label,
|
|
2778
|
-
title: `${
|
|
2779
|
-
fullUrn:
|
|
3142
|
+
source,
|
|
3143
|
+
target: stepId,
|
|
3144
|
+
label: binding.arg_name,
|
|
3145
|
+
title: `${edge.title}: ${binding.arg_name} (${binding.cap_arg_media_urn})`,
|
|
3146
|
+
fullUrn: binding.cap_arg_media_urn,
|
|
2780
3147
|
color,
|
|
2781
3148
|
strandIndex: strandIdx,
|
|
3149
|
+
argName: binding.arg_name,
|
|
2782
3150
|
capArgMediaUrn: binding.cap_arg_media_urn,
|
|
2783
3151
|
isLoop: edge.is_loop,
|
|
2784
3152
|
},
|
|
2785
|
-
classes:
|
|
3153
|
+
classes: edgeClasses,
|
|
3154
|
+
});
|
|
3155
|
+
edgeCounter++;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
for (const output of edge.outputs) {
|
|
3159
|
+
edges.push({
|
|
3160
|
+
group: 'edges',
|
|
3161
|
+
data: {
|
|
3162
|
+
id: `machine-edge-${edgeCounter}`,
|
|
3163
|
+
source: stepId,
|
|
3164
|
+
target: output.node,
|
|
3165
|
+
label: output.output_name,
|
|
3166
|
+
title: `${edge.title}: ${output.output_name} (${output.output_media_urn})`,
|
|
3167
|
+
fullUrn: output.output_media_urn,
|
|
3168
|
+
color,
|
|
3169
|
+
strandIndex: strandIdx,
|
|
3170
|
+
outputName: output.output_name,
|
|
3171
|
+
outputMediaUrn: output.output_media_urn,
|
|
3172
|
+
isLoop: edge.is_loop,
|
|
3173
|
+
},
|
|
3174
|
+
classes: edgeClasses,
|
|
2786
3175
|
});
|
|
2787
3176
|
edgeCounter++;
|
|
2788
3177
|
}
|
|
@@ -2852,6 +3241,13 @@ class CapFabRenderer {
|
|
|
2852
3241
|
this.bottomExcludedRegion = typeof options.bottomExcludedRegion === 'function'
|
|
2853
3242
|
? options.bottomExcludedRegion
|
|
2854
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;
|
|
2855
3251
|
|
|
2856
3252
|
// State — shared fields.
|
|
2857
3253
|
this.cy = null;
|
|
@@ -2869,6 +3265,7 @@ class CapFabRenderer {
|
|
|
2869
3265
|
this.capabilitiesByEdgeId = new Map();
|
|
2870
3266
|
this._mediaTitles = new Map();
|
|
2871
3267
|
this._pendingFocusCap = null;
|
|
3268
|
+
this._pendingFocusCaps = null;
|
|
2872
3269
|
this.pathMode = null;
|
|
2873
3270
|
|
|
2874
3271
|
// Strand/run state.
|
|
@@ -2993,7 +3390,7 @@ class CapFabRenderer {
|
|
|
2993
3390
|
// labels. Both the per-node label width and the per-graph edge
|
|
2994
3391
|
// label width are recorded on the renderer so the zoom-backstop
|
|
2995
3392
|
// logic can reason about them after layout-stop.
|
|
2996
|
-
const labelMetrics = shapeLabelsInElements(rawElements);
|
|
3393
|
+
const labelMetrics = shapeLabelsInElements(this.mode === 'browse' ? this._formatCapTitles(rawElements) : rawElements);
|
|
2997
3394
|
const elements = labelMetrics.elements;
|
|
2998
3395
|
this._labelMaxNodeWidthPx = labelMetrics.maxNodeLabelPx;
|
|
2999
3396
|
this._labelMaxEdgeWidthPx = labelMetrics.maxEdgeLabelPx;
|
|
@@ -3011,62 +3408,19 @@ class CapFabRenderer {
|
|
|
3011
3408
|
this.cy = null;
|
|
3012
3409
|
}
|
|
3013
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';
|
|
3014
3416
|
this.cy = this._cytoscape({
|
|
3015
3417
|
container: this.container,
|
|
3016
3418
|
elements,
|
|
3017
|
-
layout: Object.assign(
|
|
3018
|
-
{ name: 'elk', elk:
|
|
3419
|
+
layout: routed ? { name: 'null' } : Object.assign(
|
|
3420
|
+
{ name: 'elk', elk: this._layoutOptions() },
|
|
3019
3421
|
{
|
|
3020
3422
|
stop: function () {
|
|
3021
|
-
self.
|
|
3022
|
-
self._layoutReady = true;
|
|
3023
|
-
// Per-edge stretch FIRST (mutates node x-coordinates so
|
|
3024
|
-
// each edge has the horizontal room its own label needs),
|
|
3025
|
-
// THEN lock the cytoscape wheel-zoom limits to the
|
|
3026
|
-
// dynamic per-graph values, THEN fit the (possibly
|
|
3027
|
-
// stretched) bounding box to the viewport so the user
|
|
3028
|
-
// always opens at a fully-visible padded fit. Order
|
|
3029
|
-
// matters: `_recomputeZoomLimits` needs the final
|
|
3030
|
-
// post-stretch bounding box; `fitToVisibleViewport` needs
|
|
3031
|
-
// the relaxed minZoom (it sits above the tight-fit zoom)
|
|
3032
|
-
// so its padding actually shows.
|
|
3033
|
-
self._stretchLayersForEdgeLabels();
|
|
3034
|
-
self._recomputeZoomLimits();
|
|
3035
|
-
const hadPendingFocus = !!self._pendingFocusCap;
|
|
3036
|
-
if (hadPendingFocus) {
|
|
3037
|
-
const pending = self._pendingFocusCap;
|
|
3038
|
-
self._pendingFocusCap = null;
|
|
3039
|
-
self.highlightCapability(pending);
|
|
3040
|
-
}
|
|
3041
|
-
// First paint with no preselected focus: snap (no
|
|
3042
|
-
// animation) to a centered fit of the full, post-stretch
|
|
3043
|
-
// graph. Going through `_centerOnGraphInitial` rather
|
|
3044
|
-
// than `refitCurrentSelection` guarantees three things
|
|
3045
|
-
// on the bootstrap pass that the selection-aware refit
|
|
3046
|
-
// can't:
|
|
3047
|
-
// - never animate, so the user never sees a transient
|
|
3048
|
-
// un-centered state on the way to the final fit;
|
|
3049
|
-
// - always operate on the entire element set, so
|
|
3050
|
-
// per-edge stretching can't push the centered focus
|
|
3051
|
-
// off-viewport;
|
|
3052
|
-
// - reapply on every post-paint resize tick (see
|
|
3053
|
-
// `resizeAndRefit` below) until the user first
|
|
3054
|
-
// interacts, absorbing late WebKit container-size
|
|
3055
|
-
// settling without surprising a user who's already
|
|
3056
|
-
// scrolled or zoomed.
|
|
3057
|
-
//
|
|
3058
|
-
// If a focus cap WAS pending (browse-mode deep link from
|
|
3059
|
-
// capdag-dot-com), defer to the selection-aware refit so
|
|
3060
|
-
// the linked element lands centered instead.
|
|
3061
|
-
if (hadPendingFocus) {
|
|
3062
|
-
self.refitCurrentSelection();
|
|
3063
|
-
// Treat the deep-link landing as the user's chosen
|
|
3064
|
-
// viewport — late resize ticks shouldn't yank them
|
|
3065
|
-
// back to a fit-of-all.
|
|
3066
|
-
self._markInitialFitDone();
|
|
3067
|
-
} else {
|
|
3068
|
-
self._centerOnGraphInitial();
|
|
3069
|
-
}
|
|
3423
|
+
self._afterLayout({ stretch: true });
|
|
3070
3424
|
},
|
|
3071
3425
|
}
|
|
3072
3426
|
),
|
|
@@ -3085,6 +3439,9 @@ class CapFabRenderer {
|
|
|
3085
3439
|
const resizeAndRefit = () => {
|
|
3086
3440
|
if (!this.cy) return;
|
|
3087
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;
|
|
3088
3445
|
// Container size may have changed — recompute the zoom limits
|
|
3089
3446
|
// so "fit the entire graph" still corresponds to actual pixel
|
|
3090
3447
|
// capacity, not the size we had at first paint.
|
|
@@ -3107,16 +3464,210 @@ class CapFabRenderer {
|
|
|
3107
3464
|
}
|
|
3108
3465
|
};
|
|
3109
3466
|
this.cy.on('ready', resizeAndRefit);
|
|
3110
|
-
this.cy.on('resize', () =>
|
|
3467
|
+
this.cy.on('resize', () => {
|
|
3468
|
+
if (this.mode === 'browse' && !this._layoutReady) return;
|
|
3469
|
+
this._recomputeZoomLimits();
|
|
3470
|
+
});
|
|
3111
3471
|
requestAnimationFrame(resizeAndRefit);
|
|
3112
3472
|
setTimeout(resizeAndRefit, 100);
|
|
3113
3473
|
setTimeout(resizeAndRefit, 300);
|
|
3114
3474
|
|
|
3115
3475
|
this._setupEventHandlers();
|
|
3116
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
|
+
}
|
|
3117
3492
|
return this;
|
|
3118
3493
|
}
|
|
3119
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
|
+
|
|
3120
3671
|
// ===========================================================================
|
|
3121
3672
|
// Zoom backstop — dynamic per-graph minimum and maximum zoom levels.
|
|
3122
3673
|
//
|
|
@@ -3145,6 +3696,264 @@ class CapFabRenderer {
|
|
|
3145
3696
|
// viewport (≈15% slack at 800×600).
|
|
3146
3697
|
static get ZOOM_OUT_FIT_SLACK() { return 0.15; }
|
|
3147
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
|
+
|
|
3148
3957
|
// Fit padding must scale down in very short viewports. The compact
|
|
3149
3958
|
// machine-select graph pane is only ~100px tall; a fixed 50px inset
|
|
3150
3959
|
// leaves no vertical room, so the bootstrap fit degenerates and the
|
|
@@ -3183,7 +3992,7 @@ class CapFabRenderer {
|
|
|
3183
3992
|
if (containerWidth <= 0 || containerHeight <= 0) return;
|
|
3184
3993
|
const elements = cy.elements();
|
|
3185
3994
|
if (elements.length === 0) return;
|
|
3186
|
-
const bb =
|
|
3995
|
+
const bb = this._fitBox(elements);
|
|
3187
3996
|
if (bb.w === 0 && bb.h === 0) return;
|
|
3188
3997
|
|
|
3189
3998
|
const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
|
|
@@ -3255,10 +4064,15 @@ class CapFabRenderer {
|
|
|
3255
4064
|
// that sits comfortably above this relaxed minimum, so opening
|
|
3256
4065
|
// the view shows the graph centred with margin rather than
|
|
3257
4066
|
// bleeding to all four edges.
|
|
3258
|
-
const bb = this.cy.elements()
|
|
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));
|
|
3259
4073
|
let zoomMin;
|
|
3260
4074
|
if (bb.w > 0 && bb.h > 0) {
|
|
3261
|
-
const strictFit = Math.min(w / bb.w,
|
|
4075
|
+
const strictFit = Math.min(w / bb.w, visibleH / bb.h);
|
|
3262
4076
|
zoomMin = strictFit * (1 - CapFabRenderer.ZOOM_OUT_FIT_SLACK);
|
|
3263
4077
|
} else {
|
|
3264
4078
|
// Empty / degenerate graph — leave the min loose; there's
|
|
@@ -3486,6 +4300,11 @@ class CapFabRenderer {
|
|
|
3486
4300
|
this.cy.on('zoom pan', function () {
|
|
3487
4301
|
if (!self._internalPanZoom) self._markInitialFitDone();
|
|
3488
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(); });
|
|
3489
4308
|
|
|
3490
4309
|
this.cy.on('tap', 'node', function (evt) {
|
|
3491
4310
|
evt.stopPropagation();
|
|
@@ -3559,13 +4378,20 @@ class CapFabRenderer {
|
|
|
3559
4378
|
const bodyTitle = node.data('bodyTitle');
|
|
3560
4379
|
if (bodyTitle) return `${bodyTitle}: ${node.data('fullUrn') || node.id()}`;
|
|
3561
4380
|
}
|
|
3562
|
-
|
|
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;
|
|
3563
4385
|
}
|
|
3564
4386
|
|
|
3565
4387
|
_tooltipTextForEdge(edge) {
|
|
3566
4388
|
const full = edge.data('fullUrn');
|
|
3567
|
-
|
|
3568
|
-
|
|
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 || '';
|
|
3569
4395
|
}
|
|
3570
4396
|
|
|
3571
4397
|
_handleNodeTap(node) {
|
|
@@ -3648,6 +4474,37 @@ class CapFabRenderer {
|
|
|
3648
4474
|
}
|
|
3649
4475
|
}
|
|
3650
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
|
+
|
|
3651
4508
|
_capUrnString(cap) {
|
|
3652
4509
|
if (!cap || typeof cap !== 'object') {
|
|
3653
4510
|
throw new Error('CapFabRenderer: cap must be an object');
|
|
@@ -3957,15 +4814,19 @@ class CapFabRenderer {
|
|
|
3957
4814
|
this.cy.stop(true);
|
|
3958
4815
|
if (!eles || eles.length === 0) eles = this.cy.elements();
|
|
3959
4816
|
|
|
3960
|
-
const bb =
|
|
4817
|
+
const bb = this._fitBox(eles);
|
|
3961
4818
|
if (bb.w === 0 && bb.h === 0) return;
|
|
3962
4819
|
|
|
3963
4820
|
const containerWidth = this.cy.width();
|
|
3964
4821
|
const containerHeight = this.cy.height();
|
|
3965
4822
|
const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
|
|
3966
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;
|
|
3967
4828
|
|
|
3968
|
-
const visibleWidth = containerWidth -
|
|
4829
|
+
const visibleWidth = containerWidth - paddingX * 2;
|
|
3969
4830
|
const visibleHeight = containerHeight - excluded - padding * 2;
|
|
3970
4831
|
if (visibleWidth <= 0 || visibleHeight <= 0) return;
|
|
3971
4832
|
|
|
@@ -4004,10 +4865,21 @@ class CapFabRenderer {
|
|
|
4004
4865
|
// Tooltip helpers.
|
|
4005
4866
|
// ===========================================================================
|
|
4006
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)
|
|
4007
4870
|
_showTooltip(text, mouseEvent) {
|
|
4008
4871
|
if (!this.tooltip) return;
|
|
4009
4872
|
if (!text) return;
|
|
4010
|
-
|
|
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
|
+
}
|
|
4011
4883
|
this.tooltip.style.display = 'block';
|
|
4012
4884
|
const x = mouseEvent ? mouseEvent.clientX : 0;
|
|
4013
4885
|
const y = mouseEvent ? mouseEvent.clientY : 0;
|