@weasel-js/diagram 1.10.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,18 @@ 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
+
73
84
  ### Laying it out
74
85
 
75
- `layered`, `tree` and `force` are plain functions of the graph. Each hands back
76
- the new top-left for every node that **moves** — a node already standing where
77
- the layout wants it is absent, so re-running a layout on an unchanged diagram
78
- writes nothing at all.
86
+ `layered`, `tree`, `force` and `chain` are plain functions of the graph. Each
87
+ hands back the new top-left for every node that **moves** — a node already
88
+ standing where the layout wants it is absent, so re-running a layout on an
89
+ unchanged diagram writes nothing at all.
79
90
 
80
91
  ```tsx
81
92
  useAction(useMemo(() => createLayoutAction<Pose>({
@@ -89,9 +100,73 @@ move as one undo entry. Three rules keep a re-layout from scrambling a diagram
89
100
  someone has arranged: no RNG anywhere, within-rank order seeded from where the
90
101
  nodes already sit, and a node carrying `pinned: true` that nothing moves.
91
102
 
103
+ The first pinned node anchors the layout. Under `layered`, every other pin
104
+ keeps its place across its rank, with the rest of the rank moved clear of it,
105
+ past it if there is no room beside it. A pin's rank band slides or deepens to
106
+ cover it, the gaps toward other pinned ranks shrink to make room, and a group's
107
+ column moves or widens to take in its pinned members, never over a node outside
108
+ the group. A pin is left out only when no arrangement fits: its band would come
109
+ nearer another pinned rank's than the ranks between them allow with every gap
110
+ at a quarter of `rankGap` (plus group insets), or a group's box would cover a
111
+ pin outside the group. In practice that is a pin dragged level with, or past,
112
+ the rank beside it. Such a pin stays put without the layout being built around
113
+ it; `layeredPlan` names it in `unheld`.
114
+
92
115
  `force` is the exception to the second half of that: it is an iterative
93
116
  relaxation seeded from the current positions, so re-running it keeps relaxing.
94
117
 
118
+ `layered` turns around any edge that closes a cycle, then gives each node a
119
+ rank that keeps the total edge length as short as it can (`ranksByLength`,
120
+ network simplex), so a node with no inputs sits beside what it feeds rather
121
+ than in the first rank. It lines each node up with its neighbors across the
122
+ ranks (Brandes–Köpf), lining up the two ports an edge joins rather than the two
123
+ centers. An edge that spans ranks stands in each rank it crosses, so placement
124
+ leaves it room, and an edge whose `GraphEdge.label` gives its label's size gets
125
+ a rank of its own between its ends, with room beside its wire as big as the
126
+ label. Under `order: 'seeded'` an edge keeps its place in each rank the way a
127
+ node does, from where its route crosses that rank now. Under
128
+ `order: 'barycenter'` it also reorders each rank to cut crossings, reading each
129
+ edge at the port it joins, where `portsOf` puts that port on the node's
130
+ outline, so two wires into one node arrive in the order of its ports. A port
131
+ with several places is read at the one nearest where the wire's other end
132
+ falls among the node's wires on that side. `layeredPlan(graph, opts)`, also reachable as `layered.plan`,
133
+ answers with the moves plus each node's rank, each spanning edge's waypoints
134
+ and where among them each label's room is, for drawing those edges where their
135
+ room was left. `ranksOf` is the plain longest-path ranking, which `chain` uses.
136
+
137
+ On a live scene the layout action and a live `layered` run write those routes
138
+ too, in the same undo entry as the moves: each long edge gets its waypoints,
139
+ and its label, when it sits at the default middle, moves into the room left
140
+ beside them. Under `layered` each edge's compass ports are named for the way it
141
+ runs between ranks, and an end records the port layout named as `laidOut`, the
142
+ way a route does; layout names it again on each run while the end names no
143
+ port or still names that one. An edge records the waypoints layout wrote as `laidOut`, and
144
+ layout rewrites a route only while the edge's waypoints still match them; once
145
+ someone moves a bend, the route is theirs and layout leaves it and its label
146
+ alone, and keeps no room for it in the ranks it crosses. A layout that routes
147
+ nothing, `tree` or `force`, takes layered's routes back off, and anything on a
148
+ locked layer stays as it is. A live run writes its routes only when it arrives
149
+ with nothing held along the way, since a drag or a `stop` leaves the nodes
150
+ somewhere the routes were not laid out for. While it eases, each route it will
151
+ write eases with the nodes, shown as the edge's path on the override channel. A layout of your own hands routes
152
+ back the same way, by giving its `LayoutFn` a `plan`, which `diagramScene`
153
+ reads too.
154
+
155
+ `chain` stands one run of nodes on a straight line and hangs everything else
156
+ beside the chain node it is nearest to, counting edges in either direction. It
157
+ is picked like the others, by name: `layout: 'chain'` in `diagramScene`,
158
+ `layout('chain')` on a `DiagramView`, or `algorithm: 'chain'` in
159
+ `createLayoutAction`. The `chain` layout option lists the ids on the line, in
160
+ order; unset, or naming no node of the graph, it is the longest path through
161
+ the graph. `chainSide` is
162
+ `'after'` (default) or `'before'`: which side of the line the rest hangs on.
163
+ Each chain node owns a column as wide as the widest thing hung on it, so
164
+ neighbors never share space, and the hung nodes stand in the order of the ports
165
+ they connect to. An edge between a chain node and one hung beside it, both ends
166
+ naming ports that face each other, runs on a track of its own in the gap
167
+ between them, so a fan of them never crosses; the gap grows to keep tracks 12
168
+ apart. Groups are ignored.
169
+
95
170
  ### From data
96
171
 
97
172
  `diagramScene` turns plain `{ nodes, edges }` into a laid-out scene, and
@@ -105,15 +180,56 @@ return <DiagramView specs={specs} width={360} height={600} selected={id} onSelec
105
180
  Every box is a `buildBody` body, so a data node takes the same knobs a body
106
181
  does — `outline`, `rows`, `padding`, `gap`, `verticalAlign`, `ports` — plus `pinned`, a
107
182
  starting `at`, and a minimum `width`/`height`; `lines` is shorthand for one
108
- label row each. An edge can name `fromPort`/`toPort`, its own `router`,
183
+ label row each. A `slot` row is reserved space, `height` tall and, with a
184
+ `width`, that wide and centered; one with an `id` becomes a scene node of that
185
+ name, `<node>/<id>`, drawn as an empty frame until you fill it by changing its
186
+ data — with `{ image: { src } }`, say. An edge can name `fromPort`/`toPort`, its own `router`,
109
187
  `waypoints` and `labelPlacement`.
110
188
 
189
+ `outline` is `'rect'`, `'diamond'`, `'stadium'`, `'parallelogram'`,
190
+ `'trapezoid'` (a demultiplexer: tall at the right, short at the left),
191
+ `'hexagon'` (pointed at the left and right, its rows kept between the points), or a
192
+ `Path` drawn in a unit square — `(0, 0)` the box's top-left, `(1, 1)` its
193
+ bottom-right — which is stretched over the box, so it follows its node as
194
+ marks do.
195
+
196
+ `marks` draws more over a box than its rows. A mark with `d` is SVG path data
197
+ in a unit square, stretched over the node's box; it takes `fill`, `stroke`,
198
+ `strokeWidth`, `dash` and `opacity`. `diagramScene` throws on path data that
199
+ does not parse or draws nothing, naming the node and the mark's index. A mark with `text` is pinned to a point on
200
+ the box: `at` is that point as `{ u, v }` fractions of the box, `self` is which
201
+ point of the text sits on it (default `at`), and `offset` nudges it in pixels.
202
+ Marks move with their node. `DiagramView` registers the painters for outlines
203
+ and marks itself; a scene drawn on your own `SceneCanvas` calls
204
+ `registerDiagramShape()` once for outlines and `registerMarkShape()` once for
205
+ marks, each of which returns its disposer, or they do not paint:
206
+
207
+ ```tsx
208
+ { id: 'gain', marks: [
209
+ { d: 'M0 1 L1 0', stroke: '#c58bff' },
210
+ { text: 'gain', at: { u: 0.5, v: 1 }, self: { u: 0.5, v: 0 }, offset: { x: 0, y: 4 } },
211
+ ] }
212
+ ```
213
+
214
+ `DiagramView` draws ports when `onConnect` is set, or when `ports` says so:
215
+ `'connect'` draws them and lets one be dragged onto another, `'show'` draws
216
+ them and nothing more, `'none'` hides them. `portOptions` styles them: `shape`
217
+ is `'square'` (default) or `'circle'`, `fill` is one fill or a function of the
218
+ port (its `type`, say), and `showsPort` picks which of a node's ports show.
219
+
111
220
  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, and one
113
- that skips ranks is given waypoints through the gaps in each rank it crosses, so
114
- it goes around what lies between its ends. An edge running back up the ranks
115
- keeps the default: the side facing the other end most directly. A label's
116
- `offset` is the gap between the route and the label's near side.
221
+ toward the next rank and arrives at its target's face toward the last; an edge
222
+ running back up the ranks does the same the other way round. One that spans
223
+ ranks runs through the room `layeredPlan` left for it in each rank it crosses,
224
+ so it goes around what lies between its ends — unless an end is a pin
225
+ `layeredPlan` could not hold (`unheld`), when it takes the router's own path. An
226
+ edge whose pinned ends stand level runs straight between their facing sides, or
227
+ around past both when a node stands between them. An
228
+ author's own ports and waypoints win. A label's `offset` is the gap between the
229
+ route and the label's near side. A label whose edge sets no `labelPlacement`
230
+ of its own, or one at `'mid'`, sits in the room layout left it, placed with
231
+ `at: { near }`, which stations a label where its route passes nearest a point,
232
+ so a bend added or removed elsewhere leaves it where it was.
117
233
 
118
234
  `groups` draws a box around some nodes, with an optional `label` inside it:
119
235
 
@@ -121,15 +237,19 @@ keeps the default: the side facing the other end most directly. A label's
121
237
  diagramScene({ nodes, edges, groups: [{ id: 'ingest', members: ['fetch', 'decode'], label: 'ingest' }] });
122
238
  ```
