diff --git a/crates/egui_graphs/examples/circular_layout.rs b/crates/egui_graphs/examples/circular_layout.rs new file mode 100644 index 0000000..1107457 --- /dev/null +++ b/crates/egui_graphs/examples/circular_layout.rs @@ -0,0 +1,71 @@ +use eframe::egui; +use egui_graphs::{ + DefaultEdgeShape, DefaultNodeShape, Graph, GraphView, LayoutCircular, LayoutStateCircular, + SettingsInteraction, SettingsStyle, +}; +use petgraph::{ + stable_graph::{DefaultIx, StableGraph}, + Directed, +}; + +fn main() -> eframe::Result<()> { + let native_options = eframe::NativeOptions::default(); + eframe::run_native( + "Circular Layout Example", + native_options, + Box::new(|_cc| Ok::, _>(Box::new(CircularExample::new()))), + ) +} + +struct CircularExample { + g: Graph<(), ()>, +} + +impl CircularExample { + fn new() -> Self { + let mut graph = StableGraph::new(); + + // Create some nodes + let nodes: Vec<_> = (0..8).map(|_| graph.add_node(())).collect(); + + // Add some edges in a ring + for i in 0..nodes.len() { + graph.add_edge(nodes[i], nodes[(i + 1) % nodes.len()], ()); + } + + let mut g = Graph::from(&graph); + + // Set labels + for (i, idx) in nodes.iter().enumerate() { + if let Some(node) = g.node_mut(*idx) { + node.set_label(format!("Node {}", i)); + } + } + + Self { g } + } +} + +impl eframe::App for CircularExample { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Circular Layout Example"); + ui.label("8 nodes arranged in a circle"); + + ui.add( + &mut GraphView::< + (), + (), + Directed, + DefaultIx, + DefaultNodeShape, + DefaultEdgeShape, + LayoutStateCircular, + LayoutCircular, + >::new(&mut self.g) + .with_interactions(&SettingsInteraction::new()) + .with_styles(&SettingsStyle::new()), + ); + }); + } +} diff --git a/crates/egui_graphs/src/layouts/circular/layout.rs b/crates/egui_graphs/src/layouts/circular/layout.rs new file mode 100644 index 0000000..c294995 --- /dev/null +++ b/crates/egui_graphs/src/layouts/circular/layout.rs @@ -0,0 +1,190 @@ +use egui; +use serde::{Deserialize, Serialize}; + +use crate::graph::Graph; +use crate::layouts::{Layout, LayoutState}; +use crate::{DisplayEdge, DisplayNode}; +use petgraph::graph::IndexType; +use petgraph::EdgeType; + +/// State for the circular layout algorithm +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct State { + applied: bool, +} + +impl LayoutState for State {} + +/// Sort order for circular layout nodes +#[derive(Debug, Clone, Default)] +pub enum SortOrder { + /// Alphabetical by label (ascending) + #[default] + Alphabetical, + /// Reverse alphabetical by label (descending) + ReverseAlphabetical, + /// No sorting - preserve insertion order + None, +} + +/// Configuration for spacing/radius of the circular layout +#[derive(Debug, Clone)] +pub struct SpacingConfig { + /// Base radius when there are few nodes + pub base_radius: f32, + /// Additional radius per node (for auto-scaling) + pub radius_per_node: f32, + /// If set, overrides the auto-calculated radius + pub fixed_radius: Option, +} + +impl Default for SpacingConfig { + fn default() -> Self { + Self { + base_radius: 50.0, + radius_per_node: 5.0, + fixed_radius: None, + } + } +} + +impl SpacingConfig { + /// Set the base radius for the circle + pub fn with_base_radius(mut self, base: f32) -> Self { + self.base_radius = base; + self + } + + /// Set the additional radius per node for auto-scaling + pub fn with_radius_per_node(mut self, per_node: f32) -> Self { + self.radius_per_node = per_node; + self + } + + /// Set a fixed radius, overriding auto-scaling + pub fn with_fixed_radius(mut self, radius: f32) -> Self { + self.fixed_radius = Some(radius); + self + } +} + +/// Circular layout arranges nodes in a circle. +/// +/// Nodes are positioned evenly around a circle with configurable: +/// +/// - Sort order (alphabetical, reverse, or insertion order) +/// - Spacing (auto-scaling or fixed radius) +/// +/// The layout applies once and preserves the circular arrangement. +#[derive(Debug, Clone, Default)] +pub struct Circular { + state: State, + sort_order: SortOrder, + spacing: SpacingConfig, +} + +impl Circular { + /// Create a new circular layout with default configuration + pub fn new() -> Self { + Self::default() + } + + /// Set the sort order for nodes around the circle + pub fn with_sort_order(mut self, sort_order: SortOrder) -> Self { + self.sort_order = sort_order; + self + } + + /// Disable sorting, preserving insertion order + pub fn without_sorting(mut self) -> Self { + self.sort_order = SortOrder::None; + self + } + + /// Set custom spacing configuration + pub fn with_spacing(mut self, spacing: SpacingConfig) -> Self { + self.spacing = spacing; + self + } +} + +impl Layout for Circular { + fn from_state(state: State) -> impl Layout { + Self { + state, + sort_order: SortOrder::default(), + spacing: SpacingConfig::default(), + } + } + + fn next(&mut self, g: &mut Graph, ui: &egui::Ui) + where + N: Clone, + E: Clone, + Ty: EdgeType, + Ix: IndexType, + Dn: DisplayNode, + De: DisplayEdge, + { + // Only apply layout once + if self.state.applied { + return; + } + + // Collect all nodes with their indices and labels + let mut nodes: Vec<_> = g + .nodes_iter() + .map(|(idx, node)| (idx, node.label().to_string())) + .collect(); + + // Sort according to the configured sort order + match self.sort_order { + SortOrder::Alphabetical => { + nodes.sort_by(|a, b| a.1.cmp(&b.1)); + } + SortOrder::ReverseAlphabetical => { + nodes.sort_by(|a, b| b.1.cmp(&a.1)); + } + SortOrder::None => { + // Keep insertion order - no sorting + } + } + + let node_count = nodes.len(); + if node_count == 0 { + return; + } + + // Calculate center of the canvas + let rect = ui.available_rect_before_wrap(); + let center_x = rect.center().x; + let center_y = rect.center().y; + + // Calculate radius using configuration + let radius = if let Some(fixed) = self.spacing.fixed_radius { + fixed + } else { + self.spacing.base_radius + (node_count as f32) * self.spacing.radius_per_node + }; + + // Place nodes in a circle + for (i, (node_idx, _label)) in nodes.iter().enumerate() { + // Start at top (-π/2) and go clockwise + let angle = -std::f32::consts::PI / 2.0 + + (i as f32) * 2.0 * std::f32::consts::PI / (node_count as f32); + + let x = center_x + radius * angle.cos(); + let y = center_y + radius * angle.sin(); + + if let Some(node) = g.node_mut(*node_idx) { + node.set_location(egui::Pos2::new(x, y)); + } + } + + self.state.applied = true; + } + + fn state(&self) -> State { + self.state.clone() + } +} diff --git a/crates/egui_graphs/src/layouts/circular/mod.rs b/crates/egui_graphs/src/layouts/circular/mod.rs new file mode 100644 index 0000000..b08a81b --- /dev/null +++ b/crates/egui_graphs/src/layouts/circular/mod.rs @@ -0,0 +1,3 @@ +mod layout; + +pub use layout::{Circular, SortOrder, SpacingConfig, State}; diff --git a/crates/egui_graphs/src/layouts/mod.rs b/crates/egui_graphs/src/layouts/mod.rs index a2869ba..e065a82 100644 --- a/crates/egui_graphs/src/layouts/mod.rs +++ b/crates/egui_graphs/src/layouts/mod.rs @@ -1,3 +1,4 @@ +pub mod circular; pub mod force_directed; pub mod hierarchical; pub mod random; diff --git a/crates/egui_graphs/src/lib.rs b/crates/egui_graphs/src/lib.rs index f526137..7651059 100644 --- a/crates/egui_graphs/src/lib.rs +++ b/crates/egui_graphs/src/lib.rs @@ -21,6 +21,10 @@ pub use helpers::{ generate_simple_ungraph, node_size, to_graph, to_graph_custom, }; +pub use layouts::circular::{ + Circular as LayoutCircular, SortOrder as LayoutCircularSortOrder, + SpacingConfig as LayoutCircularSpacingConfig, State as LayoutStateCircular, +}; pub use layouts::force_directed::{ CenterGravity, CenterGravityParams, Extra, ForceAlgorithm, ForceDirected as LayoutForceDirected, FruchtermanReingold, FruchtermanReingoldState,