@weasel-js/diagram 1.10.1 → 1.11.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 +1160 -26
- package/dist/chunk-43LDZ6L4.js +2053 -0
- package/dist/chunk-43LDZ6L4.js.map +1 -0
- package/dist/chunk-CI3I3I6T.js +3600 -0
- package/dist/chunk-CI3I3I6T.js.map +1 -0
- package/dist/chunk-IM6DHWZO.js +3220 -0
- package/dist/chunk-IM6DHWZO.js.map +1 -0
- package/dist/chunk-QKSWZEJH.js +312 -0
- package/dist/chunk-QKSWZEJH.js.map +1 -0
- package/dist/chunk-T6DQT553.js +1431 -0
- package/dist/chunk-T6DQT553.js.map +1 -0
- package/dist/chunk-WDIE35AK.js +3 -0
- package/dist/chunk-WDIE35AK.js.map +1 -0
- package/dist/dataTypes-B-iEwKZp.d.ts +553 -0
- package/dist/effect-audio.d.ts +138 -0
- package/dist/effect-audio.js +527 -0
- package/dist/effect-audio.js.map +1 -0
- package/dist/effect.d.ts +782 -0
- package/dist/effect.js +2542 -0
- package/dist/effect.js.map +1 -0
- package/dist/icons.table.d.ts +6 -0
- package/dist/icons.table.js +32 -0
- package/dist/icons.table.js.map +1 -0
- package/dist/icons.table.json +24 -0
- package/dist/index.d.ts +2270 -355
- package/dist/index.js +953 -1331
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +3 -2
- package/dist/layout.entry-CH9gc0CY.d.ts +512 -0
- package/dist/layout.js +3 -1
- package/dist/stageTypes-DvnR8cKQ.d.ts +189 -0
- package/dist/{layout.entry-BUkiRKuX.d.ts → types-BbHunLXm.d.ts} +382 -261
- package/package.json +32 -7
- package/dist/chunk-JZ4CVLAB.js +0 -1247
- package/dist/chunk-JZ4CVLAB.js.map +0 -1
package/README.md
CHANGED
|
@@ -49,6 +49,11 @@ const { specs } = buildBody<Data, 'main', Pose>(
|
|
|
49
49
|
The rows measure a floor and the authored pose is grown to clear it, never
|
|
50
50
|
shrunk — so resize, align, distribute, snapping and undo need no special case.
|
|
51
51
|
|
|
52
|
+
A perimeter port can carry a `label`, or be named in `portLabels` (on a
|
|
53
|
+
`BodySpec` or a `DataNode`) by port id, which labels the default compass ports
|
|
54
|
+
without redeclaring them. The label is drawn outside the node beside the port's
|
|
55
|
+
wire; layout lays the node out with room for it, and a group's box takes it in.
|
|
56
|
+
|
|
52
57
|
### Making ports grabbable
|
|
53
58
|
|
|
54
59
|
`diagramPorts` returns the two halves of the connect gesture. Attach the layer
|
|
@@ -70,12 +75,452 @@ Dragging one port onto another authors an edge. Which pairs may be joined is
|
|
|
70
75
|
`canConnect`, which defaults to "a port may not join itself, and two ports that
|
|
71
76
|
both declare a `type` must declare the same one".
|
|
72
77
|
|
|
78
|
+
A port may name other places it can sit, in `sites`. An edge meeting it takes
|
|
79
|
+
whichever place faces the edge's next point — its first waypoint, or the other
|
|
80
|
+
node — so a decision's second exit can leave by its top or its bottom,
|
|
81
|
+
wherever its wire goes. `portSitesOf` resolves every place; anything else that
|
|
82
|
+
shows a port, the connect affordance included, shows it at `at`.
|
|
83
|
+
|
|
84
|
+
### Connecting to a shape's body
|
|
85
|
+
|
|
86
|
+
An edge may end on a shape rather than on one of its ports. `DiagramView` takes
|
|
87
|
+
it with `connectBodies`; on a bare canvas, set `bodies` on the connect action
|
|
88
|
+
and add the connector tool, which starts an edge from whatever shape the press
|
|
89
|
+
lands on.
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
<DiagramView specs={specs} onConnect={add} connectBodies />
|
|
93
|
+
|
|
94
|
+
const participants = useMemo(() => sceneParticipants(scene), [scene]);
|
|
95
|
+
const connector = useConnectorTool({ participants });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
An end is a `ConnectEnd`: a port, or a body (`isBodyEnd` tells them apart). A
|
|
99
|
+
data edge ended on a body has no `fromPort` or `toPort`.
|
|
100
|
+
|
|
101
|
+
### Reconnecting an edge end
|
|
102
|
+
|
|
103
|
+
Set `onReconnect` and a selected edge shows a handle at each end. Dragging one
|
|
104
|
+
onto another port, or with `connectBodies` onto a shape, reports the move and
|
|
105
|
+
changes nothing; update the data edge to keep it.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
<DiagramView
|
|
109
|
+
specs={specs}
|
|
110
|
+
onReconnect={({ edge, end, node, port }) => setEdges((all) => all.map((e) => edgeIdOf(e) !== edge ? e
|
|
111
|
+
: end === 'from' ? { ...e, from: node, fromPort: port } : { ...e, to: node, toPort: port }))}
|
|
112
|
+
connectBodies
|
|
113
|
+
/>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`edge` is `edgeIdOf` the data edge, so give edges an `id` when you want it to
|
|
117
|
+
survive a reorder.
|
|
118
|
+
|
|
119
|
+
### Editing a route
|
|
120
|
+
|
|
121
|
+
A selected edge shows handles along its route as well as at its ends.
|
|
122
|
+
|
|
123
|
+
| Router | Handles | Drag | Double-click |
|
|
124
|
+
|---|---|---|---|
|
|
125
|
+
| `straight`, `bezier` | a dot on each waypoint; a smaller hollow dot in the middle of each leg | a dot moves its waypoint; a hollow dot inserts one there | a dot removes its waypoint |
|
|
126
|
+
| `orthogonal` | a bar in the middle of each segment | slides the segment across its axis; a segment touching a port gets a jog, so the port end stays square | straightens a jog away, when a neighbor touching no port can slide into line |
|
|
127
|
+
| `avoid` | a bar in the middle of each segment | slides the segment and pins only it: its two ends become waypoints, and the router keeps the rest | unpins it, dropping the waypoints at its ends |
|
|
128
|
+
|
|
129
|
+
A dragged handle snaps through the canvas's snap session: into line with the
|
|
130
|
+
stops beside it and with a port's axis within 6 screen pixels (`snapPx`), and to
|
|
131
|
+
whatever else the canvas snaps to, such as a grid, with the readout drawing each
|
|
132
|
+
alignment. It leaves out the canvas's `'path-snap'` default, so a bend never
|
|
133
|
+
catches a shape's corner anchors, and the edge being edited offers nothing to
|
|
134
|
+
itself. A neighbor or port within reach claims its axis, and a grid fills the
|
|
135
|
+
other. Ctrl suspends snapping, Shift holds a dragged bend to the axes and
|
|
136
|
+
diagonals, and a segment always slides across its own axis. Escape cancels.
|
|
137
|
+
A double-click that would remove nothing falls through, to label editing say.
|
|
138
|
+
|
|
139
|
+
`DiagramView` takes `onEditRoute`, which reports the edge's new waypoints and
|
|
140
|
+
changes nothing: store them as the data edge's `waypoints` and clear its
|
|
141
|
+
`routed`. On a bare canvas, `diagramWaypoints` returns the handle layer to
|
|
142
|
+
register — after the port layer and before the edge-end layer — and the
|
|
143
|
+
contribution to pass as `ambient`, which also carries the `diagram.resetRoute`
|
|
144
|
+
command that clears the selected edges' waypoints. The default commit writes
|
|
145
|
+
the scene in one undo entry, and the route is the author's from then on, so
|
|
146
|
+
layout leaves it alone.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<DiagramView
|
|
150
|
+
specs={specs}
|
|
151
|
+
onEditRoute={({ edge, waypoints }) => setEdges((all) => all.map((e) => edgeIdOf(e) !== edge ? e
|
|
152
|
+
: { ...e, waypoints, routed: undefined }))}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
const route = useMemo(() => diagramWaypoints({ participants, selected }), [participants, selected]);
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
An orthogonal segment drag writes every bend of the new route as a waypoint,
|
|
159
|
+
since the router recomputes elbows between stops. The math is exported on its
|
|
160
|
+
own: `routeHandles`, `dragVertex`, `insertWaypoint`, `removeWaypoint`,
|
|
161
|
+
`dragSegment`, `removeSegment`, `pinSegment`, `unpinSegment`, and `simplifyStops`.
|
|
162
|
+
|
|
163
|
+
Waypoints ride along with a move that carries both of an edge's ends by the
|
|
164
|
+
same offset: `waypointFollower(scene)` is a transform follower, added to the
|
|
165
|
+
canvas with core's `useTransformFollower`. An edge with one end moved keeps its
|
|
166
|
+
waypoints where they are.
|
|
167
|
+
|
|
168
|
+
### Routing around shapes
|
|
169
|
+
|
|
170
|
+
The `avoid` router draws connectors around shapes: orthogonal, with few bends,
|
|
171
|
+
10 world units clear of every shape, and parallel runs spread 8 apart. Routes
|
|
172
|
+
are computed when the scene is edited and stored on the edge as
|
|
173
|
+
`diagram.route`, the bends between its ends, so a saved file draws them with no
|
|
174
|
+
router running and undo restores them exactly. A stored route that no longer
|
|
175
|
+
fits its ends, after something that did not reroute moved one, draws as
|
|
176
|
+
`orthogonal` instead.
|
|
177
|
+
|
|
178
|
+
`useAvoidRouting(scene)`, mounted as a child of `<SceneCanvas>`, reroutes what
|
|
179
|
+
each edit disturbed — a move, an insert onto a route, a delete, a paste, a
|
|
180
|
+
panel edit — in that edit's own undo entry, and while a shape is dragged it
|
|
181
|
+
reroutes the connectors near it each frame, through the waypoints a
|
|
182
|
+
`waypointFollower` registered before it carried. Installing it reroutes nothing.
|
|
183
|
+
`installAvoidRouting(scene)` does the same for a host without React, and hands
|
|
184
|
+
back the drag's transform follower. Its scene edit pass runs at
|
|
185
|
+
`DIAGRAM_PASS_ORDER.avoid`; a pass of your own that should see the routes it
|
|
186
|
+
wrote takes a higher `order`.
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
function Routing({ scene }) {
|
|
190
|
+
useAvoidRouting(scene);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
<SceneCanvas scene={scene} /* … */>
|
|
195
|
+
<Routing scene={scene} />
|
|
196
|
+
</SceneCanvas>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
What a connector routes around:
|
|
200
|
+
|
|
201
|
+
| Thing | In the way when |
|
|
202
|
+
|---|---|
|
|
203
|
+
| A shape | always, beyond the stubs where a connector leaves or meets it |
|
|
204
|
+
| A container, pool, or group box | neither end is inside it |
|
|
205
|
+
| A lane's body | never |
|
|
206
|
+
| A lane's or pool's header | always |
|
|
207
|
+
| An edge label | always, except to its own connector, 4 world units clear |
|
|
208
|
+
|
|
209
|
+
Authored waypoints are stops the route passes in order. Layout names an
|
|
210
|
+
`avoid` connector's ports and writes it no waypoints. `diagram.router.avoid`,
|
|
211
|
+
`.orthogonal`, `.straight`, and `.bezier` (`SET_ROUTER_ACTION_IDS`, carried by
|
|
212
|
+
`diagramWaypoints`) set the selected connectors' router; leaving `avoid` drops
|
|
213
|
+
the stored route.
|
|
214
|
+
|
|
215
|
+
A data-backed `DiagramView` takes `router="avoid"`: it routes every edge of
|
|
216
|
+
each new `specs` around the shapes, holding one router across them so each
|
|
217
|
+
reroutes only what changed, and never reports a route. `createSpecRouter` is
|
|
218
|
+
that step on its own. The search itself, `createAvoidRouter`, reads plain boxes
|
|
219
|
+
and ports and is in `@weasel-js/diagram/layout`.
|
|
220
|
+
|
|
221
|
+
### Line jumps
|
|
222
|
+
|
|
223
|
+
While the document value `HOPS_DOC_KEY` (`'diagram:hops'`) holds `{ size }`,
|
|
224
|
+
wherever two connectors cross, the one later in render order bows over the other
|
|
225
|
+
in a half circle of radius `size`. Hops are on or off for the whole document.
|
|
226
|
+
A hop bulges toward −y on a horizontal run and −x on a vertical one, whichever
|
|
227
|
+
way the connector travels, and to the left of travel on a diagonal. A crossing
|
|
228
|
+
within `2 × size` of either connector's ends or within `size` of a bend makes no
|
|
229
|
+
hop, nor does a collinear overlap; hops closer than `2 × size` merge into one
|
|
230
|
+
longer hop. A connector split across pages takes no part: its stubs are too
|
|
231
|
+
short to hold a hop clear of their ends, and the run between them is not drawn.
|
|
232
|
+
|
|
233
|
+
Each connector stores its hops as `diagram.hops`, `{ at, over, r }` in world
|
|
234
|
+
units, so a saved file draws them with no pass running. `edgeDerivePath` draws
|
|
235
|
+
only the hops that still lie on the route, so one left behind by an edit that
|
|
236
|
+
did not recompute never draws.
|
|
237
|
+
|
|
238
|
+
`useHops(scene)`, mounted as a child of `<SceneCanvas>` after `useAvoidRouting`,
|
|
239
|
+
recomputes the hops each edit disturbed in that edit's own undo entry, and
|
|
240
|
+
setting or clearing the value recomputes or clears every connector in the entry
|
|
241
|
+
that sets it. While a drag is in progress, the connectors it moves and those
|
|
242
|
+
hopping over them draw without hops; the release puts them back.
|
|
243
|
+
`installHops(scene)` does the same without React. Its pass runs at
|
|
244
|
+
`DIAGRAM_PASS_ORDER.hops`, after routing.
|
|
245
|
+
|
|
246
|
+
`diagram.hops` (`SET_HOPS_ACTION_ID`, carried by `diagramWaypoints`) toggles the
|
|
247
|
+
value, at radius 5 (`DEFAULT_HOP_SIZE`). A data-backed `DiagramView` takes
|
|
248
|
+
`hops={radius}`, applied after `router`; `createSpecHops` is that step on its
|
|
249
|
+
own. `edgeHops`, which finds the hops over plain polylines, is in
|
|
250
|
+
`@weasel-js/diagram/layout`.
|
|
251
|
+
|
|
252
|
+
### Edge labels
|
|
253
|
+
|
|
254
|
+
`onEditLabel` reports a double-click on an edge. `edgeLabelSpec` builds the
|
|
255
|
+
label node that rides the edge's route, and `editEdgeLabelAction` creates one
|
|
256
|
+
or edits the existing one in place.
|
|
257
|
+
|
|
258
|
+
```tsx
|
|
259
|
+
<DiagramView specs={specs} onEditLabel={(edge) => startEditing(edge)} />
|
|
260
|
+
|
|
261
|
+
const spec = edgeLabelSpec(edgeId, 'yes', { layer: 'diagram' });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`labelSize` is the box a label's text takes. Both routes size with it, so a
|
|
265
|
+
label built from data and one the action creates come out the same size.
|
|
266
|
+
|
|
267
|
+
### Labels that dodge
|
|
268
|
+
|
|
269
|
+
After any edit, a label that would sit on a shape, on another label, or across
|
|
270
|
+
another connector slides along its own connector, or flips to the other side of
|
|
271
|
+
it, to the nearest clear spot. It tries its preferred station first, then up to
|
|
272
|
+
four stations either way, a label width and 8 apart, each on both sides of the
|
|
273
|
+
line, and keeps the cheapest: area over shapes counts most, then area over
|
|
274
|
+
other labels, then connectors it covers, then how far it moved. Labels move in
|
|
275
|
+
id order, each an obstacle for the next, so the answer is always the same.
|
|
276
|
+
|
|
277
|
+
The station placement chose is stored on the trait as `placed` (`at`, a
|
|
278
|
+
fraction of the route's length, and `offset`), read before `at` and
|
|
279
|
+
`offset`, which stay the author's preference; a label goes back to its
|
|
280
|
+
preference once that spot is clear. `dodge: false` keeps a label where its
|
|
281
|
+
author put it. Under `layered`, the room layout left a label is its preference,
|
|
282
|
+
so a laid-out diagram's labels stay put unless something covers them.
|
|
283
|
+
|
|
284
|
+
`useLabelPlacement(scene)` places what each edit may have covered or uncovered
|
|
285
|
+
in that edit's own undo entry, and sizes a label to the text an edit gives it.
|
|
286
|
+
Labels hold still while a shape is dragged and settle on release.
|
|
287
|
+
`installLabelPlacement(scene)` does the same without React. Its edit pass runs
|
|
288
|
+
at `DIAGRAM_PASS_ORDER.labels`, after routing: connectors route around labels as
|
|
289
|
+
they stand, then labels move off the new routes, one round each per edit.
|
|
290
|
+
|
|
291
|
+
```tsx
|
|
292
|
+
useAvoidRouting(scene);
|
|
293
|
+
useLabelPlacement(scene);
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
A data-backed `DiagramView` takes `labels="dodge"`, placed after any
|
|
297
|
+
`router`, and never reports a placement; `createSpecLabelPlacer` is that step
|
|
298
|
+
on its own. The arithmetic, `placeLabels`, reads plain boxes and routes and is
|
|
299
|
+
in `@weasel-js/diagram/layout`.
|
|
300
|
+
|
|
301
|
+
### Connectors between pages
|
|
302
|
+
|
|
303
|
+
On a scene with pages (core's `core:pages` document value), a connector whose
|
|
304
|
+
two shapes sit on different pages draws nothing across the gap. Each end draws
|
|
305
|
+
a stub `OFF_PAGE_STUB` (24) world units out of its shape, ending at a tag that
|
|
306
|
+
reads the other page's name and a reference letter, such as "Page 2 · A". The
|
|
307
|
+
start stub ends in the connector's end marker, so it reads as leaving the page.
|
|
308
|
+
A shape in the gap between pages belongs to none, and its connectors stay
|
|
309
|
+
whole.
|
|
310
|
+
|
|
311
|
+
A data-backed diagram takes the same pages as `DiagramData.pages` (core's
|
|
312
|
+
`Page`s), placed by `layoutPages` with `pageLayout`; `diagramPages(data)` gives
|
|
313
|
+
them in world space. `diagramScene` splits each connector between pages and
|
|
314
|
+
adds its two tags, lettered in the order the data lists the edges. An edge to a
|
|
315
|
+
group or a container splits by that box's page, not a member's.
|
|
316
|
+
`dataOffPage(edges, pages, boxOf, look)` is the same step over plain boxes.
|
|
317
|
+
|
|
318
|
+
`useOffPageMarkers(scene)` splits and joins connectors as edits move their
|
|
319
|
+
ends or change the pages, in that edit's own undo entry; installing it
|
|
320
|
+
recomputes nothing. `installOffPageMarkers(scene)` does the same without React.
|
|
321
|
+
Its pass runs at `DIAGRAM_PASS_ORDER.offPage`, before routing, so the `avoid`
|
|
322
|
+
router skips a split connector and routes it again in the entry that joins it.
|
|
323
|
+
A split connector shows no route handles and keeps its waypoints for when it
|
|
324
|
+
joins.
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
function Markers({ scene }) {
|
|
328
|
+
useOffPageMarkers(scene);
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const offPage = diagramOffPage();
|
|
333
|
+
<SceneCanvas scene={scene} ambient={[offPage.contribution]} /* … */>
|
|
334
|
+
<Markers scene={scene} />
|
|
335
|
+
</SceneCanvas>
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
| Surface | What it is |
|
|
339
|
+
|---|---|
|
|
340
|
+
| `DiagramEdge.offPage` | `{ ref, from, to }`: the reference and the two ends' page ids, written by the pass |
|
|
341
|
+
| `offPageSplits`, `nextOffPageRef` | which connectors split, as arithmetic on page rects and end boxes; refs run A…Z, AA… and stay while a split does |
|
|
342
|
+
| `offPageTagSpec`, `offPageTagOf` | a tag: a text node with a background, id `<edge>/offpage-<end>`, that stations itself past its stub |
|
|
343
|
+
| `offPageOps` | the pass's ops for named connectors, or `'all'`, for a document saved before it had markers |
|
|
344
|
+
| `diagramOffPage`, `createFollowOffPageAction` | a double-click on a tag selects the far shape and fits the view to its page; `follow` replaces that |
|
|
345
|
+
| `offPageTargetOf` | where a tag leads: the far shape and its page |
|
|
346
|
+
|
|
347
|
+
Tags take the theme's label color and face on its page color, and applying a
|
|
348
|
+
theme recolors them. A host whose text editing opens on any text node should
|
|
349
|
+
leave tags out: the pass owns their text.
|
|
350
|
+
|
|
351
|
+
To save one page as an SVG document or a PNG image, cut to the page with its
|
|
352
|
+
stubs and tags, use `exportPageSvg` and `exportPagePng` from
|
|
353
|
+
`@weasel-js/diagram-formats`.
|
|
354
|
+
|
|
355
|
+
### Overlays and hover
|
|
356
|
+
|
|
357
|
+
`overlays` holds DOM against a node, a group or an edge, and places it again
|
|
358
|
+
after every paint, so it follows pans, zooms, resizes and layout. A side
|
|
359
|
+
placement uses geom's `placeRect` inside the view, flipping to the far side
|
|
360
|
+
when the near one has no room; the default centers it on the target. An
|
|
361
|
+
edge's place is the midpoint along its route.
|
|
362
|
+
|
|
363
|
+
```tsx
|
|
364
|
+
<DiagramView
|
|
365
|
+
specs={specs}
|
|
366
|
+
pickEdges
|
|
367
|
+
onHover={setHovered}
|
|
368
|
+
overlays={[
|
|
369
|
+
...(hoveredEdge ? [{ target: hoveredEdge, content: <button onClick={insert}>+</button> }] : []),
|
|
370
|
+
...(picked ? [{ target: picked, placement: { side: 'right', align: 'start' }, offset: 8, content: <Card /> }] : []),
|
|
371
|
+
]}
|
|
372
|
+
/>
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`onHover` reports the node, group or edge under the pointer, and `null` when
|
|
376
|
+
there is none or the pointer leaves the canvas, which it also does when the
|
|
377
|
+
pointer moves onto an overlay. Edges answer only with `pickEdges`. For DOM
|
|
378
|
+
the view does not hold, `rectOf(id)` on the ref gives a target's client rect
|
|
379
|
+
and `subscribeFrame` says when to read it again.
|
|
380
|
+
|
|
381
|
+
### Reordering a group's members
|
|
382
|
+
|
|
383
|
+
A group with `reorder: 'x' | 'y'` lets `onReorder` drag any of its members
|
|
384
|
+
along that axis; a member that is a group carries everything inside it. The
|
|
385
|
+
other members slide into the places they would take, and the release reports
|
|
386
|
+
the new order without moving anything. With a member picked, Alt and an arrow
|
|
387
|
+
key steps it one place. `layered` keeps a reorderable group's members in the
|
|
388
|
+
order given across every rank, so writing the order back is all it takes.
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
const data = { nodes, edges, groups: [...rows, { id: 'layers', members: order, reorder: 'y' }] };
|
|
392
|
+
<DiagramView specs={diagramScene(data)} onReorder={({ order }) => setOrder(order)} />
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Stencils
|
|
396
|
+
|
|
397
|
+
A `DiagramStencil` is a palette entry: an id, a label, an outline and a default
|
|
398
|
+
size. `FLOWCHART_STENCILS` has the basic flowchart symbols. `useStencilInsert`
|
|
399
|
+
makes the canvas's `insert` dep build them, and `useStencilTool` gives each a
|
|
400
|
+
tool that places one on a drag. A click places nothing; to place one at a
|
|
401
|
+
point, call the `insert` dep's `commit` with a zero-size box there and the
|
|
402
|
+
stencil's kind, and the body comes out at the stencil's size.
|
|
403
|
+
|
|
404
|
+
```tsx
|
|
405
|
+
useStencilInsert(FLOWCHART_STENCILS, { layer: 'diagram' });
|
|
406
|
+
const decision = useStencilTool(FLOWCHART_STENCILS[1]);
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
A drop from your own palette goes through the canvas's `ingestion` handlers:
|
|
410
|
+
a handler gets the drop's world point as `ctx.point` and calls
|
|
411
|
+
`ctx.insert.commit`. Elsewhere, `SceneCanvasApi.clientToWorld` turns a client
|
|
412
|
+
point into a world one. `useStencilInsert` hands any kind that is not a stencil's to
|
|
413
|
+
the `insert` dep it displaced, so core's rect, ellipse and text tools keep working.
|
|
414
|
+
|
|
415
|
+
### Containers and pools
|
|
416
|
+
|
|
417
|
+
A container is a titled box that holds other shapes; a pool is a header and a
|
|
418
|
+
stack of lanes, each holding shapes. Both are core `ContainerNode`s carrying a
|
|
419
|
+
`container` trait (`diagramContainerOf`): a role of `container`, `pool`, or
|
|
420
|
+
`lane`, and a header. A region's title is a text child named
|
|
421
|
+
`<id>/title`, so double-clicking a header renames it.
|
|
422
|
+
|
|
423
|
+
| What the user does | What happens |
|
|
424
|
+
|---|---|
|
|
425
|
+
| drags a shape into a container's body or a lane | the shape becomes its member, in the move's undo entry |
|
|
426
|
+
| drags a member out | it leaves, to the top level or the container under the drop |
|
|
427
|
+
| drags a container | its members, and the waypoints of connectors between them, go with it |
|
|
428
|
+
| resizes a container | its members stay where they are |
|
|
429
|
+
| deletes a container | its members go with it; Release members keeps them |
|
|
430
|
+
| drags a lane by its header | the lanes reorder, members and all |
|
|
431
|
+
| drags the boundary after a lane | that lane resizes and the pool grows or shrinks with it |
|
|
432
|
+
| clicks the button past the end of a pool | a lane is added at the end |
|
|
433
|
+
|
|
434
|
+
`CONTAINER_STENCILS` offers Container, Pool (rows), and Pool (columns); pass it
|
|
435
|
+
as `useStencilInsert`'s `containers` and give each a `stencilTool`. Their glyphs
|
|
436
|
+
are in the `diagram` icon namespace, whose table is `@weasel-js/diagram/icons`;
|
|
437
|
+
an app without the icons build plugin registers it beside the kit's. A stencil
|
|
438
|
+
drawn inside a container or a lane becomes its member (`diagramDropParentAt`).
|
|
439
|
+
`containerBuild` returns the ops on their own, and `addLane` and
|
|
440
|
+
`releaseMembers` are the edits as functions.
|
|
441
|
+
|
|
442
|
+
What a region admits is the `canDropInto` dep's call, and `diagramCanDropInto(scene)`
|
|
443
|
+
is the one for a diagram: containers and lanes take shapes and containers, a
|
|
444
|
+
pool takes only lanes, and a stencil body, an edge, or a label takes nothing.
|
|
445
|
+
`diagramContainers` returns the lane layer to register and the contribution to
|
|
446
|
+
pass as `ambient`, which carries the boundary drag (`diagram.resizeLane`), the
|
|
447
|
+
add-lane click (`diagram.addLane`), and the `diagram.releaseMembers` command.
|
|
448
|
+
The two lane gestures take a swappable commit.
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
useStencilInsert(FLOWCHART_STENCILS, { containers: CONTAINER_STENCILS });
|
|
452
|
+
useDepSource('canDropInto', () => diagramCanDropInto(scene));
|
|
453
|
+
const containers = useMemo(() => diagramContainers({ scene, selected }), [scene, selected]);
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The layouts register by key through `withDiagramRegistry`, so a saved scene
|
|
457
|
+
finds them again: `diagram:container`, `diagram:pool-rows`,
|
|
458
|
+
`diagram:pool-columns`, `diagram:lane-rows`, and `diagram:lane-columns`.
|
|
459
|
+
`diagramContainerLayout` places members freely under a header, and
|
|
460
|
+
`laneStackLayout` stacks a pool's lanes in the order they sit; a pool's size
|
|
461
|
+
along the stack is the sum of its lanes. Lanes take no connectors, and
|
|
462
|
+
connectors stay at the top level, drawn above every container. `diagramScene`
|
|
463
|
+
builds them from data too (see "From data").
|
|
464
|
+
|
|
465
|
+
### Themes
|
|
466
|
+
|
|
467
|
+
A `DiagramTheme` is a document's colors and face: a style per role (`DIAGRAM_ROLES`: Default,
|
|
468
|
+
Accent, Muted, Positive, Warning, Negative), the connector and label colors, the container and
|
|
469
|
+
lane looks, the page color, a font, and a `series` of colors for things it cannot name ahead,
|
|
470
|
+
such as a look's value types. `DIAGRAM_THEMES` ships `paper`, `night`, `blueprint`, `pastel`, and
|
|
471
|
+
`mono`; `diagramThemeFromTokens(read)` builds one from a host's resolved `--wzl-*` values.
|
|
472
|
+
|
|
473
|
+
A theme writes literal colors into the scene, so whatever draws or exports the scene needs no
|
|
474
|
+
theme. A shape or connector names its role in its trait's `style`, and lists the colors its
|
|
475
|
+
author set by hand in `ownStyle`; a theme leaves those. A theme whose font size differs from the
|
|
476
|
+
one the scene was in moves every text by the step between them, keeping a title or label's offset
|
|
477
|
+
from the body size, except text whose owner lists `'size'` in `ownFont`. A body whose text changes
|
|
478
|
+
size is laid out again in the same undo entry: it grows to what its rows need, never shrinks, and a
|
|
479
|
+
body the author made larger keeps its size. A body built from text rows alone records how it
|
|
480
|
+
stacks them (`DiagramNode.body`, each row's text node carrying `bodyRow`), which is what
|
|
481
|
+
`relayoutBodies(scene, ids, measure)` reads back. `rendererMeasure` measures a row as the kit's
|
|
482
|
+
text renderer lays it out, from the registered font, for a host whose bodies should hold their
|
|
483
|
+
text exactly.
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
applyDiagramTheme(scene, DIAGRAM_THEMES.blueprint); // one undo entry, saved as `diagram:theme`
|
|
487
|
+
setDiagramStyle(scene, selection.get(), 'warning'); // name a role, and recolor in it
|
|
488
|
+
setDiagramColor(scene, selection.get(), 'fill', '#ffcc00'); // by hand: survives the next theme
|
|
489
|
+
setDiagramFontSize(scene, selection.get(), 16); // by hand, recorded in `ownFont`
|
|
490
|
+
const theme = diagramThemeOf(scene, DIAGRAM_THEMES.paper); // the scene's theme, or a fallback
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
`themeWrites(scene, theme)` is the pure half: the new `data` for every node the theme recolors.
|
|
494
|
+
New shapes and connectors take the theme from whatever inserts them: pass `roleStyle(theme)` and
|
|
495
|
+
`themeFont(theme)` to `useStencilInsert`, `themeEdgeStroke(theme)` as a connect action's
|
|
496
|
+
`edgeStroke`, and `themeLabelStyle(theme)` and `theme.label.fill` to `editEdgeLabelAction`.
|
|
497
|
+
`diagramScene` takes `theme` too, drawing each `DataNode` in its `style` role, and the effect
|
|
498
|
+
looks take one in their options (default `night`, which draws them as they always were).
|
|
499
|
+
|
|
500
|
+
A theme is edited as a value: each edit returns a new theme, so a shipped one is never changed.
|
|
501
|
+
`editableTheme(theme)` is where an editor starts: a shipped theme forked as "<name> (edited)", a
|
|
502
|
+
custom one as it is. A custom theme lives in the document's `diagram:theme` value and travels
|
|
503
|
+
between documents as a `.weasel-theme.json` file.
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
let theme = editableTheme(diagramThemeOf(scene));
|
|
507
|
+
theme = setThemeValue(theme, ['roles', 'accent', 'fill'], '#ff8800'); // also edge, lane, page, font, series…
|
|
508
|
+
theme = addThemeRole(theme, 'Critical').theme; // in the default role's colors
|
|
509
|
+
theme = removeThemeRole(theme, 'muted'); // its shapes fall to the default role
|
|
510
|
+
applyDiagramTheme(scene, theme, 'Edit theme', { coalesceKey: 'theme:roles.accent.fill' });
|
|
511
|
+
const file = themeToJson(theme); // and back: parseDiagramTheme(file)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Applies made under one `coalesceKey` with nothing between fold into one undo entry, so a color
|
|
515
|
+
dragged undoes in one step; `scene.history.seal()` ends the run. `parseDiagramTheme` answers
|
|
516
|
+
`{ theme }` or `{ errors }`, each error naming the bad value by its path.
|
|
517
|
+
|
|
73
518
|
### Laying it out
|
|
74
519
|
|
|
75
|
-
`layered`, `tree` and `
|
|
76
|
-
the new top-left for every node that **moves** — a node already
|
|
77
|
-
the layout wants it is absent, so re-running a layout on an
|
|
78
|
-
writes nothing at all.
|
|
520
|
+
`layered`, `tree`, `force` and `chain` are plain functions of the graph. Each
|
|
521
|
+
hands back the new top-left for every node that **moves** — a node already
|
|
522
|
+
standing where the layout wants it is absent, so re-running a layout on an
|
|
523
|
+
unchanged diagram writes nothing at all.
|
|
79
524
|
|
|
80
525
|
```tsx
|
|
81
526
|
useAction(useMemo(() => createLayoutAction<Pose>({
|
|
@@ -89,9 +534,73 @@ move as one undo entry. Three rules keep a re-layout from scrambling a diagram
|
|
|
89
534
|
someone has arranged: no RNG anywhere, within-rank order seeded from where the
|
|
90
535
|
nodes already sit, and a node carrying `pinned: true` that nothing moves.
|
|
91
536
|
|
|
537
|
+
The first pinned node anchors the layout. Under `layered`, every other pin
|
|
538
|
+
keeps its place across its rank, with the rest of the rank moved clear of it,
|
|
539
|
+
past it if there is no room beside it. A pin's rank band slides or deepens to
|
|
540
|
+
cover it, the gaps toward other pinned ranks shrink to make room, and a group's
|
|
541
|
+
column moves or widens to take in its pinned members, never over a node outside
|
|
542
|
+
the group. A pin is left out only when no arrangement fits: its band would come
|
|
543
|
+
nearer another pinned rank's than the ranks between them allow with every gap
|
|
544
|
+
at a quarter of `rankGap` (plus group insets), or a group's box would cover a
|
|
545
|
+
pin outside the group. In practice that is a pin dragged level with, or past,
|
|
546
|
+
the rank beside it. Such a pin stays put without the layout being built around
|
|
547
|
+
it; `layeredPlan` names it in `unheld`.
|
|
548
|
+
|
|
92
549
|
`force` is the exception to the second half of that: it is an iterative
|
|
93
550
|
relaxation seeded from the current positions, so re-running it keeps relaxing.
|
|
94
551
|
|
|
552
|
+
`layered` turns around any edge that closes a cycle, then gives each node a
|
|
553
|
+
rank that keeps the total edge length as short as it can (`ranksByLength`,
|
|
554
|
+
network simplex), so a node with no inputs sits beside what it feeds rather
|
|
555
|
+
than in the first rank. It lines each node up with its neighbors across the
|
|
556
|
+
ranks (Brandes–Köpf), lining up the two ports an edge joins rather than the two
|
|
557
|
+
centers. An edge that spans ranks stands in each rank it crosses, so placement
|
|
558
|
+
leaves it room, and an edge whose `GraphEdge.label` gives its label's size gets
|
|
559
|
+
a rank of its own between its ends, with room beside its wire as big as the
|
|
560
|
+
label. Under `order: 'seeded'` an edge keeps its place in each rank the way a
|
|
561
|
+
node does, from where its route crosses that rank now. Under
|
|
562
|
+
`order: 'barycenter'` it also reorders each rank to cut crossings, reading each
|
|
563
|
+
edge at the port it joins, where `portsOf` puts that port on the node's
|
|
564
|
+
outline, so two wires into one node arrive in the order of its ports. A port
|
|
565
|
+
with several places is read at the one nearest where the wire's other end
|
|
566
|
+
falls among the node's wires on that side. `layeredPlan(graph, opts)`, also reachable as `layered.plan`,
|
|
567
|
+
answers with the moves plus each node's rank, each spanning edge's waypoints
|
|
568
|
+
and where among them each label's room is, for drawing those edges where their
|
|
569
|
+
room was left. `ranksOf` is the plain longest-path ranking, which `chain` uses.
|
|
570
|
+
|
|
571
|
+
On a live scene the layout action and a live `layered` run write those routes
|
|
572
|
+
too, in the same undo entry as the moves: each long edge gets its waypoints,
|
|
573
|
+
and its label, when it sits at the default middle, moves into the room left
|
|
574
|
+
beside them. Under `layered` each edge's compass ports are named for the way it
|
|
575
|
+
runs between ranks, and an end records the port layout named as `laidOut`, the
|
|
576
|
+
way a route does; layout names it again on each run while the end names no
|
|
577
|
+
port or still names that one. An edge records the waypoints layout wrote as `laidOut`, and
|
|
578
|
+
layout rewrites a route only while the edge's waypoints still match them; once
|
|
579
|
+
someone moves a bend, the route is theirs and layout leaves it and its label
|
|
580
|
+
alone, and keeps no room for it in the ranks it crosses. A layout that routes
|
|
581
|
+
nothing, `tree` or `force`, takes layered's routes back off, and anything on a
|
|
582
|
+
locked layer stays as it is. A live run writes its routes only when it arrives
|
|
583
|
+
with nothing held along the way, since a drag or a `stop` leaves the nodes
|
|
584
|
+
somewhere the routes were not laid out for. While it eases, each route it will
|
|
585
|
+
write eases with the nodes, shown as the edge's path on the override channel. A layout of your own hands routes
|
|
586
|
+
back the same way, by giving its `LayoutFn` a `plan`, which `diagramScene`
|
|
587
|
+
reads too.
|
|
588
|
+
|
|
589
|
+
`chain` stands one run of nodes on a straight line and hangs everything else
|
|
590
|
+
beside the chain node it is nearest to, counting edges in either direction. It
|
|
591
|
+
is picked like the others, by name: `layout: 'chain'` in `diagramScene`,
|
|
592
|
+
`layout('chain')` on a `DiagramView`, or `algorithm: 'chain'` in
|
|
593
|
+
`createLayoutAction`. The `chain` layout option lists the ids on the line, in
|
|
594
|
+
order; unset, or naming no node of the graph, it is the longest path through
|
|
595
|
+
the graph. `chainSide` is
|
|
596
|
+
`'after'` (default) or `'before'`: which side of the line the rest hangs on.
|
|
597
|
+
Each chain node owns a column as wide as the widest thing hung on it, so
|
|
598
|
+
neighbors never share space, and the hung nodes stand in the order of the ports
|
|
599
|
+
they connect to. An edge between a chain node and one hung beside it, both ends
|
|
600
|
+
naming ports that face each other, runs on a track of its own in the gap
|
|
601
|
+
between them, so a fan of them never crosses; the gap grows to keep tracks 12
|
|
602
|
+
apart. Groups are ignored.
|
|
603
|
+
|
|
95
604
|
### From data
|
|
96
605
|
|
|
97
606
|
`diagramScene` turns plain `{ nodes, edges }` into a laid-out scene, and
|
|
@@ -105,15 +614,64 @@ return <DiagramView specs={specs} width={360} height={600} selected={id} onSelec
|
|
|
105
614
|
Every box is a `buildBody` body, so a data node takes the same knobs a body
|
|
106
615
|
does — `outline`, `rows`, `padding`, `gap`, `verticalAlign`, `ports` — plus `pinned`, a
|
|
107
616
|
starting `at`, and a minimum `width`/`height`; `lines` is shorthand for one
|
|
108
|
-
label row each.
|
|
109
|
-
`
|
|
617
|
+
label row each. A `slot` row is reserved space, `height` tall and, with a
|
|
618
|
+
`width`, that wide and centered; one with an `id` becomes a scene node of that
|
|
619
|
+
name, `<node>/<id>`, drawn as an empty frame until you fill it by changing its
|
|
620
|
+
data — with `{ image: { src } }`, say. An edge can name `fromPort`/`toPort`, its own `router`,
|
|
621
|
+
`waypoints`, `labelPlacement`, and a theme role in `style`.
|
|
622
|
+
|
|
623
|
+
A node, an edge, a container, or a lane can carry `colors` of its own: a node's `fill`, `stroke`,
|
|
624
|
+
and `text`, an edge's `stroke`, `label`, `width`, `dash`, and markers. `diagramScene` draws them
|
|
625
|
+
over the theme and lists the colors in the trait's `ownStyle`, so a later theme change leaves
|
|
626
|
+
them, as it leaves a color set by hand. `dataNodeStyle`, `dataEdgeStyle`, and `dataRegionStyle`
|
|
627
|
+
give the style an item is drawn in, for a writer that needs it without drawing; where a style gives
|
|
628
|
+
no width, `NODE_STROKE_WIDTH`, `LANE_STROKE_WIDTH`, and `EDGE_STROKE_WIDTH` are what is drawn. `layer` puts every
|
|
629
|
+
spec on a layer other than `'main'`.
|
|
630
|
+
|
|
631
|
+
`outline` is `'rect'`, `'diamond'`, `'stadium'`, `'parallelogram'`,
|
|
632
|
+
`'trapezoid'` (a demultiplexer: tall at the right, short at the left),
|
|
633
|
+
`'hexagon'` (pointed at the left and right, its rows kept between the points), or a
|
|
634
|
+
`Path` drawn in a unit square — `(0, 0)` the box's top-left, `(1, 1)` its
|
|
635
|
+
bottom-right — which is stretched over the box, so it follows its node as
|
|
636
|
+
marks do.
|
|
637
|
+
|
|
638
|
+
`marks` draws more over a box than its rows. A mark with `d` is SVG path data
|
|
639
|
+
in a unit square, stretched over the node's box; it takes `fill`, `stroke`,
|
|
640
|
+
`strokeWidth`, `dash` and `opacity`. `diagramScene` throws on path data that
|
|
641
|
+
does not parse or draws nothing, naming the node and the mark's index. A mark with `text` is pinned to a point on
|
|
642
|
+
the box: `at` is that point as `{ u, v }` fractions of the box, `self` is which
|
|
643
|
+
point of the text sits on it (default `at`), and `offset` nudges it in pixels.
|
|
644
|
+
Marks move with their node. `DiagramView` registers the painters for outlines
|
|
645
|
+
and marks itself; a scene drawn on your own `SceneCanvas` calls
|
|
646
|
+
`registerDiagramShape()` once for outlines and `registerMarkShape()` once for
|
|
647
|
+
marks, each of which returns its disposer, or they do not paint:
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
{ id: 'gain', marks: [
|
|
651
|
+
{ d: 'M0 1 L1 0', stroke: '#c58bff' },
|
|
652
|
+
{ text: 'gain', at: { u: 0.5, v: 1 }, self: { u: 0.5, v: 0 }, offset: { x: 0, y: 4 } },
|
|
653
|
+
] }
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`DiagramView` draws ports when `onConnect` is set, or when `ports` says so:
|
|
657
|
+
`'connect'` draws them and lets one be dragged onto another, `'show'` draws
|
|
658
|
+
them and nothing more, `'none'` hides them. `portOptions` styles them: `shape`
|
|
659
|
+
is `'square'` (default) or `'circle'`, `fill` is one fill or a function of the
|
|
660
|
+
port (its `type`, say), and `showsPort` picks which of a node's ports show.
|
|
110
661
|
|
|
111
662
|
Under `layered`, an edge that names neither leaves its source by the face
|
|
112
|
-
toward the next rank and arrives at its target's face toward the last
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
`
|
|
663
|
+
toward the next rank and arrives at its target's face toward the last; an edge
|
|
664
|
+
running back up the ranks does the same the other way round. One that spans
|
|
665
|
+
ranks runs through the room `layeredPlan` left for it in each rank it crosses,
|
|
666
|
+
so it goes around what lies between its ends — unless an end is a pin
|
|
667
|
+
`layeredPlan` could not hold (`unheld`), when it takes the router's own path. An
|
|
668
|
+
edge whose pinned ends stand level runs straight between their facing sides, or
|
|
669
|
+
around past both when a node stands between them. An
|
|
670
|
+
author's own ports and waypoints win. A label's `offset` is the gap between the
|
|
671
|
+
route and the label's near side. A label whose edge sets no `labelPlacement`
|
|
672
|
+
of its own, or one at `'mid'`, sits in the room layout left it, placed with
|
|
673
|
+
`at: { near }`, which stations a label where its route passes nearest a point,
|
|
674
|
+
so a bend added or removed elsewhere leaves it where it was.
|
|
117
675
|
|
|
118
676
|
`groups` draws a box around some nodes, with an optional `label` inside it:
|
|
119
677
|
|
|
@@ -121,18 +679,53 @@ keeps the default: the side facing the other end most directly. A label's
|
|
|
121
679
|
diagramScene({ nodes, edges, groups: [{ id: 'ingest', members: ['fetch', 'decode'], label: 'ingest' }] });
|
|
122
680
|
```
|
|
123
681
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
682
|
+
A group may be a member of another. `layered`, `tree` and `force` keep a
|
|
683
|
+
group's members together; `chain` ignores groups but still draws them. Under
|
|
684
|
+
`layered` and `tree` each group gets a column on the cross axis that nothing
|
|
685
|
+
else enters, in every rank from its first member's to its last, and rank gaps
|
|
686
|
+
widen where a box needs the room; `force` pulls a group's members toward each
|
|
687
|
+
other and pushes groups apart. The box is an ordinary scene node that depends
|
|
688
|
+
on its members and derives its pose from theirs (`groupDerivePose`), so
|
|
689
|
+
dragging a member carries the box along. It also depends on the labels of the
|
|
690
|
+
wires with both ends inside it, so a long label stays inside the box. It is
|
|
691
|
+
never narrower than its label,
|
|
692
|
+
and a layout running down or up leaves room for that width. An edge whose
|
|
693
|
+
`from` or `to` names a group meets the box, and ranks against the group's first
|
|
694
|
+
stage. A scene built
|
|
133
695
|
any other way joins in with a node whose trait carries `group` and whose
|
|
134
696
|
`dependsOn` lists its members.
|
|
135
697
|
|
|
698
|
+
`containers` draws the containers and pools of "Containers and pools" from data:
|
|
699
|
+
|
|
700
|
+
```tsx
|
|
701
|
+
diagramScene({ nodes, edges, containers: [
|
|
702
|
+
{ id: 'review', role: 'container', label: 'Review', members: ['draft', 'check'] },
|
|
703
|
+
{ id: 'teams', role: 'pool', label: 'Teams', box: { x: 0, y: 400, width: 600, height: 240 }, pinned: true,
|
|
704
|
+
lanes: [{ id: 'ops', label: 'Ops', members: ['deploy'] }, { id: 'qa', label: 'QA', size: 80 }] },
|
|
705
|
+
] });
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
Each region is a scene container and its members are its children, so a
|
|
709
|
+
region's id shares the node id namespace, and so does each lane's. A member
|
|
710
|
+
already claimed by an earlier region, a pool, and a claim that would put a
|
|
711
|
+
container inside itself are ignored. A node is in a group or in a region, never
|
|
712
|
+
both: a region is its scene parent, and a group would draw a second box around
|
|
713
|
+
it that the region's does not hold, so `diagramScene` throws on a node that
|
|
714
|
+
`groups` and `containers` both name. A container with a `box` is laid out as
|
|
715
|
+
one node of that size, and its members ride along with it, keeping where they
|
|
716
|
+
stand in it; a member with no `at` is seeded inside its body, in rows. A
|
|
717
|
+
container with no `box` is laid out as a group, and its box fits its members.
|
|
718
|
+
A pool always has a box: 600 across and 120 along per lane when it gives none
|
|
719
|
+
(`DEFAULT_POOL_ACROSS`, `DEFAULT_LANE_SIZE`). Its lanes stack along `axis`
|
|
720
|
+
(`'y'`, rows, by default) at their `size`, and lanes giving none share what
|
|
721
|
+
the pool's box leaves. An edge with an end inside a boxed region ranks against
|
|
722
|
+
the outermost one holding it, and keeps none of layout's route. `pinned`
|
|
723
|
+
leaves a box where it is. Under `theme`, a region naming a `style` role takes
|
|
724
|
+
that role's colors; otherwise a container takes the theme's `container`, a
|
|
725
|
+
pool its `lane.header`, and lanes the `lane.bodies` in turn; titles are set in
|
|
726
|
+
the diagram's face, the theme's when `font` is unset. `router="avoid"`
|
|
727
|
+
routes around a region neither end is inside, and around every header.
|
|
728
|
+
|
|
136
729
|
Text is sized from character counts unless `measure` is given, so layout runs
|
|
137
730
|
without a canvas. The layout defaults to `layered`, picked by name or passed as
|
|
138
731
|
any `LayoutFn`; `layoutOptions` merge over `order: 'barycenter'`, and
|
|
@@ -153,16 +746,557 @@ since survive it. Editing reports rather than writes —
|
|
|
153
746
|
```
|
|
154
747
|
|
|
155
748
|
— `onMove` turns on dragging and hears every node a drag or a layout run
|
|
156
|
-
moved
|
|
157
|
-
|
|
158
|
-
|
|
749
|
+
moved, and, as its second argument, every edge whose route a layout run
|
|
750
|
+
or ports rewrote (`{ id, waypoints, ports }`, `id` being `edgeIdOf` of the
|
|
751
|
+
data edge, `ports` the ends whose port the run picked);
|
|
752
|
+
`onConnect` makes ports grabbable and hears the edge a connect describes.
|
|
753
|
+
Store a move as the node's `at`, with `pinned: true` unless the scene uses
|
|
754
|
+
`layout: 'none'`, or the next `diagramScene` lays it out again. Store a route
|
|
755
|
+
as the edge's `routed` and its `ports` as `routedPorts`, which `edgeIdOf` does
|
|
756
|
+
not read: it is drawn until layout runs again, and stays
|
|
757
|
+
layout's to replace, its label keeping the room layout leaves it. `waypoints`
|
|
758
|
+
would make it yours, which layout never moves. A run that only rewrote routes
|
|
759
|
+
calls `onMove` with no moves.
|
|
760
|
+
`onMove` hears only what changes after the view is built. Where the build's own
|
|
761
|
+
layout put things, `diagramLayoutOf(specs)` reads off the specs, in the same
|
|
762
|
+
shape (`{ moves, routes, containers }`): every node not pinned, every edge whose route
|
|
763
|
+
or ports layout wrote, and a `box` edit for every container and pool not pinned
|
|
764
|
+
whose box is its own. Store them the same way and the next `diagramScene`
|
|
765
|
+
leaves those nodes where they stand and lays out only the new ones, which keeps
|
|
766
|
+
a diagram still while its data is edited.
|
|
767
|
+
`onResize` gives a selected node resize handles (`diagramResizable`, so none on
|
|
768
|
+
an edge, an edge label, a lane, or a group's box). The box resizes live under
|
|
769
|
+
the drag; on release the view reports `{ id, x, y, width, height }` for each
|
|
770
|
+
resized node and draws the new box only once the specs do. Store it as the
|
|
771
|
+
node's `width`, `height`, and `at`, with `pinned: true`. The size is a minimum,
|
|
772
|
+
so a box dragged smaller than its rows grows back to them. Core's
|
|
773
|
+
`createResizeAction({ commit })` is what reports: it hands the release poses to
|
|
774
|
+
`commit` in place of writing them.
|
|
775
|
+
|
|
776
|
+
```tsx
|
|
777
|
+
<DiagramView
|
|
778
|
+
specs={specs}
|
|
779
|
+
onResize={(resizes) => setNodes((ns) => withBoxes(ns, resizes))}
|
|
780
|
+
/>
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
`onEditContainer` edits containers and pools the way chart mode does, and
|
|
784
|
+
reports each edit without keeping it (`ContainerEdit`):
|
|
785
|
+
|
|
786
|
+
| The user | Reported |
|
|
787
|
+
|---|---|
|
|
788
|
+
| drags a shape into a container or a lane, or out | `onMove`, the move carrying `parent`: the region's id, or `null` for the top level |
|
|
789
|
+
| drags a container or pool, or resizes it by a handle | `{ kind: 'box', id, box }`, with `parent` when a drop changed its region; what it carried goes to `onMove` |
|
|
790
|
+
| drags a lane's boundary | `{ kind: 'lanes', pool, lanes }`, every lane with its size; the shapes in the lanes after it go to `onMove` |
|
|
791
|
+
| drags a lane by its header to another place | `{ kind: 'lanes', pool, lanes }` in the new order |
|
|
792
|
+
| clicks a pool's add-lane button | `{ kind: 'lanes', pool, lanes }` ending in a new lane, its id minted by the view |
|
|
793
|
+
| double-clicks a header | `{ kind: 'title', id }`, for the host to edit the label |
|
|
794
|
+
| runs Release members (`diagram.releaseMembers`) | `{ kind: 'release', id }` |
|
|
795
|
+
|
|
796
|
+
Move ids between `members` lists on a move's `parent`, and store a `box` as the
|
|
797
|
+
region's `box` with `pinned: true`. `onDelete` reports a deleted region with
|
|
798
|
+
everything inside it. The view lets shapes drop only into containers and
|
|
799
|
+
lanes, and lanes only into pools (`diagramCanDropInto`).
|
|
159
800
|
`view.current.layout('layered')` re-runs a layout animated from where the
|
|
160
|
-
nodes stand; `'force'` relaxes live.
|
|
801
|
+
nodes stand; `'force'` relaxes live. Either way the routes ease with the nodes,
|
|
802
|
+
through the ports the run will commit.
|
|
803
|
+
|
|
804
|
+
`layout()` also takes a `LayoutPlanner`, an object whose `plan(graph, opts)`
|
|
805
|
+
answers with a whole `LayoutPlan`, now or as a promise. The run asks it once,
|
|
806
|
+
with the graph as it stands, and eases to the answer the way it eases to
|
|
807
|
+
`layered`'s, reporting the same moves, routes and ports to `onMove`. While the
|
|
808
|
+
answer is on its way, a drag, a change to the data, or another `layout()` call
|
|
809
|
+
drops it, since the plan was made for a diagram that is no longer there. A
|
|
810
|
+
planner whose promise rejects starts no run, and the reason goes to
|
|
811
|
+
`live.onError`, or to `console.error` when that is unset.
|
|
812
|
+
`elkPlanner()` from `@weasel-js/diagram-elk` is one: it lays the diagram out
|
|
813
|
+
with ELK (the Eclipse Layout Kernel).
|
|
814
|
+
|
|
815
|
+
```tsx
|
|
816
|
+
view.current.layout(elkPlanner({ direction: 'right' }));
|
|
817
|
+
```
|
|
161
818
|
|
|
162
819
|
Labels paint only in a registered font family, and the default is
|
|
163
820
|
sans-serif, so call `registerCanvasFont('sans-serif')` once or they render
|
|
164
|
-
blank.
|
|
165
|
-
|
|
821
|
+
blank. The view opens fitted to the diagram and fits again when `width` or
|
|
822
|
+
`height` changes or a layout run settles, until the user pans or zooms. `minScale` stops the fit from
|
|
823
|
+
shrinking a wide diagram past readable; the rest is a pan away.
|
|
824
|
+
|
|
825
|
+
### Effect charts
|
|
826
|
+
|
|
827
|
+
`@weasel-js/diagram/effect` draws an effects pipeline: the stages a signal
|
|
828
|
+
passes through, what each reads and writes, and where a decision (a stage that
|
|
829
|
+
sends the signal one of several ways) sends it.
|
|
830
|
+
|
|
831
|
+
```tsx
|
|
832
|
+
import { EffectChart, type EffectPipeline } from '@weasel-js/diagram/effect';
|
|
833
|
+
|
|
834
|
+
const pipeline: EffectPipeline = {
|
|
835
|
+
stages: [
|
|
836
|
+
{ id: 'in', title: 'Input', kind: 'source', outputs: [{ id: 'out', type: 'audio' }] },
|
|
837
|
+
{
|
|
838
|
+
id: 'gate',
|
|
839
|
+
title: 'Gate',
|
|
840
|
+
inputs: [{ id: 'in', type: 'audio' }],
|
|
841
|
+
outputs: [{ id: 'out', type: 'audio' }],
|
|
842
|
+
params: [{ label: 'threshold', value: '-40 dB' }],
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
id: 'verb',
|
|
846
|
+
title: 'Reverb',
|
|
847
|
+
inputs: [{ id: 'in', type: 'audio' }],
|
|
848
|
+
outputs: [{ id: 'out', type: 'audio' }],
|
|
849
|
+
},
|
|
850
|
+
{ id: 'out', title: 'Output', kind: 'sink', inputs: [{ id: 'in', type: 'audio' }] },
|
|
851
|
+
],
|
|
852
|
+
wires: [
|
|
853
|
+
{ from: { stage: 'in', port: 'out' }, to: { stage: 'gate', port: 'in' } },
|
|
854
|
+
{ from: { stage: 'gate', port: 'out' }, to: { stage: 'verb', port: 'in' } },
|
|
855
|
+
{ from: { stage: 'verb', port: 'out' }, to: { stage: 'out', port: 'in' } },
|
|
856
|
+
],
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
<EffectChart pipeline={pipeline} width={900} height={420} background={{ color: '#10161c' }} />
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
A pipeline is plain data and says nothing about how it is drawn:
|
|
863
|
+
|
|
864
|
+
| Field | Meaning |
|
|
865
|
+
| --- | --- |
|
|
866
|
+
| `stages` | The stages, below. |
|
|
867
|
+
| `wires` | `{ from: { stage, port }, to: { stage, port }, label? }`, from an output to an input. |
|
|
868
|
+
| `groups` | `{ id, label?, members }`: a box around stages and other groups. |
|
|
869
|
+
| `types` | `{ [type]: { control?: boolean } }`. A `control` type carries a condition or a modulation rather than the signal, and its wires draw dashed. A type not listed is plain data. |
|
|
870
|
+
|
|
871
|
+
A stage:
|
|
872
|
+
|
|
873
|
+
| Field | Meaning |
|
|
874
|
+
| --- | --- |
|
|
875
|
+
| `id`, `title` | Required. |
|
|
876
|
+
| `kind` | `'stage'` (default), `'decision'`, `'source'` or `'sink'`. Any other value draws as `'stage'`. |
|
|
877
|
+
| `inputs`, `outputs` | Ports, `{ id, label?, type }`. `type` names a value type; a wire joins ports of one type. A port's `id` is unique among the stage's inputs and outputs together. |
|
|
878
|
+
| `params` | `{ label, value }` pairs shown on the box. A decision does not show them. |
|
|
879
|
+
| `split` | On a decision that compares a number: `{ input, at }`, the input it compares and the cut points in ascending order. N cuts need N + 1 outputs, lowest band first. |
|
|
880
|
+
| `bypassed` | The stage is in the pipeline and switched off; it draws dashed and dim. |
|
|
881
|
+
| `data` | Anything else a look wants. The shipped looks do not read it. |
|
|
882
|
+
|
|
883
|
+
`EffectChart` takes:
|
|
884
|
+
|
|
885
|
+
| Prop | Meaning |
|
|
886
|
+
| --- | --- |
|
|
887
|
+
| `pipeline` | The pipeline. It is compared by its JSON, so it may be rebuilt on every render; a value JSON drops or flattens in a stage's `data`, such as a function or a `Map`, is not seen changing. |
|
|
888
|
+
| `run` | One run of the pipeline, drawn over it (below). It may change every frame. |
|
|
889
|
+
| `look`, `options` | How it is drawn, and that look's options. Default look `nodeGraph`. |
|
|
890
|
+
| `width`, `height` | The canvas size. |
|
|
891
|
+
| `minScale` | The smallest scale the chart opens at; a chart too big to fit whole at it opens at its start, the rest a pan away. Default `EFFECT_MIN_SCALE`, 0.5, which sets the shipped looks' titles at 6px. |
|
|
892
|
+
| `background` | What the canvas paints behind the chart. Unset, it is transparent; the shipped looks are drawn for a dark ground. |
|
|
893
|
+
| `selected`, `onSelect` | As on `DiagramView`, for stage ids. A pick of anything that is not a stage, such as a group box, is reported as `null`, and the canvas keeps its own highlight on it. |
|
|
894
|
+
| `onProblems` | Hears what `checkPipeline` found, on mount and each time the pipeline's content changes, whether or not the list differs. |
|
|
895
|
+
| `className` | A class for the canvas element. |
|
|
896
|
+
|
|
897
|
+
Keep `look` and `options` steady between renders: a new object for either lays
|
|
898
|
+
the chart out again and opens it fitted afresh, as a pipeline whose content
|
|
899
|
+
changes does. Ports that
|
|
900
|
+
carry a value type are drawn, colored by type. As with `DiagramView`, call
|
|
901
|
+
`registerCanvasFont('sans-serif')` once or labels render blank.
|
|
902
|
+
|
|
903
|
+
#### Runs
|
|
904
|
+
|
|
905
|
+
A run is what one pass through the pipeline did, kept beside the pipeline
|
|
906
|
+
rather than in it, keyed by stage id:
|
|
907
|
+
|
|
908
|
+
```ts
|
|
909
|
+
const run: EffectRun = {
|
|
910
|
+
stages: {
|
|
911
|
+
src: { cost: 0.3, values: { out: '1920×1080' } },
|
|
912
|
+
q: { taken: 'yes' }, // a decision's chosen output
|
|
913
|
+
blur: { cost: 4.2, values: { out: '6.0 px' } },
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
A stage the run has no entry for is one it never reached. The chart fades such
|
|
919
|
+
a stage, its rows and its wires, and a decision's wires to the outputs it did
|
|
920
|
+
not take, every one of them when `taken` names none; where a look draws a
|
|
921
|
+
decision as something else, as `rack` draws a skip as an arc, the look's
|
|
922
|
+
`exits` say which edges stand for which exit. It writes each stage's `cost`, in milliseconds, above its top-right
|
|
923
|
+
corner; and writes each output's value into the row a look keeps for it when
|
|
924
|
+
its `showValues` option is on, with a dash for an output the run says nothing
|
|
925
|
+
about. A `switch` symbol is drawn thrown to its first exit, and a run whose
|
|
926
|
+
`taken` is the second throws it there. `EffectChart` fades the port dots of a
|
|
927
|
+
stage the run never reached as well; `paintRun` alone cannot, since the port
|
|
928
|
+
layer draws them outside the scene. A decision a look does not draw has nowhere
|
|
929
|
+
to show its cost. A stage whose entry has an `error` shows it where its cost
|
|
930
|
+
would go, in the problem color, and a run's `at` names a stage to outline,
|
|
931
|
+
as a run taken a step at a time does.
|
|
932
|
+
|
|
933
|
+
A run never lays the chart out again or refits it: `paintRun(pipeline, specs,
|
|
934
|
+
run)` edits the specs the look already built, data only, and `DiagramView`
|
|
935
|
+
writes just the fields that changed. So `showValues` keeps its rows from the
|
|
936
|
+
first draw, wide enough for a value of about nine characters, and a longer one
|
|
937
|
+
runs past the box's edge rather than resizing it.
|
|
938
|
+
|
|
939
|
+
A run can also carry a picture per output, `previews: { [port]: ImageBitmap }`
|
|
940
|
+
on a stage's entry. With a look's `showPreviews` on, each output gets a slot
|
|
941
|
+
`previewSize` big (default `PREVIEW_SIZE`, a 48-unit square; or `{ width,
|
|
942
|
+
height }`) under its value row, kept from the first draw like the value rows,
|
|
943
|
+
drawn as an empty frame until a run fills it. The preview is stretched to the
|
|
944
|
+
slot. It is an `ImageBitmap`, not a canvas, because a bitmap's pixels cannot
|
|
945
|
+
change: a run that hands back the same bitmap edits nothing and uploads
|
|
946
|
+
nothing, and new pixels are a new bitmap. A canvas you draw into becomes one
|
|
947
|
+
with `transferToImageBitmap()` (`OffscreenCanvas`) or `createImageBitmap()`.
|
|
948
|
+
`EffectChart` frees the texture of a bitmap the next run no longer shows, and
|
|
949
|
+
of every one it shows when it unmounts; closing a bitmap is still yours to do.
|
|
950
|
+
Under `paintRun` alone the slot becomes an image node whose `src` is
|
|
951
|
+
`bitmapSrc(bitmap)` from `@weasel-js/core`, and releasing the replaced
|
|
952
|
+
textures is the caller's: `releaseImageSource(old)` from
|
|
953
|
+
`@weasel-js/core/renderer`.
|
|
954
|
+
|
|
955
|
+
`checkPipeline(pipeline)` returns the problems without drawing anything, each
|
|
956
|
+
`{ kind, message, stage?, port?, wire?, group? }`, where `wire` is an index into
|
|
957
|
+
`pipeline.wires`. The shipped looks draw a pipeline with problems as far as
|
|
958
|
+
they can:
|
|
959
|
+
|
|
960
|
+
| `kind` | When | On the chart |
|
|
961
|
+
| --- | --- | --- |
|
|
962
|
+
| `missing-stage`, `missing-port` | A wire names a stage or port that does not exist. | The wire is left out. |
|
|
963
|
+
| `wrong-direction` | A wire starts at an input or ends at an output. | The wire is drawn in the warning color. |
|
|
964
|
+
| `type-mismatch` | A wire joins ports of different types. | The wire is drawn in the warning color. |
|
|
965
|
+
| `duplicate-stage` | Two stages share an id. | The first is drawn. |
|
|
966
|
+
| `duplicate-port` | Two ports on one stage share an id. | Both are listed; wires attach to the first. |
|
|
967
|
+
| `duplicate-group` | A group's id repeats an earlier group's, or is a stage's. | Only the first group with the id is drawn, and a group with a stage's id is not. |
|
|
968
|
+
| `missing-member` | A group names something that is neither a stage nor a group. | The name is ignored. |
|
|
969
|
+
| `bad-split` | A `split` names an input the stage lacks, has no cuts, has cuts out of order or one that is not a finite number, or does not have one more output than cuts. | A decision that would draw as a bar draws as a diamond. |
|
|
970
|
+
|
|
971
|
+
#### Looks
|
|
972
|
+
|
|
973
|
+
A look is how a pipeline is drawn: a function from the pipeline and its
|
|
974
|
+
options to `{ data, scene, color }`, which are `diagramScene`'s two arguments
|
|
975
|
+
and a `color(type)` for the ports. `EFFECT_LOOKS` holds `nodeGraph`, `rack`, `lanes` and `compact`.
|
|
976
|
+
|
|
977
|
+
| Look | Draws | Options |
|
|
978
|
+
| --- | --- | --- |
|
|
979
|
+
| `nodeGraph` (default) | Every port a named row on its stage, a wire from port to port, flowing left to right. | `router` (default `'bezier'`), `nodeGap`, `rankGap`, `colors`, `showParams`, `showValues`, `showPreviews`, `previewSize`, `branch` |
|
|
980
|
+
| `rack` | The main signal on one straight line, and everything else hung beside the stage on the line it is nearest to, whichever way the wire runs. | `chain`, `side`, `nodeGap`, `rankGap`, `colors`, `showParams`, `showValues`, `showPreviews`, `previewSize`, `branch`, `collapseSkips` |
|
|
981
|
+
| `lanes` | A horizontal lane per value type, and each stage standing across the lanes of the types it reads and writes, as tall as they are, its ports in their own lanes. | `lanes`, `laneHeight`, `rankGap`, `router`, `colors`, `showParams`, `showValues`, `showPreviews`, `previewSize`, `branch`, `measure` |
|
|
982
|
+
| `compact` | Each stage one line, its title alone, no ports drawn, and one line between two stages however many wires join them. For a pipeline too big to read as a node graph. | `router`, `nodeGap`, `rankGap`, `colors`, `branch` |
|
|
983
|
+
|
|
984
|
+
`colors` maps a value type to a color, over the palette; `showParams` (default
|
|
985
|
+
true) shows the `params` of ordinary stages, sources and sinks; `showValues`
|
|
986
|
+
(default false) keeps a row per output on those stages for a run's values, and
|
|
987
|
+
`showPreviews` (default false) a slot per output for its previews. `rack` runs
|
|
988
|
+
along `chain`, a list of stage ids that defaults to the longest path over data
|
|
989
|
+
wires (`mainChain(pipeline)` returns it); `side` is `'below'` (default) or
|
|
990
|
+
`'above'`.
|
|
991
|
+
|
|
992
|
+
`lanes` puts the lanes top down in the order their types first appear on the
|
|
993
|
+
pipeline's ports, after any it is given in `lanes`. A stage stands
|
|
994
|
+
`LANE_INSET` inside the first and last lane it touches and covers every lane
|
|
995
|
+
between; its inputs sit on its left edge and its outputs on its right, each in
|
|
996
|
+
its own type's lane. Stages stand in columns, each right of every stage that
|
|
997
|
+
feeds it, and a stage moves a column further right rather than share one with
|
|
998
|
+
a stage holding any of its lanes. A lane is `laneHeight` (default 48) tall, or
|
|
999
|
+
taller until each stage fits the lanes it spans, params and value rows
|
|
1000
|
+
included; a source or sink stretched taller than it measured is widened by as
|
|
1001
|
+
much, so its round ends leave its rows the room they had. A decision keeps its symbol's own size, centered across its lanes,
|
|
1002
|
+
and brings its control inputs in from the bottom when their lanes are below
|
|
1003
|
+
the rest of its ports. The lanes are nodes too, `laneId(type)`, drawn behind
|
|
1004
|
+
the stages, and only stages can be picked: a click on a lane or a group box
|
|
1005
|
+
selects nothing. A wire that would cross a stage in its lane runs along a lane
|
|
1006
|
+
edge clear of every stage it passes, changing height only between columns, each
|
|
1007
|
+
wire in a shared corridor on a track of its own; its label sits as near the
|
|
1008
|
+
middle as it can without covering a stage. A decision's title and cut values
|
|
1009
|
+
stay inside its lanes and column. Text is measured to cut the
|
|
1010
|
+
lanes before `diagramScene` lays anything out, so pass `measure` to the look,
|
|
1011
|
+
not to `diagramScene`: the look hands it on in `scene`.
|
|
1012
|
+
|
|
1013
|
+
`compact` shows no params and keeps no value rows or preview slots: one line
|
|
1014
|
+
has no room for them, so a run fades and costs its stages but writes no values
|
|
1015
|
+
and draws no previews. Its ports
|
|
1016
|
+
carry no type, which is what keeps the port layer from drawing them. A
|
|
1017
|
+
decision draws as a `'hexagon'` by default, the one symbol a single row holds;
|
|
1018
|
+
another `branch` draws at that symbol's own size. Wires between two stages
|
|
1019
|
+
draw as one line, in the color of a data wire among them, dashed only when
|
|
1020
|
+
every one is a control wire, and in the warning color when any was flagged;
|
|
1021
|
+
a decision keeps one line per exit, labeled, so a run can fade the exits it
|
|
1022
|
+
did not take.
|
|
1023
|
+
|
|
1024
|
+
With `collapseSkips` (default true), `rack` draws a decision as an arc over the
|
|
1025
|
+
stages it skips, not as a symbol, when all of these hold:
|
|
1026
|
+
|
|
1027
|
+
- it is on the line, but not first on it;
|
|
1028
|
+
- it has one input and two outputs, with one wire into it and two out;
|
|
1029
|
+
- the wire in comes from the stage before it on the line;
|
|
1030
|
+
- one wire out goes to the next stage on the line, and the other lands on a
|
|
1031
|
+
stage further along;
|
|
1032
|
+
- none of the stage before it, the stage it goes on to and the stage its skip
|
|
1033
|
+
lands on is a decision. A source or a sink is fine.
|
|
1034
|
+
|
|
1035
|
+
A decision stage draws as a branch symbol, chosen by `branch`:
|
|
1036
|
+
|
|
1037
|
+
| `branch` | Symbol |
|
|
1038
|
+
| --- | --- |
|
|
1039
|
+
| `'auto'` (default) | `'bar'` for a decision with a sound `split`, else `'diamond'`. |
|
|
1040
|
+
| `'diamond'` | A flowchart diamond titled with the stage. |
|
|
1041
|
+
| `'bar'` | A tall bar in bands of equal height, the cut values as labels at the ticks. |
|
|
1042
|
+
| `'demux'` | A trapezoid with the exits spread down its right side. |
|
|
1043
|
+
| `'switch'` | A lever thrown between two contacts. |
|
|
1044
|
+
| `'hexagon'` | The stage's title inside a flat hexagon, on one line. |
|
|
1045
|
+
| `'step'` | A staircase rising a step at each cut, the cut values under the risers. |
|
|
1046
|
+
| `'comparator'` | A triangle pointing right, its two exits leaving by its slanted edges. With a sound one-cut `split` the high band leaves by the upper edge and the cut is written inside; otherwise the first exit leaves by the upper edge, and two inputs on its flat side are marked `+` and `−`. |
|
|
1047
|
+
| `'crossover'` | One filter curve per band, lowest at the bottom, overlapping at each cut. |
|
|
1048
|
+
| `'fork'` | A bare dot every exit leaves from, the conditions left to the labels on its wires. |
|
|
1049
|
+
|
|
1050
|
+
`'bar'`, `'step'` and `'crossover'` draw as a diamond for a stage with no `split` or a bad one, and
|
|
1051
|
+
`'switch'` and `'comparator'` for a stage that does not have exactly two outputs.
|
|
1052
|
+
`BRANCH_SYMBOLS`, `branchNode` and `symbolNameFor` expose the same lookup. The
|
|
1053
|
+
signal enters on the left; inputs of a `control` type enter at the top, and so
|
|
1054
|
+
does the input a `'bar'`, `'step'` or `'crossover'` tests, when the stage has more than one input. A symbol
|
|
1055
|
+
takes `{ controlSide: 'bottom' }` to bring those inputs in at the bottom instead,
|
|
1056
|
+
and `rack` passes it with `side: 'below'`, so a control wire comes straight up from
|
|
1057
|
+
the stage driving it; `lanes` passes it when the control inputs' lanes are below.
|
|
1058
|
+
The title then moves above the symbol, and a diamond takes control in by its
|
|
1059
|
+
lower-left edge, keeping its bottom vertex for the second exit. A symbol's lines, dots and ticks are drawn in the `text` color of
|
|
1060
|
+
`{ style }` (default `stageStyle(stage)`, so a bypassed decision draws dim), and a
|
|
1061
|
+
`'bar'`'s bands and a `'crossover'`'s curves in `{ tints }`, lowest first; a bypassed
|
|
1062
|
+
stage's default to its style's `stroke`.
|
|
1063
|
+
|
|
1064
|
+
A look is an `EffectLook<TOptions>`. Plain `EffectLook` is a look whose options
|
|
1065
|
+
are not known, so a list of looks with different options, such as
|
|
1066
|
+
`[nodeGraph, rack]`, is an `EffectLook[]`; such a look is called with no options.
|
|
1067
|
+
|
|
1068
|
+
To write a look, return `{ data, scene, color }`, and `exits` if it draws a
|
|
1069
|
+
decision as something other than itself; `scene` takes any
|
|
1070
|
+
`diagramScene` option. The shipped looks are built from these, which are
|
|
1071
|
+
exported for yours:
|
|
1072
|
+
|
|
1073
|
+
| Helper | Gives |
|
|
1074
|
+
| --- | --- |
|
|
1075
|
+
| `uniqueStages(pipeline)`, `uniqueGroups(pipeline)` | The stages and the groups with any repeated id dropped, first one kept. `diagramScene` throws on a repeated id, so build nodes and groups from these. |
|
|
1076
|
+
| `drawableWires(pipeline)` | The wires whose two stages and two ports exist, each as `{ wire, index, type, problem, fromStage, toStage }`: `type` is the value type it carries, and `problem` is true when `checkPipeline` reported it. |
|
|
1077
|
+
| `typeColors(pipeline, colors)` | The `color(type)` the shipped looks use. |
|
|
1078
|
+
| `stageStyle(stage)` | A stage's node style: dashed and dim when it is bypassed. |
|
|
1079
|
+
| `wireStyle(color, control, problem)` | A wire's edge style: dashed for a control wire, and `PROBLEM_COLOR` for a flagged one. |
|
|
1080
|
+
| `isControl(pipeline, type)` | Whether `types` marks a value type as `control`. |
|
|
1081
|
+
| `kindOf(stage)` | The stage's `kind`, with any value the shipped looks do not know read as `'stage'`. |
|
|
1082
|
+
| `portOn(stage, id)` | The stage's port with that id. |
|
|
1083
|
+
| `stageRows(stage, { showParams, withPorts, showValues, showPreviews, previewSize })` | Body rows for a stage: its title, a row of its ports, per output a value row (`valueRowId(port)`, showing `NO_VALUE`) and a preview slot (`previewRowId(port)`), its params. |
|
|
1084
|
+
| `wireEdges(pipeline, color)` | An edge per drawable wire, from port to port, named by `exitLabel` and styled by `wireStyle`, each as `{ edge, style }`. |
|
|
1085
|
+
| `exitLabel(wire)` | What to call one of `drawableWires`' wires: its own label, or for a wire leaving a decision the exit's label or id. |
|
|
1086
|
+
| `branchNode(stage, pipeline, branch, { controlSide, style, tints })` | The node a decision draws as. |
|
|
1087
|
+
|
|
1088
|
+
```tsx
|
|
1089
|
+
import {
|
|
1090
|
+
drawableWires, isControl, stageStyle, typeColors, uniqueGroups, uniqueStages, wireStyle,
|
|
1091
|
+
type EffectLook,
|
|
1092
|
+
} from '@weasel-js/diagram/effect';
|
|
1093
|
+
|
|
1094
|
+
const column: EffectLook = (pipeline) => {
|
|
1095
|
+
const color = typeColors(pipeline);
|
|
1096
|
+
const stages = new Map(uniqueStages(pipeline).map((s) => [s.id, s]));
|
|
1097
|
+
const wires = drawableWires(pipeline);
|
|
1098
|
+
return {
|
|
1099
|
+
data: {
|
|
1100
|
+
nodes: [...stages.values()].map((s) => ({ id: s.id, lines: [s.title] })),
|
|
1101
|
+
edges: wires.map((d) => ({ from: d.wire.from.stage, to: d.wire.to.stage })),
|
|
1102
|
+
groups: uniqueGroups(pipeline),
|
|
1103
|
+
},
|
|
1104
|
+
scene: {
|
|
1105
|
+
layout: 'layered',
|
|
1106
|
+
layoutOptions: { direction: 'down' },
|
|
1107
|
+
nodeStyle: (n) => stageStyle(stages.get(n.id)!),
|
|
1108
|
+
edgeStyle: (e) => {
|
|
1109
|
+
const d = wires.find((d) => d.wire.from.stage === e.from && d.wire.to.stage === e.to)!;
|
|
1110
|
+
return wireStyle(color(d.type), isControl(pipeline, d.type), d.problem);
|
|
1111
|
+
},
|
|
1112
|
+
},
|
|
1113
|
+
color,
|
|
1114
|
+
};
|
|
1115
|
+
};
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
#### Editing a pipeline
|
|
1119
|
+
|
|
1120
|
+
`EffectEditor` is `EffectChart` with the node-graph look made editable: drag a
|
|
1121
|
+
stage to move it, drag it into or out of a group (below), drag from an output to an input to wire them, press Delete to
|
|
1122
|
+
remove the selection, drag an end of a selected wire to move it, and drag the
|
|
1123
|
+
handles along a selected wire to route it (`EffectDoc.routes`, set with
|
|
1124
|
+
`setWireRoute`). It keeps no
|
|
1125
|
+
document state. Every edit comes back through `onChange` as the next `EffectDoc` with a
|
|
1126
|
+
name for an undo entry, and shows once `doc` follows.
|
|
1127
|
+
|
|
1128
|
+
```tsx
|
|
1129
|
+
import { EffectEditor, type EffectEditorApi } from '@weasel-js/diagram/effect';
|
|
1130
|
+
|
|
1131
|
+
const api = useRef<EffectEditorApi>(null);
|
|
1132
|
+
|
|
1133
|
+
<EffectEditor ref={api} doc={doc} onChange={(next, label) => commit(next, label)} />;
|
|
1134
|
+
|
|
1135
|
+
api.current?.addStage({ type: 'gate', title: 'Gate', inputs, outputs });
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
An `EffectStageType` carries the ports and typed params a new stage starts
|
|
1139
|
+
with. `setParam`, `addWire`, `removeStages` and the rest of the edit model are
|
|
1140
|
+
plain functions from one `EffectDoc` to the next, and `canWire` asks an
|
|
1141
|
+
`EffectPipeline` whether two ports may be wired, for driving the edits yourself.
|
|
1142
|
+
|
|
1143
|
+
`look` picks the drawing: `'nodeGraph'` (default), `'rack'`, or `'lanes'`, with
|
|
1144
|
+
that look's `options`. Connecting, reconnecting, and deleting work the same in
|
|
1145
|
+
all three. Rack and lanes place every stage themselves, so under them a stage
|
|
1146
|
+
does not move freely. Instead a stage on the main line drags along it: an
|
|
1147
|
+
insertion bar marks the gap it would land in, drawn in the problem color where
|
|
1148
|
+
the wiring cannot take it, and releasing it there reports `'Reorder'`. Under
|
|
1149
|
+
lanes, dragging a lane's label up or down reports `'Reorder lanes'`.
|
|
1150
|
+
|
|
1151
|
+
A selected wire's route handles work under every look, and report
|
|
1152
|
+
`'Edit route'`. Under rack and lanes the route goes in `EffectDoc.lookRoutes`,
|
|
1153
|
+
by look and then by wire, with the points of the wire's two ports as they were
|
|
1154
|
+
drawn (`setLookRoute(doc, look, wire, waypoints, ends)`). These looks move
|
|
1155
|
+
stages on every reorder or added stage, so a route is drawn only while both
|
|
1156
|
+
ports are still at those points; once one moves, the look routes the wire
|
|
1157
|
+
again. Reset route, or `setLookRoute` with no waypoints, gives the wire back to
|
|
1158
|
+
the look. `applyLookRoutes(specs, doc, look, built)` draws the routes that
|
|
1159
|
+
hold over a look's specs, and `wireEnds(specs, edge)` reads a drawn wire's
|
|
1160
|
+
port points. A reorder drops the routes of the wires it rewires or removes,
|
|
1161
|
+
and removing a wire or a stage drops that wire's routes.
|
|
1162
|
+
|
|
1163
|
+
```tsx
|
|
1164
|
+
<EffectEditor doc={doc} onChange={commit} look="rack" />
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
The order is the wiring, so a reorder rewires the pipeline:
|
|
1168
|
+
`moveInChain(doc, stage, to)` takes the stage's main input and output wires
|
|
1169
|
+
off, wires its old neighbors to each other, and wires it into gap `to` of
|
|
1170
|
+
`mainChain`. Side wires, control wires, and params stay with it. It returns
|
|
1171
|
+
`null` where a new wire would join unlike types. Lane order is
|
|
1172
|
+
`EffectDoc.laneOrder`, which `setLaneOrder` writes and the editor hands to
|
|
1173
|
+
`lanes` over `options.lanes`. Rack's `chain` option is not offered here, since a
|
|
1174
|
+
given chain could disagree with the wiring.
|
|
1175
|
+
|
|
1176
|
+
The drag itself is general: `createReorderAction` (in `@weasel-js/diagram`)
|
|
1177
|
+
carries one item of any drawn sequence along its axis, and the item lands in
|
|
1178
|
+
the gap nearest its middle. Give it `slots`, the items and gaps that
|
|
1179
|
+
`sequenceSlots(axis, items)` builds from their boxes, and a `commit` that hears
|
|
1180
|
+
`{ id, from, to, order }`. `show: 'bar'` (the default) draws the insertion bar;
|
|
1181
|
+
`show: 'slide'` slides the other items into the places they would take instead,
|
|
1182
|
+
moving each item's `carries` as pose overrides, which is how `onReorder` shows a
|
|
1183
|
+
group's members. `reorderContribution` packs the action and its binding to pass
|
|
1184
|
+
as `ambient`.
|
|
1185
|
+
|
|
1186
|
+
`EffectEditor` takes a `run` too, drawn as `EffectChart` draws one.
|
|
1187
|
+
|
|
1188
|
+
##### Groups
|
|
1189
|
+
|
|
1190
|
+
Under `nodeGraph` each `EffectGroup` is a titled box, drawn as a `DiagramData`
|
|
1191
|
+
container. Drag a stage into or out of it to change what it holds, drag the
|
|
1192
|
+
box to carry the stages and groups inside it with the routes of the wires
|
|
1193
|
+
between them (`'Move'`), drag a picked box's handles to resize it
|
|
1194
|
+
(`'Resize group'`), and double-click its title to rename it in a field held
|
|
1195
|
+
over the title (`'Rename group'`). A resized box is kept in
|
|
1196
|
+
`EffectDoc.groupBoxes`, by group id; a group with no box fits its members.
|
|
1197
|
+
Under `rack` and `lanes` groups draw as boxes around their members, and
|
|
1198
|
+
membership does not drag. A drag's moves and box edits come in as separate
|
|
1199
|
+
reports, so the editor gathers them and reports one edit once the drag is done
|
|
1200
|
+
reporting, a microtask later.
|
|
1201
|
+
|
|
1202
|
+
`selectedGroup` and `onSelectGroup` are the group pick, beside `selected` and
|
|
1203
|
+
`onSelect`, under every look. The ref's `group(ids)` and `ungroup(id)` report
|
|
1204
|
+
`'Group'` and `'Ungroup'`, and `renameGroup(id)` opens the title field. The
|
|
1205
|
+
edits are plain functions too:
|
|
1206
|
+
|
|
1207
|
+
| Function | What it does |
|
|
1208
|
+
| --- | --- |
|
|
1209
|
+
| `groupStages(doc, ids, label?)` | A new group, id from `nextGroupId`, holding `ids`; nested inside the group they all share, else at the top level. |
|
|
1210
|
+
| `ungroup(doc, id)` | Its members go to its parent group, in its place. |
|
|
1211
|
+
| `renameGroup(doc, id, label)` | An empty label removes it. |
|
|
1212
|
+
| `moveToGroup(doc, ids, group \| null)` | Into a group, or out to the top level. A group left empty goes too unless it has a box. |
|
|
1213
|
+
| `setGroupBox(doc, id, box \| null)` | `null` has it fit its members again. |
|
|
1214
|
+
| `moveGroup(doc, id, offset)` | Its stages' places, its box and the boxes inside it, and its inner wires' routes, by one offset. |
|
|
1215
|
+
|
|
1216
|
+
`removeStages` drops a group it leaves empty, with its box.
|
|
1217
|
+
|
|
1218
|
+
#### Running a pipeline
|
|
1219
|
+
|
|
1220
|
+
A run can be made as well as drawn. An `EffectEvaluator` evaluates one stage:
|
|
1221
|
+
it is handed `{ stage, inputs, params, sources, time, signal }` and returns
|
|
1222
|
+
`{ outputs, taken? }`, sync or async, with a value per output by port id. An
|
|
1223
|
+
`EffectValueKind` says how a run shows (`show`), previews (`preview`), and frees
|
|
1224
|
+
(`dispose`) the values of one value type. Both are keyed: evaluators by stage
|
|
1225
|
+
`type`, kinds by value type.
|
|
1226
|
+
|
|
1227
|
+
```tsx
|
|
1228
|
+
import {
|
|
1229
|
+
IMAGE_STAGE_TYPES, imageEvaluators, imageValueKinds, testCard, runPipeline, useEffectRunner,
|
|
1230
|
+
} from '@weasel-js/diagram/effect';
|
|
1231
|
+
|
|
1232
|
+
const once = await runPipeline(pipeline, {
|
|
1233
|
+
evaluators: imageEvaluators, kinds: imageValueKinds, sources: { image: testCard(256) }, time: 0,
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// In a component: Run, Step, and Play, with the run to draw.
|
|
1237
|
+
const { run, playing, runner } = useEffectRunner({ pipeline, evaluators: imageEvaluators, kinds: imageValueKinds, sources });
|
|
1238
|
+
<EffectEditor doc={doc} onChange={commit} run={run} options={{ showValues: true, showPreviews: true }} />;
|
|
1239
|
+
runner.step();
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
`runPipeline` evaluates the stages in `runOrder(pipeline)`: topologically over
|
|
1243
|
+
data and control wires alike, stable by stage order, leaving out the stages on a
|
|
1244
|
+
cycle, which never run. A stage with inputs none of which a value reaches is
|
|
1245
|
+
skipped, so a decision's untaken branch stays faded. A bypassed stage passes its
|
|
1246
|
+
first input of each output's type through. An evaluator that throws sets the
|
|
1247
|
+
stage's `error`, and what it feeds is left unreached. Each stage's `cost` is the
|
|
1248
|
+
time its evaluator took. Every value the run made is disposed before it
|
|
1249
|
+
resolves, but never a source; the previews in the run are yours to close.
|
|
1250
|
+
`createPass` takes the same run a stage at a time. A stage with no outputs, such
|
|
1251
|
+
as a sink, shows and previews what reached its inputs instead, so the end of a
|
|
1252
|
+
pipeline shows the finished result; `shownPorts(stage)` says which ports a look
|
|
1253
|
+
keeps value rows and preview slots for.
|
|
1254
|
+
|
|
1255
|
+
`createEffectRunner` keeps one pipeline running: `run()`, `step()` (the next
|
|
1256
|
+
stage, as the run's `at`, and back to the first after the last), `play()`,
|
|
1257
|
+
`pause()`, `reset()`, and `setPipeline`, `setSources`, and `setOptions` for the
|
|
1258
|
+
next run to follow. While playing, `advance(seconds)` moves its clock on and
|
|
1259
|
+
starts a run unless one is still going. It owns the previews of its runs:
|
|
1260
|
+
`drawn(run)` says a run is on screen, and closes the previews of every older run
|
|
1261
|
+
it does not show. `useEffectRunner` makes one for the life of a component,
|
|
1262
|
+
advances it from `useVisibleRaf` while playing, so time spent hidden is not
|
|
1263
|
+
counted, and calls `drawn` after each render.
|
|
1264
|
+
|
|
1265
|
+
`IMAGE_STAGE_TYPES` is a palette of image stages (Source, Blur, Composite, Mask,
|
|
1266
|
+
Oscillator, Decision, and Out), and `imageEvaluators` runs them on
|
|
1267
|
+
`OffscreenCanvas` 2D, an image or mask being an `ImageBitmap`. A source puts out
|
|
1268
|
+
`sources.image`; blur uses the canvas `filter`; composite blends by
|
|
1269
|
+
`globalCompositeOperation`; mask keeps what lies under the mask's opaque
|
|
1270
|
+
pixels; an oscillator puts out `sin(2π·rate·t)`; and a decision chooses by its
|
|
1271
|
+
`split`, or else its first output while the tested number is above 0. A stage
|
|
1272
|
+
missing an input it needs fails, naming it. `imageValueKinds` shows an image by
|
|
1273
|
+
its size and a number to three places. `testCard(size)` draws an image to start
|
|
1274
|
+
from.
|
|
1275
|
+
|
|
1276
|
+
#### Live audio
|
|
1277
|
+
|
|
1278
|
+
`@weasel-js/diagram/effect-audio` plays an audio pipeline through a patchable
|
|
1279
|
+
`@weasel-js/audio` graph, which is an optional peer dependency: install it to
|
|
1280
|
+
use this subpath. It exports `AUDIO_STAGE_TYPES` (the palette),
|
|
1281
|
+
`audioStageNodes` (the graph node each stage builds), `createLiveAudioRunner`
|
|
1282
|
+
with the `useLiveAudioRunner` hook (or `useFollowAudioRunner`, for a runner
|
|
1283
|
+
the caller makes and owns), `livePlan` and `diffPlans` (the pipeline turned
|
|
1284
|
+
into graph patches), and `drawScope` and `drawSpectrum` for the stages' `out`
|
|
1285
|
+
previews.
|
|
1286
|
+
|
|
1287
|
+
```ts
|
|
1288
|
+
import { createAudioEngine } from '@weasel-js/audio';
|
|
1289
|
+
import { createLiveAudioRunner } from '@weasel-js/diagram/effect-audio';
|
|
1290
|
+
|
|
1291
|
+
const engine = createAudioEngine();
|
|
1292
|
+
// pipeline: an EffectPipeline, such as the `pipeline` of an EffectEditor's doc
|
|
1293
|
+
const runner = createLiveAudioRunner({ engine, pipeline });
|
|
1294
|
+
await runner.play();
|
|
1295
|
+
runner.setPipeline(edited); // patches the playing graph; a delay keeps its tail
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
A param that is a level (a gain, a delay's `mix`) is written to the running
|
|
1299
|
+
node; one that is a setting (a wave, a file) replaces the node.
|
|
166
1300
|
|
|
167
1301
|
## Design notes
|
|
168
1302
|
|