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, 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, pub target_port: Option, } #[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 { 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, pub edges: Vec, pub ports: Vec, } 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> { 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 { 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, ) -> Vec { 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 { 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>, 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; } } }