123
239
 
124
- `layered` and `tree` keep a group's members together. Each group gets a column
125
- on the cross axis that nothing else enters, in every rank from its first
126
- member's to its last, and rank gaps widen where a box needs the room. The box
127
- is an ordinary scene node that depends on its members and derives its pose from
128
- theirs (`groupDerivePose`), so dragging a member carries the box along. It is
129
- never narrower than its label, and a layout running down or up leaves room for
130
- that width. An edge
131
- whose `from` or `to` names a group meets the box, and ranks against the
132
- group's first stage. Groups are flat, and `force` ignores them. A scene built
240
+ A group may be a member of another. `layered`, `tree` and `force` keep a
241
+ group's members together; `chain` ignores groups but still draws them. Under
242
+ `layered` and `tree` each group gets a column on the cross axis that nothing
243
+ else enters, in every rank from its first member's to its last, and rank gaps
244
+ widen where a box needs the room; `force` pulls a group's members toward each
245
+ other and pushes groups apart. The box is an ordinary scene node that depends
246
+ on its members and derives its pose from theirs (`groupDerivePose`), so
247
+ dragging a member carries the box along. It also depends on the labels of the
248
+ wires with both ends inside it, so a long label stays inside the box. It is
249
+ never narrower than its label,
250
+ and a layout running down or up leaves room for that width. An edge whose
251
+ `from` or `to` names a group meets the box, and ranks against the group's first
252
+ stage. A scene built
133
253
  any other way joins in with a node whose trait carries `group` and whose
