capdag 0.106.243 → 0.119.263
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 +756 -170
- package/capdag.test.js +574 -57
- package/machine-parser.js +126 -64
- package/package.json +1 -1
package/cap-graph-renderer.js
CHANGED
|
@@ -271,7 +271,11 @@ function buildStylesheet() {
|
|
|
271
271
|
{
|
|
272
272
|
selector: 'node.show-more',
|
|
273
273
|
style: {
|
|
274
|
-
|
|
274
|
+
// Use the normal node fill; the dashed border is what
|
|
275
|
+
// distinguishes a show-more node from a regular cap.
|
|
276
|
+
// The renderer never reads `--graph-bg` — the graph
|
|
277
|
+
// canvas background is entirely the host's concern.
|
|
278
|
+
'background-color': nodeBg,
|
|
275
279
|
'border-style': 'dashed',
|
|
276
280
|
'border-width': '2px',
|
|
277
281
|
'border-color': nodeBorderHighlighted,
|
|
@@ -663,22 +667,38 @@ function classifyStrandCapSteps(steps) {
|
|
|
663
667
|
return { capStepIndices, capFlags };
|
|
664
668
|
}
|
|
665
669
|
|
|
666
|
-
// Build the strand graph
|
|
667
|
-
// (`
|
|
668
|
-
//
|
|
670
|
+
// Build the strand graph by mirroring capdag's plan builder
|
|
671
|
+
// (`capdag/src/planner/plan_builder.rs::build_plan_from_path`). The plan
|
|
672
|
+
// builder is the authoritative source of truth for how strand steps
|
|
673
|
+
// translate into a DAG of nodes and edges:
|
|
669
674
|
//
|
|
670
|
-
// *
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
675
|
+
// * Node IDs are positional: `input_slot`, `step_0`, `step_1`, …,
|
|
676
|
+
// `output`. They are NOT media URN strings — URN comparisons for
|
|
677
|
+
// graph topology are wrong because the planner connects steps by
|
|
678
|
+
// the order-theoretic `conformsTo` relation, not by string equality.
|
|
679
|
+
// * `prev_node_id` is a single running pointer, only advanced by Cap
|
|
680
|
+
// steps. ForEach steps mark the start of a body span without
|
|
681
|
+
// advancing prev; the body's first Cap still connects to whatever
|
|
682
|
+
// was before the ForEach.
|
|
683
|
+
// * Cap inside a ForEach body connects from `prev_node_id` like any
|
|
684
|
+
// other cap, AND tracks `body_entry` (first cap in body) and
|
|
685
|
+
// `body_exit` (most recent cap in body).
|
|
686
|
+
// * Collect after a ForEach body creates a ForEach node with
|
|
687
|
+
// boundaries, an iteration edge to body_entry, a Collect node, and
|
|
688
|
+
// a collection edge from body_exit to Collect. prev_node_id becomes
|
|
689
|
+
// the Collect node.
|
|
690
|
+
// * Standalone Collect (no enclosing ForEach) creates a Collect node
|
|
691
|
+
// consuming prev_node_id directly.
|
|
692
|
+
// * Unclosed ForEach with no body caps is a terminal unwrap — the
|
|
693
|
+
// ForEach node is skipped; prev_node_id stays as-is.
|
|
694
|
+
// * Unclosed ForEach WITH body caps gets a ForEach node, iteration
|
|
695
|
+
// edge to body_entry, and prev_node_id becomes body_exit.
|
|
674
696
|
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
//
|
|
679
|
-
//
|
|
680
|
-
// `target_spec` differs from the last cap step's `to_spec` (the Collect
|
|
681
|
-
// shape transition).
|
|
697
|
+
// Node labels come from the `media_display_names` map keyed by the
|
|
698
|
+
// step's canonical URN (or source_spec/target_spec for the boundary
|
|
699
|
+
// nodes). ForEach and Collect nodes display "for each" / "collect".
|
|
700
|
+
// Cap edges carry the cap title plus cardinality marker when either
|
|
701
|
+
// input or output is a sequence.
|
|
682
702
|
function buildStrandGraphData(data) {
|
|
683
703
|
validateStrandPayload(data);
|
|
684
704
|
|
|
@@ -686,158 +706,520 @@ function buildStrandGraphData(data) {
|
|
|
686
706
|
const sourceSpec = canonicalMediaUrn(data.source_spec);
|
|
687
707
|
const targetSpec = canonicalMediaUrn(data.target_spec);
|
|
688
708
|
|
|
689
|
-
|
|
709
|
+
// Look up a display name for a media URN via the host-supplied map.
|
|
710
|
+
// Uses `MediaUrn.isEquivalent` so tag-order variation doesn't defeat
|
|
711
|
+
// the lookup — URNs are compared semantically, never as raw strings.
|
|
712
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
713
|
+
const displayEntries = [];
|
|
690
714
|
for (const [urn, display] of Object.entries(mediaDisplayNames)) {
|
|
691
|
-
if (typeof display
|
|
692
|
-
|
|
715
|
+
if (typeof display !== 'string' || display.length === 0) continue;
|
|
716
|
+
try {
|
|
717
|
+
displayEntries.push({ media: MediaUrn.fromString(urn), display });
|
|
718
|
+
} catch (_) {
|
|
719
|
+
// Skip entries with unparseable URN keys — the host payload is
|
|
720
|
+
// trusted, but malformed keys are not fatal.
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function displayNameFor(canonicalUrn) {
|
|
724
|
+
const candidate = MediaUrn.fromString(canonicalUrn);
|
|
725
|
+
for (const entry of displayEntries) {
|
|
726
|
+
if (candidate.isEquivalent(entry.media)) return entry.display;
|
|
693
727
|
}
|
|
728
|
+
return mediaNodeLabel(canonicalUrn);
|
|
694
729
|
}
|
|
695
730
|
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
731
|
+
const nodes = [];
|
|
732
|
+
const edges = [];
|
|
733
|
+
const nodeIds = new Set();
|
|
734
|
+
|
|
735
|
+
function addNode(id, label, fullUrn, nodeClass) {
|
|
736
|
+
if (nodeIds.has(id)) return;
|
|
737
|
+
nodeIds.add(id);
|
|
738
|
+
nodes.push({
|
|
739
|
+
id,
|
|
740
|
+
label,
|
|
741
|
+
fullUrn: fullUrn || '',
|
|
742
|
+
nodeClass: nodeClass || '',
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
let edgeCounter = 0;
|
|
746
|
+
function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
|
|
747
|
+
const m = meta || {};
|
|
748
|
+
edges.push({
|
|
749
|
+
id: `strand-edge-${edgeCounter}`,
|
|
750
|
+
source,
|
|
751
|
+
target,
|
|
752
|
+
label: label || '',
|
|
753
|
+
title: title || '',
|
|
754
|
+
fullUrn: fullUrn || '',
|
|
755
|
+
edgeClass: edgeClass || '',
|
|
756
|
+
color: edgeHueColor(edgeCounter),
|
|
757
|
+
// `foreachEntry` flags a cap edge as the first cap entering a
|
|
758
|
+
// ForEach body (the "phantom direct edge" in plan builder's
|
|
759
|
+
// terminology). Render-time collapse uses this to relabel the
|
|
760
|
+
// edge with the cap title + (1→n) marker. Defaults to false.
|
|
761
|
+
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
|
+
});
|
|
768
|
+
edgeCounter++;
|
|
769
|
+
}
|
|
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
|
+
}
|
|
705
781
|
}
|
|
706
|
-
return canonicalUrn;
|
|
707
782
|
}
|
|
708
783
|
|
|
709
|
-
|
|
710
|
-
|
|
784
|
+
// Entry node — the strand's source media spec.
|
|
785
|
+
const inputSlotId = 'input_slot';
|
|
786
|
+
addNode(inputSlotId, displayNameFor(sourceSpec), sourceSpec, 'strand-source');
|
|
711
787
|
|
|
712
|
-
|
|
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));
|
|
788
|
+
let prevNodeId = inputSlotId;
|
|
719
789
|
|
|
720
|
-
|
|
721
|
-
|
|
790
|
+
// Track ForEach body membership. `insideForEachBody = { index, nodeId }`
|
|
791
|
+
// records which ForEach step we're inside and the id we'll give its
|
|
792
|
+
// eventual node. `bodyEntry`/`bodyExit` track the first and most
|
|
793
|
+
// recent Cap step inside that body.
|
|
794
|
+
let insideForEachBody = null;
|
|
795
|
+
let bodyEntry = null;
|
|
796
|
+
let bodyExit = null;
|
|
722
797
|
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
title: label || 'fan-out',
|
|
737
|
-
label,
|
|
738
|
-
cardinality: '',
|
|
739
|
-
capUrn: '',
|
|
740
|
-
color: edgeHueColor(capEdgeIdx),
|
|
741
|
-
});
|
|
742
|
-
capEdgeIdx++;
|
|
743
|
-
}
|
|
798
|
+
// Finalize an outer ForEach body when a nested ForEach starts before
|
|
799
|
+
// the outer's Collect. Mirrors plan_builder.rs:238-289. The render
|
|
800
|
+
// collapse will later drop the ForEach node and synthesize the
|
|
801
|
+
// bridging edges, so we only need to emit the plan-builder
|
|
802
|
+
// topology here.
|
|
803
|
+
function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
|
|
804
|
+
const outerForEachInput = outerForEach.index === 0
|
|
805
|
+
? inputSlotId
|
|
806
|
+
: `step_${outerForEach.index - 1}`;
|
|
807
|
+
addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
|
|
808
|
+
addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
809
|
+
addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
|
|
810
|
+
return outerExit;
|
|
744
811
|
}
|
|
745
812
|
|
|
746
|
-
|
|
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) => {
|
|
813
|
+
data.steps.forEach((step, i) => {
|
|
755
814
|
const variant = Object.keys(step.step_type)[0];
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
815
|
+
const nodeId = `step_${i}`;
|
|
816
|
+
|
|
817
|
+
if (variant === 'Cap') {
|
|
818
|
+
const body = step.step_type.Cap;
|
|
819
|
+
const toCanonical = canonicalMediaUrn(step.to_spec);
|
|
820
|
+
addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
|
|
821
|
+
|
|
822
|
+
// The first cap inside a ForEach body is the "foreach entry"
|
|
823
|
+
// — its incoming edge crosses the foreach boundary. Strand
|
|
824
|
+
// mode's render collapse relabels that edge with a (1→n)
|
|
825
|
+
// cardinality marker regardless of the cap's own sequence
|
|
826
|
+
// flags, because visually the transition IS the foreach.
|
|
827
|
+
const isForeachEntry = insideForEachBody !== null && bodyEntry === null;
|
|
828
|
+
|
|
829
|
+
let label = body.title;
|
|
830
|
+
const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
|
|
831
|
+
if (cardinality !== '1\u21921') {
|
|
832
|
+
label = `${label} (${cardinality})`;
|
|
833
|
+
}
|
|
834
|
+
addEdge(
|
|
835
|
+
prevNodeId,
|
|
836
|
+
nodeId,
|
|
837
|
+
label,
|
|
838
|
+
body.title,
|
|
839
|
+
body.cap_urn,
|
|
840
|
+
'strand-cap-edge',
|
|
841
|
+
{ foreachEntry: isForeachEntry }
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
if (insideForEachBody !== null) {
|
|
845
|
+
if (bodyEntry === null) bodyEntry = nodeId;
|
|
846
|
+
bodyExit = nodeId;
|
|
847
|
+
}
|
|
762
848
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
if (cardinality !== '1\u21921') {
|
|
766
|
-
label = `${label} (${cardinality})`;
|
|
849
|
+
prevNodeId = nodeId;
|
|
850
|
+
return;
|
|
767
851
|
}
|
|
768
852
|
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
853
|
+
if (variant === 'ForEach') {
|
|
854
|
+
// If we're already inside a ForEach body when another ForEach
|
|
855
|
+
// starts, finalize the outer one first.
|
|
856
|
+
if (insideForEachBody !== null) {
|
|
857
|
+
const outer = insideForEachBody;
|
|
858
|
+
const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
|
|
859
|
+
const exit = bodyExit !== null ? bodyExit : prevNodeId;
|
|
860
|
+
if (bodyEntry === null) {
|
|
861
|
+
// Outer ForEach with no body caps is an illegal nesting; the
|
|
862
|
+
// plan builder throws. Mirror that.
|
|
863
|
+
throw new Error(
|
|
864
|
+
`CapGraphRenderer strand: nested ForEach at step[${i}] but outer ForEach at step[${outer.index}] has no body caps`
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
prevNodeId = finalizeOuterForEach(outer, entry, exit);
|
|
868
|
+
bodyEntry = null;
|
|
869
|
+
bodyExit = null;
|
|
870
|
+
}
|
|
871
|
+
insideForEachBody = { index: i, nodeId };
|
|
872
|
+
bodyEntry = null;
|
|
873
|
+
bodyExit = null;
|
|
874
|
+
// Do NOT advance prevNodeId — the body's first cap will connect
|
|
875
|
+
// to whatever was before the ForEach.
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (variant === 'Collect') {
|
|
880
|
+
if (insideForEachBody !== null) {
|
|
881
|
+
const outer = insideForEachBody;
|
|
882
|
+
const entry = bodyEntry !== null ? bodyEntry : prevNodeId;
|
|
883
|
+
const exit = bodyExit !== null ? bodyExit : prevNodeId;
|
|
884
|
+
const outerForEachInput = outer.index === 0
|
|
885
|
+
? inputSlotId
|
|
886
|
+
: `step_${outer.index - 1}`;
|
|
887
|
+
|
|
888
|
+
addNode(outer.nodeId, 'for each', '', 'strand-foreach');
|
|
889
|
+
addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
890
|
+
addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
|
|
891
|
+
|
|
892
|
+
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
893
|
+
addEdge(exit, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
894
|
+
|
|
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
|
+
insideForEachBody = null;
|
|
905
|
+
bodyEntry = null;
|
|
906
|
+
bodyExit = null;
|
|
907
|
+
prevNodeId = nodeId;
|
|
908
|
+
} else {
|
|
909
|
+
// Standalone Collect — scalar → list-of-one. Mirrors
|
|
910
|
+
// plan_builder.rs:333-355. There is no enclosing foreach
|
|
911
|
+
// body, so the preceding cap is NOT flagged as a
|
|
912
|
+
// foreach-exit; the render-time collapse will synthesize a
|
|
913
|
+
// plain "collect" marker on the synthesized edge replacing
|
|
914
|
+
// the dropped Collect node.
|
|
915
|
+
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
916
|
+
addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
917
|
+
prevNodeId = nodeId;
|
|
918
|
+
}
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
throw new Error(`CapGraphRenderer strand: unknown step_type variant '${variant}' at step[${i}]`);
|
|
780
923
|
});
|
|
781
924
|
|
|
782
|
-
//
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
925
|
+
// Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
|
|
926
|
+
// An unclosed ForEach with a body is NOT marked as `singleCapClosedBody`
|
|
927
|
+
// because there's no Collect closing it — the body "fans out" but
|
|
928
|
+
// never converges. The body entry still gets (1→n) on its incoming
|
|
929
|
+
// edge via the foreachEntry flag set in the Cap handler.
|
|
930
|
+
if (insideForEachBody !== null) {
|
|
931
|
+
const outer = insideForEachBody;
|
|
932
|
+
const hasBodyEntry = bodyEntry !== null;
|
|
933
|
+
if (hasBodyEntry) {
|
|
934
|
+
const entry = bodyEntry;
|
|
935
|
+
const exit = bodyExit;
|
|
936
|
+
const outerForEachInput = outer.index === 0
|
|
937
|
+
? inputSlotId
|
|
938
|
+
: `step_${outer.index - 1}`;
|
|
939
|
+
addNode(outer.nodeId, 'for each', '', 'strand-foreach');
|
|
940
|
+
addEdge(outerForEachInput, outer.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
941
|
+
addEdge(outer.nodeId, entry, '', '', '', 'strand-iteration');
|
|
942
|
+
prevNodeId = exit;
|
|
943
|
+
}
|
|
944
|
+
// hasBodyEntry === false is a terminal unwrap — skip the ForEach
|
|
945
|
+
// node entirely, prev_node_id stays as-is.
|
|
946
|
+
insideForEachBody = null;
|
|
947
|
+
bodyEntry = null;
|
|
948
|
+
bodyExit = null;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Final output node. Mirrors plan_builder.rs:430-432.
|
|
952
|
+
const outputId = 'output';
|
|
953
|
+
addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
|
|
954
|
+
addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
|
|
955
|
+
|
|
956
|
+
// Return the raw plan-builder topology. Strand mode collapses
|
|
957
|
+
// ForEach/Collect nodes into edge labels at render time (see
|
|
958
|
+
// `strandCytoscapeElements`); run mode keeps them as explicit
|
|
959
|
+
// nodes because body replicas anchor at the ForEach/Collect
|
|
960
|
+
// junctions.
|
|
961
|
+
return { nodes, edges, sourceSpec, targetSpec };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Transform the plan-builder strand topology into the render shape
|
|
965
|
+
// strand mode actually displays. Pure function; does NOT mutate the
|
|
966
|
+
// input. Run mode bypasses this transform and consumes the raw
|
|
967
|
+
// topology directly (see `runCytoscapeElements`).
|
|
968
|
+
//
|
|
969
|
+
// The display rules (per user spec):
|
|
970
|
+
//
|
|
971
|
+
// 1. ForEach and Collect are NOT rendered as nodes. They're
|
|
972
|
+
// execution-layer concepts; the visible semantic is carried on
|
|
973
|
+
// the surrounding cap edges.
|
|
974
|
+
//
|
|
975
|
+
// 2. The first cap edge entering a ForEach body is relabeled to
|
|
976
|
+
// `<cap_title> (1→n)`. The builder flags those edges with
|
|
977
|
+
// `foreachEntry: true`. The (1→n) overrides whatever cardinality
|
|
978
|
+
// the cap's own `input_is_sequence`/`output_is_sequence` would
|
|
979
|
+
// produce, because visually the transition is the foreach.
|
|
980
|
+
//
|
|
981
|
+
// 3. The last cap edge exiting a ForEach body is replaced by a
|
|
982
|
+
// synthesized bridging edge labeled `<cap_title> (n→1)` — the
|
|
983
|
+
// symmetric counterpart of the foreach-entry (1→n) label.
|
|
984
|
+
// For a single-cap body (same cap is entry and exit) the flag
|
|
985
|
+
// `singleCapClosedBody` collapses both markers into a single
|
|
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.
|
|
992
|
+
//
|
|
993
|
+
// 4. If the last cap step's `to_spec` is semantically equivalent
|
|
994
|
+
// to the strand's `target_spec` (via MediaUrn.isEquivalent),
|
|
995
|
+
// the separate `output` target node is dropped and the last
|
|
996
|
+
// cap edge lands on that merged endpoint. Removes the visible
|
|
997
|
+
// duplicate node.
|
|
998
|
+
function collapseStrandShapeTransitions(built) {
|
|
999
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
1000
|
+
const foreachCardinality = cardinalityLabel(false, true); // "1→n"
|
|
1001
|
+
const collectCardinality = cardinalityLabel(true, false); // "n→1"
|
|
1002
|
+
|
|
1003
|
+
// Index for lookups.
|
|
1004
|
+
const nodeById = new Map();
|
|
1005
|
+
for (const n of built.nodes) nodeById.set(n.id, n);
|
|
1006
|
+
|
|
1007
|
+
// Step 1: before dropping Collect nodes, synthesize the
|
|
1008
|
+
// bridging edge that replaces each dropped Collect. For a
|
|
1009
|
+
// Collect node C the plan builder produced:
|
|
1010
|
+
// body_exit → C (strand-collection, label="collect")
|
|
1011
|
+
// C → next (strand-cap-edge, label="")
|
|
1012
|
+
// The collapse drops C and its two touching edges. To preserve
|
|
1013
|
+
// the flow from body_exit to `next`, we synthesize a new edge
|
|
1014
|
+
// body_exit → next. Its label depends on the Collect's
|
|
1015
|
+
// context (determined by inspecting the body_exit cap's own
|
|
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.
|
|
1034
|
+
const synthesizedExitEdges = [];
|
|
1035
|
+
for (const node of built.nodes) {
|
|
1036
|
+
if (node.nodeClass !== 'strand-collect') continue;
|
|
1037
|
+
const incoming = built.edges.filter(e =>
|
|
1038
|
+
e.target === node.id && e.edgeClass === 'strand-collection');
|
|
1039
|
+
const outgoing = built.edges.filter(e =>
|
|
1040
|
+
e.source === node.id && e.edgeClass === 'strand-cap-edge');
|
|
1041
|
+
for (const inEdge of incoming) {
|
|
1042
|
+
for (const outEdge of outgoing) {
|
|
1043
|
+
const bodyExitNodeId = inEdge.source;
|
|
1044
|
+
const bodyExitCapEdge = built.edges.find(e =>
|
|
1045
|
+
e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
|
|
1046
|
+
// Is the Collect part of a closed foreach body? A Collect
|
|
1047
|
+
// is "closed" when the body_exit cap is reachable from the
|
|
1048
|
+
// same foreach that the Collect closes. We detect this by
|
|
1049
|
+
// checking whether the Collect's target node has any
|
|
1050
|
+
// incoming iteration edge (i.e. there's a strand-foreach
|
|
1051
|
+
// node whose outgoing iteration reaches the body_exit or
|
|
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
|
+
}
|
|
1098
|
+
synthesizedExitEdges.push({
|
|
1099
|
+
id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
|
|
1100
|
+
source: bodyExitNodeId,
|
|
1101
|
+
target: outEdge.target,
|
|
1102
|
+
label: synthLabel,
|
|
1103
|
+
title: synthTitle,
|
|
1104
|
+
fullUrn: synthFullUrn,
|
|
1105
|
+
edgeClass: 'strand-cap-edge',
|
|
1106
|
+
color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
|
|
1107
|
+
foreachEntry: false,
|
|
1108
|
+
singleCapClosedBody: false,
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
801
1111
|
}
|
|
802
1112
|
}
|
|
803
1113
|
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1114
|
+
// Drop all ForEach/Collect nodes and every edge that touches
|
|
1115
|
+
// them (direct, iteration, collection, and the trailing collect
|
|
1116
|
+
// cap-edge connector). The render never shows those nodes.
|
|
1117
|
+
const dropNodeIds = new Set();
|
|
1118
|
+
for (const node of built.nodes) {
|
|
1119
|
+
if (node.nodeClass === 'strand-foreach' || node.nodeClass === 'strand-collect') {
|
|
1120
|
+
dropNodeIds.add(node.id);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
let nodes = built.nodes.filter(n => !dropNodeIds.has(n.id));
|
|
1124
|
+
let edges = built.edges.filter(e =>
|
|
1125
|
+
!dropNodeIds.has(e.source) &&
|
|
1126
|
+
!dropNodeIds.has(e.target) &&
|
|
1127
|
+
e.edgeClass !== 'strand-iteration' &&
|
|
1128
|
+
e.edgeClass !== 'strand-collection');
|
|
1129
|
+
edges = edges.concat(synthesizedExitEdges);
|
|
1130
|
+
|
|
1131
|
+
// Step 2: relabel flagged foreach-entry cap edges with the
|
|
1132
|
+
// cap title + cardinality marker.
|
|
1133
|
+
//
|
|
1134
|
+
// When the body is a single cap (bodyEntry === bodyExit under a
|
|
1135
|
+
// closed Collect), the cap edge has `singleCapClosedBody: true`
|
|
1136
|
+
// and we use (n→n) — the outer sequence is iterated, the cap
|
|
1137
|
+
// runs per-item, and the result is collected back into a
|
|
1138
|
+
// sequence. Otherwise we use plain (1→n) for the foreach entry.
|
|
1139
|
+
const iterAndCollectCardinality = cardinalityLabel(true, true); // "n→n"
|
|
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
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Step 3: merge the trailing `step_N → output` edge when step_N
|
|
1156
|
+
// and output represent the same media URN. The strand builder
|
|
1157
|
+
// always emits a separate `output` node with a (possibly empty)
|
|
1158
|
+
// connector edge from the last prev; when the URNs coincide the
|
|
1159
|
+
// output is a visible duplicate.
|
|
1160
|
+
//
|
|
1161
|
+
// Find the `strand-target` node and look for a single incoming
|
|
1162
|
+
// edge from a `strand-cap` (or `strand-source`) node. Compare the
|
|
1163
|
+
// endpoints' `fullUrn` semantically.
|
|
1164
|
+
const targetNode = nodes.find(n => n.nodeClass === 'strand-target');
|
|
1165
|
+
if (targetNode) {
|
|
1166
|
+
const incomingToTarget = edges.filter(e => e.target === targetNode.id);
|
|
1167
|
+
if (incomingToTarget.length === 1) {
|
|
1168
|
+
const trailing = incomingToTarget[0];
|
|
1169
|
+
const upstreamNode = nodes.find(n => n.id === trailing.source);
|
|
1170
|
+
if (upstreamNode && upstreamNode.fullUrn && targetNode.fullUrn) {
|
|
1171
|
+
let equivalent = false;
|
|
1172
|
+
try {
|
|
1173
|
+
const a = MediaUrn.fromString(upstreamNode.fullUrn);
|
|
1174
|
+
const b = MediaUrn.fromString(targetNode.fullUrn);
|
|
1175
|
+
equivalent = a.isEquivalent(b);
|
|
1176
|
+
} catch (_) {
|
|
1177
|
+
equivalent = false;
|
|
1178
|
+
}
|
|
1179
|
+
// Merge only if the trailing edge is the unadorned connector
|
|
1180
|
+
// (empty label). A labeled last-cap edge carries meaningful
|
|
1181
|
+
// info and must not be collapsed away.
|
|
1182
|
+
if (equivalent && (!trailing.label || trailing.label.length === 0)) {
|
|
1183
|
+
// Drop the trailing connector edge and the target node.
|
|
1184
|
+
// The upstream node effectively becomes the target visually.
|
|
1185
|
+
// Rename its display label to the target's display to
|
|
1186
|
+
// preserve the user-configured media_display_names entry.
|
|
1187
|
+
edges = edges.filter(e => e.id !== trailing.id);
|
|
1188
|
+
nodes = nodes
|
|
1189
|
+
.filter(n => n.id !== targetNode.id)
|
|
1190
|
+
.map(n => n.id === upstreamNode.id
|
|
1191
|
+
? Object.assign({}, n, { label: targetNode.label, nodeClass: 'strand-target' })
|
|
1192
|
+
: n);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
818
1196
|
}
|
|
819
1197
|
|
|
820
|
-
return {
|
|
821
|
-
nodes: Array.from(nodesMap.values()),
|
|
822
|
-
edges,
|
|
823
|
-
sourceSpec,
|
|
824
|
-
targetSpec,
|
|
825
|
-
};
|
|
1198
|
+
return { nodes, edges };
|
|
826
1199
|
}
|
|
827
1200
|
|
|
828
|
-
function strandCytoscapeElements(built) {
|
|
829
|
-
|
|
1201
|
+
function strandCytoscapeElements(built, options) {
|
|
1202
|
+
// Strand mode presents ForEach and Collect as edge labels, not as
|
|
1203
|
+
// boxed nodes. Apply the cosmetic collapse right before emitting
|
|
1204
|
+
// cytoscape elements so the underlying plan-builder topology stays
|
|
1205
|
+
// intact for any callers that need it.
|
|
1206
|
+
//
|
|
1207
|
+
// Run mode opts out (`options.collapse === false`) because its body
|
|
1208
|
+
// replicas visually fan out from the ForEach node and merge at the
|
|
1209
|
+
// Collect node — those junctions must remain as explicit graph
|
|
1210
|
+
// nodes for the fan-in/fan-out to be visible.
|
|
1211
|
+
const shouldCollapse = !(options && options.collapse === false);
|
|
1212
|
+
const source = shouldCollapse ? collapseStrandShapeTransitions(built) : built;
|
|
1213
|
+
const nodeElements = source.nodes.map(node => ({
|
|
830
1214
|
group: 'nodes',
|
|
831
1215
|
data: {
|
|
832
1216
|
id: node.id,
|
|
833
1217
|
label: node.label,
|
|
834
1218
|
fullUrn: node.fullUrn,
|
|
835
1219
|
},
|
|
836
|
-
classes: node.
|
|
837
|
-
: node.id === built.targetSpec ? 'strand-target'
|
|
838
|
-
: '',
|
|
1220
|
+
classes: node.nodeClass || '',
|
|
839
1221
|
}));
|
|
840
|
-
const edgeElements =
|
|
1222
|
+
const edgeElements = source.edges.map(edge => ({
|
|
841
1223
|
group: 'edges',
|
|
842
1224
|
data: {
|
|
843
1225
|
id: edge.id,
|
|
@@ -845,10 +1227,10 @@ function strandCytoscapeElements(built) {
|
|
|
845
1227
|
target: edge.target,
|
|
846
1228
|
label: edge.label,
|
|
847
1229
|
title: edge.title,
|
|
848
|
-
|
|
849
|
-
fullUrn: edge.capUrn,
|
|
1230
|
+
fullUrn: edge.fullUrn,
|
|
850
1231
|
color: edge.color,
|
|
851
1232
|
},
|
|
1233
|
+
classes: edge.edgeClass || '',
|
|
852
1234
|
}));
|
|
853
1235
|
return nodeElements.concat(edgeElements);
|
|
854
1236
|
}
|
|
@@ -870,20 +1252,98 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
|
870
1252
|
return -1;
|
|
871
1253
|
}
|
|
872
1254
|
|
|
1255
|
+
// Remove body-interior cap nodes (caps strictly AFTER the first
|
|
1256
|
+
// body cap) from a COLLAPSED strand backbone so that per-body
|
|
1257
|
+
// replicas are the only visible path through the foreach body.
|
|
1258
|
+
// The FIRST body cap node is retained — its incoming foreachEntry
|
|
1259
|
+
// edge is the backbone "fallback connector" that keeps the graph
|
|
1260
|
+
// connected when zero bodies succeed.
|
|
1261
|
+
//
|
|
1262
|
+
// Dropping interior body caps also drops their outgoing edges,
|
|
1263
|
+
// including any synthesized collect-exit edge that would have
|
|
1264
|
+
// connected the last body cap to the post-collect target. To
|
|
1265
|
+
// preserve connectivity, the function replaces those with a
|
|
1266
|
+
// plain bridging edge from the first body cap directly to the
|
|
1267
|
+
// post-collect target.
|
|
1268
|
+
function stripRunBackboneBodyInterior(built, steps, foreachStepIdx, collectStepIdx) {
|
|
1269
|
+
if (foreachStepIdx < 0) return built;
|
|
1270
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
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
|
+
|
|
1323
|
+
return {
|
|
1324
|
+
nodes: keptNodes,
|
|
1325
|
+
edges: keptEdges,
|
|
1326
|
+
sourceSpec: built.sourceSpec,
|
|
1327
|
+
targetSpec: built.targetSpec,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
873
1331
|
function buildRunGraphData(data) {
|
|
874
1332
|
validateRunPayload(data);
|
|
875
1333
|
|
|
876
|
-
//
|
|
877
|
-
//
|
|
878
|
-
// ForEach/Collect
|
|
1334
|
+
// Build the raw strand topology and then apply the cosmetic
|
|
1335
|
+
// collapse — run mode uses the SAME cleaned-up backbone that
|
|
1336
|
+
// strand mode uses (no ForEach/Collect nodes). Replicas are an
|
|
1337
|
+
// additional overlay on top of that collapsed backbone.
|
|
879
1338
|
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
880
1339
|
media_display_names: data.media_display_names,
|
|
881
1340
|
});
|
|
882
|
-
const
|
|
1341
|
+
const strandBuiltRaw = buildStrandGraphData(strandInput);
|
|
1342
|
+
const strandBuiltCollapsed = collapseStrandShapeTransitions(strandBuiltRaw);
|
|
883
1343
|
|
|
884
|
-
// Locate the ForEach/Collect span in the
|
|
885
|
-
//
|
|
886
|
-
//
|
|
1344
|
+
// Locate the ForEach/Collect span in the raw steps. Positional IDs
|
|
1345
|
+
// survive the collapse (node IDs are `step_${i}` from the builder),
|
|
1346
|
+
// so we can still identify which collapsed nodes are the body caps.
|
|
887
1347
|
const steps = data.resolved_strand.steps;
|
|
888
1348
|
let foreachStepIdx = -1;
|
|
889
1349
|
let collectStepIdx = -1;
|
|
@@ -894,6 +1354,15 @@ function buildRunGraphData(data) {
|
|
|
894
1354
|
}
|
|
895
1355
|
const hasForeach = foreachStepIdx >= 0;
|
|
896
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;
|
|
1365
|
+
|
|
897
1366
|
// Filter and bound the outcomes.
|
|
898
1367
|
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
899
1368
|
const successes = allOutcomes.filter(o => o.success);
|
|
@@ -903,10 +1372,8 @@ function buildRunGraphData(data) {
|
|
|
903
1372
|
const hiddenSuccessCount = successes.length - visibleSuccess.length;
|
|
904
1373
|
const hiddenFailureCount = failures.length - visibleFailure.length;
|
|
905
1374
|
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
// ForEach) and extends through either all body caps (success) or up to
|
|
909
|
-
// the failed_cap (failure).
|
|
1375
|
+
// Collect the Cap steps inside the ForEach body. Each body replica
|
|
1376
|
+
// chains through these caps.
|
|
910
1377
|
const bodyCapSteps = [];
|
|
911
1378
|
const bodyStart = hasForeach ? foreachStepIdx + 1 : 0;
|
|
912
1379
|
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
@@ -916,19 +1383,94 @@ function buildRunGraphData(data) {
|
|
|
916
1383
|
}
|
|
917
1384
|
}
|
|
918
1385
|
|
|
919
|
-
//
|
|
920
|
-
//
|
|
1386
|
+
// Body replicas fan out from the node BEFORE the foreach (which,
|
|
1387
|
+
// in the collapsed backbone, is the source of the foreachEntry
|
|
1388
|
+
// edge). For a foreach at step index k, the pre-foreach node is
|
|
1389
|
+
// `step_${k-1}` when k > 0, or `input_slot` when k == 0. This
|
|
1390
|
+
// node survives the collapse as a regular `strand-cap` or
|
|
1391
|
+
// `strand-source` node.
|
|
1392
|
+
//
|
|
1393
|
+
// Replicas merge back at the node AFTER the body. After collapse,
|
|
1394
|
+
// this is the FIRST body cap node (`step_${bodyCapSteps[0]}`)
|
|
1395
|
+
// when the ForEach has a single-cap body, or the post-collect
|
|
1396
|
+
// node for multi-cap bodies. The post-collect node is whatever
|
|
1397
|
+
// the plan builder's Collect step connected to downstream, which
|
|
1398
|
+
// after collapse is either the next cap (`step_${collectStepIdx+1}`)
|
|
1399
|
+
// or the target/output node (possibly merged into the last body
|
|
1400
|
+
// cap if their URNs are equivalent).
|
|
1401
|
+
const anchorNodeId = hasForeach && foreachStepIdx > 0
|
|
1402
|
+
? `step_${foreachStepIdx - 1}`
|
|
1403
|
+
: 'input_slot';
|
|
1404
|
+
|
|
1405
|
+
// Find the merge node by scanning the collapsed backbone for the
|
|
1406
|
+
// node the body reaches after the foreach body. Start from the
|
|
1407
|
+
// first body cap node and follow forward edges until we leave
|
|
1408
|
+
// the body. For a single-body-cap strand where the body cap IS
|
|
1409
|
+
// the merged target, `mergeNodeId` resolves to that node.
|
|
1410
|
+
let mergeNodeId;
|
|
1411
|
+
{
|
|
1412
|
+
// Find body cap step indices in the resolved strand so we can
|
|
1413
|
+
// identify which step IDs are "inside body" (should be skipped
|
|
1414
|
+
// when finding the merge target).
|
|
1415
|
+
const bodyInteriorSet = new Set();
|
|
1416
|
+
const bodyEnd = collectStepIdx >= 0 ? collectStepIdx : steps.length;
|
|
1417
|
+
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
1418
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') {
|
|
1419
|
+
bodyInteriorSet.add(`step_${i}`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
// Start from the first body cap and walk forward.
|
|
1423
|
+
const firstBodyCapIdx = (() => {
|
|
1424
|
+
for (let i = foreachStepIdx + 1; i < bodyEnd; i++) {
|
|
1425
|
+
if (Object.keys(steps[i].step_type)[0] === 'Cap') return i;
|
|
1426
|
+
}
|
|
1427
|
+
return -1;
|
|
1428
|
+
})();
|
|
1429
|
+
if (firstBodyCapIdx < 0) {
|
|
1430
|
+
mergeNodeId = 'output';
|
|
1431
|
+
} else {
|
|
1432
|
+
// Walk the collapsed strand's edges starting from the first
|
|
1433
|
+
// body cap node; the merge target is the first node reached
|
|
1434
|
+
// that isn't inside the body.
|
|
1435
|
+
let cursor = `step_${firstBodyCapIdx}`;
|
|
1436
|
+
let guard = 64;
|
|
1437
|
+
const collapsedNodeIds = new Set(strandBuilt.nodes.map(n => n.id));
|
|
1438
|
+
while (guard-- > 0) {
|
|
1439
|
+
// If the cursor is itself outside the body, we've found it.
|
|
1440
|
+
if (!bodyInteriorSet.has(cursor) && collapsedNodeIds.has(cursor)) {
|
|
1441
|
+
mergeNodeId = cursor;
|
|
1442
|
+
break;
|
|
1443
|
+
}
|
|
1444
|
+
// Otherwise follow the cursor's outgoing edge.
|
|
1445
|
+
const out = strandBuilt.edges.find(e => e.source === cursor);
|
|
1446
|
+
if (!out) {
|
|
1447
|
+
// Nothing to follow — the body cap IS the end of the
|
|
1448
|
+
// strand. Use the cursor as the merge point (it's
|
|
1449
|
+
// typically the merged target node).
|
|
1450
|
+
mergeNodeId = cursor;
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
cursor = out.target;
|
|
1454
|
+
}
|
|
1455
|
+
if (mergeNodeId === undefined) {
|
|
1456
|
+
// Guard exhausted — fall back to output. This is impossible
|
|
1457
|
+
// for any well-formed strand but keeps the runtime safe.
|
|
1458
|
+
mergeNodeId = 'output';
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
921
1463
|
const replicaNodes = [];
|
|
922
1464
|
const replicaEdges = [];
|
|
923
1465
|
|
|
924
|
-
function buildBodyReplica(outcome
|
|
1466
|
+
function buildBodyReplica(outcome) {
|
|
925
1467
|
const success = outcome.success;
|
|
926
1468
|
const successClass = success ? 'body-success' : 'body-failure';
|
|
927
1469
|
const edgeClass = success ? 'body-success' : 'body-failure';
|
|
928
1470
|
const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
|
|
929
1471
|
|
|
930
|
-
//
|
|
931
|
-
//
|
|
1472
|
+
// Trace end: failures stop at failed_cap. `CapUrn.isEquivalent`
|
|
1473
|
+
// is used for the match — never string equality.
|
|
932
1474
|
let traceEnd = bodyCapSteps.length;
|
|
933
1475
|
if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
|
|
934
1476
|
const CapUrn = requireHostDependency('CapUrn');
|
|
@@ -941,12 +1483,9 @@ function buildRunGraphData(data) {
|
|
|
941
1483
|
}
|
|
942
1484
|
}
|
|
943
1485
|
}
|
|
944
|
-
if (traceEnd === 0) return;
|
|
1486
|
+
if (traceEnd === 0) return;
|
|
945
1487
|
|
|
946
|
-
|
|
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;
|
|
1488
|
+
let prevBodyNodeId = anchorNodeId;
|
|
950
1489
|
const bodyKey = `body-${outcome.body_index}`;
|
|
951
1490
|
const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
|
|
952
1491
|
? outcome.title
|
|
@@ -973,6 +1512,11 @@ function buildRunGraphData(data) {
|
|
|
973
1512
|
id: `${bodyKey}-e-${i}`,
|
|
974
1513
|
source: prevBodyNodeId,
|
|
975
1514
|
target: replicaNodeId,
|
|
1515
|
+
// Replica edges carry no inline label — the cap title is
|
|
1516
|
+
// identical across every visible replica and would just
|
|
1517
|
+
// pile up as unreadable rotated text over the fan-out.
|
|
1518
|
+
// Hover tooltip exposes the title via `title`; the edge
|
|
1519
|
+
// color + the replica node identify the body.
|
|
976
1520
|
label: '',
|
|
977
1521
|
title: body.title,
|
|
978
1522
|
fullUrn: body.cap_urn,
|
|
@@ -984,16 +1528,17 @@ function buildRunGraphData(data) {
|
|
|
984
1528
|
prevBodyNodeId = replicaNodeId;
|
|
985
1529
|
}
|
|
986
1530
|
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
990
|
-
|
|
1531
|
+
// Successful bodies merge their replica tail back into the Collect
|
|
1532
|
+
// node (or `output` if the ForEach is unclosed) so the graph
|
|
1533
|
+
// visibly fans in. Failed bodies do NOT merge — the trace
|
|
1534
|
+
// terminates at the failed cap.
|
|
1535
|
+
if (success) {
|
|
991
1536
|
replicaEdges.push({
|
|
992
1537
|
group: 'edges',
|
|
993
1538
|
data: {
|
|
994
1539
|
id: `${bodyKey}-merge`,
|
|
995
1540
|
source: prevBodyNodeId,
|
|
996
|
-
target:
|
|
1541
|
+
target: mergeNodeId,
|
|
997
1542
|
label: '',
|
|
998
1543
|
title: 'collect',
|
|
999
1544
|
fullUrn: '',
|
|
@@ -1005,14 +1550,51 @@ function buildRunGraphData(data) {
|
|
|
1005
1550
|
}
|
|
1006
1551
|
}
|
|
1007
1552
|
|
|
1008
|
-
visibleSuccess.forEach((o
|
|
1009
|
-
visibleFailure.forEach((o
|
|
1553
|
+
visibleSuccess.forEach((o) => buildBodyReplica(o));
|
|
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
|
+
};
|
|
1592
|
+
}
|
|
1010
1593
|
|
|
1011
1594
|
// Build success and failure "show more" nodes when there are hidden
|
|
1012
|
-
// outcomes. Anchored at the
|
|
1595
|
+
// outcomes. Anchored at the ForEach node (or input_slot if none).
|
|
1013
1596
|
const showMoreNodes = [];
|
|
1014
1597
|
if (hasForeach && bodyCapSteps.length > 0) {
|
|
1015
|
-
const anchorCanonical = canonicalMediaUrn(bodyCapSteps[0].step.from_spec);
|
|
1016
1598
|
if (hiddenSuccessCount > 0) {
|
|
1017
1599
|
const nodeId = 'show-more-success';
|
|
1018
1600
|
showMoreNodes.push({
|
|
@@ -1030,7 +1612,7 @@ function buildRunGraphData(data) {
|
|
|
1030
1612
|
group: 'edges',
|
|
1031
1613
|
data: {
|
|
1032
1614
|
id: 'show-more-success-edge',
|
|
1033
|
-
source:
|
|
1615
|
+
source: anchorNodeId,
|
|
1034
1616
|
target: nodeId,
|
|
1035
1617
|
label: '',
|
|
1036
1618
|
title: '',
|
|
@@ -1057,7 +1639,7 @@ function buildRunGraphData(data) {
|
|
|
1057
1639
|
group: 'edges',
|
|
1058
1640
|
data: {
|
|
1059
1641
|
id: 'show-more-failure-edge',
|
|
1060
|
-
source:
|
|
1642
|
+
source: anchorNodeId,
|
|
1061
1643
|
target: nodeId,
|
|
1062
1644
|
label: '',
|
|
1063
1645
|
title: '',
|
|
@@ -1085,7 +1667,10 @@ function buildRunGraphData(data) {
|
|
|
1085
1667
|
}
|
|
1086
1668
|
|
|
1087
1669
|
function runCytoscapeElements(built) {
|
|
1088
|
-
|
|
1670
|
+
// Do NOT collapse ForEach/Collect nodes in run mode — body replicas
|
|
1671
|
+
// anchor at the ForEach node (fan-out) and merge at the Collect
|
|
1672
|
+
// node (fan-in). Those junctions must stay visible.
|
|
1673
|
+
const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
|
|
1089
1674
|
return strandElements
|
|
1090
1675
|
.concat(built.replicaNodes)
|
|
1091
1676
|
.concat(built.showMoreNodes)
|
|
@@ -1988,6 +2573,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
1988
2573
|
mediaNodeLabel,
|
|
1989
2574
|
buildBrowseGraphData,
|
|
1990
2575
|
buildStrandGraphData,
|
|
2576
|
+
collapseStrandShapeTransitions,
|
|
1991
2577
|
buildRunGraphData,
|
|
1992
2578
|
buildMachineGraphData,
|
|
1993
2579
|
classifyStrandCapSteps,
|