disposition 0.2.0

SVG diagram generator
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
# Edge Descriptions Plan

## Overview

This document describes the steps needed to add per-edge description labels to
the diagram without having them overlap other nodes or other edges' labels.

The high-level approach is:

1. Determine which face each edge exits/enters per node **before** layout, using
   rank and sibling data rather than post-layout pixel coordinates.
2. Wrap each diagram node's taffy subtree in a new `envelope_node` that adds
   flex-row/column slots for edge label leaf nodes on each face.
3. Read the computed label positions after layout and emit `SvgEdgeLabelInfo`
   elements.
4. Route edge paths to the envelope boundary (rather than the inner wrapper
   boundary) so labels sit between the node rectangle and the edge path.

---

## Prerequisites / shared concerns

### P1 -- Promote `NodeFace` to `ir_model`

`NodeFace` is currently `pub(super)` in
`crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs`. Every phase
below needs it.

Move it to a new file
`crate/ir_model/src/node/node_face.rs` and make it public. Update all
existing use sites in `taffy_to_svg_elements_mapper/`.

`NodeFace` variants: `Top`, `Bottom`, `Left`, `Right`.

---

## Phase 1 -- Pre-layout face assignment

**Goal:** compute `(from_face, to_face)` for every edge using only rank and
sibling data, and store the per-node counts in `IrDiagram` so `IrToTaffyBuilder`
can build the right number of edge label slots.

### Step 1.1 -- `EdgeFaceAssignment` type

New file: `crate/ir_model/src/edge/edge_face_assignment.rs`

```rust
pub struct EdgeFaceAssignment {
    pub from_face: Option<NodeFace>,
    pub to_face: Option<NodeFace>,
}
```

`None` for both faces means the edge is a contained edge (one endpoint is an
ancestor of the other) and does not touch either node's face.

### Step 1.2 -- `EdgeFaceAssignments` collection

New file: `crate/ir_model/src/edge/edge_face_assignments.rs`

```rust
pub struct EdgeFaceAssignments<'id>(Map<EdgeId<'id>, EdgeFaceAssignment>);
```

Standard newtype with `get`, `insert`, `iter`, `is_empty`.

### Step 1.3 -- `NodeFaceEdges` collection

New file: `crate/ir_model/src/node/node_face_edges.rs`

```rust
pub struct NodeFaceEdges<'id>(Map<NodeId<'id>, Map<NodeFace, Vec<EdgeId<'id>>>>);
```

Provides:
- `edges_for(node_id, face) -> &[EdgeId]`
- `face_edge_count(node_id, face) -> usize`

Derived from `EdgeFaceAssignments`: for every `(edge_id, assignment)`, push
`edge_id` into `from_node -> from_face` and `to_node -> to_face`.

### Step 1.4 -- `EdgeFaceAssigner` calculator

New file: `crate/input_ir_rt/src/edge_face_assigner.rs`

```rust
pub struct EdgeFaceAssigner;
impl EdgeFaceAssigner {
    pub fn compute<'id>(
        edge_groups: &EdgeGroups<'id>,
        entity_types: &EntityTypes<'id>,
        node_nesting_infos: &NodeNestingInfos<'id>,
        node_ranks_nested: &NodeRanksNested<'id>,
        rank_dir: RankDir,
    ) -> EdgeFaceAssignments<'id>
}
```

For each edge in each group, apply the rules below (in priority order):

| Case | from_face | to_face |
|---|---|---|
| Self-loop (`from == to`) | `Bottom` | `None` |
| Contained (from is ancestor of to) | rank-dir face (see below) | opposite face |
| Contained (to is ancestor of from) | opposite of forward | rank-dir face |
| Cycle edge (same LCA rank, sibling distance > 1) | clockwise (see below) | clockwise |
| Forward edge (`lca_rank_from < lca_rank_to`) | rank-dir face (see below) | opposite face |
| Reverse edge (`lca_rank_from > lca_rank_to`) | opposite of forward | rank-dir face |

**Rank-direction face** (the face of the `from` node for a forward edge):
- `LeftToRight` / `RightToLeft` (reversed) → `Right`
- `TopToBottom` / `BottomToTop` (reversed) → `Bottom`
- For reversed directions the face ordering is negated: `RightToLeft` forward
  edge exits `Left`, `BottomToTop` exits `Top`.