134
254
  `dependsOn` lists its members.
135
255
 
@@ -153,16 +273,338 @@ since survive it. Editing reports rather than writes —
153
273
  ```
154
274
 
155
275
  — `onMove` turns on dragging and hears every node a drag or a layout run
156
- moved; `onConnect` makes ports grabbable and hears the edge a connect
157
- describes. Store a move as the node's `at`, with `pinned: true` unless the
158
- scene uses `layout: 'none'`, or the next `diagramScene` lays it out again.
276
+ moved, and, as its second argument, every edge whose route a layout run
277
+ or ports rewrote (`{ id, waypoints, ports }`, `id` being `edgeIdOf` of the
278
+ data edge, `ports` the ends whose port the run picked);
279
+ `onConnect` makes ports grabbable and hears the edge a connect describes.
280
+ Store a move as the node's `at`, with `pinned: true` unless the scene uses
281
+ `layout: 'none'`, or the next `diagramScene` lays it out again. Store a route
282
+ as the edge's `routed` and its `ports` as `routedPorts`, which `edgeIdOf` does
283
+ not read: it is drawn until layout runs again, and stays
284
+ layout's to replace, its label keeping the room layout leaves it. `waypoints`
285
+ would make it yours, which layout never moves. A run that only rewrote routes
286
+ calls `onMove` with no moves.
287
+ `onMove` hears only what changes after the view is built. Where the build's own
288
+ 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`
291
+ leaves those nodes where they stand and lays out only the new ones, which keeps
292
+ a diagram still while its data is edited.
159
293
  `view.current.layout('layered')` re-runs a layout animated from where the
