capdag 0.104.240 → 0.109.248
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 +2115 -0
- package/capdag.test.js +154 -60
- package/machine-parser.js +126 -64
- package/package.json +4 -1
|
@@ -0,0 +1,2115 @@
|
|
|
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 by mirroring capdag's plan builder
|
|
667
|
+
// (`capdag/src/planner/plan_builder.rs::build_plan_from_path`). The plan
|
|
668
|
+
// builder is the authoritative source of truth for how strand steps
|
|
669
|
+
// translate into a DAG of nodes and edges:
|
|
670
|
+
//
|
|
671
|
+
// * Node IDs are positional: `input_slot`, `step_0`, `step_1`, …,
|
|
672
|
+
// `output`. They are NOT media URN strings — URN comparisons for
|
|
673
|
+
// graph topology are wrong because the planner connects steps by
|
|
674
|
+
// the order-theoretic `conformsTo` relation, not by string equality.
|
|
675
|
+
// * `prev_node_id` is a single running pointer, only advanced by Cap
|
|
676
|
+
// steps. ForEach steps mark the start of a body span without
|
|
677
|
+
// advancing prev; the body's first Cap still connects to whatever
|
|
678
|
+
// was before the ForEach.
|
|
679
|
+
// * Cap inside a ForEach body connects from `prev_node_id` like any
|
|
680
|
+
// other cap, AND tracks `body_entry` (first cap in body) and
|
|
681
|
+
// `body_exit` (most recent cap in body).
|
|
682
|
+
// * Collect after a ForEach body creates a ForEach node with
|
|
683
|
+
// boundaries, an iteration edge to body_entry, a Collect node, and
|
|
684
|
+
// a collection edge from body_exit to Collect. prev_node_id becomes
|
|
685
|
+
// the Collect node.
|
|
686
|
+
// * Standalone Collect (no enclosing ForEach) creates a Collect node
|
|
687
|
+
// consuming prev_node_id directly.
|
|
688
|
+
// * Unclosed ForEach with no body caps is a terminal unwrap — the
|
|
689
|
+
// ForEach node is skipped; prev_node_id stays as-is.
|
|
690
|
+
// * Unclosed ForEach WITH body caps gets a ForEach node, iteration
|
|
691
|
+
// edge to body_entry, and prev_node_id becomes body_exit.
|
|
692
|
+
//
|
|
693
|
+
// Node labels come from the `media_display_names` map keyed by the
|
|
694
|
+
// step's canonical URN (or source_spec/target_spec for the boundary
|
|
695
|
+
// nodes). ForEach and Collect nodes display "for each" / "collect".
|
|
696
|
+
// Cap edges carry the cap title plus cardinality marker when either
|
|
697
|
+
// input or output is a sequence.
|
|
698
|
+
function buildStrandGraphData(data) {
|
|
699
|
+
validateStrandPayload(data);
|
|
700
|
+
|
|
701
|
+
const mediaDisplayNames = data.media_display_names || {};
|
|
702
|
+
const sourceSpec = canonicalMediaUrn(data.source_spec);
|
|
703
|
+
const targetSpec = canonicalMediaUrn(data.target_spec);
|
|
704
|
+
|
|
705
|
+
// Look up a display name for a media URN via the host-supplied map.
|
|
706
|
+
// Uses `MediaUrn.isEquivalent` so tag-order variation doesn't defeat
|
|
707
|
+
// the lookup — URNs are compared semantically, never as raw strings.
|
|
708
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
709
|
+
const displayEntries = [];
|
|
710
|
+
for (const [urn, display] of Object.entries(mediaDisplayNames)) {
|
|
711
|
+
if (typeof display !== 'string' || display.length === 0) continue;
|
|
712
|
+
try {
|
|
713
|
+
displayEntries.push({ media: MediaUrn.fromString(urn), display });
|
|
714
|
+
} catch (_) {
|
|
715
|
+
// Skip entries with unparseable URN keys — the host payload is
|
|
716
|
+
// trusted, but malformed keys are not fatal.
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function displayNameFor(canonicalUrn) {
|
|
720
|
+
const candidate = MediaUrn.fromString(canonicalUrn);
|
|
721
|
+
for (const entry of displayEntries) {
|
|
722
|
+
if (candidate.isEquivalent(entry.media)) return entry.display;
|
|
723
|
+
}
|
|
724
|
+
return mediaNodeLabel(canonicalUrn);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const nodes = [];
|
|
728
|
+
const edges = [];
|
|
729
|
+
const nodeIds = new Set();
|
|
730
|
+
|
|
731
|
+
function addNode(id, label, fullUrn, nodeClass) {
|
|
732
|
+
if (nodeIds.has(id)) return;
|
|
733
|
+
nodeIds.add(id);
|
|
734
|
+
nodes.push({
|
|
735
|
+
id,
|
|
736
|
+
label,
|
|
737
|
+
fullUrn: fullUrn || '',
|
|
738
|
+
nodeClass: nodeClass || '',
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
let edgeCounter = 0;
|
|
742
|
+
function addEdge(source, target, label, title, fullUrn, edgeClass) {
|
|
743
|
+
edges.push({
|
|
744
|
+
id: `strand-edge-${edgeCounter}`,
|
|
745
|
+
source,
|
|
746
|
+
target,
|
|
747
|
+
label: label || '',
|
|
748
|
+
title: title || '',
|
|
749
|
+
fullUrn: fullUrn || '',
|
|
750
|
+
edgeClass: edgeClass || '',
|
|
751
|
+
color: edgeHueColor(edgeCounter),
|
|
752
|
+
});
|
|
753
|
+
edgeCounter++;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Entry node — the strand's source media spec.
|
|
757
|
+
const inputSlotId = 'input_slot';
|
|
758
|
+
addNode(inputSlotId, displayNameFor(sourceSpec), sourceSpec, 'strand-source');
|
|
759
|
+
|
|
760
|
+
let prevNodeId = inputSlotId;
|
|
761
|
+
|
|
762
|
+
// Track ForEach body membership. `insideForEachBody = { index, nodeId }`
|
|
763
|
+
// records which ForEach step we're inside and the id we'll give its
|
|
764
|
+
// eventual node. `bodyEntry`/`bodyExit` track the first and most
|
|
765
|
+
// recent Cap step inside that body.
|
|
766
|
+
let insideForEachBody = null;
|
|
767
|
+
let bodyEntry = null;
|
|
768
|
+
let bodyExit = null;
|
|
769
|
+
|
|
770
|
+
// Finalize an outer ForEach body when a nested ForEach starts before
|
|
771
|
+
// the outer's Collect. Mirrors plan_builder.rs:238-289.
|
|
772
|
+
function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
|
|
773
|
+
const outerForEachInput = outerForEach.index === 0
|
|
774
|
+
? inputSlotId
|
|
775
|
+
: `step_${outerForEach.index - 1}`;
|
|
776
|
+
// Create the ForEach node + direct edge from its input + iteration
|
|
777
|
+
// edge into the body's first cap.
|
|
778
|
+
addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
|
|
779
|
+
addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
780
|
+
addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
|
|
781
|
+
return outerExit;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
data.steps.forEach((step, i) => {
|
|
785
|
+
const variant = Object.keys(step.step_type)[0];
|
|
786
|
+
const nodeId = `step_${i}`;
|
|
787
|
+
|
|
788
|
+
if (variant === 'Cap') {
|
|
789
|
+
const body = step.step_type.Cap;
|
|
790
|
+
const toCanonical = canonicalMediaUrn(step.to_spec);
|
|
791
|
+
addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
|
|
792
|
+
|
|
793
|
+
let label = body.title;
|
|
794
|
+
const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
|
|
795
|
+
if (cardinality !== '1\u21921') {
|
|
796
|
+
label = `${label} (${cardinality})`;
|
|
797
|
+
}
|
|
798
|
+
addEdge(prevNodeId, nodeId, label, body.title, body.cap_urn, 'strand-cap-edge');
|
|
799
|
+
|
|
800
|
+
if (insideForEachBody !== null) {
|
|
801
|
+
if (bodyEntry === null) bodyEntry = nodeId;
|
|
802
|
+
bodyExit = nodeId;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
prevNodeId = nodeId;
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (variant === 'ForEach') {
|
|
810
|
+
// If we're already inside a ForEach body when another ForEach
|
|
811
|
+
// starts, finalize the outer one first.
|
|
812
|
+
if (insideForEachBody !== null) {
|
|
813
|
+
const outer = insideForEachBody;
|
|
814
|
+
const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
|
|
815
|
+
const exit = bodyExit !== null ? bodyExit : prevNodeId;
|
|
816
|
+
if (bodyEntry === null) {
|
|
817
|
+
// Outer ForEach with no body caps is an illegal nesting; the
|
|
818
|
+
// plan builder throws. Mirror that.
|
|
819
|
+
throw new Error(
|
|
820
|
+
`CapGraphRenderer strand: nested ForEach at step[${i}] but outer ForEach at step[${outer.index}] has no body caps`
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
prevNodeId = finalizeOuterForEach(outer, entry, exit);
|
|
824
|
+
bodyEntry = null;
|
|
825
|
+
bodyExit = null;
|
|
826
|
+
}
|
|
827
|
+
insideForEachBody = { index: i, nodeId };
|
|
828
|
+
bodyEntry = null;
|
|
829
|
+
bodyExit = null;
|
|
830
|
+
// Do NOT advance prevNodeId — the body's first cap will connect
|
|
831
|
+
// to whatever was before the ForEach.
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (variant === 'Collect') {
|
|
836
|
+
if (insideForEachBody !== null) {
|
|
837
|
+
const outer = insideForEachBody;
|
|
838
|
+
const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
|
|
839
|
+
const exit = bodyExit !== null ? bodyExit : prevNodeId;
|
|
840
|
+
const outerForEachInput = outer.index === 0
|
|
841
|
+
? inputSlotId
|
|
842
|
+
: `step_${outer.index - 1}`;
|
|
843
|
+
|
|
844
|
+
addNode(outer.nodeId, 'for each', '', 'strand-foreach');
|
|
845
|
+
addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
846
|
+
addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
|
|
847
|
+
|
|
848
|
+
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
849
|
+
addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
850
|
+
|
|
851
|
+
insideForEachBody = null;
|
|
852
|
+
bodyEntry = null;
|
|
853
|
+
bodyExit = null;
|
|
854
|
+
prevNodeId = nodeId;
|
|
855
|
+
} else {
|
|
856
|
+
// Standalone Collect — scalar → list-of-one. Mirrors
|
|
857
|
+
// plan_builder.rs:333-355.
|
|
858
|
+
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
859
|
+
addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
860
|
+
prevNodeId = nodeId;
|
|
861
|
+
}
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
throw new Error(`CapGraphRenderer strand: unknown step_type variant '${variant}' at step[${i}]`);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
|
|
869
|
+
if (insideForEachBody !== null) {
|
|
870
|
+
const outer = insideForEachBody;
|
|
871
|
+
const hasBodyEntry = bodyEntry !== null;
|
|
872
|
+
if (hasBodyEntry) {
|
|
873
|
+
const entry = bodyEntry;
|
|
874
|
+
const exit = bodyExit;
|
|
875
|
+
const outerForEachInput = outer.index === 0
|
|
876
|
+
? inputSlotId
|
|
877
|
+
: `step_${outer.index - 1}`;
|
|
878
|
+
addNode(outer.nodeId, 'for each', '', 'strand-foreach');
|
|
879
|
+
addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
880
|
+
addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
|
|
881
|
+
prevNodeId = exit;
|
|
882
|
+
}
|
|
883
|
+
// hasBodyEntry === false is a terminal unwrap — skip the ForEach
|
|
884
|
+
// node entirely, prev_node_id stays as-is.
|
|
885
|
+
insideForEachBody = null;
|
|
886
|
+
bodyEntry = null;
|
|
887
|
+
bodyExit = null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Final output node. Mirrors plan_builder.rs:430-432.
|
|
891
|
+
const outputId = 'output';
|
|
892
|
+
addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
|
|
893
|
+
addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
|
|
894
|
+
|
|
895
|
+
return { nodes, edges, sourceSpec, targetSpec };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function strandCytoscapeElements(built) {
|
|
899
|
+
const nodeElements = built.nodes.map(node => ({
|
|
900
|
+
group: 'nodes',
|
|
901
|
+
data: {
|
|
902
|
+
id: node.id,
|
|
903
|
+
label: node.label,
|
|
904
|
+
fullUrn: node.fullUrn,
|
|
905
|
+
},
|
|
906
|
+
classes: node.nodeClass || '',
|
|
907
|
+
}));
|
|
908
|
+
const edgeElements = built.edges.map(edge => ({
|
|
909
|
+
group: 'edges',
|
|
910
|
+
data: {
|
|
911
|
+
id: edge.id,
|
|
912
|
+
source: edge.source,
|
|
913
|
+
target: edge.target,
|
|
914
|
+
label: edge.label,
|
|
915
|
+
title: edge.title,
|
|
916
|
+
fullUrn: edge.fullUrn,
|
|
917
|
+
color: edge.color,
|
|
918
|
+
},
|
|
919
|
+
classes: edge.edgeClass || '',
|
|
920
|
+
}));
|
|
921
|
+
return nodeElements.concat(edgeElements);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// --------- Run mode builder -------------------------------------------------
|
|
925
|
+
|
|
926
|
+
// Find a cap step in the resolved strand whose Cap.cap_urn semantically
|
|
927
|
+
// matches the supplied URN string. Uses CapUrn.isEquivalent — never
|
|
928
|
+
// string equality.
|
|
929
|
+
function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
930
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
931
|
+
const target = CapUrn.fromString(targetUrnString);
|
|
932
|
+
for (let i = 0; i < steps.length; i++) {
|
|
933
|
+
const variant = Object.keys(steps[i].step_type)[0];
|
|
934
|
+
if (variant !== 'Cap') continue;
|
|
935
|
+
const candidate = CapUrn.fromString(steps[i].step_type.Cap.cap_urn);
|
|
936
|
+
if (candidate.isEquivalent(target)) return i;
|
|
937
|
+
}
|
|
938
|
+
return -1;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Remove nodes and edges belonging to the ForEach body's interior cap
|
|
942
|
+
// steps from a strand backbone. In run mode, these are replaced by
|
|
943
|
+
// per-body replicas; keeping the prototype chain alongside the
|
|
944
|
+
// replicas produces a confusing double-render. The ForEach and Collect
|
|
945
|
+
// nodes themselves, plus their iteration/collection edges, stay.
|
|
946
|
+
function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, collectStepIdx) {
|
|
947
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
948
|
+
// Collect the positional IDs of body-interior cap steps (the caps
|
|
949
|
+
// strictly between ForEach and Collect).
|
|
950
|
+
const interiorIds = new Set();
|
|
951
|
+
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
952
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
953
|
+
interiorIds.add(`step_${i}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (interiorIds.size === 0) return built;
|
|
957
|
+
|
|
958
|
+
const keptNodes = built.nodes.filter(n => !interiorIds.has(n.id));
|
|
959
|
+
const keptEdges = built.edges.filter(e =>
|
|
960
|
+
!interiorIds.has(e.source) && !interiorIds.has(e.target));
|
|
961
|
+
|
|
962
|
+
// After stripping, the ForEach node and the Collect node (or the
|
|
963
|
+
// output node if no Collect) may become disconnected — the body
|
|
964
|
+
// replicas will bridge them. That's fine; cytoscape's ELK layout
|
|
965
|
+
// handles disconnected subgraphs.
|
|
966
|
+
return {
|
|
967
|
+
nodes: keptNodes,
|
|
968
|
+
edges: keptEdges,
|
|
969
|
+
sourceSpec: built.sourceSpec,
|
|
970
|
+
targetSpec: built.targetSpec,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function buildRunGraphData(data) {
|
|
975
|
+
validateRunPayload(data);
|
|
976
|
+
|
|
977
|
+
// The backbone is rendered with the same rules as strand mode. Feed
|
|
978
|
+
// the strand portion to the strand builder to inherit all its
|
|
979
|
+
// ForEach/Collect labeling and cardinality-marker logic.
|
|
980
|
+
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
981
|
+
media_display_names: data.media_display_names,
|
|
982
|
+
});
|
|
983
|
+
const strandBuiltRaw = buildStrandGraphData(strandInput);
|
|
984
|
+
|
|
985
|
+
// Locate the ForEach/Collect span in the backbone for body-replica
|
|
986
|
+
// placement. The strand builder uses positional node IDs mirroring
|
|
987
|
+
// the plan builder (`step_0`, `step_1`, …). The ForEach node at
|
|
988
|
+
// step index i has id `step_i`; body replicas fan out from that
|
|
989
|
+
// ForEach node and (when a Collect closes the body) merge into the
|
|
990
|
+
// Collect node at `step_j`.
|
|
991
|
+
const steps = data.resolved_strand.steps;
|
|
992
|
+
let foreachStepIdx = -1;
|
|
993
|
+
let collectStepIdx = -1;
|
|
994
|
+
for (let i = 0; i < steps.length; i++) {
|
|
995
|
+
const variant = Object.keys(steps[i].step_type)[0];
|
|
996
|
+
if (variant === 'ForEach' && foreachStepIdx < 0) foreachStepIdx = i;
|
|
997
|
+
if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
|
|
998
|
+
}
|
|
999
|
+
const hasForeach = foreachStepIdx >= 0;
|
|
1000
|
+
|
|
1001
|
+
// In run mode we want per-body replicas to REPLACE the prototype cap
|
|
1002
|
+
// chain inside the ForEach body, not sit alongside it. Strip the
|
|
1003
|
+
// backbone nodes and edges that correspond to body-interior Cap
|
|
1004
|
+
// steps and their direct edges, keeping only the input slot, the
|
|
1005
|
+
// ForEach node, the Collect node (if any), the output node, and any
|
|
1006
|
+
// caps OUTSIDE the body span. Body replicas will connect from the
|
|
1007
|
+
// ForEach node and merge at the Collect node.
|
|
1008
|
+
const strandBuilt = hasForeach
|
|
1009
|
+
? stripBodyInteriorFromStrandBackbone(strandBuiltRaw, steps, foreachStepIdx, collectStepIdx)
|
|
1010
|
+
: strandBuiltRaw;
|
|
1011
|
+
|
|
1012
|
+
// Filter and bound the outcomes.
|
|
1013
|
+
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
1014
|
+
const successes = allOutcomes.filter(o => o.success);
|
|
1015
|
+
const failures = allOutcomes.filter(o => !o.success);
|
|
1016
|
+
const visibleSuccess = successes.slice(0, data.visible_success_count);
|
|
1017
|
+
const visibleFailure = failures.slice(0, data.visible_failure_count);
|
|
1018
|
+
const hiddenSuccessCount = successes.length - visibleSuccess.length;
|
|
1019
|
+
const hiddenFailureCount = failures.length - visibleFailure.length;
|
|
1020
|
+
|
|
1021
|
+
// Collect the Cap steps inside the ForEach body. Each body replica
|
|
1022
|
+
// chains through these caps.
|
|
1023
|
+
const bodyCapSteps = [];
|
|
1024
|
+
const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
|
|
1025
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
1026
|
+
for (let i = bodyStart; i < bodyEnd; i++) {
|
|
1027
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
1028
|
+
bodyCapSteps.push({ globalIndex: i, step: steps[i] });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Body replicas fan out from the ForEach node. If no ForEach, fall
|
|
1033
|
+
// back to the input_slot. When the strand closes the body with a
|
|
1034
|
+
// Collect, replicas merge into that Collect node; otherwise
|
|
1035
|
+
// (unclosed ForEach) they merge into the `output` node.
|
|
1036
|
+
const anchorNodeId = hasForeach ? `step_${foreachStepIdx}` : 'input_slot';
|
|
1037
|
+
const mergeNodeId = collectStepIdx >= 0 ? `step_${collectStepIdx}` : 'output';
|
|
1038
|
+
|
|
1039
|
+
const replicaNodes = [];
|
|
1040
|
+
const replicaEdges = [];
|
|
1041
|
+
|
|
1042
|
+
function buildBodyReplica(outcome) {
|
|
1043
|
+
const success = outcome.success;
|
|
1044
|
+
const successClass = success ? 'body-success' : 'body-failure';
|
|
1045
|
+
const edgeClass = success ? 'body-success' : 'body-failure';
|
|
1046
|
+
const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
|
|
1047
|
+
|
|
1048
|
+
// Trace end: failures stop at failed_cap. `CapUrn.isEquivalent`
|
|
1049
|
+
// is used for the match — never string equality.
|
|
1050
|
+
let traceEnd = bodyCapSteps.length;
|
|
1051
|
+
if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
|
|
1052
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1053
|
+
const target = CapUrn.fromString(outcome.failed_cap);
|
|
1054
|
+
for (let i = 0; i < bodyCapSteps.length; i++) {
|
|
1055
|
+
const candidate = CapUrn.fromString(bodyCapSteps[i].step.step_type.Cap.cap_urn);
|
|
1056
|
+
if (candidate.isEquivalent(target)) {
|
|
1057
|
+
traceEnd = i + 1;
|
|
1058
|
+
break;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (traceEnd === 0) return;
|
|
1063
|
+
|
|
1064
|
+
let prevBodyNodeId = anchorNodeId;
|
|
1065
|
+
const bodyKey = `body-${outcome.body_index}`;
|
|
1066
|
+
const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
|
|
1067
|
+
? outcome.title
|
|
1068
|
+
: `body ${outcome.body_index}`;
|
|
1069
|
+
|
|
1070
|
+
for (let i = 0; i < traceEnd; i++) {
|
|
1071
|
+
const body = bodyCapSteps[i].step.step_type.Cap;
|
|
1072
|
+
const targetCanonical = canonicalMediaUrn(bodyCapSteps[i].step.to_spec);
|
|
1073
|
+
const replicaNodeId = `${bodyKey}-n-${i}`;
|
|
1074
|
+
replicaNodes.push({
|
|
1075
|
+
group: 'nodes',
|
|
1076
|
+
data: {
|
|
1077
|
+
id: replicaNodeId,
|
|
1078
|
+
label: mediaNodeLabel(targetCanonical),
|
|
1079
|
+
fullUrn: targetCanonical,
|
|
1080
|
+
bodyIndex: outcome.body_index,
|
|
1081
|
+
bodyTitle: titleLabel,
|
|
1082
|
+
},
|
|
1083
|
+
classes: successClass,
|
|
1084
|
+
});
|
|
1085
|
+
replicaEdges.push({
|
|
1086
|
+
group: 'edges',
|
|
1087
|
+
data: {
|
|
1088
|
+
id: `${bodyKey}-e-${i}`,
|
|
1089
|
+
source: prevBodyNodeId,
|
|
1090
|
+
target: replicaNodeId,
|
|
1091
|
+
label: i === 0 ? body.title : '',
|
|
1092
|
+
title: body.title,
|
|
1093
|
+
fullUrn: body.cap_urn,
|
|
1094
|
+
color: `var(${colorVar})`,
|
|
1095
|
+
bodyIndex: outcome.body_index,
|
|
1096
|
+
},
|
|
1097
|
+
classes: edgeClass,
|
|
1098
|
+
});
|
|
1099
|
+
prevBodyNodeId = replicaNodeId;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Successful bodies merge their replica tail back into the Collect
|
|
1103
|
+
// node (or `output` if the ForEach is unclosed) so the graph
|
|
1104
|
+
// visibly fans in. Failed bodies do NOT merge — the trace
|
|
1105
|
+
// terminates at the failed cap.
|
|
1106
|
+
if (success) {
|
|
1107
|
+
replicaEdges.push({
|
|
1108
|
+
group: 'edges',
|
|
1109
|
+
data: {
|
|
1110
|
+
id: `${bodyKey}-merge`,
|
|
1111
|
+
source: prevBodyNodeId,
|
|
1112
|
+
target: mergeNodeId,
|
|
1113
|
+
label: '',
|
|
1114
|
+
title: 'collect',
|
|
1115
|
+
fullUrn: '',
|
|
1116
|
+
color: `var(${colorVar})`,
|
|
1117
|
+
bodyIndex: outcome.body_index,
|
|
1118
|
+
},
|
|
1119
|
+
classes: edgeClass,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
visibleSuccess.forEach((o) => buildBodyReplica(o));
|
|
1125
|
+
visibleFailure.forEach((o) => buildBodyReplica(o));
|
|
1126
|
+
|
|
1127
|
+
// Build success and failure "show more" nodes when there are hidden
|
|
1128
|
+
// outcomes. Anchored at the ForEach node (or input_slot if none).
|
|
1129
|
+
const showMoreNodes = [];
|
|
1130
|
+
if (hasForeach && bodyCapSteps.length > 0) {
|
|
1131
|
+
if (hiddenSuccessCount > 0) {
|
|
1132
|
+
const nodeId = 'show-more-success';
|
|
1133
|
+
showMoreNodes.push({
|
|
1134
|
+
group: 'nodes',
|
|
1135
|
+
data: {
|
|
1136
|
+
id: nodeId,
|
|
1137
|
+
label: `+${hiddenSuccessCount} more succeeded`,
|
|
1138
|
+
fullUrn: '',
|
|
1139
|
+
showMoreGroup: 'success',
|
|
1140
|
+
hiddenCount: hiddenSuccessCount,
|
|
1141
|
+
},
|
|
1142
|
+
classes: 'show-more body-success',
|
|
1143
|
+
});
|
|
1144
|
+
replicaEdges.push({
|
|
1145
|
+
group: 'edges',
|
|
1146
|
+
data: {
|
|
1147
|
+
id: 'show-more-success-edge',
|
|
1148
|
+
source: anchorNodeId,
|
|
1149
|
+
target: nodeId,
|
|
1150
|
+
label: '',
|
|
1151
|
+
title: '',
|
|
1152
|
+
fullUrn: '',
|
|
1153
|
+
color: 'var(--graph-body-edge-success)',
|
|
1154
|
+
},
|
|
1155
|
+
classes: 'body-success',
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
if (hiddenFailureCount > 0) {
|
|
1159
|
+
const nodeId = 'show-more-failure';
|
|
1160
|
+
showMoreNodes.push({
|
|
1161
|
+
group: 'nodes',
|
|
1162
|
+
data: {
|
|
1163
|
+
id: nodeId,
|
|
1164
|
+
label: `+${hiddenFailureCount} failed`,
|
|
1165
|
+
fullUrn: '',
|
|
1166
|
+
showMoreGroup: 'failure',
|
|
1167
|
+
hiddenCount: hiddenFailureCount,
|
|
1168
|
+
},
|
|
1169
|
+
classes: 'show-more body-failure',
|
|
1170
|
+
});
|
|
1171
|
+
replicaEdges.push({
|
|
1172
|
+
group: 'edges',
|
|
1173
|
+
data: {
|
|
1174
|
+
id: 'show-more-failure-edge',
|
|
1175
|
+
source: anchorNodeId,
|
|
1176
|
+
target: nodeId,
|
|
1177
|
+
label: '',
|
|
1178
|
+
title: '',
|
|
1179
|
+
fullUrn: '',
|
|
1180
|
+
color: 'var(--graph-body-edge-failure)',
|
|
1181
|
+
},
|
|
1182
|
+
classes: 'body-failure',
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return {
|
|
1188
|
+
strandBuilt,
|
|
1189
|
+
replicaNodes,
|
|
1190
|
+
replicaEdges,
|
|
1191
|
+
showMoreNodes,
|
|
1192
|
+
totals: {
|
|
1193
|
+
hiddenSuccessCount,
|
|
1194
|
+
hiddenFailureCount,
|
|
1195
|
+
totalBodyCount: data.total_body_count,
|
|
1196
|
+
visibleSuccessCount: visibleSuccess.length,
|
|
1197
|
+
visibleFailureCount: visibleFailure.length,
|
|
1198
|
+
},
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function runCytoscapeElements(built) {
|
|
1203
|
+
const strandElements = strandCytoscapeElements(built.strandBuilt);
|
|
1204
|
+
return strandElements
|
|
1205
|
+
.concat(built.replicaNodes)
|
|
1206
|
+
.concat(built.showMoreNodes)
|
|
1207
|
+
.concat(built.replicaEdges);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// --------- Machine mode builder ---------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
function buildMachineGraphData(data) {
|
|
1213
|
+
validateMachinePayload(data);
|
|
1214
|
+
const nodes = [];
|
|
1215
|
+
const edges = [];
|
|
1216
|
+
let capEdgeIdx = 0;
|
|
1217
|
+
for (const el of data.elements) {
|
|
1218
|
+
if (el.kind === 'node') {
|
|
1219
|
+
nodes.push({
|
|
1220
|
+
group: 'nodes',
|
|
1221
|
+
data: {
|
|
1222
|
+
id: el.graph_id,
|
|
1223
|
+
label: el.label || '',
|
|
1224
|
+
fullUrn: el.detail || el.label || '',
|
|
1225
|
+
tokenId: el.token_id || '',
|
|
1226
|
+
kind: 'node',
|
|
1227
|
+
},
|
|
1228
|
+
classes: 'machine-node',
|
|
1229
|
+
});
|
|
1230
|
+
} else if (el.kind === 'cap') {
|
|
1231
|
+
nodes.push({
|
|
1232
|
+
group: 'nodes',
|
|
1233
|
+
data: {
|
|
1234
|
+
id: el.graph_id,
|
|
1235
|
+
label: el.label || '',
|
|
1236
|
+
fullUrn: el.detail || el.label || '',
|
|
1237
|
+
tokenId: el.token_id || '',
|
|
1238
|
+
kind: 'cap',
|
|
1239
|
+
},
|
|
1240
|
+
classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
|
|
1241
|
+
});
|
|
1242
|
+
} else if (el.kind === 'edge') {
|
|
1243
|
+
edges.push({
|
|
1244
|
+
group: 'edges',
|
|
1245
|
+
data: {
|
|
1246
|
+
id: el.graph_id,
|
|
1247
|
+
source: el.source_graph_id,
|
|
1248
|
+
target: el.target_graph_id,
|
|
1249
|
+
label: el.label || '',
|
|
1250
|
+
title: el.label || '',
|
|
1251
|
+
fullUrn: el.detail || '',
|
|
1252
|
+
tokenId: el.token_id || '',
|
|
1253
|
+
color: el.is_loop
|
|
1254
|
+
? 'var(--graph-node-border-highlighted)'
|
|
1255
|
+
: edgeHueColor(capEdgeIdx),
|
|
1256
|
+
},
|
|
1257
|
+
classes: 'machine-edge' + (el.is_loop ? ' machine-loop' : ''),
|
|
1258
|
+
});
|
|
1259
|
+
capEdgeIdx++;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return { nodes, edges };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function machineCytoscapeElements(built) {
|
|
1266
|
+
return built.nodes.concat(built.edges);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// A cheap signature for machine-mode inputs. The editor streams updates
|
|
1270
|
+
// on every keystroke; we skip the expensive rebuild when the element
|
|
1271
|
+
// shape is unchanged.
|
|
1272
|
+
function machineGraphSignature(data) {
|
|
1273
|
+
if (!data || !Array.isArray(data.elements)) return '';
|
|
1274
|
+
const parts = [];
|
|
1275
|
+
for (const el of data.elements) {
|
|
1276
|
+
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'}`);
|
|
1277
|
+
}
|
|
1278
|
+
return parts.join(';');
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// =============================================================================
|
|
1282
|
+
// Renderer class.
|
|
1283
|
+
// =============================================================================
|
|
1284
|
+
|
|
1285
|
+
class CapGraphRenderer {
|
|
1286
|
+
constructor(containerOrId, options) {
|
|
1287
|
+
if (options === undefined || options === null) {
|
|
1288
|
+
throw new Error('CapGraphRenderer: options object is required');
|
|
1289
|
+
}
|
|
1290
|
+
if (typeof options !== 'object') {
|
|
1291
|
+
throw new Error('CapGraphRenderer: options must be an object');
|
|
1292
|
+
}
|
|
1293
|
+
const mode = options.mode;
|
|
1294
|
+
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
|
|
1295
|
+
throw new Error(
|
|
1296
|
+
`CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine" (got ${JSON.stringify(mode)})`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Resolve cytoscape. cytoscape-elk auto-registers when loaded via
|
|
1301
|
+
// script tag; we verify by checking the elk layout's presence.
|
|
1302
|
+
const cytoscape = requireHostDependency('cytoscape');
|
|
1303
|
+
if (!cytoscape.__elkRegistered) {
|
|
1304
|
+
// Some build bundles register the extension at load, others need
|
|
1305
|
+
// an explicit `cytoscape.use(...)`. We do it once per cytoscape
|
|
1306
|
+
// instance — the extension itself is a guarded no-op on repeat.
|
|
1307
|
+
const elkExt = (typeof window !== 'undefined') ? window.cytoscapeElk
|
|
1308
|
+
: (typeof global !== 'undefined') ? global.cytoscapeElk
|
|
1309
|
+
: undefined;
|
|
1310
|
+
if (elkExt !== undefined) {
|
|
1311
|
+
cytoscape.use(elkExt);
|
|
1312
|
+
}
|
|
1313
|
+
cytoscape.__elkRegistered = true;
|
|
1314
|
+
}
|
|
1315
|
+
this._cytoscape = cytoscape;
|
|
1316
|
+
|
|
1317
|
+
let container;
|
|
1318
|
+
if (typeof containerOrId === 'string') {
|
|
1319
|
+
container = document.getElementById(containerOrId);
|
|
1320
|
+
if (!container) {
|
|
1321
|
+
throw new Error(`CapGraphRenderer: container element '${containerOrId}' not found`);
|
|
1322
|
+
}
|
|
1323
|
+
} else if (containerOrId instanceof Element) {
|
|
1324
|
+
container = containerOrId;
|
|
1325
|
+
} else {
|
|
1326
|
+
throw new Error('CapGraphRenderer: first argument must be a container id string or an Element');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
this.container = container;
|
|
1330
|
+
this.mode = mode;
|
|
1331
|
+
this.interaction = options.interaction && typeof options.interaction === 'object'
|
|
1332
|
+
? options.interaction
|
|
1333
|
+
: {};
|
|
1334
|
+
this.bottomExcludedRegion = typeof options.bottomExcludedRegion === 'function'
|
|
1335
|
+
? options.bottomExcludedRegion
|
|
1336
|
+
: () => 0;
|
|
1337
|
+
|
|
1338
|
+
// State — shared fields.
|
|
1339
|
+
this.cy = null;
|
|
1340
|
+
this.selectedElement = null;
|
|
1341
|
+
this._layoutReady = false;
|
|
1342
|
+
this.tooltip = createTooltipElement();
|
|
1343
|
+
|
|
1344
|
+
// Browse-mode state.
|
|
1345
|
+
this.navigator = null;
|
|
1346
|
+
this.nodes = [];
|
|
1347
|
+
this.edges = [];
|
|
1348
|
+
this.adjacency = new Map();
|
|
1349
|
+
this.reverseAdj = new Map();
|
|
1350
|
+
this.capGraph = null;
|
|
1351
|
+
this.capabilitiesByEdgeId = new Map();
|
|
1352
|
+
this._mediaTitles = new Map();
|
|
1353
|
+
this._pendingFocusCap = null;
|
|
1354
|
+
this.pathMode = null;
|
|
1355
|
+
|
|
1356
|
+
// Strand/run state.
|
|
1357
|
+
this._strandBuilt = null;
|
|
1358
|
+
this._runBuilt = null;
|
|
1359
|
+
|
|
1360
|
+
// Machine state.
|
|
1361
|
+
this._machineSignature = null;
|
|
1362
|
+
this._machineBuilt = null;
|
|
1363
|
+
|
|
1364
|
+
// Theme observer.
|
|
1365
|
+
this.themeObserver = new MutationObserver((mutations) => {
|
|
1366
|
+
for (const mutation of mutations) {
|
|
1367
|
+
if (mutation.attributeName === 'data-theme') {
|
|
1368
|
+
if (this.cy) this.cy.style(buildStylesheet());
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
this.themeObserver.observe(document.documentElement, {
|
|
1373
|
+
attributes: true,
|
|
1374
|
+
attributeFilter: ['data-theme'],
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ===========================================================================
|
|
1379
|
+
// Navigator bridge — browse mode only.
|
|
1380
|
+
// ===========================================================================
|
|
1381
|
+
|
|
1382
|
+
setNavigator(navigator) {
|
|
1383
|
+
if (this.mode !== 'browse') {
|
|
1384
|
+
throw new Error(`CapGraphRenderer: setNavigator is only valid in browse mode (current: ${this.mode})`);
|
|
1385
|
+
}
|
|
1386
|
+
this.navigator = navigator;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// ===========================================================================
|
|
1390
|
+
// Data entry points.
|
|
1391
|
+
// ===========================================================================
|
|
1392
|
+
|
|
1393
|
+
setData(data) {
|
|
1394
|
+
if (this.mode === 'browse') {
|
|
1395
|
+
const built = buildBrowseGraphData(data);
|
|
1396
|
+
this.nodes = built.nodes;
|
|
1397
|
+
this.edges = built.edges;
|
|
1398
|
+
this.adjacency = built.adjacency;
|
|
1399
|
+
this.reverseAdj = built.reverseAdj;
|
|
1400
|
+
this.capGraph = built.capGraph;
|
|
1401
|
+
this._mediaTitles = built.mediaTitles;
|
|
1402
|
+
this.capabilitiesByEdgeId = built.capabilitiesByEdgeId;
|
|
1403
|
+
return this;
|
|
1404
|
+
}
|
|
1405
|
+
if (this.mode === 'strand') {
|
|
1406
|
+
this._strandBuilt = buildStrandGraphData(data);
|
|
1407
|
+
return this;
|
|
1408
|
+
}
|
|
1409
|
+
if (this.mode === 'run') {
|
|
1410
|
+
this._runBuilt = buildRunGraphData(data);
|
|
1411
|
+
return this;
|
|
1412
|
+
}
|
|
1413
|
+
if (this.mode === 'machine') {
|
|
1414
|
+
const signature = machineGraphSignature(data);
|
|
1415
|
+
if (signature === this._machineSignature && this.cy) {
|
|
1416
|
+
// Same shape — restyle for theme changes and return.
|
|
1417
|
+
this.cy.style(buildStylesheet());
|
|
1418
|
+
return this;
|
|
1419
|
+
}
|
|
1420
|
+
this._machineSignature = signature;
|
|
1421
|
+
this._machineBuilt = buildMachineGraphData(data);
|
|
1422
|
+
return this;
|
|
1423
|
+
}
|
|
1424
|
+
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Compatibility shim for capdag-dot-com browse callers: `buildFromCapabilities`
|
|
1428
|
+
// is an explicit name that reads clearly at call sites like `graph.buildFromCapabilities(registry)`.
|
|
1429
|
+
buildFromCapabilities(capabilities) {
|
|
1430
|
+
if (this.mode !== 'browse') {
|
|
1431
|
+
throw new Error(
|
|
1432
|
+
`CapGraphRenderer: buildFromCapabilities is only valid in browse mode (current: ${this.mode})`
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
return this.setData(capabilities);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
// ===========================================================================
|
|
1439
|
+
// Render — creates (or recreates) the cytoscape instance.
|
|
1440
|
+
// ===========================================================================
|
|
1441
|
+
|
|
1442
|
+
render() {
|
|
1443
|
+
if (!this.container) {
|
|
1444
|
+
throw new Error('CapGraphRenderer: container is missing');
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const elements = this._buildCytoscapeElements();
|
|
1448
|
+
if (elements.length === 0) {
|
|
1449
|
+
this.container.innerHTML = '<div class="cap-graph-empty"><p>No graph data</p></div>';
|
|
1450
|
+
return this;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Clear container and size it to the window.
|
|
1454
|
+
this.container.innerHTML = '';
|
|
1455
|
+
this.container.style.width = window.innerWidth + 'px';
|
|
1456
|
+
this.container.style.height = window.innerHeight + 'px';
|
|
1457
|
+
|
|
1458
|
+
const self = this;
|
|
1459
|
+
this._layoutReady = false;
|
|
1460
|
+
|
|
1461
|
+
if (this.cy) {
|
|
1462
|
+
this.cy.destroy();
|
|
1463
|
+
this.cy = null;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
this.cy = this._cytoscape({
|
|
1467
|
+
container: this.container,
|
|
1468
|
+
elements,
|
|
1469
|
+
layout: Object.assign(
|
|
1470
|
+
{ name: 'elk', elk: layoutForMode(this.mode) },
|
|
1471
|
+
{
|
|
1472
|
+
stop: function () {
|
|
1473
|
+
self.cy.resize();
|
|
1474
|
+
self._layoutReady = true;
|
|
1475
|
+
if (self._pendingFocusCap) {
|
|
1476
|
+
const pending = self._pendingFocusCap;
|
|
1477
|
+
self._pendingFocusCap = null;
|
|
1478
|
+
self.highlightCapability(pending);
|
|
1479
|
+
}
|
|
1480
|
+
self.refitCurrentSelection();
|
|
1481
|
+
},
|
|
1482
|
+
}
|
|
1483
|
+
),
|
|
1484
|
+
style: buildStylesheet(),
|
|
1485
|
+
minZoom: 0.05,
|
|
1486
|
+
maxZoom: 10,
|
|
1487
|
+
wheelSensitivity: 0.3,
|
|
1488
|
+
boxSelectionEnabled: false,
|
|
1489
|
+
autounselectify: this.mode === 'machine',
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
const resizeAndRefit = () => {
|
|
1493
|
+
if (!this.cy) return;
|
|
1494
|
+
this.cy.resize();
|
|
1495
|
+
this.refitCurrentSelection();
|
|
1496
|
+
};
|
|
1497
|
+
this.cy.on('ready', resizeAndRefit);
|
|
1498
|
+
requestAnimationFrame(resizeAndRefit);
|
|
1499
|
+
setTimeout(resizeAndRefit, 100);
|
|
1500
|
+
setTimeout(resizeAndRefit, 300);
|
|
1501
|
+
|
|
1502
|
+
this._setupEventHandlers();
|
|
1503
|
+
return this;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
_buildCytoscapeElements() {
|
|
1507
|
+
if (this.mode === 'browse') {
|
|
1508
|
+
return browseCytoscapeElements({
|
|
1509
|
+
nodes: this.nodes,
|
|
1510
|
+
edges: this.edges,
|
|
1511
|
+
mediaTitles: this._mediaTitles,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
if (this.mode === 'strand') {
|
|
1515
|
+
if (!this._strandBuilt) return [];
|
|
1516
|
+
return strandCytoscapeElements(this._strandBuilt);
|
|
1517
|
+
}
|
|
1518
|
+
if (this.mode === 'run') {
|
|
1519
|
+
if (!this._runBuilt) return [];
|
|
1520
|
+
return runCytoscapeElements(this._runBuilt);
|
|
1521
|
+
}
|
|
1522
|
+
if (this.mode === 'machine') {
|
|
1523
|
+
if (!this._machineBuilt) return [];
|
|
1524
|
+
return machineCytoscapeElements(this._machineBuilt);
|
|
1525
|
+
}
|
|
1526
|
+
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// ===========================================================================
|
|
1530
|
+
// Event handlers. All modes share mouse handling; browse mode adds the
|
|
1531
|
+
// navigator bridge, run mode adds show-more click handling, machine mode
|
|
1532
|
+
// fires interaction callbacks with the element's tokenId for editor
|
|
1533
|
+
// cross-highlight.
|
|
1534
|
+
// ===========================================================================
|
|
1535
|
+
|
|
1536
|
+
_setupEventHandlers() {
|
|
1537
|
+
const self = this;
|
|
1538
|
+
|
|
1539
|
+
this.cy.on('tap', 'node', function (evt) {
|
|
1540
|
+
evt.stopPropagation();
|
|
1541
|
+
self._handleNodeTap(evt.target);
|
|
1542
|
+
});
|
|
1543
|
+
this.cy.on('tap', 'edge', function (evt) {
|
|
1544
|
+
evt.stopPropagation();
|
|
1545
|
+
self._handleEdgeTap(evt.target);
|
|
1546
|
+
});
|
|
1547
|
+
this.cy.on('tap', function (evt) {
|
|
1548
|
+
if (evt.target === self.cy) self.clearSelection();
|
|
1549
|
+
});
|
|
1550
|
+
this.cy.on('dbltap', function (evt) {
|
|
1551
|
+
if (evt.target === self.cy) {
|
|
1552
|
+
self.clearSelection();
|
|
1553
|
+
self.fitToVisibleViewport(undefined, 50);
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
this.cy.on('mouseover', 'node', function (evt) {
|
|
1558
|
+
const node = evt.target;
|
|
1559
|
+
self._showTooltip(self._tooltipTextForNode(node), evt.originalEvent);
|
|
1560
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1561
|
+
self._highlightConnected(node.id());
|
|
1562
|
+
}
|
|
1563
|
+
if (typeof self.interaction.onNodeHover === 'function') {
|
|
1564
|
+
self.interaction.onNodeHover(node.data());
|
|
1565
|
+
}
|
|
1566
|
+
});
|
|
1567
|
+
this.cy.on('mousemove', 'node', function (evt) {
|
|
1568
|
+
const node = evt.target;
|
|
1569
|
+
self._showTooltip(self._tooltipTextForNode(node), evt.originalEvent);
|
|
1570
|
+
});
|
|
1571
|
+
this.cy.on('mouseout', 'node', function () {
|
|
1572
|
+
self._hideTooltip();
|
|
1573
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1574
|
+
self._clearHighlighting();
|
|
1575
|
+
}
|
|
1576
|
+
if (typeof self.interaction.onNodeHoverEnd === 'function') {
|
|
1577
|
+
self.interaction.onNodeHoverEnd();
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
this.cy.on('mouseover', 'edge', function (evt) {
|
|
1582
|
+
const edge = evt.target;
|
|
1583
|
+
self._showTooltip(self._tooltipTextForEdge(edge), evt.originalEvent);
|
|
1584
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1585
|
+
self._highlightEdge(edge);
|
|
1586
|
+
}
|
|
1587
|
+
if (typeof self.interaction.onEdgeHover === 'function') {
|
|
1588
|
+
self.interaction.onEdgeHover(edge.data());
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
this.cy.on('mousemove', 'edge', function (evt) {
|
|
1592
|
+
const edge = evt.target;
|
|
1593
|
+
self._showTooltip(self._tooltipTextForEdge(edge), evt.originalEvent);
|
|
1594
|
+
});
|
|
1595
|
+
this.cy.on('mouseout', 'edge', function () {
|
|
1596
|
+
self._hideTooltip();
|
|
1597
|
+
if (self.mode === 'browse' && !self._hasActiveSelection()) {
|
|
1598
|
+
self._clearHighlighting();
|
|
1599
|
+
}
|
|
1600
|
+
if (typeof self.interaction.onEdgeHoverEnd === 'function') {
|
|
1601
|
+
self.interaction.onEdgeHoverEnd();
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
_tooltipTextForNode(node) {
|
|
1607
|
+
if (this.mode === 'run') {
|
|
1608
|
+
const bodyTitle = node.data('bodyTitle');
|
|
1609
|
+
if (bodyTitle) return `${bodyTitle}: ${node.data('fullUrn') || node.id()}`;
|
|
1610
|
+
}
|
|
1611
|
+
return node.data('fullUrn') || node.id();
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
_tooltipTextForEdge(edge) {
|
|
1615
|
+
const full = edge.data('fullUrn');
|
|
1616
|
+
if (typeof full === 'string' && full.length > 0) return full;
|
|
1617
|
+
return edge.data('title') || '';
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
_handleNodeTap(node) {
|
|
1621
|
+
// Show-more node in run mode: forward to host and return early.
|
|
1622
|
+
if (this.mode === 'run') {
|
|
1623
|
+
const group = node.data('showMoreGroup');
|
|
1624
|
+
if (group === 'success' || group === 'failure') {
|
|
1625
|
+
if (typeof this.interaction.onShowMoreBodies === 'function') {
|
|
1626
|
+
this.interaction.onShowMoreBodies(group);
|
|
1627
|
+
}
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
if (this.mode === 'browse') {
|
|
1633
|
+
// Second-click on a highlighted node while another is already
|
|
1634
|
+
// selected → enter path exploration.
|
|
1635
|
+
if (this.selectedElement && this.selectedElement.type === 'node' && !this.pathMode) {
|
|
1636
|
+
const source = this.selectedElement.element;
|
|
1637
|
+
if (!source.same(node) && node.hasClass('highlighted')) {
|
|
1638
|
+
this.enterPathMode(source.id(), node.id());
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
if (this.pathMode) this.exitPathMode();
|
|
1643
|
+
this.selectedElement = { type: 'node', element: node };
|
|
1644
|
+
this._highlightConnected(node.id());
|
|
1645
|
+
node.addClass('active');
|
|
1646
|
+
if (this.navigator) this.navigator.showNodeDetail(node.data());
|
|
1647
|
+
} else {
|
|
1648
|
+
this.selectedElement = { type: 'node', element: node };
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (typeof this.interaction.onNodeClick === 'function') {
|
|
1652
|
+
this.interaction.onNodeClick(node.data());
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
_handleEdgeTap(edge) {
|
|
1657
|
+
this.selectedElement = { type: 'edge', element: edge };
|
|
1658
|
+
if (this.mode === 'browse') {
|
|
1659
|
+
this._highlightEdge(edge);
|
|
1660
|
+
edge.addClass('active');
|
|
1661
|
+
const edgeId = edge.id();
|
|
1662
|
+
const capability = this.capabilitiesByEdgeId.get(edgeId) || null;
|
|
1663
|
+
if (this.navigator) this.navigator.showEdgeDetail(edge.data(), capability);
|
|
1664
|
+
}
|
|
1665
|
+
if (typeof this.interaction.onEdgeClick === 'function') {
|
|
1666
|
+
this.interaction.onEdgeClick(edge.data());
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// ===========================================================================
|
|
1671
|
+
// Browse-mode selection API. Used by cap-navigator.js via the
|
|
1672
|
+
// bidirectional setNavigator / setGraph wiring.
|
|
1673
|
+
// ===========================================================================
|
|
1674
|
+
|
|
1675
|
+
highlightCapability(cap) {
|
|
1676
|
+
if (this.mode !== 'browse') {
|
|
1677
|
+
throw new Error(`CapGraphRenderer: highlightCapability is only valid in browse mode (current: ${this.mode})`);
|
|
1678
|
+
}
|
|
1679
|
+
if (!this.cy || !this._layoutReady) {
|
|
1680
|
+
this._pendingFocusCap = cap;
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1685
|
+
const target = CapUrn.fromString(this._capUrnString(cap));
|
|
1686
|
+
|
|
1687
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1688
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1689
|
+
if (candidate.isEquivalent(target)) {
|
|
1690
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1691
|
+
if (edge && edge.length > 0) {
|
|
1692
|
+
this.selectedElement = { type: 'edge', element: edge };
|
|
1693
|
+
this._highlightEdge(edge);
|
|
1694
|
+
edge.addClass('active');
|
|
1695
|
+
}
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
_capUrnString(cap) {
|
|
1702
|
+
if (!cap || typeof cap !== 'object') {
|
|
1703
|
+
throw new Error('CapGraphRenderer: cap must be an object');
|
|
1704
|
+
}
|
|
1705
|
+
if (typeof cap.urn !== 'string' || cap.urn.length === 0) {
|
|
1706
|
+
throw new Error('CapGraphRenderer: cap.urn must be a non-empty string');
|
|
1707
|
+
}
|
|
1708
|
+
return cap.urn;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
selectNodeById(nodeId) {
|
|
1712
|
+
if (!this.cy) return;
|
|
1713
|
+
const node = this.cy.getElementById(nodeId);
|
|
1714
|
+
if (node && node.length > 0) this._handleNodeTap(node);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
getNodeData(nodeId) {
|
|
1718
|
+
if (!this.cy) return null;
|
|
1719
|
+
const node = this.cy.getElementById(nodeId);
|
|
1720
|
+
return node && node.length > 0 ? node.data() : null;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
getEdgeDataByCapUrn(capUrnString) {
|
|
1724
|
+
if (this.mode !== 'browse') return null;
|
|
1725
|
+
if (!this.cy || typeof capUrnString !== 'string' || capUrnString.length === 0) return null;
|
|
1726
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1727
|
+
const target = CapUrn.fromString(capUrnString);
|
|
1728
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1729
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1730
|
+
if (candidate.isEquivalent(target)) {
|
|
1731
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1732
|
+
if (edge && edge.length > 0) return edge.data();
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return null;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
selectEdgeByCapUrn(capUrnString) {
|
|
1739
|
+
if (this.mode !== 'browse') {
|
|
1740
|
+
throw new Error(`CapGraphRenderer: selectEdgeByCapUrn is only valid in browse mode (current: ${this.mode})`);
|
|
1741
|
+
}
|
|
1742
|
+
if (!this.cy || typeof capUrnString !== 'string' || capUrnString.length === 0) return;
|
|
1743
|
+
const CapUrn = requireHostDependency('CapUrn');
|
|
1744
|
+
const target = CapUrn.fromString(capUrnString);
|
|
1745
|
+
for (const [edgeId, edgeCap] of this.capabilitiesByEdgeId) {
|
|
1746
|
+
const candidate = CapUrn.fromString(edgeCap.urn);
|
|
1747
|
+
if (candidate.isEquivalent(target)) {
|
|
1748
|
+
const edge = this.cy.getElementById(edgeId);
|
|
1749
|
+
if (edge && edge.length > 0) {
|
|
1750
|
+
this._handleEdgeTap(edge);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
clearSelection() {
|
|
1758
|
+
if (this.pathMode) this.exitPathMode();
|
|
1759
|
+
this.selectedElement = null;
|
|
1760
|
+
this._clearHighlighting();
|
|
1761
|
+
if (this.mode === 'browse' && this.navigator) {
|
|
1762
|
+
this.navigator.clearGraphSelection();
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
fitAll() {
|
|
1767
|
+
if (!this.cy) return;
|
|
1768
|
+
this.fitToVisibleViewport(this.cy.elements(), 50);
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// ===========================================================================
|
|
1772
|
+
// Theme sync — the renderer owns a MutationObserver on <html
|
|
1773
|
+
// data-theme>, so hosts that use that attribute do not need to call
|
|
1774
|
+
// anything. Hosts that use a different attribute (e.g. the editor's
|
|
1775
|
+
// data-appearance) can call setTheme() explicitly after their own
|
|
1776
|
+
// theme toggle to force a stylesheet re-read.
|
|
1777
|
+
// ===========================================================================
|
|
1778
|
+
|
|
1779
|
+
setTheme() {
|
|
1780
|
+
if (!this.cy) return;
|
|
1781
|
+
this.cy.style(buildStylesheet());
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// ===========================================================================
|
|
1785
|
+
// Machine mode API — used by the editor for cross-highlight.
|
|
1786
|
+
// ===========================================================================
|
|
1787
|
+
|
|
1788
|
+
applyMachineActiveTokenIds(tokenIds) {
|
|
1789
|
+
if (this.mode !== 'machine') {
|
|
1790
|
+
throw new Error(`CapGraphRenderer: applyMachineActiveTokenIds is only valid in machine mode (current: ${this.mode})`);
|
|
1791
|
+
}
|
|
1792
|
+
if (!this.cy) return;
|
|
1793
|
+
const wanted = new Set(tokenIds || []);
|
|
1794
|
+
this.cy.batch(() => {
|
|
1795
|
+
this.cy.elements().forEach(el => {
|
|
1796
|
+
const id = el.data('tokenId');
|
|
1797
|
+
if (id && wanted.has(id)) {
|
|
1798
|
+
el.addClass('active');
|
|
1799
|
+
} else {
|
|
1800
|
+
el.removeClass('active');
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// ===========================================================================
|
|
1807
|
+
// Path exploration (browse mode).
|
|
1808
|
+
// ===========================================================================
|
|
1809
|
+
|
|
1810
|
+
enterPathMode(sourceId, targetId) {
|
|
1811
|
+
if (this.mode !== 'browse') {
|
|
1812
|
+
throw new Error(`CapGraphRenderer: enterPathMode is only valid in browse mode (current: ${this.mode})`);
|
|
1813
|
+
}
|
|
1814
|
+
if (!this.capGraph) return;
|
|
1815
|
+
|
|
1816
|
+
const MAX_PATHS = 10;
|
|
1817
|
+
let paths = this.capGraph.findAllPaths(sourceId, targetId, MAX_PATHS);
|
|
1818
|
+
let actualSource = sourceId;
|
|
1819
|
+
let actualTarget = targetId;
|
|
1820
|
+
if (paths.length === 0) {
|
|
1821
|
+
const reverse = this.capGraph.findAllPaths(targetId, sourceId, MAX_PATHS);
|
|
1822
|
+
if (reverse.length === 0) return;
|
|
1823
|
+
paths = reverse;
|
|
1824
|
+
actualSource = targetId;
|
|
1825
|
+
actualTarget = sourceId;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
this.pathMode = { sourceId: actualSource, targetId: actualTarget, paths, selectedIndex: 0 };
|
|
1829
|
+
this.selectedElement = { type: 'path' };
|
|
1830
|
+
this._highlightPath(paths[0]);
|
|
1831
|
+
|
|
1832
|
+
if (this.navigator) {
|
|
1833
|
+
const sourceNode = this.cy.getElementById(actualSource);
|
|
1834
|
+
const targetNode = this.cy.getElementById(actualTarget);
|
|
1835
|
+
this.navigator.showPathDetail(
|
|
1836
|
+
sourceNode.length > 0 ? sourceNode.data() : { id: actualSource },
|
|
1837
|
+
targetNode.length > 0 ? targetNode.data() : { id: actualTarget },
|
|
1838
|
+
paths,
|
|
1839
|
+
0
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
selectPath(index) {
|
|
1845
|
+
if (!this.pathMode) return;
|
|
1846
|
+
if (index < 0 || index >= this.pathMode.paths.length) return;
|
|
1847
|
+
this.pathMode.selectedIndex = index;
|
|
1848
|
+
this._highlightPath(this.pathMode.paths[index]);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
exitPathMode() {
|
|
1852
|
+
this.pathMode = null;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
_highlightPath(pathEdges) {
|
|
1856
|
+
const pathNodeIds = new Set();
|
|
1857
|
+
const pathEdgeIndices = new Set();
|
|
1858
|
+
for (const pathEdge of pathEdges) {
|
|
1859
|
+
pathNodeIds.add(canonicalMediaUrn(pathEdge.fromUrn));
|
|
1860
|
+
pathNodeIds.add(canonicalMediaUrn(pathEdge.toUrn));
|
|
1861
|
+
const idx = this.capGraph.edges.indexOf(pathEdge);
|
|
1862
|
+
if (idx !== -1) pathEdgeIndices.add(idx);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
this.cy.elements().removeClass('highlighted active faded path-highlighted');
|
|
1866
|
+
this.cy.elements().addClass('faded');
|
|
1867
|
+
|
|
1868
|
+
this.cy.nodes().forEach(node => {
|
|
1869
|
+
if (pathNodeIds.has(node.id())) node.removeClass('faded').addClass('path-highlighted');
|
|
1870
|
+
});
|
|
1871
|
+
this.cy.edges().forEach(edge => {
|
|
1872
|
+
const cyIdx = edge.data('capGraphEdgeIndex');
|
|
1873
|
+
if (cyIdx !== undefined && pathEdgeIndices.has(cyIdx)) {
|
|
1874
|
+
edge.removeClass('faded').addClass('path-highlighted');
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
if (this.pathMode) {
|
|
1879
|
+
const source = this.cy.getElementById(this.pathMode.sourceId);
|
|
1880
|
+
const target = this.cy.getElementById(this.pathMode.targetId);
|
|
1881
|
+
if (source.length > 0) source.addClass('active');
|
|
1882
|
+
if (target.length > 0) target.addClass('active');
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
// ===========================================================================
|
|
1887
|
+
// Highlight helpers.
|
|
1888
|
+
// ===========================================================================
|
|
1889
|
+
|
|
1890
|
+
_hasActiveSelection() {
|
|
1891
|
+
return this.selectedElement !== null;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
_highlightEdge(edge) {
|
|
1895
|
+
this.cy.elements().removeClass('highlighted active faded');
|
|
1896
|
+
this.cy.elements().addClass('faded');
|
|
1897
|
+
edge.removeClass('faded').addClass('highlighted');
|
|
1898
|
+
const src = edge.source();
|
|
1899
|
+
const tgt = edge.target();
|
|
1900
|
+
src.removeClass('faded').addClass('highlighted');
|
|
1901
|
+
tgt.removeClass('faded').addClass('highlighted');
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
_highlightConnected(nodeId) {
|
|
1905
|
+
const connected = this._findConnected(nodeId);
|
|
1906
|
+
this.cy.elements().removeClass('highlighted active faded');
|
|
1907
|
+
this.cy.elements().addClass('faded');
|
|
1908
|
+
this.cy.nodes().forEach(node => {
|
|
1909
|
+
if (connected.has(node.id())) node.removeClass('faded').addClass('highlighted');
|
|
1910
|
+
});
|
|
1911
|
+
this.cy.edges().forEach(edge => {
|
|
1912
|
+
const s = edge.source().id();
|
|
1913
|
+
const t = edge.target().id();
|
|
1914
|
+
if (connected.has(s) && connected.has(t)) edge.removeClass('faded').addClass('highlighted');
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
_clearHighlighting() {
|
|
1919
|
+
if (!this.cy) return;
|
|
1920
|
+
this.cy.elements().removeClass('highlighted active faded path-highlighted');
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
_findReachableFrom(startId) {
|
|
1924
|
+
const reachable = new Set([startId]);
|
|
1925
|
+
const queue = [startId];
|
|
1926
|
+
while (queue.length > 0) {
|
|
1927
|
+
const current = queue.shift();
|
|
1928
|
+
const neighbors = this.adjacency.get(current);
|
|
1929
|
+
if (!neighbors) continue;
|
|
1930
|
+
for (const n of neighbors) {
|
|
1931
|
+
if (!reachable.has(n)) {
|
|
1932
|
+
reachable.add(n);
|
|
1933
|
+
queue.push(n);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return reachable;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
_findReachableTo(targetId) {
|
|
1941
|
+
const canReach = new Set([targetId]);
|
|
1942
|
+
const queue = [targetId];
|
|
1943
|
+
while (queue.length > 0) {
|
|
1944
|
+
const current = queue.shift();
|
|
1945
|
+
const preds = this.reverseAdj.get(current);
|
|
1946
|
+
if (!preds) continue;
|
|
1947
|
+
for (const p of preds) {
|
|
1948
|
+
if (!canReach.has(p)) {
|
|
1949
|
+
canReach.add(p);
|
|
1950
|
+
queue.push(p);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return canReach;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
_findConnected(nodeId) {
|
|
1958
|
+
const from = this._findReachableFrom(nodeId);
|
|
1959
|
+
const to = this._findReachableTo(nodeId);
|
|
1960
|
+
return new Set([...from, ...to]);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// ===========================================================================
|
|
1964
|
+
// Viewport fit. Single entry point for all "re-fit" callers: layout
|
|
1965
|
+
// stop, resize, navigator-driven refit, dbltap-reset.
|
|
1966
|
+
// ===========================================================================
|
|
1967
|
+
|
|
1968
|
+
refitCurrentSelection() {
|
|
1969
|
+
if (!this.cy || !this._layoutReady) return;
|
|
1970
|
+
|
|
1971
|
+
if (this.pathMode) {
|
|
1972
|
+
const pathElements = this.cy.elements('.path-highlighted, .active');
|
|
1973
|
+
if (pathElements.length > 0) {
|
|
1974
|
+
this.fitToVisibleViewport(pathElements, 60);
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
if (this.selectedElement) {
|
|
1980
|
+
if (this.selectedElement.type === 'node') {
|
|
1981
|
+
const nodeId = this.selectedElement.element.id();
|
|
1982
|
+
if (this.mode === 'browse') {
|
|
1983
|
+
const connected = this._findConnected(nodeId);
|
|
1984
|
+
const connectedElements = this.cy.nodes().filter(n => connected.has(n.id()));
|
|
1985
|
+
if (connectedElements.length > 0) {
|
|
1986
|
+
this.fitToVisibleViewport(connectedElements, 60);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
} else {
|
|
1990
|
+
this.fitToVisibleViewport(this.selectedElement.element, 80);
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
} else if (this.selectedElement.type === 'edge') {
|
|
1994
|
+
const edge = this.selectedElement.element;
|
|
1995
|
+
this.fitToVisibleViewport(edge.union(edge.source()).union(edge.target()), 100);
|
|
1996
|
+
return;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
this.fitToVisibleViewport(undefined, 50);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
fitToVisibleViewport(eles, padding, animate) {
|
|
2004
|
+
if (!this.cy) return;
|
|
2005
|
+
if (padding === undefined) padding = 50;
|
|
2006
|
+
if (animate === undefined) animate = true;
|
|
2007
|
+
|
|
2008
|
+
this.cy.stop(true);
|
|
2009
|
+
if (!eles || eles.length === 0) eles = this.cy.elements();
|
|
2010
|
+
|
|
2011
|
+
const bb = eles.boundingBox();
|
|
2012
|
+
if (bb.w === 0 && bb.h === 0) return;
|
|
2013
|
+
|
|
2014
|
+
const containerWidth = this.cy.width();
|
|
2015
|
+
const containerHeight = this.cy.height();
|
|
2016
|
+
const excluded = Math.max(0, this.bottomExcludedRegion() | 0);
|
|
2017
|
+
|
|
2018
|
+
const visibleWidth = containerWidth - padding * 2;
|
|
2019
|
+
const visibleHeight = containerHeight - excluded - padding * 2;
|
|
2020
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) return;
|
|
2021
|
+
|
|
2022
|
+
const zoom = Math.min(visibleWidth / bb.w, visibleHeight / bb.h);
|
|
2023
|
+
const clampedZoom = Math.min(Math.max(zoom, this.cy.minZoom()), this.cy.maxZoom());
|
|
2024
|
+
|
|
2025
|
+
const modelCenterX = (bb.x1 + bb.x2) / 2;
|
|
2026
|
+
const modelCenterY = (bb.y1 + bb.y2) / 2;
|
|
2027
|
+
const screenCenterX = containerWidth / 2;
|
|
2028
|
+
const screenCenterY = (containerHeight - excluded) / 2;
|
|
2029
|
+
const panX = screenCenterX - modelCenterX * clampedZoom;
|
|
2030
|
+
const panY = screenCenterY - modelCenterY * clampedZoom;
|
|
2031
|
+
|
|
2032
|
+
if (animate) {
|
|
2033
|
+
this.cy.animate({
|
|
2034
|
+
zoom: clampedZoom,
|
|
2035
|
+
pan: { x: panX, y: panY },
|
|
2036
|
+
duration: 400,
|
|
2037
|
+
easing: 'ease-out-cubic',
|
|
2038
|
+
});
|
|
2039
|
+
} else {
|
|
2040
|
+
this.cy.zoom(clampedZoom);
|
|
2041
|
+
this.cy.pan({ x: panX, y: panY });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// ===========================================================================
|
|
2046
|
+
// Tooltip helpers.
|
|
2047
|
+
// ===========================================================================
|
|
2048
|
+
|
|
2049
|
+
_showTooltip(text, mouseEvent) {
|
|
2050
|
+
if (!this.tooltip) return;
|
|
2051
|
+
if (!text) return;
|
|
2052
|
+
this.tooltip.textContent = text;
|
|
2053
|
+
this.tooltip.style.display = 'block';
|
|
2054
|
+
const x = mouseEvent ? mouseEvent.clientX : 0;
|
|
2055
|
+
const y = mouseEvent ? mouseEvent.clientY : 0;
|
|
2056
|
+
this.tooltip.style.left = (x + 12) + 'px';
|
|
2057
|
+
this.tooltip.style.top = (y + 12) + 'px';
|
|
2058
|
+
const rect = this.tooltip.getBoundingClientRect();
|
|
2059
|
+
if (rect.right > window.innerWidth) {
|
|
2060
|
+
this.tooltip.style.left = (x - rect.width - 12) + 'px';
|
|
2061
|
+
}
|
|
2062
|
+
if (rect.bottom > window.innerHeight) {
|
|
2063
|
+
this.tooltip.style.top = (y - rect.height - 12) + 'px';
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
_hideTooltip() {
|
|
2068
|
+
if (this.tooltip) this.tooltip.style.display = 'none';
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// ===========================================================================
|
|
2072
|
+
// Teardown.
|
|
2073
|
+
// ===========================================================================
|
|
2074
|
+
|
|
2075
|
+
destroy() {
|
|
2076
|
+
if (this.themeObserver) {
|
|
2077
|
+
this.themeObserver.disconnect();
|
|
2078
|
+
this.themeObserver = null;
|
|
2079
|
+
}
|
|
2080
|
+
if (this.tooltip && this.tooltip.parentNode) {
|
|
2081
|
+
this.tooltip.parentNode.removeChild(this.tooltip);
|
|
2082
|
+
this.tooltip = null;
|
|
2083
|
+
}
|
|
2084
|
+
if (this.cy) {
|
|
2085
|
+
this.cy.destroy();
|
|
2086
|
+
this.cy = null;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// =============================================================================
|
|
2092
|
+
// Module exports — CJS for Node tests. Browser-side the build-browser.js
|
|
2093
|
+
// concatenation wraps these declarations in an IIFE and assigns
|
|
2094
|
+
// `window.CapGraphRenderer`.
|
|
2095
|
+
// =============================================================================
|
|
2096
|
+
|
|
2097
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
2098
|
+
module.exports = {
|
|
2099
|
+
CapGraphRenderer,
|
|
2100
|
+
cardinalityLabel,
|
|
2101
|
+
cardinalityFromCap,
|
|
2102
|
+
canonicalMediaUrn,
|
|
2103
|
+
mediaNodeLabel,
|
|
2104
|
+
buildBrowseGraphData,
|
|
2105
|
+
buildStrandGraphData,
|
|
2106
|
+
buildRunGraphData,
|
|
2107
|
+
buildMachineGraphData,
|
|
2108
|
+
classifyStrandCapSteps,
|
|
2109
|
+
validateStrandPayload,
|
|
2110
|
+
validateRunPayload,
|
|
2111
|
+
validateMachinePayload,
|
|
2112
|
+
validateStrandStep,
|
|
2113
|
+
validateBodyOutcome,
|
|
2114
|
+
};
|
|
2115
|
+
}
|