**Clockwise cycle face** (mirrors `EdgePathBuilderPass1::cycle_edge_faces_select`
but uses sibling indices instead of pixel coordinates):
- `from_sibling_index < to_sibling_index` in a LTR/RTL context: `(Top, Top)`.
- `from_sibling_index > to_sibling_index` in a LTR/RTL context: `(Bottom, Bottom)`.
- Same logic for TTB/BTT context using vertical sibling ordering.

LCA-rank computation should reuse the same logic as
`SvgEdgeInfosBuilder::nodes_lca_ranks_compute` (Step P1 extracts this into a
shared location, or it is duplicated).

### Step 1.5 -- Add fields to `IrDiagram`

In `crate/ir_model/src/ir_diagram.rs`, add two new fields:

```rust
pub edge_face_assignments: EdgeFaceAssignments<'id>,
pub node_face_edges: NodeFaceEdges<'id>,
```

In `InputToIrDiagramMapper::map`
(`crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs`), after step 14
(NodeRanksNested) and 13 (NodeNestingInfos):

- Step 15: `edge_face_assignments = EdgeFaceAssigner::compute(...)`
- Step 16: `node_face_edges = NodeFaceEdges::from(&edge_face_assignments, &edge_groups)`

Update `IrDiagram::into_static` to call `.into_static()` on both new fields.

---

## Phase 2 -- Envelope taffy nodes

**Goal:** wrap each diagram node's taffy subtree with an `envelope_node` that
has flex-row/column edge label slots on each face.

The existing `wrapper_node` becomes `diagram_node_wrapper_node` inside the
envelope. All existing rank containers and child nodes stay inside
`diagram_node_wrapper_node` unchanged.

### Step 2.1 -- `TaffyNodeCtx::EdgeLabel` variant

In `crate/taffy_model/src/taffy_node_ctx.rs`, add a third variant:

```rust
EdgeLabel(EdgeLabelCtx)
```

New file `crate/taffy_model/src/edge_label_ctx.rs`:

```rust
pub struct EdgeLabelCtx {
    pub edge_id: EdgeId<'static>,
    pub node_id: NodeId<'static>,  // the endpoint node this label is attached to
    pub face: NodeFace,
}
```

### Step 2.2 -- `EdgeLabelTaffyNodeIds` type

New file: `crate/taffy_model/src/edge_label_taffy_node_ids.rs`

```rust
pub struct EdgeLabelTaffyNodeIds {
    /// Label slot on the `from` endpoint's face. `None` when the node is
    /// absent from `NodeNestingInfos`.
    pub from_label_taffy_node_id: Option<taffy::NodeId>,
    /// Label slot on the `to` endpoint's face. `None` for self-loop edges
    /// (only one slot needed) and when the node is absent from
    /// `NodeNestingInfos`.
    pub to_label_taffy_node_id: Option<taffy::NodeId>,
}
```

### Step 2.3 -- Update `TaffyNodeMappings`

In `crate/taffy_model/src/taffy_node_mappings.rs`, add:

```rust
/// Map from edge ID to its edge label taffy leaf node IDs.
pub edge_label_taffy_nodes: Map<EdgeId<'static>, EdgeLabelTaffyNodeIds>,
/// Map from diagram node ID to its envelope taffy node ID.
pub node_id_to_envelope_taffy_node: Map<NodeId<'static>, taffy::NodeId>,
```

Keeping `node_id_to_envelope_taffy_node` separate (rather than changing
`NodeToTaffyNodeIds` variants) minimises churn in all existing code that reads
`node_id_to_taffy`.

### Step 2.4 -- Envelope node structure

Two methods on `IrToTaffyBuilder`:

- `fn taffy_envelope_node_build` -- builds the full envelope structure.
- `fn taffy_envelope_node_build_face_leaves` -- extracted helper (per naming
  convention: called function name is prefixed by the calling function name)
  that creates label leaves for one face and appends them to an accumulator.

`taffy_envelope_node_build` takes `diagram_node_wrapper_node` and
`node_face_edges`, and builds:

