Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
* 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])
* Scope stroke and fill tailwind classes to edge body/arrowhead and thing wrapper node. ([#60][#60])
* Fix separate edge offset calculation ending up with the same final coordinate. ([#60][#60])
* Add `thing_layout_edges` to affect node ranks without rendering any visible `<path>`s. ([#61][#61])

[#42]: https://github.com/azriel91/disposition/pull/42
[#43]: https://github.com/azriel91/disposition/pull/43
Expand All @@ -84,6 +85,7 @@
[#58]: https://github.com/azriel91/disposition/pull/58
[#59]: https://github.com/azriel91/disposition/pull/59
[#60]: https://github.com/azriel91/disposition/pull/60
[#61]: https://github.com/azriel91/disposition/pull/61


## 0.3.0 (2026-06-07)
Expand Down
39 changes: 39 additions & 0 deletions app/playground/assets/example_diagrams/022_layout_edges.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
# Layout Edges
#
# `thing_layout_edges` nudges a thing's rank (and hence its position) without
# drawing a visible edge. Each entry is a single `from`/`to` pair keyed by its
# own id (conventionally prefixed `edge_layout_`) -- the `to` thing is ranked
# after the `from` thing, exactly like a dependency edge, but no `<path>` is
# ever rendered for it.
#
# Here, `t_style_guide` and `t_changelog` have no real dependency or
# interaction with the rest of the diagram, but layout edges keep them
# ordered after `t_release`, where they read best next to it.
things:
t_design:
t_design_mockups: {}
t_release: {}
t_style_guide: {}
t_changelog: {}
thing_names:
t_design: "Design"
t_design_mockups: "Mockups"
t_release: "Release"
t_style_guide: "Style Guide"
t_changelog: "Changelog"

thing_dependencies:
edge_dep_design_release:
kind: sequence
things:
- t_design
- t_release

thing_layout_edges:
edge_layout_release_style_guide:
from: t_release
to: t_style_guide
edge_layout_style_guide_changelog:
from: t_style_guide
to: t_changelog
5 changes: 5 additions & 0 deletions app/playground/src/example_diagrams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ pub enum ExampleDiagram {
/// Interaction halo with edge descriptions on cyclic dependency edges,
/// where the divergent ancestors share a rank.
InteractionHaloDescCyclic,
/// Invisible, rank-only edges -- `thing_layout_edges`.
LayoutEdges,
}

impl ExampleDiagram {
Expand All @@ -94,6 +96,7 @@ impl ExampleDiagram {
ExampleDiagram::InteractionHalo,
ExampleDiagram::InteractionHaloLabels,
ExampleDiagram::InteractionHaloDescCyclic,
ExampleDiagram::LayoutEdges,
];

/// Human-readable label shown in the example selector dropdown.
Expand All @@ -120,6 +123,7 @@ impl ExampleDiagram {
Self::InteractionHalo => "Interaction Halo",
Self::InteractionHaloLabels => "Interaction Halo Labels",
Self::InteractionHaloDescCyclic => "Interaction Halo Cyclic Descriptions",
Self::LayoutEdges => "Layout Edges",
}
}

Expand Down Expand Up @@ -165,6 +169,7 @@ impl ExampleDiagram {
Self::InteractionHaloDescCyclic => {
asset!("/assets/example_diagrams/021_interaction_halo_with_desc_cyclic.yaml")
}
Self::LayoutEdges => asset!("/assets/example_diagrams/022_layout_edges.yaml"),
}
}

Expand Down
18 changes: 17 additions & 1 deletion crate/input_ir_rt/src/input_diagram_merger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use disposition_input_model::{
},
thing::{
ThingCopyText, ThingDependencies, ThingDescs, ThingHierarchy, ThingInteractions,
ThingLayouts, ThingNames,
ThingLayoutEdges, ThingLayouts, ThingNames,
},
InputDiagram,
};
Expand Down Expand Up @@ -64,6 +64,10 @@ impl InputDiagramMerger {
base_diagram.thing_interactions,
&overlay_diagram.thing_interactions,
);
let thing_layout_edges = Self::merge_thing_layout_edges(
base_diagram.thing_layout_edges,
&overlay_diagram.thing_layout_edges,
);
let processes = Self::merge_processes(base_diagram.processes, &overlay_diagram.processes);
let tags = Self::merge_tag_names(base_diagram.tags, &overlay_diagram.tags);
let tag_things =
Expand Down Expand Up @@ -104,6 +108,7 @@ impl InputDiagramMerger {
thing_layouts,
thing_dependencies,
thing_interactions,
thing_layout_edges,
thing_descs,
processes,
tags,
Expand Down Expand Up @@ -191,6 +196,17 @@ impl InputDiagramMerger {
result
}

fn merge_thing_layout_edges<'id>(
base: ThingLayoutEdges<'static>,
overlay: &ThingLayoutEdges<'id>,
) -> ThingLayoutEdges<'id> {
let mut result = base;
overlay.iter().for_each(|(key, value)| {
result.insert(key.clone(), value.clone());
});
result
}

fn merge_processes<'id>(base: Processes<'static>, overlay: &Processes<'id>) -> Processes<'id> {
let mut result = base;
overlay.iter().for_each(|(key, value)| {
Expand Down
40 changes: 35 additions & 5 deletions crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use disposition_input_model::{
theme::{ThemeDefault, ThemeTypesStyles},
thing::{
ThingCopyText, ThingDependencies, ThingHierarchy as InputThingHierarchy, ThingId,
ThingInteractions, ThingLayouts, ThingNames,
ThingInteractions, ThingLayoutEdges, ThingLayouts, ThingNames,
},
InputDiagram,
};
Expand Down Expand Up @@ -103,6 +103,7 @@ impl InputToIrDiagramMapper {
thing_layouts,
thing_dependencies,
thing_interactions,
thing_layout_edges,
thing_descs,
processes,
tags,
Expand Down Expand Up @@ -225,10 +226,19 @@ impl InputToIrDiagramMapper {
// 15. Compute NodeNestingInfos from node_hierarchy
let node_nesting_infos = NodeNestingInfosBuilder::build(&node_hierarchy);

// 16. Compute NodeRanksNested from dependency edges, using nesting infos to
// attribute cross-container edges to the correct level
let node_ranks_nested =
NodeRanksCalculator::calculate(&edge_groups, &ir_entity_types, &node_nesting_infos);
// 15a. Build layout edges from thing_layout_edges -- these never
// enter edge_groups, so they only ever contribute to rank
// computation below, never to rendering.
let layout_edges = Self::build_layout_edges(thing_layout_edges);

// 16. Compute NodeRanksNested from dependency and layout edges, using nesting
// infos to attribute cross-container edges to the correct level
let node_ranks_nested = NodeRanksCalculator::calculate(
&edge_groups,
&ir_entity_types,
&node_nesting_infos,
&layout_edges,
);

// 17. Compute EdgeFaceAssignments from rank/sibling data before layout
let edge_face_assignments = EdgeFaceAssigner::compute(
Expand All @@ -255,6 +265,7 @@ impl InputToIrDiagramMapper {
node_ordering,
edge_groups,
thing_descs,
thing_layout_edges: thing_layout_edges.clone(),
edge_descs,
edge_labels,
entity_tooltips,
Expand Down Expand Up @@ -693,6 +704,25 @@ impl InputToIrDiagramMapper {
dependency_entries.chain(interaction_entries).collect()
}

// === Layout Edges === //

/// Build layout [`Edge`]s from `thing_layout_edges`.
///
/// These never enter `edge_groups` -- they are only ever passed to
/// [`NodeRanksCalculator`] to influence rank, and never produce an SVG
/// path.
fn build_layout_edges<'id>(thing_layout_edges: &ThingLayoutEdges<'id>) -> Vec<Edge<'id>> {
thing_layout_edges
.values()
.map(|layout_edge| {
Edge::new(
NodeId::from(layout_edge.from.clone()),
NodeId::from(layout_edge.to.clone()),
)
})
.collect()
}

/// Convert an [`InputEdgeGroup`] to a list of [`Edge`]s.
fn input_edge_group_to_edges<'id>(input_edge_group: &InputEdgeGroup<'id>) -> EdgeGroup<'id> {
let things = &input_edge_group.things;
Expand Down
1 change: 1 addition & 0 deletions crate/input_ir_rt/src/ir_to_taffy_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ impl IrToTaffyBuilder<'_> {
node_ordering: _,
edge_groups,
thing_descs,
thing_layout_edges: _,
edge_descs,
edge_labels,
entity_tooltips: _,
Expand Down
36 changes: 27 additions & 9 deletions crate/input_ir_rt/src/node_ranks_calculator.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use disposition_ir_model::{
edge::EdgeGroups,
edge::{Edge, EdgeGroups},
entity::{EntityType, EntityTypes},
node::{NodeId, NodeNestingInfos, NodeRank, NodeRanks, NodeRanksNested},
};
Expand All @@ -22,7 +22,10 @@ use disposition_model_common::{Id, Map};
/// contracted into a single logical node for ranking purposes.
///
/// Only **dependency** edges (not interaction edges) are considered for rank
/// computation.
/// computation, plus any **layout edges** passed in separately -- these are
/// invisible edges (from `thing_layout_edges`) that contribute to rank
/// exactly like dependency edges, without ever appearing in `edge_groups` or
/// being rendered.
///
/// [`IrDiagram`]: disposition_ir_model::IrDiagram
///
Expand Down Expand Up @@ -80,10 +83,14 @@ impl NodeRanksCalculator {
/// interaction edges.
/// * `node_nesting_infos`: Nesting information for each node, used to build
/// the container-to-children map and compute LCA-level edge attribution.
/// * `layout_edges`: Invisible layout-only edges (from
/// `thing_layout_edges`) that contribute to rank alongside dependency
/// edges, without being backed by an edge group.
pub fn calculate<'id>(
edge_groups: &EdgeGroups<'id>,
entity_types: &EntityTypes<'id>,
node_nesting_infos: &NodeNestingInfos<'id>,
layout_edges: &[Edge<'id>],
) -> NodeRanksNested<'id> {
if node_nesting_infos.is_empty() {
return NodeRanksNested::new();
Expand All @@ -93,7 +100,8 @@ impl NodeRanksCalculator {
let container_to_children = Self::container_to_children_build(node_nesting_infos);

// === Collect Dependency Edges === //
let dependency_edges = Self::dependency_edges_collect(edge_groups, entity_types);
let dependency_edges =
Self::dependency_edges_collect(edge_groups, entity_types, layout_edges);

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

/// Extracts dependency edges from edge groups, filtering out interaction
/// edges.
/// Extracts dependency and layout edges that contribute to rank,
/// filtering out interaction edges.
///
/// Returns a list of `(from_id, to_id)` pairs for dependency edges only.
/// Returns a list of `(from_id, to_id)` pairs for dependency edges (from
/// `edge_groups`, filtered by `entity_types`) and layout edges (passed in
/// directly -- they have no backing edge group).
fn dependency_edges_collect<'id>(
edge_groups: &EdgeGroups<'id>,
entity_types: &EntityTypes<'id>,
layout_edges: &[Edge<'id>],
) -> Vec<(NodeId<'id>, NodeId<'id>)> {
edge_groups
let dependency_group_edges = edge_groups
.iter()
.filter(|(edge_group_id, _edge_group)| {
Self::edge_group_is_dependency(edge_group_id.as_ref(), entity_types)
})
.flat_map(|(_edge_group_id, edge_group)| edge_group.iter())
.map(|edge| (edge.from.clone(), edge.to.clone()));

let layout_edge_pairs = layout_edges
.iter()
.map(|edge| (edge.from.clone(), edge.to.clone()));

dependency_group_edges
.chain(layout_edge_pairs)
// Skip self-loops -- they don't affect rank.
.filter(|edge| edge.from != edge.to)
.map(|edge| (edge.from.clone(), edge.to.clone()))
.filter(|(from, to)| from != to)
.collect()
}

Expand Down
19 changes: 18 additions & 1 deletion crate/input_model/src/input_diagram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::{
},
thing::{
ThingCopyText, ThingDependencies, ThingDescs, ThingHierarchy, ThingInteractions,
ThingLayouts, ThingNames,
ThingLayoutEdges, ThingLayouts, ThingNames,
},
};

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

/// Invisible edges between things that affect rank/layout without ever
/// being rendered as a path.
///
/// Each entry is a single `from`/`to` pair keyed by its own ID
/// (conventionally prefixed `edge_layout_`). The `to` thing is ranked
/// after the `from` thing, exactly like a dependency edge, but no
/// `<path>` is ever rendered for it.
#[serde(default, skip_serializing_if = "ThingLayoutEdges::is_empty")]
pub thing_layout_edges: ThingLayoutEdges<'id>,

/// Descriptions to render next to things in the diagram.
#[serde(default, skip_serializing_if = "ThingDescs::is_empty")]
pub thing_descs: ThingDescs<'id>,
Expand Down Expand Up @@ -272,6 +288,7 @@ impl InputDiagram<'static> {
thing_layouts: ThingLayouts::default(),
thing_dependencies: ThingDependencies::default(),
thing_interactions: ThingInteractions::default(),
thing_layout_edges: ThingLayoutEdges::default(),
thing_descs: ThingDescs::default(),
processes: Processes::default(),
tags: TagNames::default(),
Expand Down
7 changes: 5 additions & 2 deletions crate/input_model/src/thing.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
pub use self::{
thing_copy_text::ThingCopyText, thing_dependencies::ThingDependencies, thing_descs::ThingDescs,
thing_hierarchy::ThingHierarchy, thing_id::ThingId, thing_interactions::ThingInteractions,
layout_edge::LayoutEdge, thing_copy_text::ThingCopyText, thing_dependencies::ThingDependencies,
thing_descs::ThingDescs, thing_hierarchy::ThingHierarchy, thing_id::ThingId,
thing_interactions::ThingInteractions, thing_layout_edges::ThingLayoutEdges,
thing_layouts::ThingLayouts, thing_names::ThingNames,
};

mod layout_edge;
mod thing_copy_text;
mod thing_dependencies;
mod thing_descs;
mod thing_hierarchy;
mod thing_id;
mod thing_interactions;
mod thing_layout_edges;
mod thing_layouts;
mod thing_names;
1 change: 1 addition & 0 deletions crate/input_model/src/thing/layout_edge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub use disposition_model_common::thing::LayoutEdge;
1 change: 1 addition & 0 deletions crate/input_model/src/thing/thing_layout_edges.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub use disposition_model_common::thing::ThingLayoutEdges;
1 change: 1 addition & 0 deletions crate/input_rt/src/id_rename.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub fn id_rename_in_input_diagram(
thing_layouts: _,
thing_dependencies: _,
thing_interactions: _,
thing_layout_edges: _,
thing_descs,
processes: _,
tags: _,
Expand Down
Loading
Loading