capdag 0.109.248 → 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 +904 -164
- package/capdag.js +123 -132
- package/capdag.test.js +1048 -238
- 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',
|
|
@@ -271,7 +291,11 @@ function buildStylesheet() {
|
|
|
271
291
|
{
|
|
272
292
|
selector: 'node.show-more',
|
|
273
293
|
style: {
|
|
274
|
-
|
|
294
|
+
// Use the normal node fill; the dashed border is what
|
|
295
|
+
// distinguishes a show-more node from a regular cap.
|
|
296
|
+
// The renderer never reads `--graph-bg` — the graph
|
|
297
|
+
// canvas background is entirely the host's concern.
|
|
298
|
+
'background-color': nodeBg,
|
|
275
299
|
'border-style': 'dashed',
|
|
276
300
|
'border-width': '2px',
|
|
277
301
|
'border-color': nodeBorderHighlighted,
|
|
@@ -286,11 +310,14 @@ function buildStylesheet() {
|
|
|
286
310
|
'font-weight': '500',
|
|
287
311
|
'color': 'data(color)',
|
|
288
312
|
'text-background-color': edgeTextBg,
|
|
289
|
-
'text-background-opacity':
|
|
290
|
-
'text-background-padding': '
|
|
313
|
+
'text-background-opacity': 1,
|
|
314
|
+
'text-background-padding': '4px',
|
|
291
315
|
'text-background-shape': 'roundrectangle',
|
|
292
|
-
|
|
293
|
-
|
|
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,
|
|
294
321
|
'curve-style': 'bezier',
|
|
295
322
|
'control-point-step-size': 40,
|
|
296
323
|
'width': 1.5,
|
|
@@ -316,11 +343,21 @@ function buildStylesheet() {
|
|
|
316
343
|
},
|
|
317
344
|
{
|
|
318
345
|
selector: 'edge.body-success',
|
|
319
|
-
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
|
+
},
|
|
320
353
|
},
|
|
321
354
|
{
|
|
322
355
|
selector: 'edge.body-failure',
|
|
323
|
-
style: {
|
|
356
|
+
style: {
|
|
357
|
+
'line-color': bodyEdgeFailure,
|
|
358
|
+
'target-arrow-color': bodyEdgeFailure,
|
|
359
|
+
'color': bodyEdgeFailure,
|
|
360
|
+
},
|
|
324
361
|
},
|
|
325
362
|
{
|
|
326
363
|
selector: 'node.path-highlighted',
|
|
@@ -330,6 +367,44 @@ function buildStylesheet() {
|
|
|
330
367
|
selector: 'edge.path-highlighted',
|
|
331
368
|
style: { 'width': 3, 'z-index': 999, 'line-style': 'solid' },
|
|
332
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
|
+
},
|
|
333
408
|
];
|
|
334
409
|
}
|
|
335
410
|
|
|
@@ -444,6 +519,9 @@ function validateStrandPayload(data) {
|
|
|
444
519
|
&& (data.media_display_names === null || typeof data.media_display_names !== 'object')) {
|
|
445
520
|
throw new Error('CapGraphRenderer strand mode: data.media_display_names must be an object when present');
|
|
446
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
|
+
}
|
|
447
525
|
}
|
|
448
526
|
|
|
449
527
|
function validateBodyOutcome(outcome, path) {
|
|
@@ -495,25 +573,93 @@ function validateRunPayload(data) {
|
|
|
495
573
|
}
|
|
496
574
|
}
|
|
497
575
|
|
|
498
|
-
function
|
|
576
|
+
function validateEditorGraphPayload(data) {
|
|
499
577
|
if (!data || typeof data !== 'object') {
|
|
500
|
-
throw new Error('CapGraphRenderer
|
|
578
|
+
throw new Error('CapGraphRenderer editor-graph mode: data must be an object');
|
|
501
579
|
}
|
|
502
|
-
assertArray(data.elements, '
|
|
580
|
+
assertArray(data.elements, 'editor-graph mode data.elements');
|
|
503
581
|
data.elements.forEach((el, idx) => {
|
|
504
582
|
if (!el || typeof el !== 'object') {
|
|
505
|
-
throw new Error(`CapGraphRenderer
|
|
583
|
+
throw new Error(`CapGraphRenderer editor-graph mode: data.elements[${idx}] is not an object`);
|
|
506
584
|
}
|
|
507
585
|
if (el.kind !== 'node' && el.kind !== 'cap' && el.kind !== 'edge') {
|
|
508
586
|
throw new Error(
|
|
509
|
-
`CapGraphRenderer
|
|
587
|
+
`CapGraphRenderer editor-graph mode: data.elements[${idx}].kind must be "node" | "cap" | "edge" (got: ${JSON.stringify(el.kind)})`
|
|
510
588
|
);
|
|
511
589
|
}
|
|
512
|
-
assertString(el.graph_id, `
|
|
590
|
+
assertString(el.graph_id, `editor-graph mode data.elements[${idx}].graph_id`);
|
|
513
591
|
if (el.kind === 'edge') {
|
|
514
|
-
assertString(el.source_graph_id, `
|
|
515
|
-
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`);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
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`);
|
|
516
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
|
+
});
|
|
517
663
|
});
|
|
518
664
|
}
|
|
519
665
|
|
|
@@ -739,7 +885,8 @@ function buildStrandGraphData(data) {
|
|
|
739
885
|
});
|
|
740
886
|
}
|
|
741
887
|
let edgeCounter = 0;
|
|
742
|
-
function addEdge(source, target, label, title, fullUrn, edgeClass) {
|
|
888
|
+
function addEdge(source, target, label, title, fullUrn, edgeClass, meta) {
|
|
889
|
+
const m = meta || {};
|
|
743
890
|
edges.push({
|
|
744
891
|
id: `strand-edge-${edgeCounter}`,
|
|
745
892
|
source,
|
|
@@ -749,6 +896,11 @@ function buildStrandGraphData(data) {
|
|
|
749
896
|
fullUrn: fullUrn || '',
|
|
750
897
|
edgeClass: edgeClass || '',
|
|
751
898
|
color: edgeHueColor(edgeCounter),
|
|
899
|
+
// `foreachEntry` flags a cap edge as the first cap entering a
|
|
900
|
+
// ForEach body (the "phantom direct edge" in plan builder's
|
|
901
|
+
// terminology). Render-time collapse uses this to relabel the
|
|
902
|
+
// edge with the cap title + (1→n) marker. Defaults to false.
|
|
903
|
+
foreachEntry: m.foreachEntry === true,
|
|
752
904
|
});
|
|
753
905
|
edgeCounter++;
|
|
754
906
|
}
|
|
@@ -768,13 +920,14 @@ function buildStrandGraphData(data) {
|
|
|
768
920
|
let bodyExit = null;
|
|
769
921
|
|
|
770
922
|
// Finalize an outer ForEach body when a nested ForEach starts before
|
|
771
|
-
// the outer's Collect. Mirrors plan_builder.rs:238-289.
|
|
923
|
+
// the outer's Collect. Mirrors plan_builder.rs:238-289. The render
|
|
924
|
+
// collapse will later drop the ForEach node and synthesize the
|
|
925
|
+
// bridging edges, so we only need to emit the plan-builder
|
|
926
|
+
// topology here.
|
|
772
927
|
function finalizeOuterForEach(outerForEach, outerEntry, outerExit) {
|
|
773
928
|
const outerForEachInput = outerForEach.index === 0
|
|
774
929
|
? inputSlotId
|
|
775
930
|
: `step_${outerForEach.index - 1}`;
|
|
776
|
-
// Create the ForEach node + direct edge from its input + iteration
|
|
777
|
-
// edge into the body's first cap.
|
|
778
931
|
addNode(outerForEach.nodeId, 'for each', '', 'strand-foreach');
|
|
779
932
|
addEdge(outerForEachInput, outerForEach.nodeId, 'for each', 'for each', '', 'strand-iteration');
|
|
780
933
|
addEdge(outerForEach.nodeId, outerEntry, '', '', '', 'strand-iteration');
|
|
@@ -790,12 +943,27 @@ function buildStrandGraphData(data) {
|
|
|
790
943
|
const toCanonical = canonicalMediaUrn(step.to_spec);
|
|
791
944
|
addNode(nodeId, displayNameFor(toCanonical), toCanonical, 'strand-cap');
|
|
792
945
|
|
|
946
|
+
// The first cap inside a ForEach body is the "foreach entry"
|
|
947
|
+
// — its incoming edge crosses the foreach boundary. Strand
|
|
948
|
+
// mode's render collapse relabels that edge with a (1→n)
|
|
949
|
+
// cardinality marker regardless of the cap's own sequence
|
|
950
|
+
// flags, because visually the transition IS the foreach.
|
|
951
|
+
const isForeachEntry = insideForEachBody !== null && bodyEntry === null;
|
|
952
|
+
|
|
793
953
|
let label = body.title;
|
|
794
954
|
const cardinality = cardinalityLabel(body.input_is_sequence, body.output_is_sequence);
|
|
795
955
|
if (cardinality !== '1\u21921') {
|
|
796
956
|
label = `${label} (${cardinality})`;
|
|
797
957
|
}
|
|
798
|
-
addEdge(
|
|
958
|
+
addEdge(
|
|
959
|
+
prevNodeId,
|
|
960
|
+
nodeId,
|
|
961
|
+
label,
|
|
962
|
+
body.title,
|
|
963
|
+
body.cap_urn,
|
|
964
|
+
'strand-cap-edge',
|
|
965
|
+
{ foreachEntry: isForeachEntry }
|
|
966
|
+
);
|
|
799
967
|
|
|
800
968
|
if (insideForEachBody !== null) {
|
|
801
969
|
if (bodyEntry === null) bodyEntry = nodeId;
|
|
@@ -854,7 +1022,11 @@ function buildStrandGraphData(data) {
|
|
|
854
1022
|
prevNodeId = nodeId;
|
|
855
1023
|
} else {
|
|
856
1024
|
// Standalone Collect — scalar → list-of-one. Mirrors
|
|
857
|
-
// plan_builder.rs:333-355.
|
|
1025
|
+
// plan_builder.rs:333-355. There is no enclosing foreach
|
|
1026
|
+
// body, so the preceding cap is NOT flagged as a
|
|
1027
|
+
// foreach-exit; the render-time collapse will synthesize a
|
|
1028
|
+
// plain "collect" marker on the synthesized edge replacing
|
|
1029
|
+
// the dropped Collect node.
|
|
858
1030
|
addNode(nodeId, 'collect', '', 'strand-collect');
|
|
859
1031
|
addEdge(prevNodeId, nodeId, 'collect', 'collect', '', 'strand-collection');
|
|
860
1032
|
prevNodeId = nodeId;
|
|
@@ -866,6 +1038,10 @@ function buildStrandGraphData(data) {
|
|
|
866
1038
|
});
|
|
867
1039
|
|
|
868
1040
|
// Handle unclosed ForEach after the walk. Mirrors plan_builder.rs:362-428.
|
|
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.
|
|
869
1045
|
if (insideForEachBody !== null) {
|
|
870
1046
|
const outer = insideForEachBody;
|
|
871
1047
|
const hasBodyEntry = bodyEntry !== null;
|
|
@@ -892,11 +1068,180 @@ function buildStrandGraphData(data) {
|
|
|
892
1068
|
addNode(outputId, displayNameFor(targetSpec), targetSpec, 'strand-target');
|
|
893
1069
|
addEdge(prevNodeId, outputId, '', '', '', 'strand-cap-edge');
|
|
894
1070
|
|
|
1071
|
+
// Return the raw plan-builder topology. Strand mode collapses
|
|
1072
|
+
// ForEach/Collect nodes into edge labels at render time (see
|
|
1073
|
+
// `strandCytoscapeElements`); run mode keeps them as explicit
|
|
1074
|
+
// nodes because body replicas anchor at the ForEach/Collect
|
|
1075
|
+
// junctions.
|
|
895
1076
|
return { nodes, edges, sourceSpec, targetSpec };
|
|
896
1077
|
}
|
|
897
1078
|
|
|
898
|
-
|
|
899
|
-
|
|
1079
|
+
// Transform the plan-builder strand topology into the render shape
|
|
1080
|
+
// strand mode actually displays. Pure function; does NOT mutate the
|
|
1081
|
+
// input. Run mode bypasses this transform and consumes the raw
|
|
1082
|
+
// topology directly (see `runCytoscapeElements`).
|
|
1083
|
+
//
|
|
1084
|
+
// The display rules (per user spec):
|
|
1085
|
+
//
|
|
1086
|
+
// 1. ForEach and Collect are NOT rendered as nodes. They're
|
|
1087
|
+
// execution-layer concepts; the visible semantic is carried on
|
|
1088
|
+
// the surrounding cap edges.
|
|
1089
|
+
//
|
|
1090
|
+
// 2. The first cap edge entering a ForEach body is relabeled to
|
|
1091
|
+
// `<cap_title> (1→n)`. The builder flags those edges with
|
|
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.
|
|
1098
|
+
//
|
|
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.
|
|
1104
|
+
//
|
|
1105
|
+
// 4. If the last cap step's `to_spec` is semantically equivalent
|
|
1106
|
+
// to the strand's `target_spec` (via MediaUrn.isEquivalent),
|
|
1107
|
+
// the separate `output` target node is dropped and the last
|
|
1108
|
+
// cap edge lands on that merged endpoint. Removes the visible
|
|
1109
|
+
// duplicate node.
|
|
1110
|
+
function collapseStrandShapeTransitions(built) {
|
|
1111
|
+
const MediaUrn = requireHostDependency('MediaUrn');
|
|
1112
|
+
|
|
1113
|
+
// Index for lookups.
|
|
1114
|
+
const nodeById = new Map();
|
|
1115
|
+
for (const n of built.nodes) nodeById.set(n.id, n);
|
|
1116
|
+
|
|
1117
|
+
// Step 1: before dropping Collect nodes, synthesize a plain
|
|
1118
|
+
// bridging edge that replaces each dropped Collect. For a
|
|
1119
|
+
// Collect node C the plan builder produced:
|
|
1120
|
+
// body_exit → C (strand-collection, label="collect")
|
|
1121
|
+
// C → next (strand-cap-edge, 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.
|
|
1126
|
+
const synthesizedExitEdges = [];
|
|
1127
|
+
for (const node of built.nodes) {
|
|
1128
|
+
if (node.nodeClass !== 'strand-collect') continue;
|
|
1129
|
+
const incoming = built.edges.filter(e =>
|
|
1130
|
+
e.target === node.id && e.edgeClass === 'strand-collection');
|
|
1131
|
+
const outgoing = built.edges.filter(e =>
|
|
1132
|
+
e.source === node.id && e.edgeClass === 'strand-cap-edge');
|
|
1133
|
+
for (const inEdge of incoming) {
|
|
1134
|
+
for (const outEdge of outgoing) {
|
|
1135
|
+
const bodyExitNodeId = inEdge.source;
|
|
1136
|
+
const bodyExitCapEdge = built.edges.find(e =>
|
|
1137
|
+
e.edgeClass === 'strand-cap-edge' && e.target === bodyExitNodeId);
|
|
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.
|
|
1144
|
+
synthesizedExitEdges.push({
|
|
1145
|
+
id: `${node.id}-collapsed-exit-${synthesizedExitEdges.length}`,
|
|
1146
|
+
source: bodyExitNodeId,
|
|
1147
|
+
target: outEdge.target,
|
|
1148
|
+
label: '',
|
|
1149
|
+
title: '',
|
|
1150
|
+
fullUrn: '',
|
|
1151
|
+
edgeClass: 'strand-cap-edge',
|
|
1152
|
+
color: bodyExitCapEdge ? bodyExitCapEdge.color : inEdge.color,
|
|
1153
|
+
foreachEntry: false,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Drop all ForEach/Collect nodes and every edge that touches
|
|
1160
|
+
// them (direct, iteration, collection, and the trailing collect
|
|
1161
|
+
// cap-edge connector). The render never shows those nodes.
|
|
1162
|
+
const dropNodeIds = new Set();
|
|
1163
|
+
for (const node of built.nodes) {
|
|
1164
|
+
if (node.nodeClass === 'strand-foreach' || node.nodeClass === 'strand-collect') {
|
|
1165
|
+
dropNodeIds.add(node.id);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
let nodes = built.nodes.filter(n => !dropNodeIds.has(n.id));
|
|
1169
|
+
let edges = built.edges.filter(e =>
|
|
1170
|
+
!dropNodeIds.has(e.source) &&
|
|
1171
|
+
!dropNodeIds.has(e.target) &&
|
|
1172
|
+
e.edgeClass !== 'strand-iteration' &&
|
|
1173
|
+
e.edgeClass !== 'strand-collection');
|
|
1174
|
+
edges = edges.concat(synthesizedExitEdges);
|
|
1175
|
+
|
|
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.
|
|
1185
|
+
|
|
1186
|
+
// Step 3: merge the trailing `step_N → output` edge when step_N
|
|
1187
|
+
// and output represent the same media URN. The strand builder
|
|
1188
|
+
// always emits a separate `output` node with a (possibly empty)
|
|
1189
|
+
// connector edge from the last prev; when the URNs coincide the
|
|
1190
|
+
// output is a visible duplicate.
|
|
1191
|
+
//
|
|
1192
|
+
// Find the `strand-target` node and look for a single incoming
|
|
1193
|
+
// edge from a `strand-cap` (or `strand-source`) node. Compare the
|
|
1194
|
+
// endpoints' `fullUrn` semantically.
|
|
1195
|
+
const targetNode = nodes.find(n => n.nodeClass === 'strand-target');
|
|
1196
|
+
if (targetNode) {
|
|
1197
|
+
const incomingToTarget = edges.filter(e => e.target === targetNode.id);
|
|
1198
|
+
if (incomingToTarget.length === 1) {
|
|
1199
|
+
const trailing = incomingToTarget[0];
|
|
1200
|
+
const upstreamNode = nodes.find(n => n.id === trailing.source);
|
|
1201
|
+
if (upstreamNode && upstreamNode.fullUrn && targetNode.fullUrn) {
|
|
1202
|
+
let equivalent = false;
|
|
1203
|
+
try {
|
|
1204
|
+
const a = MediaUrn.fromString(upstreamNode.fullUrn);
|
|
1205
|
+
const b = MediaUrn.fromString(targetNode.fullUrn);
|
|
1206
|
+
equivalent = a.isEquivalent(b);
|
|
1207
|
+
} catch (_) {
|
|
1208
|
+
equivalent = false;
|
|
1209
|
+
}
|
|
1210
|
+
// Merge only if the trailing edge is the unadorned connector
|
|
1211
|
+
// (empty label). A labeled last-cap edge carries meaningful
|
|
1212
|
+
// info and must not be collapsed away.
|
|
1213
|
+
if (equivalent && (!trailing.label || trailing.label.length === 0)) {
|
|
1214
|
+
// Drop the trailing connector edge and the target node.
|
|
1215
|
+
// The upstream node effectively becomes the target visually.
|
|
1216
|
+
// Rename its display label to the target's display to
|
|
1217
|
+
// preserve the user-configured media_display_names entry.
|
|
1218
|
+
edges = edges.filter(e => e.id !== trailing.id);
|
|
1219
|
+
nodes = nodes
|
|
1220
|
+
.filter(n => n.id !== targetNode.id)
|
|
1221
|
+
.map(n => n.id === upstreamNode.id
|
|
1222
|
+
? Object.assign({}, n, { label: targetNode.label, nodeClass: 'strand-target' })
|
|
1223
|
+
: n);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
return { nodes, edges };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function strandCytoscapeElements(built, options) {
|
|
1233
|
+
// Strand mode presents ForEach and Collect as edge labels, not as
|
|
1234
|
+
// boxed nodes. Apply the cosmetic collapse right before emitting
|
|
1235
|
+
// cytoscape elements so the underlying plan-builder topology stays
|
|
1236
|
+
// intact for any callers that need it.
|
|
1237
|
+
//
|
|
1238
|
+
// Run mode opts out (`options.collapse === false`) because its body
|
|
1239
|
+
// replicas visually fan out from the ForEach node and merge at the
|
|
1240
|
+
// Collect node — those junctions must remain as explicit graph
|
|
1241
|
+
// nodes for the fan-in/fan-out to be visible.
|
|
1242
|
+
const shouldCollapse = !(options && options.collapse === false);
|
|
1243
|
+
const source = shouldCollapse ? collapseStrandShapeTransitions(built) : built;
|
|
1244
|
+
const nodeElements = source.nodes.map(node => ({
|
|
900
1245
|
group: 'nodes',
|
|
901
1246
|
data: {
|
|
902
1247
|
id: node.id,
|
|
@@ -905,7 +1250,7 @@ function strandCytoscapeElements(built) {
|
|
|
905
1250
|
},
|
|
906
1251
|
classes: node.nodeClass || '',
|
|
907
1252
|
}));
|
|
908
|
-
const edgeElements =
|
|
1253
|
+
const edgeElements = source.edges.map(edge => ({
|
|
909
1254
|
group: 'edges',
|
|
910
1255
|
data: {
|
|
911
1256
|
id: edge.id,
|
|
@@ -938,31 +1283,21 @@ function findCapStepIndexByUrn(steps, targetUrnString) {
|
|
|
938
1283
|
return -1;
|
|
939
1284
|
}
|
|
940
1285
|
|
|
941
|
-
// Remove nodes
|
|
942
|
-
//
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
function
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
if (interiorIds.size === 0) return built;
|
|
957
|
-
|
|
958
|
-
const keptNodes = built.nodes.filter(n => !interiorIds.has(n.id));
|
|
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.
|
|
1290
|
+
//
|
|
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));
|
|
959
1299
|
const keptEdges = built.edges.filter(e =>
|
|
960
|
-
!
|
|
961
|
-
|
|
962
|
-
// After stripping, the ForEach node and the Collect node (or the
|
|
963
|
-
// output node if no Collect) may become disconnected — the body
|
|
964
|
-
// replicas will bridge them. That's fine; cytoscape's ELK layout
|
|
965
|
-
// handles disconnected subgraphs.
|
|
1300
|
+
!dropStepIds.has(e.source) && !dropStepIds.has(e.target));
|
|
966
1301
|
return {
|
|
967
1302
|
nodes: keptNodes,
|
|
968
1303
|
edges: keptEdges,
|
|
@@ -974,20 +1309,34 @@ function stripBodyInteriorFromStrandBackbone(built, steps, foreachStepIdx, colle
|
|
|
974
1309
|
function buildRunGraphData(data) {
|
|
975
1310
|
validateRunPayload(data);
|
|
976
1311
|
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
// ForEach/Collect
|
|
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).
|
|
980
1316
|
const strandInput = Object.assign({}, data.resolved_strand, {
|
|
981
1317
|
media_display_names: data.media_display_names,
|
|
982
1318
|
});
|
|
983
1319
|
const strandBuiltRaw = buildStrandGraphData(strandInput);
|
|
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
|
+
}
|
|
984
1335
|
|
|
985
|
-
// Locate the ForEach/Collect span in the
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
// ForEach node and (when a Collect closes the body) merge into the
|
|
990
|
-
// Collect node at `step_j`.
|
|
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.
|
|
991
1340
|
const steps = data.resolved_strand.steps;
|
|
992
1341
|
let foreachStepIdx = -1;
|
|
993
1342
|
let collectStepIdx = -1;
|
|
@@ -997,17 +1346,7 @@ function buildRunGraphData(data) {
|
|
|
997
1346
|
if (variant === 'Collect' && collectStepIdx < 0) collectStepIdx = i;
|
|
998
1347
|
}
|
|
999
1348
|
const hasForeach = foreachStepIdx >= 0;
|
|
1000
|
-
|
|
1001
|
-
// In run mode we want per-body replicas to REPLACE the prototype cap
|
|
1002
|
-
// chain inside the ForEach body, not sit alongside it. Strip the
|
|
1003
|
-
// backbone nodes and edges that correspond to body-interior Cap
|
|
1004
|
-
// steps and their direct edges, keeping only the input slot, the
|
|
1005
|
-
// ForEach node, the Collect node (if any), the output node, and any
|
|
1006
|
-
// caps OUTSIDE the body span. Body replicas will connect from the
|
|
1007
|
-
// ForEach node and merge at the Collect node.
|
|
1008
|
-
const strandBuilt = hasForeach
|
|
1009
|
-
? stripBodyInteriorFromStrandBackbone(strandBuiltRaw, steps, foreachStepIdx, collectStepIdx)
|
|
1010
|
-
: strandBuiltRaw;
|
|
1349
|
+
const hasCollect = collectStepIdx >= 0;
|
|
1011
1350
|
|
|
1012
1351
|
// Filter and bound the outcomes.
|
|
1013
1352
|
const allOutcomes = data.body_outcomes.slice().sort((a, b) => a.body_index - b.body_index);
|
|
@@ -1017,27 +1356,180 @@ function buildRunGraphData(data) {
|
|
|
1017
1356
|
const visibleFailure = failures.slice(0, data.visible_failure_count);
|
|
1018
1357
|
const hiddenSuccessCount = successes.length - visibleSuccess.length;
|
|
1019
1358
|
const hiddenFailureCount = failures.length - visibleFailure.length;
|
|
1359
|
+
const visibleOutcomes = visibleSuccess.concat(visibleFailure);
|
|
1020
1360
|
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
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.
|
|
1023
1368
|
const bodyCapSteps = [];
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
+
}
|
|
1029
1376
|
}
|
|
1030
1377
|
}
|
|
1031
1378
|
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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`:
|
|
1399
|
+
//
|
|
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;
|
|
1475
|
+
}
|
|
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.
|
|
1481
|
+
}
|
|
1038
1482
|
|
|
1039
1483
|
const replicaNodes = [];
|
|
1040
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
|
+
: '';
|
|
1041
1533
|
|
|
1042
1534
|
function buildBodyReplica(outcome) {
|
|
1043
1535
|
const success = outcome.success;
|
|
@@ -1045,7 +1537,7 @@ function buildRunGraphData(data) {
|
|
|
1045
1537
|
const edgeClass = success ? 'body-success' : 'body-failure';
|
|
1046
1538
|
const colorVar = success ? '--graph-body-edge-success' : '--graph-body-edge-failure';
|
|
1047
1539
|
|
|
1048
|
-
// Trace end: failures stop at failed_cap
|
|
1540
|
+
// Trace end: failures stop at `failed_cap`. `CapUrn.isEquivalent`
|
|
1049
1541
|
// is used for the match — never string equality.
|
|
1050
1542
|
let traceEnd = bodyCapSteps.length;
|
|
1051
1543
|
if (!success && typeof outcome.failed_cap === 'string' && outcome.failed_cap.length > 0) {
|
|
@@ -1059,13 +1551,48 @@ function buildRunGraphData(data) {
|
|
|
1059
1551
|
}
|
|
1060
1552
|
}
|
|
1061
1553
|
}
|
|
1062
|
-
if (traceEnd === 0) return;
|
|
1063
1554
|
|
|
1064
|
-
let prevBodyNodeId = anchorNodeId;
|
|
1065
1555
|
const bodyKey = `body-${outcome.body_index}`;
|
|
1066
1556
|
const titleLabel = typeof outcome.title === 'string' && outcome.title.length > 0
|
|
1067
1557
|
? outcome.title
|
|
1068
|
-
:
|
|
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;
|
|
1069
1596
|
|
|
1070
1597
|
for (let i = 0; i < traceEnd; i++) {
|
|
1071
1598
|
const body = bodyCapSteps[i].step.step_type.Cap;
|
|
@@ -1075,7 +1602,7 @@ function buildRunGraphData(data) {
|
|
|
1075
1602
|
group: 'nodes',
|
|
1076
1603
|
data: {
|
|
1077
1604
|
id: replicaNodeId,
|
|
1078
|
-
label:
|
|
1605
|
+
label: displayNameFor(targetCanonical),
|
|
1079
1606
|
fullUrn: targetCanonical,
|
|
1080
1607
|
bodyIndex: outcome.body_index,
|
|
1081
1608
|
bodyTitle: titleLabel,
|
|
@@ -1088,7 +1615,11 @@ function buildRunGraphData(data) {
|
|
|
1088
1615
|
id: `${bodyKey}-e-${i}`,
|
|
1089
1616
|
source: prevBodyNodeId,
|
|
1090
1617
|
target: replicaNodeId,
|
|
1091
|
-
label
|
|
1618
|
+
// Replica edges carry no inline label — the cap title is
|
|
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`.
|
|
1622
|
+
label: '',
|
|
1092
1623
|
title: body.title,
|
|
1093
1624
|
fullUrn: body.cap_urn,
|
|
1094
1625
|
color: `var(${colorVar})`,
|
|
@@ -1099,17 +1630,18 @@ function buildRunGraphData(data) {
|
|
|
1099
1630
|
prevBodyNodeId = replicaNodeId;
|
|
1100
1631
|
}
|
|
1101
1632
|
|
|
1102
|
-
// Successful bodies merge
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
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) {
|
|
1107
1639
|
replicaEdges.push({
|
|
1108
1640
|
group: 'edges',
|
|
1109
1641
|
data: {
|
|
1110
|
-
id: `${bodyKey}-
|
|
1642
|
+
id: `${bodyKey}-collect`,
|
|
1111
1643
|
source: prevBodyNodeId,
|
|
1112
|
-
target:
|
|
1644
|
+
target: collectTargetId,
|
|
1113
1645
|
label: '',
|
|
1114
1646
|
title: 'collect',
|
|
1115
1647
|
fullUrn: '',
|
|
@@ -1119,10 +1651,11 @@ function buildRunGraphData(data) {
|
|
|
1119
1651
|
classes: edgeClass,
|
|
1120
1652
|
});
|
|
1121
1653
|
}
|
|
1654
|
+
|
|
1655
|
+
replicasBuiltCount++;
|
|
1122
1656
|
}
|
|
1123
1657
|
|
|
1124
|
-
|
|
1125
|
-
visibleFailure.forEach((o) => buildBodyReplica(o));
|
|
1658
|
+
visibleOutcomes.forEach((o) => buildBodyReplica(o));
|
|
1126
1659
|
|
|
1127
1660
|
// Build success and failure "show more" nodes when there are hidden
|
|
1128
1661
|
// outcomes. Anchored at the ForEach node (or input_slot if none).
|
|
@@ -1200,7 +1733,11 @@ function buildRunGraphData(data) {
|
|
|
1200
1733
|
}
|
|
1201
1734
|
|
|
1202
1735
|
function runCytoscapeElements(built) {
|
|
1203
|
-
|
|
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.
|
|
1740
|
+
const strandElements = strandCytoscapeElements(built.strandBuilt, { collapse: false });
|
|
1204
1741
|
return strandElements
|
|
1205
1742
|
.concat(built.replicaNodes)
|
|
1206
1743
|
.concat(built.showMoreNodes)
|
|
@@ -1209,67 +1746,150 @@ function runCytoscapeElements(built) {
|
|
|
1209
1746
|
|
|
1210
1747
|
// --------- Machine mode builder ---------------------------------------------
|
|
1211
1748
|
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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
|
|
1217
1768
|
for (const el of data.elements) {
|
|
1218
1769
|
if (el.kind === 'node') {
|
|
1219
|
-
|
|
1220
|
-
group: 'nodes',
|
|
1221
|
-
data: {
|
|
1222
|
-
id: el.graph_id,
|
|
1223
|
-
label: el.label || '',
|
|
1224
|
-
fullUrn: el.detail || el.label || '',
|
|
1225
|
-
tokenId: el.token_id || '',
|
|
1226
|
-
kind: 'node',
|
|
1227
|
-
},
|
|
1228
|
-
classes: 'machine-node',
|
|
1229
|
-
});
|
|
1770
|
+
dataNodes.set(el.graph_id, el);
|
|
1230
1771
|
} else if (el.kind === 'cap') {
|
|
1231
|
-
|
|
1232
|
-
group: 'nodes',
|
|
1233
|
-
data: {
|
|
1234
|
-
id: el.graph_id,
|
|
1235
|
-
label: el.label || '',
|
|
1236
|
-
fullUrn: el.detail || el.label || '',
|
|
1237
|
-
tokenId: el.token_id || '',
|
|
1238
|
-
kind: 'cap',
|
|
1239
|
-
},
|
|
1240
|
-
classes: 'machine-cap' + (el.is_loop ? ' machine-loop' : ''),
|
|
1241
|
-
});
|
|
1772
|
+
capNodes.set(el.graph_id, el);
|
|
1242
1773
|
} else if (el.kind === 'edge') {
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1774
|
+
argEdges.push(el);
|
|
1775
|
+
}
|
|
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);
|
|
1260
1791
|
}
|
|
1261
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
|
+
|
|
1262
1882
|
return { nodes, edges };
|
|
1263
1883
|
}
|
|
1264
1884
|
|
|
1265
|
-
function
|
|
1885
|
+
function editorGraphCytoscapeElements(built) {
|
|
1266
1886
|
return built.nodes.concat(built.edges);
|
|
1267
1887
|
}
|
|
1268
1888
|
|
|
1269
|
-
// A cheap signature for
|
|
1270
|
-
// on every keystroke; we skip the expensive rebuild when the
|
|
1271
|
-
// shape is unchanged.
|
|
1272
|
-
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) {
|
|
1273
1893
|
if (!data || !Array.isArray(data.elements)) return '';
|
|
1274
1894
|
const parts = [];
|
|
1275
1895
|
for (const el of data.elements) {
|
|
@@ -1278,6 +1898,110 @@ function machineGraphSignature(data) {
|
|
|
1278
1898
|
return parts.join(';');
|
|
1279
1899
|
}
|
|
1280
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
|
+
|
|
1281
2005
|
// =============================================================================
|
|
1282
2006
|
// Renderer class.
|
|
1283
2007
|
// =============================================================================
|
|
@@ -1291,9 +2015,9 @@ class CapGraphRenderer {
|
|
|
1291
2015
|
throw new Error('CapGraphRenderer: options must be an object');
|
|
1292
2016
|
}
|
|
1293
2017
|
const mode = options.mode;
|
|
1294
|
-
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine') {
|
|
2018
|
+
if (mode !== 'browse' && mode !== 'strand' && mode !== 'run' && mode !== 'machine' && mode !== 'editor-graph') {
|
|
1295
2019
|
throw new Error(
|
|
1296
|
-
`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)})`
|
|
1297
2021
|
);
|
|
1298
2022
|
}
|
|
1299
2023
|
|
|
@@ -1357,8 +2081,12 @@ class CapGraphRenderer {
|
|
|
1357
2081
|
this._strandBuilt = null;
|
|
1358
2082
|
this._runBuilt = null;
|
|
1359
2083
|
|
|
1360
|
-
//
|
|
1361
|
-
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`).
|
|
1362
2090
|
this._machineBuilt = null;
|
|
1363
2091
|
|
|
1364
2092
|
// Theme observer.
|
|
@@ -1410,15 +2138,19 @@ class CapGraphRenderer {
|
|
|
1410
2138
|
this._runBuilt = buildRunGraphData(data);
|
|
1411
2139
|
return this;
|
|
1412
2140
|
}
|
|
1413
|
-
if (this.mode === '
|
|
1414
|
-
const signature =
|
|
1415
|
-
if (signature === this.
|
|
2141
|
+
if (this.mode === 'editor-graph') {
|
|
2142
|
+
const signature = editorGraphSignature(data);
|
|
2143
|
+
if (signature === this._editorGraphSignature && this.cy) {
|
|
1416
2144
|
// Same shape — restyle for theme changes and return.
|
|
1417
2145
|
this.cy.style(buildStylesheet());
|
|
1418
2146
|
return this;
|
|
1419
2147
|
}
|
|
1420
|
-
this.
|
|
1421
|
-
this.
|
|
2148
|
+
this._editorGraphSignature = signature;
|
|
2149
|
+
this._editorGraphBuilt = buildEditorGraphData(data);
|
|
2150
|
+
return this;
|
|
2151
|
+
}
|
|
2152
|
+
if (this.mode === 'machine') {
|
|
2153
|
+
this._machineBuilt = buildResolvedMachineGraphData(data);
|
|
1422
2154
|
return this;
|
|
1423
2155
|
}
|
|
1424
2156
|
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
@@ -1486,7 +2218,7 @@ class CapGraphRenderer {
|
|
|
1486
2218
|
maxZoom: 10,
|
|
1487
2219
|
wheelSensitivity: 0.3,
|
|
1488
2220
|
boxSelectionEnabled: false,
|
|
1489
|
-
autounselectify: this.mode === 'machine',
|
|
2221
|
+
autounselectify: this.mode === 'editor-graph' || this.mode === 'machine',
|
|
1490
2222
|
});
|
|
1491
2223
|
|
|
1492
2224
|
const resizeAndRefit = () => {
|
|
@@ -1519,9 +2251,13 @@ class CapGraphRenderer {
|
|
|
1519
2251
|
if (!this._runBuilt) return [];
|
|
1520
2252
|
return runCytoscapeElements(this._runBuilt);
|
|
1521
2253
|
}
|
|
2254
|
+
if (this.mode === 'editor-graph') {
|
|
2255
|
+
if (!this._editorGraphBuilt) return [];
|
|
2256
|
+
return editorGraphCytoscapeElements(this._editorGraphBuilt);
|
|
2257
|
+
}
|
|
1522
2258
|
if (this.mode === 'machine') {
|
|
1523
2259
|
if (!this._machineBuilt) return [];
|
|
1524
|
-
return
|
|
2260
|
+
return resolvedMachineCytoscapeElements(this._machineBuilt);
|
|
1525
2261
|
}
|
|
1526
2262
|
throw new Error(`CapGraphRenderer: unreachable mode '${this.mode}'`);
|
|
1527
2263
|
}
|
|
@@ -1782,12 +2518,13 @@ class CapGraphRenderer {
|
|
|
1782
2518
|
}
|
|
1783
2519
|
|
|
1784
2520
|
// ===========================================================================
|
|
1785
|
-
//
|
|
2521
|
+
// Editor-graph mode API — used by the notation editor for
|
|
2522
|
+
// cross-highlight between source-text spans and graph elements.
|
|
1786
2523
|
// ===========================================================================
|
|
1787
2524
|
|
|
1788
|
-
|
|
1789
|
-
if (this.mode !== '
|
|
1790
|
-
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})`);
|
|
1791
2528
|
}
|
|
1792
2529
|
if (!this.cy) return;
|
|
1793
2530
|
const wanted = new Set(tokenIds || []);
|
|
@@ -2103,12 +2840,15 @@ if (typeof module !== 'undefined' && module.exports) {
|
|
|
2103
2840
|
mediaNodeLabel,
|
|
2104
2841
|
buildBrowseGraphData,
|
|
2105
2842
|
buildStrandGraphData,
|
|
2843
|
+
collapseStrandShapeTransitions,
|
|
2106
2844
|
buildRunGraphData,
|
|
2107
|
-
|
|
2845
|
+
buildEditorGraphData,
|
|
2846
|
+
buildResolvedMachineGraphData,
|
|
2108
2847
|
classifyStrandCapSteps,
|
|
2109
2848
|
validateStrandPayload,
|
|
2110
2849
|
validateRunPayload,
|
|
2111
|
-
|
|
2850
|
+
validateEditorGraphPayload,
|
|
2851
|
+
validateResolvedMachinePayload,
|
|
2112
2852
|
validateStrandStep,
|
|
2113
2853
|
validateBodyOutcome,
|
|
2114
2854
|
};
|