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.
- package/README.md +621 -1
- package/css/base.css +2835 -0
- package/css/flexdesk.css +2835 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- 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-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/flexdesk.css +2835 -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 +6 -4
- package/dist/widgets.js.map +1 -1
- 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/dist/chunk-24MS7W5Y.js.map +0 -7
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas kernel — edge routing, crow's-foot decoration, a loop's return
|
|
3
|
+
* line and a layered "Arrange" (36 §4.2). Every export is a pure function of
|
|
4
|
+
* points and boxes; only `crowsFoot` builds DOM (an SVG group), so the rest
|
|
5
|
+
* runs under plain node.
|
|
6
|
+
*
|
|
7
|
+
* The router is an obstacle-avoiding multi-strategy model reduced to the two
|
|
8
|
+
* strategies that hold up at a dense diagram's scale — 40 nodes averaging 12
|
|
9
|
+
* ports each, 480 ports — with the node count at which it degrades made
|
|
10
|
+
* explicit rather than discovered.
|
|
11
|
+
*
|
|
12
|
+
* Crow's-foot notation is general diagram vocabulary (an entity-relationship
|
|
13
|
+
* drawing's ends), and it moved with the rest; the cardinality decoration was
|
|
14
|
+
* written rather than lifted, because the router it descends from drew a
|
|
15
|
+
* plain path with no markers at all.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Above this, routing time becomes visible; fall back to direct curves. */
|
|
19
|
+
export const OBSTACLE_ROUTING_NODE_LIMIT = 60;
|
|
20
|
+
|
|
21
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A line between two port anchors `{x, y, side: 'left'|'right'}`, as an SVG
|
|
25
|
+
* path `d`. `simple` (or more than `OBSTACLE_ROUTING_NODE_LIMIT` obstacles)
|
|
26
|
+
* draws the direct curve and never looks at an obstacle.
|
|
27
|
+
*/
|
|
28
|
+
export function routeEdge(from, to, { obstacles = [], simple = false } = {}) {
|
|
29
|
+
// Orthogonal-ish routing with a horizontal lead-out, which is what makes a
|
|
30
|
+
// dense diagram readable: edges leave a port sideways and meet the target
|
|
31
|
+
// sideways, so parallel lines stack instead of crossing.
|
|
32
|
+
const dx = to.x - from.x;
|
|
33
|
+
const lead = Math.max(28, Math.min(Math.abs(dx) / 2, 90));
|
|
34
|
+
const outX = from.side === 'right' ? from.x + lead : from.x - lead;
|
|
35
|
+
const inX = to.side === 'right' ? to.x + lead : to.x - lead;
|
|
36
|
+
|
|
37
|
+
if (simple || obstacles.length > OBSTACLE_ROUTING_NODE_LIMIT) {
|
|
38
|
+
return `M ${from.x} ${from.y} C ${outX} ${from.y}, ${inX} ${to.y}, ${to.x} ${to.y}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Try the direct cubic first; if it crosses a node box, step the midpoint
|
|
42
|
+
// out of the way and try again. Two strategies, not five — at 480 ports the
|
|
43
|
+
// extra strategies cost more than they buy.
|
|
44
|
+
const direct = `M ${from.x} ${from.y} C ${outX} ${from.y}, ${inX} ${to.y}, ${to.x} ${to.y}`;
|
|
45
|
+
if (!crossesAny(from, to, obstacles)) return direct;
|
|
46
|
+
|
|
47
|
+
const midY = midpointClear(from, to, obstacles);
|
|
48
|
+
return `M ${from.x} ${from.y} C ${outX} ${from.y}, ${outX} ${midY}, `
|
|
49
|
+
+ `${(from.x + to.x) / 2} ${midY} S ${inX} ${to.y}, ${to.x} ${to.y}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function crossesAny(from, to, obstacles) {
|
|
53
|
+
for (const box of obstacles) {
|
|
54
|
+
if (segmentIntersectsBox(from, to, box)) return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function midpointClear(from, to, obstacles) {
|
|
60
|
+
const base = (from.y + to.y) / 2;
|
|
61
|
+
for (let offset = 0; offset < 400; offset += 40) {
|
|
62
|
+
for (const candidate of [base - offset, base + offset]) {
|
|
63
|
+
const point = { x: (from.x + to.x) / 2, y: candidate };
|
|
64
|
+
if (!obstacles.some((box) => pointInBox(point, box))) return candidate;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return base;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pointInBox(point, box) {
|
|
71
|
+
return point.x >= box.x && point.x <= box.x + box.width
|
|
72
|
+
&& point.y >= box.y && point.y <= box.y + box.height;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function segmentIntersectsBox(a, b, box) {
|
|
76
|
+
// Cheap AABB rejection first; the exact test is not worth it at this scale.
|
|
77
|
+
const left = Math.min(a.x, b.x), right = Math.max(a.x, b.x);
|
|
78
|
+
const top = Math.min(a.y, b.y), bottom = Math.max(a.y, b.y);
|
|
79
|
+
if (right < box.x || left > box.x + box.width) return false;
|
|
80
|
+
if (bottom < box.y || top > box.y + box.height) return false;
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The six crow's-foot ends, and what each one asserts.
|
|
86
|
+
*
|
|
87
|
+
* `maximum` is the symbol nearest the box; `minimum` is the one further out
|
|
88
|
+
* along the line. `null` means "do not draw one", which is the pre-cardinality
|
|
89
|
+
* behaviour and is kept only so the two legacy kinds below still render.
|
|
90
|
+
*/
|
|
91
|
+
const FOOT_ENDS = {
|
|
92
|
+
// The two a diagram draws when it does not know optionality. Kept because a
|
|
93
|
+
// caller that knows only the maximum should draw only the maximum rather
|
|
94
|
+
// than guessing at a minimum it has no evidence for.
|
|
95
|
+
many: { maximum: 'many', minimum: null },
|
|
96
|
+
one: { maximum: 'one', minimum: null },
|
|
97
|
+
// The four a diagram can actually derive from a foreign key.
|
|
98
|
+
'zero-or-many': { maximum: 'many', minimum: 'zero' },
|
|
99
|
+
'one-or-many': { maximum: 'many', minimum: 'one' },
|
|
100
|
+
'zero-or-one': { maximum: 'one', minimum: 'zero' },
|
|
101
|
+
'exactly-one': { maximum: 'one', minimum: 'one' },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* THE NOTATION'S ONE NUMBER. Everything below is a multiple of it, so a mark
|
|
106
|
+
* that has to grow grows in proportion instead of drifting apart.
|
|
107
|
+
*/
|
|
108
|
+
const FOOT_SIZE = 9;
|
|
109
|
+
/**
|
|
110
|
+
* Where each symbol lives, measured OUTWARD FROM THE BOX in multiples of
|
|
111
|
+
* `FOOT_SIZE`, and the reason the line can now be told where to stop.
|
|
112
|
+
*
|
|
113
|
+
* `MAXIMUM_ENDS_AT` is the far side of the symbol touching the box: the crow's
|
|
114
|
+
* foot's toes start ON the box and converge one unit out, so its far side is 1;
|
|
115
|
+
* the bar is a single stroke standing 0.45 out, clear of the node's own border
|
|
116
|
+
* without reading as a second border.
|
|
117
|
+
*
|
|
118
|
+
* `MINIMUM_SITS_AT`, keyed by the maximum it stands behind, is where the ring or
|
|
119
|
+
* the second bar is CENTRED — far enough out to clear the foot's apex, which is
|
|
120
|
+
* why the two entries differ.
|
|
121
|
+
*/
|
|
122
|
+
const MAXIMUM_ENDS_AT = { many: 1, one: 0.45 };
|
|
123
|
+
const MINIMUM_SITS_AT = { many: 1.5, one: 0.95 };
|
|
124
|
+
const RING_RADIUS = 0.33;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* WHERE THE LINE STARTS, which is where the mark ENDS.
|
|
128
|
+
*
|
|
129
|
+
* "The line routing should have the line connector after the cardinality
|
|
130
|
+
* indicator. Currently the line connects directly to the box and the
|
|
131
|
+
* cardinality indicator is on top." It did: `crowsFoot` draws at the box edge
|
|
132
|
+
* and `routeEdge` was handed the same point, so the connector ran the whole
|
|
133
|
+
* depth of the mark underneath it — three toes with a line through their middle
|
|
134
|
+
* read as an arrowhead somebody had drawn over, not as one symbol.
|
|
135
|
+
*
|
|
136
|
+
* The offset is the MAXIMUM's depth and deliberately not the whole end's. IE
|
|
137
|
+
* notation is read outward from the box — maximum first, then minimum — and the
|
|
138
|
+
* minimum is a mark ON the line, not before it: a ring is a bead on a string and
|
|
139
|
+
* a second bar is a tick across it. Stopping the line beyond the ring instead
|
|
140
|
+
* would leave the ring hanging off the tip with a gap behind it, which is the
|
|
141
|
+
* same defect in the other direction.
|
|
142
|
+
*
|
|
143
|
+
* Takes and returns the `{x, y, side}` shape `routeEdge` and `selfLoop` consume,
|
|
144
|
+
* so a caller offsets an endpoint by wrapping it rather than by doing this
|
|
145
|
+
* arithmetic itself — which is how a diagram and a dialog's preview of one of
|
|
146
|
+
* its lines stay in step. `side` is the direction the line LEAVES the box, exactly
|
|
147
|
+
* as `crowsFoot` means it.
|
|
148
|
+
*/
|
|
149
|
+
export function footAnchor(point, side, kind) {
|
|
150
|
+
const { maximum } = FOOT_ENDS[kind] || FOOT_ENDS.one;
|
|
151
|
+
const direction = side === 'right' ? 1 : -1;
|
|
152
|
+
return { ...point, side, x: point.x + direction * FOOT_SIZE * MAXIMUM_ENDS_AT[maximum] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Crow's-foot decoration.
|
|
157
|
+
*
|
|
158
|
+
* A foreign key is many-to-one from child to parent, so the child end gets the
|
|
159
|
+
* foot and the parent end gets the bar. Getting these the right way round is the
|
|
160
|
+
* whole information content of the notation.
|
|
161
|
+
*
|
|
162
|
+
* ── AN END IS TWO SYMBOLS, NOT ONE ──
|
|
163
|
+
*
|
|
164
|
+
* IE/Barker notation is read OUTWARD FROM THE BOX: the symbol touching the box
|
|
165
|
+
* is the *maximum* (a foot for many, a bar for one) and the symbol further along
|
|
166
|
+
* the line is the *minimum* (a ring for zero, a bar for one). "Zero or many" is
|
|
167
|
+
* therefore a foot with a ring behind it. Drawing only the maximum — which is all
|
|
168
|
+
* this function could do before — says how many children a parent may have and
|
|
169
|
+
* stays silent on whether a child must have a parent, which is the half of a
|
|
170
|
+
* schema diagram people actually argue about.
|
|
171
|
+
*
|
|
172
|
+
* The one place this stops short of the textbook is "exactly one", which the
|
|
173
|
+
* books draw as two bars and this draws as one. The argument is at the bar
|
|
174
|
+
* itself, below; the short version is that at 9px the second bar is not a second
|
|
175
|
+
* symbol, and the ring already carries the whole of the minimum.
|
|
176
|
+
*
|
|
177
|
+
* `side` is the direction the LINE LEAVES THE BOX, so both symbols are laid out
|
|
178
|
+
* away from it. Passing the opposite side draws the whole end underneath the
|
|
179
|
+
* node, which is opaque and painted above the edge layer, so it disappears
|
|
180
|
+
* without a trace.
|
|
181
|
+
*
|
|
182
|
+
* The group is `twm-edge__marker twm-edge__marker--<kind>`; its strokes take the
|
|
183
|
+
* stylesheet's `.twm-edge__marker line` and `circle` rules.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} kind one of the keys of `FOOT_ENDS`.
|
|
186
|
+
*/
|
|
187
|
+
export function crowsFoot(point, side, kind) {
|
|
188
|
+
const group = document.createElementNS(SVG, 'g');
|
|
189
|
+
group.setAttribute('class', `twm-edge__marker twm-edge__marker--${kind}`);
|
|
190
|
+
const direction = side === 'right' ? 1 : -1;
|
|
191
|
+
// Unknown kinds fall back to the bar rather than throwing: a diagram that
|
|
192
|
+
// draws one end slightly wrong is recoverable, one that throws out of
|
|
193
|
+
// `draw()` renders no edges at all.
|
|
194
|
+
const { maximum, minimum } = FOOT_ENDS[kind] || FOOT_ENDS.one;
|
|
195
|
+
|
|
196
|
+
const bar = (offset) => {
|
|
197
|
+
const line = document.createElementNS(SVG, 'line');
|
|
198
|
+
line.setAttribute('x1', point.x + direction * FOOT_SIZE * offset);
|
|
199
|
+
line.setAttribute('y1', point.y - FOOT_SIZE * 0.7);
|
|
200
|
+
line.setAttribute('x2', point.x + direction * FOOT_SIZE * offset);
|
|
201
|
+
line.setAttribute('y2', point.y + FOOT_SIZE * 0.7);
|
|
202
|
+
group.appendChild(line);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (maximum === 'many') {
|
|
206
|
+
// The toes touch the box and the three lines converge on the connector,
|
|
207
|
+
// which is the way round every ERD tool draws it — and the way round
|
|
208
|
+
// that leaves the minimum symbol room to sit behind the foot rather
|
|
209
|
+
// than floating in the middle of the line belonging to nothing.
|
|
210
|
+
for (const dy of [-FOOT_SIZE * 0.8, 0, FOOT_SIZE * 0.8]) {
|
|
211
|
+
const line = document.createElementNS(SVG, 'line');
|
|
212
|
+
line.setAttribute('x1', point.x + direction * FOOT_SIZE * MAXIMUM_ENDS_AT.many);
|
|
213
|
+
line.setAttribute('y1', point.y);
|
|
214
|
+
line.setAttribute('x2', point.x);
|
|
215
|
+
line.setAttribute('y2', point.y + dy);
|
|
216
|
+
group.appendChild(line);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
bar(MAXIMUM_ENDS_AT.one);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (minimum === 'zero') {
|
|
223
|
+
const ring = document.createElementNS(SVG, 'circle');
|
|
224
|
+
ring.setAttribute('cx', point.x + direction * FOOT_SIZE * MINIMUM_SITS_AT[maximum]);
|
|
225
|
+
ring.setAttribute('cy', point.y);
|
|
226
|
+
ring.setAttribute('r', FOOT_SIZE * RING_RADIUS);
|
|
227
|
+
group.appendChild(ring);
|
|
228
|
+
} else if (minimum === 'one' && maximum === 'many') {
|
|
229
|
+
// ── "TO ONE" IS ONE BAR, NOT TWO ──
|
|
230
|
+
//
|
|
231
|
+
// Textbook IE draws mandatory-one as two bars, the outer one asserting
|
|
232
|
+
// the minimum. At this size it cannot: the two strokes land 4px apart,
|
|
233
|
+
// which at 9px and 1.4px of stroke is not a pair of bars but a thick
|
|
234
|
+
// one — and a thick bar is what "many" reads as when the foot is small.
|
|
235
|
+
// The report was "on a `to one` cardinality I would expect a single
|
|
236
|
+
// vertical bar, not two", and that is the eye reading the drawing
|
|
237
|
+
// correctly.
|
|
238
|
+
//
|
|
239
|
+
// OF THE TWO RULES THE SECOND BAR WAS EXPRESSING, THE MAXIMUM WINS. It
|
|
240
|
+
// is the symbol nearest the box, it is the one the notation is read
|
|
241
|
+
// from, and it is the one that cannot be inferred from anything else.
|
|
242
|
+
// The minimum is not lost with it: optionality is carried by the RING,
|
|
243
|
+
// whose presence means zero and whose absence means one, so `exactly-one`
|
|
244
|
+
// is a bar with nothing behind it and `zero-or-one` is a bar with a ring
|
|
245
|
+
// on the line behind it. Every one of the six ends stays distinguishable
|
|
246
|
+
// and each is now one legible mark.
|
|
247
|
+
//
|
|
248
|
+
// `one-or-many` keeps its bar, because there it stands behind a FOOT and
|
|
249
|
+
// the two shapes cannot be confused for one another.
|
|
250
|
+
bar(MINIMUM_SITS_AT[maximum]);
|
|
251
|
+
}
|
|
252
|
+
return group;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The two ends a foreign key implies, named in the vocabulary `crowsFoot` takes.
|
|
257
|
+
*
|
|
258
|
+
* ONE PLACE, because a diagram and any preview of one of its lines have to
|
|
259
|
+
* agree: a preview drawn with this function and `crowsFoot` cannot disagree
|
|
260
|
+
* with the canvas — it is not expressible rather than merely unlikely.
|
|
261
|
+
*
|
|
262
|
+
* Both facts come off the physical constraint and nothing else, which is why
|
|
263
|
+
* only these two are derivable:
|
|
264
|
+
*
|
|
265
|
+
* * **the parent end** — how many parents one child row has — is settled by the
|
|
266
|
+
* child column's NULLABILITY. `NOT NULL` is exactly one; nullable is zero or
|
|
267
|
+
* one. The caller passes it as `optional`.
|
|
268
|
+
* * **the child end** — how many children one parent row has — is settled by
|
|
269
|
+
* the child column's UNIQUENESS. A single-column `UNIQUE` makes it zero or
|
|
270
|
+
* one (`kind: '1:1'`); without it, zero or many (`kind: '1:N'`).
|
|
271
|
+
*
|
|
272
|
+
* "One or many" is deliberately never returned. No foreign key can require a
|
|
273
|
+
* parent to have at least one child — that is a business rule living in a
|
|
274
|
+
* trigger or nowhere — and inventing it on a schema diagram would be worse than
|
|
275
|
+
* saying less.
|
|
276
|
+
*
|
|
277
|
+
* @param {{kind?: string, optional?: boolean}} edge
|
|
278
|
+
* @returns {{child: string, parent: string}}
|
|
279
|
+
*/
|
|
280
|
+
export function cardinality({ kind, optional } = {}) {
|
|
281
|
+
// `N:M` is a junction drawn as ONE line between its two ends rather than as
|
|
282
|
+
// a three-box picture. Many at both ends is what that line means; the
|
|
283
|
+
// parent end's `optional` describes a column the junction has not got yet.
|
|
284
|
+
if (kind === 'N:M') return { child: 'zero-or-many', parent: 'zero-or-many' };
|
|
285
|
+
return {
|
|
286
|
+
child: kind === '1:1' ? 'zero-or-one' : 'zero-or-many',
|
|
287
|
+
parent: optional ? 'zero-or-one' : 'exactly-one',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* A SELF-REFERENCING edge: `employee.manager_id → employee`.
|
|
293
|
+
*
|
|
294
|
+
* A first-class thing a user creates on day one — a manager, a parent category,
|
|
295
|
+
* a predecessor task. `routeEdge` cannot draw it: source and target are on the
|
|
296
|
+
* same node, so a cubic between them is a line of zero visible length hidden
|
|
297
|
+
* behind the node body.
|
|
298
|
+
*
|
|
299
|
+
* The shape is a loop out of the right edge and back into it, bulging by an
|
|
300
|
+
* amount that grows with the vertical distance between the two ports so a node
|
|
301
|
+
* with several self-references does not stack them on top of each other.
|
|
302
|
+
*/
|
|
303
|
+
export function selfLoop(from, to, { nodeRight, gap = 46 } = {}) {
|
|
304
|
+
const right = Math.max(nodeRight ?? from.x, from.x, to.x);
|
|
305
|
+
const span = Math.abs(to.y - from.y);
|
|
306
|
+
const bulge = right + gap + Math.min(span / 2, 60);
|
|
307
|
+
return `M ${from.x} ${from.y} C ${bulge} ${from.y}, ${bulge} ${to.y}, ${to.x} ${to.y}`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A RETURN edge: a loop's body handing control back to one of the loop step's
|
|
312
|
+
* own inputs, for its next pass.
|
|
313
|
+
*
|
|
314
|
+
* The body sits RIGHT of its loop and that input is on the loop's LEFT edge, so
|
|
315
|
+
* `routeEdge` draws the line doubling back across both nodes, where it reads
|
|
316
|
+
* as one more forward step. This one leaves the body step rightwards, drops to
|
|
317
|
+
* `floor` (the caller passes a line below both nodes), runs back left under
|
|
318
|
+
* them, and rises into the port from its left side — the shape a reader
|
|
319
|
+
* recognises as "and round again". It is never higher than the lower of the
|
|
320
|
+
* two ends, whatever `floor` is.
|
|
321
|
+
*/
|
|
322
|
+
export function returnEdge(from, to, { floor = null, lead = 36 } = {}) {
|
|
323
|
+
const bottom = Math.max(Number.isFinite(floor) ? floor : -Infinity, from.y, to.y);
|
|
324
|
+
const outX = from.x + lead;
|
|
325
|
+
const inX = to.x - lead;
|
|
326
|
+
return `M ${from.x} ${from.y} C ${outX} ${from.y}, ${outX} ${bottom}, ${from.x} ${bottom} `
|
|
327
|
+
+ `L ${to.x} ${bottom} C ${inX} ${bottom}, ${inX} ${to.y}, ${to.x} ${to.y}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Dagre-free layered auto-layout — "Arrange".
|
|
332
|
+
*
|
|
333
|
+
* Rank by dependency depth, then pack within a rank. `nodes` are
|
|
334
|
+
* `{id, label, height?}` and `edges` `{from, to}`; returns a Map of id →
|
|
335
|
+
* `{x, y}` from (0, 0).
|
|
336
|
+
*
|
|
337
|
+
* ── ITS TWO QUIRKS, KEPT AND STATED, because callers rely on both ──
|
|
338
|
+
*
|
|
339
|
+
* * **AN EDGE POINTS FROM CHILD TO PARENT.** A node with no outgoing edge is
|
|
340
|
+
* at rank 0, and each node sits one rank RIGHT of its deepest `to` — which
|
|
341
|
+
* lays a schema out the way people draw one, a foreign key pointing
|
|
342
|
+
* leftwards at what it references. A flow, whose lines point at what runs
|
|
343
|
+
* NEXT, hands its connections over REVERSED (`{from: target, to: source}`).
|
|
344
|
+
* * **EVERY NODE NEEDS A `label`.** Each column is sorted with
|
|
345
|
+
* `a.label.localeCompare(b.label)`, which throws on a missing one. A caller
|
|
346
|
+
* that wants another order within a column passes an order key as the label
|
|
347
|
+
* (the canvas editor passes a zero-padded breadth-first index).
|
|
348
|
+
*
|
|
349
|
+
* A cycle is broken (its back edge read as rank 0) rather than hung on.
|
|
350
|
+
*/
|
|
351
|
+
export function arrange(nodes, edges, { columnGap = 340, rowGap = 40 } = {}) {
|
|
352
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
353
|
+
const parents = new Map(nodes.map((n) => [n.id, []]));
|
|
354
|
+
for (const edge of edges) {
|
|
355
|
+
if (byId.has(edge.from) && byId.has(edge.to) && edge.from !== edge.to) {
|
|
356
|
+
parents.get(edge.from).push(edge.to);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const rank = new Map();
|
|
361
|
+
const visiting = new Set();
|
|
362
|
+
const rankOf = (id) => {
|
|
363
|
+
if (rank.has(id)) return rank.get(id);
|
|
364
|
+
if (visiting.has(id)) return 0; // a cycle: break it rather than hang
|
|
365
|
+
visiting.add(id);
|
|
366
|
+
const value = parents.get(id).length
|
|
367
|
+
? Math.max(...parents.get(id).map(rankOf)) + 1
|
|
368
|
+
: 0;
|
|
369
|
+
visiting.delete(id);
|
|
370
|
+
rank.set(id, value);
|
|
371
|
+
return value;
|
|
372
|
+
};
|
|
373
|
+
for (const node of nodes) rankOf(node.id);
|
|
374
|
+
|
|
375
|
+
const columns = new Map();
|
|
376
|
+
for (const node of nodes) {
|
|
377
|
+
const column = rank.get(node.id) ?? 0;
|
|
378
|
+
if (!columns.has(column)) columns.set(column, []);
|
|
379
|
+
columns.get(column).push(node);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const positions = new Map();
|
|
383
|
+
for (const [column, members] of [...columns].sort((a, b) => a[0] - b[0])) {
|
|
384
|
+
members.sort((a, b) => a.label.localeCompare(b.label));
|
|
385
|
+
let y = 0;
|
|
386
|
+
for (const node of members) {
|
|
387
|
+
positions.set(node.id, { x: column * columnGap, y });
|
|
388
|
+
y += (node.height || 120) + rowGap;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return positions;
|
|
392
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canvas kernel — GRAPH LAYER: the edge model and its legality guard
|
|
3
|
+
* (36 §4.3).
|
|
4
|
+
*
|
|
5
|
+
* THE POINT OF THIS FILE IS THAT A DROP IS REFUSED — OR BETTER, NEGOTIATED —
|
|
6
|
+
* BEFORE ANY NETWORK CALL. A gesture that produces a round trip and then a
|
|
7
|
+
* server error reads as a broken editor; the same gesture producing an
|
|
8
|
+
* immediate reason reads as an editor that knows the rules.
|
|
9
|
+
*
|
|
10
|
+
* GENERIC. `canConnect` makes the refusals every graph shares, in this order:
|
|
11
|
+
*
|
|
12
|
+
* 1. an end is gone — a node the platform no longer has
|
|
13
|
+
* 2. a port is gone — a port its node no longer has
|
|
14
|
+
* 3. a disabled port — `port.disabled`, refused with `port.disabledReason`
|
|
15
|
+
* when it carries one (by default at either end;
|
|
16
|
+
* `disabledEnds: ['source']` asks only of the source)
|
|
17
|
+
* 4. a self-reference — unless `selfReference: true`
|
|
18
|
+
* 5. capacity — a port with `maxConnections` (or `allowMultiple:
|
|
19
|
+
* false`, which is one) that already carries that many
|
|
20
|
+
*
|
|
21
|
+
* and THEN asks the embedder's `policy(ctx)`, which is where a domain's own rules
|
|
22
|
+
* live — and its sentences. A policy returns a verdict, or nothing for "allowed".
|
|
23
|
+
*
|
|
24
|
+
* SELF-REFERENCES ARE OPT-IN PER ROUTER rather than blanket-refused:
|
|
25
|
+
* `employee.manager_id → employee` is the most common recursive relationship
|
|
26
|
+
* in business schemas, and a diagram of them needs it on; a graph that must be
|
|
27
|
+
* a DAG leaves it off. There is no cycle detection here at all — mutual
|
|
28
|
+
* references are legal and common, and a graph that needs a DAG says so in
|
|
29
|
+
* its policy.
|
|
30
|
+
*
|
|
31
|
+
* A verdict is `{ok: true, selfReference, …}` or `{ok: false, reason}`. A policy
|
|
32
|
+
* may answer `{ok: true, offer: {…}}`: a drop that is legal once something
|
|
33
|
+
* else changes too is an OFFER, not a failure.
|
|
34
|
+
*
|
|
35
|
+
* An edge is the embedder's object with at least `{id, from, to}`; which PORT
|
|
36
|
+
* each end uses is read through `endsOf(edge) → {from, to}` (port ids, or null
|
|
37
|
+
* for an end that names none), by default `edge.fromPort` and `edge.toPort`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export const CONNECTOR_STRINGS = Object.freeze({
|
|
41
|
+
nodeGone: 'That node is no longer on the canvas.',
|
|
42
|
+
portGone: 'That port is no longer on the canvas.',
|
|
43
|
+
portDisabled: 'That port cannot be connected.',
|
|
44
|
+
selfReference: 'A node cannot connect to itself on this canvas.',
|
|
45
|
+
portFull: (max) => (max === 1 ? 'That port already carries its one connection.'
|
|
46
|
+
: `That port already carries its ${max} connections.`),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const say = (strings, key, ...args) => {
|
|
50
|
+
const s = strings[key];
|
|
51
|
+
return typeof s === 'function' ? s(...args) : String(s);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const defaultEndsOf = (edge) => ({ from: edge?.fromPort ?? null, to: edge?.toPort ?? null });
|
|
55
|
+
|
|
56
|
+
/** The most connections a port may carry: `maxConnections`, else one when it allows no more. */
|
|
57
|
+
function capacityOf(port) {
|
|
58
|
+
if (Number.isFinite(port?.maxConnections)) return port.maxConnections;
|
|
59
|
+
if (port?.allowMultiple === false) return 1;
|
|
60
|
+
return Infinity;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ConnectorRouter {
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} o
|
|
66
|
+
* @param {object} o.platform a `NodePlatform`
|
|
67
|
+
* @param {boolean} [o.selfReference] may a node connect to itself
|
|
68
|
+
* @param {(ctx: object) => object|null|undefined} [o.policy] the embedder's rules, asked last
|
|
69
|
+
* @param {(edge: object) => {from, to}} [o.endsOf] the port ids an edge uses at each end
|
|
70
|
+
* @param {('source'|'target')[]} [o.disabledEnds] which ends a disabled port refuses
|
|
71
|
+
* @param {object} [o.strings] the generic refusals' words
|
|
72
|
+
*/
|
|
73
|
+
constructor({ platform, selfReference = false, policy = null, endsOf = null,
|
|
74
|
+
disabledEnds = ['source', 'target'], strings = null } = {}) {
|
|
75
|
+
this.platform = platform;
|
|
76
|
+
this.selfReference = selfReference;
|
|
77
|
+
this.policy = typeof policy === 'function' ? policy : null;
|
|
78
|
+
this.endsOf = typeof endsOf === 'function' ? endsOf : defaultEndsOf;
|
|
79
|
+
this.disabledEnds = new Set(disabledEnds);
|
|
80
|
+
this.strings = Object.freeze({ ...CONNECTOR_STRINGS, ...(strings || {}) });
|
|
81
|
+
/** @type {Map<string, object>} edge id -> edge */
|
|
82
|
+
this.edges = new Map();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
hydrate(edges) {
|
|
86
|
+
this.edges.clear();
|
|
87
|
+
for (const edge of edges) this.edges.set(edge.id, edge);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Every edge already touching this node, in either direction. */
|
|
91
|
+
edgesFor(nodeId) {
|
|
92
|
+
return [...this.edges.values()].filter((e) => e.from === nodeId || e.to === nodeId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How many edges (applied and `pending`) already use this port at this node. */
|
|
96
|
+
connectionsAt(nodeId, portId, { pendingEdges = [] } = {}) {
|
|
97
|
+
let n = 0;
|
|
98
|
+
for (const edge of [...this.edges.values(), ...pendingEdges]) {
|
|
99
|
+
const ends = this.endsOf(edge) || {};
|
|
100
|
+
if (edge.from === nodeId && ends.from === portId) n += 1;
|
|
101
|
+
if (edge.to === nodeId && ends.to === portId) n += 1;
|
|
102
|
+
}
|
|
103
|
+
return n;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Can this drop become an edge?
|
|
108
|
+
*
|
|
109
|
+
* `source` and `target` are `{nodeId, portId}`. The answer is a verdict the
|
|
110
|
+
* caller can render directly — the reason strings ARE the notice text, so a
|
|
111
|
+
* policy should name the objects rather than describe the rule.
|
|
112
|
+
*/
|
|
113
|
+
canConnect(source, target, { pendingEdges = [] } = {}) {
|
|
114
|
+
const S = this.strings;
|
|
115
|
+
const fromNode = this.platform.get(source.nodeId);
|
|
116
|
+
const toNode = this.platform.get(target.nodeId);
|
|
117
|
+
if (!fromNode || !toNode) return { ok: false, reason: say(S, 'nodeGone') };
|
|
118
|
+
const fromPort = this.platform.port(source.nodeId, source.portId);
|
|
119
|
+
const toPort = this.platform.port(target.nodeId, target.portId);
|
|
120
|
+
if (!fromPort || !toPort) return { ok: false, reason: say(S, 'portGone') };
|
|
121
|
+
if (fromPort.disabled && this.disabledEnds.has('source')) {
|
|
122
|
+
return { ok: false, reason: fromPort.disabledReason || say(S, 'portDisabled') };
|
|
123
|
+
}
|
|
124
|
+
if (toPort.disabled && this.disabledEnds.has('target')) {
|
|
125
|
+
return { ok: false, reason: toPort.disabledReason || say(S, 'portDisabled') };
|
|
126
|
+
}
|
|
127
|
+
const selfReference = fromNode.id === toNode.id;
|
|
128
|
+
if (selfReference && !this.selfReference) return { ok: false, reason: say(S, 'selfReference') };
|
|
129
|
+
for (const [node, port] of [[fromNode, fromPort], [toNode, toPort]]) {
|
|
130
|
+
const max = capacityOf(port);
|
|
131
|
+
if (max !== Infinity && this.connectionsAt(node.id, port.id, { pendingEdges }) >= max) {
|
|
132
|
+
return { ok: false, reason: say(S, 'portFull', max) };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (this.policy) {
|
|
136
|
+
const verdict = this.policy({
|
|
137
|
+
source, target, fromNode, toNode, fromPort, toPort, pendingEdges, selfReference,
|
|
138
|
+
edges: [...this.edges.values()], router: this,
|
|
139
|
+
});
|
|
140
|
+
if (verdict && typeof verdict === 'object') return verdict;
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, selfReference };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Record a STAGED edge. Nothing here talks to a server — the embedder turns
|
|
147
|
+
* a staged edge into whatever its own write is, and only that makes it real.
|
|
148
|
+
*/
|
|
149
|
+
stage(edge) {
|
|
150
|
+
this.edges.set(edge.id, edge);
|
|
151
|
+
return edge;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
unstage(id) {
|
|
155
|
+
const edge = this.edges.get(id);
|
|
156
|
+
this.edges.delete(id);
|
|
157
|
+
return edge || null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Re-map edges when a node's port set is rebuilt.
|
|
162
|
+
*
|
|
163
|
+
* A node gains or loses a port constantly in a live diagram. Matches by port
|
|
164
|
+
* id, drops what cannot be mapped, and reports both — a silently dropped
|
|
165
|
+
* edge is a line that vanished from the drawing while what it stands for
|
|
166
|
+
* still exists. `endsOf` (else the router's own) names the ports an edge
|
|
167
|
+
* uses; an end that names none is not checked.
|
|
168
|
+
*/
|
|
169
|
+
remapNodeConnections(nodeId, portIds, { endsOf = null } = {}) {
|
|
170
|
+
const ends = typeof endsOf === 'function' ? endsOf : this.endsOf;
|
|
171
|
+
const available = new Set(portIds);
|
|
172
|
+
let remapped = 0;
|
|
173
|
+
const removed = [];
|
|
174
|
+
for (const [id, edge] of [...this.edges]) {
|
|
175
|
+
const at = ends(edge) || {};
|
|
176
|
+
const touches = [];
|
|
177
|
+
if (edge.from === nodeId && at.from !== null && at.from !== undefined) touches.push(at.from);
|
|
178
|
+
if (edge.to === nodeId && at.to !== null && at.to !== undefined) touches.push(at.to);
|
|
179
|
+
if (edge.from !== nodeId && edge.to !== nodeId) continue;
|
|
180
|
+
if (touches.every((portId) => available.has(portId))) {
|
|
181
|
+
remapped += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
this.edges.delete(id);
|
|
185
|
+
removed.push(edge);
|
|
186
|
+
}
|
|
187
|
+
return { remapped, removed };
|
|
188
|
+
}
|
|
189
|
+
}
|