Establish OA/SA prototype baseline and first-release work

This commit is contained in:
2026-09-05 17:04:16 +03:00
commit 93ba83d321
109 changed files with 56512 additions and 0 deletions
+702
View File
@@ -0,0 +1,702 @@
use crate::{app::Message, style, views::short};
use iced::{
Color, Point, Rectangle, Renderer, Size, Theme, Vector, mouse,
widget::{
Action,
canvas::{self, Frame, Path, Stroke, Text},
},
};
use oa_core::{
projection::{ProjectedEdge, ProjectedNode, ProjectedView, project_view},
*,
};
use std::collections::BTreeSet;
pub struct DiagramCanvas<'a> {
pub project: &'a Project,
pub diagram: Id,
pub selected: Id,
pub zoom: f32,
pub pan: Vector,
}
#[derive(Default)]
pub struct State {
drag: Option<(Id, Point, Position)>,
preview: Option<Position>,
pan_start: Option<Point>,
}
impl DiagramCanvas<'_> {
fn world(&self, p: Point) -> Point {
Point::new(
(p.x - self.pan.x) / self.zoom,
(p.y - self.pan.y) / self.zoom,
)
}
fn hit(&self, view: &ProjectedView, p: Point) -> Option<Id> {
let p = self.world(p);
view.nodes
.iter()
.rev()
.find(|n| {
Rectangle::new(
Point::new(n.position.x, n.position.y),
Size::new(n.size.width, n.size.height),
)
.contains(p)
&& (!view
.nodes
.iter()
.any(|child| child.visual_parent == Some(n.id))
|| p.y < n.position.y + 62.)
})
.map(|n| n.id)
}
fn hit_edge(&self, view: &ProjectedView, p: Point) -> Option<Id> {
let p = self.world(p);
view.edges.iter().rev().find_map(|edge| {
let (start, end) = edge_endpoints(view, edge)?;
let tolerance = 7. / self.zoom;
let mut previous = start;
for i in 1..=24 {
let next = curve_point(start, end, i as f32 / 24.);
if distance_to_segment(p, previous, next) <= tolerance {
return Some(edge.id);
}
previous = next;
}
None
})
}
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) {
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;
}
}
}
view
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
type State = State;
fn update(
&self,
state: &mut State,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let local = cursor
.position()
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
if cursor.is_over(bounds) =>
{
let p = local?;
let view = project_view(self.project, self.diagram);
let world = self.world(p);
if let Some(port) = view.ports.iter().rev().find(|port| {
let radius = (10. / self.zoom).max(10.);
(world.x - port.position.x).abs() <= radius
&& (world.y - port.position.y).abs() <= radius
}) {
return Some(Action::publish(Message::Select(port.element)).and_capture());
}
if let Some(id) = self.hit(&view, p) {
let position = self
.project
.diagrams
.get(&self.diagram)?
.nodes
.get(&id)?
.position;
state.drag = Some((id, p, position));
state.preview = Some(position);
Some(Action::publish(Message::SelectNode(id)).and_capture())
} else if let Some(id) = self.hit_edge(&view, p) {
Some(Action::publish(Message::Select(id)).and_capture())
} else {
state.pan_start = Some(p);
Some(Action::capture())
}
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
let p = local?;
if let Some((_, start, original)) = state.drag {
let delta = p - start;
state.preview = Some(if delta.x * delta.x + delta.y * delta.y > 9. {
Position {
x: original.x + delta.x / self.zoom,
y: original.y + delta.y / self.zoom,
}
} else {
original
});
return Some(Action::request_redraw());
}
if let Some(start) = state.pan_start {
state.pan_start = Some(p);
return Some(Action::publish(Message::Pan(p - start)).and_capture());
}
None
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
state.pan_start = None;
if let Some((id, _, original)) = state.drag.take() {
let pos = state.preview.take()?;
if pos == original {
return Some(Action::capture());
}
Some(
Action::publish(Message::MoveNode(
id,
Position {
x: (pos.x / 10.).round() * 10.,
y: (pos.y / 10.).round() * 10.,
},
))
.and_capture(),
)
} else {
None
}
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
if cursor.is_over(bounds) =>
{
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
*y
}
};
if y == 0. {
None
} else {
Some(
Action::publish(Message::Zoom(if y > 0. { 1.1 } else { 1. / 1.1 }))
.and_capture(),
)
}
}
_ => None,
}
}
fn draw(
&self,
state: &State,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(
Point::ORIGIN,
bounds.size(),
Color::from_rgb8(246, 248, 251),
);
let step = (24. * self.zoom).max(14.);
let ox = self.pan.x.rem_euclid(step);
let oy = self.pan.y.rem_euclid(step);
for x in 0..(bounds.width / step) as i32 + 1 {
for y in 0..(bounds.height / step) as i32 + 1 {
f.fill(
&Path::circle(Point::new(ox + x as f32 * step, oy + y as f32 * step), 0.65),
style::LINE,
);
}
}
f.translate(self.pan);
f.scale(self.zoom);
let view = self.preview_view(state);
let containers = view
.nodes
.iter()
.filter_map(|n| n.visual_parent)
.collect::<BTreeSet<_>>();
for n in &view.nodes {
if containers.contains(&n.id) {
paint_node(&mut f, self.project, n, self.selected, true);
}
}
let highlighted = highlighted_exchanges(self.project, self.selected);
for edge in &view.edges {
let Some((start, end)) = edge_endpoints(&view, edge) else {
continue;
};
let active = edge.id == self.selected || highlighted.contains(&edge.id);
let color = if active {
style::TEAL
} else if edge.trace {
Color::from_rgb8(134, 107, 168)
} 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 mut stroke =
Stroke::default()
.with_color(color)
.with_width(if active { 3. } else { 1.4 });
if matches!(capability_label, Some("«include»" | "«extend»")) {
stroke.line_dash = canvas::LineDash {
segments: &[6., 4.],
offset: 0,
};
}
f.stroke(&curve(start, end), stroke);
let previous = curve_point(start, end, 0.96);
let direction = end - previous;
let length = (direction.x * direction.x + direction.y * direction.y)
.sqrt()
.max(0.001);
let direction = direction / length;
let tip1 = end - direction * 9. + Vector::new(-direction.y, direction.x) * 4.;
let tip2 = end - direction * 9. - Vector::new(-direction.y, direction.x) * 4.;
f.stroke(
&Path::new(|p| {
p.move_to(tip1);
p.line_to(end);
p.line_to(tip2);
}),
Stroke::default().with_color(color).with_width(1.7),
);
if edge.id == self.selected
|| capability_label.is_some()
|| matches!(
self.project.relations.get(&edge.id).map(|r| r.kind()),
Some(
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
)
)
{
let center = curve_point(start, end, 0.5);
f.fill_rectangle(
Point::new(center.x - 112., center.y - 30.),
Size::new(224., 24.),
Color::WHITE,
);
f.fill_text(Text {
content: if edge.id == self.selected || capability_label.is_none() {
short(self.project.named(edge.id), 32)
} else {
capability_label.unwrap_or_default().into()
},
position: Point::new(center.x, center.y - 8.),
color: if active { style::TEAL } else { style::MUTED },
size: 13.into(),
align_x: iced::widget::text::Alignment::Center,
align_y: iced::alignment::Vertical::Bottom,
max_width: f32::INFINITY,
..Default::default()
});
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
paint_node(&mut f, self.project, n, self.selected, false);
} else {
// Container headers stay readable when relationships traverse
// the allocation enclosure. Children remain on top.
let mut header = n.clone();
header.size.height = 62.;
paint_node(&mut f, self.project, &header, self.selected, true);
}
}
for port in &view.ports {
let color = if self.selected == port.element {
style::TEAL
} else {
style::kind_color(
self.project.elements[&port.element]
.parent
.and_then(|id| self.project.elements.get(&id))
.map(|e| e.kind())
.unwrap_or(Kind::Port),
)
};
let origin = Point::new(port.position.x - 7., port.position.y - 7.);
let path = Path::rectangle(origin, Size::new(14., 14.));
f.fill(&path, Color::WHITE);
f.stroke(
&path,
Stroke::default()
.with_color(color)
.with_width(if self.selected == port.element {
2.5
} else {
1.5
}),
);
let center = Point::new(port.position.x, port.position.y);
f.stroke(
&Path::new(|p| {
p.move_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(4., 0.));
p.move_to(center + Vector::new(1., -3.));
p.line_to(center + Vector::new(4., 0.));
p.line_to(center + Vector::new(1., 3.));
if port.direction == PortDirection::InOut {
p.move_to(center + Vector::new(-1., -3.));
p.line_to(center - Vector::new(4., 0.));
p.line_to(center + Vector::new(-1., 3.));
}
}),
Stroke::default().with_color(color),
);
if self.selected == port.element {
f.fill_text(Text {
content: short(self.project.named(port.element), 40),
position: center + Vector::new(12., 9.),
color,
size: 12.into(),
..Default::default()
});
}
}
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
state: &State,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if state.drag.is_some() || state.pan_start.is_some() {
mouse::Interaction::Grabbing
} else if cursor.is_over(bounds) {
mouse::Interaction::Grab
} else {
mouse::Interaction::default()
}
}
}
fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, container: bool) {
let Some(e) = p.elements.get(&n.element) else {
return;
};
let color = style::kind_color(e.kind());
let origin = Point::new(n.position.x, n.position.y);
let size = Size::new(n.size.width, n.size.height);
let path = Path::rounded_rectangle(origin, size, 10.into());
let tint = Color {
r: 0.94 + color.r * 0.06,
g: 0.94 + color.g * 0.06,
b: 0.94 + color.b * 0.06,
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 }),
);
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 { "" }
),
position: Point::new(origin.x + 25., origin.y + 14.),
color,
size: 11.into(),
max_width: f32::INFINITY,
..Default::default()
});
let max_chars = ((size.width - 28.) / 8.0).max(8.) as usize;
f.fill_text(Text {
content: short(&e.name, max_chars),
position: Point::new(origin.x + 14., origin.y + 35.),
color: style::INK,
size: 16.into(),
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 highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet<Id> {
p.elements
.get(&selected)
.and_then(|e| e.steps())
.into_iter()
.flatten()
.filter_map(|id| match p.elements.get(id)?.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
_ => None,
})
.collect()
}
fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point, Point)> {
let a = view.nodes.iter().find(|n| n.id == edge.source)?;
let b = view.nodes.iter().find(|n| n.id == edge.target)?;
let (start, end) = endpoints(a, b);
let port_point = |id: Option<Id>| {
id.and_then(|id| view.ports.iter().find(|p| p.element == id))
.map(|p| Point::new(p.position.x, p.position.y))
};
Some((
port_point(edge.source_port).unwrap_or(start),
port_point(edge.target_port).unwrap_or(end),
))
}
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.;
let bx = b.position.x + b.size.width / 2.;
let by = b.position.y + b.size.height / 2.;
if (bx - ax).abs() > (by - ay).abs() {
if bx >= ax {
(
Point::new(a.position.x + a.size.width, ay),
Point::new(b.position.x, by),
)
} else {
(
Point::new(a.position.x, ay),
Point::new(b.position.x + b.size.width, by),
)
}
} else if by >= ay {
(
Point::new(ax, a.position.y + a.size.height),
Point::new(bx, b.position.y),
)
} else {
(
Point::new(ax, a.position.y),
Point::new(bx, b.position.y + b.size.height),
)
}
}
fn curve(a: Point, b: Point) -> Path {
let mid = (a.x + b.x) / 2.;
Path::new(|p| {
p.move_to(a);
p.bezier_curve_to(Point::new(mid, a.y), Point::new(mid, b.y), b);
})
}
fn curve_point(a: Point, b: Point, t: f32) -> Point {
let mid = (a.x + b.x) / 2.;
let u = 1. - t;
Point::new(
u * u * u * a.x + 3. * u * u * t * mid + 3. * u * t * t * mid + t * t * t * b.x,
u * u * u * a.y + 3. * u * u * t * a.y + 3. * u * t * t * b.y + t * t * t * b.y,
)
}
pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 {
let ab = b - a;
let ap = p - a;
let len = ab.x * ab.x + ab.y * ab.y;
let t = if len > 0. {
((ap.x * ab.x + ap.y * ab.y) / len).clamp(0., 1.)
} else {
0.
};
let delta = p - (a + ab * t);
(delta.x * delta.x + delta.y * delta.y).sqrt()
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
fn hex(c: Color) -> String {
format!(
"#{:02x}{:02x}{:02x}",
(c.r * 255.) as u8,
(c.g * 255.) as u8,
(c.b * 255.) as u8
)
}
pub fn export_svg(p: &Project, diagram: Id) -> String {
let view = project_view(p, diagram);
let left = view.nodes.iter().map(|n| n.position.x).fold(0., f32::min) - 30.;
let top = view.nodes.iter().map(|n| n.position.y).fold(0., f32::min) - 30.;
let right = view
.nodes
.iter()
.map(|n| n.position.x + n.size.width)
.fold(800., f32::max)
+ 30.;
let bottom = view
.nodes
.iter()
.map(|n| n.position.y + n.size.height)
.fold(500., f32::max)
+ 30.;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {} {}\"><title>{}</title><defs><marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10\" fill=\"#8999a9\"/></marker></defs><rect x=\"{left}\" y=\"{top}\" width=\"{}\" height=\"{}\" fill=\"#f6f8fb\"/>",
right - left,
bottom - top,
escape(p.named(diagram)),
right - left,
bottom - top
);
let containers = view
.nodes
.iter()
.filter_map(|n| n.visual_parent)
.collect::<BTreeSet<_>>();
let node_svg = |n: &ProjectedNode| {
let e = &p.elements[&n.element];
let color = hex(style::kind_color(e.kind()));
let x = n.position.x;
let y = n.position.y;
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>",
n.size.width,
n.size.height,
if containers.contains(&n.id) {
"#edf3f7"
} else {
"white"
},
x + 14.,
y + 25.,
escape(e.kind().label()),
x + 14.,
y + 51.,
escape(&short(
&e.name,
((n.size.width - 28.) / 8.).max(8.) as usize
))
)
};
for n in &view.nodes {
if containers.contains(&n.id) {
svg.push_str(&node_svg(n));
}
}
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))));
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
svg.push_str(&node_svg(n));
}
}
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));
}
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() {
let p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let canvas = DiagramCanvas {
project: &p,
diagram,
selected: p.id,
zoom: 0.6,
pan: Vector::new(10., 20.),
};
let view = project_view(&p, diagram);
let port = &view.ports[0];
let click = Point::new(port.position.x * 0.6 + 10., port.position.y * 0.6 + 20.);
let mut state = State::default();
let selected = canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(1600., 1000.)),
mouse::Cursor::Available(click),
)
.unwrap()
.into_inner()
.0;
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
assert!(
state.drag.is_none(),
"Port selection must not drag its parent"
);
let original = p.diagrams[&diagram].nodes[&port.owner].position;
state.drag = Some((port.owner, Point::ORIGIN, original));
state.preview = Some(Position {
x: original.x + 100.,
y: original.y + 70.,
});
let preview = canvas.preview_view(&state);
let moved = preview
.ports
.iter()
.find(|p| p.element == port.element)
.unwrap();
assert_eq!(moved.position.x, port.position.x + 100.);
assert_eq!(moved.position.y, port.position.y + 70.);
let svg = export_svg(&p, diagram);
for port in &view.ports {
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
}
for edge in &view.edges {
let (start, end) = edge_endpoints(&view, edge).unwrap();
assert!(svg.contains(&format!("M {} {} C", start.x, start.y)));
assert!(svg.contains(&format!("{}, {} {}", end.y, end.x, end.y)));
}
}
}