```yaml
envelope_node:               # (flex column, align_items: Stretch)
  edge_wrapper_top:          # (flex row,    children = label leaves for Top edges)
  edge_and_diagram_wrapper:  # (flex row,    align_items: Stretch)
    edge_wrapper_left:       # (flex column, children = label leaves for Left edges)
    diagram_node_wrapper_node
    edge_wrapper_right:      # (flex column, children = label leaves for Right edges)
  edge_wrapper_bottom:       # (flex row,    children = label leaves for Bottom edges)
```

Each label leaf is created with:

```rust
TaffyNodeCtx::EdgeLabel(EdgeLabelCtx { edge_id, node_id, face })
```

The `edge_wrapper_*` nodes with zero children are still created so the flex
layout stays consistent; they will have zero size.

The return type is `(taffy::NodeId, Vec<EdgeLabelLeafBuilt>)` where
`EdgeLabelLeafBuilt` (defined in
`crate/input_ir_rt/src/ir_to_taffy_builder/taffy_node_build_context.rs`) is:

```rust
pub(crate) struct EdgeLabelLeafBuilt {
    pub(crate) edge_id: EdgeId<'static>,
    pub(crate) node_id: NodeId<'static>,
    pub(crate) face: NodeFace,
    pub(crate) taffy_node_id: taffy::NodeId,
}
```

This carries per-leaf info out of envelope construction so step 2.5 can
populate `edge_label_taffy_nodes` after all nodes have been built.

### Step 2.5 -- Wire into `IrToTaffyBuilder`

Modify `build_taffy_nodes_for_node_without_child_hierarchy` and
`build_taffy_nodes_for_node_with_child_hierarchy` in
`crate/input_ir_rt/src/ir_to_taffy_builder.rs`:

1. Build `diagram_node_wrapper_node` (the existing `wrapper_node`) as before.
2. Call `taffy_envelope_node_build` with `diagram_node_wrapper_node` and
   `node_face_edges`. Collect the returned `Vec<EdgeLabelLeafBuilt>` alongside
   the returned `envelope_node`.
3. Return `envelope_node` (not `wrapper_node`) as the ID stored in rank
   containers and passed up the call stack.
4. Record `node_id → envelope_node` in `node_id_to_envelope_taffy_node`.
5. After all nodes are built, merge the collected `EdgeLabelLeafBuilt` entries
   into `edge_label_taffy_nodes`. For each `EdgeLabelLeafBuilt`:
   - Look up `edge_face_assignments.get(&built.edge_id)` and the raw edge
     (from `edge_groups`) to obtain its `.from` and `.to` node IDs.
   - If `assignment.from_face.is_some()` and `built.node_id == edge.from`,
     this leaf is the `from_label_taffy_node_id` for that edge.
   - If `assignment.to_face.is_some()` and `built.node_id == edge.to`,
     this leaf is the `to_label_taffy_node_id` for that edge.
   - Self-loop edges have `to_label_taffy_node_id = None` (only `from_label`
     is populated). Contained edges populate both slots.

Add `node_face_edges` to `TaffyNodeBuildContext` alongside the existing fields
so it is available during the recursive child-node build (used by the two
`build_taffy_nodes_for_node_*` functions, which receive the context).

`edge_face_assignments` is only needed during the post-build collection in
step 5 above, so it can be passed directly at that call site rather than
threaded through `TaffyNodeBuildContext`.

### Step 2.6 -- Edge label text measurement

In `IrToTaffyBuilder::node_size_measure`, add a match arm for
`TaffyNodeCtx::EdgeLabel(ctx)`:

- Look up `entity_descs.get(ctx.edge_id.as_ref())` for the description text.
  `EntityDescs` is keyed by `Id<'static>` and `EdgeId` wraps `Id`, so
  `ctx.edge_id.as_ref()` gives `&Id<'static>` which works as the lookup key.
- Apply the same monospace wrapping logic used for `DiagramNode`.
- `NodeMeasureContext` already carries `entity_descs`, so no new context field
  is needed.

---

## Phase 3 -- SVG rendering of edge labels

**Goal:** after layout, read edge label positions and emit them as SVG elements.

### Step 3.1 -- `SvgEdgeLabelInfo` type

New file: `crate/svg_model/src/svg_edge_label_info.rs`

