Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user