160
- nodes stand; `'force'` relaxes live.
294
+ nodes stand; `'force'` relaxes live. Either way the routes ease with the nodes,
295
+ through the ports the run will commit.
296
+
297
+ `layout()` also takes a `LayoutPlanner`, an object whose `plan(graph, opts)`
298
+ answers with a whole `LayoutPlan`, now or as a promise. The run asks it once,
299
+ with the graph as it stands, and eases to the answer the way it eases to
300
+ `layered`'s, reporting the same moves, routes and ports to `onMove`. While the
301
+ answer is on its way, a drag, a change to the data, or another `layout()` call
302
+ drops it, since the plan was made for a diagram that is no longer there. A
303
+ planner whose promise rejects starts no run, and the reason goes to
304
+ `live.onError`, or to `console.error` when that is unset.
305
+ `elkPlanner()` from `@weasel-js/diagram-elk` is one: it lays the diagram out
306
+ with ELK (the Eclipse Layout Kernel).
307
+
308
+ ```tsx
309
+ view.current.layout(elkPlanner({ direction: 'right' }));
310
+ ```
161
311
 
162
312
  Labels paint only in a registered font family, and the default is
163
313
  sans-serif, so call `registerCanvasFont('sans-serif')` once or they render
164
- blank. `minScale` stops the initial fit from shrinking a wide diagram past
165
- readable; the rest is a pan away.
314
+ blank. The view opens fitted to the diagram and fits again when `width` or
315
+ `height` changes or a layout run settles, until the user pans or zooms. `minScale` stops the fit from
316
+ shrinking a wide diagram past readable; the rest is a pan away.
317
+
318
+ ### Effect charts
319
+
320
+ `@weasel-js/diagram/effect` draws an effects pipeline: the stages a signal
321
+ passes through, what each reads and writes, and where a decision (a stage that
322
+ sends the signal one of several ways) sends it.
323
+
324
+ ```tsx
325
+ import { EffectChart, type EffectPipeline } from '@weasel-js/diagram/effect';
326
+
327
+ const pipeline: EffectPipeline = {
328
+ stages: [
329
+ { id: 'in', title: 'Input', kind: 'source', outputs: [{ id: 'out', type: 'audio' }] },
330
+ {
331
+ id: 'gate',
332
+ title: 'Gate',
333
+ inputs: [{ id: 'in', type: 'audio' }],
334
+ outputs: [{ id: 'out', type: 'audio' }],
335
+ params: [{ label: 'threshold', value: '-40 dB' }],
336
+ },
337
+ {
338
+ id: 'verb',
339
+ title: 'Reverb',
340
+ inputs: [{ id: 'in', type: 'audio' }],
341
+ outputs: [{ id: 'out', type: 'audio' }],
342
+ },
343
+ { id: 'out', title: 'Output', kind: 'sink', inputs: [{ id: 'in', type: 'audio' }] },
344
+ ],
345
+ wires: [
346
+ { from: { stage: 'in', port: 'out' }, to: { stage: 'gate', port: 'in' } },
347
+ { from: { stage: 'gate', port: 'out' }, to: { stage: 'verb', port: 'in' } },
348
+ { from: { stage: 'verb', port: 'out' }, to: { stage: 'out', port: 'in' } },
349
+ ],
350
+ };
351
+
352
+ <EffectChart pipeline={pipeline} width={900} height={420} background={{ color: '#10161c' }} />
353
+ ```
354
+
355
+ A pipeline is plain data and says nothing about how it is drawn:
356
+
357
+ | Field | Meaning |
358
+ | --- | --- |
359
+ | `stages` | The stages, below. |
360
+ | `wires` | `{ from: { stage, port }, to: { stage, port }, label? }`, from an output to an input. |
361
+ | `groups` | `{ id, label?, members }`: a box around stages and other groups. |
362
+ | `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. |
363
+
364
+ A stage:
365
+
366
+ | Field | Meaning |
367
+ | --- | --- |
368
+ | `id`, `title` | Required. |
369
+ | `kind` | `'stage'` (default), `'decision'`, `'source'` or `'sink'`. Any other value draws as `'stage'`. |
370
+ | `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. |
371
+ | `params` | `{ label, value }` pairs shown on the box. A decision does not show them. |
372
+ | `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. |
373
+ | `bypassed` | The stage is in the pipeline and switched off; it draws dashed and dim. |
374
+ | `data` | Anything else a look wants. The shipped looks do not read it. |
375
+
376
+ `EffectChart` takes:
377
+
378
+ | Prop | Meaning |
379
+ | --- | --- |
380
+ | `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. |
381
+ | `run` | One run of the pipeline, drawn over it (below). It may change every frame. |
382
+ | `look`, `options` | How it is drawn, and that look's options. Default look `nodeGraph`. |
383
+ | `width`, `height` | The canvas size. |
384
+ | `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. |
385
+ | `background` | What the canvas paints behind the chart. Unset, it is transparent; the shipped looks are drawn for a dark ground. |
386
+ | `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. |
387
+ | `onProblems` | Hears what `checkPipeline` found, on mount and each time the pipeline's content changes, whether or not the list differs. |
388
+ | `className` | A class for the canvas element. |
389
+
390
+ Keep `look` and `options` steady between renders: a new object for either lays
391
+ the chart out again and opens it fitted afresh, as a pipeline whose content
392
+ changes does. Ports that
393
+ carry a value type are drawn, colored by type. As with `DiagramView`, call
394
+ `registerCanvasFont('sans-serif')` once or labels render blank.
395
+
396
+ #### Runs
397
+
398
+ A run is what one pass through the pipeline did, kept beside the pipeline
399
+ rather than in it, keyed by stage id:
400
+
401
+ ```ts
402
+ const run: EffectRun = {
403
+ stages: {
404
+ src: { cost: 0.3, values: { out: '1920×1080' } },
405
+ q: { taken: 'yes' }, // a decision's chosen output
406
+ blur: { cost: 4.2, values: { out: '6.0 px' } },
407
+ },
408
+ };
409
+ ```
410
+
411
+ A stage the run has no entry for is one it never reached. The chart fades such
412
+ a stage, its rows and its wires, and a decision's wires to the outputs it did
413
+ not take, every one of them when `taken` names none; where a look draws a
414
+ decision as something else, as `rack` draws a skip as an arc, the look's
415
+ `exits` say which edges stand for which exit. It writes each stage's `cost`, in milliseconds, above its top-right
416
+ corner; and writes each output's value into the row a look keeps for it when
417
+ its `showValues` option is on, with a dash for an output the run says nothing
418
+ about. A `switch` symbol is drawn thrown to its first exit, and a run whose
419
+ `taken` is the second throws it there. `EffectChart` fades the port dots of a
420
+ stage the run never reached as well; `paintRun` alone cannot, since the port
421
+ layer draws them outside the scene. A decision a look does not draw has nowhere
422
+ to show its cost.
423
+
424
+ A run never lays the chart out again or refits it: `paintRun(pipeline, specs,
425
+ run)` edits the specs the look already built, data only, and `DiagramView`
426
+ writes just the fields that changed. So `showValues` keeps its rows from the
427
+ first draw, wide enough for a value of about nine characters, and a longer one
428
+ runs past the box's edge rather than resizing it.
429
+
430
+ A run can also carry a picture per output, `previews: { [port]: ImageBitmap }`
431
+ on a stage's entry. With a look's `showPreviews` on, each output gets a slot
432
+ `previewSize` big (default `PREVIEW_SIZE`, a 48-unit square; or `{ width,
433
+ height }`) under its value row, kept from the first draw like the value rows,
434
+ drawn as an empty frame until a run fills it. The preview is stretched to the
435
+ slot. It is an `ImageBitmap`, not a canvas, because a bitmap's pixels cannot
436
+ change: a run that hands back the same bitmap edits nothing and uploads
437
+ nothing, and new pixels are a new bitmap. A canvas you draw into becomes one
438
+ with `transferToImageBitmap()` (`OffscreenCanvas`) or `createImageBitmap()`.
439
+ `EffectChart` frees the texture of a bitmap the next run no longer shows, and
440
+ of every one it shows when it unmounts; closing a bitmap is still yours to do.
441
+ Under `paintRun` alone the slot becomes an image node whose `src` is
442
+ `bitmapSrc(bitmap)` from `@weasel-js/core`, and releasing the replaced
443
+ textures is the caller's: `releaseImageSource(old)` from
444
+ `@weasel-js/core/renderer`.
445
+
446
+ `checkPipeline(pipeline)` returns the problems without drawing anything, each
447
+ `{ kind, message, stage?, port?, wire?, group? }`, where `wire` is an index into
448
+ `pipeline.wires`. The shipped looks draw a pipeline with problems as far as
449
+ they can:
450
+
451
+ | `kind` | When | On the chart |
452
+ | --- | --- | --- |
453
+ | `missing-stage`, `missing-port` | A wire names a stage or port that does not exist. | The wire is left out. |
454
+ | `wrong-direction` | A wire starts at an input or ends at an output. | The wire is drawn in the warning color. |
455
+ | `type-mismatch` | A wire joins ports of different types. | The wire is drawn in the warning color. |
456
+ | `duplicate-stage` | Two stages share an id. | The first is drawn. |
457
+ | `duplicate-port` | Two ports on one stage share an id. | Both are listed; wires attach to the first. |
458
+ | `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. |
459
+ | `missing-member` | A group names something that is neither a stage nor a group. | The name is ignored. |
460
+ | `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. |
461
+
462
+ #### Looks
463
+
464
+ A look is how a pipeline is drawn: a function from the pipeline and its
465
+ options to `{ data, scene, color }`, which are `diagramScene`'s two arguments
466
+ and a `color(type)` for the ports. `EFFECT_LOOKS` holds `nodeGraph`, `rack`, `lanes` and `compact`.
467
+
468
+ | Look | Draws | Options |
469
+ | --- | --- | --- |
470
+ | `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` |
471
+ | `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` |
472
+ | `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` |
473
+ | `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` |
474
+
475
+ `colors` maps a value type to a color, over the palette; `showParams` (default
476
+ true) shows the `params` of ordinary stages, sources and sinks; `showValues`
477
+ (default false) keeps a row per output on those stages for a run's values, and
478
+ `showPreviews` (default false) a slot per output for its previews. `rack` runs
479
+ along `chain`, a list of stage ids that defaults to the longest path over data
480
+ wires (`mainChain(pipeline)` returns it); `side` is `'below'` (default) or
481
+ `'above'`.
482
+
483
+ `lanes` puts the lanes top down in the order their types first appear on the
484
+ pipeline's ports, after any it is given in `lanes`. A stage stands
485
+ `LANE_INSET` inside the first and last lane it touches and covers every lane
486
+ between; its inputs sit on its left edge and its outputs on its right, each in
487
+ its own type's lane. Stages stand in columns, each right of every stage that
488
+ feeds it, and a stage moves a column further right rather than share one with
489
+ a stage holding any of its lanes. A lane is `laneHeight` (default 48) tall, or
490
+ taller until each stage fits the lanes it spans, params and value rows
491
+ included; a source or sink stretched taller than it measured is widened by as
492
+ much, so its round ends leave its rows the room they had. A decision keeps its symbol's own size, centered across its lanes,
493
+ and brings its control inputs in from the bottom when their lanes are below
494
+ the rest of its ports. The lanes are nodes too, `laneId(type)`, drawn behind
495
+ the stages, and only stages can be picked: a click on a lane or a group box
496
+ selects nothing. A wire that would cross a stage in its lane runs along a lane
497
+ edge clear of every stage it passes, changing height only between columns, each
498
+ wire in a shared corridor on a track of its own; its label sits as near the
499
+ middle as it can without covering a stage. A decision's title and cut values
500
+ stay inside its lanes and column. Text is measured to cut the
501
+ lanes before `diagramScene` lays anything out, so pass `measure` to the look,
502
+ not to `diagramScene`: the look hands it on in `scene`.
503
+
504
+ `compact` shows no params and keeps no value rows or preview slots: one line
505
+ has no room for them, so a run fades and costs its stages but writes no values
506
+ and draws no previews. Its ports
507
+ carry no type, which is what keeps the port layer from drawing them. A
508
+ decision draws as a `'hexagon'` by default, the one symbol a single row holds;
509
+ another `branch` draws at that symbol's own size. Wires between two stages
510
+ draw as one line, in the color of a data wire among them, dashed only when
511
+ every one is a control wire, and in the warning color when any was flagged;
512
+ a decision keeps one line per exit, labeled, so a run can fade the exits it
513
+ did not take.
514
+
515
+ With `collapseSkips` (default true), `rack` draws a decision as an arc over the
516
+ stages it skips, not as a symbol, when all of these hold:
517
+
518
+ - it is on the line, but not first on it;
519
+ - it has one input and two outputs, with one wire into it and two out;
520
+ - the wire in comes from the stage before it on the line;
521
+ - one wire out goes to the next stage on the line, and the other lands on a
522
+ stage further along;
523
+ - none of the stage before it, the stage it goes on to and the stage its skip
524
+ lands on is a decision. A source or a sink is fine.
525
+
526
+ A decision stage draws as a branch symbol, chosen by `branch`:
527
+
528
+ | `branch` | Symbol |
529
+ | --- | --- |
530
+ | `'auto'` (default) | `'bar'` for a decision with a sound `split`, else `'diamond'`. |
531
+ | `'diamond'` | A flowchart diamond titled with the stage. |
532
+ | `'bar'` | A tall bar in bands of equal height, the cut values as labels at the ticks. |
533
+ | `'demux'` | A trapezoid with the exits spread down its right side. |
534
+ | `'switch'` | A lever thrown between two contacts. |
535
+ | `'hexagon'` | The stage's title inside a flat hexagon, on one line. |
536
+ | `'step'` | A staircase rising a step at each cut, the cut values under the risers. |
537
+ | `'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 `−`. |
538
+ | `'crossover'` | One filter curve per band, lowest at the bottom, overlapping at each cut. |
539
+ | `'fork'` | A bare dot every exit leaves from, the conditions left to the labels on its wires. |
540
+
541
+ `'bar'`, `'step'` and `'crossover'` draw as a diamond for a stage with no `split` or a bad one, and
542
+ `'switch'` and `'comparator'` for a stage that does not have exactly two outputs.
543
+ `BRANCH_SYMBOLS`, `branchNode` and `symbolNameFor` expose the same lookup. The
544
+ signal enters on the left; inputs of a `control` type enter at the top, and so
545
+ does the input a `'bar'`, `'step'` or `'crossover'` tests, when the stage has more than one input. A symbol
546
+ takes `{ controlSide: 'bottom' }` to bring those inputs in at the bottom instead,
547
+ and `rack` passes it with `side: 'below'`, so a control wire comes straight up from
548
+ the stage driving it; `lanes` passes it when the control inputs' lanes are below.
549
+ The title then moves above the symbol, and a diamond takes control in by its
550
+ 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
551
+ `{ style }` (default `stageStyle(stage)`, so a bypassed decision draws dim), and a
552
+ `'bar'`'s bands and a `'crossover'`'s curves in `{ tints }`, lowest first; a bypassed
553
+ stage's default to its style's `stroke`.
554
+
555
+ A look is an `EffectLook<TOptions>`. Plain `EffectLook` is a look whose options
556
+ are not known, so a list of looks with different options, such as
557
+ `[nodeGraph, rack]`, is an `EffectLook[]`; such a look is called with no options.
558
+
559
+ To write a look, return `{ data, scene, color }`, and `exits` if it draws a
560
+ decision as something other than itself; `scene` takes any
561
+ `diagramScene` option. The shipped looks are built from these, which are
562
+ exported for yours:
563
+
564
+ | Helper | Gives |
565
+ | --- | --- |
566
+ | `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. |
567
+ | `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. |
568
+ | `typeColors(pipeline, colors)` | The `color(type)` the shipped looks use. |
569
+ | `stageStyle(stage)` | A stage's node style: dashed and dim when it is bypassed. |
570
+ | `wireStyle(color, control, problem)` | A wire's edge style: dashed for a control wire, and `PROBLEM_COLOR` for a flagged one. |
571
+ | `isControl(pipeline, type)` | Whether `types` marks a value type as `control`. |
572
+ | `kindOf(stage)` | The stage's `kind`, with any value the shipped looks do not know read as `'stage'`. |
573
+ | `portOn(stage, id)` | The stage's port with that id. |
574
+ | `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. |
575
+ | `wireEdges(pipeline, color)` | An edge per drawable wire, from port to port, named by `exitLabel` and styled by `wireStyle`, each as `{ edge, style }`. |
576
+ | `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. |
577
+ | `branchNode(stage, pipeline, branch, { controlSide, style, tints })` | The node a decision draws as. |
578
+
579
+ ```tsx
580
+ import {
581
+ drawableWires, isControl, stageStyle, typeColors, uniqueGroups, uniqueStages, wireStyle,
582
+ type EffectLook,
583
+ } from '@weasel-js/diagram/effect';
584
+
585
+ const column: EffectLook = (pipeline) => {
586
+ const color = typeColors(pipeline);
587
+ const stages = new Map(uniqueStages(pipeline).map((s) => [s.id, s]));
588
+ const wires = drawableWires(pipeline);
589
+ return {
590
+ data: {
591
+ nodes: [...stages.values()].map((s) => ({ id: s.id, lines: [s.title] })),
592
+ edges: wires.map((d) => ({ from: d.wire.from.stage, to: d.wire.to.stage })),
593
+ groups: uniqueGroups(pipeline),
594
+ },
595
+ scene: {
596
+ layout: 'layered',
597
+ layoutOptions: { direction: 'down' },
598
+ nodeStyle: (n) => stageStyle(stages.get(n.id)!),
599
+ edgeStyle: (e) => {
600
+ const d = wires.find((d) => d.wire.from.stage === e.from && d.wire.to.stage === e.to)!;
601
+ return wireStyle(color(d.type), isControl(pipeline, d.type), d.problem);
602
+ },
603
+ },
604
+ color,
605
+ };
606
+ };
607
+ ```
166
608
 
167
609
  ## Design notes
168
610