From dca08b9e978189806048ec8f0600864f84031b19 Mon Sep 17 00:00:00 2001 From: Michael Mikovsky <77305074+Astatin3@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:52:38 -0700 Subject: [PATCH] Add force directed flow chart --- unshell-gui/Cargo.lock | 1 + unshell-gui/Cargo.toml | 1 + unshell-gui/src/app.rs | 12 +- unshell-gui/src/config/mod.rs | 16 +++ unshell-gui/src/flowchart.rs | 112 ------------------ unshell-gui/src/flowchart/container.rs | 117 +++++++++++++++++++ unshell-gui/src/flowchart/flowchart.rs | 152 +++++++++++++++++++++++++ unshell-gui/src/flowchart/force.rs | 140 +++++++++++++++++++++++ unshell-gui/src/flowchart/group.rs | 59 ++++++++++ unshell-gui/src/flowchart/mod.rs | 28 +++++ unshell-gui/src/lib.rs | 1 + 11 files changed, 521 insertions(+), 118 deletions(-) delete mode 100644 unshell-gui/src/flowchart.rs create mode 100644 unshell-gui/src/flowchart/container.rs create mode 100644 unshell-gui/src/flowchart/flowchart.rs create mode 100644 unshell-gui/src/flowchart/force.rs create mode 100644 unshell-gui/src/flowchart/group.rs create mode 100644 unshell-gui/src/flowchart/mod.rs diff --git a/unshell-gui/Cargo.lock b/unshell-gui/Cargo.lock index 207894f..7ed1624 100644 --- a/unshell-gui/Cargo.lock +++ b/unshell-gui/Cargo.lock @@ -2688,6 +2688,7 @@ dependencies = [ "egui_extras", "log", "pretty_env_logger", + "rand 0.9.2", "serde", "unshell-lib", "wasm-bindgen-futures", diff --git a/unshell-gui/Cargo.toml b/unshell-gui/Cargo.toml index b41c584..50bb1c7 100644 --- a/unshell-gui/Cargo.toml +++ b/unshell-gui/Cargo.toml @@ -28,6 +28,7 @@ log = "0.4.27" # You only need serde if you want app persistence: serde = { version = "1.0.219", features = ["derive"] } egui_extras = "0.33.2" +rand = "0.9.2" # native: [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/unshell-gui/src/app.rs b/unshell-gui/src/app.rs index b363a6b..1d503bd 100644 --- a/unshell-gui/src/app.rs +++ b/unshell-gui/src/app.rs @@ -31,17 +31,17 @@ impl Default for TemplateApp { impl TemplateApp { /// Called once before the first frame. - pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { // This is also where you can customize the look and feel of egui using // `cc.egui_ctx.set_visuals` and `cc.egui_ctx.set_fonts`. // Load previous app state (if any). // Note that you must enable the `persistence` feature for this to work. - if let Some(storage) = cc.storage { - eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() - } else { - Default::default() - } + // if let Some(storage) = cc.storage { + // eframe::get_value(storage, eframe::APP_KEY).unwrap_or_default() + // } else { + Default::default() + // } } } diff --git a/unshell-gui/src/config/mod.rs b/unshell-gui/src/config/mod.rs index 7f2a917..328ac06 100644 --- a/unshell-gui/src/config/mod.rs +++ b/unshell-gui/src/config/mod.rs @@ -100,6 +100,7 @@ impl Config { .column(Column::remainder()) .column(Column::remainder()) .column(Column::remainder()) + .column(Column::auto().resizable(false)) .min_scrolled_height(0.0) .sense(egui::Sense::click()); @@ -117,6 +118,7 @@ impl Config { header.col(|ui| { ui.strong("Runtimes"); }); + header.col(|_| {}); }) .body(|mut body| { for i in 0..self.current_payloads.len() { @@ -138,6 +140,20 @@ impl Config { row.col(|ui| { ui.label("A"); }); + row.col(|ui| { + if ui.button("Edit").clicked() { + self.state = ConfigState::EditConfig( + i, + self.current_payloads[i].clone(), + ); + } + // if ui.button("Delete").clicked() { + // self.state = ConfigState::EditConfig( + // i, + // self.current_payloads[i].clone(), + // ); + // } + }); if row.response().clicked() { self.state = ConfigState::EditConfig( diff --git a/unshell-gui/src/flowchart.rs b/unshell-gui/src/flowchart.rs deleted file mode 100644 index 740cb93..0000000 --- a/unshell-gui/src/flowchart.rs +++ /dev/null @@ -1,112 +0,0 @@ -use egui::Ui; -use egui::{Color32, Painter, Pos2, Rect, Stroke, UiBuilder, Vec2}; - -const TARGET_LINE_GAP: f32 = 80.; - -const BG_STROKE: Stroke = Stroke { - width: 0.3, - color: Color32::GRAY, -}; - -#[derive(serde::Deserialize, serde::Serialize)] - -pub struct FlowChart { - // frame: Frame, - container: DraggableContainer, -} - -impl FlowChart { - pub fn new() -> Self { - Self { - container: DraggableContainer::new( - Pos2 { x: 100., y: 100. }, - Vec2 { x: 100., y: 100. }, - ), - } - } - - fn paint_bg(&self, rect: &Rect, painter: &Painter) { - let h_count = (rect.width() / TARGET_LINE_GAP).round() as usize; - let h_spacing = rect.width() / h_count as f32; - for n in 0..h_count { - painter.vline(rect.min.x + n as f32 * h_spacing, rect.y_range(), BG_STROKE); - } - - let v_count = (rect.height() / TARGET_LINE_GAP).round() as usize; - let v_spacing = rect.height() / v_count as f32; - for n in 0..v_count { - painter.hline(rect.x_range(), rect.min.y + n as f32 * v_spacing, BG_STROKE); - } - } - - pub fn paint(&mut self, ui: &mut Ui) { - self.paint_bg(&ui.clip_rect(), ui.painter()); - self.container.show(ui, |ui, rect| { - ui.painter().rect( - // ui.top - *rect, - 0., - Color32::PURPLE, - BG_STROKE, - egui::StrokeKind::Outside, - ); - ui.label("Tests"); - let _ = ui.button("Test"); - }) - } -} - -#[derive(serde::Deserialize, serde::Serialize)] -pub struct DraggableContainer { - pub pos: egui::Pos2, - pub size: egui::Vec2, - is_dragging: bool, - drag_offset: egui::Vec2, -} - -impl DraggableContainer { - pub fn new(pos: egui::Pos2, size: egui::Vec2) -> Self { - Self { - pos, - size, - is_dragging: false, - drag_offset: egui::Vec2::ZERO, - } - } - - pub fn show( - &mut self, - ui: &mut egui::Ui, - add_contents: impl FnOnce(&mut egui::Ui, &Rect) -> R, - ) -> R { - let rect = egui::Rect::from_min_size(self.pos, self.size); - - // Handle dragging logic - let response = ui.interact(rect, ui.id().with("drag_area"), egui::Sense::drag()); - - if response.drag_started() { - self.is_dragging = true; - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - self.drag_offset = self.pos - pointer_pos; - } - } - - if response.dragged() && self.is_dragging { - if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { - self.pos = pointer_pos + self.drag_offset; - } - } - - if response.drag_stopped() { - self.is_dragging = false; - } - - // Create a child UI at the specified position - // let mut child_ui = ui.child_ui(rect, egui::Layout::top_down(egui::Align::LEFT), None); - - let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect)); - - // Add contents - add_contents(&mut child_ui, &rect) - } -} diff --git a/unshell-gui/src/flowchart/container.rs b/unshell-gui/src/flowchart/container.rs new file mode 100644 index 0000000..ea3a987 --- /dev/null +++ b/unshell-gui/src/flowchart/container.rs @@ -0,0 +1,117 @@ +use egui::{Pos2, Rect, UiBuilder, Vec2}; + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct DraggableContainer { + pub pos: egui::Vec2, // Offset from center of clip_rect + pub size: egui::Vec2, + is_dragging: bool, + drag_offset: egui::Vec2, + drag_id: String, + + pub vel: egui::Vec2, +} + +impl DraggableContainer { + // pub fn new(center_offset: egui::Vec2, size: egui::Vec2, id: usize) -> Self { + // Self { + // pos: center_offset, + // size, + // is_dragging: false, + // drag_offset: egui::Vec2::ZERO, + // drag_id: format!("flowchart_drag_area{}", id), + // vel: Vec2::ZERO, + // } + // } + + pub fn new_zero(id: usize) -> Self { + Self { + pos: Vec2::ZERO, + size: Vec2 { x: 100., y: 100. }, + is_dragging: false, + drag_offset: egui::Vec2::ZERO, + drag_id: format!("flowchart_drag_area{}", id), + vel: Vec2::ZERO, + } + } + + // pub fn show( + // &mut self, + // ui: &mut egui::Ui, + // add_contents: impl FnOnce(&mut egui::Ui, &Rect) -> R, + // ) -> R { + // let rect = egui::Rect::from_min_size(self.pos, self.size); + + // // Handle dragging logic + // let response = ui.interact(rect, ui.id().with(&self.drag_id), egui::Sense::drag()); + + // if response.drag_started() { + // self.is_dragging = true; + // if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + // self.drag_offset = self.pos - pointer_pos; + // } + // } + + // if response.dragged() && self.is_dragging { + // if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + // self.pos = pointer_pos + self.drag_offset; + // } + // } + + // if response.drag_stopped() { + // self.is_dragging = false; + // } + + // // Create a child UI at the specified position + // // let mut child_ui = ui.child_ui(rect, egui::Layout::top_down(egui::Align::LEFT), None); + + // let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect)); + + // // Add contents + // add_contents(&mut child_ui, &rect) + // } + + pub fn get_pos(&self, center: &Pos2) -> Pos2 { + center.clone() + self.pos + } + + pub fn show( + &mut self, + ui: &mut egui::Ui, + add_contents: impl FnOnce(&mut egui::Ui, &Rect) -> R, + ) -> R { + // Calculate center of the clip rect + let clip_center = ui.clip_rect().center(); + + // Calculate actual position from center offset + let center_pos = clip_center + self.pos; + let pos = center_pos - self.size / 2.0; // Top-left corner from center + let rect = egui::Rect::from_min_size(pos, self.size); + + // Handle dragging logic + let response = ui.interact(rect, ui.id().with(&self.drag_id), egui::Sense::drag()); + + if response.drag_started() { + self.is_dragging = true; + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + self.drag_offset = center_pos - pointer_pos; + } + } + + if response.dragged() && self.is_dragging { + if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() { + let new_center = pointer_pos + self.drag_offset; + self.pos = new_center - clip_center; + } + } + + if response.drag_stopped() { + self.is_dragging = false; + } + + // Create a child UI at the specified position + let mut child_ui = ui.new_child(UiBuilder::new().max_rect(rect)); + + // Add contents + add_contents(&mut child_ui, &rect) + } +} diff --git a/unshell-gui/src/flowchart/flowchart.rs b/unshell-gui/src/flowchart/flowchart.rs new file mode 100644 index 0000000..58159ac --- /dev/null +++ b/unshell-gui/src/flowchart/flowchart.rs @@ -0,0 +1,152 @@ +use egui::Shape; +use egui::Ui; +use egui::{Color32, Painter, Pos2, Rect}; + +use crate::flowchart::CONNECTION_STROKE; +use crate::flowchart::GROUP_BORDER_MARGIN; +use crate::flowchart::container::DraggableContainer; +use crate::flowchart::group::convex_hull; +use crate::flowchart::{BG_STROKE, TARGET_LINE_GAP}; + +#[derive(serde::Deserialize, serde::Serialize)] + +pub struct FlowChart { + pub containers: Vec, + pub connections: Vec<(usize, usize)>, + pub groups: Vec>, +} + +impl FlowChart { + pub fn new() -> Self { + let mut this = Self { + containers: vec![ + DraggableContainer::new_zero(0), + DraggableContainer::new_zero(1), + DraggableContainer::new_zero(2), + DraggableContainer::new_zero(3), + DraggableContainer::new_zero(4), + DraggableContainer::new_zero(5), + DraggableContainer::new_zero(6), + DraggableContainer::new_zero(7), + ], + connections: vec![(0, 1), (1, 2), (1, 3), (1, 4), (3, 5), (3, 6), (3, 7)], + groups: vec![], + }; + + this.arrange_circle(); + + this + } + + fn paint_bg(&self, rect: &Rect, painter: &Painter) { + let h_count = (rect.width() / TARGET_LINE_GAP).round() as usize; + let h_spacing = rect.width() / h_count as f32; + for n in 0..h_count { + painter.vline(rect.min.x + n as f32 * h_spacing, rect.y_range(), BG_STROKE); + } + + let v_count = (rect.height() / TARGET_LINE_GAP).round() as usize; + let v_spacing = rect.height() / v_count as f32; + for n in 0..v_count { + painter.hline(rect.x_range(), rect.min.y + n as f32 * v_spacing, BG_STROKE); + } + } + + fn paint_groups(&self, ui: &mut Ui) { + let center = ui.clip_rect().center(); + for group in &self.groups { + let mut points = Vec::new(); + + for n in group { + let container = &self.containers[*n]; + let pos = container.get_pos(¢er); + let size = container.size; + points.append(&mut vec![ + Pos2 { + x: pos.x - size.x / 2. - GROUP_BORDER_MARGIN, + y: pos.y - size.x / 2. - GROUP_BORDER_MARGIN, + }, + Pos2 { + x: pos.x + size.x / 2. + GROUP_BORDER_MARGIN, + y: pos.y - size.y / 2. - GROUP_BORDER_MARGIN, + }, + Pos2 { + x: pos.x - size.x / 2. - GROUP_BORDER_MARGIN, + y: pos.y + size.y / 2. + GROUP_BORDER_MARGIN, + }, + Pos2 { + x: pos.x + size.x / 2. + GROUP_BORDER_MARGIN, + y: pos.y + size.y / 2. + GROUP_BORDER_MARGIN, + }, + ]); + } + + let points = convex_hull(&points); + + ui.painter().add(Shape::convex_polygon( + points, + Color32::DEBUG_COLOR, + BG_STROKE, + )); + } + } + + pub fn paint(&mut self, ui: &mut Ui) { + self.paint_bg(&ui.clip_rect(), ui.painter()); + self.paint_groups(ui); + + let center = ui.clip_rect().center(); + + for (a, b) in &self.connections { + ui.painter().line_segment( + [ + self.containers[*a].get_pos(¢er), + self.containers[*b].get_pos(¢er), + ], + CONNECTION_STROKE, + ); + + // let start = self.containers[m.clone()]; + // let end = self.containers[n.clone()]; + } + + for container in &mut self.containers { + container.show(ui, |ui, rect| { + ui.painter().rect( + // ui.top + *rect, + 0., + Color32::PURPLE, + BG_STROKE, + egui::StrokeKind::Outside, + ); + // ui.label("Tests"); + // let _ = ui.button("Test"); + }); + } + + if ui.button("Arrange").clicked() { + // let positions: Vec = (0..num_nodes) + // .map(|i| { + // let angle = (i as f32) * 2.0 * std::f32::consts::PI / (num_nodes as f32); + // Vec2::new(angle.cos() * 100.0, angle.sin() * 100.0) + // }) + // .collect(); + + // let node_count = self.containers.len() as f32; + + // for (i, m) in self.containers.iter_mut().enumerate() { + // let ang = -(i as f32 / node_count) * PI * 2.; + // m.pos = Vec2 { + // x: 1000. * ang.sin(), + // y: 1000. * ang.cos(), + // }; + // m.vel = Vec2::ZERO; + // } + + for _ in 0..1_000 { + self.force(0.1); + } + } + } +} diff --git a/unshell-gui/src/flowchart/force.rs b/unshell-gui/src/flowchart/force.rs new file mode 100644 index 0000000..e0fb311 --- /dev/null +++ b/unshell-gui/src/flowchart/force.rs @@ -0,0 +1,140 @@ +use std::f32::consts::TAU; + +use egui::Vec2; + +use crate::flowchart::{ + ATTRACTION_STRENGTH, CENTER_ATTRACTION_STRENGTH, DAMPING, FlowChart, GROUP_ATTRACTION_STRENGTH, + REPULSION_STRENGTH, REST_LENGTH, +}; + +pub fn normalize(v: &Vec2) -> Vec2 { + let len = v.length(); + if len > 0.0 { + Vec2 { + x: v.x / len, + y: v.y / len, + } + } else { + Vec2 { x: 0.0, y: 0.0 } + } +} + +impl FlowChart { + pub fn force(&mut self, delta_time: f32) { + let num_nodes = self.containers.len(); + let mut forces = vec![Vec2::new(0.0, 0.0); num_nodes]; + + // Calculate repulsive forces between all nodes + for i in 0..num_nodes { + for j in (i + 1)..num_nodes { + let diff = self.containers[i].pos - self.containers[j].pos; + let dist = diff.length().max(0.1); // Prevent division by zero + let force = normalize(&diff) * (REPULSION_STRENGTH / (dist * dist)); + + forces[i] = forces[i] + force; + forces[j] = forces[j] + (force * -1.0); + } + } + + // Calculate attractive forces along connections + for &(i, j) in &self.connections { + let diff = self.containers[j].pos - self.containers[i].pos; + let dist = diff.length(); + let displacement = dist - REST_LENGTH; + let force = normalize(&diff) * (displacement * ATTRACTION_STRENGTH); + + forces[i] = forces[i] + force; + forces[j] = forces[j] + (force * -1.0); + } + + // Apply force to center + for i in 0..num_nodes { + let diff = self.containers[i].pos; + let dist = diff.length(); + let displacement = dist - REST_LENGTH; + let force = normalize(&diff) * (displacement * CENTER_ATTRACTION_STRENGTH); + + forces[i] = forces[i] + force * -1.; + } + + let group_avg = &self + .groups + .iter() + .map(|group| { + let mut sum = Vec2::ZERO; + for n in group { + sum += self.containers[*n].pos; + } + sum / group.len() as f32 + }) + .collect::>(); + + for (group, group_avg) in self.groups.iter().zip(group_avg) { + for i in 0..num_nodes { + let diff = self.containers[i].pos - *group_avg; + let dist = diff.length(); + let displacement = dist - REST_LENGTH; + let force = normalize(&diff) * (displacement * GROUP_ATTRACTION_STRENGTH); + + if group.contains(&i) { + forces[i] = forces[i] + force * -1.; + } else { + forces[i] = forces[i] + force; + } + } + } + + // Update velocities and positions + for i in 0..num_nodes { + let c = &mut self.containers[i]; + c.vel = (c.vel + forces[i] * delta_time) * DAMPING; + c.pos += c.vel * delta_time; + } + } + + pub fn arrange_circle(&mut self) { + let node_count = self.containers.len() as f32; + for (i, m) in self.containers.iter_mut().enumerate() { + let ang = -(i as f32 / node_count) * TAU; + m.pos = Vec2 { + x: 300. * ang.sin(), + y: 300. * ang.cos(), + }; + m.vel = Vec2::ZERO; + } + } + + // pub fn get_positions(&self) -> &[Vec2] { + // &self.positions + // } + + // pub fn get_connections(&self) -> &[(usize, usize)] { + // &self.connections + // } +} + +// fn main() { +// // Example usage: Create a simple triangle graph +// let connections = vec![(0, 1), (1, 2), (2, 0)]; +// let mut graph = ForceDirectedGraph::new( +// 3, +// connections, +// 1000.0, // repulsion_strength +// 0.1, // attraction_strength +// 50.0, // rest_length +// 0.9, // damping +// ); + +// // Simulate 100 frames at 60 FPS +// let delta_time = 1.0 / 60.0; +// for frame in 0..100 { +// graph.update(delta_time); + +// if frame % 20 == 0 { +// println!("Frame {}: ", frame); +// for (i, pos) in graph.get_positions().iter().enumerate() { +// println!(" Node {}: ({:.2}, {:.2})", i, pos.x, pos.y); +// } +// } +// } +// } diff --git a/unshell-gui/src/flowchart/group.rs b/unshell-gui/src/flowchart/group.rs new file mode 100644 index 0000000..5fd21dd --- /dev/null +++ b/unshell-gui/src/flowchart/group.rs @@ -0,0 +1,59 @@ +use egui::Pos2; + +/// Calculate the convex hull of a set of points using Graham scan +pub fn convex_hull(points: &[Pos2]) -> Vec { + if points.len() < 3 { + return points.to_vec(); + } + + let mut pts = points.to_vec(); + + // Find the point with lowest y-coordinate (and leftmost if tie) + let start_idx = pts + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + a.y.partial_cmp(&b.y) + .unwrap() + .then(a.x.partial_cmp(&b.x).unwrap()) + }) + .unwrap() + .0; + + pts.swap(0, start_idx); + let start = pts[0]; + + // Sort points by polar angle with respect to start point + pts[1..].sort_by(|a, b| { + let angle_a = polar_angle_to(&start, a); + let angle_b = polar_angle_to(&start, b); + angle_a.partial_cmp(&angle_b).unwrap() + }); + + // Build convex hull + let mut hull = Vec::new(); + hull.push(pts[0]); + hull.push(pts[1]); + + for i in 2..pts.len() { + while hull.len() > 1 + && cross_product(&hull[hull.len() - 2], &hull[hull.len() - 1], &pts[i]) <= 0.0 + { + hull.pop(); + } + hull.push(pts[i]); + } + + hull +} + +/// Calculate cross product of vectors (self->p2) and (self->p3) +/// Positive if counter-clockwise, negative if clockwise, zero if collinear +fn cross_product(p1: &Pos2, p2: &Pos2, p3: &Pos2) -> f32 { + (p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x) +} + +/// Calculate polar angle from self to other point +fn polar_angle_to(a: &Pos2, b: &Pos2) -> f32 { + (b.y - a.y).atan2(b.x - a.x) +} diff --git a/unshell-gui/src/flowchart/mod.rs b/unshell-gui/src/flowchart/mod.rs new file mode 100644 index 0000000..efa99cd --- /dev/null +++ b/unshell-gui/src/flowchart/mod.rs @@ -0,0 +1,28 @@ +mod container; +mod flowchart; +mod force; +mod group; + +use egui::{Color32, Stroke}; +pub use flowchart::FlowChart; + +const TARGET_LINE_GAP: f32 = 80.; + +const BG_STROKE: Stroke = Stroke { + width: 0.3, + color: Color32::GRAY, +}; + +const CONNECTION_STROKE: Stroke = Stroke { + width: 3., + color: Color32::WHITE, +}; + +const GROUP_BORDER_MARGIN: f32 = 20.; + +static REPULSION_STRENGTH: f32 = 100000.0; // repulsion_strength +static ATTRACTION_STRENGTH: f32 = 0.01; // attraction_strength +static CENTER_ATTRACTION_STRENGTH: f32 = 0.01; // attraction_strength +static GROUP_ATTRACTION_STRENGTH: f32 = 0.001; // attraction_strength +static REST_LENGTH: f32 = 50.0; // rest_length +static DAMPING: f32 = 0.9; // damping diff --git a/unshell-gui/src/lib.rs b/unshell-gui/src/lib.rs index d99a2c7..d86a283 100644 --- a/unshell-gui/src/lib.rs +++ b/unshell-gui/src/lib.rs @@ -1,5 +1,6 @@ #![warn(clippy::all, rust_2018_idioms)] #![macro_use] +#[allow(unused_extern_crates)] extern crate log; mod app;