Add persisted per-view port anchors and connection-aware border placement

This commit is contained in:
2026-09-06 01:20:28 +03:00
parent 93fd32c684
commit 35133de650
13 changed files with 1435 additions and 149 deletions
@@ -0,0 +1,383 @@
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
//! never on routed elbows, so routing cannot feed back into layout.
use super::*;
const GAP: f32 = 28.;
const CORNER: f32 = 18.;
const SIDES: [PortAnchorSide; 4] = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
];
pub fn port_anchor_position(node: &ProjectedNode, side: PortAnchorSide, offset: f32) -> Position {
let (x, y) = match side {
PortAnchorSide::Left => (0., node.size.height * offset),
PortAnchorSide::Right => (node.size.width, node.size.height * offset),
PortAnchorSide::Top => (node.size.width * offset, 0.),
PortAnchorSide::Bottom => (node.size.width * offset, node.size.height),
};
Position {
x: node.position.x + x,
y: node.position.y + y,
}
}
pub fn port_anchor_offset(node: &ProjectedNode, side: PortAnchorSide, position: Position) -> f32 {
let value = match side {
PortAnchorSide::Left | PortAnchorSide::Right => {
(position.y - node.position.y) / node.size.height
}
PortAnchorSide::Top | PortAnchorSide::Bottom => {
(position.x - node.position.x) / node.size.width
}
};
value.clamp(0., 1.)
}
fn center(node: &ProjectedNode) -> Position {
Position {
x: node.position.x + node.size.width / 2.,
y: node.position.y + node.size.height / 2.,
}
}
#[derive(Clone)]
struct Choice {
port: Id,
side: PortAnchorSide,
automatic_side: PortAnchorSide,
manual: Option<f32>,
peer: Option<Position>,
}
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
let c = center(node);
let dx = (peer.x - c.x) / node.size.width;
let dy = (peer.y - c.y) / node.size.height;
if dx.abs().max(dy.abs()) < 0.001 {
return None;
}
if dx.abs() >= dy.abs() {
Some(if dx < 0. {
PortAnchorSide::Left
} else {
PortAnchorSide::Right
})
} else {
Some(if dy < 0. {
PortAnchorSide::Top
} else {
PortAnchorSide::Bottom
})
}
}
fn clear_exit(
node: &ProjectedNode,
side: PortAnchorSide,
peers: &BTreeSet<Id>,
by_id: &BTreeMap<Id, &ProjectedNode>,
) -> bool {
let a = port_anchor_position(node, side, 0.5);
let normal = side.normal();
let b = Position {
x: a.x + normal.x * crate::routing::PORT_STUB,
y: a.y + normal.y * crate::routing::PORT_STUB,
};
peers.iter().all(|peer| {
let other = by_id[peer];
// A containing endpoint's body is traversable, just as in routing.
let mut ancestor = node.visual_parent;
for _ in 0..by_id.len() {
let Some(id) = ancestor else {
break;
};
if id == *peer {
return true;
}
ancestor = by_id.get(&id).and_then(|n| n.visual_parent);
}
let padding = crate::routing::CLEARANCE;
let left = other.position.x - padding;
let right = other.position.x + other.size.width + padding;
let top = other.position.y - padding;
let bottom = other.position.y + other.size.height + padding;
if a.x == b.x {
!(a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top)
} else {
!(a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left)
}
})
}
#[derive(Default)]
struct Peers {
x: f64,
y: f64,
count: usize,
owners: BTreeSet<Id>,
}
impl Peers {
fn center(&self) -> Position {
Position {
x: (self.x / self.count as f64) as f32,
y: (self.y / self.count as f64) as f32,
}
}
}
pub(super) fn minimum_size(p: &Project, element: Id, mut size: Size) -> Size {
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
let mut neutral = 0;
for port in p.children(element).filter(|p| p.kind() == Kind::Port) {
if let Some(side) = port_anchor_side(port) {
*counts.get_mut(&side).unwrap() += 1;
} else {
neutral += 1;
}
}
for _ in 0..neutral {
let side = *SIDES.iter().min_by_key(|s| counts[s]).unwrap();
*counts.get_mut(&side).unwrap() += 1;
}
let vertical = counts[&PortAnchorSide::Left].max(counts[&PortAnchorSide::Right]);
let horizontal = counts[&PortAnchorSide::Top].max(counts[&PortAnchorSide::Bottom]);
if vertical > 0 {
size.height = size.height.max(76. + GAP * (vertical + 1) as f32);
}
if horizontal > 0 {
size.width = size.width.max(GAP * (horizontal + 1) as f32);
}
size
}
pub(super) fn layout(
p: &Project,
d: &Diagram,
nodes: &mut [ProjectedNode],
edges: &[ProjectedEdge],
) -> Vec<ProjectedPort> {
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
for edge in edges {
// Port allocation is implementation traceability, not a communication
// peer. It must not turn an external boundary port toward its own work.
if !p.relations.get(&edge.id).is_some_and(|r| {
matches!(
r.kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
)
}) {
continue;
}
for (owner, port, other) in [
(edge.source, edge.source_port, edge.target),
(edge.target, edge.target_port, edge.source),
] {
if let (Some(port), Some(other)) = (port, by_id.get(&other)) {
let c = center(other);
let entry = peers.entry((owner, port)).or_default();
entry.x += f64::from(c.x);
entry.y += f64::from(c.y);
entry.count += 1;
entry.owners.insert(other.id);
}
}
}
let mut children: BTreeMap<Id, Vec<&Element>> = BTreeMap::new();
for port in p.elements.values().filter(|e| e.kind() == Kind::Port) {
if let Some(parent) = port.parent {
children.entry(parent).or_default().push(port);
}
}
let mut choices = BTreeMap::<Id, Vec<Choice>>::new();
for node in nodes.iter() {
let anchors: BTreeMap<_, _> = d.nodes[&node.id]
.port_anchors
.iter()
.map(|a| (a.port, a))
.collect();
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
// Compute the automatic capacity independently of manual overrides.
// Moving a port must not resize its owner or flip unrelated ports.
for port in children.get(&node.element).into_iter().flatten() {
let connected = peers.get(&(node.id, port.id));
let peer = connected.map(Peers::center);
let clear =
|side| connected.is_none_or(|peers| clear_exit(node, side, &peers.owners, &by_id));
let anchor = anchors.get(&port.id);
let side = {
peer.and_then(|v| facing(node, v))
.filter(|side| clear(*side))
}
.or_else(|| port_anchor_side(port).filter(|side| clear(*side)))
.or_else(|| {
SIDES
.iter()
.filter(|side| clear(**side))
.min_by_key(|s| counts[s])
.copied()
})
.unwrap_or_else(|| *SIDES.iter().min_by_key(|s| counts[s]).unwrap());
*counts.get_mut(&side).unwrap() += 1;
choices.entry(node.id).or_default().push(Choice {
port: port.id,
side: anchor.map_or(side, |a| a.side),
automatic_side: side,
manual: anchor.map(|a| a.offset),
peer,
});
}
}
// One capacity pass followed by semantic enclosure. Side choices stay frozen
// throughout this pass; expanded parents cannot cause layout oscillation.
for node in nodes.iter_mut() {
for side in SIDES {
let count = choices
.get(&node.id)
.into_iter()
.flatten()
.filter(|c| c.automatic_side == side)
.count();
if count == 0 {
continue;
}
let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32;
match side {
PortAnchorSide::Left | PortAnchorSide::Right => {
node.size.height = node.size.height.max(minimum)
}
PortAnchorSide::Top | PortAnchorSide::Bottom => {
node.size.width = node.size.width.max(minimum)
}
}
}
}
super::enclose(p, nodes);
let mut result = Vec::new();
for node in nodes.iter() {
let Some(choices) = choices.get(&node.id) else {
continue;
};
for side in SIDES {
let mut automatic: Vec<_> = choices
.iter()
.filter(|c| c.side == side && c.manual.is_none())
.collect();
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
automatic.sort_by(|a, b| {
let axis = |c: &Choice| c.peer.map(|p| if horizontal { p.x } else { p.y });
match (axis(a), axis(b)) {
(Some(a), Some(b)) => a.total_cmp(&b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
}
.then_with(|| a.port.cmp(&b.port))
});
let length = if horizontal {
node.size.width
} else {
node.size.height
};
let mut fixed = Vec::new();
for choice in choices.iter().filter(|c| c.side == side) {
if let Some(offset) = choice.manual {
fixed.push(offset * length);
result.push(ProjectedPort {
element: choice.port,
owner: node.id,
side,
position: port_anchor_position(node, side, offset),
});
}
}
let positions = automatic_positions(length, &fixed, automatic.len());
for (choice, distance) in automatic.into_iter().zip(positions) {
result.push(ProjectedPort {
element: choice.port,
owner: node.id,
side,
position: port_anchor_position(node, side, distance / length),
});
}
}
}
result
}
/// Allocate free intervals around authored anchors. If manual anchors are too
/// crowded to satisfy the desired gap, retain them and degrade spacing evenly.
fn automatic_positions(length: f32, fixed: &[f32], count: usize) -> Vec<f32> {
if count == 0 {
return Vec::new();
}
let mut fixed = fixed.to_vec();
fixed.sort_by(f32::total_cmp);
let mut gap = GAP;
let intervals = loop {
let mut start = CORNER.min(length / 2.);
let end = (length - CORNER).max(start);
let mut free = Vec::new();
for &anchor in &fixed {
if anchor - gap >= start {
free.push((start, (anchor - gap).min(end)));
}
start = start.max(anchor + gap);
if start > end {
break;
}
}
if start <= end {
free.push((start, end));
}
free.retain(|(a, b)| b >= a);
let capacity: usize = free
.iter()
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
.sum();
if capacity >= count {
break free;
}
gap /= 2.;
if gap < 0.001 {
return (0..count)
.map(|i| length * (i + 1) as f32 / (count + 1) as f32)
.collect();
}
};
let capacities: Vec<_> = intervals
.iter()
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
.collect();
let total: usize = capacities.iter().sum();
let mut allocated: Vec<_> = capacities
.iter()
.map(|capacity| (count as u128 * *capacity as u128 / total as u128) as usize)
.collect();
let mut remainder: Vec<_> = capacities
.iter()
.enumerate()
.map(|(i, capacity)| (count as u128 * *capacity as u128 % total as u128, i))
.collect();
remainder.sort_by(|(a, i), (b, j)| b.cmp(a).then_with(|| i.cmp(j)));
let unassigned = count - allocated.iter().sum::<usize>();
for (_, index) in remainder.into_iter().take(unassigned) {
allocated[index] += 1;
}
intervals
.into_iter()
.zip(allocated)
.flat_map(|((a, b), count)| {
(0..count).map(move |i| {
if count == 1 {
(a + b) / 2.
} else {
a + (b - a) * i as f32 / (count - 1) as f32
}
})
})
.collect()
}