flexdesk 0.5.0 → 0.6.0

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.
Files changed (62) hide show
  1. package/README.md +621 -1
  2. package/css/base.css +2835 -0
  3. package/css/flexdesk.css +2835 -0
  4. package/css/tokens.css +72 -0
  5. package/dist/canvas.js +390 -0
  6. package/dist/canvas.js.map +7 -0
  7. package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
  8. package/dist/chunk-6NG7YVBG.js.map +7 -0
  9. package/dist/chunk-H4LT43RS.js +475 -0
  10. package/dist/chunk-H4LT43RS.js.map +7 -0
  11. package/dist/chunk-SN4LSOAR.js +532 -0
  12. package/dist/chunk-SN4LSOAR.js.map +7 -0
  13. package/dist/flexdesk.css +2835 -0
  14. package/dist/flow.js +10030 -0
  15. package/dist/flow.js.map +7 -0
  16. package/dist/tiles.js +9 -9
  17. package/dist/tokens.css +72 -0
  18. package/dist/widgets.js +6 -4
  19. package/dist/widgets.js.map +1 -1
  20. package/dist/wm.js +4 -2
  21. package/dist/wm.js.map +1 -1
  22. package/package.json +9 -1
  23. package/src/canvas/canvas_adapter.js +523 -0
  24. package/src/canvas/edge_router.js +392 -0
  25. package/src/canvas/graph/connector_router.js +189 -0
  26. package/src/canvas/graph/history_service.js +170 -0
  27. package/src/canvas/graph/node_platform.js +116 -0
  28. package/src/canvas/index.js +16 -0
  29. package/src/flow/canvas_editor/editor.js +1081 -0
  30. package/src/flow/canvas_editor/index.js +16 -0
  31. package/src/flow/canvas_editor/layout.js +118 -0
  32. package/src/flow/canvas_editor/strings.js +37 -0
  33. package/src/flow/kit/always_before.js +325 -0
  34. package/src/flow/kit/catalogue.js +170 -0
  35. package/src/flow/kit/chip_input.js +461 -0
  36. package/src/flow/kit/dom.js +205 -0
  37. package/src/flow/kit/findings.js +129 -0
  38. package/src/flow/kit/history.js +173 -0
  39. package/src/flow/kit/index.js +38 -0
  40. package/src/flow/kit/keys.js +127 -0
  41. package/src/flow/kit/logic_graph.js +210 -0
  42. package/src/flow/kit/references.js +199 -0
  43. package/src/flow/kit/settings_panel.js +357 -0
  44. package/src/flow/kit/settings_schema.js +175 -0
  45. package/src/flow/kit/step_picker.js +209 -0
  46. package/src/flow/kit/strings.js +102 -0
  47. package/src/flow/kit/value_picker.js +238 -0
  48. package/src/flow/kit/widgets.js +763 -0
  49. package/src/flow/lanes/editor.js +1469 -0
  50. package/src/flow/lanes/index.js +26 -0
  51. package/src/flow/lanes/layout.js +525 -0
  52. package/src/flow/lanes/pipeline.js +327 -0
  53. package/src/flow/lanes/preview.js +109 -0
  54. package/src/flow/lanes/strings.js +98 -0
  55. package/src/flow/outline/editor.js +1511 -0
  56. package/src/flow/outline/index.js +20 -0
  57. package/src/flow/outline/mapping.js +286 -0
  58. package/src/flow/outline/menu.js +154 -0
  59. package/src/flow/outline/operations.js +738 -0
  60. package/src/flow/outline/recognise.js +755 -0
  61. package/src/flow/outline/strings.js +135 -0
  62. package/dist/chunk-24MS7W5Y.js.map +0 -7