```rust
pub struct SvgEdgeLabelInfo<'id> {
    pub edge_id: EdgeId<'id>,
    pub from_label: Option<SvgEdgeLabelEndpointInfo>,
    pub to_label: Option<SvgEdgeLabelEndpointInfo>,
}

pub struct SvgEdgeLabelEndpointInfo {
    pub x: f32,
    pub y: f32,
    pub width: f32,
    pub height: f32,
    pub text_spans: Vec<SvgTextSpan>,
}
```

### Step 3.2 -- `SvgEdgeLabelsBuilder`

New file:
`crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_labels_builder.rs`

Iterates `TaffyNodeMappings::edge_label_taffy_nodes`. For each
`EdgeLabelTaffyNodeIds`:
- Call `SvgNodeInfoBuilder::node_absolute_xy_coordinates` on the label taffy
  node to get `(x, y)`.
- Read `layout.size.width` / `height` from `taffy_tree.layout(label_node_id)`.
- Read syntax-highlighted spans from `entity_highlighted_spans` using the
  `edge_id` (same mechanism as node text spans, once `highlighted_spans_compute`
  is extended to handle `TaffyNodeCtx::EdgeLabel` nodes).
- Build and return `Vec<SvgEdgeLabelInfo>`.

### Step 3.3 -- Add to `SvgElements` and `TaffyToSvgElementsMapper`

In `SvgElements` (`crate/svg_model/src/svg_elements.rs`), add:

```rust
pub edge_label_infos: Vec<SvgEdgeLabelInfo<'static>>,
```

Call `SvgEdgeLabelsBuilder::build` in `TaffyToSvgElementsMapper` and store the
result.

### Step 3.4 -- SVG template

In `SvgElementsToSvgMapper`, iterate `edge_label_infos` and emit a `<text>`
element for each non-empty `from_label` / `to_label`. Use the same `<tspan>`
structure as node text spans. Apply Tailwind / entity CSS classes via the
`edge_id`.

---

## Phase 4 -- Edge path routing around label nodes

**Goal:** edge paths route around the full envelope (inner node + label wrapper
slots) so that label text is not obscured by a passing edge. Edge paths still
connect to the inner wrapper node boundary -- the rendered `<rect>` -- not to
the envelope boundary.

### Step 4.1 -- Envelope bounds in `SvgNodeInfo`

`SvgNodeInfoBuilder` currently reads `x, y, width, height_collapsed` from the
`wrapper_taffy_node_id` (now `diagram_node_wrapper_node`). This must stay as-is
for the SVG `<rect>` path -- the visible node rectangle should not change.

Add envelope fields to `SvgNodeInfo` in `crate/svg_model/src/svg_node_info.rs`:

```rust
pub envelope_x: f32,
pub envelope_y: f32,
pub envelope_width: f32,
pub envelope_height_collapsed: f32,
```

In `SvgNodeInfoBuilder::build`, look up `node_id_to_envelope_taffy_node` to get
the envelope taffy node and compute its absolute coordinates using the same
`node_absolute_xy_coordinates` helper.

### Step 4.2 -- Face contact points remain on inner wrapper bounds (no change)

Edge face contact points should lie on the inner wrapper node's boundary, not
the envelope boundary. The rendered `<rect>` is the inner wrapper, so edges
should visually connect to it.

No changes are needed to `EdgePathBuilderPass1::faces_select`,
`select_edge_faces`, or `get_face_center` -- all of these already use
`svg_node_info.x/y/width/height_collapsed` (wrapper bounds) and must continue
to do so.

Contained-edge detection (`is_node_contained_in`) likewise continues to use
wrapper bounds.

### Step 4.3 -- Protrusion calculations use envelope bounds

`OrthoProtrusionCalculator::face_coord_for_endpoint` returns the coordinate of
a node face along the protrusion axis (e.g. `y + height_collapsed` for the
`Bottom` face). It is used by `min_protrusion_divergent_sibling_extent` and
`same_rank_sibling_extreme` to compute how far an edge must protrude to clear
adjacent nodes.

Update `face_coord_for_endpoint` to use the envelope bounds instead of the
wrapper bounds:

```rust
NodeFace::Bottom => info.envelope_y + info.envelope_height_collapsed,
NodeFace::Top    => info.envelope_y,
NodeFace::Right  => info.envelope_x + info.envelope_width,
NodeFace::Left   => info.envelope_x,
```

