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.
@@ -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
+ }