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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
* 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])
* Reverse path calculation computation when `to` node has a smaller node rank than the `from` node, to reduce unnecessary spacer routing. ([#62][#62])
* Update `EdgeCurvature::Curved` and `EdgeCurvature::Orthogonal` edge path builders to skip thing description spacers when rank dir spacers are at a larger cross-axis coordinate. ([#62][#62])

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


## 0.3.0 (2026-06-07)
Expand Down
81 changes: 81 additions & 0 deletions crate/input_ir_rt/src/divergent_ancestor_ranks_calculator.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use disposition_ir_model::node::{NodeNestingInfo, NodeRank, NodeRanksNested};

use crate::ir_to_taffy_builder::LcaDepthCalculator;

/// Calculates the ranks of two nodes' divergent ancestors at their LCA level.
///
/// The divergent ancestors are the first nodes in each endpoint's ancestor
/// chain where the chains differ. Their ranks determine the visual rank span
/// that an edge between the two nodes crosses.
pub(crate) struct DivergentAncestorRanksCalculator;

impl DivergentAncestorRanksCalculator {
/// Returns the ranks of the divergent ancestors as `(rank_low, rank_high)`.
///
/// For example, given:
///
/// ```text
/// t_a0 (rank 0):
/// t_a01 (rank 0)
/// t_b0 (rank 1)
/// t_c0 (rank 2):
/// t_c01 (rank 1)
/// ```
///
/// An edge from `t_a01` to `t_c01` has ancestor chains `[t_a0, t_a01]`
/// and `[t_c0, t_c01]`. The chains diverge at index 0, so the
/// divergent ancestors are `t_a0` (rank 0) and `t_c0` (rank 2).
/// The returned ranks are `(0, 2)`.
///
/// Returns `None` if either endpoint is the same node as the other's
/// ancestor (one chain is a prefix of the other), since no
/// cross-rank spacer is meaningful in that case.
pub(crate) fn divergent_ancestor_ranks<'id>(
info_from: &NodeNestingInfo<'id>,
info_to: &NodeNestingInfo<'id>,
node_ranks_nested: &NodeRanksNested<'id>,
) -> Option<(NodeRank, NodeRank)> {
let (rank_from, rank_to) =
Self::divergent_ancestor_ranks_from_to(info_from, info_to, node_ranks_nested)?;

let (rank_low, rank_high) = if rank_from < rank_to {
(rank_from, rank_to)
} else {
(rank_to, rank_from)
};
Some((rank_low, rank_high))
}

/// Returns the ranks of the divergent ancestors as `(rank_from, rank_to)`,
/// preserving which rank belongs to which endpoint.
///
/// Unlike [`Self::divergent_ancestor_ranks`], the ranks are not reordered
/// into `(low, high)`, so callers can tell on which side of a container the
/// LCA gap lies (e.g. whether the gap is at a higher or lower rank than the
/// container's divergent ancestor).
pub(crate) fn divergent_ancestor_ranks_from_to<'id>(
info_from: &NodeNestingInfo<'id>,
info_to: &NodeNestingInfo<'id>,
node_ranks_nested: &NodeRanksNested<'id>,
) -> Option<(NodeRank, NodeRank)> {
let lca_depth = LcaDepthCalculator::calculate(info_from, info_to);
let divergent_from = info_from.ancestor_chain.get(lca_depth)?;
let divergent_to = info_to.ancestor_chain.get(lca_depth)?;

let lca_container = lca_depth
.checked_sub(1)
.map(|i| &info_from.ancestor_chain[i]);
let container_ranks = node_ranks_nested.ranks_for(lca_container)?;

let rank_from = container_ranks
.get(divergent_from)
.copied()
.unwrap_or(NodeRank::new(0));
let rank_to = container_ranks
.get(divergent_to)
.copied()
.unwrap_or(NodeRank::new(0));

Some((rank_from, rank_to))
}
}
149 changes: 149 additions & 0 deletions crate/input_ir_rt/src/edge_route_normalizer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
use disposition_ir_model::{
edge::{EdgeGroups, EdgeId, EdgeRouteReversals},
entity::{EntityType, EntityTypes},
node::{NodeNestingInfos, NodeRanksNested},
};
use disposition_model_common::{
edge::{EdgeCurvature, EdgeGroupId, EdgeLabel, EdgeLabels},
Id, RenderOptions,
};

use crate::{
divergent_ancestor_ranks_calculator::DivergentAncestorRanksCalculator, EdgeIdGenerator,
};

/// Normalizes edge routing direction for cleaner paths.
///
/// An edge whose effective [`EdgeCurvature`] is `Curved`, and whose `from`
/// node's divergent ancestor rank at the LCA level is strictly greater than
/// its `to` node's, is routed through far more spacer waypoints than the same
/// edge in the opposite direction -- the routing algorithm's spacer placement
/// and protrusion bands are tuned for ascending-rank travel. Such an edge's
/// path is much cleaner when computed as though its endpoints were swapped.
///
/// This normalizer swaps `from`/`to` in the stored [`EdgeGroups`] entry (and
/// the edge's [`EdgeLabels`] entry, so each label stays on its real node) for
/// every qualifying edge, and records the edge's ID in the returned
/// [`EdgeRouteReversals`]. Every downstream stage (spacer construction, face
/// assignment, offsets, protrusions, path building) then computes the mirror
/// geometry; at SVG emission the path is reversed so the drawn path still
/// runs from the real `from` node to the real `to` node, with the arrow head
/// on the real `to` node.
///
/// Edges are left untouched when any of the following hold:
///
/// * The effective curvature is not [`EdgeCurvature::Curved`] or
/// [`EdgeCurvature::Orthogonal`] -- `Direct*` edges bypass spacers entirely.
/// * The edge is a self-loop.
/// * One endpoint is an ancestor of the other (no divergent ancestors).
/// * The divergent ancestor ranks are equal (same-rank / cycle edges).
pub(crate) struct EdgeRouteNormalizer;

impl EdgeRouteNormalizer {
/// Reverses the stored direction of descending-rank `Curved` edges.
///
/// Returns the IDs of the edges that were reversed.
pub(crate) fn normalize<'id>(
edge_groups: &mut EdgeGroups<'id>,
edge_labels: &mut EdgeLabels<'id>,
entity_types: &EntityTypes<'id>,
node_nesting_infos: &NodeNestingInfos<'id>,
node_ranks_nested: &NodeRanksNested<'id>,
render_options: &RenderOptions,
) -> EdgeRouteReversals<'id> {
let mut edge_route_reversals = EdgeRouteReversals::new();

edge_groups
.iter_mut()
.for_each(|(edge_group_id, edge_group)| {
edge_group
.iter_mut()
.enumerate()
.for_each(|(edge_index, edge)| {
let edge_id = EdgeIdGenerator::generate(edge_group_id, edge_index);

let edge_curvature =
Self::edge_curvature_effective(entity_types, render_options, &edge_id);
match edge_curvature {
EdgeCurvature::Curved | EdgeCurvature::Orthogonal => {}
EdgeCurvature::DirectStraight | EdgeCurvature::DirectCurved => return,
}
if edge.is_self_loop() {
return;
}

let Some(info_from) = node_nesting_infos.get(&edge.from) else {
return;
};
let Some(info_to) = node_nesting_infos.get(&edge.to) else {
return;
};
let Some((rank_from, rank_to)) =
DivergentAncestorRanksCalculator::divergent_ancestor_ranks_from_to(
info_from,
info_to,
node_ranks_nested,
)
else {
return;
};

if rank_from > rank_to {
*edge = edge.reversed();
Self::edge_label_swap(edge_labels, edge_group_id, &edge_id);
edge_route_reversals.insert(edge_id);
}
});
});

edge_route_reversals
}

/// Returns the effective curvature for an edge.
///
/// Interaction edges use [`RenderOptions::interactions_edge_curvature`];
/// all other edges use [`RenderOptions::dependencies_edge_curvature`].
fn edge_curvature_effective<'id>(
entity_types: &EntityTypes<'id>,
render_options: &RenderOptions,
edge_id: &EdgeId<'id>,
) -> EdgeCurvature {
let is_interaction_edge = entity_types
.get(AsRef::<Id<'_>>::as_ref(edge_id))
.map(|edge_entity_types| {
edge_entity_types
.iter()
.any(EntityType::is_interaction_edge)
})
.unwrap_or(false);

if is_interaction_edge {
render_options.interactions_edge_curvature
} else {
render_options.dependencies_edge_curvature
}
}

/// Swaps the `from`/`to` labels of a reversed edge.
///
/// When only a group-level label entry exists, a swapped edge-specific
/// entry is materialized so the group entry stays valid for the group's
/// non-reversed edges.
fn edge_label_swap<'id>(
edge_labels: &mut EdgeLabels<'id>,
edge_group_id: &EdgeGroupId<'id>,
edge_id: &EdgeId<'id>,
) {
let edge_label_swapped =
edge_labels
.get_for_edge(edge_id, edge_group_id)
.map(|edge_label| EdgeLabel {
from: edge_label.to.clone(),
to: edge_label.from.clone(),
});

if let Some(edge_label_swapped) = edge_label_swapped {
edge_labels.insert(edge_id.clone(), edge_label_swapped);
}
}
}
21 changes: 18 additions & 3 deletions crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use disposition_model_common::{edge::EdgeGroupId, theme::Css, Id, Map, RankDir,
use disposition_taffy_model::{MD_BLOCKQUOTE_BORDER_COLOR, MD_CODE_BG_COLOR, MD_LINK_COLOR};

use crate::{
edge_face_assigner::EdgeFaceAssigner, node_ranks_calculator::NodeRanksCalculator,
edge_face_assigner::EdgeFaceAssigner, edge_route_normalizer::EdgeRouteNormalizer,
node_ranks_calculator::NodeRanksCalculator,
process_step_graph_calculator::ProcessStepGraphCalculator,
};

Expand Down Expand Up @@ -145,13 +146,13 @@ impl InputToIrDiagramMapper {
let node_ordering = Self::build_node_ordering(things, tags, processes, &process_step_ranks);

// 5. Build EdgeGroups from thing_dependencies and thing_interactions
let edge_groups = Self::build_edge_groups(thing_dependencies, thing_interactions);
let mut edge_groups = Self::build_edge_groups(thing_dependencies, thing_interactions);

// 6. Clone ThingDescs from input thing_descs
let thing_descs = thing_descs.clone();

// 7. Build EdgeLabels from input edge_labels
let edge_labels = edge_labels.clone();
let mut edge_labels = edge_labels.clone();

// 8. Clone EdgeDescs from input edge_descs
let edge_descs = edge_descs.clone();
Expand Down Expand Up @@ -240,6 +241,19 @@ impl InputToIrDiagramMapper {
&layout_edges,
);

// 16a. Reverse the stored direction of descending-rank `Curved` edges
// so every later stage (spacer construction, face assignment,
// offsets, protrusions, path building) computes the cleaner
// mirror geometry. The SVG path is reversed back at emission.
let edge_route_reversals = EdgeRouteNormalizer::normalize(
&mut edge_groups,
&mut edge_labels,
&ir_entity_types,
&node_nesting_infos,
&node_ranks_nested,
render_options,
);

// 17. Compute EdgeFaceAssignments from rank/sibling data before layout
let edge_face_assignments = EdgeFaceAssigner::compute(
&edge_groups,
Expand All @@ -264,6 +278,7 @@ impl InputToIrDiagramMapper {
node_hierarchy,
node_ordering,
edge_groups,
edge_route_reversals,
thing_descs,
thing_layout_edges: thing_layout_edges.clone(),
edge_descs,
Expand Down
3 changes: 3 additions & 0 deletions crate/input_ir_rt/src/ir_to_taffy_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use typed_builder::TypedBuilder;

use crate::EdgeIdGenerator;

pub(crate) use self::edge_spacer_builder::LcaDepthCalculator;

use self::{
edge_description_builder::{EdgeDescriptionBuildResult, EdgeDescriptionBuilder},
edge_label_builder::EdgeLabelBuilder,
Expand Down Expand Up @@ -120,6 +122,7 @@ impl IrToTaffyBuilder<'_> {
node_hierarchy,
node_ordering: _,
edge_groups,
edge_route_reversals: _,
thing_descs,
thing_layout_edges: _,
edge_descs,
Expand Down
50 changes: 17 additions & 33 deletions crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use disposition_taffy_model::{
};
use taffy::AlignSelf;

use crate::EdgeIdGenerator;
use crate::{
divergent_ancestor_ranks_calculator::DivergentAncestorRanksCalculator, EdgeIdGenerator,
};

use super::{
rank_and_sibling_index_middle::RankAndSiblingIndexMiddle,
Expand Down Expand Up @@ -1282,19 +1284,15 @@ impl EdgeSpacerBuilder {
/// ancestor (one chain is a prefix of the other), since no
/// cross-rank spacer is meaningful in that case.
fn divergent_ancestor_ranks(
info_from: &NodeNestingInfo<'_>,
info_to: &NodeNestingInfo<'_>,
info_from: &NodeNestingInfo<'static>,
info_to: &NodeNestingInfo<'static>,
node_ranks_nested: &NodeRanksNested<'static>,
) -> Option<(NodeRank, NodeRank)> {
let (rank_from, rank_to) =
Self::divergent_ancestor_ranks_from_to(info_from, info_to, node_ranks_nested)?;

let (rank_low, rank_high) = if rank_from < rank_to {
(rank_from, rank_to)
} else {
(rank_to, rank_from)
};
Some((rank_low, rank_high))
DivergentAncestorRanksCalculator::divergent_ancestor_ranks(
info_from,
info_to,
node_ranks_nested,
)
}

/// Returns the ranks of the divergent ancestors as `(rank_from, rank_to)`,
Expand All @@ -1305,28 +1303,14 @@ impl EdgeSpacerBuilder {
/// LCA gap lies (e.g. whether the gap is at a higher or lower rank than the
/// container's divergent ancestor).
fn divergent_ancestor_ranks_from_to(
info_from: &NodeNestingInfo<'_>,
info_to: &NodeNestingInfo<'_>,
info_from: &NodeNestingInfo<'static>,
info_to: &NodeNestingInfo<'static>,
node_ranks_nested: &NodeRanksNested<'static>,
) -> Option<(NodeRank, NodeRank)> {
let lca_depth = LcaDepthCalculator::calculate(info_from, info_to);
let divergent_from = info_from.ancestor_chain.get(lca_depth)?;
let divergent_to = info_to.ancestor_chain.get(lca_depth)?;

let lca_container = lca_depth
.checked_sub(1)
.map(|i| &info_from.ancestor_chain[i]);
let container_ranks = node_ranks_nested.ranks_for(lca_container)?;

let rank_from = container_ranks
.get(divergent_from)
.copied()
.unwrap_or(NodeRank::new(0));
let rank_to = container_ranks
.get(divergent_to)
.copied()
.unwrap_or(NodeRank::new(0));

Some((rank_from, rank_to))
DivergentAncestorRanksCalculator::divergent_ancestor_ranks_from_to(
info_from,
info_to,
node_ranks_nested,
)
}
}
Loading
Loading