Preserve nested architecture during authoring and distinguish allocation summaries
This commit is contained in:
+111
-17
@@ -873,7 +873,17 @@ impl App {
|
||||
.relations
|
||||
.get(&selected)
|
||||
.map(|r| vec![r.source(), r.target()])
|
||||
.unwrap_or_else(|| vec![selected]);
|
||||
.unwrap_or_else(|| vec![selected])
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
self.project()
|
||||
.elements
|
||||
.get(&id)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.and_then(|e| e.parent)
|
||||
.unwrap_or(id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let nodes = view
|
||||
.nodes
|
||||
.values()
|
||||
@@ -1076,11 +1086,15 @@ impl App {
|
||||
.cloned()
|
||||
{
|
||||
n.collapsed = !n.collapsed;
|
||||
self.execute(Command::EditNode {
|
||||
let selected = self.selected;
|
||||
if self.execute(Command::EditNode {
|
||||
diagram,
|
||||
original: n.id,
|
||||
node: n,
|
||||
});
|
||||
}) {
|
||||
self.selected = selected;
|
||||
self.refresh_draft();
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::MoveNode(node, position) => {
|
||||
@@ -1421,28 +1435,35 @@ impl App {
|
||||
let id = e.id;
|
||||
let mut commands = vec![Command::CreateElement(e)];
|
||||
let mut diagram_target = self.active_diagram;
|
||||
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
|
||||
Some(ProjectionKind::Allocation)
|
||||
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
|
||||
Some(ProjectionKind::Capabilities)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self.page == Page::Diagram
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| !d.projection.includes(self.create_kind))
|
||||
&& ProjectionKind::Capabilities.includes(self.create_kind)
|
||||
&& let Some(preferred) = preferred
|
||||
{
|
||||
diagram_target = self
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == self.layer && d.projection == ProjectionKind::Capabilities)
|
||||
.find(|d| d.layer == self.layer && d.projection == preferred)
|
||||
.map(|d| d.id);
|
||||
if diagram_target.is_none() {
|
||||
let view = Id::new_v4();
|
||||
commands.push(Command::CreateDiagram(Diagram {
|
||||
id: view,
|
||||
alias: self.project().fresh_alias("capabilities", "view", view),
|
||||
name: format!("{} missions and capabilities", self.layer),
|
||||
alias: self.project().fresh_alias("engineering_view", "view", view),
|
||||
name: format!("{} {preferred}", self.layer),
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
projection: preferred,
|
||||
nodes: Default::default(),
|
||||
}));
|
||||
diagram_target = Some(view);
|
||||
@@ -1486,25 +1507,98 @@ impl App {
|
||||
_ => unreachable!(),
|
||||
},
|
||||
);
|
||||
let count = p.diagrams[&diagram].nodes.len();
|
||||
let node = p.place(
|
||||
diagram,
|
||||
id,
|
||||
Position {
|
||||
x: 60. + (count % 3) as f32 * 280.,
|
||||
y: 60. + (count / 3) as f32 * 150.,
|
||||
},
|
||||
);
|
||||
let owner = if allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
// Insertion measures an expanded owner path and commits that reveal
|
||||
// with the new child, so folded contents cannot overlap the new row.
|
||||
if let Some(owner) = owner {
|
||||
let mut expanded = p.diagrams[&diagram].clone();
|
||||
for node in expanded.nodes.values_mut() {
|
||||
node.collapsed = false;
|
||||
}
|
||||
let expanded_view = oa_core::projection::project_diagram(&p, &expanded);
|
||||
let mut current = expanded_view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == owner)
|
||||
.map(|n| n.id);
|
||||
while let Some(id) = current {
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id)
|
||||
.unwrap();
|
||||
if node.collapsed {
|
||||
node.collapsed = false;
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
current = expanded_view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.and_then(|n| n.visual_parent);
|
||||
}
|
||||
}
|
||||
// Reserve the expanded size of folded child containers so inserting
|
||||
// a sibling does not overlap their contents when they are unfolded.
|
||||
let mut expanded = p.diagrams[&diagram].clone();
|
||||
for node in expanded.nodes.values_mut() {
|
||||
node.collapsed = false;
|
||||
}
|
||||
let view = oa_core::projection::project_diagram(&p, &expanded);
|
||||
let position =
|
||||
if let Some(container) = view.nodes.iter().find(|n| Some(n.element) == owner) {
|
||||
let y = view
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent == Some(container.id))
|
||||
.map(|n| n.position.y + n.size.height + 16.)
|
||||
.fold(container.position.y + 64., f32::max);
|
||||
Position {
|
||||
x: container.position.x + 24.,
|
||||
y,
|
||||
}
|
||||
} else {
|
||||
Position {
|
||||
x: view
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| n.position.x + n.size.width + 240.)
|
||||
.fold(60., f32::max),
|
||||
y: 60.,
|
||||
}
|
||||
};
|
||||
let node = p.place(diagram, id, position);
|
||||
commands.push(Command::CreateNode {
|
||||
diagram,
|
||||
node: p.diagrams[&diagram].nodes[&node].clone(),
|
||||
});
|
||||
}
|
||||
if self.page == Page::Diagram
|
||||
&& let Some(diagram) = diagram_target
|
||||
&& let Ok(candidate) = execute_batch(self.project(), &commands)
|
||||
{
|
||||
commands.extend(crate::diagram::insertion_adjustments(
|
||||
&candidate, diagram, id,
|
||||
));
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
self.active_diagram = diagram_target;
|
||||
self.selected = id;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
if self.page == Page::Diagram {
|
||||
self.fit();
|
||||
}
|
||||
self.status = "Element created · enter its engineering name and properties".into();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,101 @@ pub struct State {
|
||||
preview: Option<Position>,
|
||||
pan_start: Option<Point>,
|
||||
}
|
||||
/// Make room along the inserted node's containment path, moving only colliding
|
||||
/// later siblings and their descendants. Existing semantic ownership is unchanged.
|
||||
pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec<Command> {
|
||||
let mut diagram = p.diagrams[&diagram_id].clone();
|
||||
// Reflow folded subtrees at their expanded size, without changing the
|
||||
// user's fold state. Their hidden placements move with their container.
|
||||
for node in diagram.nodes.values_mut() {
|
||||
node.collapsed = false;
|
||||
}
|
||||
let view = oa_core::projection::project_diagram(p, &diagram);
|
||||
let Some(inserted) = view.nodes.iter().find(|n| n.element == element) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut groups = Vec::new();
|
||||
let mut affected = inserted.id;
|
||||
let mut parent = inserted.visual_parent;
|
||||
loop {
|
||||
groups.push((parent, affected));
|
||||
let Some(id) = parent else {
|
||||
break;
|
||||
};
|
||||
affected = id;
|
||||
parent = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == id)
|
||||
.and_then(|n| n.visual_parent);
|
||||
}
|
||||
for (group, affected) in groups {
|
||||
let view = oa_core::projection::project_diagram(p, &diagram);
|
||||
let mut siblings: Vec<_> = view
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent == group)
|
||||
.collect();
|
||||
siblings.sort_by(|a, b| {
|
||||
a.position
|
||||
.y
|
||||
.total_cmp(&b.position.y)
|
||||
.then(a.position.x.total_cmp(&b.position.x))
|
||||
.then(a.id.cmp(&b.id))
|
||||
});
|
||||
let ids: Vec<_> = siblings.iter().map(|n| n.id).collect();
|
||||
let mut settled: Vec<(Rectangle, bool)> = Vec::new();
|
||||
for id in ids {
|
||||
let current = oa_core::projection::project_diagram(p, &diagram);
|
||||
let node = current.nodes.iter().find(|n| n.id == id).unwrap();
|
||||
let mut rect = Rectangle::new(
|
||||
Point::new(node.position.x, node.position.y),
|
||||
Size::new(node.size.width, node.size.height),
|
||||
);
|
||||
let original_y = rect.y;
|
||||
let displaced = id == affected
|
||||
|| settled
|
||||
.iter()
|
||||
.any(|(other, moved)| *moved && rect.intersects(other));
|
||||
if displaced {
|
||||
loop {
|
||||
let next_y = settled
|
||||
.iter()
|
||||
.filter(|(other, _)| rect.intersects(other))
|
||||
.map(|(other, _)| other.y + other.height + 24.)
|
||||
.fold(rect.y, f32::max);
|
||||
if next_y == rect.y {
|
||||
break;
|
||||
}
|
||||
rect.y = next_y;
|
||||
}
|
||||
}
|
||||
let dy = rect.y - original_y;
|
||||
if dy > 0. {
|
||||
let mut moved = oa_core::projection::descendant_nodes(p, diagram_id, id);
|
||||
moved.push(id);
|
||||
for moved in moved {
|
||||
diagram.nodes.get_mut(&moved).unwrap().position.y += dy;
|
||||
}
|
||||
}
|
||||
settled.push((rect, displaced));
|
||||
}
|
||||
}
|
||||
diagram
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.collapsed = p.diagrams[&diagram_id].nodes[&node.id].collapsed;
|
||||
node
|
||||
})
|
||||
.filter(|n| p.diagrams[&diagram_id].nodes[&n.id] != *n)
|
||||
.map(|node| Command::EditNode {
|
||||
diagram: diagram_id,
|
||||
original: node.id,
|
||||
node,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
impl DiagramCanvas<'_> {
|
||||
fn world(&self, p: Point) -> Point {
|
||||
Point::new(
|
||||
@@ -68,30 +163,21 @@ impl DiagramCanvas<'_> {
|
||||
})
|
||||
}
|
||||
fn preview_view(&self, state: &State) -> ProjectedView {
|
||||
let mut view = project_view(self.project, self.diagram);
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
let dx = preview.x - original.x;
|
||||
let dy = preview.y - original.y;
|
||||
let mut moved = BTreeSet::from([id]);
|
||||
for n in &view.nodes {
|
||||
if n.visual_parent.is_some_and(|p| moved.contains(&p)) {
|
||||
moved.insert(n.id);
|
||||
}
|
||||
}
|
||||
for n in &mut view.nodes {
|
||||
if moved.contains(&n.id) {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
let mut moved = oa_core::projection::descendant_nodes(self.project, self.diagram, id);
|
||||
moved.push(id);
|
||||
for id in moved {
|
||||
if let Some(n) = diagram.nodes.get_mut(&id) {
|
||||
n.position.x += dx;
|
||||
n.position.y += dy;
|
||||
}
|
||||
}
|
||||
for port in &mut view.ports {
|
||||
if moved.contains(&port.owner) {
|
||||
port.position.x += dx;
|
||||
port.position.y += dy;
|
||||
}
|
||||
}
|
||||
return oa_core::projection::project_diagram(self.project, &diagram);
|
||||
}
|
||||
view
|
||||
project_view(self.project, self.diagram)
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
@@ -250,20 +336,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
} else {
|
||||
Color::from_rgb8(144, 158, 173)
|
||||
};
|
||||
let capability_label =
|
||||
self.project
|
||||
.relations
|
||||
.get(&edge.id)
|
||||
.and_then(|r| match r.kind() {
|
||||
RelationKind::CapabilityInclude => Some("«include»"),
|
||||
RelationKind::CapabilityExtend => Some("«extend»"),
|
||||
RelationKind::CapabilityGeneralization => Some("specializes"),
|
||||
RelationKind::MissionCapability => Some("exploits"),
|
||||
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
|
||||
Some("participates")
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let capability_label = relationship_stereotype(self.project, edge.id);
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
@@ -418,23 +491,23 @@ fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, conta
|
||||
a: 1.,
|
||||
};
|
||||
f.fill(&path, if container { tint } else { Color::WHITE });
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if e.id == selected { color } else { style::LINE })
|
||||
.with_width(if e.id == selected { 2.6 } else { 1.2 }),
|
||||
);
|
||||
let mut stroke = Stroke::default()
|
||||
.with_color(if e.id == selected { color } else { style::LINE })
|
||||
.with_width(if e.id == selected { 2.6 } else { 1.2 });
|
||||
if n.allocation_summary {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
segments: &[6., 4.],
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
f.stroke(&path, stroke);
|
||||
f.fill_rectangle(
|
||||
Point::new(origin.x + 14., origin.y + 15.),
|
||||
Size::new(4., 12.),
|
||||
color,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: format!(
|
||||
"{}{}",
|
||||
e.kind().label().to_uppercase(),
|
||||
if n.collapsed { " +" } else { "" }
|
||||
),
|
||||
content: node_header(e.kind(), n),
|
||||
position: Point::new(origin.x + 25., origin.y + 14.),
|
||||
color,
|
||||
size: 11.into(),
|
||||
@@ -447,19 +520,32 @@ fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, conta
|
||||
position: Point::new(origin.x + 14., origin.y + 35.),
|
||||
color: style::INK,
|
||||
size: 16.into(),
|
||||
font: iced::Font {
|
||||
style: if n.allocation_summary {
|
||||
iced::font::Style::Italic
|
||||
} else {
|
||||
iced::font::Style::Normal
|
||||
},
|
||||
..iced::Font::DEFAULT
|
||||
},
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
if !container && size.height >= 86. {
|
||||
f.fill_text(Text {
|
||||
content: format!("@{}", short(&e.alias, max_chars + 6)),
|
||||
position: Point::new(origin.x + 14., origin.y + 66.),
|
||||
color: style::MUTED,
|
||||
size: 10.5.into(),
|
||||
max_width: f32::INFINITY,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
fn node_header(kind: Kind, node: &ProjectedNode) -> String {
|
||||
let header = if node.allocation_summary {
|
||||
format!(
|
||||
"{} · SUMMARY",
|
||||
if kind == Kind::Function {
|
||||
"FUNCTION"
|
||||
} else {
|
||||
"ACTIVITY"
|
||||
}
|
||||
)
|
||||
} else {
|
||||
kind.label().to_uppercase()
|
||||
};
|
||||
format!("{header}{}", if node.collapsed { " +" } else { "" })
|
||||
}
|
||||
fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet<Id> {
|
||||
p.elements
|
||||
@@ -489,6 +575,18 @@ fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point,
|
||||
port_point(edge.target_port).unwrap_or(end),
|
||||
))
|
||||
}
|
||||
fn relationship_stereotype(p: &Project, id: Id) -> Option<&'static str> {
|
||||
match p.relations.get(&id)?.kind() {
|
||||
RelationKind::CapabilityInclude => Some("«include»"),
|
||||
RelationKind::CapabilityExtend => Some("«extend»"),
|
||||
RelationKind::CapabilityGeneralization => Some("specializes"),
|
||||
RelationKind::MissionCapability => Some("exploits"),
|
||||
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
|
||||
Some("participates")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn endpoints(a: &ProjectedNode, b: &ProjectedNode) -> (Point, Point) {
|
||||
let ax = a.position.x + a.size.width / 2.;
|
||||
let ay = a.position.y + a.size.height / 2.;
|
||||
@@ -594,8 +692,13 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
let color = hex(style::kind_color(e.kind()));
|
||||
let x = n.position.x;
|
||||
let y = n.position.y;
|
||||
let (border, name_style) = if n.allocation_summary {
|
||||
(" stroke-dasharray=\"6 4\"", " font-style=\"italic\"")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\">{}</text></g>",
|
||||
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"{border}/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\"{name_style}>{}</text></g>",
|
||||
n.size.width,
|
||||
n.size.height,
|
||||
if containers.contains(&n.id) {
|
||||
@@ -605,7 +708,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
},
|
||||
x + 14.,
|
||||
y + 25.,
|
||||
escape(e.kind().label()),
|
||||
escape(&node_header(e.kind(), n)),
|
||||
x + 14.,
|
||||
y + 51.,
|
||||
escape(&short(
|
||||
@@ -622,7 +725,27 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
for edge in &view.edges {
|
||||
if let Some((a, b)) = edge_endpoints(&view, edge) {
|
||||
let mid = (a.x + b.x) / 2.;
|
||||
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\" marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
|
||||
let stereotype = relationship_stereotype(p, edge.id);
|
||||
let dashed = if matches!(stereotype, Some("«include»" | "«extend»")) {
|
||||
" stroke-dasharray=\"6 4\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed} marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
|
||||
if stereotype.is_some()
|
||||
|| matches!(
|
||||
p.relations.get(&edge.id).map(|r| r.kind()),
|
||||
Some(
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
)
|
||||
)
|
||||
{
|
||||
let label = stereotype.unwrap_or_else(|| p.named(edge.id));
|
||||
let center = curve_point(a, b, 0.5);
|
||||
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"224\" height=\"24\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",center.x-112.,center.y-30.,center.x,center.y-8.,escape(&short(label,32))));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in &view.nodes {
|
||||
@@ -632,6 +755,9 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
}
|
||||
for port in &view.ports {
|
||||
svg.push_str(&format!("<g><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"/><path d=\"M {} {} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/></g>",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y));
|
||||
if port.direction == PortDirection::InOut {
|
||||
svg.push_str(&format!("<path d=\"M {} {} l -3 -3 m 3 3 l -3 3\" transform=\"rotate(180 {} {})\" fill=\"none\" stroke=\"#147d7b\"/>",port.position.x+4.,port.position.y,port.position.x,port.position.y));
|
||||
}
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
@@ -641,6 +767,136 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn svg_distinguishes_inferred_work_allocation_from_direct_allocation() {
|
||||
for (layer, performer_kind, work_kind, header) in [
|
||||
(
|
||||
Layer::Oa,
|
||||
Kind::Entity,
|
||||
Kind::Activity,
|
||||
"ACTIVITY · SUMMARY",
|
||||
),
|
||||
(
|
||||
Layer::Sa,
|
||||
Kind::System,
|
||||
Kind::Function,
|
||||
"FUNCTION · SUMMARY",
|
||||
),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(performer_kind, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let parent = p
|
||||
.add_element(work_kind, layer, "Grouped work", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(work_kind, layer, "Allocated leaf", Some(parent))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "Performs", owner, child);
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
for id in [owner, parent, child] {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let svg = export_svg(&p, diagram);
|
||||
let group = svg
|
||||
.split("<g>")
|
||||
.find(|g| g.contains(">Grouped work</text>"))
|
||||
.unwrap();
|
||||
assert!(group.contains(&format!(">{header}</text>")));
|
||||
assert!(group.contains("stroke-dasharray=\"6 4\""));
|
||||
assert!(group.contains("font-style=\"italic\""));
|
||||
let leaf = svg
|
||||
.split("<g>")
|
||||
.find(|g| g.contains(">Allocated leaf</text>"))
|
||||
.unwrap();
|
||||
assert!(!leaf.contains("SUMMARY"));
|
||||
assert!(!leaf.contains("stroke-dasharray"));
|
||||
assert!(!leaf.contains("font-style=\"italic\""));
|
||||
assert!(
|
||||
!p.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn dragging_a_child_previews_the_same_enclosure_and_ports_as_commit() {
|
||||
let p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let child = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.visual_parent.is_some())
|
||||
.unwrap()
|
||||
.id;
|
||||
let original = p.diagrams[&diagram].nodes[&child].position;
|
||||
let target = Position {
|
||||
x: original.x - 400.,
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
};
|
||||
let state = State {
|
||||
drag: Some((child, Point::ORIGIN, original)),
|
||||
preview: Some(target),
|
||||
..State::default()
|
||||
};
|
||||
let preview = canvas.preview_view(&state);
|
||||
let committed = execute_batch(
|
||||
&p,
|
||||
&[Command::MoveNode {
|
||||
diagram,
|
||||
node: child,
|
||||
position: target,
|
||||
with_descendants: true,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(preview, project_view(&committed, diagram));
|
||||
}
|
||||
#[test]
|
||||
fn svg_preserves_visible_exchange_labels_and_capability_stereotypes() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let svg = export_svg(&p, diagram);
|
||||
for edge in &view.edges {
|
||||
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
|
||||
}
|
||||
let a = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Primary", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Included", None)
|
||||
.unwrap();
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Capabilities;
|
||||
p.place(d, a, Position { x: 0., y: 0. });
|
||||
p.place(d, b, Position { x: 400., y: 0. });
|
||||
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
|
||||
let svg = export_svg(&p, d);
|
||||
assert!(svg.contains("«include»</text>"));
|
||||
assert!(svg.contains("stroke-dasharray=\"6 4\""));
|
||||
}
|
||||
#[test]
|
||||
fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() {
|
||||
let p = Project::demo();
|
||||
let diagram = p
|
||||
|
||||
@@ -173,7 +173,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Create an operational context",
|
||||
&[
|
||||
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
|
||||
"On Architecture, choose Create architecture view. The initial Allocation projection shows work inside its performer.",
|
||||
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
|
||||
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
|
||||
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
@@ -239,7 +239,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
|
||||
"Create Activity in OA or Function in SA. Select existing work and use + In selected to decompose it into smaller work units.",
|
||||
"To create work for a participant, select the participant, choose Activity or Function, then + Allocated. To allocate existing work, use + Relationship with Allocation from the participant to the leaf work.",
|
||||
"Open the Allocation projection to review responsibilities. Open Decomposition to review the activity/function hierarchy independently.",
|
||||
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -274,7 +274,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose and edit the relationship you mean",
|
||||
&[
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective and projection.",
|
||||
"Choose Decomposition to nest semantic children, or Allocation to nest work under its performer. Overview keeps a broader view; OA → SA displays counterpart traces.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
||||
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
|
||||
"Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.",
|
||||
@@ -284,6 +284,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Keep diagrams readable",
|
||||
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
|
||||
),
|
||||
explain(
|
||||
"Read allocation summaries and ports",
|
||||
"A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.",
|
||||
),
|
||||
explain(
|
||||
"What nesting does not imply",
|
||||
"Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.",
|
||||
|
||||
@@ -936,3 +936,299 @@ fn behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = oa_core::projection::project_view(&p, diagram);
|
||||
let function = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| p.elements[&n.element].kind() == Kind::Function && n.visual_parent.is_some())
|
||||
.unwrap();
|
||||
let ancestor = function.visual_parent.unwrap();
|
||||
let port = p
|
||||
.children(function.element)
|
||||
.find(|e| e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&ancestor)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, port));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&ancestor].collapsed);
|
||||
assert!(
|
||||
oa_core::projection::project_view(app.project(), diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_from_capabilities_routes_work_to_architecture_and_folded_allocation_inserts_below_existing_work()
|
||||
{
|
||||
let p = Project::demo();
|
||||
let system = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::System)
|
||||
.unwrap()
|
||||
.id;
|
||||
let capabilities = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Capabilities)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Layer(Layer::Sa));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(crate::app::Choice {
|
||||
id: capabilities,
|
||||
label: String::new(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Message::CreateKind(Kind::Function));
|
||||
send(&mut app, Message::Add(false));
|
||||
let diagram = app.active_diagram.unwrap();
|
||||
assert_eq!(
|
||||
app.project().diagrams[&diagram].projection,
|
||||
ProjectionKind::Allocation
|
||||
);
|
||||
assert!(
|
||||
app.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == app.selected)
|
||||
);
|
||||
send(&mut app, Message::Select(system));
|
||||
let owner = app.selected_node.unwrap();
|
||||
send(&mut app, Message::ToggleCollapse);
|
||||
assert!(app.project().diagrams[&diagram].nodes[&owner].collapsed);
|
||||
assert_eq!(
|
||||
app.selected, system,
|
||||
"Folding must retain semantic selection"
|
||||
);
|
||||
let before = app.project().clone();
|
||||
send(&mut app, Message::Add(true));
|
||||
let created = app.selected;
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let child = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == created)
|
||||
.unwrap_or_else(|| panic!("Created work is not visible: {}", app.status));
|
||||
assert_eq!(child.visual_parent, Some(owner));
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&owner].collapsed);
|
||||
assert!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent == Some(owner) && n.id != child.id)
|
||||
.all(|n| n.position.y + n.size.height <= child.position.y)
|
||||
);
|
||||
let parent = view.nodes.iter().find(|n| n.id == owner).unwrap();
|
||||
assert!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.filter(|n| n.visual_parent.is_none() && n.id != owner)
|
||||
.all(|n| n.position.x + n.size.width <= parent.position.x
|
||||
|| parent.position.x + parent.size.width <= n.position.x
|
||||
|| n.position.y + n.size.height <= parent.position.y
|
||||
|| parent.position.y + parent.size.height <= n.position.y)
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state() {
|
||||
use oa_core::projection::{project_diagram, project_view};
|
||||
for folded in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let root = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Root", None)
|
||||
.unwrap();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "A", Some(root))
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "B", Some(root))
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Other root", None)
|
||||
.unwrap();
|
||||
let a_work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A work", None)
|
||||
.unwrap();
|
||||
let b_work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B work", None)
|
||||
.unwrap();
|
||||
let b_port = p
|
||||
.add_element(Kind::Port, Layer::Oa, "B boundary", Some(b))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "A performs", a, a_work);
|
||||
p.relate(RelationKind::Allocation, "B performs", b, b_work);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
for id in [root, a, b, other, a_work, b_work] {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let mut p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let view = project_view(&p, diagram);
|
||||
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
|
||||
let a_node = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap();
|
||||
// Layout's UUID order is intentionally irrelevant to this scenario:
|
||||
// explicitly put A before B, as a user can arrange these participants.
|
||||
p = execute_batch(
|
||||
&p,
|
||||
&[
|
||||
Command::MoveNode {
|
||||
diagram,
|
||||
node: a_node.id,
|
||||
position: Position {
|
||||
x: root_node.position.x + 24.,
|
||||
y: root_node.position.y + 64.,
|
||||
},
|
||||
with_descendants: true,
|
||||
},
|
||||
Command::MoveNode {
|
||||
diagram,
|
||||
node: b_node.id,
|
||||
position: Position {
|
||||
x: root_node.position.x + 24.,
|
||||
y: root_node.position.y + 64. + a_node.size.height + 16.,
|
||||
},
|
||||
with_descendants: true,
|
||||
},
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let view = project_view(&p, diagram);
|
||||
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
|
||||
let other_node = view.nodes.iter().find(|n| n.element == other).unwrap().id;
|
||||
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap().id;
|
||||
let b_work_node = view.nodes.iter().find(|n| n.element == b_work).unwrap().id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&other_node)
|
||||
.unwrap()
|
||||
.position = Position {
|
||||
x: root_node.position.x,
|
||||
y: root_node.position.y + root_node.size.height + 24.,
|
||||
};
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&b_node)
|
||||
.unwrap()
|
||||
.collapsed = folded;
|
||||
// Deliberately overlapping unrelated roots must not be rearranged.
|
||||
let mut unrelated = Vec::new();
|
||||
for name in ["Unrelated one", "Unrelated two"] {
|
||||
let id = p.add_element(Kind::Entity, Layer::Oa, name, None).unwrap();
|
||||
unrelated.push(p.place(diagram, id, Position { x: 2000., y: 1000. }));
|
||||
}
|
||||
let before = p.clone();
|
||||
let mut expanded = p.diagrams[&diagram].clone();
|
||||
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
||||
let before_view = project_diagram(&p, &expanded);
|
||||
let before_port = before_view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.element == b_port)
|
||||
.unwrap()
|
||||
.position;
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::Select(a));
|
||||
send(&mut app, Message::CreateKind(Kind::Activity));
|
||||
send(&mut app, Message::Add(true));
|
||||
let created = app.selected;
|
||||
assert_eq!(
|
||||
app.project().elements[&created].kind(),
|
||||
Kind::Activity,
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
let after = &app.project().diagrams[&diagram];
|
||||
assert_eq!(after.nodes[&b_node].collapsed, folded);
|
||||
let dy =
|
||||
after.nodes[&b_node].position.y - before.diagrams[&diagram].nodes[&b_node].position.y;
|
||||
assert!(dy > 0., "B must move below the enlarged A");
|
||||
assert_eq!(
|
||||
after.nodes[&b_work_node].position.y,
|
||||
before.diagrams[&diagram].nodes[&b_work_node].position.y + dy
|
||||
);
|
||||
assert!(
|
||||
after.nodes[&other_node].position.y
|
||||
> before.diagrams[&diagram].nodes[&other_node].position.y
|
||||
);
|
||||
for id in unrelated {
|
||||
assert_eq!(after.nodes[&id], before.diagrams[&diagram].nodes[&id]);
|
||||
}
|
||||
let mut expanded = after.clone();
|
||||
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
||||
let view = project_diagram(app.project(), &expanded);
|
||||
let after_port = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.element == b_port)
|
||||
.unwrap()
|
||||
.position;
|
||||
assert_eq!(
|
||||
after_port,
|
||||
Position {
|
||||
x: before_port.x,
|
||||
y: before_port.y + dy
|
||||
}
|
||||
);
|
||||
for (left, right) in [(a, b), (root, other), (a_work, created)] {
|
||||
let left = view.nodes.iter().find(|n| n.element == left).unwrap();
|
||||
let right = view.nodes.iter().find(|n| n.element == right).unwrap();
|
||||
assert!(left.position.y + left.size.height <= right.position.y);
|
||||
}
|
||||
assert!(crate::diagram::insertion_adjustments(app.project(), diagram, created).is_empty());
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&before,
|
||||
"Creation and all reflow must undo together"
|
||||
);
|
||||
|
||||
if folded {
|
||||
send(&mut app, Message::Select(root));
|
||||
send(&mut app, Message::CreateKind(Kind::Entity));
|
||||
send(&mut app, Message::Add(true));
|
||||
let mut expanded = app.project().diagrams[&diagram].clone();
|
||||
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
|
||||
let view = project_diagram(app.project(), &expanded);
|
||||
let b = view.nodes.iter().find(|n| n.id == b_node).unwrap();
|
||||
let created = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == app.selected)
|
||||
.unwrap();
|
||||
assert!(b.position.y + b.size.height <= created.position.y);
|
||||
assert!(app.project().diagrams[&diagram].nodes[&b_node].collapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,6 +599,23 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if !p
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == e.id)
|
||||
&& let Some(performer) = oa_core::validation::allocation_summary(p, e.id)
|
||||
{
|
||||
body = body.push(label(
|
||||
"Allocation summary: all leaf activities or functions belong to the same performer. The parent has no direct allocation.",
|
||||
));
|
||||
body = body.push(
|
||||
action(
|
||||
&format!("Leaf performer: {}", short(p.named(performer), 24)),
|
||||
Message::Select(performer),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if e.kind() == Kind::Port {
|
||||
body = body.push(label(
|
||||
"Shown automatically on the boundary of its visible owner.",
|
||||
|
||||
Reference in New Issue
Block a user