@@ -0,0 +1,26 @@
1
+ /**
2
+ * What `@flexdesk/flow` exports for the LANE editor (`createLaneEditor`):
3
+ * a data flow drawn as lanes, left to right (36 §7).
4
+ *
5
+ * OWNED BY the lanes agent (D, 36 §2 and §7).
6
+ *
7
+ * editor.js createLaneEditor — the editor, and its compact strip
8
+ * layout.js layoutLanes, placeLanes — PURE: lanes, columns and wires
9
+ * pipeline.js the flow's JSON and every edit the editor makes to it — PURE
10
+ * preview.js the debounced provider runner: the newest answer wins
11
+ * strings.js every word it draws
12
+ *
13
+ * Every name is the lane editor's own — `lane…`, `LANE_…`, `…Pipeline` — so the
14
+ * three editors' `export *` in `flow.js` cannot collide; the pure edits are one
15
+ * namespace, `lanePipeline`, rather than a dozen loose verbs.
16
+ */
17
+
18
+ export { createLaneEditor } from './editor.js';
19
+ export {
20
+ LANE_GEOMETRY, LANE_PAD, LANE_GAP, CW as LANE_CW, CH as LANE_CH, GAP as LANE_COLUMN_GAP,
21
+ LAYOUT_STRINGS as LANE_LAYOUT_STRINGS, layoutLanes, placeLanes, stepPorts as laneStepPorts,
22
+ } from './layout.js';
23
+ export { LANE_ACTIONS, emptyPipeline, normalisePipeline, serialisePipeline } from './pipeline.js';
24
+ export * as lanePipeline from './pipeline.js';
25
+ export { createPreviewRunner as createLanePreviewRunner } from './preview.js';
26
+ export { LANE_STRINGS } from './strings.js';
@@ -0,0 +1,525 @@
1
+ /**
2
+ * THE LANE LAYOUT — a data flow drawn as lanes, left to right (36 §7.3).
3
+ * PURE: no DOM, no clock; runs under plain node.
4
+ *
5
+ * const layout = layoutLanes(pipeline, catalogue);
6
+ * const placed = placeLanes(layout, 'regular'); // or 'small', 'strip'
7
+ *
8
+ * The flow is the pipeline JSON — `{nodes, connections: [{id, sourceId,
9
+ * sourcePort, targetId, targetPort}], parameters}` — and the catalogue is the
10
+ * kit's (`createStepCatalogue`), read for each step's FLOW ports in their
11
+ * DECLARED order: a step's first input is its LANE input, every further input
12
+ * takes a lane from elsewhere (a join's second side), and its first output is
13
+ * its continuation.
14
+ *
15
+ * ══ THE RULES ══════════════════════════════════════════════════════════
16
+ *
17
+ * 1. ROOTS are the steps with nothing connected to their lane input — the
18
+ * sources, and any step left unconnected (its lane flagged), in `nodes`
19
+ * order.
20
+ * 2. A LANE starts at a root and follows the step that takes its output ON
21
+ * ITS LANE INPUT. A step belongs to the lane that feeds its lane input —
22
+ * never to whichever lane reached it first. An output that feeds several
23
+ * steps' lane inputs is a FORK: the first, in connection order, goes on in
24
+ * the lane, and each other starts a lane below. An output that feeds
25
+ * another step's OTHER input makes this lane a FEEDER of that join.
26
+ * 3. COLUMNS: a root is column 0 and every other step is one more than the
27
+ * latest of the steps that feed it. Then each feeder lane is SHIFTED RIGHT
28
+ * as a whole until the card that feeds the join sits one column before it
29
+ * — never left of where the first pass put it, and never so far that the
30
+ * join itself would move. Forks and nested feeders are re-settled until
31
+ * nothing moves.
32
+ * 4. LANE ORDER, top to bottom: the root lanes in `nodes` order, each followed
33
+ * directly by its feeders (by their join's column), then its forks (by
34
+ * their fork's column), each of those followed by its own, recursively.
35
+ * 5. WIRES are computed from the grid, never measured from the page: `lane`,
36
+ * card to next card; `join`, from the feeding card right and then up into
37
+ * the join's bottom; `fork`, from the forking card's bottom down and then
38
+ * right into the new lane's first card. A wire that would run through a
39
+ * card that is neither of its ends (a feeder lane that goes on past its
40
+ * feed, a fork drawn under a feeder) takes the gutters instead — the gap
41
+ * after a column and the gap beside a lane, which hold no card.
42
+ *
43
+ * ══ TWO DEFECTS OF THE EARLIER LAYOUT, LEFT BEHIND ═════════════════════
44
+ *
45
+ * The lane layout this ports gave a join to WHICHEVER LANE REACHED IT FIRST,
46
+ * which depended on the order of the nodes array: list the feeding source
47
+ * first and the join, and everything after it, moved into the feeder's lane.
48
+ * Rule 2 is the fix. And it measured its wires from the page after every
49
+ * rebuild, so nothing about them could be tested without a browser; rule 5 is
50
+ * the fix, and `placeLanes` is what the stylesheet is held to.
51
+ *
52
+ * ══ WHAT IT REFUSES ════════════════════════════════════════════════════
53
+ *
54
+ * Three shapes cannot be drawn honestly as lanes, and each is a `problem`
55
+ * that opens the editor READ-ONLY with its sentence (the graph is drawn as
56
+ * best it can be, and never repaired): a step whose lane input two
57
+ * connections feed (`lanes_two_inputs`), a cycle (`lanes_cycle`, broken for
58
+ * drawing at the first step of it in `nodes` order), and two steps with one id
59
+ * (`lanes_duplicate_step`; the second is not drawn).
60
+ */
61
+
62
+ /** The three sizes a lane is drawn at — each the numbers of the mock it comes from. */
63
+ export const LANE_GEOMETRY = Object.freeze({
64
+ /** The lane editor. `x = left + column·(width + gap)`, `laneTop = lane·(height + 2·lanePad + laneGap)`. */
65
+ regular: Object.freeze({
66
+ name: 'regular', width: 200, height: 62, gap: 30, lanePad: 12, laneGap: 26, left: 12, right: 12,
67
+ notch: 12, tipInset: 4, wireIn: 12, radius: 10, portDot: true,
68
+ }),
69
+ /** The lane editor in a narrower host (two lines a card). */
70
+ small: Object.freeze({
71
+ name: 'small', width: 160, height: 50, gap: 24, lanePad: 8, laneGap: 18, left: 10, right: 10,
72
+ notch: 10, tipInset: 4, wireIn: 10, radius: 10, portDot: true,
73
+ }),
74
+ /** The compact strip: a title a card, nothing to press. */
75
+ strip: Object.freeze({
76
+ name: 'strip', width: 92, height: 26, gap: 10, lanePad: 0, laneGap: 14, left: 0, right: 0,
77
+ notch: 8, tipInset: 2, wireIn: 6, radius: 10, portDot: false,
78
+ }),
79
+ });
80
+
81
+ /** The regular card's numbers, by the names the mock gives them. */
82
+ export const CW = LANE_GEOMETRY.regular.width;
83
+ export const CH = LANE_GEOMETRY.regular.height;
84
+ export const GAP = LANE_GEOMETRY.regular.gap;
85
+ export const LANE_PAD = LANE_GEOMETRY.regular.lanePad;
86
+ export const LANE_GAP = LANE_GEOMETRY.regular.laneGap;
87
+
88
+ export const LAYOUT_STRINGS = Object.freeze({
89
+ lanes_two_inputs: (name) => `‘${name}’ takes its lane from two steps at once, so the flow cannot be drawn `
90
+ + 'as lanes.',
91
+ lanes_cycle: (name) => `‘${name}’ leads back to itself through the steps after it, so the flow cannot be `
92
+ + 'drawn as lanes.',
93
+ lanes_duplicate_step: (id) => `Two steps are called ‘${id}’, so the flow cannot be drawn as lanes.`,
94
+ });
95
+
96
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
97
+
98
+ function unique(list) {
99
+ return [...new Set(list)];
100
+ }
101
+
102
+ /**
103
+ * A step's FLOW ports as a lane reads them: `{known, role, laneInput, inputs,
104
+ * continuation, outputs}`. A type the catalogue does not know is read from the
105
+ * connections it has, in connection order.
106
+ */
107
+ export function stepPorts(catalogue, node, connections = []) {
108
+ const type = catalogue?.get?.(node?.type) ?? null;
109
+ const conns = Array.isArray(connections) ? connections : [];
110
+ let inputs;
111
+ let outputs;
112
+ if (type) {
113
+ inputs = catalogue.inputs(node.type, { flow: true }).map((p) => p.name);
114
+ outputs = catalogue.outputs(node.type, { flow: true }).map((p) => p.name);
115
+ } else {
116
+ inputs = unique(conns.filter((c) => c.targetId === node?.id).map((c) => String(c.targetPort ?? 'in')));
117
+ outputs = unique(conns.filter((c) => c.sourceId === node?.id).map((c) => String(c.sourcePort ?? 'out')));
118
+ }
119
+ return {
120
+ known: Boolean(type),
121
+ role: type ? (type.role || 'transform') : null,
122
+ laneInput: inputs[0] ?? null,
123
+ inputs,
124
+ continuation: outputs[0] ?? null,
125
+ outputs,
126
+ };
127
+ }
128
+
129
+ /** A step's name for a sentence: its label, its type's label, its id. */
130
+ function nameOf(catalogue, node) {
131
+ return node?.label || catalogue?.get?.(node?.type)?.label || String(node?.id ?? '');
132
+ }
133
+
134
+ /**
135
+ * Lay a data flow out as lanes.
136
+ *
137
+ * @param {{nodes: object[], connections: object[]}} pipeline
138
+ * @param {object} catalogue the kit's step catalogue
139
+ * @param {object} [o]
140
+ * @param {object} [o.strings] the problems' sentences (`LAYOUT_STRINGS` by default)
141
+ * @returns {{lanes: {index, steps: {id, column}[], feeds: {from, to, port}[], forkOf: string|null,
142
+ * unconnected: boolean, parent: number|null, kind: 'root'|'fork'|'feeder'}[],
143
+ * wires: {kind: 'lane'|'join'|'fork', from, to, toPort, connection, slot?, slots?, cycle?}[],
144
+ * at: Object<string, {lane, column, position}>, columns: number,
145
+ * problems: {code, node_id, message}[]}}
146
+ */
147
+ export function layoutLanes(pipeline, catalogue, { strings = null } = {}) {
148
+ const S = { ...LAYOUT_STRINGS, ...(isObject(strings) ? strings : {}) };
149
+ const problems = [];
150
+
151
+ // ── the steps, once each ─────────────────────────────────────────────
152
+ const nodes = [];
153
+ const byId = new Map();
154
+ for (const n of Array.isArray(pipeline?.nodes) ? pipeline.nodes : []) {
155
+ if (!isObject(n)) continue;
156
+ const id = String(n.id);
157
+ if (byId.has(id)) {
158
+ if (!problems.some((p) => p.code === 'lanes_duplicate_step' && p.node_id === id)) {
159
+ problems.push({ code: 'lanes_duplicate_step', node_id: id, message: S.lanes_duplicate_step(id) });
160
+ }
161
+ continue;
162
+ }
163
+ byId.set(id, n);
164
+ nodes.push(n);
165
+ }
166
+ const order = new Map(nodes.map((n, i) => [String(n.id), i]));
167
+ const ids = nodes.map((n) => String(n.id));
168
+
169
+ // ── the connections whose ends are both steps ────────────────────────
170
+ const conns = [];
171
+ (Array.isArray(pipeline?.connections) ? pipeline.connections : []).forEach((c, index) => {
172
+ if (!isObject(c)) return;
173
+ const source = String(c.sourceId);
174
+ const target = String(c.targetId);
175
+ if (!byId.has(source) || !byId.has(target)) return;
176
+ conns.push({ index, id: c.id ?? null, source, target, sourcePort: String(c.sourcePort ?? 'out'),
177
+ targetPort: String(c.targetPort ?? 'in'), ignored: false });
178
+ });
179
+ const rawConns = conns.map((c) => ({ id: c.id, sourceId: c.source, targetId: c.target,
180
+ sourcePort: c.sourcePort, targetPort: c.targetPort }));
181
+ const ports = new Map(nodes.map((n) => [String(n.id), stepPorts(catalogue, n, rawConns)]));
182
+
183
+ // ── the order steps run in, breaking a cycle where it must be ────────
184
+ const incoming = new Map(ids.map((id) => [id, []]));
185
+ const outgoing = new Map(ids.map((id) => [id, []]));
186
+ for (const c of conns) {
187
+ incoming.get(c.target).push(c);
188
+ outgoing.get(c.source).push(c);
189
+ }
190
+ const indeg = new Map(ids.map((id) => [id, incoming.get(id).length]));
191
+ const topo = [];
192
+ const done = new Set();
193
+ const ready = ids.filter((id) => indeg.get(id) === 0);
194
+ const release = (id) => {
195
+ done.add(id);
196
+ topo.push(id);
197
+ for (const c of outgoing.get(id)) {
198
+ if (c.ignored) continue;
199
+ indeg.set(c.target, indeg.get(c.target) - 1);
200
+ if (indeg.get(c.target) === 0 && !done.has(c.target)) ready.push(c.target);
201
+ }
202
+ };
203
+ while (topo.length < ids.length) {
204
+ if (ready.length) {
205
+ const id = ready.shift();
206
+ if (!done.has(id)) release(id);
207
+ continue;
208
+ }
209
+ // Nothing is ready: the steps left wait on each other. Break the cycle at
210
+ // the first of them in `nodes` order, by setting aside every line into it
211
+ // from a step that has not run yet.
212
+ const first = ids.find((id) => !done.has(id));
213
+ for (const c of incoming.get(first)) {
214
+ if (!done.has(c.source) && !c.ignored) {
215
+ c.ignored = true;
216
+ indeg.set(first, indeg.get(first) - 1);
217
+ }
218
+ }
219
+ problems.push({ code: 'lanes_cycle', node_id: first, message: S.lanes_cycle(nameOf(catalogue, byId.get(first))) });
220
+ if (indeg.get(first) === 0) ready.push(first);
221
+ }
222
+
223
+ // ── which line feeds each step's lane input ──────────────────────────
224
+ const laneFeed = new Map();
225
+ for (const id of ids) {
226
+ const lane = ports.get(id).laneInput;
227
+ if (lane === null) continue;
228
+ const into = incoming.get(id).filter((c) => c.targetPort === lane);
229
+ if (into.length > 1) {
230
+ problems.push({ code: 'lanes_two_inputs', node_id: id,
231
+ message: S.lanes_two_inputs(nameOf(catalogue, byId.get(id))) });
232
+ }
233
+ const feed = into.find((c) => !c.ignored);
234
+ if (feed) laneFeed.set(id, feed);
235
+ }
236
+ const successors = new Map(ids.map((id) => [id, []]));
237
+ for (const [target, c] of [...laneFeed.entries()].sort((a, b) => a[1].index - b[1].index)) {
238
+ successors.get(c.source).push(target);
239
+ }
240
+
241
+ // ── the lanes, in the order they are found ───────────────────────────
242
+ const laneOf = new Map();
243
+ const created = [];
244
+ const build = (root, forkOf) => {
245
+ const lane = {
246
+ steps: [], feeds: [], forkOf, parent: null, kind: forkOf === null ? 'root' : 'fork',
247
+ unconnected: ports.get(root).laneInput !== null && !laneFeed.has(root),
248
+ };
249
+ created.push(lane);
250
+ const forks = [];
251
+ let current = root;
252
+ while (current !== null && !laneOf.has(current)) {
253
+ laneOf.set(current, lane);
254
+ lane.steps.push(current);
255
+ const next = successors.get(current).filter((t) => !laneOf.has(t));
256
+ for (const f of next.slice(1)) forks.push([current, f]);
257
+ current = next[0] ?? null;
258
+ }
259
+ for (const [from, to] of forks) if (!laneOf.has(to)) build(to, from);
260
+ };
261
+ for (const id of ids) if (!laneFeed.has(id) && !laneOf.has(id)) build(id, null);
262
+ for (const id of ids) if (!laneOf.has(id)) build(id, null);
263
+
264
+ // ── feeds: a line into any input but the lane's ──────────────────────
265
+ const feedsInto = new Map();
266
+ for (const c of conns) {
267
+ if (laneFeed.get(c.target) === c) continue;
268
+ laneOf.get(c.source).feeds.push({ from: c.source, to: c.target, port: c.targetPort, index: c.index });
269
+ if (!feedsInto.has(c.target)) feedsInto.set(c.target, []);
270
+ feedsInto.get(c.target).push(c);
271
+ }
272
+ for (const lane of created) {
273
+ if (lane.forkOf !== null) {
274
+ lane.parent = laneOf.get(lane.forkOf);
275
+ continue;
276
+ }
277
+ const out = lane.feeds.find((f) => laneOf.get(f.to) !== lane);
278
+ if (out) {
279
+ lane.parent = laneOf.get(out.to);
280
+ lane.kind = 'feeder';
281
+ }
282
+ }
283
+
284
+ // ── columns: longest path, then the feeders pulled right ─────────────
285
+ const preds = new Map(ids.map((id) => [id, incoming.get(id).filter((c) => !c.ignored).map((c) => c.source)]));
286
+ const settle = (floor) => {
287
+ const col = new Map();
288
+ for (const id of topo) {
289
+ let c = floor.get(id) ?? 0;
290
+ for (const p of preds.get(id)) c = Math.max(c, col.get(p) + 1);
291
+ col.set(id, c);
292
+ }
293
+ return col;
294
+ };
295
+ let floor = new Map();
296
+ let col = settle(floor);
297
+ let moved = true;
298
+ for (let pass = 0; moved && pass <= ids.length + 1; pass += 1) {
299
+ moved = false;
300
+ for (const lane of created) {
301
+ const out = lane.feeds.filter((f) => laneOf.get(f.to) !== lane && !isIgnored(conns, f));
302
+ if (!out.length) continue;
303
+ let want = Infinity;
304
+ for (const f of out) want = Math.min(want, col.get(f.to) - 1 - col.get(f.from));
305
+ for (let shift = want; shift >= 1; shift -= 1) {
306
+ const trial = new Map(floor);
307
+ for (const id of lane.steps) trial.set(id, col.get(id) + shift);
308
+ const next = settle(trial);
309
+ // A feeder may move up to its join, never move the join.
310
+ if (out.every((f) => next.get(f.to) === col.get(f.to))) {
311
+ floor = trial;
312
+ col = next;
313
+ moved = true;
314
+ break;
315
+ }
316
+ }
317
+ }
318
+ }
319
+
320
+ // ── lane order: each lane, then its feeders, then its forks ──────────
321
+ const created_ = new Map(created.map((l, i) => [l, i]));
322
+ const children = new Map(created.map((l) => [l, []]));
323
+ for (const l of created) if (l.parent && l.parent !== l) children.get(l.parent).push(l);
324
+ const attach = (l) => {
325
+ if (l.kind === 'fork') return col.get(l.forkOf);
326
+ const f = l.feeds.find((x) => laneOf.get(x.to) === l.parent);
327
+ return f ? col.get(f.to) : 0;
328
+ };
329
+ const ordered = [];
330
+ const placed = new Set();
331
+ const visit = (lane) => {
332
+ if (placed.has(lane)) return;
333
+ placed.add(lane);
334
+ ordered.push(lane);
335
+ const kids = children.get(lane).slice().sort((a, b) => ((a.kind === 'fork') - (b.kind === 'fork'))
336
+ || (attach(a) - attach(b)) || (created_.get(a) - created_.get(b)));
337
+ for (const k of kids) visit(k);
338
+ };
339
+ for (const l of created) if (!l.parent) visit(l);
340
+ // A lane whose parents lead round in a circle (each feeds the other) has
341
+ // no top: the first of them found is put at the top.
342
+ for (const l of created) if (!placed.has(l)) visit(l);
343
+ const indexOf = new Map(ordered.map((l, i) => [l, i]));
344
+
345
+ const at = {};
346
+ const lanes = ordered.map((l, index) => {
347
+ l.steps.forEach((id, position) => { at[id] = { lane: index, column: col.get(id), position }; });
348
+ return {
349
+ index,
350
+ steps: l.steps.map((id) => ({ id, column: col.get(id) })),
351
+ feeds: l.feeds.map((f) => ({ from: f.from, to: f.to, port: f.port })),
352
+ forkOf: l.forkOf,
353
+ unconnected: l.unconnected,
354
+ parent: l.parent && l.parent !== l ? indexOf.get(l.parent) : null,
355
+ kind: l.kind,
356
+ };
357
+ });
358
+
359
+ // ── the wires, one a connection, in connection order ─────────────────
360
+ const wires = [];
361
+ for (const c of conns) {
362
+ const base = { from: c.source, to: c.target, toPort: c.targetPort, connection: c.id };
363
+ if (laneFeed.get(c.target) === c) {
364
+ const a = at[c.source];
365
+ const b = at[c.target];
366
+ const next = a.lane === b.lane && b.position === a.position + 1;
367
+ wires.push({ kind: next ? 'lane' : 'fork', ...base });
368
+ continue;
369
+ }
370
+ const others = ports.get(c.target).inputs.filter((p) => p !== ports.get(c.target).laneInput);
371
+ for (const x of feedsInto.get(c.target) || []) if (!others.includes(x.targetPort)) others.push(x.targetPort);
372
+ const slot = Math.max(0, others.indexOf(c.targetPort));
373
+ wires.push({ kind: 'join', ...base, slot, slots: Math.max(1, others.length), ...(c.ignored ? { cycle: true } : {}) });
374
+ }
375
+
376
+ const columns = ids.length ? Math.max(...ids.map((id) => col.get(id))) + 1 : 0;
377
+ return { lanes, wires, at, columns, problems };
378
+ }
379
+
380
+ function isIgnored(conns, feed) {
381
+ return conns.some((c) => c.ignored && c.index === feed.index);
382
+ }
383
+
384
+ // ── coordinates ──────────────────────────────────────────────────────────
385
+
386
+ const num = (v) => {
387
+ const r = Math.round(v * 10) / 10;
388
+ return Object.is(r, -0) ? '0' : String(r);
389
+ };
390
+
391
+ /**
392
+ * An orthogonal path through `points`, each inner corner rounded by `radius`
393
+ * (never more than half of either segment it joins).
394
+ */
395
+ export function roundedPath(points, radius = 10) {
396
+ const pts = points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
397
+ if (!pts.length) return '';
398
+ let d = `M${num(pts[0].x)} ${num(pts[0].y)}`;
399
+ for (let i = 1; i < pts.length; i += 1) {
400
+ const p = pts[i];
401
+ const prev = pts[i - 1];
402
+ const next = pts[i + 1];
403
+ if (!next) {
404
+ d += p.x === prev.x ? `V${num(p.y)}` : p.y === prev.y ? `H${num(p.x)}` : `L${num(p.x)} ${num(p.y)}`;
405
+ break;
406
+ }
407
+ const inLen = Math.hypot(p.x - prev.x, p.y - prev.y);
408
+ const outLen = Math.hypot(next.x - p.x, next.y - p.y);
409
+ const r = Math.min(radius, inLen / 2, outLen / 2);
410
+ const ux = Math.sign(p.x - prev.x);
411
+ const uy = Math.sign(p.y - prev.y);
412
+ const vx = Math.sign(next.x - p.x);
413
+ const vy = Math.sign(next.y - p.y);
414
+ const a = { x: p.x - ux * r, y: p.y - uy * r };
415
+ const b = { x: p.x + vx * r, y: p.y + vy * r };
416
+ d += a.x === prev.x ? `V${num(a.y)}` : a.y === prev.y ? `H${num(a.x)}` : `L${num(a.x)} ${num(a.y)}`;
417
+ d += `Q${num(p.x)} ${num(p.y)} ${num(b.x)} ${num(b.y)}`;
418
+ pts[i] = b; // the next segment starts where the corner ends
419
+ }
420
+ return d;
421
+ }
422
+
423
+ /**
424
+ * Put a layout on a grid: every band, card and wire in pixels.
425
+ *
426
+ * @param {ReturnType<typeof layoutLanes>} layout
427
+ * @param {'regular'|'small'|'strip'|object} [geometry] a name, or numbers over the regular ones
428
+ * @returns {{geometry, width, height,
429
+ * bands: {index, top, height, unconnected}[],
430
+ * cards: {id, lane, column, x, y, w, h}[],
431
+ * wires: {kind, from, to, toPort, connection, d, points, port: {x, y}|null, cycle?}[]}}
432
+ */
433
+ export function placeLanes(layout, geometry = 'regular') {
434
+ const g = typeof geometry === 'string'
435
+ ? (LANE_GEOMETRY[geometry] ?? LANE_GEOMETRY.regular)
436
+ : { ...LANE_GEOMETRY.regular, ...(isObject(geometry) ? geometry : {}) };
437
+ const band = g.height + 2 * g.lanePad;
438
+ const colX = (c) => g.left + c * (g.width + g.gap);
439
+ const laneTop = (l) => l * (band + g.laneGap);
440
+ const cardY = (l) => laneTop(l) + g.lanePad;
441
+ const mid = (l) => cardY(l) + g.height / 2;
442
+
443
+ const bands = layout.lanes.map((l) => ({ index: l.index, top: laneTop(l.index), height: band,
444
+ unconnected: l.unconnected }));
445
+ const cards = [];
446
+ for (const l of layout.lanes) {
447
+ for (const s of l.steps) {
448
+ cards.push({ id: s.id, lane: l.index, column: s.column, x: colX(s.column), y: cardY(l.index),
449
+ w: g.width, h: g.height });
450
+ }
451
+ }
452
+ // A straight wire may run through a card that is neither of its ends: a
453
+ // feeder lane that goes on past the card feeding a join, a join fed from
454
+ // the lane ABOVE whose own lane goes on, a fork whose lane is drawn under
455
+ // a feeder. Then the wire takes the GUTTERS instead — the gap between two
456
+ // columns and the gap between two lanes hold no card, by construction.
457
+ const crossesACard = (points, ends) => cards.some((c) => {
458
+ if (ends.includes(c.id)) return false;
459
+ for (let i = 1; i < points.length; i += 1) {
460
+ const p = points[i - 1];
461
+ const q = points[i];
462
+ const x1 = Math.min(p.x, q.x);
463
+ const x2 = Math.max(p.x, q.x);
464
+ const y1 = Math.min(p.y, q.y);
465
+ const y2 = Math.max(p.y, q.y);
466
+ if (x2 > c.x && x1 < c.x + c.w && y2 > c.y && y1 < c.y + c.h) return true;
467
+ }
468
+ return false;
469
+ });
470
+ /** The middle of the gap between lane `l` and the lane beside it, below (`+1`) or above (`-1`). */
471
+ const gapBeside = (l, side) => (side > 0 ? laneTop(l) + band + g.laneGap / 2 : laneTop(l) - g.laneGap / 2);
472
+ const wires = layout.wires.map((w) => {
473
+ const a = layout.at[w.from];
474
+ const b = layout.at[w.to];
475
+ const ends = [w.from, w.to];
476
+ let points;
477
+ let port = null;
478
+ if (w.kind === 'lane') {
479
+ points = [{ x: colX(a.column) + g.width - g.tipInset, y: mid(a.lane) },
480
+ { x: colX(b.column) + g.wireIn, y: mid(b.lane) }];
481
+ } else if (w.kind === 'fork') {
482
+ const fx = colX(a.column) + g.width / 2;
483
+ const down = b.lane > a.lane;
484
+ const fy = down ? cardY(a.lane) + g.height : cardY(a.lane);
485
+ points = [{ x: fx, y: fy }, { x: fx, y: mid(b.lane) }, { x: colX(b.column) + g.wireIn, y: mid(b.lane) }];
486
+ if (b.column > a.column && crossesACard(points, ends)) {
487
+ // Out of the forking card into the gap beside its lane, along it
488
+ // to the gutter before the new lane's first card, along that
489
+ // gutter to the new lane, and in.
490
+ const gy = gapBeside(a.lane, down ? 1 : -1);
491
+ const gx = colX(b.column) - g.gap / 2;
492
+ points = [{ x: fx, y: fy }, { x: fx, y: gy }, { x: gx, y: gy }, { x: gx, y: mid(b.lane) },
493
+ { x: colX(b.column) + g.wireIn, y: mid(b.lane) }];
494
+ }
495
+ } else {
496
+ const tx = colX(b.column) + (g.width * (w.slot + 1)) / (w.slots + 1);
497
+ const sx = colX(a.column) + g.width - g.tipInset;
498
+ if (a.lane !== b.lane && tx > sx) {
499
+ const below = a.lane > b.lane;
500
+ port = { x: tx, y: below ? cardY(b.lane) + g.height : cardY(b.lane) };
501
+ points = [{ x: sx, y: mid(a.lane) }, { x: tx, y: mid(a.lane) }, port];
502
+ if (crossesACard(points, ends)) {
503
+ // Out of the feeding card's tip into the gutter after it, along
504
+ // that gutter to the gap beside the join's lane, along the gap,
505
+ // and into the join's port.
506
+ const gx = colX(a.column) + g.width + g.gap / 2;
507
+ const gy = gapBeside(b.lane, below ? 1 : -1);
508
+ points = [{ x: sx, y: mid(a.lane) }, { x: gx, y: mid(a.lane) }, { x: gx, y: gy },
509
+ { x: tx, y: gy }, port];
510
+ }
511
+ } else {
512
+ // The same lane, or a line that runs back (a cycle): under the
513
+ // source's lane, along the gap, and up into the join's bottom.
514
+ const gy = laneTop(a.lane) + band + g.laneGap / 2;
515
+ const fx = colX(a.column) + g.width / 2;
516
+ port = { x: tx, y: b.lane > a.lane ? cardY(b.lane) : cardY(b.lane) + g.height };
517
+ points = [{ x: fx, y: cardY(a.lane) + g.height }, { x: fx, y: gy }, { x: tx, y: gy }, port];
518
+ }
519
+ }
520
+ return { ...w, points, d: roundedPath(points, g.radius), port: g.portDot ? port : null };
521
+ });
522
+ const width = layout.columns ? colX(layout.columns - 1) + g.width + g.right : 0;
523
+ const height = layout.lanes.length ? laneTop(layout.lanes.length - 1) + band : 0;
524
+ return { geometry: g, width, height, bands, cards, wires };
525
+ }