capdag 0.119.263 → 0.124.274
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cap-graph-renderer.js +721 -452
- package/capdag.js +123 -132
- package/capdag.test.js +716 -329
- package/package.json +1 -1
package/cap-graph-renderer.js
CHANGED
|
@@ -185,7 +185,7 @@ function layoutForMode(mode) {
|
|
|
185
185
|
'elk.spacing.nodeNode': 35,
|
|
186
186
|
});
|
|
187
187
|
}
|
|
188
|
-
if (mode === '
|
|
188
|
+
if (mode === 'editor-graph') {
|
|
189
189
|
// Editor graph is a small bipartite-ish DAG; orthogonal routing
|
|
190
190
|
// reads more cleanly than polyline at this density.
|
|
191
191
|
return {
|
|
@@ -199,6 +199,16 @@ function layoutForMode(mode) {
|
|
|
199
199
|
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
|
|
200
200
|
};
|
|
201
201
|
}
|
|
202
|
+
if (mode === 'machine') {
|
|
203
|
+
// Resolved-machine graph: a (potentially multi-strand) DAG laid
|
|
204
|
+
// out left-to-right with the same spacing the strand mode uses
|
|
205
|
+
// for single strands. Multiple disconnected strands are placed
|
|
206
|
+
// side by side by elk's component packer.
|
|
207
|
+
return Object.assign({}, base, {
|
|
208
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': 120,
|
|
209
|
+
'elk.spacing.nodeNode': 40,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
202
212
|
throw new Error(`CapGraphRenderer: unknown mode '${mode}'`);
|
|
203
213
|
}
|
|
204
214
|
|
|
@@ -222,6 +232,16 @@ function buildStylesheet() {
|
|
|
222
232
|
const bodyEdgeSuccess = getCssVar('--graph-body-edge-success');
|
|
223
233
|
const bodyEdgeFailure = getCssVar('--graph-body-edge-failure');
|
|
224
234
|
|
|
235
|
+
// Machine mode uses a dedicated palette exposed by the notation
|
|
236
|
+
// editor's CSS (`--graph-node-color`, `--graph-loop-color`,
|
|
237
|
+
// `--graph-edge-color`, `--graph-text`, `--graph-muted`). Caps
|
|
238
|
+
// are collapsed into edges so there's no cap-node color.
|
|
239
|
+
const machineNodeColor = getCssVar('--graph-node-color');
|
|
240
|
+
const machineLoopColor = getCssVar('--graph-loop-color');
|
|
241
|
+
const machineEdgeColor = getCssVar('--graph-edge-color');
|
|
242
|
+
const machineText = getCssVar('--graph-text');
|
|
243
|
+
const machineMuted = getCssVar('--graph-muted');
|
|
244
|
+
|
|
225
245
|
return [
|
|
226
246
|
{
|
|
227
247
|
selector: 'node',
|
|
@@ -290,11 +310,14 @@ function buildStylesheet() {
|
|
|
290
310
|
'font-weight': '500',
|
|
291
311
|
'color': 'data(color)',
|
|
292
312
|
'text-background-color': edgeTextBg,
|
|
293
|
-
'text-background-opacity':
|
|
294
|
-
'text-background-padding': '
|
|
313
|
+
'text-background-opacity': 1,
|
|
314
|
+
'text-background-padding': '4px',
|
|
295
315
|
'text-background-shape': 'roundrectangle',
|
|
296
|
-
|
|
297
|
-
|
|
316
|
+
// Keep labels horizontal — autorotate clips along
|
|
317
|
+
// fan-out edges and rotates labels into unreadable
|
|
318
|
+
// angles when multiple edges share a source.
|
|
319
|
+
'text-rotation': 0,
|
|
320
|
+
'text-margin-y': 0,
|
|
298
321
|
'curve-style': 'bezier',
|
|
299
322
|
'control-point-step-size': 40,
|
|
300
323
|
'width': 1.5,
|
|
@@ -320,11 +343,21 @@ function buildStylesheet() {
|
|
|
320
343
|
},
|
|
321
344
|
{
|
|
322
345
|
selector: 'edge.body-success',
|
|
323
|
-
style: {
|
|
346
|
+
style: {
|
|
347
|
+
'line-color': bodyEdgeSuccess,
|
|
348
|
+
'target-arrow-color': bodyEdgeSuccess,
|
|
349
|
+
// Label text uses the resolved success color too, not the
|
|
350
|
+
// unresolved `var(...)` string stored in `data.color`.
|
|
351
|
+
'color': bodyEdgeSuccess,
|
|
352
|
+
},
|
|
324
353
|
},
|
|
325
354
|
{
|
|
326
355
|
selector: 'edge.body-failure',
|
|
327
|
-
style: {
|
|
356
|
+
style: {
|
|
357
|
+
'line-color': bodyEdgeFailure,
|
|
358
|
+
'target-arrow-color': bodyEdgeFailure,
|
|
359
|
+
'color': bodyEdgeFailure,
|
|
360
|
+
},
|
|
328
361
|
},
|
|
329
362
|
{
|
|
330
363
|
selector: 'node.path-highlighted',
|
|
@@ -334,6 +367,44 @@ function buildStylesheet() {
|
|
|
334
367
|
selector: 'edge.path-highlighted',
|
|
335
368
|
style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
|
|
336
369
|
},
|
|
370
|
+
|
|
371
|
+
// -------- Machine / editor-graph mode ------------------------------
|
|
372
|
+
// Shared between the resolved-machine view (`mode: 'machine'`,
|
|
373
|
+
// canonical `Machine::to_render_payload_json`) and the notation
|
|
374
|
+
// editor's live graph (`mode: 'editor-graph'`, ast-driven). Both
|
|
375
|
+
// collapse each cap application into a single labeled edge
|
|
376
|
+
// between its input and output data slots — caps do NOT appear
|
|
377
|
+
// as separate nodes. Only data slots are nodes; cap semantics
|
|
378
|
+
// are carried on edges.
|
|
379
|
+
//
|
|
380
|
+
// Reads the editor's dedicated palette so nodes, edges and
|
|
381
|
+
// loop markers match the text theme.
|
|
382
|
+
{
|
|
383
|
+
selector: 'node.machine-node',
|
|
384
|
+
style: {
|
|
385
|
+
'background-color': machineNodeColor || nodeBg,
|
|
386
|
+
'border-color': machineNodeColor || nodeBorder,
|
|
387
|
+
'color': machineText || nodeText,
|
|
388
|
+
'border-opacity': 1,
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
selector: 'edge.machine-edge',
|
|
393
|
+
style: {
|
|
394
|
+
'line-color': machineEdgeColor || 'data(color)',
|
|
395
|
+
'target-arrow-color': machineEdgeColor || 'data(color)',
|
|
396
|
+
'color': machineMuted || nodeText,
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
selector: 'edge.machine-loop',
|
|
401
|
+
style: {
|
|
402
|
+
'line-color': machineLoopColor || nodeBorderActive,
|
|
403
|
+
'target-arrow-color': machineLoopColor || nodeBorderActive,
|
|
404
|
+
'color': machineLoopColor || nodeBorderActive,
|
|
405
|
+
'line-style': 'dashed',
|
|
406
|
+
},
|
|
407
|
+
},
|
|
337
408
|
];
|
|
338
409
|
}
|
|
339
410
|
|
|
@@ -448,6 +519,9 @@ function validateStrandPayload(data) {
|
|
|
448
519
|
&& (data.media_display_names === null || typeof data.media_display_names !== 'object')) {
|
|
449
520
|
throw new Error('CapGraphRenderer strand mode: data.media_display_names must be an object when present');
|
|
450
521
|
}
|
|
522
|
+
if (data.source_display !== undefined && typeof data.source_display !== 'string') {
|
|
523
|
+
throw new Error('CapGraphRenderer strand mode: data.source_display must be a string when present');
|
|
524
|
+
}
|
|
451
525
|
}
|
|
452
526
|
|
|
453
527
|
function validateBodyOutcome(outcome, path) {
|
|
@@ -499,28 +573,96 @@ function validateRunPayload(data) {
|
|
|
499
573
|
}
|
|
500
574
|
}
|
|
501
575
|
|
|
502
|
-
function
|
|
576
|
+
function validateEditorGraphPayload(data) {
|
|
503
577
|
if (!data || typeof data !== 'object') {
|
|
504
|
-
throw new Error('CapGraphRenderer
|
|
578
|
+
throw new Error('CapGraphRenderer editor-graph mode: data must be an object');
|
|
505
579
|
}
|
|
506
|
-
assertArray(data.elements, '
|
|
580
|
+
assertArray(data.elements, 'editor-graph mode data.elements');
|
|
507
581
|
data.elements.forEach((el, idx) => {
|
|
508
582
|
if (!el || typeof el !== 'object') {
|
|
509
|
-
throw new Error(`CapGraphRenderer
|
|
583
|
+
throw new Error(`CapGraphRenderer editor-graph mode: data.elements[${idx}] is not an object`);
|
|
510
584
|
}
|
|
511
585
|
if (el.kind !== 'node' && el.kind !== 'cap' && el.kind !== 'edge') {
|
|
512
586
|
throw new Error(
|
|
513
|
-
`CapGraphRenderer
|
|
587
|
+
`CapGraphRenderer editor-graph mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
|
|
514
588
|
);
|
|
515
589
|
}
|
|
516
|
-
assertString(el.graph_id, `
|
|
590
|
+
assertString(el.graph_id, `editor-graph mode data.elements[${idx}].graph_id`);
|
|
517
591
|
if (el.kind === 'edge') {
|
|
518
|
-
assertString(el.source_graph_id, `
|
|
519
|
-
assertString(el.target_graph_id, `
|
|
592
|
+
assertString(el.source_graph_id, `editor-graph mode data.elements[${idx}].source_graph_id`);
|
|
593
|
+
assertString(el.target_graph_id, `editor-graph mode data.elements[${idx}].target_graph_id`);
|
|
520
594
|
}
|
|
521
595
|
});
|
|
522
596
|
}
|
|
523
597
|
|
|
598
|
+
// `validateResolvedMachinePayload` checks the canonical machine
|
|
599
|
+
// render payload produced by Rust `Machine::to_render_payload_json`.
|
|
600
|
+
// Shape:
|
|
601
|
+
// {
|
|
602
|
+
// "strands": [
|
|
603
|
+
// {
|
|
604
|
+
// "nodes": [{"id": "n0", "urn": "media:pdf"}, ...],
|
|
605
|
+
// "edges": [{
|
|
606
|
+
// "alias": "edge_0",
|
|
607
|
+
// "cap_urn": "...",
|
|
608
|
+
// "is_loop": false,
|
|
609
|
+
// "assignment": [{"cap_arg_media_urn": "media:pdf", "source_node": "n0"}, ...],
|
|
610
|
+
// "target_node": "n1"
|
|
611
|
+
// }, ...],
|
|
612
|
+
// "input_anchor_nodes": ["n0"],
|
|
613
|
+
// "output_anchor_nodes": ["n2"]
|
|
614
|
+
// },
|
|
615
|
+
// ...
|
|
616
|
+
// ]
|
|
617
|
+
// }
|
|
618
|
+
function validateResolvedMachinePayload(data) {
|
|
619
|
+
if (!data || typeof data !== 'object') {
|
|
620
|
+
throw new Error('CapGraphRenderer machine mode: data must be an object');
|
|
621
|
+
}
|
|
622
|
+
assertArray(data.strands, 'machine mode data.strands');
|
|
623
|
+
data.strands.forEach((strand, sIdx) => {
|
|
624
|
+
if (!strand || typeof strand !== 'object') {
|
|
625
|
+
throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}] is not an object`);
|
|
626
|
+
}
|
|
627
|
+
assertArray(strand.nodes, `machine mode data.strands[${sIdx}].nodes`);
|
|
628
|
+
strand.nodes.forEach((n, nIdx) => {
|
|
629
|
+
if (!n || typeof n !== 'object') {
|
|
630
|
+
throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].nodes[${nIdx}] is not an object`);
|
|
631
|
+
}
|
|
632
|
+
assertString(n.id, `machine mode data.strands[${sIdx}].nodes[${nIdx}].id`);
|
|
633
|
+
assertString(n.urn, `machine mode data.strands[${sIdx}].nodes[${nIdx}].urn`);
|
|
634
|
+
});
|
|
635
|
+
assertArray(strand.edges, `machine mode data.strands[${sIdx}].edges`);
|
|
636
|
+
strand.edges.forEach((e, eIdx) => {
|
|
637
|
+
if (!e || typeof e !== 'object') {
|
|
638
|
+
throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}] is not an object`);
|
|
639
|
+
}
|
|
640
|
+
assertString(e.alias, `machine mode data.strands[${sIdx}].edges[${eIdx}].alias`);
|
|
641
|
+
assertString(e.cap_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].cap_urn`);
|
|
642
|
+
if (typeof e.is_loop !== 'boolean') {
|
|
643
|
+
throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].is_loop must be boolean`);
|
|
644
|
+
}
|
|
645
|
+
assertArray(e.assignment, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment`);
|
|
646
|
+
e.assignment.forEach((b, bIdx) => {
|
|
647
|
+
if (!b || typeof b !== 'object') {
|
|
648
|
+
throw new Error(`CapGraphRenderer machine mode: data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}] is not an object`);
|
|
649
|
+
}
|
|
650
|
+
assertString(b.cap_arg_media_urn, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].cap_arg_media_urn`);
|
|
651
|
+
assertString(b.source_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].assignment[${bIdx}].source_node`);
|
|
652
|
+
});
|
|
653
|
+
assertString(e.target_node, `machine mode data.strands[${sIdx}].edges[${eIdx}].target_node`);
|
|
654
|
+
});
|
|
655
|
+
assertArray(strand.input_anchor_nodes, `machine mode data.strands[${sIdx}].input_anchor_nodes`);
|
|
656
|
+
strand.input_anchor_nodes.forEach((id, iIdx) => {
|
|
657
|
+
assertString(id, `machine mode data.strands[${sIdx}].input_anchor_nodes[${iIdx}]`);
|
|
658
|
+
});
|
|
659
|
+
assertArray(strand.output_anchor_nodes, `machine mode data.strands[${sIdx}].output_anchor_nodes`);
|
|
660
|
+
strand.output_anchor_nodes.forEach((id, oIdx) => {
|
|
661
|
+
assertString(id, `machine mode data.strands[${sIdx}].output_anchor_nodes[${oIdx}]`);
|
|
662
|
+
});
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
524
666
|
// =============================================================================
|
|
525
667
|
// Per-mode graph builders. Each returns the cytoscape `elements` list plus
|
|
526
668
|
// any mode-specific bookkeeping stored on the renderer instance.
|
|
@@ -759,27 +901,9 @@ function buildStrandGraphData(data) {
|
|
|
759
901
|
// terminology). Render-time collapse uses this to relabel the
|
|
760
902
|
// edge with the cap title + (1→n) marker. Defaults to false.
|
|
761
903
|
foreachEntry: m.foreachEntry === true,
|
|
762
|
-
// `singleCapClosedBody` flags a foreach-entry cap edge whose
|
|
763
|
-
// body is closed by a Collect AND contains exactly one cap.
|
|
764
|
-
// Such a cap is both the body entry and the body exit; the
|
|
765
|
-
// render labels the edge with (n→n) to combine iterate+collect.
|
|
766
|
-
singleCapClosedBody: m.singleCapClosedBody === true,
|
|
767
904
|
});
|
|
768
905
|
edgeCounter++;
|
|
769
906
|
}
|
|
770
|
-
// Find the most recently added cap edge whose target is `nodeId`
|
|
771
|
-
// and stamp it with `singleCapClosedBody=true`. Used by the Collect
|
|
772
|
-
// handler when a closed body has exactly one cap (bodyEntry === bodyExit).
|
|
773
|
-
function markSingleCapClosedBody(nodeId) {
|
|
774
|
-
if (!nodeId) return;
|
|
775
|
-
for (let idx = edges.length - 1; idx >= 0; idx--) {
|
|
776
|
-
const e = edges[idx];
|
|
777
|
-
if (e.edgeClass === 'strand-cap-edge' && e.target === nodeId) {
|
|
778
|
-
e.singleCapClosedBody = true;
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
907
|
|
|
784
908
|
// Entry node — the strand's source media spec.
|
|
785
909
|
const inputSlotId = 'input_slot';
|
|
@@ -892,15 +1016,6 @@ function buildStrandGraphData(data) {
|
|
|
892
1016
|
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
893
1017
|
addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
894
1018
|
|
|
895
|
-
// Single-cap closed body: the single cap serves as both the
|
|
896
|
-
// body entry and the body exit. The render collapse uses
|
|
897
|
-
// this flag to pick the (n→n) combined cardinality marker
|
|
898
|
-
// for the entry edge and emit a plain unlabeled connector
|
|
899
|
-
// for the synthesized exit edge.
|
|
900
|
-
if (bodyEntry !== null && bodyEntry === bodyExit) {
|
|
901
|
-
markSingleCapClosedBody(bodyEntry);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
1019
|
insideForEachBody = null;
|
|
905
1020
|
bodyEntry = null;
|
|
906
1021
|
bodyExit = null;
|
|
@@ -923,10 +1038,10 @@ function buildStrandGraphData(data) {
|
|
|
923
1038
|
});
|
|
924
1039
|
|
|
925
1040
|
// Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
|
|
926
|
-
// An unclosed ForEach with a body
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
1041
|
+
// An unclosed ForEach with a body just creates the synthetic
|
|
1042
|
+
// ForEach node + iteration edge and leaves `prev_node_id` at
|
|
1043
|
+
// the last body cap. The render collapse drops the ForEach node;
|
|
1044
|
+
// the cap edges inside the body keep their own labels verbatim.
|
|
930
1045
|
if (insideForEachBody !== null) {
|
|
931
1046
|
const outer = insideForEachBody;
|
|
932
1047
|
const hasBodyEntry = bodyEntry !== null;
|
|
@@ -974,21 +1089,18 @@ function buildStrandGraphData(data) {
|
|
|
974
1089
|
//
|
|
975
1090
|
// 2. The first cap edge entering a ForEach body is relabeled to
|
|
976
1091
|
// `<cap_title> (1→n)`. The builder flags those edges with
|
|
977
|
-
// `foreachEntry: true`. The
|
|
978
|
-
// the cap's own
|
|
979
|
-
//
|
|
1092
|
+
// `foreachEntry: true`. The collapse does NOT relabel this
|
|
1093
|
+
// edge — the cap's own cardinality marker (from its
|
|
1094
|
+
// `input_is_sequence`/`output_is_sequence`) is the single
|
|
1095
|
+
// source of truth. A `(1→n)` marker on a strand edge means
|
|
1096
|
+
// the cap on that edge produces a sequence, and that's
|
|
1097
|
+
// already reflected in the builder's label.
|
|
980
1098
|
//
|
|
981
|
-
// 3.
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// `
|
|
986
|
-
// `(n→n)` label on the entry edge, and the exit side becomes
|
|
987
|
-
// a plain unlabeled connector.
|
|
988
|
-
//
|
|
989
|
-
// 4. Standalone Collect (not wrapping a ForEach) synthesizes a
|
|
990
|
-
// plain edge labeled `"collect"` — the Collect is rendered
|
|
991
|
-
// as a transition, not the preceding cap's own label.
|
|
1099
|
+
// 3. Every Collect (whether closing a body or standalone) is
|
|
1100
|
+
// replaced by a plain unlabeled bridging edge from the
|
|
1101
|
+
// body-exit node to the post-collect target. Any cardinality
|
|
1102
|
+
// shift introduced by the collect is already visible on the
|
|
1103
|
+
// post-collect cap's `input_is_sequence=true` flag.
|
|
992
1104
|
//
|
|
993
1105
|
// 4. If the last cap step's `to_spec` is semantically equivalent
|
|
994
1106
|
// to the strand's `target_spec` (via MediaUrn.isEquivalent),
|
|
@@ -997,40 +1109,20 @@ function buildStrandGraphData(data) {
|
|
|
997
1109
|
// duplicate node.
|
|
998
1110
|
function collapseStrandShapeTransitions(built) {
|
|
999
1111
|
const MediaUrn = requireHostDependency('MediaUrn');
|
|
1000
|
-
const foreachCardinality = cardinalityLabel(false, true); // "1→n"
|
|
1001
|
-
const collectCardinality = cardinalityLabel(true, false); // "n→1"
|
|
1002
1112
|
|
|
1003
1113
|
// Index for lookups.
|
|
1004
1114
|
const nodeById = new Map();
|
|
1005
1115
|
for (const n of built.nodes) nodeById.set(n.id, n);
|
|
1006
1116
|
|
|
1007
|
-
// Step 1: before dropping Collect nodes, synthesize
|
|
1117
|
+
// Step 1: before dropping Collect nodes, synthesize a plain
|
|
1008
1118
|
// bridging edge that replaces each dropped Collect. For a
|
|
1009
1119
|
// Collect node C the plan builder produced:
|
|
1010
1120
|
// body_exit → C (strand-collection, label="collect")
|
|
1011
1121
|
// C → next (strand-cap-edge, label="")
|
|
1012
|
-
// The collapse drops C and its two touching edges.
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
// incoming edge):
|
|
1017
|
-
//
|
|
1018
|
-
// * Standalone Collect (no enclosing foreach; body_exit is a
|
|
1019
|
-
// plain cap with no foreach markers): synthesize a plain
|
|
1020
|
-
// edge labeled "collect" — the Collect itself is the
|
|
1021
|
-
// transition the user sees.
|
|
1022
|
-
//
|
|
1023
|
-
// * Closed-body Collect with a single-cap body (the body_exit
|
|
1024
|
-
// cap edge is flagged `singleCapClosedBody`): synthesize an
|
|
1025
|
-
// unlabeled connector. The single cap's own entry edge is
|
|
1026
|
-
// relabeled with (n→n) in step 2 and carries both the
|
|
1027
|
-
// iterate and collect semantics at once.
|
|
1028
|
-
//
|
|
1029
|
-
// * Closed-body Collect with a multi-cap body (body_exit cap
|
|
1030
|
-
// edge is a plain cap edge inside the body, NOT marked as
|
|
1031
|
-
// foreachEntry or singleCapClosedBody): label the synth
|
|
1032
|
-
// edge with the body-exit cap's title + (n→1) — the
|
|
1033
|
-
// symmetric counterpart of the foreach-entry (1→n) label.
|
|
1122
|
+
// The collapse drops C and its two touching edges. We emit an
|
|
1123
|
+
// unlabeled `body_exit → next` cap edge in their place. The
|
|
1124
|
+
// Collect transition itself is invisible — any cardinality
|
|
1125
|
+
// shift is already on the post-collect cap's own label.
|
|
1034
1126
|
const synthesizedExitEdges = [];
|
|
1035
1127
|
for (const node of built.nodes) {
|
|
1036
1128
|
if (node.nodeClass !== 'strand-collect') continue;
|
|
@@ -1043,69 +1135,22 @@ function collapseStrandShapeTransitions(built) {
|
|
|
1043
1135
|
const bodyExitNodeId = inEdge.source;
|
|
1044
1136
|
const bodyExitCapEdge = built.edges.find(e =>
|
|
1045
1137
|
e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
// one of its upstream cap nodes). Simpler heuristic that
|
|
1053
|
-
// works for every shape we emit: the body_exit has an
|
|
1054
|
-
// ancestor that is a strand-foreach node.
|
|
1055
|
-
let hasEnclosingForeach = false;
|
|
1056
|
-
{
|
|
1057
|
-
const visited = new Set();
|
|
1058
|
-
const stack = [bodyExitNodeId];
|
|
1059
|
-
while (stack.length > 0) {
|
|
1060
|
-
const cur = stack.pop();
|
|
1061
|
-
if (visited.has(cur)) continue;
|
|
1062
|
-
visited.add(cur);
|
|
1063
|
-
const curNode = built.nodes.find(n => n.id === cur);
|
|
1064
|
-
if (curNode && curNode.nodeClass === 'strand-foreach') {
|
|
1065
|
-
hasEnclosingForeach = true;
|
|
1066
|
-
break;
|
|
1067
|
-
}
|
|
1068
|
-
for (const up of built.edges) {
|
|
1069
|
-
if (up.target === cur) stack.push(up.source);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
let synthLabel;
|
|
1074
|
-
let synthTitle;
|
|
1075
|
-
let synthFullUrn;
|
|
1076
|
-
if (!hasEnclosingForeach) {
|
|
1077
|
-
// Standalone Collect.
|
|
1078
|
-
synthLabel = 'collect';
|
|
1079
|
-
synthTitle = 'collect';
|
|
1080
|
-
synthFullUrn = '';
|
|
1081
|
-
} else if (bodyExitCapEdge && bodyExitCapEdge.singleCapClosedBody) {
|
|
1082
|
-
// Single-cap closed body. The cap's own entry edge is
|
|
1083
|
-
// relabeled with (n→n) in step 2; the exit side is just
|
|
1084
|
-
// a connector with no duplicate label.
|
|
1085
|
-
synthLabel = '';
|
|
1086
|
-
synthTitle = '';
|
|
1087
|
-
synthFullUrn = '';
|
|
1088
|
-
} else {
|
|
1089
|
-
// Multi-cap closed body. The body_exit cap's own
|
|
1090
|
-
// incoming edge stays unmodified (carrying just the
|
|
1091
|
-
// cap title, no cardinality markers). The synthesized
|
|
1092
|
-
// exit edge carries the (n→1) collect marker.
|
|
1093
|
-
const title = bodyExitCapEdge ? bodyExitCapEdge.title : '';
|
|
1094
|
-
synthLabel = `${title} (${collectCardinality})`;
|
|
1095
|
-
synthTitle = title;
|
|
1096
|
-
synthFullUrn = bodyExitCapEdge ? bodyExitCapEdge.fullUrn : '';
|
|
1097
|
-
}
|
|
1138
|
+
// The Collect transition is invisible in the render.
|
|
1139
|
+
// Every Collect becomes a plain unlabeled bridge edge
|
|
1140
|
+
// from the body-exit node to the post-collect target.
|
|
1141
|
+
// Any cardinality shift is already visible on the
|
|
1142
|
+
// body-exit cap's own label (via its input/output
|
|
1143
|
+
// sequence flags) or on the post-collect cap's label.
|
|
1098
1144
|
synthesizedExitEdges.push({
|
|
1099
1145
|
id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
|
|
1100
1146
|
source: bodyExitNodeId,
|
|
1101
1147
|
target: outEdge.target,
|
|
1102
|
-
label:
|
|
1103
|
-
title:
|
|
1104
|
-
fullUrn:
|
|
1148
|
+
label: '',
|
|
1149
|
+
title: '',
|
|
1150
|
+
fullUrn: '',
|
|
1105
1151
|
edgeClass: 'strand-cap-edge',
|
|
1106
1152
|
color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
|
|
1107
1153
|
foreachEntry: false,
|
|
1108
|
-
singleCapClosedBody: false,
|
|
1109
1154
|
});
|
|
1110
1155
|
}
|
|
1111
1156
|
}
|
|
@@ -1128,29 +1173,15 @@ function collapseStrandShapeTransitions(built) {
|
|
|
1128
1173
|
e.edgeClass !== 'strand-collection');
|
|
1129
1174
|
edges = edges.concat(synthesizedExitEdges);
|
|
1130
1175
|
|
|
1131
|
-
// Step 2:
|
|
1132
|
-
// cap
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
|
|
1140
|
-
edges = edges.map(e => {
|
|
1141
|
-
if (e.edgeClass !== 'strand-cap-edge') return e;
|
|
1142
|
-
if (e.foreachEntry && e.singleCapClosedBody) {
|
|
1143
|
-
return Object.assign({}, e, {
|
|
1144
|
-
label: `${e.title} (${iterAndCollectCardinality})`,
|
|
1145
|
-
});
|
|
1146
|
-
}
|
|
1147
|
-
if (e.foreachEntry) {
|
|
1148
|
-
return Object.assign({}, e, {
|
|
1149
|
-
label: `${e.title} (${foreachCardinality})`,
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
return e;
|
|
1153
|
-
});
|
|
1176
|
+
// Step 2: foreach-entry cap edges keep whatever label the
|
|
1177
|
+
// strand builder emitted — the cap's own cardinality marker
|
|
1178
|
+
// (from input_is_sequence/output_is_sequence) is the single
|
|
1179
|
+
// source of truth for which edge carries a (1→n) / (n→1) /
|
|
1180
|
+
// (n→n) annotation. The ForEach/Collect shape transitions
|
|
1181
|
+
// themselves are invisible in the render; the cap preceding a
|
|
1182
|
+
// ForEach (with output_is_sequence=true) already produces the
|
|
1183
|
+
// (1→n) marker, and the cap following a Collect (with
|
|
1184
|
+
// input_is_sequence=true) produces (n→1). No relabeling.
|
|
1154
1185
|
|
|
1155
1186
|
// Step 3: merge the trailing `step_N → output` edge when step_N
|
|
1156
1187
|
// and output represent the same media URN. The strand builder
|
|
@@ -1252,74 +1283,21 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
|
1252
1283
|
return -1;
|
|
1253
1284
|
}
|
|
1254
1285
|
|
|
1255
|
-
// Remove
|
|
1256
|
-
//
|
|
1257
|
-
//
|
|
1258
|
-
//
|
|
1259
|
-
// edge is the backbone "fallback connector" that keeps the graph
|
|
1260
|
-
// connected when zero bodies succeed.
|
|
1286
|
+
// Remove backbone cap nodes that will be replaced by per-body
|
|
1287
|
+
// replicas from the collapsed strand backbone. Every step_id in
|
|
1288
|
+
// `dropStepIds` is erased along with its incoming and outgoing
|
|
1289
|
+
// edges. Replicas own the per-body rendering of those nodes.
|
|
1261
1290
|
//
|
|
1262
|
-
//
|
|
1263
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
//
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
const interiorStepIdxs = [];
|
|
1273
|
-
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
1274
|
-
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
1275
|
-
interiorStepIdxs.push(i);
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
if (interiorStepIdxs.length <= 1) {
|
|
1279
|
-
// Zero or one body cap — no prototype chain to strip.
|
|
1280
|
-
return built;
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const firstBodyStepId = `step_${interiorStepIdxs[0]}`;
|
|
1284
|
-
const lastBodyStepId = `step_${interiorStepIdxs[interiorStepIdxs.length - 1]}`;
|
|
1285
|
-
const dropInteriorIds = new Set();
|
|
1286
|
-
for (let i = 1; i < interiorStepIdxs.length; i++) {
|
|
1287
|
-
dropInteriorIds.add(`step_${interiorStepIdxs[i]}`);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
// Find the post-body target: the node that the last body cap's
|
|
1291
|
-
// outgoing edge points at in the collapsed backbone. This is
|
|
1292
|
-
// usually `output` or (after the collect-exit synth) the merged
|
|
1293
|
-
// target node.
|
|
1294
|
-
const postBodyEdge = built.edges.find(e => e.source === lastBodyStepId);
|
|
1295
|
-
const postBodyTarget = postBodyEdge ? postBodyEdge.target : null;
|
|
1296
|
-
|
|
1297
|
-
const keptNodes = built.nodes.filter(n => !dropInteriorIds.has(n.id));
|
|
1298
|
-
let keptEdges = built.edges.filter(e =>
|
|
1299
|
-
!dropInteriorIds.has(e.source) && !dropInteriorIds.has(e.target));
|
|
1300
|
-
|
|
1301
|
-
// Bridge firstBody → postBodyTarget directly with a plain
|
|
1302
|
-
// unlabeled connector so the graph stays connected. Replicas
|
|
1303
|
-
// will overlay labeled chains alongside this connector.
|
|
1304
|
-
if (postBodyTarget && postBodyTarget !== firstBodyStepId) {
|
|
1305
|
-
const bridgeExists = keptEdges.some(e =>
|
|
1306
|
-
e.source === firstBodyStepId && e.target === postBodyTarget);
|
|
1307
|
-
if (!bridgeExists) {
|
|
1308
|
-
keptEdges = keptEdges.concat([{
|
|
1309
|
-
id: `run-bridge-${firstBodyStepId}-${postBodyTarget}`,
|
|
1310
|
-
source: firstBodyStepId,
|
|
1311
|
-
target: postBodyTarget,
|
|
1312
|
-
label: '',
|
|
1313
|
-
title: '',
|
|
1314
|
-
fullUrn: '',
|
|
1315
|
-
edgeClass: 'strand-cap-edge',
|
|
1316
|
-
color: postBodyEdge ? postBodyEdge.color : edgeHueColor(0),
|
|
1317
|
-
foreachEntry: false,
|
|
1318
|
-
singleCapClosedBody: false,
|
|
1319
|
-
}]);
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1291
|
+
// The function does NOT try to stitch the backbone back together —
|
|
1292
|
+
// the replicas are responsible for connecting the anchor to the
|
|
1293
|
+
// merge target, and the "no outcomes yet" case is handled by
|
|
1294
|
+
// `buildRunGraphData`'s backbone-drop logic which also removes the
|
|
1295
|
+
// now-dangling target node when there are zero successful replicas.
|
|
1296
|
+
function stripRunBackboneReplicaNodes(built, dropStepIds) {
|
|
1297
|
+
if (dropStepIds.size === 0) return built;
|
|
1298
|
+
const keptNodes = built.nodes.filter(n => !dropStepIds.has(n.id));
|
|
1299
|
+
const keptEdges = built.edges.filter(e =>
|
|
1300
|
+
!dropStepIds.has(e.source) && !dropStepIds.has(e.target));
|
|
1323
1301
|
return {
|
|
1324
1302
|
nodes: keptNodes,
|
|
1325
1303
|
edges: keptEdges,
|
|
@@ -1331,19 +1309,34 @@ function stripRunBackboneBodyInterior(built, steps, foreachStepIdx, collectStepI
|
|
|
1331
1309
|
function buildRunGraphData(data) {
|
|
1332
1310
|
validateRunPayload(data);
|
|
1333
1311
|
|
|
1334
|
-
// Build the raw strand topology and then apply the
|
|
1335
|
-
// collapse
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1312
|
+
// Build the raw strand topology and then apply the strand
|
|
1313
|
+
// collapse so the run backbone inherits the same cosmetic
|
|
1314
|
+
// transform (no ForEach/Collect nodes, fix-up edges for
|
|
1315
|
+
// foreach-entry, merged trailing target).
|
|
1338
1316
|
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
1339
1317
|
media_display_names: data.media_display_names,
|
|
1340
1318
|
});
|
|
1341
1319
|
const strandBuiltRaw = buildStrandGraphData(strandInput);
|
|
1342
|
-
|
|
1320
|
+
let strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
|
|
1321
|
+
|
|
1322
|
+
// Run mode overrides the input_slot node's label with the
|
|
1323
|
+
// host-supplied `source_display` (runtime input filename).
|
|
1324
|
+
// Strand mode ignores this field so the abstract graph shows
|
|
1325
|
+
// the media spec's title from `media_display_names`.
|
|
1326
|
+
if (typeof data.source_display === 'string' && data.source_display.length > 0) {
|
|
1327
|
+
strandBuiltCollapsed = {
|
|
1328
|
+
nodes: strandBuiltCollapsed.nodes.map(n =>
|
|
1329
|
+
n.id === 'input_slot' ? Object.assign({}, n, { label: data.source_display }) : n),
|
|
1330
|
+
edges: strandBuiltCollapsed.edges,
|
|
1331
|
+
sourceSpec: strandBuiltCollapsed.sourceSpec,
|
|
1332
|
+
targetSpec: strandBuiltCollapsed.targetSpec,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1343
1335
|
|
|
1344
|
-
// Locate the ForEach/Collect span in the raw steps. Positional
|
|
1345
|
-
// survive the collapse (node IDs are `step_${i}` from the
|
|
1346
|
-
// so we can still identify which collapsed nodes
|
|
1336
|
+
// Locate the ForEach/Collect span in the raw steps. Positional
|
|
1337
|
+
// IDs survive the collapse (node IDs are `step_${i}` from the
|
|
1338
|
+
// builder), so we can still identify which collapsed nodes
|
|
1339
|
+
// correspond to body-interior caps.
|
|
1347
1340
|
const steps = data.resolved_strand.steps;
|
|
1348
1341
|
let foreachStepIdx = -1;
|
|
1349
1342
|
let collectStepIdx = -1;
|
|
@@ -1353,15 +1346,7 @@ function buildRunGraphData(data) {
|
|
|
1353
1346
|
if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
|
|
1354
1347
|
}
|
|
1355
1348
|
const hasForeach = foreachStepIdx >= 0;
|
|
1356
|
-
|
|
1357
|
-
// Drop interior body-cap nodes (caps 2..N inside the body) from
|
|
1358
|
-
// the collapsed backbone so the prototype chain doesn't duplicate
|
|
1359
|
-
// the per-body replicas visually. The FIRST body cap is retained
|
|
1360
|
-
// as the body-entry node so the backbone's foreachEntry edge
|
|
1361
|
-
// (`<cap_title> (1→n)`) still has a landing point.
|
|
1362
|
-
let strandBuilt = hasForeach
|
|
1363
|
-
? stripRunBackboneBodyInterior(strandBuiltCollapsed, steps, foreachStepIdx, collectStepIdx)
|
|
1364
|
-
: strandBuiltCollapsed;
|
|
1349
|
+
const hasCollect = collectStepIdx >= 0;
|
|
1365
1350
|
|
|
1366
1351
|
// Filter and bound the outcomes.
|
|
1367
1352
|
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
@@ -1371,97 +1356,180 @@ function buildRunGraphData(data) {
|
|
|
1371
1356
|
const visibleFailure = failures.slice(0, data.visible_failure_count);
|
|
1372
1357
|
const hiddenSuccessCount = successes.length - visibleSuccess.length;
|
|
1373
1358
|
const hiddenFailureCount = failures.length - visibleFailure.length;
|
|
1359
|
+
const visibleOutcomes = visibleSuccess.concat(visibleFailure);
|
|
1374
1360
|
|
|
1375
|
-
//
|
|
1376
|
-
//
|
|
1361
|
+
// Per-body replicas only fire when there's a ForEach AND at
|
|
1362
|
+
// least one visible outcome. Without outcomes, the strand
|
|
1363
|
+
// backbone renders the "plan preview" unchanged.
|
|
1364
|
+
const shouldExpand = hasForeach && visibleOutcomes.length > 0;
|
|
1365
|
+
|
|
1366
|
+
// Body-interior Cap steps — each body iteration chains through
|
|
1367
|
+
// these caps once. Only valid when `shouldExpand` is true.
|
|
1377
1368
|
const bodyCapSteps = [];
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1369
|
+
if (hasForeach) {
|
|
1370
|
+
const bodyStart = foreachStepIdx + 1;
|
|
1371
|
+
const bodyEnd = hasCollect ? collectStepIdx : steps.length;
|
|
1372
|
+
for (let i = bodyStart; i < bodyEnd; i++) {
|
|
1373
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
1374
|
+
bodyCapSteps.push({ globalIndex: i, step: steps[i] });
|
|
1375
|
+
}
|
|
1383
1376
|
}
|
|
1384
1377
|
}
|
|
1385
1378
|
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1379
|
+
// Short-circuit: no outcomes → the backbone IS the render.
|
|
1380
|
+
// `anchorNodeId` and `mergeNodeId` are unused in this path.
|
|
1381
|
+
if (!shouldExpand) {
|
|
1382
|
+
return {
|
|
1383
|
+
strandBuilt: strandBuiltCollapsed,
|
|
1384
|
+
replicaNodes: [],
|
|
1385
|
+
replicaEdges: [],
|
|
1386
|
+
showMoreNodes: [],
|
|
1387
|
+
totals: {
|
|
1388
|
+
hiddenSuccessCount,
|
|
1389
|
+
hiddenFailureCount,
|
|
1390
|
+
totalBodyCount: data.total_body_count,
|
|
1391
|
+
visibleSuccessCount: visibleSuccess.length,
|
|
1392
|
+
visibleFailureCount: visibleFailure.length,
|
|
1393
|
+
},
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Expanding. The plan mirrors the working
|
|
1398
|
+
// `machfab-mac/.scrap/working_graph/.../RunGraphViewer.html`:
|
|
1392
1399
|
//
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1396
|
-
//
|
|
1397
|
-
//
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1400
|
+
// pre-foreach backbone ... → [anchor]
|
|
1401
|
+
// │
|
|
1402
|
+
// │ (Disbind PDF Into Pages, fork)
|
|
1403
|
+
// ▼
|
|
1404
|
+
// body_N_entry (per-body page, e.g. "page_3")
|
|
1405
|
+
// │ (Make a Decision)
|
|
1406
|
+
// ▼
|
|
1407
|
+
// body_N_step_0 (per-body Decision output)
|
|
1408
|
+
// │
|
|
1409
|
+
// ▼ (if Collect exists) → shared collect target
|
|
1410
|
+
// (else terminal leaf)
|
|
1411
|
+
//
|
|
1412
|
+
// If the cap immediately before the ForEach has
|
|
1413
|
+
// `output_is_sequence=true`, THAT cap is the fan-out point:
|
|
1414
|
+
// its output IS the sequence the ForEach iterates. Its
|
|
1415
|
+
// backbone output node is dropped (replicas own the per-body
|
|
1416
|
+
// rendering), and its step becomes the "fork cap" whose title
|
|
1417
|
+
// labels the anchor→entry edge on the first body.
|
|
1418
|
+
//
|
|
1419
|
+
// Without such a preceding sequence cap, the source itself is
|
|
1420
|
+
// already a list (e.g. `media:pdf;list` source_spec) and the
|
|
1421
|
+
// ForEach iterates it directly.
|
|
1422
|
+
let seqProducerStepIdx = -1;
|
|
1423
|
+
let seqProducerStep = null;
|
|
1424
|
+
if (hasForeach && foreachStepIdx > 0) {
|
|
1425
|
+
const preStep = steps[foreachStepIdx - 1];
|
|
1426
|
+
if (Object.keys(preStep.step_type)[0] === 'Cap'
|
|
1427
|
+
&& preStep.step_type.Cap.output_is_sequence === true) {
|
|
1428
|
+
seqProducerStepIdx = foreachStepIdx - 1;
|
|
1429
|
+
seqProducerStep = preStep;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// The anchor is the node BEFORE the sequence producer (or
|
|
1434
|
+
// before the ForEach if there is none). Every body iteration
|
|
1435
|
+
// shares this node as its starting point; per-body entries fan
|
|
1436
|
+
// out from here.
|
|
1437
|
+
let anchorNodeId;
|
|
1438
|
+
if (seqProducerStepIdx >= 0) {
|
|
1439
|
+
anchorNodeId = seqProducerStepIdx > 0
|
|
1440
|
+
? `step_${seqProducerStepIdx - 1}`
|
|
1441
|
+
: 'input_slot';
|
|
1442
|
+
} else {
|
|
1443
|
+
anchorNodeId = foreachStepIdx > 0
|
|
1444
|
+
? `step_${foreachStepIdx - 1}`
|
|
1445
|
+
: 'input_slot';
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Strip nodes whose per-body replicas will replace them:
|
|
1449
|
+
// the sequence producer's backbone output (if any) AND every
|
|
1450
|
+
// body-interior cap. The foreach-entry edge is dropped
|
|
1451
|
+
// automatically because its source/target is in this set.
|
|
1452
|
+
const dropStepIds = new Set(bodyCapSteps.map(b => `step_${b.globalIndex}`));
|
|
1453
|
+
if (seqProducerStepIdx >= 0) {
|
|
1454
|
+
dropStepIds.add(`step_${seqProducerStepIdx}`);
|
|
1455
|
+
}
|
|
1456
|
+
let strandBuilt = stripRunBackboneReplicaNodes(strandBuiltCollapsed, dropStepIds);
|
|
1457
|
+
|
|
1458
|
+
// If there's a Collect, find the post-collect target in the
|
|
1459
|
+
// backbone — the node successful replicas merge into. This is
|
|
1460
|
+
// the first node reachable forward from the last body cap that
|
|
1461
|
+
// isn't itself a body cap. Walk the pre-strip backbone so we
|
|
1462
|
+
// can cross over the now-dropped body cap nodes.
|
|
1463
|
+
let collectTargetId = null;
|
|
1464
|
+
if (hasCollect && bodyCapSteps.length > 0) {
|
|
1465
|
+
const lastBodyStepId = `step_${bodyCapSteps[bodyCapSteps.length - 1].globalIndex}`;
|
|
1466
|
+
let cursor = lastBodyStepId;
|
|
1467
|
+
let guard = 64;
|
|
1468
|
+
while (guard-- > 0) {
|
|
1469
|
+
const out = strandBuiltCollapsed.edges.find(e => e.source === cursor);
|
|
1470
|
+
if (!out) break;
|
|
1471
|
+
cursor = out.target;
|
|
1472
|
+
if (!dropStepIds.has(cursor)) {
|
|
1473
|
+
collectTargetId = cursor;
|
|
1474
|
+
break;
|
|
1459
1475
|
}
|
|
1460
1476
|
}
|
|
1477
|
+
// If the last-body-step is itself the merged terminal (no
|
|
1478
|
+
// outgoing edge), there's no separate collect target —
|
|
1479
|
+
// replicas simply don't merge and the terminal node stays
|
|
1480
|
+
// dropped.
|
|
1461
1481
|
}
|
|
1462
1482
|
|
|
1463
1483
|
const replicaNodes = [];
|
|
1464
1484
|
const replicaEdges = [];
|
|
1485
|
+
let replicasBuiltCount = 0;
|
|
1486
|
+
|
|
1487
|
+
// Look up a display name for a media URN via the host-supplied
|
|
1488
|
+
// `media_display_names` map. Uses `MediaUrn.isEquivalent` for
|
|
1489
|
+
// semantic URN equality.
|
|
1490
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
1491
|
+
const mediaDisplayNames = data.media_display_names || {};
|
|
1492
|
+
const displayEntries = [];
|
|
1493
|
+
for (const [urn, display] of Object.entries(mediaDisplayNames)) {
|
|
1494
|
+
if (typeof display !== 'string' || display.length === 0) continue;
|
|
1495
|
+
try {
|
|
1496
|
+
displayEntries.push({ media: MediaUrn.fromString(urn), display });
|
|
1497
|
+
} catch (_) { /* ignore malformed keys */ }
|
|
1498
|
+
}
|
|
1499
|
+
function displayNameFor(canonicalUrn) {
|
|
1500
|
+
const candidate = MediaUrn.fromString(canonicalUrn);
|
|
1501
|
+
for (const entry of displayEntries) {
|
|
1502
|
+
if (candidate.isEquivalent(entry.media)) return entry.display;
|
|
1503
|
+
}
|
|
1504
|
+
return mediaNodeLabel(canonicalUrn);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// The per-body "entry" node represents one item of the
|
|
1508
|
+
// sequence being iterated. Its URN is:
|
|
1509
|
+
// * the sequence producer cap's `to_spec` (if such a cap
|
|
1510
|
+
// precedes the ForEach) — this is what Disbind produces,
|
|
1511
|
+
// one-per-body.
|
|
1512
|
+
// * otherwise the ForEach step's own `to_spec` — used when
|
|
1513
|
+
// the source spec is already a list.
|
|
1514
|
+
// Its label defaults to the display name of that URN and is
|
|
1515
|
+
// overridden by the host's per-body `outcome.title` (e.g.
|
|
1516
|
+
// "page_3") when provided.
|
|
1517
|
+
const entryUrn = seqProducerStep
|
|
1518
|
+
? canonicalMediaUrn(seqProducerStep.to_spec)
|
|
1519
|
+
: canonicalMediaUrn(steps[foreachStepIdx].to_spec);
|
|
1520
|
+
const entryDefaultLabel = displayNameFor(entryUrn);
|
|
1521
|
+
|
|
1522
|
+
// Label shown on the anchor → first-body-entry edge. When the
|
|
1523
|
+
// fan-out is caused by a sequence-producing cap, its title
|
|
1524
|
+
// (e.g. "Disbind PDF Into Pages") labels that edge. The label
|
|
1525
|
+
// appears on the FIRST body's fork edge only to avoid N copies
|
|
1526
|
+
// of the same title cluttering the fan.
|
|
1527
|
+
const forkEdgeTitle = seqProducerStep
|
|
1528
|
+
? seqProducerStep.step_type.Cap.title
|
|
1529
|
+
: '';
|
|
1530
|
+
const forkEdgeFullUrn = seqProducerStep
|
|
1531
|
+
? seqProducerStep.step_type.Cap.cap_urn
|
|
1532
|
+
: '';
|
|
1465
1533
|
|
|
1466
1534
|
function buildBodyReplica(outcome) {
|
|
1467
1535
|
const success = outcome.success;
|
|
@@ -1469,7 +1537,7 @@ function buildRunGraphData(data) {
|
|
|
1469
1537
|
const edgeClass = success ? 'body-success' : 'body-failure';
|
|
1470
1538
|
const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
|
|
1471
1539
|
|
|
1472
|
-
// Trace end: failures stop at failed_cap
|
|
1540
|
+
// Trace end: failures stop at `failed_cap`. `CapUrn.isEquivalent`
|
|
1473
1541
|
// is used for the match — never string equality.
|
|
1474
1542
|
let traceEnd = bodyCapSteps.length;
|
|
1475
1543
|
if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
|
|
@@ -1483,13 +1551,48 @@ function buildRunGraphData(data) {
|
|
|
1483
1551
|
}
|
|
1484
1552
|
}
|
|
1485
1553
|
}
|
|
1486
|
-
if (traceEnd === 0) return;
|
|
1487
1554
|
|
|
1488
|
-
let prevBodyNodeId = anchorNodeId;
|
|
1489
1555
|
const bodyKey = `body-${outcome.body_index}`;
|
|
1490
1556
|
const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
|
|
1491
1557
|
? outcome.title
|
|
1492
|
-
:
|
|
1558
|
+
: entryDefaultLabel;
|
|
1559
|
+
|
|
1560
|
+
// Per-body entry node: one item from the iterated sequence.
|
|
1561
|
+
const entryNodeId = `${bodyKey}-entry`;
|
|
1562
|
+
replicaNodes.push({
|
|
1563
|
+
group: 'nodes',
|
|
1564
|
+
data: {
|
|
1565
|
+
id: entryNodeId,
|
|
1566
|
+
label: titleLabel,
|
|
1567
|
+
fullUrn: entryUrn,
|
|
1568
|
+
bodyIndex: outcome.body_index,
|
|
1569
|
+
bodyTitle: titleLabel,
|
|
1570
|
+
},
|
|
1571
|
+
classes: successClass,
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
// Fan-out edge from the pre-foreach anchor to this body's
|
|
1575
|
+
// entry. The fork label (cap title) shows on the FIRST body
|
|
1576
|
+
// only to avoid N copies of the same title cluttering the
|
|
1577
|
+
// fan. The `title` tooltip always exposes the cap title.
|
|
1578
|
+
const isFirstReplica = replicasBuiltCount === 0;
|
|
1579
|
+
const forkLabel = (forkEdgeTitle && isFirstReplica) ? forkEdgeTitle : '';
|
|
1580
|
+
replicaEdges.push({
|
|
1581
|
+
group: 'edges',
|
|
1582
|
+
data: {
|
|
1583
|
+
id: `${bodyKey}-fork`,
|
|
1584
|
+
source: anchorNodeId,
|
|
1585
|
+
target: entryNodeId,
|
|
1586
|
+
label: forkLabel,
|
|
1587
|
+
title: forkEdgeTitle || `body ${outcome.body_index}`,
|
|
1588
|
+
fullUrn: forkEdgeFullUrn,
|
|
1589
|
+
color: `var(${colorVar})`,
|
|
1590
|
+
bodyIndex: outcome.body_index,
|
|
1591
|
+
},
|
|
1592
|
+
classes: edgeClass,
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
let prevBodyNodeId = entryNodeId;
|
|
1493
1596
|
|
|
1494
1597
|
for (let i = 0; i < traceEnd; i++) {
|
|
1495
1598
|
const body = bodyCapSteps[i].step.step_type.Cap;
|
|
@@ -1499,7 +1602,7 @@ function buildRunGraphData(data) {
|
|
|
1499
1602
|
group: 'nodes',
|
|
1500
1603
|
data: {
|
|
1501
1604
|
id: replicaNodeId,
|
|
1502
|
-
label:
|
|
1605
|
+
label: displayNameFor(targetCanonical),
|
|
1503
1606
|
fullUrn: targetCanonical,
|
|
1504
1607
|
bodyIndex: outcome.body_index,
|
|
1505
1608
|
bodyTitle: titleLabel,
|
|
@@ -1513,10 +1616,9 @@ function buildRunGraphData(data) {
|
|
|
1513
1616
|
source: prevBodyNodeId,
|
|
1514
1617
|
target: replicaNodeId,
|
|
1515
1618
|
// Replica edges carry no inline label — the cap title is
|
|
1516
|
-
// identical across every visible replica and would
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
// color + the replica node identify the body.
|
|
1619
|
+
// identical across every visible replica and would pile
|
|
1620
|
+
// up as unreadable rotated text across the fan-out. The
|
|
1621
|
+
// hover tooltip exposes the title via `title`.
|
|
1520
1622
|
label: '',
|
|
1521
1623
|
title: body.title,
|
|
1522
1624
|
fullUrn: body.cap_urn,
|
|
@@ -1528,17 +1630,18 @@ function buildRunGraphData(data) {
|
|
|
1528
1630
|
prevBodyNodeId = replicaNodeId;
|
|
1529
1631
|
}
|
|
1530
1632
|
|
|
1531
|
-
// Successful bodies merge
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
|
|
1633
|
+
// Successful bodies merge into the collect target ONLY when a
|
|
1634
|
+
// Collect closes the foreach body. Without a Collect (the
|
|
1635
|
+
// common "unclosed foreach" case in machfab realize_strand),
|
|
1636
|
+
// replicas terminate at their last body step — each body has
|
|
1637
|
+
// its own separate output, no shared merge.
|
|
1638
|
+
if (success && hasCollect && collectTargetId) {
|
|
1536
1639
|
replicaEdges.push({
|
|
1537
1640
|
group: 'edges',
|
|
1538
1641
|
data: {
|
|
1539
|
-
id: `${bodyKey}-
|
|
1642
|
+
id: `${bodyKey}-collect`,
|
|
1540
1643
|
source: prevBodyNodeId,
|
|
1541
|
-
target:
|
|
1644
|
+
target: collectTargetId,
|
|
1542
1645
|
label: '',
|
|
1543
1646
|
title: 'collect',
|
|
1544
1647
|
fullUrn: '',
|
|
@@ -1548,49 +1651,12 @@ function buildRunGraphData(data) {
|
|
|
1548
1651
|
classes: edgeClass,
|
|
1549
1652
|
});
|
|
1550
1653
|
}
|
|
1551
|
-
}
|
|
1552
1654
|
|
|
1553
|
-
|
|
1554
|
-
visibleFailure.forEach((o) => buildBodyReplica(o));
|
|
1555
|
-
|
|
1556
|
-
// Once any replicas are rendered (success or failure), the
|
|
1557
|
-
// backbone foreach-entry edge becomes a stale placeholder — the
|
|
1558
|
-
// replicas ARE the actual execution traces, so the user
|
|
1559
|
-
// shouldn't see a duplicate labeled "1→n" edge alongside them.
|
|
1560
|
-
// Drop the backbone foreach-entry edge whenever we emit
|
|
1561
|
-
// replicas, AND if there were zero successful replicas also
|
|
1562
|
-
// drop the orphaned merge node: nothing reached it, and
|
|
1563
|
-
// showing a disconnected node misleads the user into thinking
|
|
1564
|
-
// the target was reached.
|
|
1565
|
-
if (hasForeach && (visibleSuccess.length > 0 || visibleFailure.length > 0)) {
|
|
1566
|
-
const backboneForeachEntry = strandBuilt.edges.find(e =>
|
|
1567
|
-
e.edgeClass === 'strand-cap-edge' &&
|
|
1568
|
-
e.source === anchorNodeId &&
|
|
1569
|
-
e.foreachEntry === true);
|
|
1570
|
-
let newEdges = strandBuilt.edges;
|
|
1571
|
-
let newNodes = strandBuilt.nodes;
|
|
1572
|
-
if (backboneForeachEntry) {
|
|
1573
|
-
newEdges = newEdges.filter(e => e.id !== backboneForeachEntry.id);
|
|
1574
|
-
}
|
|
1575
|
-
// If no successful replica will merge into `mergeNodeId`, and
|
|
1576
|
-
// the merge node is now only reachable via the (just-dropped)
|
|
1577
|
-
// backbone edge, drop the merge node itself.
|
|
1578
|
-
if (visibleSuccess.length === 0 && mergeNodeId) {
|
|
1579
|
-
const stillIncoming = newEdges.some(e => e.target === mergeNodeId);
|
|
1580
|
-
const replicaIncoming = replicaEdges.some(e => e.data && e.data.target === mergeNodeId);
|
|
1581
|
-
if (!stillIncoming && !replicaIncoming) {
|
|
1582
|
-
newNodes = newNodes.filter(n => n.id !== mergeNodeId);
|
|
1583
|
-
newEdges = newEdges.filter(e => e.source !== mergeNodeId && e.target !== mergeNodeId);
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
strandBuilt = {
|
|
1587
|
-
nodes: newNodes,
|
|
1588
|
-
edges: newEdges,
|
|
1589
|
-
sourceSpec: strandBuilt.sourceSpec,
|
|
1590
|
-
targetSpec: strandBuilt.targetSpec,
|
|
1591
|
-
};
|
|
1655
|
+
replicasBuiltCount++;
|
|
1592
1656
|
}
|
|
1593
1657
|
|
|
1658
|
+
visibleOutcomes.forEach((o) => buildBodyReplica(o));
|
|
1659
|
+
|
|
1594
1660
|
// Build success and failure "show more" nodes when there are hidden
|
|
1595
1661
|
// outcomes. Anchored at the ForEach node (or input_slot if none).
|
|
1596
1662
|
const showMoreNodes = [];
|
|
@@ -1667,9 +1733,10 @@ function buildRunGraphData(data) {
|
|
|
1667
1733
|
}
|
|
1668
1734
|
|
|
1669
1735
|
function runCytoscapeElements(built) {
|
|
1670
|
-
//
|
|
1671
|
-
//
|
|
1672
|
-
//
|
|
1736
|
+
// Run mode's `buildRunGraphData` already applied the strand
|
|
1737
|
+
// collapse to its backbone (and dropped body-interior caps
|
|
1738
|
+
// that are replaced by per-body replicas). Emit the backbone
|
|
1739
|
+
// verbatim — `{ collapse: false }` prevents a second pass.
|
|
1673
1740
|
const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
|
|
1674
1741
|
return strandElements
|
|
1675
1742
|
.concat(built.replicaNodes)
|
|
@@ -1679,67 +1746,150 @@ function runCytoscapeElements(built) {
|
|
|
1679
1746
|
|
|
1680
1747
|
// --------- Machine mode builder ---------------------------------------------
|
|
1681
1748
|
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1749
|
+
// The notation analyzer emits a bipartite graph where each cap
|
|
1750
|
+
// application is a 3-element chain:
|
|
1751
|
+
//
|
|
1752
|
+
// data_node → [argument edge] → cap_node → [argument edge] → data_node
|
|
1753
|
+
//
|
|
1754
|
+
// The render collapses that chain into a single labeled data→data
|
|
1755
|
+
// edge carrying the cap's name (and cardinality marker derived
|
|
1756
|
+
// from the source/target data nodes' `is_sequence` flags). Cap
|
|
1757
|
+
// nodes and argument edges are dropped. The result is a clean
|
|
1758
|
+
// abstract-machine view that matches strand mode's rendering
|
|
1759
|
+
// style: one edge per cap application, labeled with the cap
|
|
1760
|
+
// title, with cardinality markers where relevant.
|
|
1761
|
+
function buildEditorGraphData(data) {
|
|
1762
|
+
validateEditorGraphPayload(data);
|
|
1763
|
+
|
|
1764
|
+
// Index elements by kind for quick lookup.
|
|
1765
|
+
const dataNodes = new Map(); // graph_id → element
|
|
1766
|
+
const capNodes = new Map(); // graph_id → element
|
|
1767
|
+
const argEdges = []; // list of edge elements
|
|
1687
1768
|
for (const el of data.elements) {
|
|
1688
1769
|
if (el.kind === 'node') {
|
|
1689
|
-
|
|
1690
|
-
group: 'nodes',
|
|
1691
|
-
data: {
|
|
1692
|
-
id: el.graph_id,
|
|
1693
|
-
label: el.label || '',
|
|
1694
|
-
fullUrn: el.detail || el.label || '',
|
|
1695
|
-
tokenId: el.token_id || '',
|
|
1696
|
-
kind: 'node',
|
|
1697
|
-
},
|
|
1698
|
-
classes: 'machine-node',
|
|
1699
|
-
});
|
|
1770
|
+
dataNodes.set(el.graph_id, el);
|
|
1700
1771
|
} else if (el.kind === 'cap') {
|
|
1701
|
-
|
|
1702
|
-
group: 'nodes',
|
|
1703
|
-
data: {
|
|
1704
|
-
id: el.graph_id,
|
|
1705
|
-
label: el.label || '',
|
|
1706
|
-
fullUrn: el.detail || el.label || '',
|
|
1707
|
-
tokenId: el.token_id || '',
|
|
1708
|
-
kind: 'cap',
|
|
1709
|
-
},
|
|
1710
|
-
classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
|
|
1711
|
-
});
|
|
1772
|
+
capNodes.set(el.graph_id, el);
|
|
1712
1773
|
} else if (el.kind === 'edge') {
|
|
1713
|
-
|
|
1714
|
-
group: 'edges',
|
|
1715
|
-
data: {
|
|
1716
|
-
id: el.graph_id,
|
|
1717
|
-
source: el.source_graph_id,
|
|
1718
|
-
target: el.target_graph_id,
|
|
1719
|
-
label: el.label || '',
|
|
1720
|
-
title: el.label || '',
|
|
1721
|
-
fullUrn: el.detail || '',
|
|
1722
|
-
tokenId: el.token_id || '',
|
|
1723
|
-
color: el.is_loop
|
|
1724
|
-
? 'var(--graph-node-border-highlighted)'
|
|
1725
|
-
: edgeHueColor(capEdgeIdx),
|
|
1726
|
-
},
|
|
1727
|
-
classes: 'machine-edge' + (el.is_loop ? ' machine-loop' : ''),
|
|
1728
|
-
});
|
|
1729
|
-
capEdgeIdx++;
|
|
1774
|
+
argEdges.push(el);
|
|
1730
1775
|
}
|
|
1731
1776
|
}
|
|
1777
|
+
|
|
1778
|
+
// For each cap, identify its incoming and outgoing argument
|
|
1779
|
+
// edges. Incoming edges connect a data-slot source → this cap.
|
|
1780
|
+
// Outgoing edges connect this cap → a data-slot target.
|
|
1781
|
+
const capIncoming = new Map(); // capId → [argEdge, ...]
|
|
1782
|
+
const capOutgoing = new Map(); // capId → [argEdge, ...]
|
|
1783
|
+
for (const e of argEdges) {
|
|
1784
|
+
if (capNodes.has(e.target_graph_id)) {
|
|
1785
|
+
if (!capIncoming.has(e.target_graph_id)) capIncoming.set(e.target_graph_id, []);
|
|
1786
|
+
capIncoming.get(e.target_graph_id).push(e);
|
|
1787
|
+
}
|
|
1788
|
+
if (capNodes.has(e.source_graph_id)) {
|
|
1789
|
+
if (!capOutgoing.has(e.source_graph_id)) capOutgoing.set(e.source_graph_id, []);
|
|
1790
|
+
capOutgoing.get(e.source_graph_id).push(e);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
const nodes = [];
|
|
1795
|
+
const edges = [];
|
|
1796
|
+
|
|
1797
|
+
// Emit the data slot nodes verbatim.
|
|
1798
|
+
for (const el of dataNodes.values()) {
|
|
1799
|
+
nodes.push({
|
|
1800
|
+
group: 'nodes',
|
|
1801
|
+
data: {
|
|
1802
|
+
id: el.graph_id,
|
|
1803
|
+
label: el.label || '',
|
|
1804
|
+
fullUrn: el.detail || el.label || '',
|
|
1805
|
+
tokenId: el.token_id || '',
|
|
1806
|
+
kind: 'node',
|
|
1807
|
+
isSequence: el.is_sequence === true,
|
|
1808
|
+
},
|
|
1809
|
+
classes: 'machine-node',
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
// Collapse each cap into one labeled edge per (input, output)
|
|
1814
|
+
// data-slot pair. For a simple single-input single-output cap
|
|
1815
|
+
// that's one edge. Multi-arg caps emit one edge per combination,
|
|
1816
|
+
// preserving the argument structure while still using a single
|
|
1817
|
+
// visual target per cap application.
|
|
1818
|
+
let capEdgeIdx = 0;
|
|
1819
|
+
for (const [capId, capEl] of capNodes) {
|
|
1820
|
+
const incoming = capIncoming.get(capId) || [];
|
|
1821
|
+
const outgoing = capOutgoing.get(capId) || [];
|
|
1822
|
+
|
|
1823
|
+
// Degenerate cases: a cap with no incoming or no outgoing
|
|
1824
|
+
// argument edges (e.g. partially-written notation). Render
|
|
1825
|
+
// nothing for it — the user sees a missing edge where they
|
|
1826
|
+
// need to complete the notation.
|
|
1827
|
+
if (incoming.length === 0 || outgoing.length === 0) continue;
|
|
1828
|
+
|
|
1829
|
+
// Cardinality of the cap's visual edge comes from the
|
|
1830
|
+
// source-side and target-side data-slot `is_sequence` flags.
|
|
1831
|
+
// When a cap has multiple inputs/outputs, use the MOST
|
|
1832
|
+
// restrictive reading (any seq input → `is_sequence=true` on
|
|
1833
|
+
// that side). This mirrors how cardinalityLabel collapses
|
|
1834
|
+
// multi-arg caps in the strand/browse builders.
|
|
1835
|
+
const inputIsSequence = incoming.some(e => {
|
|
1836
|
+
const src = dataNodes.get(e.source_graph_id);
|
|
1837
|
+
return src && src.is_sequence === true;
|
|
1838
|
+
});
|
|
1839
|
+
const outputIsSequence = outgoing.some(e => {
|
|
1840
|
+
const tgt = dataNodes.get(e.target_graph_id);
|
|
1841
|
+
return tgt && tgt.is_sequence === true;
|
|
1842
|
+
});
|
|
1843
|
+
const cardinality = cardinalityLabel(inputIsSequence, outputIsSequence);
|
|
1844
|
+
const capTitle = capEl.label || '';
|
|
1845
|
+
const label = cardinality === '1\u21921'
|
|
1846
|
+
? capTitle
|
|
1847
|
+
: `${capTitle} (${cardinality})`;
|
|
1848
|
+
|
|
1849
|
+
const color = capEl.is_loop
|
|
1850
|
+
? 'var(--graph-loop-color)'
|
|
1851
|
+
: edgeHueColor(capEdgeIdx);
|
|
1852
|
+
const baseClasses = capEl.is_loop ? 'machine-edge machine-loop' : 'machine-edge';
|
|
1853
|
+
|
|
1854
|
+
// Cartesian over incoming × outgoing. For the common case
|
|
1855
|
+
// of one incoming + one outgoing, that's exactly one emitted
|
|
1856
|
+
// edge.
|
|
1857
|
+
for (const inEdge of incoming) {
|
|
1858
|
+
for (const outEdge of outgoing) {
|
|
1859
|
+
edges.push({
|
|
1860
|
+
group: 'edges',
|
|
1861
|
+
data: {
|
|
1862
|
+
id: `${capId}-${inEdge.graph_id}-${outEdge.graph_id}`,
|
|
1863
|
+
source: inEdge.source_graph_id,
|
|
1864
|
+
target: outEdge.target_graph_id,
|
|
1865
|
+
label,
|
|
1866
|
+
title: capTitle,
|
|
1867
|
+
fullUrn: capEl.linked_cap_urn || capEl.detail || '',
|
|
1868
|
+
// The cap node's token id is what the editor uses
|
|
1869
|
+
// for cross-highlighting; prefer that over the arg
|
|
1870
|
+
// edge token ids so clicking the rendered edge
|
|
1871
|
+
// selects the cap in the source text.
|
|
1872
|
+
tokenId: capEl.token_id || '',
|
|
1873
|
+
color: color,
|
|
1874
|
+
},
|
|
1875
|
+
classes: baseClasses,
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
capEdgeIdx++;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1732
1882
|
return { nodes, edges };
|
|
1733
1883
|
}
|
|
1734
1884
|
|
|
1735
|
-
function
|
|
1885
|
+
function editorGraphCytoscapeElements(built) {
|
|
1736
1886
|
return built.nodes.concat(built.edges);
|
|
1737
1887
|
}
|
|
1738
1888
|
|
|
1739
|
-
// A cheap signature for
|
|
1740
|
-
// on every keystroke; we skip the expensive rebuild when the
|
|
1741
|
-
// shape is unchanged.
|
|
1742
|
-
function
|
|
1889
|
+
// A cheap signature for editor-graph mode inputs. The editor streams
|
|
1890
|
+
// updates on every keystroke; we skip the expensive rebuild when the
|
|
1891
|
+
// element shape is unchanged.
|
|
1892
|
+
function editorGraphSignature(data) {
|
|
1743
1893
|
if (!data || !Array.isArray(data.elements)) return '';
|
|
1744
1894
|
const parts = [];
|
|
1745
1895
|
for (const el of data.elements) {
|
|
@@ -1748,6 +1898,110 @@ function machineGraphSignature(data) {
|
|
|
1748
1898
|
return parts.join(';');
|
|
1749
1899
|
}
|
|
1750
1900
|
|
|
1901
|
+
// `buildResolvedMachineGraphData` consumes the canonical
|
|
1902
|
+
// `Machine::to_render_payload_json` shape and produces the cytoscape
|
|
1903
|
+
// elements list directly. Each strand contributes its own nodes and
|
|
1904
|
+
// edges to the same graph; cytoscape lays disconnected components
|
|
1905
|
+
// side by side.
|
|
1906
|
+
//
|
|
1907
|
+
// Node ids and edge aliases are globally unique across all strands
|
|
1908
|
+
// (the Rust serializer assigns them from a single global counter),
|
|
1909
|
+
// so we can pass them straight through to cytoscape without name
|
|
1910
|
+
// collisions.
|
|
1911
|
+
//
|
|
1912
|
+
// Each cap edge is rendered as one cytoscape edge per
|
|
1913
|
+
// (assignment.source_node, target_node) pair. The common
|
|
1914
|
+
// single-input cap produces exactly one rendered edge; multi-input
|
|
1915
|
+
// (fan-in) caps produce one edge per source-arg slot, all sharing
|
|
1916
|
+
// the cap title and color so they read as a single fan-in.
|
|
1917
|
+
function buildResolvedMachineGraphData(data) {
|
|
1918
|
+
validateResolvedMachinePayload(data);
|
|
1919
|
+
|
|
1920
|
+
const nodes = [];
|
|
1921
|
+
const edges = [];
|
|
1922
|
+
const seenNodeIds = new Set();
|
|
1923
|
+
let edgeCounter = 0;
|
|
1924
|
+
|
|
1925
|
+
data.strands.forEach((strand, strandIdx) => {
|
|
1926
|
+
const inputAnchorSet = new Set(strand.input_anchor_nodes);
|
|
1927
|
+
const outputAnchorSet = new Set(strand.output_anchor_nodes);
|
|
1928
|
+
|
|
1929
|
+
for (const node of strand.nodes) {
|
|
1930
|
+
// Each node id is unique across the whole machine; the
|
|
1931
|
+
// Rust side guarantees this via its global counter. If a
|
|
1932
|
+
// duplicate appears it indicates a malformed payload, so
|
|
1933
|
+
// fail hard rather than silently dropping.
|
|
1934
|
+
if (seenNodeIds.has(node.id)) {
|
|
1935
|
+
throw new Error(
|
|
1936
|
+
`CapGraphRenderer machine mode: duplicate node id "${node.id}" in strand ${strandIdx}`
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
seenNodeIds.add(node.id);
|
|
1940
|
+
|
|
1941
|
+
const nodeClasses = ['machine-node'];
|
|
1942
|
+
if (inputAnchorSet.has(node.id)) nodeClasses.push('strand-source');
|
|
1943
|
+
if (outputAnchorSet.has(node.id)) nodeClasses.push('strand-target');
|
|
1944
|
+
|
|
1945
|
+
nodes.push({
|
|
1946
|
+
group: 'nodes',
|
|
1947
|
+
data: {
|
|
1948
|
+
id: node.id,
|
|
1949
|
+
label: mediaNodeLabel(canonicalMediaUrn(node.urn)),
|
|
1950
|
+
fullUrn: node.urn,
|
|
1951
|
+
strandIndex: strandIdx,
|
|
1952
|
+
},
|
|
1953
|
+
classes: nodeClasses.join(' '),
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
for (const edge of strand.edges) {
|
|
1958
|
+
const cardinality = edge.is_loop ? 'n\u21921' : '1\u21921';
|
|
1959
|
+
const capUrn = edge.cap_urn;
|
|
1960
|
+
// Title falls back to the canonical cap URN. The host can
|
|
1961
|
+
// pre-resolve a friendlier title via `step_titles` on the
|
|
1962
|
+
// proto message and use it elsewhere in the UI; the graph
|
|
1963
|
+
// renderer is intentionally registry-free.
|
|
1964
|
+
const capTitle = capUrn;
|
|
1965
|
+
const label = cardinality === '1\u21921'
|
|
1966
|
+
? edge.alias
|
|
1967
|
+
: `${edge.alias} (${cardinality})`;
|
|
1968
|
+
|
|
1969
|
+
const color = edge.is_loop
|
|
1970
|
+
? 'var(--graph-loop-color)'
|
|
1971
|
+
: edgeHueColor(edgeCounter);
|
|
1972
|
+
const baseClasses = edge.is_loop
|
|
1973
|
+
? 'machine-edge machine-loop'
|
|
1974
|
+
: 'machine-edge';
|
|
1975
|
+
|
|
1976
|
+
for (const binding of edge.assignment) {
|
|
1977
|
+
edges.push({
|
|
1978
|
+
group: 'edges',
|
|
1979
|
+
data: {
|
|
1980
|
+
id: `machine-edge-${edgeCounter}`,
|
|
1981
|
+
source: binding.source_node,
|
|
1982
|
+
target: edge.target_node,
|
|
1983
|
+
label,
|
|
1984
|
+
title: `${capTitle} (${binding.cap_arg_media_urn})`,
|
|
1985
|
+
fullUrn: capUrn,
|
|
1986
|
+
color,
|
|
1987
|
+
strandIndex: strandIdx,
|
|
1988
|
+
capArgMediaUrn: binding.cap_arg_media_urn,
|
|
1989
|
+
isLoop: edge.is_loop,
|
|
1990
|
+
},
|
|
1991
|
+
classes: baseClasses,
|
|
1992
|
+
});
|
|
1993
|
+
edgeCounter++;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
return { nodes, edges };
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function resolvedMachineCytoscapeElements(built) {
|
|
2002
|
+
return built.nodes.concat(built.edges);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1751
2005
|
// =============================================================================
|
|
1752
2006
|
// Renderer class.
|
|
1753
2007
|
// =============================================================================
|
|
@@ -1761,9 +2015,9 @@ class CapGraphRenderer {
|
|
|
1761
2015
|
throw new Error('CapGraphRenderer: options must be an object');
|
|
1762
2016
|
}
|
|
1763
2017
|
const mode = options.mode;
|
|
1764
|
-
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
|
|
2018
|
+
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine' && mode !== 'editor-graph') {
|
|
1765
2019
|
throw new Error(
|
|
1766
|
-
`CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine" (got ${JSON.stringify(mode)})`
|
|
2020
|
+
`CapGraphRenderer: options.mode must be one of "browse", "strand", "run", "machine", "editor-graph" (got ${JSON.stringify(mode)})`
|
|
1767
2021
|
);
|
|
1768
2022
|
}
|
|
1769
2023
|
|
|
@@ -1827,8 +2081,12 @@ class CapGraphRenderer {
|
|
|
1827
2081
|
this._strandBuilt = null;
|
|
1828
2082
|
this._runBuilt = null;
|
|
1829
2083
|
|
|
1830
|
-
//
|
|
1831
|
-
this.
|
|
2084
|
+
// Editor-graph state (Monaco machine-notation editor live view).
|
|
2085
|
+
this._editorGraphSignature = null;
|
|
2086
|
+
this._editorGraphBuilt = null;
|
|
2087
|
+
|
|
2088
|
+
// Resolved-machine state (canonical Machine render payload from
|
|
2089
|
+
// Rust `Machine::to_render_payload_json`).
|
|
1832
2090
|
this._machineBuilt = null;
|
|
1833
2091
|
|
|
1834
2092
|
// Theme observer.
|
|
@@ -1880,15 +2138,19 @@ class CapGraphRenderer {
|
|
|
1880
2138
|
this._runBuilt = buildRunGraphData(data);
|
|
1881
2139
|
return this;
|
|
1882
2140
|
}
|
|
1883
|
-
if (this.mode === '
|
|
1884
|
-
const signature =
|
|
1885
|
-
if (signature === this.
|
|
2141
|
+
if (this.mode === 'editor-graph') {
|
|
2142
|
+
const signature = editorGraphSignature(data);
|
|
2143
|
+
if (signature === this._editorGraphSignature && this.cy) {
|
|
1886
2144
|
// Same shape — restyle for theme changes and return.
|
|
1887
2145
|
this.cy.style(buildStylesheet());
|
|
1888
2146
|
return this;
|
|
1889
2147
|
}
|
|
1890
|
-
this.
|
|
1891
|
-
this.
|
|
2148
|
+
this._editorGraphSignature = signature;
|
|
2149
|
+
this._editorGraphBuilt = buildEditorGraphData(data);
|
|
2150
|
+
return this;
|
|
2151
|
+
}
|
|
2152
|
+
if (this.mode === 'machine') {
|
|
2153
|
+
this._machineBuilt = buildResolvedMachineGraphData(data);
|
|
1892
2154
|
return this;
|
|
1893
2155
|
}
|
|
1894
2156
|
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
@@ -1956,7 +2218,7 @@ class CapGraphRenderer {
|
|
|
1956
2218
|
maxZoom: 10,
|
|
1957
2219
|
wheelSensitivity: 0.3,
|
|
1958
2220
|
boxSelectionEnabled: false,
|
|
1959
|
-
autounselectify: this.mode === 'machine',
|
|
2221
|
+
autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
|
|
1960
2222
|
});
|
|
1961
2223
|
|
|
1962
2224
|
const resizeAndRefit = () => {
|
|
@@ -1989,9 +2251,13 @@ class CapGraphRenderer {
|
|
|
1989
2251
|
if (!this._runBuilt) return [];
|
|
1990
2252
|
return runCytoscapeElements(this._runBuilt);
|
|
1991
2253
|
}
|
|
2254
|
+
if (this.mode === 'editor-graph') {
|
|
2255
|
+
if (!this._editorGraphBuilt) return [];
|
|
2256
|
+
return editorGraphCytoscapeElements(this._editorGraphBuilt);
|
|
2257
|
+
}
|
|
1992
2258
|
if (this.mode === 'machine') {
|
|
1993
2259
|
if (!this._machineBuilt) return [];
|
|
1994
|
-
return
|
|
2260
|
+
return resolvedMachineCytoscapeElements(this._machineBuilt);
|
|
1995
2261
|
}
|
|
1996
2262
|
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1997
2263
|
}
|
|
@@ -2252,12 +2518,13 @@ class CapGraphRenderer {
|
|
|
2252
2518
|
}
|
|
2253
2519
|
|
|
2254
2520
|
// ===========================================================================
|
|
2255
|
-
//
|
|
2521
|
+
// Editor-graph mode API — used by the notation editor for
|
|
2522
|
+
// cross-highlight between source-text spans and graph elements.
|
|
2256
2523
|
// ===========================================================================
|
|
2257
2524
|
|
|
2258
|
-
|
|
2259
|
-
if (this.mode !== '
|
|
2260
|
-
throw new Error(`CapGraphRenderer:
|
|
2525
|
+
applyEditorGraphActiveTokenIds(tokenIds) {
|
|
2526
|
+
if (this.mode !== 'editor-graph') {
|
|
2527
|
+
throw new Error(`CapGraphRenderer: applyEditorGraphActiveTokenIds is only valid in editor-graph mode (current: ${this.mode})`);
|
|
2261
2528
|
}
|
|
2262
2529
|
if (!this.cy) return;
|
|
2263
2530
|
const wanted = new Set(tokenIds || []);
|
|
@@ -2575,11 +2842,13 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
2575
2842
|
buildStrandGraphData,
|
|
2576
2843
|
collapseStrandShapeTransitions,
|
|
2577
2844
|
buildRunGraphData,
|
|
2578
|
-
|
|
2845
|
+
buildEditorGraphData,
|
|
2846
|
+
buildResolvedMachineGraphData,
|
|
2579
2847
|
classifyStrandCapSteps,
|
|
2580
2848
|
validateStrandPayload,
|
|
2581
2849
|
validateRunPayload,
|
|
2582
|
-
|
|
2850
|
+
validateEditorGraphPayload,
|
|
2851
|
+
validateResolvedMachinePayload,
|
|
2583
2852
|
validateStrandStep,
|
|
2584
2853
|
validateBodyOutcome,
|
|
2585
2854
|
};
|