Skip to content

Commit 5058a82

Browse files
authored
Merge pull request #61 from azriel91/feature/layout-only-edges
Add `thing_layout_edges` to influence `thing` node ranks without drawing edges.
2 parents 26b3126 + 6f999e5 commit 5058a82

20 files changed

Lines changed: 394 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
* Add `EntityType::EdgeLabelAndDescBg`, `DependencyEdgeLabelAndDescBg`, `DependencyEdgeLabelBg`, `DependencyEdgeDescBg`, `InteractionEdgeLabelAndDescBg` so edge label/description background styling resolves through a 3-tier fallback hierarchy (shared default -> dependency/interaction-specific -> label/desc-specific). ([#60][#60])
6565
* Scope stroke and fill tailwind classes to edge body/arrowhead and thing wrapper node. ([#60][#60])
6666
* Fix separate edge offset calculation ending up with the same final coordinate. ([#60][#60])
67+
* Add `thing_layout_edges` to affect node ranks without rendering any visible `<path>`s. ([#61][#61])
6768

6869
[#42]: https://github.com/azriel91/disposition/pull/42
6970
[#43]: https://github.com/azriel91/disposition/pull/43
@@ -84,6 +85,7 @@
8485
[#58]: https://github.com/azriel91/disposition/pull/58
8586
[#59]: https://github.com/azriel91/disposition/pull/59
8687
[#60]: https://github.com/azriel91/disposition/pull/60
88+
[#61]: https://github.com/azriel91/disposition/pull/61
8789

8890

8991
## 0.3.0 (2026-06-07)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
# Layout Edges
3+
#
4+
# `thing_layout_edges` nudges a thing's rank (and hence its position) without
5+
# drawing a visible edge. Each entry is a single `from`/`to` pair keyed by its
6+
# own id (conventionally prefixed `edge_layout_`) -- the `to` thing is ranked
7+
# after the `from` thing, exactly like a dependency edge, but no `<path>` is
8+
# ever rendered for it.
9+
#
10+
# Here, `t_style_guide` and `t_changelog` have no real dependency or
11+
# interaction with the rest of the diagram, but layout edges keep them
12+
# ordered after `t_release`, where they read best next to it.
13+
things:
14+
t_design:
15+
t_design_mockups: {}
16+
t_release: {}
17+
t_style_guide: {}
18+
t_changelog: {}
19+
thing_names:
20+
t_design: "Design"
21+
t_design_mockups: "Mockups"
22+
t_release: "Release"
23+
t_style_guide: "Style Guide"
24+
t_changelog: "Changelog"
25+
26+
thing_dependencies:
27+
edge_dep_design_release:
28+
kind: sequence
29+
things:
30+
- t_design
31+
- t_release
32+
33+
thing_layout_edges:
34+
edge_layout_release_style_guide:
35+
from: t_release
36+
to: t_style_guide
37+
edge_layout_style_guide_changelog:
38+
from: t_style_guide
39+
to: t_changelog

app/playground/src/example_diagrams.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ pub enum ExampleDiagram {
6868
/// Interaction halo with edge descriptions on cyclic dependency edges,
6969
/// where the divergent ancestors share a rank.
7070
InteractionHaloDescCyclic,
71+
/// Invisible, rank-only edges -- `thing_layout_edges`.
72+
LayoutEdges,
7173
}
7274

7375
impl ExampleDiagram {
@@ -94,6 +96,7 @@ impl ExampleDiagram {
9496
ExampleDiagram::InteractionHalo,
9597
ExampleDiagram::InteractionHaloLabels,
9698
ExampleDiagram::InteractionHaloDescCyclic,
99+
ExampleDiagram::LayoutEdges,
97100
];
98101

99102
/// Human-readable label shown in the example selector dropdown.
@@ -120,6 +123,7 @@ impl ExampleDiagram {
120123
Self::InteractionHalo => "Interaction Halo",
121124
Self::InteractionHaloLabels => "Interaction Halo Labels",
122125
Self::InteractionHaloDescCyclic => "Interaction Halo Cyclic Descriptions",
126+
Self::LayoutEdges => "Layout Edges",
123127
}
124128
}
125129

@@ -165,6 +169,7 @@ impl ExampleDiagram {
165169
Self::InteractionHaloDescCyclic => {
166170
asset!("/assets/example_diagrams/021_interaction_halo_with_desc_cyclic.yaml")
167171
}
172+
Self::LayoutEdges => asset!("/assets/example_diagrams/022_layout_edges.yaml"),
168173
}
169174
}
170175

crate/input_ir_rt/src/input_diagram_merger.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use disposition_input_model::{
99
},
1010
thing::{
1111
ThingCopyText, ThingDependencies, ThingDescs, ThingHierarchy, ThingInteractions,
12-
ThingLayouts, ThingNames,
12+
ThingLayoutEdges, ThingLayouts, ThingNames,
1313
},
1414
InputDiagram,
1515
};
@@ -64,6 +64,10 @@ impl InputDiagramMerger {
6464
base_diagram.thing_interactions,
6565
&overlay_diagram.thing_interactions,
6666
);
67+
let thing_layout_edges = Self::merge_thing_layout_edges(
68+
base_diagram.thing_layout_edges,
69+
&overlay_diagram.thing_layout_edges,
70+
);
6771
let processes = Self::merge_processes(base_diagram.processes, &overlay_diagram.processes);
6872
let tags = Self::merge_tag_names(base_diagram.tags, &overlay_diagram.tags);
6973
let tag_things =
@@ -104,6 +108,7 @@ impl InputDiagramMerger {
104108
thing_layouts,
105109
thing_dependencies,
106110
thing_interactions,
111+
thing_layout_edges,
107112
thing_descs,
108113
processes,
109114
tags,
@@ -191,6 +196,17 @@ impl InputDiagramMerger {
191196
result
192197
}
193198

199+
fn merge_thing_layout_edges<'id>(
200+
base: ThingLayoutEdges<'static>,
201+
overlay: &ThingLayoutEdges<'id>,
202+
) -> ThingLayoutEdges<'id> {
203+
let mut result = base;
204+
overlay.iter().for_each(|(key, value)| {
205+
result.insert(key.clone(), value.clone());
206+
});
207+
result
208+
}
209+
194210
fn merge_processes<'id>(base: Processes<'static>, overlay: &Processes<'id>) -> Processes<'id> {
195211
let mut result = base;
196212
overlay.iter().for_each(|(key, value)| {

crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use disposition_input_model::{
77
theme::{ThemeDefault, ThemeTypesStyles},
88
thing::{
99
ThingCopyText, ThingDependencies, ThingHierarchy as InputThingHierarchy, ThingId,
10-
ThingInteractions, ThingLayouts, ThingNames,
10+
ThingInteractions, ThingLayoutEdges, ThingLayouts, ThingNames,
1111
},
1212
InputDiagram,
1313
};
@@ -103,6 +103,7 @@ impl InputToIrDiagramMapper {
103103
thing_layouts,
104104
thing_dependencies,
105105
thing_interactions,
106+
thing_layout_edges,
106107
thing_descs,
107108
processes,
108109
tags,
@@ -225,10 +226,19 @@ impl InputToIrDiagramMapper {
225226
// 15. Compute NodeNestingInfos from node_hierarchy
226227
let node_nesting_infos = NodeNestingInfosBuilder::build(&node_hierarchy);
227228

228-
// 16. Compute NodeRanksNested from dependency edges, using nesting infos to
229-
// attribute cross-container edges to the correct level
230-
let node_ranks_nested =
231-
NodeRanksCalculator::calculate(&edge_groups, &ir_entity_types, &node_nesting_infos);
229+
// 15a. Build layout edges from thing_layout_edges -- these never
230+
// enter edge_groups, so they only ever contribute to rank
231+
// computation below, never to rendering.
232+
let layout_edges = Self::build_layout_edges(thing_layout_edges);
233+
234+
// 16. Compute NodeRanksNested from dependency and layout edges, using nesting
235+
// infos to attribute cross-container edges to the correct level
236+
let node_ranks_nested = NodeRanksCalculator::calculate(
237+
&edge_groups,
238+
&ir_entity_types,
239+
&node_nesting_infos,
240+
&layout_edges,
241+
);
232242

233243
// 17. Compute EdgeFaceAssignments from rank/sibling data before layout
234244
let edge_face_assignments = EdgeFaceAssigner::compute(
@@ -255,6 +265,7 @@ impl InputToIrDiagramMapper {
255265
node_ordering,
256266
edge_groups,
257267
thing_descs,
268+
thing_layout_edges: thing_layout_edges.clone(),
258269
edge_descs,
259270
edge_labels,
260271
entity_tooltips,
@@ -693,6 +704,25 @@ impl InputToIrDiagramMapper {
693704
dependency_entries.chain(interaction_entries).collect()
694705
}
695706

707+
// === Layout Edges === //
708+
709+
/// Build layout [`Edge`]s from `thing_layout_edges`.
710+
///
711+
/// These never enter `edge_groups` -- they are only ever passed to
712+
/// [`NodeRanksCalculator`] to influence rank, and never produce an SVG
713+
/// path.
714+
fn build_layout_edges<'id>(thing_layout_edges: &ThingLayoutEdges<'id>) -> Vec<Edge<'id>> {
715+
thing_layout_edges
716+
.values()
717+
.map(|layout_edge| {
718+
Edge::new(
719+
NodeId::from(layout_edge.from.clone()),
720+
NodeId::from(layout_edge.to.clone()),
721+
)
722+
})
723+
.collect()
724+
}
725+
696726
/// Convert an [`InputEdgeGroup`] to a list of [`Edge`]s.
697727
fn input_edge_group_to_edges<'id>(input_edge_group: &InputEdgeGroup<'id>) -> EdgeGroup<'id> {
698728
let things = &input_edge_group.things;

crate/input_ir_rt/src/ir_to_taffy_builder.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ impl IrToTaffyBuilder<'_> {
121121
node_ordering: _,
122122
edge_groups,
123123
thing_descs,
124+
thing_layout_edges: _,
124125
edge_descs,
125126
edge_labels,
126127
entity_tooltips: _,

crate/input_ir_rt/src/node_ranks_calculator.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use disposition_ir_model::{
2-
edge::EdgeGroups,
2+
edge::{Edge, EdgeGroups},
33
entity::{EntityType, EntityTypes},
44
node::{NodeId, NodeNestingInfos, NodeRank, NodeRanks, NodeRanksNested},
55
};
@@ -22,7 +22,10 @@ use disposition_model_common::{Id, Map};
2222
/// contracted into a single logical node for ranking purposes.
2323
///
2424
/// Only **dependency** edges (not interaction edges) are considered for rank
25-
/// computation.
25+
/// computation, plus any **layout edges** passed in separately -- these are
26+
/// invisible edges (from `thing_layout_edges`) that contribute to rank
27+
/// exactly like dependency edges, without ever appearing in `edge_groups` or
28+
/// being rendered.
2629
///
2730
/// [`IrDiagram`]: disposition_ir_model::IrDiagram
2831
///
@@ -80,10 +83,14 @@ impl NodeRanksCalculator {
8083
/// interaction edges.
8184
/// * `node_nesting_infos`: Nesting information for each node, used to build
8285
/// the container-to-children map and compute LCA-level edge attribution.
86+
/// * `layout_edges`: Invisible layout-only edges (from
87+
/// `thing_layout_edges`) that contribute to rank alongside dependency
88+
/// edges, without being backed by an edge group.
8389
pub fn calculate<'id>(
8490
edge_groups: &EdgeGroups<'id>,
8591
entity_types: &EntityTypes<'id>,
8692
node_nesting_infos: &NodeNestingInfos<'id>,
93+
layout_edges: &[Edge<'id>],
8794
) -> NodeRanksNested<'id> {
8895
if node_nesting_infos.is_empty() {
8996
return NodeRanksNested::new();
@@ -93,7 +100,8 @@ impl NodeRanksCalculator {
93100
let container_to_children = Self::container_to_children_build(node_nesting_infos);
94101

95102
// === Collect Dependency Edges === //
96-
let dependency_edges = Self::dependency_edges_collect(edge_groups, entity_types);
103+
let dependency_edges =
104+
Self::dependency_edges_collect(edge_groups, entity_types, layout_edges);
97105

98106
// === Lift Edges to LCA Level === //
99107
let lca_level_edges = Self::lca_level_edges_build(&dependency_edges, node_nesting_infos);
@@ -225,23 +233,33 @@ impl NodeRanksCalculator {
225233
Some((lca_container, divergent_from, divergent_to))
226234
}
227235

228-
/// Extracts dependency edges from edge groups, filtering out interaction
229-
/// edges.
236+
/// Extracts dependency and layout edges that contribute to rank,
237+
/// filtering out interaction edges.
230238
///
231-
/// Returns a list of `(from_id, to_id)` pairs for dependency edges only.
239+
/// Returns a list of `(from_id, to_id)` pairs for dependency edges (from
240+
/// `edge_groups`, filtered by `entity_types`) and layout edges (passed in
241+
/// directly -- they have no backing edge group).
232242
fn dependency_edges_collect<'id>(
233243
edge_groups: &EdgeGroups<'id>,
234244
entity_types: &EntityTypes<'id>,
245+
layout_edges: &[Edge<'id>],
235246
) -> Vec<(NodeId<'id>, NodeId<'id>)> {
236-
edge_groups
247+
let dependency_group_edges = edge_groups
237248
.iter()
238249
.filter(|(edge_group_id, _edge_group)| {
239250
Self::edge_group_is_dependency(edge_group_id.as_ref(), entity_types)
240251
})
241252
.flat_map(|(_edge_group_id, edge_group)| edge_group.iter())
253+
.map(|edge| (edge.from.clone(), edge.to.clone()));
254+
255+
let layout_edge_pairs = layout_edges
256+
.iter()
257+
.map(|edge| (edge.from.clone(), edge.to.clone()));
258+
259+
dependency_group_edges
260+
.chain(layout_edge_pairs)
242261
// Skip self-loops -- they don't affect rank.
243-
.filter(|edge| edge.from != edge.to)
244-
.map(|edge| (edge.from.clone(), edge.to.clone()))
262+
.filter(|(from, to)| from != to)
245263
.collect()
246264
}
247265

crate/input_model/src/input_diagram.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::{
1717
},
1818
thing::{
1919
ThingCopyText, ThingDependencies, ThingDescs, ThingHierarchy, ThingInteractions,
20-
ThingLayouts, ThingNames,
20+
ThingLayoutEdges, ThingLayouts, ThingNames,
2121
},
2222
};
2323

@@ -39,6 +39,12 @@ use crate::{
3939
/// `cyclic`) and a list of `things`; individual edges within a group get an
4040
/// ID of `<edge_group_id>__<index>`.
4141
///
42+
/// * **Layout-only edges** -- `thing_layout_edges` nudges a thing's rank (and
43+
/// hence its position) without drawing a visible edge. Each entry is a single
44+
/// `from`/`to` pair keyed by its own ID. It's combined with
45+
/// `thing_dependencies` when computing node rank, but -- unlike dependency or
46+
/// interaction edges -- never produces an SVG `<path>`.
47+
///
4248
/// * **Entity types (shared styling)** -- `entity_types` attaches one or more
4349
/// reusable `type_*` ids to *any* entity, **both things and edge groups**.
4450
/// The look of each type is then defined once in `theme_types_styles`, so a
@@ -120,6 +126,16 @@ pub struct InputDiagram<'id> {
120126
#[serde(default, skip_serializing_if = "ThingInteractions::is_empty")]
121127
pub thing_interactions: ThingInteractions<'id>,
122128

129+
/// Invisible edges between things that affect rank/layout without ever
130+
/// being rendered as a path.
131+
///
132+
/// Each entry is a single `from`/`to` pair keyed by its own ID
133+
/// (conventionally prefixed `edge_layout_`). The `to` thing is ranked
134+
/// after the `from` thing, exactly like a dependency edge, but no
135+
/// `<path>` is ever rendered for it.
136+
#[serde(default, skip_serializing_if = "ThingLayoutEdges::is_empty")]
137+
pub thing_layout_edges: ThingLayoutEdges<'id>,
138+
123139
/// Descriptions to render next to things in the diagram.
124140
#[serde(default, skip_serializing_if = "ThingDescs::is_empty")]
125141
pub thing_descs: ThingDescs<'id>,
@@ -272,6 +288,7 @@ impl InputDiagram<'static> {
272288
thing_layouts: ThingLayouts::default(),
273289
thing_dependencies: ThingDependencies::default(),
274290
thing_interactions: ThingInteractions::default(),
291+
thing_layout_edges: ThingLayoutEdges::default(),
275292
thing_descs: ThingDescs::default(),
276293
processes: Processes::default(),
277294
tags: TagNames::default(),

crate/input_model/src/thing.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
pub use self::{
2-
thing_copy_text::ThingCopyText, thing_dependencies::ThingDependencies, thing_descs::ThingDescs,
3-
thing_hierarchy::ThingHierarchy, thing_id::ThingId, thing_interactions::ThingInteractions,
2+
layout_edge::LayoutEdge, thing_copy_text::ThingCopyText, thing_dependencies::ThingDependencies,
3+
thing_descs::ThingDescs, thing_hierarchy::ThingHierarchy, thing_id::ThingId,
4+
thing_interactions::ThingInteractions, thing_layout_edges::ThingLayoutEdges,
45
thing_layouts::ThingLayouts, thing_names::ThingNames,
56
};
67

8+
mod layout_edge;
79
mod thing_copy_text;
810
mod thing_dependencies;
911
mod thing_descs;
1012
mod thing_hierarchy;
1113
mod thing_id;
1214
mod thing_interactions;
15+
mod thing_layout_edges;
1316
mod thing_layouts;
1417
mod thing_names;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub use disposition_model_common::thing::LayoutEdge;

0 commit comments

Comments
 (0)