capdag 0.102.232 → 0.106.243
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/build-browser.js +203 -0
- package/cap-graph-renderer.js +2000 -0
- package/capdag.test.js +626 -0
- package/package.json +4 -1
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
// CapGraphRenderer — unified graph rendering for capdag-js
|
|
2
|
+
//
|
|
3
|
+
// One class, four modes:
|
|
4
|
+
//
|
|
5
|
+
// * "browse" — freely-browsable capability registry (capdag-dot-com).
|
|
6
|
+
// Nodes are media URNs, edges are capabilities from
|
|
7
|
+
// /api/capabilities. Supports selection, path exploration
|
|
8
|
+
// between two media nodes, and bidirectional navigator
|
|
9
|
+
// sync.
|
|
10
|
+
//
|
|
11
|
+
// * "strand" — one abstract strand (linear capability chain) focused on
|
|
12
|
+
// a specific source → target path. ForEach / Collect steps
|
|
13
|
+
// label the edges bounding the body span; they are not
|
|
14
|
+
// rendered as distinct nodes.
|
|
15
|
+
//
|
|
16
|
+
// * "run" — realized machine with per-body outcomes. Strand backbone
|
|
17
|
+
// plus body replicas colored by success/failure, grouped
|
|
18
|
+
// pagination (first N successes and first K failures,
|
|
19
|
+
// with independent show-more controls).
|
|
20
|
+
//
|
|
21
|
+
// * "machine" — machine editor live preview (Monaco host). Arbitrary DAG
|
|
22
|
+
// of "node" and "cap" elements with "edge" connections.
|
|
23
|
+
// Supports cross-highlight with the editor via element
|
|
24
|
+
// `tokenId` round-trips.
|
|
25
|
+
//
|
|
26
|
+
// Dependencies (must be loaded before this file):
|
|
27
|
+
// * cytoscape
|
|
28
|
+
// * cytoscape-elk extension (registers itself on `cytoscape`)
|
|
29
|
+
// * elkjs (via cytoscape-elk)
|
|
30
|
+
// * TaggedUrn (from tagged-urn browser build)
|
|
31
|
+
// * CapUrn, MediaUrn, Cap, createCap, CapGraph (from capdag.js)
|
|
32
|
+
//
|
|
33
|
+
// The renderer owns its own theme observer (<html data-theme>) so hosts do
|
|
34
|
+
// nothing to drive theme sync. It owns its own tooltip element and its own
|
|
35
|
+
// cytoscape instance. No implicit defaults: every required option and
|
|
36
|
+
// every required input field is validated up front, and every missing
|
|
37
|
+
// dependency throws immediately.
|
|
38
|
+
//
|
|
39
|
+
// NAMING RULE: core Rust capdag (`capdag/src/...`) is authoritative for
|
|
40
|
+
// every field name this module reads on the wire. Where a payload producer
|
|
41
|
+
// uses a different name for the same concept, the fix is at the producer,
|
|
42
|
+
// not here.
|
|
43
|
+
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Host dependencies — resolved at call time. When this file runs inside
|
|
48
|
+
// Node (for tests) the globals are on `global`.
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
function requireHostDependency(name) {
|
|
52
|
+
const g = (typeof window !== 'undefined') ? window
|
|
53
|
+
: (typeof global !== 'undefined') ? global
|
|
54
|
+
: null;
|
|
55
|
+
if (g === null) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`CapGraphRenderer: no global object (window/global) — cannot resolve '${name}'`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const value = g[name];
|
|
61
|
+
if (value === undefined) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`CapGraphRenderer: required host dependency '${name}' is not loaded. ` +
|
|
64
|
+
`Load cytoscape, cytoscape-elk, tagged-urn.js, and capdag.js before this script.`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// Cardinality labels — derived from is_sequence booleans. The naming
|
|
72
|
+
// follows core Rust capdag: `CapArg.is_sequence` / `CapOutput.is_sequence`
|
|
73
|
+
// at the cap level (browse mode) and `StrandStepType::Cap.input_is_sequence`
|
|
74
|
+
// / `.output_is_sequence` at the strand step level (strand/run modes).
|
|
75
|
+
// =============================================================================
|
|
76
|
+
|
|
77
|
+
function cardinalityLabel(input_is_sequence, output_is_sequence) {
|
|
78
|
+
const lhs = input_is_sequence ? 'n' : '1';
|
|
79
|
+
const rhs = output_is_sequence ? 'n' : '1';
|
|
80
|
+
return `${lhs}\u2192${rhs}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Compute the cardinality marker for a cap as it appears in the
|
|
84
|
+
// /api/capabilities JSON. The main input arg is the one whose sources
|
|
85
|
+
// include a stdin source. Matches core Rust `CapArg.is_sequence` and
|
|
86
|
+
// `CapOutput.is_sequence` names exactly.
|
|
87
|
+
function cardinalityFromCap(cap) {
|
|
88
|
+
if (!cap || typeof cap !== 'object') {
|
|
89
|
+
throw new Error('CapGraphRenderer: cardinalityFromCap requires a cap object');
|
|
90
|
+
}
|
|
91
|
+
const args = cap.args || [];
|
|
92
|
+
const mainArg = args.find(arg =>
|
|
93
|
+
arg && arg.sources && arg.sources.some(src => src && src.stdin !== undefined)
|
|
94
|
+
);
|
|
95
|
+
const input_is_sequence = mainArg ? (mainArg.is_sequence === true) : false;
|
|
96
|
+
const output_is_sequence = (cap.output && cap.output.is_sequence === true) || false;
|
|
97
|
+
return cardinalityLabel(input_is_sequence, output_is_sequence);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Media URN helpers. Every media URN that becomes a cytoscape node ID is
|
|
102
|
+
// first canonicalized via `TaggedUrn.toString()` so tag-order variation
|
|
103
|
+
// never produces distinct cytoscape nodes for the same semantic URN.
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
function canonicalMediaUrn(mediaUrnString) {
|
|
107
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
108
|
+
return MediaUrn.fromString(mediaUrnString).toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Produce a multi-line node label from a canonical media URN, one line
|
|
112
|
+
// per tag. Marker tags render as bare keys; value tags render as
|
|
113
|
+
// `key: value`. TaggedUrn.getTags() is iterated in sorted order matching
|
|
114
|
+
// the canonical serialization.
|
|
115
|
+
function mediaNodeLabel(canonicalUrn) {
|
|
116
|
+
const TaggedUrn = requireHostDependency('TaggedUrn');
|
|
117
|
+
const parsed = TaggedUrn.fromString(canonicalUrn);
|
|
118
|
+
const tags = parsed.tags;
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const key of Object.keys(tags).sort()) {
|
|
121
|
+
const value = tags[key];
|
|
122
|
+
if (value === '*') {
|
|
123
|
+
lines.push(key);
|
|
124
|
+
} else {
|
|
125
|
+
lines.push(`${key}: ${value}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// =============================================================================
|
|
132
|
+
// CSS variable helpers + theme observer hook.
|
|
133
|
+
// =============================================================================
|
|
134
|
+
|
|
135
|
+
function getCssVar(name) {
|
|
136
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function cssVarNumber(name, fallback) {
|
|
140
|
+
const raw = getCssVar(name);
|
|
141
|
+
if (raw === '') return fallback;
|
|
142
|
+
const parsed = parseFloat(raw);
|
|
143
|
+
if (!Number.isFinite(parsed)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`CapGraphRenderer: CSS variable '${name}' value '${raw}' is not a number`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// =============================================================================
|
|
152
|
+
// Layout configs per mode. Same ELK algorithm; spacing is tuned per mode
|
|
153
|
+
// to match the typical graph density and reading direction of each.
|
|
154
|
+
// =============================================================================
|
|
155
|
+
|
|
156
|
+
function layoutForMode(mode) {
|
|
157
|
+
const base = {
|
|
158
|
+
algorithm: 'layered',
|
|
159
|
+
'elk.direction': 'RIGHT',
|
|
160
|
+
'elk.edgeRouting': 'POLYLINE',
|
|
161
|
+
'elk.layered.spacing.edgeEdgeBetweenLayers': 20,
|
|
162
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': 30,
|
|
163
|
+
'elk.spacing.edgeEdge': 15,
|
|
164
|
+
'elk.spacing.edgeNode': 25,
|
|
165
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
166
|
+
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
167
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
168
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
169
|
+
};
|
|
170
|
+
if (mode === 'browse') {
|
|
171
|
+
return Object.assign({}, base, {
|
|
172
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': 150,
|
|
173
|
+
'elk.spacing.nodeNode': 50,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (mode === 'strand') {
|
|
177
|
+
return Object.assign({}, base, {
|
|
178
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': 120,
|
|
179
|
+
'elk.spacing.nodeNode': 40,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (mode === 'run') {
|
|
183
|
+
return Object.assign({}, base, {
|
|
184
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': 100,
|
|
185
|
+
'elk.spacing.nodeNode': 35,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (mode === 'machine') {
|
|
189
|
+
// Editor graph is a small bipartite-ish DAG; orthogonal routing
|
|
190
|
+
// reads more cleanly than polyline at this density.
|
|
191
|
+
return {
|
|
192
|
+
algorithm: 'layered',
|
|
193
|
+
'elk.direction': 'RIGHT',
|
|
194
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
195
|
+
'elk.spacing.nodeNode': 40,
|
|
196
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': 90,
|
|
197
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
198
|
+
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
|
|
199
|
+
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`CapGraphRenderer: unknown mode '${mode}'`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// =============================================================================
|
|
206
|
+
// Stylesheet — reads CSS variables on every call so theme toggles work by
|
|
207
|
+
// re-running this and calling `cy.style(...)`.
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
function buildStylesheet() {
|
|
211
|
+
const nodeText = getCssVar('--graph-node-text');
|
|
212
|
+
const nodeBg = getCssVar('--graph-node-bg');
|
|
213
|
+
const nodeBorder = getCssVar('--graph-node-border');
|
|
214
|
+
const nodeBorderHighlighted = getCssVar('--graph-node-border-highlighted');
|
|
215
|
+
const nodeBorderActive = getCssVar('--graph-node-border-active');
|
|
216
|
+
const edgeTextBg = getCssVar('--graph-edge-text-bg');
|
|
217
|
+
const edgeTextBgOpacity = cssVarNumber('--graph-edge-text-bg-opacity', 0.9);
|
|
218
|
+
const fadedOpacity = cssVarNumber('--graph-faded-opacity', 0.15);
|
|
219
|
+
const fadedEdgeOpacity = cssVarNumber('--graph-faded-edge-opacity', 0.1);
|
|
220
|
+
const bodyNodeSuccess = getCssVar('--graph-body-node-success');
|
|
221
|
+
const bodyNodeFailure = getCssVar('--graph-body-node-failure');
|
|
222
|
+
const bodyEdgeSuccess = getCssVar('--graph-body-edge-success');
|
|
223
|
+
const bodyEdgeFailure = getCssVar('--graph-body-edge-failure');
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
selector: 'node',
|
|
228
|
+
style: {
|
|
229
|
+
'label': 'data(label)',
|
|
230
|
+
'text-valign': 'center',
|
|
231
|
+
'text-halign': 'center',
|
|
232
|
+
'text-wrap': 'wrap',
|
|
233
|
+
'text-max-width': '150px',
|
|
234
|
+
'line-height': 1.3,
|
|
235
|
+
'font-family': '"JetBrains Mono", ui-monospace, monospace',
|
|
236
|
+
'font-size': '9px',
|
|
237
|
+
'font-weight': '500',
|
|
238
|
+
'color': nodeText,
|
|
239
|
+
'background-color': nodeBg,
|
|
240
|
+
'shape': 'round-rectangle',
|
|
241
|
+
'width': 'label',
|
|
242
|
+
'height': 'label',
|
|
243
|
+
'padding': '12px',
|
|
244
|
+
'border-width': '2px',
|
|
245
|
+
'border-color': nodeBorder,
|
|
246
|
+
'border-opacity': 0.8,
|
|
247
|
+
'transition-property': 'opacity, border-color, border-width',
|
|
248
|
+
'transition-duration': '0.2s',
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
selector: 'node.highlighted',
|
|
253
|
+
style: { 'border-width': '3px', 'border-color': nodeBorderHighlighted },
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
selector: 'node.active',
|
|
257
|
+
style: { 'border-width': '3px', 'border-color': nodeBorderActive, 'z-index': 999 },
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
selector: 'node.faded',
|
|
261
|
+
style: { 'opacity': fadedOpacity },
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
selector: 'node.body-success',
|
|
265
|
+
style: { 'background-color': bodyNodeSuccess },
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
selector: 'node.body-failure',
|
|
269
|
+
style: { 'background-color': bodyNodeFailure },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
selector: 'node.show-more',
|
|
273
|
+
style: {
|
|
274
|
+
'background-color': getCssVar('--graph-bg'),
|
|
275
|
+
'border-style': 'dashed',
|
|
276
|
+
'border-width': '2px',
|
|
277
|
+
'border-color': nodeBorderHighlighted,
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
selector: 'edge',
|
|
282
|
+
style: {
|
|
283
|
+
'label': 'data(label)',
|
|
284
|
+
'font-family': '"JetBrains Mono", ui-monospace, monospace',
|
|
285
|
+
'font-size': '9px',
|
|
286
|
+
'font-weight': '500',
|
|
287
|
+
'color': 'data(color)',
|
|
288
|
+
'text-background-color': edgeTextBg,
|
|
289
|
+
'text-background-opacity': edgeTextBgOpacity,
|
|
290
|
+
'text-background-padding': '3px',
|
|
291
|
+
'text-background-shape': 'roundrectangle',
|
|
292
|
+
'text-rotation': 'autorotate',
|
|
293
|
+
'text-margin-y': -8,
|
|
294
|
+
'curve-style': 'bezier',
|
|
295
|
+
'control-point-step-size': 40,
|
|
296
|
+
'width': 1.5,
|
|
297
|
+
'line-color': 'data(color)',
|
|
298
|
+
'target-arrow-color': 'data(color)',
|
|
299
|
+
'target-arrow-shape': 'triangle',
|
|
300
|
+
'arrow-scale': 0.8,
|
|
301
|
+
'transition-property': 'opacity, width',
|
|
302
|
+
'transition-duration': '0.2s',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
selector: 'edge.highlighted',
|
|
307
|
+
style: { 'width': 2.5, 'z-index': 999 },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
selector: 'edge.active',
|
|
311
|
+
style: { 'width': 3, 'z-index': 1000 },
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
selector: 'edge.faded',
|
|
315
|
+
style: { 'opacity': fadedEdgeOpacity },
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
selector: 'edge.body-success',
|
|
319
|
+
style: { 'line-color': bodyEdgeSuccess, 'target-arrow-color': bodyEdgeSuccess },
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
selector: 'edge.body-failure',
|
|
323
|
+
style: { 'line-color': bodyEdgeFailure, 'target-arrow-color': bodyEdgeFailure },
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
selector: 'node.path-highlighted',
|
|
327
|
+
style: { 'border-width': '3px', 'border-color': nodeBorderHighlighted },
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
selector: 'edge.path-highlighted',
|
|
331
|
+
style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
|
|
332
|
+
},
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// =============================================================================
|
|
337
|
+
// Tooltip element — fixed-position div that follows the cursor.
|
|
338
|
+
// =============================================================================
|
|
339
|
+
|
|
340
|
+
function createTooltipElement() {
|
|
341
|
+
const el = document.createElement('div');
|
|
342
|
+
el.className = 'graph-tooltip';
|
|
343
|
+
el.style.cssText = [
|
|
344
|
+
'position: fixed',
|
|
345
|
+
'display: none',
|
|
346
|
+
'background: var(--bg-elevated)',
|
|
347
|
+
'border: 1px solid var(--border-primary)',
|
|
348
|
+
'border-radius: 6px',
|
|
349
|
+
'padding: 6px 10px',
|
|
350
|
+
'font-family: var(--font-mono, ui-monospace, monospace)',
|
|
351
|
+
'font-size: 11px',
|
|
352
|
+
'color: var(--text-secondary)',
|
|
353
|
+
'max-width: 400px',
|
|
354
|
+
'word-break: break-all',
|
|
355
|
+
'z-index: 10000',
|
|
356
|
+
'pointer-events: none',
|
|
357
|
+
'box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3)',
|
|
358
|
+
].join('; ') + ';';
|
|
359
|
+
document.body.appendChild(el);
|
|
360
|
+
return el;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// =============================================================================
|
|
364
|
+
// Validation — strict per-mode input shape checks. Every required field is
|
|
365
|
+
// enforced with a descriptive error naming the failing path. No fallback.
|
|
366
|
+
// =============================================================================
|
|
367
|
+
|
|
368
|
+
function assertString(value, path) {
|
|
369
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
370
|
+
throw new Error(`CapGraphRenderer: ${path} must be a non-empty string`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function assertArray(value, path) {
|
|
375
|
+
if (!Array.isArray(value)) {
|
|
376
|
+
throw new Error(`CapGraphRenderer: ${path} must be an array`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function validateBrowseData(data) {
|
|
381
|
+
assertArray(data, 'browse mode data');
|
|
382
|
+
data.forEach((cap, idx) => {
|
|
383
|
+
if (!cap || typeof cap !== 'object') {
|
|
384
|
+
throw new Error(`CapGraphRenderer browse mode: data[${idx}] is not an object`);
|
|
385
|
+
}
|
|
386
|
+
assertString(cap.urn, `browse mode data[${idx}].urn`);
|
|
387
|
+
assertString(cap.in_spec, `browse mode data[${idx}].in_spec (cap urn: ${cap.urn})`);
|
|
388
|
+
assertString(cap.out_spec, `browse mode data[${idx}].out_spec (cap urn: ${cap.urn})`);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Validate a canonical `StrandStep` — the Rust-serialized form with
|
|
393
|
+
// externally-tagged step_type.
|
|
394
|
+
function validateStrandStep(step, path) {
|
|
395
|
+
if (!step || typeof step !== 'object') {
|
|
396
|
+
throw new Error(`CapGraphRenderer: ${path} is not an object`);
|
|
397
|
+
}
|
|
398
|
+
assertString(step.from_spec, `${path}.from_spec`);
|
|
399
|
+
assertString(step.to_spec, `${path}.to_spec`);
|
|
400
|
+
if (!step.step_type || typeof step.step_type !== 'object') {
|
|
401
|
+
throw new Error(`CapGraphRenderer: ${path}.step_type must be an object`);
|
|
402
|
+
}
|
|
403
|
+
const keys = Object.keys(step.step_type);
|
|
404
|
+
if (keys.length !== 1) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`CapGraphRenderer: ${path}.step_type must have exactly one variant key (got: ${keys.join(',')})`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
const variant = keys[0];
|
|
410
|
+
if (variant !== 'Cap' && variant !== 'ForEach' && variant !== 'Collect') {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`CapGraphRenderer: ${path}.step_type variant must be Cap | ForEach | Collect (got: ${variant})`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const body = step.step_type[variant];
|
|
416
|
+
if (!body || typeof body !== 'object') {
|
|
417
|
+
throw new Error(`CapGraphRenderer: ${path}.step_type.${variant} must be an object`);
|
|
418
|
+
}
|
|
419
|
+
if (variant === 'Cap') {
|
|
420
|
+
assertString(body.cap_urn, `${path}.step_type.Cap.cap_urn`);
|
|
421
|
+
assertString(body.title, `${path}.step_type.Cap.title`);
|
|
422
|
+
if (typeof body.input_is_sequence !== 'boolean') {
|
|
423
|
+
throw new Error(`CapGraphRenderer: ${path}.step_type.Cap.input_is_sequence must be a boolean`);
|
|
424
|
+
}
|
|
425
|
+
if (typeof body.output_is_sequence !== 'boolean') {
|
|
426
|
+
throw new Error(`CapGraphRenderer: ${path}.step_type.Cap.output_is_sequence must be a boolean`);
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
assertString(body.media_spec, `${path}.step_type.${variant}.media_spec`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function validateStrandPayload(data) {
|
|
434
|
+
if (!data || typeof data !== 'object') {
|
|
435
|
+
throw new Error('CapGraphRenderer strand mode: data must be an object');
|
|
436
|
+
}
|
|
437
|
+
assertString(data.source_spec, 'strand mode data.source_spec');
|
|
438
|
+
assertString(data.target_spec, 'strand mode data.target_spec');
|
|
439
|
+
assertArray(data.steps, 'strand mode data.steps');
|
|
440
|
+
data.steps.forEach((step, idx) => {
|
|
441
|
+
validateStrandStep(step, `strand mode data.steps[${idx}]`);
|
|
442
|
+
});
|
|
443
|
+
if (data.media_display_names !== undefined
|
|
444
|
+
&& (data.media_display_names === null || typeof data.media_display_names !== 'object')) {
|
|
445
|
+
throw new Error('CapGraphRenderer strand mode: data.media_display_names must be an object when present');
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function validateBodyOutcome(outcome, path) {
|
|
450
|
+
if (!outcome || typeof outcome !== 'object') {
|
|
451
|
+
throw new Error(`CapGraphRenderer: ${path} is not an object`);
|
|
452
|
+
}
|
|
453
|
+
if (typeof outcome.body_index !== 'number' || !Number.isInteger(outcome.body_index) || outcome.body_index < 0) {
|
|
454
|
+
throw new Error(`CapGraphRenderer: ${path}.body_index must be a non-negative integer`);
|
|
455
|
+
}
|
|
456
|
+
if (typeof outcome.success !== 'boolean') {
|
|
457
|
+
throw new Error(`CapGraphRenderer: ${path}.success must be a boolean`);
|
|
458
|
+
}
|
|
459
|
+
assertArray(outcome.cap_urns, `${path}.cap_urns`);
|
|
460
|
+
outcome.cap_urns.forEach((u, i) => assertString(u, `${path}.cap_urns[${i}]`));
|
|
461
|
+
if (outcome.failed_cap !== undefined && outcome.failed_cap !== null
|
|
462
|
+
&& (typeof outcome.failed_cap !== 'string' || outcome.failed_cap.length === 0)) {
|
|
463
|
+
throw new Error(`CapGraphRenderer: ${path}.failed_cap must be a non-empty string when present`);
|
|
464
|
+
}
|
|
465
|
+
if (!outcome.success && outcome.failed_cap === undefined) {
|
|
466
|
+
// Failure without a failed_cap is allowed (e.g. infrastructure
|
|
467
|
+
// failure before any cap ran) but we still expect the field to be
|
|
468
|
+
// present — either null or a string. Rust's Option<String>
|
|
469
|
+
// serializes as null or the string, never missing.
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function validateRunPayload(data) {
|
|
474
|
+
if (!data || typeof data !== 'object') {
|
|
475
|
+
throw new Error('CapGraphRenderer run mode: data must be an object');
|
|
476
|
+
}
|
|
477
|
+
if (!data.resolved_strand || typeof data.resolved_strand !== 'object') {
|
|
478
|
+
throw new Error('CapGraphRenderer run mode: data.resolved_strand must be an object');
|
|
479
|
+
}
|
|
480
|
+
validateStrandPayload(Object.assign({}, data.resolved_strand, {
|
|
481
|
+
media_display_names: data.media_display_names,
|
|
482
|
+
}));
|
|
483
|
+
assertArray(data.body_outcomes, 'run mode data.body_outcomes');
|
|
484
|
+
data.body_outcomes.forEach((o, idx) => {
|
|
485
|
+
validateBodyOutcome(o, `run mode data.body_outcomes[${idx}]`);
|
|
486
|
+
});
|
|
487
|
+
if (typeof data.visible_success_count !== 'number' || data.visible_success_count < 0) {
|
|
488
|
+
throw new Error('CapGraphRenderer run mode: data.visible_success_count must be a non-negative number');
|
|
489
|
+
}
|
|
490
|
+
if (typeof data.visible_failure_count !== 'number' || data.visible_failure_count < 0) {
|
|
491
|
+
throw new Error('CapGraphRenderer run mode: data.visible_failure_count must be a non-negative number');
|
|
492
|
+
}
|
|
493
|
+
if (typeof data.total_body_count !== 'number' || data.total_body_count < 0) {
|
|
494
|
+
throw new Error('CapGraphRenderer run mode: data.total_body_count must be a non-negative number');
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function validateMachinePayload(data) {
|
|
499
|
+
if (!data || typeof data !== 'object') {
|
|
500
|
+
throw new Error('CapGraphRenderer machine mode: data must be an object');
|
|
501
|
+
}
|
|
502
|
+
assertArray(data.elements, 'machine mode data.elements');
|
|
503
|
+
data.elements.forEach((el, idx) => {
|
|
504
|
+
if (!el || typeof el !== 'object') {
|
|
505
|
+
throw new Error(`CapGraphRenderer machine mode: data.elements[${idx}] is not an object`);
|
|
506
|
+
}
|
|
507
|
+
if (el.kind !== 'node' && el.kind !== 'cap' && el.kind !== 'edge') {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`CapGraphRenderer machine mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
assertString(el.graph_id, `machine mode data.elements[${idx}].graph_id`);
|
|
513
|
+
if (el.kind === 'edge') {
|
|
514
|
+
assertString(el.source_graph_id, `machine mode data.elements[${idx}].source_graph_id`);
|
|
515
|
+
assertString(el.target_graph_id, `machine mode data.elements[${idx}].target_graph_id`);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// =============================================================================
|
|
521
|
+
// Per-mode graph builders. Each returns the cytoscape `elements` list plus
|
|
522
|
+
// any mode-specific bookkeeping stored on the renderer instance.
|
|
523
|
+
// =============================================================================
|
|
524
|
+
|
|
525
|
+
const GOLDEN_ANGLE = 137.508;
|
|
526
|
+
|
|
527
|
+
function goldenHue(index) {
|
|
528
|
+
return (index * GOLDEN_ANGLE) % 360;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Remap a raw 0..360 hue into bands that avoid red (330-30) and green
|
|
532
|
+
// (90-150). Used across all cap-style edges so success/failure greens and
|
|
533
|
+
// reds stay reserved for run-mode body outcomes.
|
|
534
|
+
function remapHue(raw) {
|
|
535
|
+
const t = ((raw % 360) + 360) % 360 / 360;
|
|
536
|
+
const safe = t * 240;
|
|
537
|
+
if (safe < 60) return 30 + safe;
|
|
538
|
+
return 150 + (safe - 60);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function edgeHueColor(edgeIdx) {
|
|
542
|
+
const hue = remapHue(goldenHue(edgeIdx));
|
|
543
|
+
return `hsl(${hue}, 60%, 55%)`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --------- Browse mode builder ----------------------------------------------
|
|
547
|
+
|
|
548
|
+
function buildBrowseGraphData(capabilities) {
|
|
549
|
+
validateBrowseData(capabilities);
|
|
550
|
+
|
|
551
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
552
|
+
const createCap = requireHostDependency('createCap');
|
|
553
|
+
const CapGraph = requireHostDependency('CapGraph');
|
|
554
|
+
|
|
555
|
+
const nodesMap = new Map();
|
|
556
|
+
const edges = [];
|
|
557
|
+
const capGraph = new CapGraph();
|
|
558
|
+
const mediaTitles = new Map();
|
|
559
|
+
const capabilitiesByEdgeId = new Map();
|
|
560
|
+
|
|
561
|
+
for (const capData of capabilities) {
|
|
562
|
+
const inSpec = canonicalMediaUrn(capData.in_spec);
|
|
563
|
+
const outSpec = canonicalMediaUrn(capData.out_spec);
|
|
564
|
+
|
|
565
|
+
if (!nodesMap.has(inSpec)) nodesMap.set(inSpec, { id: inSpec });
|
|
566
|
+
if (!nodesMap.has(outSpec)) nodesMap.set(outSpec, { id: outSpec });
|
|
567
|
+
|
|
568
|
+
if (capData.in_media_title && !mediaTitles.has(inSpec)) {
|
|
569
|
+
mediaTitles.set(inSpec, capData.in_media_title);
|
|
570
|
+
}
|
|
571
|
+
if (capData.out_media_title && !mediaTitles.has(outSpec)) {
|
|
572
|
+
mediaTitles.set(outSpec, capData.out_media_title);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const edgeId = `edge-${edges.length}`;
|
|
576
|
+
const title = capData.title || '';
|
|
577
|
+
|
|
578
|
+
// Parsing the URN canonicalizes it and validates it — fail hard on
|
|
579
|
+
// malformed registry data.
|
|
580
|
+
const parsedUrn = CapUrn.fromString(capData.urn);
|
|
581
|
+
const cap = createCap(parsedUrn, title, capData.command || '');
|
|
582
|
+
const capGraphEdgeIndex = capGraph.edges.length;
|
|
583
|
+
capGraph.addCap(cap, 'registry');
|
|
584
|
+
|
|
585
|
+
edges.push({
|
|
586
|
+
id: edgeId,
|
|
587
|
+
source: inSpec,
|
|
588
|
+
target: outSpec,
|
|
589
|
+
title,
|
|
590
|
+
capability: capData,
|
|
591
|
+
capGraphEdgeIndex,
|
|
592
|
+
});
|
|
593
|
+
capabilitiesByEdgeId.set(edgeId, capData);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
edges.forEach((edge, i) => {
|
|
597
|
+
edge.color = edgeHueColor(i);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const nodes = Array.from(nodesMap.values());
|
|
601
|
+
|
|
602
|
+
const adjacency = new Map();
|
|
603
|
+
const reverseAdj = new Map();
|
|
604
|
+
for (const edge of edges) {
|
|
605
|
+
if (!adjacency.has(edge.source)) adjacency.set(edge.source, new Set());
|
|
606
|
+
adjacency.get(edge.source).add(edge.target);
|
|
607
|
+
if (!reverseAdj.has(edge.target)) reverseAdj.set(edge.target, new Set());
|
|
608
|
+
reverseAdj.get(edge.target).add(edge.source);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return { nodes, edges, adjacency, reverseAdj, capGraph, mediaTitles, capabilitiesByEdgeId };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function browseCytoscapeElements(built) {
|
|
615
|
+
const nodeElements = built.nodes.map(node => ({
|
|
616
|
+
group: 'nodes',
|
|
617
|
+
data: {
|
|
618
|
+
id: node.id,
|
|
619
|
+
label: mediaNodeLabel(node.id),
|
|
620
|
+
mediaTitle: built.mediaTitles.get(node.id) || '',
|
|
621
|
+
fullUrn: node.id,
|
|
622
|
+
},
|
|
623
|
+
}));
|
|
624
|
+
const edgeElements = built.edges.map(edge => {
|
|
625
|
+
const cardinality = cardinalityFromCap(edge.capability);
|
|
626
|
+
const label = `${edge.title} (${cardinality})`;
|
|
627
|
+
return {
|
|
628
|
+
group: 'edges',
|
|
629
|
+
data: {
|
|
630
|
+
id: edge.id,
|
|
631
|
+
source: edge.source,
|
|
632
|
+
target: edge.target,
|
|
633
|
+
label,
|
|
634
|
+
title: edge.title,
|
|
635
|
+
cardinality,
|
|
636
|
+
fullUrn: edge.capability.urn,
|
|
637
|
+
capGraphEdgeIndex: edge.capGraphEdgeIndex,
|
|
638
|
+
color: edge.color,
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
});
|
|
642
|
+
return nodeElements.concat(edgeElements);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --------- Strand mode builder ----------------------------------------------
|
|
646
|
+
|
|
647
|
+
// Classify each Cap step by its adjacency to non-Cap neighbor steps
|
|
648
|
+
// (ForEach before, Collect after). Exported for testing.
|
|
649
|
+
function classifyStrandCapSteps(steps) {
|
|
650
|
+
const capStepIndices = [];
|
|
651
|
+
steps.forEach((step, idx) => {
|
|
652
|
+
const variant = Object.keys(step.step_type)[0];
|
|
653
|
+
if (variant === 'Cap') capStepIndices.push(idx);
|
|
654
|
+
});
|
|
655
|
+
const capFlags = new Map();
|
|
656
|
+
for (const idx of capStepIndices) {
|
|
657
|
+
const prevForEach = idx > 0
|
|
658
|
+
&& Object.keys(steps[idx - 1].step_type)[0] === 'ForEach';
|
|
659
|
+
const nextCollect = idx < steps.length - 1
|
|
660
|
+
&& Object.keys(steps[idx + 1].step_type)[0] === 'Collect';
|
|
661
|
+
capFlags.set(idx, { prevForEach, nextCollect });
|
|
662
|
+
}
|
|
663
|
+
return { capStepIndices, capFlags };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Build the strand graph. Every Cap step becomes an edge
|
|
667
|
+
// (`from_spec` → `to_spec`). ForEach / Collect are NOT rendered as nodes
|
|
668
|
+
// — they are transitions that annotate the adjacent cap edges:
|
|
669
|
+
//
|
|
670
|
+
// * A Cap whose previous step is ForEach gets the prefix "for each"
|
|
671
|
+
// on its edge label (the first-body entry edge).
|
|
672
|
+
// * A Cap whose next step is Collect gets the suffix "collect" on its
|
|
673
|
+
// edge label (the last-body exit edge).
|
|
674
|
+
//
|
|
675
|
+
// Source-to-body and body-to-target continuity is guaranteed by the
|
|
676
|
+
// strand structure itself: consecutive cap steps satisfy
|
|
677
|
+
// `stepN.to_spec == stepN+1.from_spec`, and fix-up edges are added
|
|
678
|
+
// explicitly when the strand's `source_spec` differs from the first cap
|
|
679
|
+
// step's `from_spec` (the ForEach shape transition) or when the strand's
|
|
680
|
+
// `target_spec` differs from the last cap step's `to_spec` (the Collect
|
|
681
|
+
// shape transition).
|
|
682
|
+
function buildStrandGraphData(data) {
|
|
683
|
+
validateStrandPayload(data);
|
|
684
|
+
|
|
685
|
+
const mediaDisplayNames = data.media_display_names || {};
|
|
686
|
+
const sourceSpec = canonicalMediaUrn(data.source_spec);
|
|
687
|
+
const targetSpec = canonicalMediaUrn(data.target_spec);
|
|
688
|
+
|
|
689
|
+
const canonicalDisplayLookup = new Map();
|
|
690
|
+
for (const [urn, display] of Object.entries(mediaDisplayNames)) {
|
|
691
|
+
if (typeof display === 'string' && display.length > 0) {
|
|
692
|
+
canonicalDisplayLookup.set(canonicalMediaUrn(urn), display);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const nodesMap = new Map();
|
|
697
|
+
function ensureNode(canonicalUrn) {
|
|
698
|
+
if (!nodesMap.has(canonicalUrn)) {
|
|
699
|
+
const displayName = canonicalDisplayLookup.get(canonicalUrn);
|
|
700
|
+
nodesMap.set(canonicalUrn, {
|
|
701
|
+
id: canonicalUrn,
|
|
702
|
+
label: displayName !== undefined ? displayName : mediaNodeLabel(canonicalUrn),
|
|
703
|
+
fullUrn: canonicalUrn,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return canonicalUrn;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
ensureNode(sourceSpec);
|
|
710
|
+
ensureNode(targetSpec);
|
|
711
|
+
|
|
712
|
+
const { capStepIndices } = classifyStrandCapSteps(data.steps);
|
|
713
|
+
const firstCapIdx = capStepIndices.length > 0 ? capStepIndices[0] : -1;
|
|
714
|
+
const lastCapIdx = capStepIndices.length > 0 ? capStepIndices[capStepIndices.length - 1] : -1;
|
|
715
|
+
const hasLeadingForEach = data.steps.some((s, i) =>
|
|
716
|
+
Object.keys(s.step_type)[0] === 'ForEach' && i < (firstCapIdx === -1 ? Infinity : firstCapIdx));
|
|
717
|
+
const hasTrailingCollect = data.steps.some((s, i) =>
|
|
718
|
+
Object.keys(s.step_type)[0] === 'Collect' && i > (lastCapIdx === -1 ? -Infinity : lastCapIdx));
|
|
719
|
+
|
|
720
|
+
const edges = [];
|
|
721
|
+
let capEdgeIdx = 0;
|
|
722
|
+
|
|
723
|
+
// Prepend a fix-up edge from source_spec to the first cap step's
|
|
724
|
+
// from_spec when they differ. This is the ForEach shape transition
|
|
725
|
+
// rendered as an explicit edge.
|
|
726
|
+
if (firstCapIdx >= 0) {
|
|
727
|
+
const firstCap = data.steps[firstCapIdx];
|
|
728
|
+
const firstCapFrom = canonicalMediaUrn(firstCap.from_spec);
|
|
729
|
+
if (firstCapFrom !== sourceSpec) {
|
|
730
|
+
ensureNode(firstCapFrom);
|
|
731
|
+
const label = hasLeadingForEach ? 'for each' : '';
|
|
732
|
+
edges.push({
|
|
733
|
+
id: `strand-edge-${capEdgeIdx}`,
|
|
734
|
+
source: sourceSpec,
|
|
735
|
+
target: firstCapFrom,
|
|
736
|
+
title: label || 'fan-out',
|
|
737
|
+
label,
|
|
738
|
+
cardinality: '',
|
|
739
|
+
capUrn: '',
|
|
740
|
+
color: edgeHueColor(capEdgeIdx),
|
|
741
|
+
});
|
|
742
|
+
capEdgeIdx++;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Cap step edges. Each Cap's edge connects its from_spec to its
|
|
747
|
+
// to_spec. Adjacent cap edges are continuous because the planner
|
|
748
|
+
// guarantees stepN.to_spec == stepN+1.from_spec. "for each" and
|
|
749
|
+
// "collect" labels belong on the fix-up edges around the cap chain,
|
|
750
|
+
// not on the cap edges themselves — those labels describe shape
|
|
751
|
+
// transitions between the source list and the first body scalar (and
|
|
752
|
+
// between the last body scalar and the target list), which are the
|
|
753
|
+
// fix-up edges.
|
|
754
|
+
data.steps.forEach((step) => {
|
|
755
|
+
const variant = Object.keys(step.step_type)[0];
|
|
756
|
+
if (variant !== 'Cap') return;
|
|
757
|
+
const body = step.step_type.Cap;
|
|
758
|
+
const fromCanonical = canonicalMediaUrn(step.from_spec);
|
|
759
|
+
const toCanonical = canonicalMediaUrn(step.to_spec);
|
|
760
|
+
ensureNode(fromCanonical);
|
|
761
|
+
ensureNode(toCanonical);
|
|
762
|
+
|
|
763
|
+
let label = body.title;
|
|
764
|
+
const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
|
|
765
|
+
if (cardinality !== '1\u21921') {
|
|
766
|
+
label = `${label} (${cardinality})`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
edges.push({
|
|
770
|
+
id: `strand-edge-${capEdgeIdx}`,
|
|
771
|
+
source: fromCanonical,
|
|
772
|
+
target: toCanonical,
|
|
773
|
+
title: body.title,
|
|
774
|
+
label,
|
|
775
|
+
cardinality,
|
|
776
|
+
capUrn: body.cap_urn,
|
|
777
|
+
color: edgeHueColor(capEdgeIdx),
|
|
778
|
+
});
|
|
779
|
+
capEdgeIdx++;
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
// Append a fix-up edge from the last cap's to_spec to target_spec
|
|
783
|
+
// when they differ. This is the Collect shape transition as an
|
|
784
|
+
// explicit edge.
|
|
785
|
+
if (lastCapIdx >= 0) {
|
|
786
|
+
const lastCap = data.steps[lastCapIdx];
|
|
787
|
+
const lastCapTo = canonicalMediaUrn(lastCap.to_spec);
|
|
788
|
+
if (lastCapTo !== targetSpec) {
|
|
789
|
+
const label = hasTrailingCollect ? 'collect' : '';
|
|
790
|
+
edges.push({
|
|
791
|
+
id: `strand-edge-${capEdgeIdx}`,
|
|
792
|
+
source: lastCapTo,
|
|
793
|
+
target: targetSpec,
|
|
794
|
+
title: label || 'fan-in',
|
|
795
|
+
label,
|
|
796
|
+
cardinality: '',
|
|
797
|
+
capUrn: '',
|
|
798
|
+
color: edgeHueColor(capEdgeIdx),
|
|
799
|
+
});
|
|
800
|
+
capEdgeIdx++;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Edge case: if there are no Cap steps at all, still connect source
|
|
805
|
+
// to target directly so the graph has at least one edge. This handles
|
|
806
|
+
// degenerate strands (e.g. identity).
|
|
807
|
+
if (firstCapIdx === -1 && sourceSpec !== targetSpec) {
|
|
808
|
+
edges.push({
|
|
809
|
+
id: `strand-edge-${capEdgeIdx}`,
|
|
810
|
+
source: sourceSpec,
|
|
811
|
+
target: targetSpec,
|
|
812
|
+
title: '',
|
|
813
|
+
label: '',
|
|
814
|
+
cardinality: '',
|
|
815
|
+
capUrn: '',
|
|
816
|
+
color: edgeHueColor(capEdgeIdx),
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
nodes: Array.from(nodesMap.values()),
|
|
822
|
+
edges,
|
|
823
|
+
sourceSpec,
|
|
824
|
+
targetSpec,
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function strandCytoscapeElements(built) {
|
|
829
|
+
const nodeElements = built.nodes.map(node => ({
|
|
830
|
+
group: 'nodes',
|
|
831
|
+
data: {
|
|
832
|
+
id: node.id,
|
|
833
|
+
label: node.label,
|
|
834
|
+
fullUrn: node.fullUrn,
|
|
835
|
+
},
|
|
836
|
+
classes: node.id === built.sourceSpec ? 'strand-source'
|
|
837
|
+
: node.id === built.targetSpec ? 'strand-target'
|
|
838
|
+
: '',
|
|
839
|
+
}));
|
|
840
|
+
const edgeElements = built.edges.map(edge => ({
|
|
841
|
+
group: 'edges',
|
|
842
|
+
data: {
|
|
843
|
+
id: edge.id,
|
|
844
|
+
source: edge.source,
|
|
845
|
+
target: edge.target,
|
|
846
|
+
label: edge.label,
|
|
847
|
+
title: edge.title,
|
|
848
|
+
cardinality: edge.cardinality,
|
|
849
|
+
fullUrn: edge.capUrn,
|
|
850
|
+
color: edge.color,
|
|
851
|
+
},
|
|
852
|
+
}));
|
|
853
|
+
return nodeElements.concat(edgeElements);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// --------- Run mode builder -------------------------------------------------
|
|
857
|
+
|
|
858
|
+
// Find a cap step in the resolved strand whose Cap.cap_urn semantically
|
|
859
|
+
// matches the supplied URN string. Uses CapUrn.isEquivalent — never
|
|
860
|
+
// string equality.
|
|
861
|
+
function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
862
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
863
|
+
const target = CapUrn.fromString(targetUrnString);
|
|
864
|
+
for (let i = 0; i < steps.length; i++) {
|
|
865
|
+
const variant = Object.keys(steps[i].step_type)[0];
|
|
866
|
+
if (variant !== 'Cap') continue;
|
|
867
|
+
const candidate = CapUrn.fromString(steps[i].step_type.Cap.cap_urn);
|
|
868
|
+
if (candidate.isEquivalent(target)) return i;
|
|
869
|
+
}
|
|
870
|
+
return -1;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function buildRunGraphData(data) {
|
|
874
|
+
validateRunPayload(data);
|
|
875
|
+
|
|
876
|
+
// The backbone is rendered with the same rules as strand mode. Feed
|
|
877
|
+
// the strand portion to the strand builder to inherit all its
|
|
878
|
+
// ForEach/Collect labeling and cardinality-marker logic.
|
|
879
|
+
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
880
|
+
media_display_names: data.media_display_names,
|
|
881
|
+
});
|
|
882
|
+
const strandBuilt = buildStrandGraphData(strandInput);
|
|
883
|
+
|
|
884
|
+
// Locate the ForEach/Collect span in the backbone for body-replica
|
|
885
|
+
// placement. We anchor body replicas to the first Cap step after a
|
|
886
|
+
// ForEach (the fan-out point) and merge back at the first Collect.
|
|
887
|
+
const steps = data.resolved_strand.steps;
|
|
888
|
+
let foreachStepIdx = -1;
|
|
889
|
+
let collectStepIdx = -1;
|
|
890
|
+
for (let i = 0; i < steps.length; i++) {
|
|
891
|
+
const variant = Object.keys(steps[i].step_type)[0];
|
|
892
|
+
if (variant === 'ForEach' && foreachStepIdx < 0) foreachStepIdx = i;
|
|
893
|
+
if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
|
|
894
|
+
}
|
|
895
|
+
const hasForeach = foreachStepIdx >= 0;
|
|
896
|
+
|
|
897
|
+
// Filter and bound the outcomes.
|
|
898
|
+
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
899
|
+
const successes = allOutcomes.filter(o => o.success);
|
|
900
|
+
const failures = allOutcomes.filter(o => !o.success);
|
|
901
|
+
const visibleSuccess = successes.slice(0, data.visible_success_count);
|
|
902
|
+
const visibleFailure = failures.slice(0, data.visible_failure_count);
|
|
903
|
+
const hiddenSuccessCount = successes.length - visibleSuccess.length;
|
|
904
|
+
const hiddenFailureCount = failures.length - visibleFailure.length;
|
|
905
|
+
|
|
906
|
+
// Map each displayed body to its per-body chain. The chain starts at
|
|
907
|
+
// the first Cap step's from_spec (the node immediately after the
|
|
908
|
+
// ForEach) and extends through either all body caps (success) or up to
|
|
909
|
+
// the failed_cap (failure).
|
|
910
|
+
const bodyCapSteps = [];
|
|
911
|
+
const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
|
|
912
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
913
|
+
for (let i = bodyStart; i < bodyEnd; i++) {
|
|
914
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
915
|
+
bodyCapSteps.push({ globalIndex: i, step: steps[i] });
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Replica node/edge ids are prefixed to avoid collision with backbone
|
|
920
|
+
// ids.
|
|
921
|
+
const replicaNodes = [];
|
|
922
|
+
const replicaEdges = [];
|
|
923
|
+
|
|
924
|
+
function buildBodyReplica(outcome, groupIndex) {
|
|
925
|
+
const success = outcome.success;
|
|
926
|
+
const successClass = success ? 'body-success' : 'body-failure';
|
|
927
|
+
const edgeClass = success ? 'body-success' : 'body-failure';
|
|
928
|
+
const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
|
|
929
|
+
|
|
930
|
+
// Find the trace end (failed_cap stops the trace for failed
|
|
931
|
+
// bodies). Comparison uses CapUrn.isEquivalent.
|
|
932
|
+
let traceEnd = bodyCapSteps.length;
|
|
933
|
+
if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
|
|
934
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
935
|
+
const target = CapUrn.fromString(outcome.failed_cap);
|
|
936
|
+
for (let i = 0; i < bodyCapSteps.length; i++) {
|
|
937
|
+
const candidate = CapUrn.fromString(bodyCapSteps[i].step.step_type.Cap.cap_urn);
|
|
938
|
+
if (candidate.isEquivalent(target)) {
|
|
939
|
+
traceEnd = i + 1;
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
if (traceEnd === 0) return; // Nothing to render for this body.
|
|
945
|
+
|
|
946
|
+
// Anchor to the first body cap's from_spec, which equals the
|
|
947
|
+
// ForEach's from_spec (fan-out from the same source node).
|
|
948
|
+
const anchorCanonical = canonicalMediaUrn(bodyCapSteps[0].step.from_spec);
|
|
949
|
+
let prevBodyNodeId = anchorCanonical;
|
|
950
|
+
const bodyKey = `body-${outcome.body_index}`;
|
|
951
|
+
const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
|
|
952
|
+
? outcome.title
|
|
953
|
+
: `body ${outcome.body_index}`;
|
|
954
|
+
|
|
955
|
+
for (let i = 0; i < traceEnd; i++) {
|
|
956
|
+
const body = bodyCapSteps[i].step.step_type.Cap;
|
|
957
|
+
const targetCanonical = canonicalMediaUrn(bodyCapSteps[i].step.to_spec);
|
|
958
|
+
const replicaNodeId = `${bodyKey}-n-${i}`;
|
|
959
|
+
replicaNodes.push({
|
|
960
|
+
group: 'nodes',
|
|
961
|
+
data: {
|
|
962
|
+
id: replicaNodeId,
|
|
963
|
+
label: mediaNodeLabel(targetCanonical),
|
|
964
|
+
fullUrn: targetCanonical,
|
|
965
|
+
bodyIndex: outcome.body_index,
|
|
966
|
+
bodyTitle: titleLabel,
|
|
967
|
+
},
|
|
968
|
+
classes: successClass,
|
|
969
|
+
});
|
|
970
|
+
replicaEdges.push({
|
|
971
|
+
group: 'edges',
|
|
972
|
+
data: {
|
|
973
|
+
id: `${bodyKey}-e-${i}`,
|
|
974
|
+
source: prevBodyNodeId,
|
|
975
|
+
target: replicaNodeId,
|
|
976
|
+
label: '',
|
|
977
|
+
title: body.title,
|
|
978
|
+
fullUrn: body.cap_urn,
|
|
979
|
+
color: `var(${colorVar})`,
|
|
980
|
+
bodyIndex: outcome.body_index,
|
|
981
|
+
},
|
|
982
|
+
classes: edgeClass,
|
|
983
|
+
});
|
|
984
|
+
prevBodyNodeId = replicaNodeId;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// If the collect exists and the body succeeded, attach the replica
|
|
988
|
+
// tail to the collect's to_spec node so the graph visibly fans in.
|
|
989
|
+
if (success && collectStepIdx >= 0) {
|
|
990
|
+
const collectTo = canonicalMediaUrn(steps[collectStepIdx].to_spec);
|
|
991
|
+
replicaEdges.push({
|
|
992
|
+
group: 'edges',
|
|
993
|
+
data: {
|
|
994
|
+
id: `${bodyKey}-merge`,
|
|
995
|
+
source: prevBodyNodeId,
|
|
996
|
+
target: collectTo,
|
|
997
|
+
label: '',
|
|
998
|
+
title: 'collect',
|
|
999
|
+
fullUrn: '',
|
|
1000
|
+
color: `var(${colorVar})`,
|
|
1001
|
+
bodyIndex: outcome.body_index,
|
|
1002
|
+
},
|
|
1003
|
+
classes: edgeClass,
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
visibleSuccess.forEach((o, i) => buildBodyReplica(o, i));
|
|
1009
|
+
visibleFailure.forEach((o, i) => buildBodyReplica(o, i));
|
|
1010
|
+
|
|
1011
|
+
// Build success and failure "show more" nodes when there are hidden
|
|
1012
|
+
// outcomes. Anchored at the same ForEach fan-out source.
|
|
1013
|
+
const showMoreNodes = [];
|
|
1014
|
+
if (hasForeach && bodyCapSteps.length > 0) {
|
|
1015
|
+
const anchorCanonical = canonicalMediaUrn(bodyCapSteps[0].step.from_spec);
|
|
1016
|
+
if (hiddenSuccessCount > 0) {
|
|
1017
|
+
const nodeId = 'show-more-success';
|
|
1018
|
+
showMoreNodes.push({
|
|
1019
|
+
group: 'nodes',
|
|
1020
|
+
data: {
|
|
1021
|
+
id: nodeId,
|
|
1022
|
+
label: `+${hiddenSuccessCount} more succeeded`,
|
|
1023
|
+
fullUrn: '',
|
|
1024
|
+
showMoreGroup: 'success',
|
|
1025
|
+
hiddenCount: hiddenSuccessCount,
|
|
1026
|
+
},
|
|
1027
|
+
classes: 'show-more body-success',
|
|
1028
|
+
});
|
|
1029
|
+
replicaEdges.push({
|
|
1030
|
+
group: 'edges',
|
|
1031
|
+
data: {
|
|
1032
|
+
id: 'show-more-success-edge',
|
|
1033
|
+
source: anchorCanonical,
|
|
1034
|
+
target: nodeId,
|
|
1035
|
+
label: '',
|
|
1036
|
+
title: '',
|
|
1037
|
+
fullUrn: '',
|
|
1038
|
+
color: 'var(--graph-body-edge-success)',
|
|
1039
|
+
},
|
|
1040
|
+
classes: 'body-success',
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
if (hiddenFailureCount > 0) {
|
|
1044
|
+
const nodeId = 'show-more-failure';
|
|
1045
|
+
showMoreNodes.push({
|
|
1046
|
+
group: 'nodes',
|
|
1047
|
+
data: {
|
|
1048
|
+
id: nodeId,
|
|
1049
|
+
label: `+${hiddenFailureCount} failed`,
|
|
1050
|
+
fullUrn: '',
|
|
1051
|
+
showMoreGroup: 'failure',
|
|
1052
|
+
hiddenCount: hiddenFailureCount,
|
|
1053
|
+
},
|
|
1054
|
+
classes: 'show-more body-failure',
|
|
1055
|
+
});
|
|
1056
|
+
replicaEdges.push({
|
|
1057
|
+
group: 'edges',
|
|
1058
|
+
data: {
|
|
1059
|
+
id: 'show-more-failure-edge',
|
|
1060
|
+
source: anchorCanonical,
|
|
1061
|
+
target: nodeId,
|
|
1062
|
+
label: '',
|
|
1063
|
+
title: '',
|
|
1064
|
+
fullUrn: '',
|
|
1065
|
+
color: 'var(--graph-body-edge-failure)',
|
|
1066
|
+
},
|
|
1067
|
+
classes: 'body-failure',
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
strandBuilt,
|
|
1074
|
+
replicaNodes,
|
|
1075
|
+
replicaEdges,
|
|
1076
|
+
showMoreNodes,
|
|
1077
|
+
totals: {
|
|
1078
|
+
hiddenSuccessCount,
|
|
1079
|
+
hiddenFailureCount,
|
|
1080
|
+
totalBodyCount: data.total_body_count,
|
|
1081
|
+
visibleSuccessCount: visibleSuccess.length,
|
|
1082
|
+
visibleFailureCount: visibleFailure.length,
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function runCytoscapeElements(built) {
|
|
1088
|
+
const strandElements = strandCytoscapeElements(built.strandBuilt);
|
|
1089
|
+
return strandElements
|
|
1090
|
+
.concat(built.replicaNodes)
|
|
1091
|
+
.concat(built.showMoreNodes)
|
|
1092
|
+
.concat(built.replicaEdges);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// --------- Machine mode builder ---------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
function buildMachineGraphData(data) {
|
|
1098
|
+
validateMachinePayload(data);
|
|
1099
|
+
const nodes = [];
|
|
1100
|
+
const edges = [];
|
|
1101
|
+
let capEdgeIdx = 0;
|
|
1102
|
+
for (const el of data.elements) {
|
|
1103
|
+
if (el.kind === 'node') {
|
|
1104
|
+
nodes.push({
|
|
1105
|
+
group: 'nodes',
|
|
1106
|
+
data: {
|
|
1107
|
+
id: el.graph_id,
|
|
1108
|
+
label: el.label || '',
|
|
1109
|
+
fullUrn: el.detail || el.label || '',
|
|
1110
|
+
tokenId: el.token_id || '',
|
|
1111
|
+
kind: 'node',
|
|
1112
|
+
},
|
|
1113
|
+
classes: 'machine-node',
|
|
1114
|
+
});
|
|
1115
|
+
} else if (el.kind === 'cap') {
|
|
1116
|
+
nodes.push({
|
|
1117
|
+
group: 'nodes',
|
|
1118
|
+
data: {
|
|
1119
|
+
id: el.graph_id,
|
|
1120
|
+
label: el.label || '',
|
|
1121
|
+
fullUrn: el.detail || el.label || '',
|
|
1122
|
+
tokenId: el.token_id || '',
|
|
1123
|
+
kind: 'cap',
|
|
1124
|
+
},
|
|
1125
|
+
classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
|
|
1126
|
+
});
|
|
1127
|
+
} else if (el.kind === 'edge') {
|
|
1128
|
+
edges.push({
|
|
1129
|
+
group: 'edges',
|
|
1130
|
+
data: {
|
|
1131
|
+
id: el.graph_id,
|
|
1132
|
+
source: el.source_graph_id,
|
|
1133
|
+
target: el.target_graph_id,
|
|
1134
|
+
label: el.label || '',
|
|
1135
|
+
title: el.label || '',
|
|
1136
|
+
fullUrn: el.detail || '',
|
|
1137
|
+
tokenId: el.token_id || '',
|
|
1138
|
+
color: el.is_loop
|
|
1139
|
+
? 'var(--graph-node-border-highlighted)'
|
|
1140
|
+
: edgeHueColor(capEdgeIdx),
|
|
1141
|
+
},
|
|
1142
|
+
classes: 'machine-edge' + (el.is_loop ? ' machine-loop' : ''),
|
|
1143
|
+
});
|
|
1144
|
+
capEdgeIdx++;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
return { nodes, edges };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function machineCytoscapeElements(built) {
|
|
1151
|
+
return built.nodes.concat(built.edges);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// A cheap signature for machine-mode inputs. The editor streams updates
|
|
1155
|
+
// on every keystroke; we skip the expensive rebuild when the element
|
|
1156
|
+
// shape is unchanged.
|
|
1157
|
+
function machineGraphSignature(data) {
|
|
1158
|
+
if (!data || !Array.isArray(data.elements)) return '';
|
|
1159
|
+
const parts = [];
|
|
1160
|
+
for (const el of data.elements) {
|
|
1161
|
+
parts.push(`${el.kind}|${el.graph_id}|${el.token_id || ''}|${el.label || ''}|${el.source_graph_id || ''}|${el.target_graph_id || ''}|${el.is_loop ? '1' : '0'}`);
|
|
1162
|
+
}
|
|
1163
|
+
return parts.join(';');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// =============================================================================
|
|
1167
|
+
// Renderer class.
|
|
1168
|
+
// =============================================================================
|
|
1169
|
+
|
|
1170
|
+
class CapGraphRenderer {
|
|
1171
|
+
constructor(containerOrId, options) {
|
|
1172
|
+
if (options === undefined || options === null) {
|
|
1173
|
+
throw new Error('CapGraphRenderer: options object is required');
|
|
1174
|
+
}
|
|
1175
|
+
if (typeof options !== 'object') {
|
|
1176
|
+
throw new Error('CapGraphRenderer: options must be an object');
|
|
1177
|
+
}
|
|
1178
|
+
const mode = options.mode;
|
|
1179
|
+
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
|
|
1180
|
+
throw new Error(
|
|
1181
|
+
`CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine" (got ${JSON.stringify(mode)})`
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Resolve cytoscape. cytoscape-elk auto-registers when loaded via
|
|
1186
|
+
// script tag; we verify by checking the elk layout's presence.
|
|
1187
|
+
const cytoscape = requireHostDependency('cytoscape');
|
|
1188
|
+
if (!cytoscape.__elkRegistered) {
|
|
1189
|
+
// Some build bundles register the extension at load, others need
|
|
1190
|
+
// an explicit `cytoscape.use(...)`. We do it once per cytoscape
|
|
1191
|
+
// instance — the extension itself is a guarded no-op on repeat.
|
|
1192
|
+
const elkExt = (typeof window !== 'undefined') ? window.cytoscapeElk
|
|
1193
|
+
: (typeof global !== 'undefined') ? global.cytoscapeElk
|
|
1194
|
+
: undefined;
|
|
1195
|
+
if (elkExt !== undefined) {
|
|
1196
|
+
cytoscape.use(elkExt);
|
|
1197
|
+
}
|
|
1198
|
+
cytoscape.__elkRegistered = true;
|
|
1199
|
+
}
|
|
1200
|
+
this._cytoscape = cytoscape;
|
|
1201
|
+
|
|
1202
|
+
let container;
|
|
1203
|
+
if (typeof containerOrId === 'string') {
|
|
1204
|
+
container = document.getElementById(containerOrId);
|
|
1205
|
+
if (!container) {
|
|
1206
|
+
throw new Error(`CapGraphRenderer: container element '${containerOrId}' not found`);
|
|
1207
|
+
}
|
|
1208
|
+
} else if (containerOrId instanceof Element) {
|
|
1209
|
+
container = containerOrId;
|
|
1210
|
+
} else {
|
|
1211
|
+
throw new Error('CapGraphRenderer: first argument must be a container id string or an Element');
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
this.container = container;
|
|
1215
|
+
this.mode = mode;
|
|
1216
|
+
this.interaction = options.interaction && typeof options.interaction === 'object'
|
|
1217
|
+
? options.interaction
|
|
1218
|
+
: {};
|
|
1219
|
+
this.bottomExcludedRegion = typeof options.bottomExcludedRegion === 'function'
|
|
1220
|
+
? options.bottomExcludedRegion
|
|
1221
|
+
: () => 0;
|
|
1222
|
+
|
|
1223
|
+
// State — shared fields.
|
|
1224
|
+
this.cy = null;
|
|
1225
|
+
this.selectedElement = null;
|
|
1226
|
+
this._layoutReady = false;
|
|
1227
|
+
this.tooltip = createTooltipElement();
|
|
1228
|
+
|
|
1229
|
+
// Browse-mode state.
|
|
1230
|
+
this.navigator = null;
|
|
1231
|
+
this.nodes = [];
|
|
1232
|
+
this.edges = [];
|
|
1233
|
+
this.adjacency = new Map();
|
|
1234
|
+
this.reverseAdj = new Map();
|
|
1235
|
+
this.capGraph = null;
|
|
1236
|
+
this.capabilitiesByEdgeId = new Map();
|
|
1237
|
+
this._mediaTitles = new Map();
|
|
1238
|
+
this._pendingFocusCap = null;
|
|
1239
|
+
this.pathMode = null;
|
|
1240
|
+
|
|
1241
|
+
// Strand/run state.
|
|
1242
|
+
this._strandBuilt = null;
|
|
1243
|
+
this._runBuilt = null;
|
|
1244
|
+
|
|
1245
|
+
// Machine state.
|
|
1246
|
+
this._machineSignature = null;
|
|
1247
|
+
this._machineBuilt = null;
|
|
1248
|
+
|
|
1249
|
+
// Theme observer.
|
|
1250
|
+
this.themeObserver = new MutationObserver((mutations) => {
|
|
1251
|
+
for (const mutation of mutations) {
|
|
1252
|
+
if (mutation.attributeName === 'data-theme') {
|
|
1253
|
+
if (this.cy) this.cy.style(buildStylesheet());
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
this.themeObserver.observe(document.documentElement, {
|
|
1258
|
+
attributes: true,
|
|
1259
|
+
attributeFilter: ['data-theme'],
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// ===========================================================================
|
|
1264
|
+
// Navigator bridge — browse mode only.
|
|
1265
|
+
// ===========================================================================
|
|
1266
|
+
|
|
1267
|
+
setNavigator(navigator) {
|
|
1268
|
+
if (this.mode !== 'browse') {
|
|
1269
|
+
throw new Error(`CapGraphRenderer: setNavigator is only valid in browse mode (current: ${this.mode})`);
|
|
1270
|
+
}
|
|
1271
|
+
this.navigator = navigator;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ===========================================================================
|
|
1275
|
+
// Data entry points.
|
|
1276
|
+
// ===========================================================================
|
|
1277
|
+
|
|
1278
|
+
setData(data) {
|
|
1279
|
+
if (this.mode === 'browse') {
|
|
1280
|
+
const built = buildBrowseGraphData(data);
|
|
1281
|
+
this.nodes = built.nodes;
|
|
1282
|
+
this.edges = built.edges;
|
|
1283
|
+
this.adjacency = built.adjacency;
|
|
1284
|
+
this.reverseAdj = built.reverseAdj;
|
|
1285
|
+
this.capGraph = built.capGraph;
|
|
1286
|
+
this._mediaTitles = built.mediaTitles;
|
|
1287
|
+
this.capabilitiesByEdgeId = built.capabilitiesByEdgeId;
|
|
1288
|
+
return this;
|
|
1289
|
+
}
|
|
1290
|
+
if (this.mode === 'strand') {
|
|
1291
|
+
this._strandBuilt = buildStrandGraphData(data);
|
|
1292
|
+
return this;
|
|
1293
|
+
}
|
|
1294
|
+
if (this.mode === 'run') {
|
|
1295
|
+
this._runBuilt = buildRunGraphData(data);
|
|
1296
|
+
return this;
|
|
1297
|
+
}
|
|
1298
|
+
if (this.mode === 'machine') {
|
|
1299
|
+
const signature = machineGraphSignature(data);
|
|
1300
|
+
if (signature === this._machineSignature && this.cy) {
|
|
1301
|
+
// Same shape — restyle for theme changes and return.
|
|
1302
|
+
this.cy.style(buildStylesheet());
|
|
1303
|
+
return this;
|
|
1304
|
+
}
|
|
1305
|
+
this._machineSignature = signature;
|
|
1306
|
+
this._machineBuilt = buildMachineGraphData(data);
|
|
1307
|
+
return this;
|
|
1308
|
+
}
|
|
1309
|
+
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Compatibility shim for capdag-dot-com browse callers: `buildFromCapabilities`
|
|
1313
|
+
// is an explicit name that reads clearly at call sites like `graph.buildFromCapabilities(registry)`.
|
|
1314
|
+
buildFromCapabilities(capabilities) {
|
|
1315
|
+
if (this.mode !== 'browse') {
|
|
1316
|
+
throw new Error(
|
|
1317
|
+
`CapGraphRenderer: buildFromCapabilities is only valid in browse mode (current: ${this.mode})`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
return this.setData(capabilities);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ===========================================================================
|
|
1324
|
+
// Render — creates (or recreates) the cytoscape instance.
|
|
1325
|
+
// ===========================================================================
|
|
1326
|
+
|
|
1327
|
+
render() {
|
|
1328
|
+
if (!this.container) {
|
|
1329
|
+
throw new Error('CapGraphRenderer: container is missing');
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const elements = this._buildCytoscapeElements();
|
|
1333
|
+
if (elements.length === 0) {
|
|
1334
|
+
this.container.innerHTML = '<div class="cap-graph-empty"><p>No graph data</p></div>';
|
|
1335
|
+
return this;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Clear container and size it to the window.
|
|
1339
|
+
this.container.innerHTML = '';
|
|
1340
|
+
this.container.style.width = window.innerWidth + 'px';
|
|
1341
|
+
this.container.style.height = window.innerHeight + 'px';
|
|
1342
|
+
|
|
1343
|
+
const self = this;
|
|
1344
|
+
this._layoutReady = false;
|
|
1345
|
+
|
|
1346
|
+
if (this.cy) {
|
|
1347
|
+
this.cy.destroy();
|
|
1348
|
+
this.cy = null;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
this.cy = this._cytoscape({
|
|
1352
|
+
container: this.container,
|
|
1353
|
+
elements,
|
|
1354
|
+
layout: Object.assign(
|
|
1355
|
+
{ name: 'elk', elk: layoutForMode(this.mode) },
|
|
1356
|
+
{
|
|
1357
|
+
stop: function () {
|
|
1358
|
+
self.cy.resize();
|
|
1359
|
+
self._layoutReady = true;
|
|
1360
|
+
if (self._pendingFocusCap) {
|
|
1361
|
+
const pending = self._pendingFocusCap;
|
|
1362
|
+
self._pendingFocusCap = null;
|
|
1363
|
+
self.highlightCapability(pending);
|
|
1364
|
+
}
|
|
1365
|
+
self.refitCurrentSelection();
|
|
1366
|
+
},
|
|
1367
|
+
}
|
|
1368
|
+
),
|
|
1369
|
+
style: buildStylesheet(),
|
|
1370
|
+
minZoom: 0.05,
|
|
1371
|
+
maxZoom: 10,
|
|
1372
|
+
wheelSensitivity: 0.3,
|
|
1373
|
+
boxSelectionEnabled: false,
|
|
1374
|
+
autounselectify: this.mode === 'machine',
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
const resizeAndRefit = () => {
|
|
1378
|
+
if (!this.cy) return;
|
|
1379
|
+
this.cy.resize();
|
|
1380
|
+
this.refitCurrentSelection();
|
|
1381
|
+
};
|
|
1382
|
+
this.cy.on('ready', resizeAndRefit);
|
|
1383
|
+
requestAnimationFrame(resizeAndRefit);
|
|
1384
|
+
setTimeout(resizeAndRefit, 100);
|
|
1385
|
+
setTimeout(resizeAndRefit, 300);
|
|
1386
|
+
|
|
1387
|
+
this._setupEventHandlers();
|
|
1388
|
+
return this;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
_buildCytoscapeElements() {
|
|
1392
|
+
if (this.mode === 'browse') {
|
|
1393
|
+
return browseCytoscapeElements({
|
|
1394
|
+
nodes: this.nodes,
|
|
1395
|
+
edges: this.edges,
|
|
1396
|
+
mediaTitles: this._mediaTitles,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
if (this.mode === 'strand') {
|
|
1400
|
+
if (!this._strandBuilt) return [];
|
|
1401
|
+
return strandCytoscapeElements(this._strandBuilt);
|
|
1402
|
+
}
|
|
1403
|
+
if (this.mode === 'run') {
|
|
1404
|
+
if (!this._runBuilt) return [];
|
|
1405
|
+
return runCytoscapeElements(this._runBuilt);
|
|
1406
|
+
}
|
|
1407
|
+
if (this.mode === 'machine') {
|
|
1408
|
+
if (!this._machineBuilt) return [];
|
|
1409
|
+
return machineCytoscapeElements(this._machineBuilt);
|
|
1410
|
+
}
|
|
1411
|
+
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// ===========================================================================
|
|
1415
|
+
// Event handlers. All modes share mouse handling; browse mode adds the
|
|
1416
|
+
// navigator bridge, run mode adds show-more click handling, machine mode
|
|
1417
|
+
// fires interaction callbacks with the element's tokenId for editor
|
|
1418
|
+
// cross-highlight.
|
|
1419
|
+
// ===========================================================================
|
|
1420
|
+
|
|
1421
|
+
_setupEventHandlers() {
|
|
1422
|
+
const self = this;
|
|
1423
|
+
|
|
1424
|
+
this.cy.on('tap', 'node', function (evt) {
|
|
1425
|
+
evt.stopPropagation();
|
|
1426
|
+
self._handleNodeTap(evt.target);
|
|
1427
|
+
});
|
|
1428
|
+
this.cy.on('tap', 'edge', function (evt) {
|
|
1429
|
+
evt.stopPropagation();
|
|
1430
|
+
self._handleEdgeTap(evt.target);
|
|
1431
|
+
});
|
|
1432
|
+
this.cy.on('tap', function (evt) {
|
|
1433
|
+
if (evt.target === self.cy) self.clearSelection();
|
|
1434
|
+
});
|
|
1435
|
+
this.cy.on('dbltap', function (evt) {
|
|
1436
|
+
if (evt.target === self.cy) {
|
|
1437
|
+
self.clearSelection();
|
|
1438
|
+
self.fitToVisibleViewport(undefined, 50);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
this.cy.on('mouseover', 'node', function (evt) {
|
|
1443
|
+
const node = evt.target;
|
|
1444
|
+
self._showTooltip(self._tooltipTextForNode(node), evt.originalEvent);
|
|
1445
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1446
|
+
self._highlightConnected(node.id());
|
|
1447
|
+
}
|
|
1448
|
+
if (typeof self.interaction.onNodeHover === 'function') {
|
|
1449
|
+
self.interaction.onNodeHover(node.data());
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
this.cy.on('mousemove', 'node', function (evt) {
|
|
1453
|
+
const node = evt.target;
|
|
1454
|
+
self._showTooltip(self._tooltipTextForNode(node), evt.originalEvent);
|
|
1455
|
+
});
|
|
1456
|
+
this.cy.on('mouseout', 'node', function () {
|
|
1457
|
+
self._hideTooltip();
|
|
1458
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1459
|
+
self._clearHighlighting();
|
|
1460
|
+
}
|
|
1461
|
+
if (typeof self.interaction.onNodeHoverEnd === 'function') {
|
|
1462
|
+
self.interaction.onNodeHoverEnd();
|
|
1463
|
+
}
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
this.cy.on('mouseover', 'edge', function (evt) {
|
|
1467
|
+
const edge = evt.target;
|
|
1468
|
+
self._showTooltip(self._tooltipTextForEdge(edge), evt.originalEvent);
|
|
1469
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1470
|
+
self._highlightEdge(edge);
|
|
1471
|
+
}
|
|
1472
|
+
if (typeof self.interaction.onEdgeHover === 'function') {
|
|
1473
|
+
self.interaction.onEdgeHover(edge.data());
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
this.cy.on('mousemove', 'edge', function (evt) {
|
|
1477
|
+
const edge = evt.target;
|
|
1478
|
+
self._showTooltip(self._tooltipTextForEdge(edge), evt.originalEvent);
|
|
1479
|
+
});
|
|
1480
|
+
this.cy.on('mouseout', 'edge', function () {
|
|
1481
|
+
self._hideTooltip();
|
|
1482
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1483
|
+
self._clearHighlighting();
|
|
1484
|
+
}
|
|
1485
|
+
if (typeof self.interaction.onEdgeHoverEnd === 'function') {
|
|
1486
|
+
self.interaction.onEdgeHoverEnd();
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
_tooltipTextForNode(node) {
|
|
1492
|
+
if (this.mode === 'run') {
|
|
1493
|
+
const bodyTitle = node.data('bodyTitle');
|
|
1494
|
+
if (bodyTitle) return `${bodyTitle}: ${node.data('fullUrn') || node.id()}`;
|
|
1495
|
+
}
|
|
1496
|
+
return node.data('fullUrn') || node.id();
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
_tooltipTextForEdge(edge) {
|
|
1500
|
+
const full = edge.data('fullUrn');
|
|
1501
|
+
if (typeof full === 'string' && full.length > 0) return full;
|
|
1502
|
+
return edge.data('title') || '';
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
_handleNodeTap(node) {
|
|
1506
|
+
// Show-more node in run mode: forward to host and return early.
|
|
1507
|
+
if (this.mode === 'run') {
|
|
1508
|
+
const group = node.data('showMoreGroup');
|
|
1509
|
+
if (group === 'success' || group === 'failure') {
|
|
1510
|
+
if (typeof this.interaction.onShowMoreBodies === 'function') {
|
|
1511
|
+
this.interaction.onShowMoreBodies(group);
|
|
1512
|
+
}
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
if (this.mode === 'browse') {
|
|
1518
|
+
// Second-click on a highlighted node while another is already
|
|
1519
|
+
// selected → enter path exploration.
|
|
1520
|
+
if (this.selectedElement && this.selectedElement.type === 'node' && !this.pathMode) {
|
|
1521
|
+
const source = this.selectedElement.element;
|
|
1522
|
+
if (!source.same(node) && node.hasClass('highlighted')) {
|
|
1523
|
+
this.enterPathMode(source.id(), node.id());
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
if (this.pathMode) this.exitPathMode();
|
|
1528
|
+
this.selectedElement = { type: 'node', element: node };
|
|
1529
|
+
this._highlightConnected(node.id());
|
|
1530
|
+
node.addClass('active');
|
|
1531
|
+
if (this.navigator) this.navigator.showNodeDetail(node.data());
|
|
1532
|
+
} else {
|
|
1533
|
+
this.selectedElement = { type: 'node', element: node };
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (typeof this.interaction.onNodeClick === 'function') {
|
|
1537
|
+
this.interaction.onNodeClick(node.data());
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
_handleEdgeTap(edge) {
|
|
1542
|
+
this.selectedElement = { type: 'edge', element: edge };
|
|
1543
|
+
if (this.mode === 'browse') {
|
|
1544
|
+
this._highlightEdge(edge);
|
|
1545
|
+
edge.addClass('active');
|
|
1546
|
+
const edgeId = edge.id();
|
|
1547
|
+
const capability = this.capabilitiesByEdgeId.get(edgeId) || null;
|
|
1548
|
+
if (this.navigator) this.navigator.showEdgeDetail(edge.data(), capability);
|
|
1549
|
+
}
|
|
1550
|
+
if (typeof this.interaction.onEdgeClick === 'function') {
|
|
1551
|
+
this.interaction.onEdgeClick(edge.data());
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// ===========================================================================
|
|
1556
|
+
// Browse-mode selection API. Used by cap-navigator.js via the
|
|
1557
|
+
// bidirectional setNavigator / setGraph wiring.
|
|
1558
|
+
// ===========================================================================
|
|
1559
|
+
|
|
1560
|
+
highlightCapability(cap) {
|
|
1561
|
+
if (this.mode !== 'browse') {
|
|
1562
|
+
throw new Error(`CapGraphRenderer: highlightCapability is only valid in browse mode (current: ${this.mode})`);
|
|
1563
|
+
}
|
|
1564
|
+
if (!this.cy || !this._layoutReady) {
|
|
1565
|
+
this._pendingFocusCap = cap;
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1570
|
+
const target = CapUrn.fromString(this._capUrnString(cap));
|
|
1571
|
+
|
|
1572
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1573
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1574
|
+
if (candidate.isEquivalent(target)) {
|
|
1575
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1576
|
+
if (edge && edge.length > 0) {
|
|
1577
|
+
this.selectedElement = { type: 'edge', element: edge };
|
|
1578
|
+
this._highlightEdge(edge);
|
|
1579
|
+
edge.addClass('active');
|
|
1580
|
+
}
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
_capUrnString(cap) {
|
|
1587
|
+
if (!cap || typeof cap !== 'object') {
|
|
1588
|
+
throw new Error('CapGraphRenderer: cap must be an object');
|
|
1589
|
+
}
|
|
1590
|
+
if (typeof cap.urn !== 'string' || cap.urn.length === 0) {
|
|
1591
|
+
throw new Error('CapGraphRenderer: cap.urn must be a non-empty string');
|
|
1592
|
+
}
|
|
1593
|
+
return cap.urn;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
selectNodeById(nodeId) {
|
|
1597
|
+
if (!this.cy) return;
|
|
1598
|
+
const node = this.cy.getElementById(nodeId);
|
|
1599
|
+
if (node && node.length > 0) this._handleNodeTap(node);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
getNodeData(nodeId) {
|
|
1603
|
+
if (!this.cy) return null;
|
|
1604
|
+
const node = this.cy.getElementById(nodeId);
|
|
1605
|
+
return node && node.length > 0 ? node.data() : null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
getEdgeDataByCapUrn(capUrnString) {
|
|
1609
|
+
if (this.mode !== 'browse') return null;
|
|
1610
|
+
if (!this.cy || typeof capUrnString !== 'string' || capUrnString.length === 0) return null;
|
|
1611
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1612
|
+
const target = CapUrn.fromString(capUrnString);
|
|
1613
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1614
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1615
|
+
if (candidate.isEquivalent(target)) {
|
|
1616
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1617
|
+
if (edge && edge.length > 0) return edge.data();
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
selectEdgeByCapUrn(capUrnString) {
|
|
1624
|
+
if (this.mode !== 'browse') {
|
|
1625
|
+
throw new Error(`CapGraphRenderer: selectEdgeByCapUrn is only valid in browse mode (current: ${this.mode})`);
|
|
1626
|
+
}
|
|
1627
|
+
if (!this.cy || typeof capUrnString !== 'string' || capUrnString.length === 0) return;
|
|
1628
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1629
|
+
const target = CapUrn.fromString(capUrnString);
|
|
1630
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1631
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1632
|
+
if (candidate.isEquivalent(target)) {
|
|
1633
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1634
|
+
if (edge && edge.length > 0) {
|
|
1635
|
+
this._handleEdgeTap(edge);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
clearSelection() {
|
|
1643
|
+
if (this.pathMode) this.exitPathMode();
|
|
1644
|
+
this.selectedElement = null;
|
|
1645
|
+
this._clearHighlighting();
|
|
1646
|
+
if (this.mode === 'browse' && this.navigator) {
|
|
1647
|
+
this.navigator.clearGraphSelection();
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
fitAll() {
|
|
1652
|
+
if (!this.cy) return;
|
|
1653
|
+
this.fitToVisibleViewport(this.cy.elements(), 50);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// ===========================================================================
|
|
1657
|
+
// Theme sync — the renderer owns a MutationObserver on <html
|
|
1658
|
+
// data-theme>, so hosts that use that attribute do not need to call
|
|
1659
|
+
// anything. Hosts that use a different attribute (e.g. the editor's
|
|
1660
|
+
// data-appearance) can call setTheme() explicitly after their own
|
|
1661
|
+
// theme toggle to force a stylesheet re-read.
|
|
1662
|
+
// ===========================================================================
|
|
1663
|
+
|
|
1664
|
+
setTheme() {
|
|
1665
|
+
if (!this.cy) return;
|
|
1666
|
+
this.cy.style(buildStylesheet());
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// ===========================================================================
|
|
1670
|
+
// Machine mode API — used by the editor for cross-highlight.
|
|
1671
|
+
// ===========================================================================
|
|
1672
|
+
|
|
1673
|
+
applyMachineActiveTokenIds(tokenIds) {
|
|
1674
|
+
if (this.mode !== 'machine') {
|
|
1675
|
+
throw new Error(`CapGraphRenderer: applyMachineActiveTokenIds is only valid in machine mode (current: ${this.mode})`);
|
|
1676
|
+
}
|
|
1677
|
+
if (!this.cy) return;
|
|
1678
|
+
const wanted = new Set(tokenIds || []);
|
|
1679
|
+
this.cy.batch(() => {
|
|
1680
|
+
this.cy.elements().forEach(el => {
|
|
1681
|
+
const id = el.data('tokenId');
|
|
1682
|
+
if (id && wanted.has(id)) {
|
|
1683
|
+
el.addClass('active');
|
|
1684
|
+
} else {
|
|
1685
|
+
el.removeClass('active');
|
|
1686
|
+
}
|
|
1687
|
+
});
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ===========================================================================
|
|
1692
|
+
// Path exploration (browse mode).
|
|
1693
|
+
// ===========================================================================
|
|
1694
|
+
|
|
1695
|
+
enterPathMode(sourceId, targetId) {
|
|
1696
|
+
if (this.mode !== 'browse') {
|
|
1697
|
+
throw new Error(`CapGraphRenderer: enterPathMode is only valid in browse mode (current: ${this.mode})`);
|
|
1698
|
+
}
|
|
1699
|
+
if (!this.capGraph) return;
|
|
1700
|
+
|
|
1701
|
+
const MAX_PATHS = 10;
|
|
1702
|
+
let paths = this.capGraph.findAllPaths(sourceId, targetId, MAX_PATHS);
|
|
1703
|
+
let actualSource = sourceId;
|
|
1704
|
+
let actualTarget = targetId;
|
|
1705
|
+
if (paths.length === 0) {
|
|
1706
|
+
const reverse = this.capGraph.findAllPaths(targetId, sourceId, MAX_PATHS);
|
|
1707
|
+
if (reverse.length === 0) return;
|
|
1708
|
+
paths = reverse;
|
|
1709
|
+
actualSource = targetId;
|
|
1710
|
+
actualTarget = sourceId;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
this.pathMode = { sourceId: actualSource, targetId: actualTarget, paths, selectedIndex: 0 };
|
|
1714
|
+
this.selectedElement = { type: 'path' };
|
|
1715
|
+
this._highlightPath(paths[0]);
|
|
1716
|
+
|
|
1717
|
+
if (this.navigator) {
|
|
1718
|
+
const sourceNode = this.cy.getElementById(actualSource);
|
|
1719
|
+
const targetNode = this.cy.getElementById(actualTarget);
|
|
1720
|
+
this.navigator.showPathDetail(
|
|
1721
|
+
sourceNode.length > 0 ? sourceNode.data() : { id: actualSource },
|
|
1722
|
+
targetNode.length > 0 ? targetNode.data() : { id: actualTarget },
|
|
1723
|
+
paths,
|
|
1724
|
+
0
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
selectPath(index) {
|
|
1730
|
+
if (!this.pathMode) return;
|
|
1731
|
+
if (index < 0 || index >= this.pathMode.paths.length) return;
|
|
1732
|
+
this.pathMode.selectedIndex = index;
|
|
1733
|
+
this._highlightPath(this.pathMode.paths[index]);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
exitPathMode() {
|
|
1737
|
+
this.pathMode = null;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
_highlightPath(pathEdges) {
|
|
1741
|
+
const pathNodeIds = new Set();
|
|
1742
|
+
const pathEdgeIndices = new Set();
|
|
1743
|
+
for (const pathEdge of pathEdges) {
|
|
1744
|
+
pathNodeIds.add(canonicalMediaUrn(pathEdge.fromUrn));
|
|
1745
|
+
pathNodeIds.add(canonicalMediaUrn(pathEdge.toUrn));
|
|
1746
|
+
const idx = this.capGraph.edges.indexOf(pathEdge);
|
|
1747
|
+
if (idx !== -1) pathEdgeIndices.add(idx);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
this.cy.elements().removeClass('highlighted active faded path-highlighted');
|
|
1751
|
+
this.cy.elements().addClass('faded');
|
|
1752
|
+
|
|
1753
|
+
this.cy.nodes().forEach(node => {
|
|
1754
|
+
if (pathNodeIds.has(node.id())) node.removeClass('faded').addClass('path-highlighted');
|
|
1755
|
+
});
|
|
1756
|
+
this.cy.edges().forEach(edge => {
|
|
1757
|
+
const cyIdx = edge.data('capGraphEdgeIndex');
|
|
1758
|
+
if (cyIdx !== undefined && pathEdgeIndices.has(cyIdx)) {
|
|
1759
|
+
edge.removeClass('faded').addClass('path-highlighted');
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
if (this.pathMode) {
|
|
1764
|
+
const source = this.cy.getElementById(this.pathMode.sourceId);
|
|
1765
|
+
const target = this.cy.getElementById(this.pathMode.targetId);
|
|
1766
|
+
if (source.length > 0) source.addClass('active');
|
|
1767
|
+
if (target.length > 0) target.addClass('active');
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// ===========================================================================
|
|
1772
|
+
// Highlight helpers.
|
|
1773
|
+
// ===========================================================================
|
|
1774
|
+
|
|
1775
|
+
_hasActiveSelection() {
|
|
1776
|
+
return this.selectedElement !== null;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
_highlightEdge(edge) {
|
|
1780
|
+
this.cy.elements().removeClass('highlighted active faded');
|
|
1781
|
+
this.cy.elements().addClass('faded');
|
|
1782
|
+
edge.removeClass('faded').addClass('highlighted');
|
|
1783
|
+
const src = edge.source();
|
|
1784
|
+
const tgt = edge.target();
|
|
1785
|
+
src.removeClass('faded').addClass('highlighted');
|
|
1786
|
+
tgt.removeClass('faded').addClass('highlighted');
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
_highlightConnected(nodeId) {
|
|
1790
|
+
const connected = this._findConnected(nodeId);
|
|
1791
|
+
this.cy.elements().removeClass('highlighted active faded');
|
|
1792
|
+
this.cy.elements().addClass('faded');
|
|
1793
|
+
this.cy.nodes().forEach(node => {
|
|
1794
|
+
if (connected.has(node.id())) node.removeClass('faded').addClass('highlighted');
|
|
1795
|
+
});
|
|
1796
|
+
this.cy.edges().forEach(edge => {
|
|
1797
|
+
const s = edge.source().id();
|
|
1798
|
+
const t = edge.target().id();
|
|
1799
|
+
if (connected.has(s) && connected.has(t)) edge.removeClass('faded').addClass('highlighted');
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
_clearHighlighting() {
|
|
1804
|
+
if (!this.cy) return;
|
|
1805
|
+
this.cy.elements().removeClass('highlighted active faded path-highlighted');
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
_findReachableFrom(startId) {
|
|
1809
|
+
const reachable = new Set([startId]);
|
|
1810
|
+
const queue = [startId];
|
|
1811
|
+
while (queue.length > 0) {
|
|
1812
|
+
const current = queue.shift();
|
|
1813
|
+
const neighbors = this.adjacency.get(current);
|
|
1814
|
+
if (!neighbors) continue;
|
|
1815
|
+
for (const n of neighbors) {
|
|
1816
|
+
if (!reachable.has(n)) {
|
|
1817
|
+
reachable.add(n);
|
|
1818
|
+
queue.push(n);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return reachable;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
_findReachableTo(targetId) {
|
|
1826
|
+
const canReach = new Set([targetId]);
|
|
1827
|
+
const queue = [targetId];
|
|
1828
|
+
while (queue.length > 0) {
|
|
1829
|
+
const current = queue.shift();
|
|
1830
|
+
const preds = this.reverseAdj.get(current);
|
|
1831
|
+
if (!preds) continue;
|
|
1832
|
+
for (const p of preds) {
|
|
1833
|
+
if (!canReach.has(p)) {
|
|
1834
|
+
canReach.add(p);
|
|
1835
|
+
queue.push(p);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
return canReach;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
_findConnected(nodeId) {
|
|
1843
|
+
const from = this._findReachableFrom(nodeId);
|
|
1844
|
+
const to = this._findReachableTo(nodeId);
|
|
1845
|
+
return new Set([...from, ...to]);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// ===========================================================================
|
|
1849
|
+
// Viewport fit. Single entry point for all "re-fit" callers: layout
|
|
1850
|
+
// stop, resize, navigator-driven refit, dbltap-reset.
|
|
1851
|
+
// ===========================================================================
|
|
1852
|
+
|
|
1853
|
+
refitCurrentSelection() {
|
|
1854
|
+
if (!this.cy || !this._layoutReady) return;
|
|
1855
|
+
|
|
1856
|
+
if (this.pathMode) {
|
|
1857
|
+
const pathElements = this.cy.elements('.path-highlighted, .active');
|
|
1858
|
+
if (pathElements.length > 0) {
|
|
1859
|
+
this.fitToVisibleViewport(pathElements, 60);
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
if (this.selectedElement) {
|
|
1865
|
+
if (this.selectedElement.type === 'node') {
|
|
1866
|
+
const nodeId = this.selectedElement.element.id();
|
|
1867
|
+
if (this.mode === 'browse') {
|
|
1868
|
+
const connected = this._findConnected(nodeId);
|
|
1869
|
+
const connectedElements = this.cy.nodes().filter(n => connected.has(n.id()));
|
|
1870
|
+
if (connectedElements.length > 0) {
|
|
1871
|
+
this.fitToVisibleViewport(connectedElements, 60);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
} else {
|
|
1875
|
+
this.fitToVisibleViewport(this.selectedElement.element, 80);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
} else if (this.selectedElement.type === 'edge') {
|
|
1879
|
+
const edge = this.selectedElement.element;
|
|
1880
|
+
this.fitToVisibleViewport(edge.union(edge.source()).union(edge.target()), 100);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
this.fitToVisibleViewport(undefined, 50);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
fitToVisibleViewport(eles, padding, animate) {
|
|
1889
|
+
if (!this.cy) return;
|
|
1890
|
+
if (padding === undefined) padding = 50;
|
|
1891
|
+
if (animate === undefined) animate = true;
|
|
1892
|
+
|
|
1893
|
+
this.cy.stop(true);
|
|
1894
|
+
if (!eles || eles.length === 0) eles = this.cy.elements();
|
|
1895
|
+
|
|
1896
|
+
const bb = eles.boundingBox();
|
|
1897
|
+
if (bb.w === 0 && bb.h === 0) return;
|
|
1898
|
+
|
|
1899
|
+
const containerWidth = this.cy.width();
|
|
1900
|
+
const containerHeight = this.cy.height();
|
|
1901
|
+
const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
|
|
1902
|
+
|
|
1903
|
+
const visibleWidth = containerWidth - padding * 2;
|
|
1904
|
+
const visibleHeight = containerHeight - excluded - padding * 2;
|
|
1905
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) return;
|
|
1906
|
+
|
|
1907
|
+
const zoom = Math.min(visibleWidth / bb.w, visibleHeight / bb.h);
|
|
1908
|
+
const clampedZoom = Math.min(Math.max(zoom, this.cy.minZoom()), this.cy.maxZoom());
|
|
1909
|
+
|
|
1910
|
+
const modelCenterX = (bb.x1 + bb.x2) / 2;
|
|
1911
|
+
const modelCenterY = (bb.y1 + bb.y2) / 2;
|
|
1912
|
+
const screenCenterX = containerWidth / 2;
|
|
1913
|
+
const screenCenterY = (containerHeight - excluded) / 2;
|
|
1914
|
+
const panX = screenCenterX - modelCenterX * clampedZoom;
|
|
1915
|
+
const panY = screenCenterY - modelCenterY * clampedZoom;
|
|
1916
|
+
|
|
1917
|
+
if (animate) {
|
|
1918
|
+
this.cy.animate({
|
|
1919
|
+
zoom: clampedZoom,
|
|
1920
|
+
pan: { x: panX, y: panY },
|
|
1921
|
+
duration: 400,
|
|
1922
|
+
easing: 'ease-out-cubic',
|
|
1923
|
+
});
|
|
1924
|
+
} else {
|
|
1925
|
+
this.cy.zoom(clampedZoom);
|
|
1926
|
+
this.cy.pan({ x: panX, y: panY });
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// ===========================================================================
|
|
1931
|
+
// Tooltip helpers.
|
|
1932
|
+
// ===========================================================================
|
|
1933
|
+
|
|
1934
|
+
_showTooltip(text, mouseEvent) {
|
|
1935
|
+
if (!this.tooltip) return;
|
|
1936
|
+
if (!text) return;
|
|
1937
|
+
this.tooltip.textContent = text;
|
|
1938
|
+
this.tooltip.style.display = 'block';
|
|
1939
|
+
const x = mouseEvent ? mouseEvent.clientX : 0;
|
|
1940
|
+
const y = mouseEvent ? mouseEvent.clientY : 0;
|
|
1941
|
+
this.tooltip.style.left = (x + 12) + 'px';
|
|
1942
|
+
this.tooltip.style.top = (y + 12) + 'px';
|
|
1943
|
+
const rect = this.tooltip.getBoundingClientRect();
|
|
1944
|
+
if (rect.right > window.innerWidth) {
|
|
1945
|
+
this.tooltip.style.left = (x - rect.width - 12) + 'px';
|
|
1946
|
+
}
|
|
1947
|
+
if (rect.bottom > window.innerHeight) {
|
|
1948
|
+
this.tooltip.style.top = (y - rect.height - 12) + 'px';
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
_hideTooltip() {
|
|
1953
|
+
if (this.tooltip) this.tooltip.style.display = 'none';
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// ===========================================================================
|
|
1957
|
+
// Teardown.
|
|
1958
|
+
// ===========================================================================
|
|
1959
|
+
|
|
1960
|
+
destroy() {
|
|
1961
|
+
if (this.themeObserver) {
|
|
1962
|
+
this.themeObserver.disconnect();
|
|
1963
|
+
this.themeObserver = null;
|
|
1964
|
+
}
|
|
1965
|
+
if (this.tooltip && this.tooltip.parentNode) {
|
|
1966
|
+
this.tooltip.parentNode.removeChild(this.tooltip);
|
|
1967
|
+
this.tooltip = null;
|
|
1968
|
+
}
|
|
1969
|
+
if (this.cy) {
|
|
1970
|
+
this.cy.destroy();
|
|
1971
|
+
this.cy = null;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// =============================================================================
|
|
1977
|
+
// Module exports — CJS for Node tests. Browser-side the build-browser.js
|
|
1978
|
+
// concatenation wraps these declarations in an IIFE and assigns
|
|
1979
|
+
// `window.CapGraphRenderer`.
|
|
1980
|
+
// =============================================================================
|
|
1981
|
+
|
|
1982
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
1983
|
+
module.exports = {
|
|
1984
|
+
CapGraphRenderer,
|
|
1985
|
+
cardinalityLabel,
|
|
1986
|
+
cardinalityFromCap,
|
|
1987
|
+
canonicalMediaUrn,
|
|
1988
|
+
mediaNodeLabel,
|
|
1989
|
+
buildBrowseGraphData,
|
|
1990
|
+
buildStrandGraphData,
|
|
1991
|
+
buildRunGraphData,
|
|
1992
|
+
buildMachineGraphData,
|
|
1993
|
+
classifyStrandCapSteps,
|
|
1994
|
+
validateStrandPayload,
|
|
1995
|
+
validateRunPayload,
|
|
1996
|
+
validateMachinePayload,
|
|
1997
|
+
validateStrandStep,
|
|
1998
|
+
validateBodyOutcome,
|
|
1999
|
+
};
|
|
2000
|
+
}
|