capdag 0.104.240 → 0.106.243

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