Add persisted per-view port anchors and connection-aware border placement
This commit is contained in:
@@ -66,7 +66,14 @@ impl ParseLimits {
|
||||
project
|
||||
.diagrams
|
||||
.values()
|
||||
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
|
||||
.map(|d| {
|
||||
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
|
||||
d.nodes
|
||||
.values()
|
||||
.map(|n| n.port_anchors.len())
|
||||
.sum::<usize>(),
|
||||
)
|
||||
})
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
@@ -349,6 +356,7 @@ fn reference_field(key: &str) -> bool {
|
||||
| "sa"
|
||||
| "diagram"
|
||||
| "edge"
|
||||
| "port"
|
||||
)
|
||||
}
|
||||
fn enum_field(key: &str) -> bool {
|
||||
@@ -368,6 +376,7 @@ fn enum_field(key: &str) -> bool {
|
||||
| "viewpoint"
|
||||
| "message_kind"
|
||||
| "transition_kind"
|
||||
| "side"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1663,7 +1672,7 @@ pub(crate) fn resolve_declarations(
|
||||
if !fields.contains_key("element") {
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
|
||||
@@ -1264,6 +1264,34 @@ pub struct EdgeLabel {
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
/// Presentation side; independent of the semantic port direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
impl PortAnchorSide {
|
||||
pub fn normal(self) -> Position {
|
||||
match self {
|
||||
Self::Left => Position { x: -1., y: 0. },
|
||||
Self::Right => Position { x: 1., y: 0. },
|
||||
Self::Top => Position { x: 0., y: -1. },
|
||||
Self::Bottom => Position { x: 0., y: 1. },
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Manual position of a semantic child port on one owner placement.
|
||||
/// Absence from the placement's list selects automatic positioning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortAnchor {
|
||||
pub port: Id,
|
||||
pub side: PortAnchorSide,
|
||||
/// Fraction of the complete edge, from top (vertical) or left (horizontal).
|
||||
pub offset: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1273,6 +1301,8 @@ pub struct NodePlacement {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub port_anchors: Vec<PortAnchor>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ArtifactRelation {
|
||||
@@ -1660,6 +1690,7 @@ impl Project {
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
if let Some(d) = self.diagrams.get_mut(&diagram) {
|
||||
d.nodes.insert(id, node);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -187,6 +187,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
@@ -345,6 +350,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
node.port_anchors
|
||||
.retain(|anchor| !removed.contains(&anchor.port));
|
||||
}
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
@@ -264,11 +264,7 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
.iter()
|
||||
.find(|p| p.element == id && p.owner == node.id)
|
||||
}) {
|
||||
let normal = match port.side {
|
||||
PortAnchorSide::Left => pos(-1.0, 0.0),
|
||||
PortAnchorSide::Right => pos(1.0, 0.0),
|
||||
PortAnchorSide::Bottom => pos(0.0, 1.0),
|
||||
};
|
||||
let normal = port.side.normal();
|
||||
return vec![Endpoint {
|
||||
point: port.position,
|
||||
stub: pos(
|
||||
@@ -1190,11 +1186,22 @@ impl BoundsBuilder {
|
||||
}
|
||||
fn finish(self) -> RoutingBounds {
|
||||
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||||
// Subtraction and subsequent addition can round in opposite directions
|
||||
// when an authored label extends far left/up. Bounds must still enclose
|
||||
// every original point, including the far right and bottom edges.
|
||||
let span = |start: f32, end: f32| {
|
||||
let extent = end - start;
|
||||
if start + extent < end {
|
||||
extent.next_up()
|
||||
} else {
|
||||
extent
|
||||
}
|
||||
};
|
||||
RoutingBounds {
|
||||
position: pos(left, top),
|
||||
size: Size {
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
width: span(left, right),
|
||||
height: span(top, bottom),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ pub enum Command {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
SetPortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
anchor: PortAnchor,
|
||||
},
|
||||
RemovePortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
port: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -679,6 +689,45 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if let Some(existing) = n.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*existing = anchor.clone();
|
||||
} else {
|
||||
n.port_anchors.push(anchor.clone());
|
||||
}
|
||||
Some(anchor.port)
|
||||
}
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if !p
|
||||
.elements
|
||||
.get(port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Port positioning requires a direct child port of the owner placement.".into(),
|
||||
));
|
||||
}
|
||||
n.port_anchors.retain(|anchor| anchor.port != *port);
|
||||
Some(*port)
|
||||
}
|
||||
Command::SetEdgeRoute { diagram, route } => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
|
||||
@@ -1457,15 +1457,17 @@ fn item_key(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge", "port"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::List(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -981,6 +981,7 @@ pub fn plan_transition(
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
working
|
||||
.diagrams
|
||||
|
||||
@@ -732,6 +732,40 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
let mut ports = BTreeSet::new();
|
||||
for anchor in &n.port_anchors {
|
||||
if !ports.insert(anchor.port) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"duplicate_port_anchor",
|
||||
"port_anchors",
|
||||
"Each port has at most one manual anchor per owner placement.",
|
||||
);
|
||||
}
|
||||
if !p
|
||||
.elements
|
||||
.get(&anchor.port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"port_anchor_owner",
|
||||
"port_anchors",
|
||||
"A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.",
|
||||
);
|
||||
}
|
||||
if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"invalid_port_offset",
|
||||
"port_anchors",
|
||||
"Port offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if *key != n.id {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
Reference in New Issue
Block a user