This ensures protrusions clear the full label area, not just the inner
node rectangle. Because `same_rank_sibling_extreme` calls
`face_coord_for_endpoint` for every sibling, it will also automatically use
envelope bounds, so sibling-clearance protrusions account for their label
slots as well.

Leave the following unchanged (wrapper bounds are correct for these):

- `face_offsets_compute` / `face_length_for_node` in `SvgEdgeInfosBuilder` --
  face lengths for distributing contact points along a face should match the
  inner node dimensions, since contact points lie on the wrapper boundary.
- `OrthoProtrusionCalculator::face_center` (used in `rank_gap_px`) -- measures
  the distance between actual contact points; must remain wrapper-based.
- `cycle_edge_collect_rank_gap_entries` direct use of
  `info.y + info.height_collapsed` -- cycle-edge rank-gap geometry is tied to
  the rendered node boundary, not the envelope.

### Step 4.4 -- Reconcile pre-layout vs post-layout face selection

The pre-layout face assignment (Phase 1) and the post-layout face selection
(currently in `SvgEdgeInfosBuilder`) may give different results in ambiguous
cases (e.g. diagonal layouts). Options:

- **Option A (recommended initially):** Keep both computations. Use pre-layout
  results only for building envelope slots (Phase 2). Post-layout results drive
  path routing as before. Accept that a label slot may be on a different face
  than the path exits if the two disagree.
- **Option B (cleaner long-term):** Replace the post-layout face selection with
  the pre-layout result. Store `EdgeFaceAssignments` in `TaffyNodeMappings` and
  read it in `SvgEdgeInfosBuilder` instead of re-deriving faces. This requires
  verifying the approximation is accurate enough for all layout configurations.

---

## Open questions

### OQ1 -- Contained edges (resolved)

Contained edges now have face assignments: downward edges (from is ancestor of
to) use the forward faces for the rank direction; upward edges use the reverse
faces. Both endpoints get label slots. Self-loop edges use a single `from_label`
slot on the `Bottom` face.

### OQ2 -- `entity_highlighted_spans` extension

Currently `highlighted_spans_compute` only handles `TaffyNodeCtx::DiagramNode`.
It must be extended to iterate edge label leaves (`TaffyNodeCtx::EdgeLabel`) and
produce syntax-highlighted spans keyed by `edge_id` (not `node_id`). A separate
map `edge_highlighted_spans: Map<EdgeId, Vec<HighlightedSpan>>` may be cleaner
than adding a mixed key to the existing `EntityHighlightedSpans`.

### OQ3 -- Multiple edges per face

When two edges share the same face on the same node, each gets its own label
slot. These stack in a flex column (left/right) or flex row (top/bottom). The
slot order should match the face-offset slot order produced by
`face_offsets_compute` (rank distance ascending, then target coordinate) so
labels align with their corresponding edge exit points.

Currently `taffy_envelope_node_build_face_leaves` iterates
`node_face_edges.edges_for(node_id, face)` in insertion order, which is the
order edges appear in `EdgeGroups`. Before step 2.5 is finalised, verify that
this order agrees with `face_offsets_compute`'s slot ordering, or add an
explicit sort inside `taffy_envelope_node_build_face_leaves`.

### OQ4 -- Edge label slot styling

Edge label slots should have `flex_shrink: 0` and appropriate padding/margin
(similar to node text nodes) so they do not compress. The envelope flex layout
should use `align_items: Stretch` to keep label wrappers and the diagram node
wrapper at the same cross-axis size.

### OQ5 -- Edges with no description

If `entity_descs` has no entry for an edge, the label leaf still exists in the
taffy tree but measures to zero size. The envelope will therefore have zero-size
wrappers for those faces. This is correct behaviour -- no visible label is
emitted and the layout is unchanged from the no-envelope case.

### OQ6 -- `taffy_to_svg_elements_mapper/svg_node_info_builder.rs` call to `wrapper_taffy_node_id`

The method `NodeToTaffyNodeIds::wrapper_taffy_node_id()` is currently used to
obtain the layout for `SvgNodeInfo`. After Phase 2 this still refers to
`diagram_node_wrapper_node`. Verify that process height subtraction logic
(which currently subtracts `proc_info.total_height` from `height_expanded`)
continues to use the inner `wrapper` dimensions, not the envelope.