574 lines
21 KiB
Rust
574 lines
21 KiB
Rust
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};
|
|
#[path = "projection/state.rs"]
|
|
mod state;
|
|
pub(crate) use state::node_size as state_node_size;
|
|
pub use state::{state_header_height, state_machine_contains, state_symbol_size};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ProjectedNode {
|
|
pub id: Id,
|
|
pub element: Id,
|
|
pub position: Position,
|
|
pub size: Size,
|
|
pub visual_parent: Option<Id>,
|
|
pub depth: usize,
|
|
pub collapsed: bool,
|
|
/// The work is grouped by its allocated leaves, not by a direct allocation.
|
|
pub allocation_summary: bool,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ProjectedEdge {
|
|
pub id: Id,
|
|
pub source: Id,
|
|
pub target: Id,
|
|
pub trace: bool,
|
|
pub oriented: bool,
|
|
pub source_port: Option<Id>,
|
|
pub target_port: Option<Id>,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct ProjectedPort {
|
|
pub element: Id,
|
|
/// Placement identity of the owning visible function/participant.
|
|
pub owner: Id,
|
|
pub position: Position,
|
|
pub side: PortAnchorSide,
|
|
}
|
|
/// 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;
|
|
};
|
|
match contract {
|
|
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
|
|
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation: ComponentPortOrientation::In,
|
|
} => Some(PortAnchorSide::Left),
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation: ComponentPortOrientation::Out,
|
|
} => Some(PortAnchorSide::Right),
|
|
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
|
Some(PortDirection::In) => Some(PortAnchorSide::Left),
|
|
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
}
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
|
pub struct ProjectedView {
|
|
pub nodes: Vec<ProjectedNode>,
|
|
pub edges: Vec<ProjectedEdge>,
|
|
pub ports: Vec<ProjectedPort>,
|
|
}
|
|
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 {
|
|
24.
|
|
}
|
|
|
|
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
|
let placed: BTreeMap<_, _> = d
|
|
.nodes
|
|
.values()
|
|
.filter(|n| {
|
|
p.elements.get(&n.element).is_some_and(|e| {
|
|
e.kind() != Kind::Port
|
|
&& d.projection.includes(e.kind())
|
|
&& d.projection
|
|
.state_machine()
|
|
.is_none_or(|machine| state_machine_contains(p, machine, e.id))
|
|
})
|
|
})
|
|
.map(|n| (n.element, n.id))
|
|
.collect();
|
|
d.nodes
|
|
.values()
|
|
.filter(|n| placed.contains_key(&n.element))
|
|
.map(|n| {
|
|
let mut semantic = p.elements.get(&n.element).and_then(|e| e.parent);
|
|
if d.projection == ProjectionKind::Allocation
|
|
&& let Some(performer) = crate::validation::allocation_summary(p, n.element)
|
|
{
|
|
let mut candidate = semantic;
|
|
let mut visible_work_parent = None;
|
|
let mut seen = BTreeSet::new();
|
|
while let Some(id) = candidate {
|
|
if !seen.insert(id)
|
|
|| crate::validation::allocation_summary(p, id) != Some(performer)
|
|
{
|
|
break;
|
|
}
|
|
if placed.contains_key(&id) {
|
|
visible_work_parent = Some(id);
|
|
break;
|
|
}
|
|
candidate = p.elements.get(&id).and_then(|e| e.parent);
|
|
}
|
|
semantic = visible_work_parent.or(Some(performer));
|
|
}
|
|
if d.projection == ProjectionKind::Traceability {
|
|
return (n.id, None);
|
|
}
|
|
let mut seen = BTreeSet::new();
|
|
let mut parent = None;
|
|
while let Some(id) = semantic {
|
|
if !seen.insert(id) {
|
|
break;
|
|
}
|
|
if let Some(node) = placed.get(&id) {
|
|
parent = Some(*node);
|
|
break;
|
|
}
|
|
semantic = p.elements.get(&id).and_then(|e| e.parent);
|
|
}
|
|
(n.id, parent)
|
|
})
|
|
.collect()
|
|
}
|
|
pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec<Id> {
|
|
let Some(d) = p.diagrams.get(&diagram) else {
|
|
return vec![];
|
|
};
|
|
let parents = parents(p, d);
|
|
parents
|
|
.keys()
|
|
.copied()
|
|
.filter(|candidate| {
|
|
let mut current = parents.get(candidate).copied().flatten();
|
|
let mut seen = BTreeSet::new();
|
|
while let Some(id) = current {
|
|
if id == node {
|
|
return true;
|
|
}
|
|
if !seen.insert(id) {
|
|
break;
|
|
}
|
|
current = parents.get(&id).copied().flatten();
|
|
}
|
|
false
|
|
})
|
|
.collect()
|
|
}
|
|
/// Actual endpoint-owner placements before folding promotes a hidden endpoint
|
|
/// to an ancestor. Ports belong to their placed function/participant; they do not
|
|
/// acquire independent placement identities. Missing placements are not inferred.
|
|
pub fn edge_endpoint_placements(p: &Project, d: &Diagram, edge: Id) -> Option<(Id, Id)> {
|
|
let (source, target) = if let Some(r) = p.relations.get(&edge) {
|
|
(r.source(), r.target())
|
|
} else {
|
|
let trace = p.traces.get(&edge)?;
|
|
(trace.oa, trace.sa)
|
|
};
|
|
let placement = |id| {
|
|
let element = p.elements.get(&id)?;
|
|
let owner = if element.kind() == Kind::Port {
|
|
element.parent?
|
|
} else {
|
|
id
|
|
};
|
|
d.nodes.values().find(|n| n.element == owner).map(|n| n.id)
|
|
};
|
|
Some((placement(source)?, placement(target)?))
|
|
}
|
|
|
|
/// Route updates for a simultaneous placement movement. `deltas` maps placement
|
|
/// identities to diagram-space displacement. Only routes with two actual owners
|
|
/// moving by the same nonzero delta translate; one-ended moves keep world pins.
|
|
/// Returns changed records only, for the caller to apply with its node edits in
|
|
/// one transaction. This pure helper also drives reflow and drag previews.
|
|
pub fn translated_routes(
|
|
p: &Project,
|
|
d: &Diagram,
|
|
deltas: &BTreeMap<Id, Position>,
|
|
) -> Vec<EdgeRoute> {
|
|
d.routes
|
|
.iter()
|
|
.filter_map(|route| {
|
|
let (source, target) = edge_endpoint_placements(p, d, route.edge)?;
|
|
let source_delta = deltas.get(&source)?;
|
|
let target_delta = deltas.get(&target)?;
|
|
if d.projection.state_machine().is_some()
|
|
&& !state::boundary_moves_with(p, d, route.edge, source_delta, deltas)
|
|
{
|
|
return None;
|
|
}
|
|
if source_delta != target_delta
|
|
|| (source_delta.x == 0. && source_delta.y == 0.)
|
|
|| route.waypoints.is_empty()
|
|
{
|
|
return None;
|
|
}
|
|
let mut translated = route.clone();
|
|
for point in &mut translated.waypoints {
|
|
point.x += source_delta.x;
|
|
point.y += source_delta.y;
|
|
}
|
|
Some(translated)
|
|
})
|
|
.collect()
|
|
}
|
|
/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement.
|
|
pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
|
|
let Some(d) = p.diagrams.get(&diagram) else {
|
|
return ProjectedView::default();
|
|
};
|
|
project_diagram(p, d)
|
|
}
|
|
/// Reuse an index built for this accepted semantic revision during presentation
|
|
/// previews. Callers must invalidate it when semantic records change.
|
|
pub fn project_view_with_index(
|
|
p: &Project,
|
|
diagram: Id,
|
|
index: &crate::view_options::ViewIndex,
|
|
) -> ProjectedView {
|
|
p.diagrams
|
|
.get(&diagram)
|
|
.map_or_else(ProjectedView::default, |d| {
|
|
project_diagram_with_index(p, d, index)
|
|
})
|
|
}
|
|
/// Project alternate placement geometry without copying or mutating semantics.
|
|
pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
|
let index = crate::view_options::ViewIndex::new(p);
|
|
project_diagram_with_index(p, d, &index)
|
|
}
|
|
pub fn project_diagram_with_index(
|
|
p: &Project,
|
|
d: &Diagram,
|
|
index: &crate::view_options::ViewIndex,
|
|
) -> ProjectedView {
|
|
project_diagram_impl(p, d, None, Some(index))
|
|
}
|
|
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
|
|
/// for drawing. Compute once for a view-options editor, then combine with the
|
|
/// prepared filter's reasons; hidden lines remain geometrically eligible here.
|
|
pub fn edge_availability(
|
|
p: &Project,
|
|
d: &Diagram,
|
|
) -> BTreeMap<Id, crate::view_options::EdgeAvailability> {
|
|
let mut availability = p
|
|
.relations
|
|
.keys()
|
|
.chain(p.traces.keys())
|
|
.map(|id| {
|
|
(
|
|
*id,
|
|
crate::view_options::EdgeAvailability::OutsideProjection,
|
|
)
|
|
})
|
|
.collect();
|
|
project_diagram_impl(p, d, Some(&mut availability), None);
|
|
availability
|
|
}
|
|
fn project_diagram_impl(
|
|
p: &Project,
|
|
d: &Diagram,
|
|
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
|
index: Option<&crate::view_options::ViewIndex>,
|
|
) -> ProjectedView {
|
|
let parents = parents(p, d);
|
|
let mut nodes = Vec::new();
|
|
for n in d.nodes.values() {
|
|
let Some(e) = p.elements.get(&n.element) else {
|
|
continue;
|
|
};
|
|
if e.kind() == Kind::Port
|
|
|| !d.projection.includes(e.kind())
|
|
|| d.projection
|
|
.state_machine()
|
|
.is_some_and(|machine| !state_machine_contains(p, machine, e.id))
|
|
{
|
|
continue;
|
|
}
|
|
if d.projection != ProjectionKind::Traceability && e.layer() != d.layer {
|
|
continue;
|
|
}
|
|
let mut current = parents.get(&n.id).copied().flatten();
|
|
let mut seen = BTreeSet::new();
|
|
let mut depth = 0;
|
|
let mut hidden = false;
|
|
while let Some(id) = current {
|
|
if !seen.insert(id) {
|
|
hidden = true;
|
|
break;
|
|
}
|
|
depth += 1;
|
|
if d.nodes.get(&id).is_some_and(|n| n.collapsed) {
|
|
hidden = true;
|
|
}
|
|
current = parents.get(&id).copied().flatten();
|
|
}
|
|
if !hidden {
|
|
let size = minimum_node_size(
|
|
p,
|
|
e.id,
|
|
if n.collapsed {
|
|
Size {
|
|
width: n.size.width,
|
|
height: 94.,
|
|
}
|
|
} else {
|
|
n.size
|
|
},
|
|
);
|
|
let size = if d.projection.state_machine().is_some() {
|
|
state::node_size(p, e, size, n.collapsed)
|
|
} else {
|
|
size
|
|
};
|
|
nodes.push(ProjectedNode {
|
|
id: n.id,
|
|
element: n.element,
|
|
position: n.position,
|
|
size,
|
|
visual_parent: parents.get(&n.id).copied().flatten(),
|
|
depth,
|
|
collapsed: n.collapsed,
|
|
allocation_summary: d.projection == ProjectionKind::Allocation
|
|
&& crate::validation::allocation_summary(p, e.id).is_some()
|
|
&& !p
|
|
.relations
|
|
.values()
|
|
.any(|r| r.kind() == RelationKind::Allocation && r.target() == e.id),
|
|
});
|
|
}
|
|
}
|
|
nodes.sort_by_key(|n| (n.depth, n.id));
|
|
enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some());
|
|
if d.projection.state_machine().is_some() {
|
|
state::snap_boundaries(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| {
|
|
let owner = p
|
|
.elements
|
|
.get(&element)
|
|
.filter(|e| e.kind() == Kind::Port)
|
|
.and_then(|e| e.parent)
|
|
.unwrap_or(element);
|
|
let mut id = placed.get(&owner).copied();
|
|
let mut seen = BTreeSet::new();
|
|
while let Some(n) = id {
|
|
if visible.contains(&n) {
|
|
return Some(n);
|
|
}
|
|
if !seen.insert(n) {
|
|
break;
|
|
}
|
|
id = parents.get(&n).copied().flatten();
|
|
}
|
|
None
|
|
};
|
|
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() {
|
|
// Interface allocations are selectable nested dictionary rows. Their
|
|
// independently identified contracts do not add noisy diagram arrows.
|
|
if r.kind() == RelationKind::InterfaceItemAllocation {
|
|
continue;
|
|
}
|
|
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
|
continue;
|
|
}
|
|
let included = match d.projection {
|
|
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
|
RelationBody::StateTransition { transition_kind, .. }
|
|
if *transition_kind != TransitionKind::Internal
|
|
&& state_machine_contains(p, machine, r.source())
|
|
&& state_machine_contains(p, machine, r.target())),
|
|
ProjectionKind::Allocation => matches!(
|
|
r.kind(),
|
|
RelationKind::Interaction
|
|
| RelationKind::FunctionalExchange
|
|
| RelationKind::BoundaryExchange
|
|
| RelationKind::CommunicationMean
|
|
),
|
|
ProjectionKind::FunctionalDataflow => matches!(
|
|
r.kind(),
|
|
RelationKind::Interaction | RelationKind::FunctionalExchange
|
|
),
|
|
ProjectionKind::Capabilities => matches!(
|
|
r.kind(),
|
|
RelationKind::CapabilityInclude
|
|
| RelationKind::CapabilityExtend
|
|
| RelationKind::CapabilityGeneralization
|
|
| RelationKind::MissionCapability
|
|
| RelationKind::MissionInvolvement
|
|
| RelationKind::CapabilityInvolvement
|
|
| RelationKind::CommunicationMean
|
|
),
|
|
_ => true,
|
|
};
|
|
if !included {
|
|
continue;
|
|
}
|
|
let ends = (anchor(r.source()), anchor(r.target()));
|
|
let self_loop = (d.projection.state_machine().is_some()
|
|
|| r.kind() == RelationKind::DataAssociation)
|
|
&& r.source() == r.target()
|
|
&& nodes
|
|
.iter()
|
|
.any(|n| Some(n.id) == ends.0 && n.element == r.source());
|
|
if let Some(availability) = availability.as_deref_mut() {
|
|
use crate::view_options::EdgeAvailability as A;
|
|
availability.insert(
|
|
r.id,
|
|
match ends {
|
|
(Some(a), Some(b)) if a != b || self_loop => A::Visible,
|
|
(Some(_), Some(_)) => A::FoldedInternal,
|
|
(a, b) => A::EndpointNotShown {
|
|
source: a.is_none(),
|
|
target: b.is_none(),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
if let (Some(source), Some(target)) = ends
|
|
&& (source != target || self_loop)
|
|
{
|
|
let (source_port, target_port) = match &r.body {
|
|
RelationBody::FunctionalExchange {
|
|
source_port,
|
|
target_port,
|
|
..
|
|
} => (Some(*source_port), Some(*target_port)),
|
|
RelationBody::BoundaryExchange { source, target, .. }
|
|
| RelationBody::PortAllocation { source, target } => (Some(*source), Some(*target)),
|
|
_ => (None, None),
|
|
};
|
|
edges.push(ProjectedEdge {
|
|
id: r.id,
|
|
source,
|
|
target,
|
|
trace: false,
|
|
oriented: !matches!(
|
|
&r.body,
|
|
RelationBody::BoundaryExchange {
|
|
oriented: false,
|
|
..
|
|
} | RelationBody::CommunicationMean {
|
|
oriented: false,
|
|
..
|
|
} | RelationBody::DataAssociation { .. }
|
|
),
|
|
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
|
target_port: target_port.filter(|id| visible_ports.contains(id)),
|
|
});
|
|
}
|
|
}
|
|
if d.projection == ProjectionKind::Traceability {
|
|
for t in p.traces.values() {
|
|
let ends = (anchor(t.oa), anchor(t.sa));
|
|
if let Some(availability) = availability.as_deref_mut() {
|
|
use crate::view_options::EdgeAvailability as A;
|
|
availability.insert(
|
|
t.id,
|
|
match ends {
|
|
(Some(a), Some(b)) if a != b => A::Visible,
|
|
(Some(_), Some(_)) => A::FoldedInternal,
|
|
(a, b) => A::EndpointNotShown {
|
|
source: a.is_none(),
|
|
target: b.is_none(),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
if let (Some(source), Some(target)) = ends
|
|
&& source != target
|
|
{
|
|
edges.push(ProjectedEdge {
|
|
id: t.id,
|
|
source,
|
|
target,
|
|
trace: true,
|
|
oriented: true,
|
|
source_port: None,
|
|
target_port: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
// Dedicated state views contain state symbols, not semantic component ports.
|
|
// Port layout would re-enclose already snapped boundary vertices.
|
|
let ports = if d.projection.state_machine().is_some() {
|
|
Vec::new()
|
|
} else {
|
|
port_layout::layout(p, d, &mut nodes, &edges)
|
|
};
|
|
// Port geometry uses every eligible edge. Presentation filters must never
|
|
// move visible ports when another line is hidden or a category is selected.
|
|
if let Some(index) = index {
|
|
let filter = if d.projection == ProjectionKind::Traceability {
|
|
index.prepare_cross_layer(d.layer, &d.view_options)
|
|
} else {
|
|
index.prepare(d.layer, &d.view_options)
|
|
};
|
|
edges.retain(|edge| filter.includes(edge.id));
|
|
}
|
|
ProjectedView {
|
|
nodes,
|
|
edges,
|
|
ports,
|
|
}
|
|
}
|
|
|
|
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
|
|
enclose_with_headers(p, nodes, false);
|
|
}
|
|
fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) {
|
|
for index in (0..nodes.len()).rev() {
|
|
let child = nodes[index].clone();
|
|
if state_view
|
|
&& p.elements
|
|
.get(&child.element)
|
|
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
|
|
{
|
|
continue;
|
|
}
|
|
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 header = if state_view {
|
|
state_header_height(p, nodes[parent].element)
|
|
} else {
|
|
64.
|
|
};
|
|
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 - header);
|
|
p.size.width = right - p.position.x;
|
|
p.size.height = bottom - p.position.y;
|
|
}
|
|
}
|
|
}
|