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
+57 -118
View File
@@ -1,6 +1,9 @@
pub use crate::model::PortAnchorSide;
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod port_layout;
pub use port_layout::{port_anchor_offset, port_anchor_position};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedNode {
@@ -32,34 +35,31 @@ pub struct ProjectedPort {
pub position: Position,
pub side: PortAnchorSide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortAnchorSide {
Left,
Right,
Bottom,
}
/// Semantic fallback for an unconnected port. Neutral ports have no preferred
/// side; the projected view resolves their placement using peers and capacity.
/// A preferred side never constrains an authored anchor.
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
return None;
};
Some(match contract {
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
match contract {
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
} => PortAnchorSide::Left,
} => Some(PortAnchorSide::Left),
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
} => PortAnchorSide::Right,
} => Some(PortAnchorSide::Right),
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
Some(PortDirection::In) => PortAnchorSide::Left,
Some(PortDirection::Out) => PortAnchorSide::Right,
_ => PortAnchorSide::Bottom,
Some(PortDirection::In) => Some(PortAnchorSide::Left),
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
_ => None,
},
_ => PortAnchorSide::Bottom,
})
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectedView {
@@ -67,38 +67,11 @@ pub struct ProjectedView {
pub edges: Vec<ProjectedEdge>,
pub ports: Vec<ProjectedPort>,
}
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
.into_iter()
.map(|side| {
p.children(element)
.filter(|port| port_anchor_side(port) == Some(side))
.count()
})
.max()
.unwrap_or(0);
if count > 0 {
size.height = size.height.max(76. + 28. * (count + 1) as f32);
}
let bottom = p
.children(element)
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
.count();
if bottom > 0 {
size.width = size.width.max(28. * (bottom + 1) as f32);
}
size
pub(crate) fn minimum_node_size(p: &Project, element: Id, size: Size) -> Size {
port_layout::minimum_size(p, element, size)
}
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
let count = p
.children(element)
.filter(|e| e.kind() == Kind::Port)
.count();
if count == 0 {
24.
} else {
28. * count as f32 + 24.
}
pub(crate) fn port_footer(_p: &Project, _element: Id) -> f32 {
24.
}
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
@@ -300,25 +273,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
nodes.sort_by_key(|n| (n.depth, n.id));
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let footer = port_footer(p, nodes[parent].element);
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
let bottom =
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - 64.);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
}
enclose(p, &mut nodes);
let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect();
let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect();
let anchor = |element: Id| {
@@ -341,56 +296,17 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
None
};
let mut ports = Vec::new();
for node in &nodes {
let mut children: Vec<_> = p
.children(node.element)
.filter(|e| e.kind() == Kind::Port)
.collect();
children.sort_by_key(|e| (&e.alias, e.id));
for anchor_side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Bottom,
] {
let side: Vec<_> = children
.iter()
.filter(|e| port_anchor_side(e) == Some(anchor_side))
.collect();
for (index, port) in side.iter().enumerate() {
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
let y = if p.elements[&node.element].kind().is_performer()
|| nodes
.iter()
.any(|child| child.visual_parent == Some(node.id))
{
node.position.y + node.size.height - 24. - index as f32 * 28.
} else {
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
};
let position = match anchor_side {
PortAnchorSide::Left => Position {
x: node.position.x,
y,
},
PortAnchorSide::Right => Position {
x: node.position.x + node.size.width,
y,
},
PortAnchorSide::Bottom => Position {
x: node.position.x + node.size.width * fraction,
y: node.position.y + node.size.height,
},
};
ports.push(ProjectedPort {
element: port.id,
owner: node.id,
position,
side: anchor_side,
});
}
}
}
let visible_ports: BTreeSet<_> = p
.elements
.values()
.filter(|e| {
e.kind() == Kind::Port
&& e.parent
.and_then(|id| placed.get(&id))
.is_some_and(|id| visible.contains(id))
})
.map(|e| e.id)
.collect();
let mut edges = Vec::new();
for r in p.relations.values() {
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
@@ -446,8 +362,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
..
}
),
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)),
});
}
}
@@ -468,9 +384,32 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
}
}
let ports = port_layout::layout(p, d, &mut nodes, &edges);
ProjectedView {
nodes,
edges,
ports,
}
}
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let footer = port_footer(p, nodes[parent].element);
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
let bottom =
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - 64.);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
}
}