flexdesk 0.5.0 → 0.6.1
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/README.md +654 -1
- package/css/base.css +2868 -0
- package/css/flexdesk.css +2868 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
- package/dist/chunk-IYV5JVYB.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
- package/dist/flexdesk.css +2868 -0
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +9 -9
- package/dist/tokens.css +72 -0
- package/dist/widgets.js +87 -5
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +9 -1
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/ui/components/data_table.js +40 -0
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-24MS7W5Y.js.map +0 -7
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `@flexdesk/flow` exports for the CANVAS editor (36 §5).
|
|
3
|
+
*
|
|
4
|
+
* OWNED BY the canvas agent (B, 36 §2 and §5): `createCanvasEditor`, its
|
|
5
|
+
* closed action list, its words, the node box its edges are anchored by, and
|
|
6
|
+
* the pure geometry under it. The geometry is exported under canvas-prefixed
|
|
7
|
+
* names, because `flow.js` re-exports three editors with `export *`, and two
|
|
8
|
+
* modules exporting one name make that name silently vanish from the barrel.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { createCanvasEditor, CANVAS_ACTIONS } from './editor.js';
|
|
12
|
+
export { CANVAS_STRINGS } from './strings.js';
|
|
13
|
+
export {
|
|
14
|
+
CANVAS_NODE, layoutGraph as arrangeCanvasGraph, portAnchor as canvasPortAnchor, nodeBottom as canvasNodeBottom,
|
|
15
|
+
isReturnEdge as isCanvasReturnEdge,
|
|
16
|
+
} from './layout.js';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas editor's geometry — PURE: no DOM, runs under plain node.
|
|
3
|
+
*
|
|
4
|
+
* CANVAS_NODE a node's box: its width, its header's height and the
|
|
5
|
+
* height of one row of ports. The edges are anchored by
|
|
6
|
+
* ARITHMETIC over these, never by measuring, so the
|
|
7
|
+
* stylesheet must agree with them — `flow_canvas_css` reads
|
|
8
|
+
* the sheet and holds it to them.
|
|
9
|
+
* portAnchor where a port's line starts or ends
|
|
10
|
+
* nodeBottom where a node's drawn box ends
|
|
11
|
+
* isReturnEdge is this line a loop's body going back round?
|
|
12
|
+
* layoutGraph ARRANGE: every step's place, left to right in run order
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { arrange } from '../../canvas/edge_router.js';
|
|
16
|
+
|
|
17
|
+
export const CANVAS_NODE = Object.freeze({ width: 208, header: 34, row: 22 });
|
|
18
|
+
|
|
19
|
+
/** The space under a node's last row of ports (its findings, its run line). */
|
|
20
|
+
const FOOT = 12;
|
|
21
|
+
|
|
22
|
+
const inputsOf = (catalogue, node) => (catalogue?.inputs ? catalogue.inputs(node?.type) : []);
|
|
23
|
+
const outputsOf = (catalogue, node) => (catalogue?.outputs ? catalogue.outputs(node?.type) : []);
|
|
24
|
+
const roleOf = (catalogue, node) => (catalogue?.role ? catalogue.role(node?.type) : 'step');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The point a line meets `node`'s port `portName`: an input on the node's
|
|
28
|
+
* left edge, an output on its right, one row of ports down from the header
|
|
29
|
+
* per port before it — in the order the type DECLARES its ports, the order
|
|
30
|
+
* they are drawn in. A port the type does not have anchors at the first row.
|
|
31
|
+
*/
|
|
32
|
+
export function portAnchor(catalogue, node, portName, direction, box = CANVAS_NODE) {
|
|
33
|
+
const list = direction === 'input' ? inputsOf(catalogue, node) : outputsOf(catalogue, node);
|
|
34
|
+
const i = Math.max(0, list.findIndex((p) => p.name === portName));
|
|
35
|
+
return {
|
|
36
|
+
x: node.position.x + (direction === 'input' ? 0 : box.width),
|
|
37
|
+
y: node.position.y + box.header + box.row * i + box.row / 2,
|
|
38
|
+
side: direction === 'input' ? 'left' : 'right',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where `node`'s drawn box ends — computed, because jsdom and a node not yet
|
|
43
|
+
* laid out both answer 0 for `offsetHeight`. */
|
|
44
|
+
export function nodeBottom(catalogue, node, box = CANVAS_NODE) {
|
|
45
|
+
const rows = Math.max(inputsOf(catalogue, node).length, outputsOf(catalogue, node).length, 1);
|
|
46
|
+
return node.position.y + box.header + box.row * rows + FOOT;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is `connection` a loop's body handing back to its loop — a line into a
|
|
51
|
+
* `loop`-role step on any input but the one the loop is ENTERED by
|
|
52
|
+
* (`loopEntry`)? The editor's reading of the shape, for drawing it; whether
|
|
53
|
+
* the graph is a valid loop is the consumer's validator's.
|
|
54
|
+
*/
|
|
55
|
+
export function isReturnEdge(connection, target, catalogue, loopEntry = 'in') {
|
|
56
|
+
return Boolean(target) && roleOf(catalogue, target) === 'loop' && connection.targetPort !== loopEntry;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ARRANGE: where every step goes, left to right in the order a run takes them.
|
|
61
|
+
* Returns a Map of node id → `{x, y}` from (0, 0); the editor anchors it at
|
|
62
|
+
* the graph's current corner, so arranging never throws the graph off screen.
|
|
63
|
+
*
|
|
64
|
+
* It is the kernel's `arrange` (a layered layout), given what that function
|
|
65
|
+
* needs and does not check for:
|
|
66
|
+
*
|
|
67
|
+
* - **lines REVERSED.** `arrange` puts an edge's `from` one column right of
|
|
68
|
+
* its `to` (a reference points at what it references); a flow's line points
|
|
69
|
+
* at what runs NEXT, so `source → target` is handed over as
|
|
70
|
+
* `{from: target, to: source}`.
|
|
71
|
+
* - **a label on every node.** `arrange` sorts each column with
|
|
72
|
+
* `a.label.localeCompare`, which throws on a missing one. The label handed
|
|
73
|
+
* over is an ORDER KEY — a step's place in a breadth-first walk from the
|
|
74
|
+
* start step(s) that follows each step's outputs in the order its type
|
|
75
|
+
* declares them — so a condition's first arm sits above its second
|
|
76
|
+
* whatever the two steps are called.
|
|
77
|
+
* - **a loop's return lines left out.** The line from a loop's body back into
|
|
78
|
+
* the loop is the loop, not a dependency; followed, it would pull the body
|
|
79
|
+
* left of the loop that runs it. Any other cycle is broken by `arrange`.
|
|
80
|
+
*/
|
|
81
|
+
export function layoutGraph(graph, catalogue, { width = CANVAS_NODE.width, header = CANVAS_NODE.header,
|
|
82
|
+
row = CANVAS_NODE.row, gap = 96, rowGap = 40,
|
|
83
|
+
loopEntry = 'in' } = {}) {
|
|
84
|
+
const nodes = graph?.nodes || [];
|
|
85
|
+
const connections = graph?.connections || [];
|
|
86
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
87
|
+
const portOrder = (n, port) => {
|
|
88
|
+
const outs = outputsOf(catalogue, n);
|
|
89
|
+
const i = outs.findIndex((p) => p.name === port);
|
|
90
|
+
return i < 0 ? outs.length : i;
|
|
91
|
+
};
|
|
92
|
+
const isReturn = (c) => isReturnEdge(c, byId.get(c.target), catalogue, loopEntry);
|
|
93
|
+
|
|
94
|
+
// The order key: breadth first from the start step(s), outputs in port order.
|
|
95
|
+
const order = new Map();
|
|
96
|
+
const queue = nodes.filter((n) => roleOf(catalogue, n) === 'start').map((n) => n.id);
|
|
97
|
+
for (const id of queue) order.set(id, order.size);
|
|
98
|
+
while (queue.length) {
|
|
99
|
+
const id = queue.shift();
|
|
100
|
+
const out = connections
|
|
101
|
+
.map((c, i) => ({ c, i }))
|
|
102
|
+
.filter(({ c }) => c.source === id && byId.has(c.target) && !isReturn(c))
|
|
103
|
+
.sort((a, b) => (portOrder(byId.get(id), a.c.sourcePort) - portOrder(byId.get(id), b.c.sourcePort))
|
|
104
|
+
|| (a.i - b.i));
|
|
105
|
+
for (const { c } of out) {
|
|
106
|
+
if (!order.has(c.target)) { order.set(c.target, order.size); queue.push(c.target); }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const n of nodes) if (!order.has(n.id)) order.set(n.id, order.size);
|
|
110
|
+
|
|
111
|
+
const rows = (n) => Math.max(inputsOf(catalogue, n).length, outputsOf(catalogue, n).length);
|
|
112
|
+
return arrange(
|
|
113
|
+
nodes.map((n) => ({ id: n.id, label: String(order.get(n.id)).padStart(6, '0'),
|
|
114
|
+
height: header + row * rows(n) + 24 })),
|
|
115
|
+
connections.filter((c) => !isReturn(c)).map((c) => ({ from: c.target, to: c.source })),
|
|
116
|
+
{ columnGap: width + gap, rowGap },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas editor's own words (36 §3.13), laid over the kit's `FLOW_STRINGS`
|
|
3
|
+
* and under the consumer's: `createStrings(CANVAS_STRINGS, options.strings)`.
|
|
4
|
+
*
|
|
5
|
+
* English, and they name no domain. What a consumer calls a loop's next pass
|
|
6
|
+
* ("the next row") or how a data port is read (`${…}`) is the consumer's, given
|
|
7
|
+
* through `strings` and the `dataPortRead` option.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CANVAS_STRINGS = Object.freeze({
|
|
11
|
+
palette: 'Steps',
|
|
12
|
+
canvasLabel: 'Flow canvas',
|
|
13
|
+
toolbarLabel: 'Flow',
|
|
14
|
+
arrange: 'Arrange',
|
|
15
|
+
arrangeTitle: 'Lay the steps out left to right, in the order a run takes them',
|
|
16
|
+
fit: 'Fit',
|
|
17
|
+
fitTitle: 'Show the whole flow',
|
|
18
|
+
nothingToUndo: 'There is nothing to undo.',
|
|
19
|
+
nothingToRedo: 'There is nothing to redo.',
|
|
20
|
+
arrangeNeedsTwo: 'Arrange needs two steps or more.',
|
|
21
|
+
readOnlyRefusal: 'This flow is read only.',
|
|
22
|
+
connectFrom: 'Click, then click the input to connect to',
|
|
23
|
+
connectTo: 'Connect an output here',
|
|
24
|
+
connectStarted: 'Now click the input to connect to. Esc cancels.',
|
|
25
|
+
connectFirst: 'Click an output first, then this input.',
|
|
26
|
+
dataPort: (how) => (how ? `Read it in a later step as ${how}` : 'A data port: it is read, not connected.'),
|
|
27
|
+
dropHere: 'Drop a step on the canvas to add it.',
|
|
28
|
+
returnLine: 'Back to the loop for its next pass',
|
|
29
|
+
connectedReturn: 'Goes round the loop again.',
|
|
30
|
+
unknownType: (type) => `There is no step type "${type}".`,
|
|
31
|
+
stepId: (id) => `Step id: ${id}`,
|
|
32
|
+
connection: 'Connection',
|
|
33
|
+
connectionLine: (e) => `${e.source} · ${e.sourcePort} → ${e.target} · ${e.targetPort}`,
|
|
34
|
+
removeConnection: 'Remove connection',
|
|
35
|
+
portCount: (n) => `×${n}`,
|
|
36
|
+
portNotTaken: 'not taken',
|
|
37
|
+
});
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH STEPS ALWAYS RUN BEFORE THIS ONE — the one place the rule lives
|
|
3
|
+
* (36 §3.7). PURE: no DOM, runs under plain node.
|
|
4
|
+
*
|
|
5
|
+
* *Insert a value* lists only the steps that ALWAYS run before the step being
|
|
6
|
+
* edited, so a reference to a step that may not have run — the other arm of a
|
|
7
|
+
* condition, a step that ends the run on an error — cannot be picked. The
|
|
8
|
+
* answer is computed over FLOW edges with every loop's RETURN edges set aside
|
|
9
|
+
* (an edge into a loop step's input other than its entry port is the line back
|
|
10
|
+
* from the body, not a way in):
|
|
11
|
+
*
|
|
12
|
+
* - a step's DOMINATORS from the start step — the steps on EVERY path to it;
|
|
13
|
+
* - EXCEPT that a fanout whose join waits for ALL its branches makes every
|
|
14
|
+
* step that always runs on each branch run before the join (and before
|
|
15
|
+
* everything after it): dominators read a parallel's branches as
|
|
16
|
+
* alternatives, and they are not. A join that goes on when the FIRST branch
|
|
17
|
+
* finishes contributes only the fanout, which dominates it anyway;
|
|
18
|
+
* - a step inside a loop's body has the loop before it, and the body steps
|
|
19
|
+
* before it on every path; a BODY STEP IS NEVER BEFORE A STEP AFTER THE
|
|
20
|
+
* LOOP, because the loop may visit no row;
|
|
21
|
+
* - a step in an arm has the arm's earlier steps, the block's head and
|
|
22
|
+
* whatever runs before the block — never a sibling arm's steps (which is
|
|
23
|
+
* what dominators give).
|
|
24
|
+
*
|
|
25
|
+
* The answer is nearest first: by the length of the shortest flow path from
|
|
26
|
+
* each step to this one, then in the graph's node order.
|
|
27
|
+
*
|
|
28
|
+
* Roles are the catalogue's: `start`, `fanout`, `join`, `loop`. Which input a
|
|
29
|
+
* loop is entered by, and which output its body leaves from, are the
|
|
30
|
+
* consumer's port names (`loopPorts`); whether a join waits for every branch is
|
|
31
|
+
* the consumer's setting (`waitsForAll`).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_LOOP_PORTS = Object.freeze({ entry: 'in', next: 'next', body: 'body', done: 'done' });
|
|
35
|
+
|
|
36
|
+
const defaultWaitsForAll = (node) => (node?.config?.join ?? 'all') !== 'any';
|
|
37
|
+
|
|
38
|
+
function isFlowPort(catalogue, typeId, name, direction) {
|
|
39
|
+
const port = catalogue?.port ? catalogue.port(typeId, name, direction) : null;
|
|
40
|
+
return port ? (port.port_type || 'FLOW') === 'FLOW' : true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The graph's flow structure: successors and predecessors, return edges left out. */
|
|
44
|
+
export function flowStructure(graph, catalogue, { loopPorts = DEFAULT_LOOP_PORTS, kindOf = null } = {}) {
|
|
45
|
+
const ports = { ...DEFAULT_LOOP_PORTS, ...loopPorts };
|
|
46
|
+
const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
|
|
47
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
48
|
+
// `kindOf` (optional) reads a type's role through a consumer's mapping —
|
|
49
|
+
// an outline whose catalogue spells its roles differently (36 §6.2).
|
|
50
|
+
const role = typeof kindOf === 'function'
|
|
51
|
+
? (id) => kindOf(byId.get(id)?.type)
|
|
52
|
+
: (id) => (catalogue?.role ? catalogue.role(byId.get(id)?.type) : 'step');
|
|
53
|
+
const succ = new Map(nodes.map((n) => [n.id, []]));
|
|
54
|
+
const pred = new Map(nodes.map((n) => [n.id, []]));
|
|
55
|
+
const returns = [];
|
|
56
|
+
for (const c of Array.isArray(graph?.connections) ? graph.connections : []) {
|
|
57
|
+
const from = byId.get(c.source);
|
|
58
|
+
const to = byId.get(c.target);
|
|
59
|
+
if (!from || !to) continue;
|
|
60
|
+
if (!isFlowPort(catalogue, from.type, c.sourcePort, 'output')
|
|
61
|
+
|| !isFlowPort(catalogue, to.type, c.targetPort, 'input')) continue;
|
|
62
|
+
if (role(c.target) === 'loop' && c.targetPort !== ports.entry) { returns.push(c); continue; }
|
|
63
|
+
succ.get(c.source).push({ id: c.target, port: c.sourcePort });
|
|
64
|
+
pred.get(c.target).push({ id: c.source, port: c.sourcePort });
|
|
65
|
+
}
|
|
66
|
+
return { nodes, byId, role, succ, pred, returns, ports };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every node reachable from `roots`, not entering `stop`. */
|
|
70
|
+
function reach(roots, succ, stop = null) {
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const queue = [...roots].filter((r) => r !== stop);
|
|
73
|
+
for (const r of queue) seen.add(r);
|
|
74
|
+
while (queue.length) {
|
|
75
|
+
const id = queue.shift();
|
|
76
|
+
for (const s of succ.get(id) || []) {
|
|
77
|
+
if (s.id === stop || seen.has(s.id)) continue;
|
|
78
|
+
seen.add(s.id);
|
|
79
|
+
queue.push(s.id);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return seen;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Dominator sets over the nodes reachable from `roots` (several roots behave
|
|
87
|
+
* as one virtual root above them). Map: node → Set of its dominators, itself
|
|
88
|
+
* included. The iterative set-intersection form — a flow is a few dozen steps.
|
|
89
|
+
*/
|
|
90
|
+
function dominators(roots, succ, pred) {
|
|
91
|
+
const live = reach(roots, succ);
|
|
92
|
+
const rootSet = new Set(roots);
|
|
93
|
+
const order = [...live];
|
|
94
|
+
const dom = new Map();
|
|
95
|
+
for (const id of order) dom.set(id, rootSet.has(id) ? new Set([id]) : new Set(order));
|
|
96
|
+
let changed = true;
|
|
97
|
+
while (changed) {
|
|
98
|
+
changed = false;
|
|
99
|
+
for (const id of order) {
|
|
100
|
+
if (rootSet.has(id)) continue;
|
|
101
|
+
let acc = null;
|
|
102
|
+
for (const p of pred.get(id) || []) {
|
|
103
|
+
if (!live.has(p.id)) continue;
|
|
104
|
+
const d = dom.get(p.id);
|
|
105
|
+
acc = acc === null ? new Set(d) : new Set([...acc].filter((x) => d.has(x)));
|
|
106
|
+
}
|
|
107
|
+
const next = new Set(acc ?? []);
|
|
108
|
+
next.add(id);
|
|
109
|
+
const cur = dom.get(id);
|
|
110
|
+
if (next.size !== cur.size || [...next].some((x) => !cur.has(x))) {
|
|
111
|
+
dom.set(id, next);
|
|
112
|
+
changed = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return dom;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A loop's body: what its body port leads to, not passing back through the loop. */
|
|
120
|
+
function bodyOf(structure, loopId) {
|
|
121
|
+
const starts = (structure.succ.get(loopId) || [])
|
|
122
|
+
.filter((s) => s.port === structure.ports.body).map((s) => s.id);
|
|
123
|
+
return reach(starts, structure.succ, loopId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every loop and its body, for the loops in the graph. */
|
|
127
|
+
function loopBodies(structure) {
|
|
128
|
+
const out = new Map();
|
|
129
|
+
for (const n of structure.nodes) {
|
|
130
|
+
if (structure.role(n.id) === 'loop') out.set(n.id, bodyOf(structure, n.id));
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The roots a flow is walked from: its start steps, else the steps nothing leads to. */
|
|
136
|
+
function rootsOf(st) {
|
|
137
|
+
const roots = st.nodes.filter((n) => st.role(n.id) === 'start').map((n) => n.id);
|
|
138
|
+
return roots.length ? roots : st.nodes.filter((n) => !(st.pred.get(n.id) || []).length).map((n) => n.id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The ids of the steps that always run before `stepId`, nearest first.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} graph `{nodes, connections}` (the logic-graph shape)
|
|
145
|
+
* @param {object} catalogue `createStepCatalogue`'s
|
|
146
|
+
* @param {string} stepId
|
|
147
|
+
* @param {object} [options]
|
|
148
|
+
* @param {object} [options.loopPorts] `{entry, next, body, done}`
|
|
149
|
+
* @param {(node: object) => boolean} [options.waitsForAll] does this join wait for every branch?
|
|
150
|
+
*/
|
|
151
|
+
export function alwaysBefore(graph, catalogue, stepId, { loopPorts = DEFAULT_LOOP_PORTS,
|
|
152
|
+
waitsForAll = defaultWaitsForAll } = {}) {
|
|
153
|
+
const st = flowStructure(graph, catalogue, { loopPorts });
|
|
154
|
+
if (!st.byId.has(stepId)) return [];
|
|
155
|
+
const dom = dominators(rootsOf(st), st.succ, st.pred);
|
|
156
|
+
if (!dom.has(stepId)) return [];
|
|
157
|
+
|
|
158
|
+
/** The nearest dominator of `id` (by `domMap`) whose role is `fanout`. */
|
|
159
|
+
const nearestFanout = (id, domMap) => {
|
|
160
|
+
const mine = domMap.get(id);
|
|
161
|
+
if (!mine) return null;
|
|
162
|
+
let best = null;
|
|
163
|
+
for (const d of mine) {
|
|
164
|
+
if (d === id || st.role(d) !== 'fanout') continue;
|
|
165
|
+
if (best === null || domMap.get(d).size > domMap.get(best).size) best = d;
|
|
166
|
+
}
|
|
167
|
+
return best;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// What always runs on the branches of the fanout a join closes, memoised.
|
|
171
|
+
const branchMemo = new Map();
|
|
172
|
+
const branchSteps = (joinId, domMap) => {
|
|
173
|
+
if (branchMemo.has(joinId)) return branchMemo.get(joinId);
|
|
174
|
+
branchMemo.set(joinId, new Set()); // a cycle guard
|
|
175
|
+
const out = new Set();
|
|
176
|
+
const fan = nearestFanout(joinId, domMap);
|
|
177
|
+
if (fan !== null && waitsForAll(st.byId.get(joinId))) {
|
|
178
|
+
for (const s of st.succ.get(fan) || []) {
|
|
179
|
+
if (s.id === joinId) continue; // an empty branch
|
|
180
|
+
const sub = dominators([s.id], st.succ, st.pred);
|
|
181
|
+
const onEvery = sub.get(joinId);
|
|
182
|
+
if (!onEvery) continue; // this branch never reaches the join
|
|
183
|
+
for (const x of onEvery) {
|
|
184
|
+
if (x === joinId) continue;
|
|
185
|
+
out.add(x);
|
|
186
|
+
if (st.role(x) === 'join') for (const y of branchSteps(x, sub)) out.add(y);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
branchMemo.set(joinId, out);
|
|
191
|
+
return out;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const before = new Set([...dom.get(stepId)].filter((x) => x !== stepId));
|
|
195
|
+
for (const j of [...before, stepId]) {
|
|
196
|
+
if (st.role(j) === 'join') for (const x of branchSteps(j, dom)) if (x !== stepId) before.add(x);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// A body step is never before a step after (or outside) its loop.
|
|
200
|
+
for (const [, body] of loopBodies(st)) {
|
|
201
|
+
if (body.has(stepId)) continue;
|
|
202
|
+
for (const b of body) before.delete(b);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Nearest first: the shortest flow path from each step to this one.
|
|
206
|
+
const dist = new Map([[stepId, 0]]);
|
|
207
|
+
const queue = [stepId];
|
|
208
|
+
while (queue.length) {
|
|
209
|
+
const id = queue.shift();
|
|
210
|
+
for (const p of st.pred.get(id) || []) {
|
|
211
|
+
if (dist.has(p.id)) continue;
|
|
212
|
+
dist.set(p.id, dist.get(id) + 1);
|
|
213
|
+
queue.push(p.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const index = new Map(st.nodes.map((n, i) => [n.id, i]));
|
|
217
|
+
return [...before].sort((a, b) => ((dist.get(a) ?? Infinity) - (dist.get(b) ?? Infinity))
|
|
218
|
+
|| (index.get(a) - index.get(b)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The loops `stepId` is inside, innermost first. */
|
|
222
|
+
export function enclosingLoops(graph, catalogue, stepId, { loopPorts = DEFAULT_LOOP_PORTS } = {}) {
|
|
223
|
+
const st = flowStructure(graph, catalogue, { loopPorts });
|
|
224
|
+
const around = [];
|
|
225
|
+
for (const [loopId, body] of loopBodies(st)) {
|
|
226
|
+
if (body.has(stepId)) around.push({ loopId, size: body.size });
|
|
227
|
+
}
|
|
228
|
+
return around.sort((a, b) => a.size - b.size).map((x) => x.loopId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* THE ARMS `stepId` IS INSIDE, innermost first: `[{head, port}]` (36 §3.7).
|
|
233
|
+
*
|
|
234
|
+
* *Insert a value* must know when the step being edited sits in an arm taken
|
|
235
|
+
* on its head's FAILURE: there the head ran and failed, and on failure a
|
|
236
|
+
* step's data output is empty — so the consumer offers that head by its error
|
|
237
|
+
* output alone. The kit only REPORTS the port; which port means failure is
|
|
238
|
+
* the consumer's mapping (an outline's `arms.error`).
|
|
239
|
+
*
|
|
240
|
+
* An arm is a line out of a step that is not the step's continuation: every
|
|
241
|
+
* output of a branch, every line out of a fanout, a loop's outputs but `done`
|
|
242
|
+
* (its `body` included), and an ordinary step's FLOW outputs but its continue
|
|
243
|
+
* port. `stepId` is inside the arm `(head, port)` when EVERY flow path from
|
|
244
|
+
* the start to it leaves `head` by that line (each loop's return lines set
|
|
245
|
+
* aside, as `alwaysBefore` sets them aside) and it is not past the Merge that
|
|
246
|
+
* closes the block — the run reaches it only after the head left by `port`.
|
|
247
|
+
* So a step after the place the arms meet is inside none of them, and where a
|
|
248
|
+
* branch's other arms all END, the rest of the flow is inside its live arm:
|
|
249
|
+
* what the outline draws (36 §6.3).
|
|
250
|
+
*
|
|
251
|
+
* Over a block-shaped graph this IS the outline's nesting (the recogniser
|
|
252
|
+
* suite holds the two equal over its corpus); over any other graph it is
|
|
253
|
+
* still true of every run, which is why the canvas editor passes it too.
|
|
254
|
+
*
|
|
255
|
+
* @param {object} graph `{nodes, connections}` (the logic-graph shape)
|
|
256
|
+
* @param {object} catalogue `createStepCatalogue`'s
|
|
257
|
+
* @param {string} stepId
|
|
258
|
+
* @param {object} [options]
|
|
259
|
+
* @param {object} [options.loopPorts] `{entry, next, body, done}`
|
|
260
|
+
* @param {string|((node: object) => string|null)} [options.continuePort] an ordinary step's
|
|
261
|
+
* continuation (default: its `out` FLOW output, else its first)
|
|
262
|
+
* @param {(typeId: string) => string} [options.kindOf] a type's role — `start`, `branch`, `fanout`,
|
|
263
|
+
* `join`, `loop`, or anything else for an ordinary step; default the catalogue's `role`
|
|
264
|
+
*/
|
|
265
|
+
export function enclosingArms(graph, catalogue, stepId, { loopPorts = DEFAULT_LOOP_PORTS, continuePort = null,
|
|
266
|
+
kindOf = null } = {}) {
|
|
267
|
+
const st = flowStructure(graph, catalogue, { loopPorts, kindOf });
|
|
268
|
+
if (!st.byId.has(stepId)) return [];
|
|
269
|
+
const flowOutputs = (typeId) => (catalogue?.outputs ? catalogue.outputs(typeId, { flow: true }) : [])
|
|
270
|
+
.map((p) => p.name);
|
|
271
|
+
const cont = (node) => {
|
|
272
|
+
if (typeof continuePort === 'function') return continuePort(node);
|
|
273
|
+
if (typeof continuePort === 'string') return continuePort;
|
|
274
|
+
const outs = flowOutputs(node?.type);
|
|
275
|
+
return outs.includes('out') ? 'out' : (outs[0] ?? 'out');
|
|
276
|
+
};
|
|
277
|
+
const isArm = (headId, port) => {
|
|
278
|
+
const r = st.role(headId);
|
|
279
|
+
if (r === 'branch' || r === 'fanout') return true;
|
|
280
|
+
if (r === 'loop') return port !== st.ports.done;
|
|
281
|
+
return port !== cont(st.byId.get(headId));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Each flow line becomes a node of its own, so that a LINE can dominate a step.
|
|
285
|
+
const LINE = '\u0000line:';
|
|
286
|
+
const succ = new Map();
|
|
287
|
+
const pred = new Map();
|
|
288
|
+
const lines = new Map();
|
|
289
|
+
for (const n of st.nodes) { succ.set(n.id, []); pred.set(n.id, []); }
|
|
290
|
+
for (const n of st.nodes) {
|
|
291
|
+
for (const s of st.succ.get(n.id) || []) {
|
|
292
|
+
const key = `${LINE}${lines.size}`;
|
|
293
|
+
lines.set(key, { head: n.id, port: s.port });
|
|
294
|
+
succ.set(key, [{ id: s.id }]);
|
|
295
|
+
pred.set(key, [{ id: n.id }]);
|
|
296
|
+
succ.get(n.id).push({ id: key });
|
|
297
|
+
pred.get(s.id).push({ id: key });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const dom = dominators(rootsOf(st), succ, pred);
|
|
301
|
+
const mine = dom.get(stepId);
|
|
302
|
+
if (!mine) return [];
|
|
303
|
+
|
|
304
|
+
const out = [];
|
|
305
|
+
for (const key of mine) {
|
|
306
|
+
const line = lines.get(key);
|
|
307
|
+
if (!line || !isArm(line.head, line.port)) continue;
|
|
308
|
+
// Walk from the line down to the step: a Merge met with no Parallel
|
|
309
|
+
// opened below the line closes the block, and the step is past it.
|
|
310
|
+
const chain = [...mine].filter((x) => !lines.has(x) && dom.get(x).has(key))
|
|
311
|
+
.sort((a, b) => dom.get(a).size - dom.get(b).size);
|
|
312
|
+
let depth = 0;
|
|
313
|
+
let past = false;
|
|
314
|
+
for (const x of chain) {
|
|
315
|
+
const r = st.role(x);
|
|
316
|
+
if (r === 'join') {
|
|
317
|
+
if (depth === 0) { past = true; break; }
|
|
318
|
+
depth -= 1;
|
|
319
|
+
} else if (r === 'fanout' && x !== stepId) depth += 1;
|
|
320
|
+
}
|
|
321
|
+
if (past || out.some((a) => a.head === line.head && a.port === line.port)) continue;
|
|
322
|
+
out.push({ head: line.head, port: line.port, depth: dom.get(key).size });
|
|
323
|
+
}
|
|
324
|
+
return out.sort((a, b) => b.depth - a.depth).map(({ head, port }) => ({ head, port }));
|
|
325
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STEP CATALOGUE — the list of step types a consumer passes in (36 §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Its field names are the consumer's WIRE names, so a server's node-type list
|
|
5
|
+
* is handed to an editor as it arrives:
|
|
6
|
+
*
|
|
7
|
+
* { type_id, label, category, role, description, icon,
|
|
8
|
+
* ports: [{ name, direction: 'input'|'output', port_type: 'FLOW'|'DATA',
|
|
9
|
+
* label, required, multiple }],
|
|
10
|
+
* config_schema, unavailable }
|
|
11
|
+
*
|
|
12
|
+
* Any other key a type carries is kept and ignored.
|
|
13
|
+
*
|
|
14
|
+
* ══ DECLARED PORT ORDER IS MEANING ═════════════════════════════════════
|
|
15
|
+
*
|
|
16
|
+
* A lane reads an operation's first FLOW input as its own lane and the rest as
|
|
17
|
+
* joined lanes; an outline draws a step's other FLOW outputs, after its
|
|
18
|
+
* continuation, as arms in declared order. So `inputs()` and `outputs()` keep
|
|
19
|
+
* the order the type DECLARES and never sort: an earlier pipeline engine
|
|
20
|
+
* sorted a join's inputs by port name, and `in_bottom` came before `in_top`.
|
|
21
|
+
*
|
|
22
|
+
* ══ TONES, NOT COLOURS ═════════════════════════════════════════════════
|
|
23
|
+
*
|
|
24
|
+
* A category names a tone (`dom.js`'s TONES); the stylesheet maps tones to
|
|
25
|
+
* tokens. A category's `layout: 'grid'` draws its entries two to a row in the
|
|
26
|
+
* step picker (the mocks' Data, Integration and Utility groups).
|
|
27
|
+
*
|
|
28
|
+
* ══ A BROKEN TYPE IS LISTED, NEVER THROWN ══════════════════════════════
|
|
29
|
+
*
|
|
30
|
+
* A duplicate id (the first is kept), a type without an id, a port without a
|
|
31
|
+
* name (the port is dropped), a schema outside the subset (the type is kept,
|
|
32
|
+
* its form drawn from what can be drawn): each is a line in `problems`, so a
|
|
33
|
+
* consumer's catalogue with one bad entry still opens an editor.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { checkSettingsSchema } from './settings_schema.js';
|
|
37
|
+
import { TONES } from './dom.js';
|
|
38
|
+
|
|
39
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
40
|
+
|
|
41
|
+
/** The default id stem for a new step: the type id, made safe for an id. */
|
|
42
|
+
export function defaultIdBase(type) {
|
|
43
|
+
return String(type?.type_id ?? type ?? 'step');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cleanStem(stem) {
|
|
47
|
+
return String(stem ?? '').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40) || 'step';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalisePort(p) {
|
|
51
|
+
return {
|
|
52
|
+
...p,
|
|
53
|
+
name: String(p.name),
|
|
54
|
+
direction: p.direction === 'output' ? 'output' : 'input',
|
|
55
|
+
port_type: p.port_type ? String(p.port_type) : 'FLOW',
|
|
56
|
+
label: p.label ?? String(p.name),
|
|
57
|
+
required: Boolean(p.required),
|
|
58
|
+
multiple: Boolean(p.multiple),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object[]} types the consumer's step types
|
|
64
|
+
* @param {object} [options]
|
|
65
|
+
* @param {{id: string, label: string, tone?: string, layout?: 'list'|'grid'}[]} [options.categories]
|
|
66
|
+
* @param {(type: object) => string} [options.idBase] a new step's id stem
|
|
67
|
+
*/
|
|
68
|
+
export function createStepCatalogue(types, { categories = [], idBase = defaultIdBase } = {}) {
|
|
69
|
+
const problems = [];
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
const order = [];
|
|
72
|
+
|
|
73
|
+
for (const raw of Array.isArray(types) ? types : []) {
|
|
74
|
+
if (!isPlainObject(raw)) {
|
|
75
|
+
problems.push({ type_id: null, message: 'A step type must be an object.' });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const id = raw.type_id;
|
|
79
|
+
if (typeof id !== 'string' || !id) {
|
|
80
|
+
problems.push({ type_id: null, message: `A step type has no type_id (label: ${raw.label ?? '—'}).` });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (byId.has(id)) {
|
|
84
|
+
problems.push({ type_id: id, message: `A second step type is called "${id}"; the first is kept.` });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const ports = [];
|
|
88
|
+
for (const p of Array.isArray(raw.ports) ? raw.ports : []) {
|
|
89
|
+
if (!isPlainObject(p) || typeof p.name !== 'string' || !p.name) {
|
|
90
|
+
problems.push({ type_id: id, message: `"${id}" declares a port without a name; it is left out.` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
ports.push(normalisePort(p));
|
|
94
|
+
}
|
|
95
|
+
const schema = isPlainObject(raw.config_schema) ? raw.config_schema : {};
|
|
96
|
+
const refused = raw.config_schema === undefined ? null : checkSettingsSchema(raw.config_schema);
|
|
97
|
+
if (refused) problems.push({ type_id: id, message: `"${id}"'s settings schema: ${refused}` });
|
|
98
|
+
const type = Object.freeze({
|
|
99
|
+
...raw,
|
|
100
|
+
label: raw.label ?? id,
|
|
101
|
+
category: raw.category ?? null,
|
|
102
|
+
role: raw.role || 'step',
|
|
103
|
+
description: raw.description ?? '',
|
|
104
|
+
icon: raw.icon ?? '',
|
|
105
|
+
ports: Object.freeze(ports.map((p) => Object.freeze(p))),
|
|
106
|
+
config_schema: schema,
|
|
107
|
+
unavailable: raw.unavailable ?? null,
|
|
108
|
+
});
|
|
109
|
+
byId.set(id, type);
|
|
110
|
+
order.push(type);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cats = (Array.isArray(categories) ? categories : []).filter(isPlainObject).map((c) => Object.freeze({
|
|
114
|
+
id: String(c.id),
|
|
115
|
+
label: c.label ?? String(c.id),
|
|
116
|
+
tone: TONES.includes(c.tone) ? c.tone : 'grey',
|
|
117
|
+
layout: c.layout === 'grid' ? 'grid' : 'list',
|
|
118
|
+
}));
|
|
119
|
+
const catIndex = new Map(cats.map((c, i) => [c.id, i]));
|
|
120
|
+
// A type whose category nobody listed is not lost: it goes under a group of
|
|
121
|
+
// its own category id, after the listed ones, in the order first seen.
|
|
122
|
+
const extra = [];
|
|
123
|
+
for (const t of order) {
|
|
124
|
+
if (t.category !== null && !catIndex.has(t.category) && !extra.some((c) => c.id === t.category)) {
|
|
125
|
+
extra.push(Object.freeze({ id: String(t.category), label: String(t.category), tone: 'grey', layout: 'list' }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (order.some((t) => t.category === null)) {
|
|
129
|
+
extra.push(Object.freeze({ id: '', label: '', tone: 'grey', layout: 'list' }));
|
|
130
|
+
}
|
|
131
|
+
const allCats = Object.freeze([...cats, ...extra]);
|
|
132
|
+
const rank = (t) => {
|
|
133
|
+
const id = t.category === null ? '' : t.category;
|
|
134
|
+
return allCats.findIndex((c) => c.id === id);
|
|
135
|
+
};
|
|
136
|
+
const sorted = [...order].sort((a, b) => (rank(a) - rank(b))
|
|
137
|
+
|| String(a.label).localeCompare(String(b.label)));
|
|
138
|
+
|
|
139
|
+
const portsOf = (typeId, direction, flow) => {
|
|
140
|
+
const t = byId.get(typeId);
|
|
141
|
+
if (!t) return [];
|
|
142
|
+
return t.ports.filter((p) => p.direction === direction && (!flow || p.port_type === 'FLOW'));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
problems: Object.freeze(problems.map((p) => Object.freeze(p))),
|
|
147
|
+
categories: allCats,
|
|
148
|
+
get: (typeId) => byId.get(typeId) ?? null,
|
|
149
|
+
has: (typeId) => byId.has(typeId),
|
|
150
|
+
list: () => [...sorted],
|
|
151
|
+
byCategory: () => allCats
|
|
152
|
+
.map((category) => ({ category, types: sorted.filter((t) => (t.category ?? '') === category.id) }))
|
|
153
|
+
.filter((g) => g.types.length > 0),
|
|
154
|
+
role: (typeId) => byId.get(typeId)?.role || 'step',
|
|
155
|
+
category: (typeId) => {
|
|
156
|
+
const t = byId.get(typeId);
|
|
157
|
+
return t ? allCats.find((c) => c.id === (t.category ?? '')) ?? null : null;
|
|
158
|
+
},
|
|
159
|
+
tone: (typeId) => {
|
|
160
|
+
const t = byId.get(typeId);
|
|
161
|
+
return (t && allCats.find((c) => c.id === (t.category ?? ''))?.tone) || 'grey';
|
|
162
|
+
},
|
|
163
|
+
inputs: (typeId, { flow = false } = {}) => portsOf(typeId, 'input', flow),
|
|
164
|
+
outputs: (typeId, { flow = false } = {}) => portsOf(typeId, 'output', flow),
|
|
165
|
+
port: (typeId, name, direction) => byId.get(typeId)?.ports
|
|
166
|
+
.find((p) => p.name === name && (!direction || p.direction === direction)) ?? null,
|
|
167
|
+
/** A new step's id stem for `typeId`, safe to put in an id. */
|
|
168
|
+
idBase: (typeId) => cleanStem(idBase(byId.get(typeId) ?? { type_id: typeId })),
|
|
169
|
+
});
|
|
170
|
+
}
|