Add persisted per-view port anchors and connection-aware border placement
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user