@weasel-js/diagram 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +696 -4
  2. package/dist/chunk-2SYY4W3B.js +2055 -0
  3. package/dist/chunk-2SYY4W3B.js.map +1 -0
  4. package/dist/chunk-5GIP2LA6.js +312 -0
  5. package/dist/chunk-5GIP2LA6.js.map +1 -0
  6. package/dist/chunk-DYDX6EJC.js +3653 -0
  7. package/dist/chunk-DYDX6EJC.js.map +1 -0
  8. package/dist/chunk-GA4BX7SH.js +1431 -0
  9. package/dist/chunk-GA4BX7SH.js.map +1 -0
  10. package/dist/{chunk-NUMMW5BI.js → chunk-R2XB6D32.js} +416 -36
  11. package/dist/chunk-R2XB6D32.js.map +1 -0
  12. package/dist/chunk-WDIE35AK.js +3 -0
  13. package/dist/chunk-WDIE35AK.js.map +1 -0
  14. package/dist/{fromData-GKqRgPFs.d.ts → dataTypes-DHdkzCL8.d.ts} +238 -59
  15. package/dist/effect-audio.d.ts +139 -0
  16. package/dist/effect-audio.js +533 -0
  17. package/dist/effect-audio.js.map +1 -0
  18. package/dist/effect.d.ts +486 -121
  19. package/dist/effect.js +1632 -347
  20. package/dist/effect.js.map +1 -1
  21. package/dist/icons.table.d.ts +6 -0
  22. package/dist/icons.table.js +32 -0
  23. package/dist/icons.table.js.map +1 -0
  24. package/dist/icons.table.json +24 -0
  25. package/dist/index.d.ts +2159 -92
  26. package/dist/index.js +1056 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/layout.d.ts +3 -3
  29. package/dist/layout.entry-B-jJMc50.d.ts +617 -0
  30. package/dist/layout.js +3 -1
  31. package/dist/stageTypes-DvnR8cKQ.d.ts +189 -0
  32. package/dist/{body-2lC7IiIA.d.ts → types-M_ccNAlk.d.ts} +221 -182
  33. package/package.json +28 -7
  34. package/dist/chunk-C4NAI6NV.js +0 -2041
  35. package/dist/chunk-C4NAI6NV.js.map +0 -1
  36. package/dist/chunk-NUMMW5BI.js.map +0 -1
  37. package/dist/layout.entry-BoWFcR4G.d.ts +0 -285
package/README.md CHANGED
@@ -81,6 +81,440 @@ node — so a decision's second exit can leave by its top or its bottom,
81
81
  wherever its wire goes. `portSitesOf` resolves every place; anything else that
82
82
  shows a port, the connect affordance included, shows it at `at`.
83
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
+
84
518
  ### Laying it out
85
519
 
86
520
  `layered`, `tree`, `force` and `chain` are plain functions of the graph. Each
@@ -184,7 +618,15 @@ label row each. A `slot` row is reserved space, `height` tall and, with a
184
618
  `width`, that wide and centered; one with an `id` becomes a scene node of that
185
619
  name, `<node>/<id>`, drawn as an empty frame until you fill it by changing its
186
620
  data — with `{ image: { src } }`, say. An edge can name `fromPort`/`toPort`, its own `router`,
187
- `waypoints` and `labelPlacement`.
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'`.
188
630
 
189
631
  `outline` is `'rect'`, `'diamond'`, `'stadium'`, `'parallelogram'`,
190
632
  `'trapezoid'` (a demultiplexer: tall at the right, short at the left),
@@ -253,6 +695,37 @@ stage. A scene built
253
695
  any other way joins in with a node whose trait carries `group` and whose
254
696
  `dependsOn` lists its members.
255
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
+
256
729
  Text is sized from character counts unless `measure` is given, so layout runs
257
730
  without a canvas. The layout defaults to `layered`, picked by name or passed as
258
731
  any `LayoutFn`; `layoutOptions` merge over `order: 'barycenter'`, and
@@ -286,10 +759,44 @@ would make it yours, which layout never moves. A run that only rewrote routes
286
759
  calls `onMove` with no moves.
287
760
  `onMove` hears only what changes after the view is built. Where the build's own
288
761
  layout put things, `diagramLayoutOf(specs)` reads off the specs, in the same
289
- shape (`{ moves, routes }`): every node not pinned, and every edge whose route
290
- or ports layout wrote. Store them the same way and the next `diagramScene`
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`
291
765
  leaves those nodes where they stand and lays out only the new ones, which keeps
292
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`).
293
800
  `view.current.layout('layered')` re-runs a layout animated from where the
294
801
  nodes stand; `'force'` relaxes live. Either way the routes ease with the nodes,
295
802
  through the ports the run will commit.
@@ -419,7 +926,9 @@ about. A `switch` symbol is drawn thrown to its first exit, and a run whose
419
926
  `taken` is the second throws it there. `EffectChart` fades the port dots of a
420
927
  stage the run never reached as well; `paintRun` alone cannot, since the port
421
928
  layer draws them outside the scene. A decision a look does not draw has nowhere
422
- to show its cost.
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.
423
932
 
424
933
  A run never lays the chart out again or refits it: `paintRun(pipeline, specs,
425
934
  run)` edits the specs the look already built, data only, and `DiagramView`
@@ -606,6 +1115,189 @@ const column: EffectLook = (pipeline) => {
606
1115
  };
607
1116
  ```
608
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.
1300
+
609
1301
  ## Design notes
610
1302
 
611
1303
  Decisions the code cannot explain on its own.