use std::time::Instant;
use std::collections::{BTreeSet, HashSet};
use uzor::input::{KeyCode, ModifierKeys, MouseButton, PlatformEvent};
use uzor::render::{RenderContext, RenderRegion, UNCAPPED_FPS};
use uzor::types::Rect;
use uzor_figures::interact::FocusSet;
use crate::camera::{Aabb, Camera2D};
use crate::cluster::{ClusterRegistry, GroupId};
use crate::graph::{Graph, NodeIndex, SimEdge, SimTopology};
use crate::interaction::drag::DragController;
use crate::interaction::pick;
use crate::label_grid;
use crate::layout::force_directed::ForceDirectedLayout;
use crate::layout::{ForceParams, GraphLayoutMode, Layout, LayoutTickResult};
use crate::particle::Particle;
use crate::render as gr_render;
use crate::theme::GraphTheme;
const CLICK_DRAG_THRESHOLD_PX: f64 = 4.0;
const ZOOM_SENSITIVITY: f64 = 0.0015;
const DRAG_REHEAT_ALPHA: f32 = 0.35;
const DRAG_ALPHA_TARGET: f32 = 0.3;
const HOVER_PICK_MIN_MOVE_PX: f64 = 2.0;
const DEFAULT_HOVER_DEPTH: u8 = 1;
pub(crate) const DEFAULT_LABEL_HALO: &str = "#0d0f14";
pub(crate) const DEFAULT_TRANSITION_MS: f64 = 400.0;
pub(crate) const DEFAULT_FIT_PADDING_PX: f64 = 40.0;
const DEFAULT_LOCAL_DEPTH: u8 = 2;
const KEY_PAN_SPEED_PX_PER_S: f64 = 480.0;
const KEY_ZOOM_RATE_PER_S: f64 = 1.2;
const SCROLL_ZOOM_FACTOR_MIN: f64 = 0.8;
const SCROLL_ZOOM_FACTOR_MAX: f64 = 1.25;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GraphInteractionConfig {
pub click_drag_threshold_px: f64,
pub zoom_sensitivity: f64,
pub scroll_zoom_factor_min: f64,
pub scroll_zoom_factor_max: f64,
pub key_pan_speed_px_per_s: f64,
pub key_zoom_rate_per_s: f64,
pub drag_reheat_alpha: f32,
pub drag_alpha_target: f32,
pub hover_pick_min_move_px: f64,
}
impl Default for GraphInteractionConfig {
fn default() -> Self {
Self {
click_drag_threshold_px: CLICK_DRAG_THRESHOLD_PX,
zoom_sensitivity: ZOOM_SENSITIVITY,
scroll_zoom_factor_min: SCROLL_ZOOM_FACTOR_MIN,
scroll_zoom_factor_max: SCROLL_ZOOM_FACTOR_MAX,
key_pan_speed_px_per_s: KEY_PAN_SPEED_PX_PER_S,
key_zoom_rate_per_s: KEY_ZOOM_RATE_PER_S,
drag_reheat_alpha: DRAG_REHEAT_ALPHA,
drag_alpha_target: DRAG_ALPHA_TARGET,
hover_pick_min_move_px: HOVER_PICK_MIN_MOVE_PX,
}
}
}
pub(crate) const DEFAULT_CULL_MARGIN_WORLD: f64 = 64.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum NavKey {
PanUp,
PanDown,
PanLeft,
PanRight,
ZoomIn,
ZoomOut,
}
fn nav_key_for(key: KeyCode) -> Option<NavKey> {
match key {
KeyCode::ArrowUp => Some(NavKey::PanUp),
KeyCode::ArrowDown => Some(NavKey::PanDown),
KeyCode::ArrowLeft => Some(NavKey::PanLeft),
KeyCode::ArrowRight => Some(NavKey::PanRight),
KeyCode::Plus | KeyCode::BracketRight | KeyCode::PageUp => Some(NavKey::ZoomIn),
KeyCode::Minus | KeyCode::BracketLeft | KeyCode::PageDown => Some(NavKey::ZoomOut),
_ => None,
}
}
pub(crate) fn ease_in_out_cubic(t: f64) -> f64 {
if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
}
}
#[derive(Debug, Clone, Copy)]
struct CameraTransition {
start_pan: (f64, f64),
start_zoom: f64,
target_pan: (f64, f64),
target_zoom: f64,
elapsed_s: f32,
duration_s: f32,
}
impl CameraTransition {
fn new(camera: Camera2D, target_pan: (f64, f64), target_zoom: f64, duration_ms: f64) -> Self {
Self {
start_pan: (camera.pan_x, camera.pan_y),
start_zoom: camera.zoom,
target_pan,
target_zoom: target_zoom.clamp(crate::camera::ZOOM_MIN, crate::camera::ZOOM_MAX),
elapsed_s: 0.0,
duration_s: (duration_ms.max(0.0) / 1000.0) as f32,
}
}
fn step(&mut self, dt: f32) -> ((f64, f64), f64, bool) {
self.elapsed_s += dt.max(0.0);
let t = if self.duration_s <= 0.0 { 1.0 } else { (self.elapsed_s / self.duration_s).clamp(0.0, 1.0) as f64 };
let eased = ease_in_out_cubic(t);
let pan = (
self.start_pan.0 + (self.target_pan.0 - self.start_pan.0) * eased,
self.start_pan.1 + (self.target_pan.1 - self.start_pan.1) * eased,
);
let zoom = self.start_zoom + (self.target_zoom - self.start_zoom) * eased;
(pan, zoom, t >= 1.0)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct FilterSpec {
pub label_substring: Option<String>,
pub categories: Option<Vec<String>>,
pub min_degree: Option<u32>,
}
impl FilterSpec {
pub(crate) fn matches<N, E>(&self, graph: &Graph<N, E>, id: NodeIndex) -> bool {
let Some(node) = graph.get_node(id) else { return false };
if let Some(sub) = &self.label_substring {
if !node.label.to_lowercase().contains(&sub.to_lowercase()) {
return false;
}
}
if let Some(categories) = &self.categories {
if !categories.iter().any(|c| c == &node.category) {
return false;
}
}
if let Some(min_degree) = self.min_degree {
if graph.degree(id) < min_degree {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GraphPointerBindings {
pub select_button: Option<MouseButton>,
pub pan_button: Option<MouseButton>,
pub drag_node_button: Option<MouseButton>,
}
impl Default for GraphPointerBindings {
fn default() -> Self {
Self {
select_button: Some(MouseButton::Left),
pan_button: Some(MouseButton::Left),
drag_node_button: Some(MouseButton::Left),
}
}
}
#[derive(Debug, Clone, Copy)]
enum PointerMode {
Idle,
Selecting { button: MouseButton, last: (f64, f64), total: f64 },
PanningCamera {
button: MouseButton,
last: (f64, f64),
total: f64,
select_on_click: bool,
},
DraggingNode { button: MouseButton },
BoxSelecting {
button: MouseButton,
origin: (f64, f64),
current: (f64, f64),
mode: SelectMode,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectMode {
Replace,
Union,
Diff,
}
pub(crate) fn box_select_mode_for(modifiers: ModifierKeys) -> Option<SelectMode> {
if !modifiers.shift {
return None;
}
Some(if modifiers.ctrl {
SelectMode::Union
} else if modifiers.alt {
SelectMode::Diff
} else {
SelectMode::Replace
})
}
pub(crate) fn normalized_rect(a: (f64, f64), b: (f64, f64)) -> Rect {
let x = a.0.min(b.0);
let y = a.1.min(b.1);
Rect::new(x, y, (a.0 - b.0).abs(), (a.1 - b.1).abs())
}
#[derive(Debug, Clone, Copy)]
struct DragMember {
node: NodeIndex,
offset_from_anchor: (f32, f32),
prior_pinned: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DragEndPolicy {
#[default]
Sticky,
RestorePrior,
}
#[derive(Debug, Clone, Copy)]
pub struct NodeFacts<'a> {
pub index: NodeIndex,
pub label: &'a str,
pub category: &'a str,
pub degree: u32,
pub position: (f32, f32),
pub pinned: bool,
}
pub struct GraphEngine<N, E, L: Layout = ForceDirectedLayout> {
pub graph: Graph<N, E>,
pub particles: Vec<Particle>,
pub camera: Camera2D,
pub layout: L,
pub selected: Option<NodeIndex>,
pub hovered: Option<NodeIndex>,
pub selection: BTreeSet<NodeIndex>,
pub focus: FocusSet,
pub clusters: ClusterRegistry,
pinned: Vec<bool>,
drag: DragController,
drag_group: Vec<DragMember>,
drag_was_group: bool,
drag_end_policy: DragEndPolicy,
pointer_bindings: GraphPointerBindings,
modifiers: ModifierKeys,
mode: PointerMode,
canvas_rect: Rect,
last_pointer_screen: (f64, f64),
last_hover_pick_screen: Option<(f64, f64)>,
hover_depth: u8,
hover_card: bool,
label_density: f64,
label_halo: String,
labels_drawn_last_frame: usize,
node_labels_enabled: bool,
visible: Vec<NodeIndex>,
last_tick: LayoutTickResult,
last_frame_at: Option<Instant>,
dirty: bool,
camera_transition: Option<CameraTransition>,
held_nav_keys: HashSet<NavKey>,
local_root: Option<(NodeIndex, u8)>,
filter: Option<FilterSpec>,
theme: GraphTheme,
label_lod: label_grid::LabelLodConfig,
interaction: GraphInteractionConfig,
cull_margin_world: f64,
pub(crate) agent_slot_id: String,
}
impl<N, E, L: Layout> GraphEngine<N, E, L> {
pub fn new(graph: Graph<N, E>, layout: L) -> Self {
let n = graph.node_count();
Self {
graph,
particles: vec![Particle::default(); n],
camera: Camera2D::default(),
layout,
selected: None,
hovered: None,
selection: BTreeSet::new(),
focus: FocusSet::empty(),
clusters: ClusterRegistry::default(),
pinned: vec![false; n],
drag: DragController::default(),
drag_group: Vec::new(),
drag_was_group: false,
drag_end_policy: DragEndPolicy::default(),
pointer_bindings: GraphPointerBindings::default(),
modifiers: ModifierKeys::default(),
mode: PointerMode::Idle,
canvas_rect: Rect::new(0.0, 0.0, 0.0, 0.0),
last_pointer_screen: (0.0, 0.0),
last_hover_pick_screen: None,
hover_depth: DEFAULT_HOVER_DEPTH,
hover_card: true,
label_density: label_grid::DEFAULT_LABEL_DENSITY,
label_halo: DEFAULT_LABEL_HALO.to_owned(),
labels_drawn_last_frame: 0,
node_labels_enabled: true,
visible: Vec::new(),
last_tick: LayoutTickResult { alpha: 1.0, max_displacement: 0.0, settled: false },
last_frame_at: None,
dirty: true,
camera_transition: None,
held_nav_keys: HashSet::new(),
local_root: None,
filter: None,
theme: GraphTheme::dark(),
label_lod: label_grid::LabelLodConfig::default(),
interaction: GraphInteractionConfig::default(),
cull_margin_world: DEFAULT_CULL_MARGIN_WORLD,
agent_slot_id: "graph".to_owned(),
}
}
pub fn set_agent_slot_id(&mut self, id: impl Into<String>) {
self.agent_slot_id = id.into();
}
pub fn seed_positions(&mut self, positions: &[(f32, f32)]) {
for (p, &(x, y)) in self.particles.iter_mut().zip(positions.iter()) {
p.x = x;
p.y = y;
}
self.dirty = true;
}
pub fn set_canvas_rect(&mut self, rect: Rect) {
self.canvas_rect = rect;
}
pub fn canvas_rect(&self) -> Rect {
self.canvas_rect
}
pub fn is_hot(&self) -> bool {
!self.last_tick.settled || self.camera_transition.is_some() || !self.held_nav_keys.is_empty()
}
pub fn last_tick(&self) -> LayoutTickResult {
self.last_tick
}
pub fn dirty(&self) -> bool {
self.dirty
}
pub fn clear_dirty(&mut self) {
self.dirty = false;
}
pub fn render_region(&self, id: &'static str) -> RenderRegion {
if self.is_hot() {
RenderRegion { id, rect: self.canvas_rect, target_fps: UNCAPPED_FPS, dirty: true }
} else {
RenderRegion { id, rect: self.canvas_rect, target_fps: 0, dirty: self.dirty }
}
}
pub fn tick(&mut self, dt: f32) -> LayoutTickResult {
let was_hot = self.is_hot();
self.apply_held_nav_keys(dt);
self.advance_camera_transition(dt);
let topo = self.graph.topology();
self.last_tick = match &self.filter {
Some(filter) => {
let filtered_edges: Vec<SimEdge> = topo
.edges
.iter()
.copied()
.filter(|e| filter.matches(&self.graph, e.from) && filter.matches(&self.graph, e.to))
.collect();
let filtered_topo =
SimTopology { node_count: topo.node_count, edges: &filtered_edges, degree: topo.degree, radii: topo.radii };
self.layout.tick(&filtered_topo, &mut self.particles, dt)
}
None => self.layout.tick(&topo, &mut self.particles, dt),
};
if was_hot || self.is_hot() {
self.dirty = true;
}
self.last_tick
}
pub fn tick_real_time(&mut self) -> LayoutTickResult {
let now = Instant::now();
let dt = match self.last_frame_at {
Some(prev) => now.duration_since(prev).as_secs_f32().min(0.1),
None => 1.0 / 60.0,
};
self.last_frame_at = Some(now);
self.tick(dt)
}
pub fn tick_view(&mut self, dt: f32) -> LayoutTickResult {
let was_hot = self.is_hot();
self.apply_held_nav_keys(dt);
self.advance_camera_transition(dt);
self.last_tick = LayoutTickResult {
alpha: self.last_tick.alpha,
max_displacement: 0.0,
settled: true,
};
if was_hot || self.is_hot() {
self.dirty = true;
}
self.last_tick
}
pub fn tick_view_real_time(&mut self) -> LayoutTickResult {
let now = Instant::now();
let dt = match self.last_frame_at {
Some(prev) => now.duration_since(prev).as_secs_f32().min(0.1),
None => 1.0 / 60.0,
};
self.last_frame_at = Some(now);
self.tick_view(dt)
}
pub fn reheat(&mut self, alpha: f32) {
self.layout.reheat(alpha);
self.dirty = true;
}
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
fn apply_held_nav_keys(&mut self, dt: f32) {
if self.held_nav_keys.is_empty() {
return;
}
let dt = dt.max(0.0) as f64;
let pan_step = self.interaction.key_pan_speed_px_per_s * dt;
let key_zoom_rate = self.interaction.key_zoom_rate_per_s;
let mut dx = 0.0;
let mut dy = 0.0;
let mut zoom_factor = 1.0;
for nav in &self.held_nav_keys {
match nav {
NavKey::PanUp => dy -= pan_step,
NavKey::PanDown => dy += pan_step,
NavKey::PanLeft => dx -= pan_step,
NavKey::PanRight => dx += pan_step,
NavKey::ZoomIn => zoom_factor *= 1.0 + key_zoom_rate * dt,
NavKey::ZoomOut => zoom_factor /= 1.0 + key_zoom_rate * dt,
}
}
self.camera.pan_x += dx;
self.camera.pan_y += dy;
if (zoom_factor - 1.0).abs() > f64::EPSILON {
let center = (self.canvas_rect.center_x(), self.canvas_rect.center_y());
self.camera.zoom_at(center, self.canvas_rect, zoom_factor);
}
self.dirty = true;
}
fn advance_camera_transition(&mut self, dt: f32) {
let Some(transition) = self.camera_transition.as_mut() else { return };
let (pan, zoom, finished) = transition.step(dt);
self.camera.pan_x = pan.0;
self.camera.pan_y = pan.1;
self.camera.zoom = zoom.clamp(crate::camera::ZOOM_MIN, crate::camera::ZOOM_MAX);
self.dirty = true;
if finished {
self.camera_transition = None;
}
}
fn compute_excluded_nodes(&self) -> HashSet<NodeIndex> {
let mut excluded: HashSet<NodeIndex> =
if self.clusters.any_collapsed() { self.clusters.hidden_nodes().collect() } else { HashSet::new() };
if let Some((root, depth)) = self.local_root {
let local_set = self.local_bfs_nodes(root, depth);
for (id, _) in self.graph.nodes() {
if !local_set.contains(&id) {
excluded.insert(id);
}
}
}
if let Some(filter) = &self.filter {
for (id, _) in self.graph.nodes() {
if !filter.matches(&self.graph, id) {
excluded.insert(id);
}
}
}
excluded
}
fn local_bfs_nodes(&self, root: NodeIndex, depth: u8) -> HashSet<NodeIndex> {
self.graph
.neighborhood_focus_keys_depth(root, depth)
.into_iter()
.filter(|k| k % 2 == 0)
.map(|k| NodeIndex((k >> 1) as u32))
.collect()
}
fn refresh_visible(&mut self) {
let culled = gr_render::cull_visible(&self.graph, &self.particles, &self.camera, self.canvas_rect, self.cull_margin_world);
let excluded = self.compute_excluded_nodes();
self.visible =
if excluded.is_empty() { culled } else { culled.into_iter().filter(|id| !excluded.contains(id)).collect() };
}
pub fn visible_nodes(&self) -> &[NodeIndex] {
&self.visible
}
pub fn draw(&mut self, render: &mut dyn RenderContext) {
let draw_started = std::time::Instant::now();
uzor::diagnostics::stage(
"graph_2d",
"draw_begin",
format_args!(
"nodes={} edges={} particles={}",
self.graph.node_count(),
self.graph.edge_count(),
self.particles.len(),
),
);
let visible_started = std::time::Instant::now();
self.refresh_visible();
uzor::diagnostics::stage(
"graph_2d",
"visible_end",
format_args!(
"visible={} duration_us={}",
self.visible.len(),
visible_started.elapsed().as_micros(),
),
);
let hidden_started = std::time::Instant::now();
let hidden = self.compute_excluded_nodes();
uzor::diagnostics::stage(
"graph_2d",
"hidden_end",
format_args!(
"hidden={} duration_us={}",
hidden.len(),
hidden_started.elapsed().as_micros(),
),
);
let forced_labels: HashSet<NodeIndex> = self.clusters.collapsed_clusters().map(|c| c.representative).collect();
let ctx = gr_render::DrawContext {
camera: &self.camera,
viewport: self.canvas_rect,
visible: &self.visible,
focus: &self.focus,
selection: &self.selection,
hovered: self.hovered,
hidden: &hidden,
label_density: self.label_density,
label_halo: &self.label_halo,
forced_labels: &forced_labels,
label_lod: &self.label_lod,
theme: &self.theme,
};
let edges_started = std::time::Instant::now();
let edges_drawn = gr_render::draw_edges(render, &self.graph, &self.particles, &ctx);
uzor::diagnostics::stage(
"graph_2d",
"edges_end",
format_args!(
"drawn={} duration_us={}",
edges_drawn,
edges_started.elapsed().as_micros(),
),
);
gr_render::draw_cluster_edges(render, &self.particles, &ctx, &self.clusters);
let nodes_started = std::time::Instant::now();
let node_stats = if self.node_labels_enabled {
gr_render::draw_nodes(render, &self.graph, &self.particles, &ctx)
} else {
gr_render::draw_nodes_without_labels(render, &self.graph, &self.particles, &ctx)
};
uzor::diagnostics::stage(
"graph_2d",
"nodes_end",
format_args!(
"drawn={} labels={} duration_us={}",
node_stats.nodes_drawn,
node_stats.labels_drawn,
nodes_started.elapsed().as_micros(),
),
);
self.labels_drawn_last_frame = node_stats.labels_drawn;
gr_render::draw_cluster_supernodes(render, &self.graph, &self.particles, &ctx, &self.clusters);
if let Some(rect) = self.box_select_rect() {
gr_render::draw_box_select_rect(render, rect, &self.theme);
}
if self.hover_card {
if let Some(id) = self.hovered {
if let Some(facts) = self.node_facts(id) {
let anchor = self.camera.world_to_screen((facts.position.0 as f64, facts.position.1 as f64), self.canvas_rect);
let info = gr_render::HoverCardInfo {
label: facts.label,
category: facts.category,
degree: facts.degree,
pinned: facts.pinned,
};
gr_render::draw_hover_card(render, anchor, &info, self.canvas_rect, &self.theme.hover_card);
}
}
}
uzor::diagnostics::stage(
"graph_2d",
"draw_end",
format_args!("duration_us={}", draw_started.elapsed().as_micros()),
);
}
pub fn fit_view(&mut self) {
let points: Vec<(f64, f64)> = self.particles.iter().map(|p| (p.x as f64, p.y as f64)).collect();
if let Some(aabb) = Aabb::from_points(&points) {
self.camera.fit_view(aabb, self.canvas_rect);
}
self.dirty = true;
}
pub fn zoom_to_fit(&mut self, duration_ms: f64, padding_px: f64) {
let excluded = self.compute_excluded_nodes();
let points: Vec<(f64, f64)> = self
.graph
.nodes()
.filter(|(id, _)| !excluded.contains(id))
.filter_map(|(id, _)| self.particles.get(id.index()).map(|p| (p.x as f64, p.y as f64)))
.collect();
let Some(aabb) = Aabb::from_points(&points) else { return };
let (target_pan, target_zoom) = crate::camera::fit_target(aabb, self.canvas_rect, padding_px);
self.start_camera_transition(target_pan, target_zoom, duration_ms);
}
pub fn zoom_to_node(&mut self, node: NodeIndex, duration_ms: f64, target_zoom: Option<f64>) -> bool {
let Some(p) = self.particles.get(node.index()) else { return false };
let zoom = target_zoom.unwrap_or(self.camera.zoom).clamp(crate::camera::ZOOM_MIN, crate::camera::ZOOM_MAX);
let target_pan =
(self.canvas_rect.width / 2.0 - p.x as f64 * zoom, self.canvas_rect.height / 2.0 - p.y as f64 * zoom);
self.start_camera_transition(target_pan, zoom, duration_ms);
true
}
fn start_camera_transition(&mut self, target_pan: (f64, f64), target_zoom: f64, duration_ms: f64) {
self.camera_transition = Some(CameraTransition::new(self.camera, target_pan, target_zoom, duration_ms));
self.dirty = true;
}
pub fn camera_transitioning(&self) -> bool {
self.camera_transition.is_some()
}
pub fn set_local_root(&mut self, root: Option<NodeIndex>, depth: Option<u8>) {
self.local_root = root.map(|node| (node, depth.unwrap_or(DEFAULT_LOCAL_DEPTH)));
self.mark_dirty();
self.zoom_to_fit(DEFAULT_TRANSITION_MS, DEFAULT_FIT_PADDING_PX);
}
pub fn local_root(&self) -> Option<(NodeIndex, u8)> {
self.local_root
}
pub fn set_filter(&mut self, filter: Option<FilterSpec>) {
self.filter = filter;
self.reheat(self.interaction.drag_reheat_alpha);
}
pub fn filter(&self) -> Option<&FilterSpec> {
self.filter.as_ref()
}
pub fn select(&mut self, node: NodeIndex) {
self.selected = Some(node);
self.selection = std::iter::once(node).collect();
self.refresh_focus();
self.dirty = true;
}
pub fn clear_selection(&mut self) {
self.selected = None;
self.selection.clear();
self.refresh_focus();
self.dirty = true;
}
pub fn apply_selection(&mut self, nodes: impl IntoIterator<Item = NodeIndex>, mode: SelectMode) {
match mode {
SelectMode::Replace => self.selection = nodes.into_iter().collect(),
SelectMode::Union => self.selection.extend(nodes),
SelectMode::Diff => {
for node in nodes {
if !self.selection.remove(&node) {
self.selection.insert(node);
}
}
}
}
self.refresh_focus();
self.dirty = true;
}
pub fn box_select(&mut self, corner_a: (f64, f64), corner_b: (f64, f64), mode: SelectMode) {
let rect = normalized_rect(corner_a, corner_b);
let nodes = self.nodes_in_screen_rect(rect);
self.apply_selection(nodes, mode);
}
fn nodes_in_screen_rect(&self, rect: Rect) -> Vec<NodeIndex> {
self.visible
.iter()
.copied()
.filter(|&id| match self.particles.get(id.index()) {
Some(p) => {
let (sx, sy) = self.camera.world_to_screen((p.x as f64, p.y as f64), self.canvas_rect);
rect.contains(sx, sy)
}
None => false,
})
.collect()
}
pub fn box_select_rect(&self) -> Option<Rect> {
match self.mode {
PointerMode::BoxSelecting { origin, current, .. } => Some(normalized_rect(origin, current)),
_ => None,
}
}
pub fn modifiers(&self) -> ModifierKeys {
self.modifiers
}
pub fn collapse_selection(&mut self) -> Option<GroupId> {
if self.selection.is_empty() {
return None;
}
let members: Vec<NodeIndex> = self.selection.iter().copied().collect();
let id = self.define_cluster(members)?;
self.collapse_cluster(id);
Some(id)
}
pub fn selection_collapsed_group(&self) -> Option<GroupId> {
if self.selection.is_empty() {
return None;
}
self.clusters.iter().find_map(|(id, cluster)| {
if !cluster.is_collapsed() {
return None;
}
let members: BTreeSet<NodeIndex> = cluster.members.iter().copied().collect();
(members == self.selection).then_some(id)
})
}
pub fn pin_selection(&mut self) {
let nodes: Vec<NodeIndex> = self.selection.iter().copied().collect();
for node in nodes {
self.pin_node(node);
}
}
pub fn unpin_selection(&mut self) {
let nodes: Vec<NodeIndex> = self.selection.iter().copied().collect();
for node in nodes {
self.unpin_node(node);
}
}
pub fn hover_depth(&self) -> u8 {
self.hover_depth
}
pub fn set_hover_depth(&mut self, depth: u8) {
self.hover_depth = depth;
if self.selection.is_empty() {
self.refresh_focus();
self.dirty = true;
}
}
pub fn hover_card_enabled(&self) -> bool {
self.hover_card
}
pub fn set_hover_card_enabled(&mut self, enabled: bool) {
self.hover_card = enabled;
self.dirty = true;
}
pub fn label_density(&self) -> f64 {
self.label_density
}
pub fn set_label_density(&mut self, density: f64) {
self.label_density = density.max(0.0);
self.dirty = true;
}
pub fn label_halo(&self) -> &str {
&self.label_halo
}
pub fn set_label_halo(&mut self, color: impl Into<String>) {
self.label_halo = color.into();
self.dirty = true;
}
pub fn node_labels_enabled(&self) -> bool {
self.node_labels_enabled
}
pub fn set_node_labels_enabled(&mut self, enabled: bool) {
if self.node_labels_enabled != enabled {
self.node_labels_enabled = enabled;
self.dirty = true;
}
}
pub fn theme(&self) -> &GraphTheme {
&self.theme
}
pub fn set_theme(&mut self, theme: GraphTheme) {
self.theme = theme;
self.dirty = true;
}
pub fn label_lod(&self) -> &label_grid::LabelLodConfig {
&self.label_lod
}
pub fn set_label_lod(&mut self, lod: label_grid::LabelLodConfig) {
self.label_lod = lod;
self.dirty = true;
}
pub fn interaction_config(&self) -> &GraphInteractionConfig {
&self.interaction
}
pub fn set_interaction_config(&mut self, config: GraphInteractionConfig) {
self.interaction = config;
}
pub fn cull_margin_world(&self) -> f64 {
self.cull_margin_world
}
pub fn set_cull_margin_world(&mut self, margin: f64) {
self.cull_margin_world = margin.max(0.0);
self.dirty = true;
}
pub fn labels_drawn_last_frame(&self) -> usize {
self.labels_drawn_last_frame
}
pub fn hovered(&self) -> Option<NodeIndex> {
self.hovered
}
pub(crate) fn set_hovered(&mut self, hit: Option<NodeIndex>) {
if hit == self.hovered {
return;
}
self.hovered = hit;
if self.selection.is_empty() {
self.refresh_focus();
}
self.dirty = true;
}
fn refresh_focus(&mut self) {
if !self.selection.is_empty() {
let mut keys: Vec<u64> = Vec::new();
for &node in &self.selection {
keys.extend(self.graph.neighborhood_focus_keys(node));
}
self.focus.select_many(keys);
return;
}
match self.hovered {
Some(node) => self.focus.select_many(self.graph.neighborhood_focus_keys_depth(node, self.hover_depth)),
None => {
self.focus.clear_selection();
}
}
}
pub fn define_cluster(&mut self, members: Vec<NodeIndex>) -> Option<GroupId> {
self.clusters.define(&self.graph, members)
}
pub fn is_collapsed(&self, id: GroupId) -> bool {
self.clusters.is_collapsed(id)
}
pub fn collapse_cluster(&mut self, id: GroupId) -> bool {
let ok = self.clusters.collapse(id, &mut self.graph, &mut self.particles);
if ok {
self.dirty = true;
}
ok
}
pub fn expand_cluster(&mut self, id: GroupId) -> bool {
let ok = self.clusters.expand(id, &mut self.graph, &mut self.particles);
if ok {
self.reheat(self.interaction.drag_reheat_alpha);
}
ok
}
pub fn is_pinned(&self, node: NodeIndex) -> bool {
self.pinned.get(node.index()).copied().unwrap_or(false)
}
pub fn drag_end_policy(&self) -> DragEndPolicy {
self.drag_end_policy
}
pub fn set_drag_end_policy(&mut self, policy: DragEndPolicy) {
self.drag_end_policy = policy;
}
pub fn pointer_bindings(&self) -> GraphPointerBindings {
self.pointer_bindings
}
pub fn set_pointer_bindings(&mut self, bindings: GraphPointerBindings) {
self.pointer_bindings = bindings;
}
pub fn pin_node(&mut self, node: NodeIndex) {
if let Some(p) = self.particles.get_mut(node.index()) {
p.pin(p.x, p.y);
}
if let Some(flag) = self.pinned.get_mut(node.index()) {
*flag = true;
}
self.dirty = true;
}
pub fn unpin_node(&mut self, node: NodeIndex) {
if let Some(p) = self.particles.get_mut(node.index()) {
p.unpin();
}
if let Some(flag) = self.pinned.get_mut(node.index()) {
*flag = false;
}
self.reheat(self.interaction.drag_reheat_alpha);
}
pub fn force_params(&self) -> Option<ForceParams>
where
L: 'static,
{
let layout_any: &dyn std::any::Any = &self.layout;
if let Some(force) = layout_any.downcast_ref::<ForceDirectedLayout>() {
return Some(*force.params());
}
if let Some(mode) = layout_any.downcast_ref::<GraphLayoutMode>() {
return Some(*mode.force_params());
}
None
}
pub fn set_force_params(&mut self, params: ForceParams) -> bool
where
L: 'static,
{
let applied = {
let layout_any: &mut dyn std::any::Any = &mut self.layout;
if let Some(force) = layout_any.downcast_mut::<ForceDirectedLayout>() {
force.set_params(params);
true
} else if let Some(mode) = layout_any.downcast_mut::<GraphLayoutMode>() {
mode.set_force_params(params);
true
} else {
false
}
};
if applied {
self.reheat(self.interaction.drag_reheat_alpha);
}
applied
}
pub fn node_facts(&self, node: NodeIndex) -> Option<NodeFacts<'_>> {
let n = self.graph.get_node(node)?;
let p = self.particles.get(node.index())?;
Some(NodeFacts {
index: node,
label: &n.label,
category: &n.category,
degree: self.graph.degree(node),
position: (p.x, p.y),
pinned: self.is_pinned(node),
})
}
pub fn selected_facts(&self) -> Option<NodeFacts<'_>> {
self.selected.and_then(|id| self.node_facts(id))
}
pub fn on_event(&mut self, event: &PlatformEvent) -> bool {
match event {
PlatformEvent::PointerDown { x, y, button } => self.on_pointer_down(*x, *y, *button),
PlatformEvent::PointerMoved { x, y } => self.on_pointer_moved(*x, *y),
PlatformEvent::PointerUp { x, y, button } => self.on_pointer_up(*x, *y, *button),
PlatformEvent::Scroll { dy, .. } => self.on_scroll(*dy),
PlatformEvent::ModifiersChanged { modifiers } => {
self.modifiers = *modifiers;
true
}
PlatformEvent::KeyDown { key, modifiers } => {
self.modifiers = *modifiers;
self.on_key_down(*key)
}
PlatformEvent::KeyUp { key, modifiers } => {
self.modifiers = *modifiers;
self.on_key_up(*key)
}
PlatformEvent::PointerLeft => self.cancel_pointer_gesture(),
PlatformEvent::WindowFocused(false) => self.cancel_pointer_gesture(),
_ => false,
}
}
fn cancel_pointer_gesture(&mut self) -> bool {
let button = match self.mode {
PointerMode::Idle => return false,
PointerMode::Selecting { button, .. }
| PointerMode::PanningCamera { button, .. }
| PointerMode::DraggingNode { button }
| PointerMode::BoxSelecting { button, .. } => button,
};
let (x, y) = self.last_pointer_screen;
self.on_pointer_up(x, y, button)
}
fn on_key_down(&mut self, key: KeyCode) -> bool {
let Some(nav) = nav_key_for(key) else { return false };
self.camera_transition = None;
self.held_nav_keys.insert(nav);
self.dirty = true;
true
}
fn on_key_up(&mut self, key: KeyCode) -> bool {
let Some(nav) = nav_key_for(key) else { return false };
self.held_nav_keys.remove(&nav);
true
}
fn apply_drag_shift(&mut self, screen: (f64, f64)) {
let world = self.camera.screen_to_world(screen, self.canvas_rect);
for member in &self.drag_group {
if let Some(p) = self.particles.get_mut(member.node.index()) {
p.fx = Some(world.0 as f32 + member.offset_from_anchor.0);
p.fy = Some(world.1 as f32 + member.offset_from_anchor.1);
}
}
}
fn on_pointer_down(&mut self, x: f64, y: f64, button: MouseButton) -> bool {
if !self.canvas_rect.contains(x, y) || !matches!(self.mode, PointerMode::Idle) {
return false;
}
let can_select = self.pointer_bindings.select_button == Some(button);
let can_pan = self.pointer_bindings.pan_button == Some(button);
let can_drag_node = self.pointer_bindings.drag_node_button == Some(button);
if !can_select && !can_pan && !can_drag_node {
return false;
}
self.last_pointer_screen = (x, y);
if can_pan && !can_select && !can_drag_node {
self.camera_transition = None;
self.mode = PointerMode::PanningCamera {
button,
last: (x, y),
total: 0.0,
select_on_click: false,
};
self.dirty = true;
return true;
}
if can_select {
if let Some(mode) = box_select_mode_for(self.modifiers) {
self.mode = PointerMode::BoxSelecting {
button,
origin: (x, y),
current: (x, y),
mode,
};
self.dirty = true;
return true;
}
}
if let Some(hit) = pick::nearest_node(&self.graph, &self.particles, &self.camera, self.canvas_rect, (x, y), &self.visible) {
if !can_drag_node {
if can_select {
self.mode = PointerMode::Selecting { button, last: (x, y), total: 0.0 };
self.dirty = true;
return true;
}
if can_pan {
self.camera_transition = None;
self.mode = PointerMode::PanningCamera {
button,
last: (x, y),
total: 0.0,
select_on_click: false,
};
self.dirty = true;
return true;
}
return false;
}
let was_selected = self.selection.contains(&hit);
self.drag_was_group = was_selected;
let group: Vec<NodeIndex> = if was_selected { self.selection.iter().copied().collect() } else { vec![hit] };
let anchor_pos = self.particles.get(hit.index()).map(|p| (p.x, p.y)).unwrap_or((0.0, 0.0));
self.drag_group = group
.into_iter()
.map(|node| {
let pos = self.particles.get(node.index()).map(|p| (p.x, p.y)).unwrap_or((0.0, 0.0));
DragMember {
node,
offset_from_anchor: (pos.0 - anchor_pos.0, pos.1 - anchor_pos.1),
prior_pinned: self.is_pinned(node),
}
})
.collect();
self.mode = PointerMode::DraggingNode { button };
self.drag.start(hit);
self.apply_drag_shift((x, y));
self.layout.set_alpha_target(self.interaction.drag_alpha_target);
self.reheat(self.interaction.drag_alpha_target);
} else if can_pan {
self.camera_transition = None;
self.mode = PointerMode::PanningCamera {
button,
last: (x, y),
total: 0.0,
select_on_click: can_select,
};
} else if can_select {
self.mode = PointerMode::Selecting { button, last: (x, y), total: 0.0 };
} else {
return false;
}
self.dirty = true;
true
}
fn on_pointer_moved(&mut self, x: f64, y: f64) -> bool {
self.last_pointer_screen = (x, y);
let mut handled = false;
match self.mode {
PointerMode::Selecting { button, last, total } => {
let dx = x - last.0;
let dy = y - last.1;
self.mode = PointerMode::Selecting {
button,
last: (x, y),
total: total + (dx * dx + dy * dy).sqrt(),
};
handled = true;
}
PointerMode::DraggingNode { .. } => {
if self.drag.dragging_node().is_some() {
self.apply_drag_shift((x, y));
}
handled = true;
}
PointerMode::PanningCamera { button, last, total, select_on_click } => {
let dx = x - last.0;
let dy = y - last.1;
self.camera.pan_x += dx;
self.camera.pan_y += dy;
self.mode = PointerMode::PanningCamera {
button,
last: (x, y),
total: total + (dx * dx + dy * dy).sqrt(),
select_on_click,
};
handled = true;
}
PointerMode::BoxSelecting { button, origin, mode, .. } => {
self.mode = PointerMode::BoxSelecting { button, origin, current: (x, y), mode };
handled = true;
}
PointerMode::Idle => {}
}
if self.canvas_rect.contains(x, y) {
let moved_enough = match self.last_hover_pick_screen {
Some((lx, ly)) => {
let dx = x - lx;
let dy = y - ly;
(dx * dx + dy * dy).sqrt() >= self.interaction.hover_pick_min_move_px
}
None => true,
};
if moved_enough {
self.last_hover_pick_screen = Some((x, y));
let hit = pick::nearest_node(&self.graph, &self.particles, &self.camera, self.canvas_rect, (x, y), &self.visible);
self.set_hovered(hit);
}
handled = true;
} else if self.hovered.is_some() {
self.set_hovered(None);
self.last_hover_pick_screen = None;
}
if handled {
self.dirty = true;
}
handled
}
fn activate_selection_at(&mut self, x: f64, y: f64) {
match pick::nearest_node(
&self.graph,
&self.particles,
&self.camera,
self.canvas_rect,
(x, y),
&self.visible,
) {
Some(hit) if self.clusters.cluster_of(hit).is_some_and(|id| self.clusters.is_collapsed(id)) => {
if let Some(id) = self.clusters.cluster_of(hit) {
self.expand_cluster(id);
}
}
Some(hit) => self.select(hit),
None => self.clear_selection(),
}
}
fn on_pointer_up(&mut self, x: f64, y: f64, button: MouseButton) -> bool {
let active_button = match self.mode {
PointerMode::Idle => return false,
PointerMode::Selecting { button, .. }
| PointerMode::PanningCamera { button, .. }
| PointerMode::DraggingNode { button }
| PointerMode::BoxSelecting { button, .. } => button,
};
if active_button != button {
return false;
}
match self.mode {
PointerMode::Selecting { total, .. } => {
self.mode = PointerMode::Idle;
if total < self.interaction.click_drag_threshold_px && self.canvas_rect.contains(x, y) {
self.activate_selection_at(x, y);
}
self.dirty = true;
true
}
PointerMode::DraggingNode { .. } => {
if let Some(anchor) = self.drag.stop() {
let policy = self.drag_end_policy;
let was_group = self.drag_was_group;
for member in std::mem::take(&mut self.drag_group) {
let stay_pinned = match policy {
DragEndPolicy::Sticky => true,
DragEndPolicy::RestorePrior => member.prior_pinned,
};
if stay_pinned {
if let Some(flag) = self.pinned.get_mut(member.node.index()) {
*flag = true;
}
} else {
if let Some(p) = self.particles.get_mut(member.node.index()) {
p.unpin();
}
if let Some(flag) = self.pinned.get_mut(member.node.index()) {
*flag = false;
}
}
}
self.layout.set_alpha_target(0.0);
if was_group {
self.selected = Some(anchor);
self.refresh_focus();
} else {
self.select(anchor);
}
}
self.mode = PointerMode::Idle;
self.dirty = true;
true
}
PointerMode::PanningCamera { total, select_on_click, .. } => {
self.mode = PointerMode::Idle;
if select_on_click
&& total < self.interaction.click_drag_threshold_px
&& self.canvas_rect.contains(x, y)
{
match pick::nearest_node(&self.graph, &self.particles, &self.camera, self.canvas_rect, (x, y), &self.visible) {
Some(hit) if self.clusters.cluster_of(hit).is_some_and(|id| self.clusters.is_collapsed(id)) => {
if let Some(id) = self.clusters.cluster_of(hit) {
self.expand_cluster(id);
}
}
Some(hit) => self.select(hit),
None => self.clear_selection(),
}
}
self.dirty = true;
true
}
PointerMode::BoxSelecting { origin, mode, .. } => {
self.mode = PointerMode::Idle;
self.box_select(origin, (x, y), mode);
self.dirty = true;
true
}
PointerMode::Idle => false,
}
}
fn on_scroll(&mut self, dy: f64) -> bool {
if !self.canvas_rect.contains(self.last_pointer_screen.0, self.last_pointer_screen.1) {
return false;
}
self.camera_transition = None;
let factor = (1.0 + dy * self.interaction.zoom_sensitivity)
.clamp(self.interaction.scroll_zoom_factor_min, self.interaction.scroll_zoom_factor_max);
self.camera.zoom_at(self.last_pointer_screen, self.canvas_rect, factor);
self.dirty = true;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::Graph;
type TestEngine = GraphEngine<(), (), ForceDirectedLayout>;
fn chain_graph() -> (Graph<(), ()>, NodeIndex, NodeIndex, NodeIndex) {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
let c = graph.push_node((), "c", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(b, c, 1.0, ());
(graph, a, b, c)
}
#[test]
fn select_populates_the_repointed_figures_focus_set_with_neighborhood_keys() {
let (graph, a, b, c) = chain_graph();
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.select(a);
assert!(engine.focus.is_active());
assert!(engine.focus.is_selected(u64::from(a)));
assert!(engine.focus.is_selected(u64::from(b)));
assert!(!engine.focus.is_selected(u64::from(c)), "c is 2 hops away — outside the 1-hop neighborhood");
engine.clear_selection();
assert!(!engine.focus.is_active());
}
#[test]
fn reselecting_a_different_node_replaces_the_whole_focus_set() {
let (graph, a, _b, c) = chain_graph();
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.select(a);
assert!(engine.focus.is_selected(u64::from(a)));
engine.select(c);
assert!(!engine.focus.is_selected(u64::from(a)), "stale selection from the previous select() must not leak");
assert!(engine.focus.is_selected(u64::from(c)));
}
fn empty_engine_with_canvas(rect: Rect) -> TestEngine {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
engine.set_canvas_rect(rect);
engine
}
#[test]
fn default_pointer_bindings_preserve_legacy_left_drag_pan() {
let (mut engine, a, _b) = two_node_chain_engine();
assert_eq!(engine.pointer_bindings(), GraphPointerBindings::default());
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 130.0, y: 120.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 130.0, y: 120.0, button: MouseButton::Left });
assert_eq!(engine.selected, Some(a));
assert!(engine.is_pinned(a));
let pan_before = (engine.camera.pan_x, engine.camera.pan_y);
engine.on_event(&PlatformEvent::PointerDown { x: 500.0, y: 400.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 525.0, y: 410.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 525.0, y: 410.0, button: MouseButton::Left });
assert_eq!(
(engine.camera.pan_x, engine.camera.pan_y),
(pan_before.0 + 25.0, pan_before.1 + 10.0),
);
}
#[test]
fn flow_bindings_use_middle_pan_and_left_select_without_node_drag() {
let (mut engine, a, _b) = two_node_chain_engine();
let bindings = GraphPointerBindings {
select_button: Some(MouseButton::Left),
pan_button: Some(MouseButton::Middle),
drag_node_button: None,
};
engine.set_pointer_bindings(bindings);
assert_eq!(engine.pointer_bindings(), bindings);
let node_before = engine.particles[a.index()];
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerUp { x: 100.0, y: 100.0, button: MouseButton::Left });
assert_eq!(engine.selected, Some(a), "left click must still select");
assert!(!engine.is_pinned(a), "selection-only binding must not pin or drag the node");
assert_eq!((engine.particles[a.index()].x, engine.particles[a.index()].y), (node_before.x, node_before.y));
let pan_before = (engine.camera.pan_x, engine.camera.pan_y);
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Middle });
engine.on_event(&PlatformEvent::PointerMoved { x: 140.0, y: 115.0 });
assert!(matches!(engine.mode, PointerMode::PanningCamera { button: MouseButton::Middle, .. }));
assert_eq!(
(engine.camera.pan_x, engine.camera.pan_y),
(pan_before.0 + 40.0, pan_before.1 + 15.0),
"middle drag must pan even when it starts over a node",
);
assert!(!engine.on_event(&PlatformEvent::PointerUp {
x: 140.0,
y: 115.0,
button: MouseButton::Left,
}));
assert!(matches!(engine.mode, PointerMode::PanningCamera { button: MouseButton::Middle, .. }));
assert!(engine.on_event(&PlatformEvent::PointerUp {
x: 140.0,
y: 115.0,
button: MouseButton::Middle,
}));
assert_eq!(engine.selected, Some(a), "middle pan must not alter selection");
assert!(!engine.is_pinned(a), "middle pan over a node must never start node drag");
}
#[test]
fn focus_loss_cancels_middle_pan() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.set_pointer_bindings(GraphPointerBindings {
select_button: Some(MouseButton::Left),
pan_button: Some(MouseButton::Middle),
drag_node_button: None,
});
engine.on_event(&PlatformEvent::PointerDown { x: 300.0, y: 300.0, button: MouseButton::Middle });
engine.on_event(&PlatformEvent::PointerMoved { x: 340.0, y: 320.0 });
assert!(matches!(engine.mode, PointerMode::PanningCamera { button: MouseButton::Middle, .. }));
assert!(engine.on_event(&PlatformEvent::WindowFocused(false)));
assert!(matches!(engine.mode, PointerMode::Idle));
let pan_after_focus_loss = (engine.camera.pan_x, engine.camera.pan_y);
engine.on_event(&PlatformEvent::PointerMoved { x: 380.0, y: 350.0 });
assert_eq!((engine.camera.pan_x, engine.camera.pan_y), pan_after_focus_loss);
assert!(!engine.on_event(&PlatformEvent::PointerUp {
x: 380.0,
y: 350.0,
button: MouseButton::Middle,
}));
}
#[test]
fn background_drag_sequence_pans_camera_by_exact_per_step_delta() {
let canvas = Rect::new(0.0, 0.0, 800.0, 600.0);
let mut engine = empty_engine_with_canvas(canvas);
let grab = (300.0, 300.0);
assert!(engine.on_event(&PlatformEvent::PointerDown {
x: grab.0,
y: grab.1,
button: MouseButton::Left,
}));
assert_eq!((engine.camera.pan_x, engine.camera.pan_y), (0.0, 0.0),
"PointerDown alone must not move the camera");
let moves = [(310.0, 300.0), (325.0, 305.0), (325.0, 320.0)];
let mut last = grab;
for &(mx, my) in &moves {
let before = (engine.camera.pan_x, engine.camera.pan_y);
engine.on_event(&PlatformEvent::PointerMoved { x: mx, y: my });
let (expected_dx, expected_dy) = (mx - last.0, my - last.1);
assert!((engine.camera.pan_x - (before.0 + expected_dx)).abs() < 1e-9);
assert!((engine.camera.pan_y - (before.1 + expected_dy)).abs() < 1e-9);
last = (mx, my);
}
engine.on_event(&PlatformEvent::PointerUp { x: last.0, y: last.1, button: MouseButton::Left });
assert!((engine.camera.pan_x - 25.0).abs() < 1e-9);
assert!((engine.camera.pan_y - 20.0).abs() < 1e-9);
}
#[test]
fn regression_stale_zero_down_would_teleport_vs_fixed_pipeline_pans_by_delta() {
let canvas = Rect::new(0.0, 0.0, 800.0, 600.0);
let grab = (300.0, 300.0);
let mut fixed = empty_engine_with_canvas(canvas);
fixed.on_event(&PlatformEvent::PointerDown { x: grab.0, y: grab.1, button: MouseButton::Left });
fixed.on_event(&PlatformEvent::PointerMoved { x: grab.0 + 10.0, y: grab.1 });
assert!((fixed.camera.pan_x - 10.0).abs() < 1e-9,
"a +10px move after a correctly-stamped Down pans by exactly +10px");
assert!((fixed.camera.pan_y - 0.0).abs() < 1e-9);
let mut buggy = empty_engine_with_canvas(canvas);
buggy.on_event(&PlatformEvent::PointerDown { x: 0.0, y: 0.0, button: MouseButton::Left });
buggy.on_event(&PlatformEvent::PointerMoved { x: grab.0 + 10.0, y: grab.1 });
assert!(buggy.camera.pan_x > 100.0,
"stale (0,0) Down turns the same +10px physical move into a \
camera teleport of ~grab_x pixels — this is the P0 bug: {} \
(fixed pipeline pans by exactly 10.0)", buggy.camera.pan_x);
}
fn two_node_chain_engine() -> (TestEngine, NodeIndex, NodeIndex) {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(100.0, 100.0), (300.0, 100.0)]);
engine.refresh_visible();
(engine, a, b)
}
#[test]
fn sticky_drag_is_the_default_and_pins_the_node_at_the_release_position() {
let (mut engine, a, _b) = two_node_chain_engine();
assert_eq!(engine.drag_end_policy(), DragEndPolicy::Sticky, "Sticky must be the default policy");
assert!(!engine.is_pinned(a));
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 250.0, y: 220.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 250.0, y: 220.0, button: MouseButton::Left });
engine.tick(1.0 / 60.0);
assert!(engine.is_pinned(a), "a node dragged and released must be reported pinned under Sticky");
let released = engine.particles[a.index()];
assert!((released.x - 250.0).abs() < 1e-6);
assert!((released.y - 220.0).abs() < 1e-6);
for _ in 0..120 {
engine.tick(1.0 / 60.0);
}
assert_eq!(engine.particles[a.index()].x, released.x);
assert_eq!(engine.particles[a.index()].y, released.y);
}
#[test]
fn restore_prior_policy_unfixes_a_previously_free_node_but_keeps_a_pre_pinned_one_pinned() {
let (mut engine, a, b) = two_node_chain_engine();
engine.set_drag_end_policy(DragEndPolicy::RestorePrior);
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 250.0, y: 220.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 250.0, y: 220.0, button: MouseButton::Left });
assert!(!engine.is_pinned(a), "a node that was free before the drag must NOT stay pinned under RestorePrior");
engine.pin_node(b);
engine.on_event(&PlatformEvent::PointerDown { x: 300.0, y: 100.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 400.0, y: 150.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 400.0, y: 150.0, button: MouseButton::Left });
engine.tick(1.0 / 60.0);
assert!(engine.is_pinned(b), "a node pinned before the drag must stay pinned under RestorePrior");
assert!((engine.particles[b.index()].x - 400.0).abs() < 1e-6);
assert!((engine.particles[b.index()].y - 150.0).abs() < 1e-6);
}
#[test]
fn drag_holds_alpha_near_the_sustained_target_and_decays_after_release() {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
let canvas = Rect::new(-400.0, -300.0, 800.0, 600.0);
engine.set_canvas_rect(canvas);
engine.seed_positions(&[(100.0, 100.0), (300.0, 100.0)]);
for _ in 0..600 {
engine.tick(1.0 / 60.0);
}
assert!(!engine.is_hot(), "fixture must settle before the drag starts");
engine.refresh_visible();
let click = {
let p = engine.particles[a.index()];
engine.camera.world_to_screen((p.x as f64, p.y as f64), canvas)
};
engine.on_event(&PlatformEvent::PointerDown { x: click.0, y: click.1, button: MouseButton::Left });
let mut min_alpha = f32::MAX;
let mut max_alpha = f32::MIN;
for i in 0..90 {
if i % 10 == 0 {
engine.on_event(&PlatformEvent::PointerMoved { x: click.0 + i as f64, y: click.1 });
}
let r = engine.tick(1.0 / 60.0);
min_alpha = min_alpha.min(r.alpha);
max_alpha = max_alpha.max(r.alpha);
}
assert!(
(min_alpha - DRAG_ALPHA_TARGET).abs() < 0.01 && (max_alpha - DRAG_ALPHA_TARGET).abs() < 0.01,
"alpha must hold near the sustained drag target for the whole gesture: min {min_alpha} max {max_alpha} (target {DRAG_ALPHA_TARGET})"
);
engine.on_event(&PlatformEvent::PointerUp { x: click.0 + 80.0, y: click.1, button: MouseButton::Left });
let post_release = engine.tick(1.0 / 60.0);
assert!(
post_release.alpha < DRAG_ALPHA_TARGET - 1e-4,
"alpha must start decaying immediately after drag-end clears alpha_target: {}",
post_release.alpha
);
for _ in 0..600 {
engine.tick(1.0 / 60.0);
}
assert!(!engine.is_hot(), "alpha must decay all the way back down once alpha_target is cleared");
}
fn chain4_engine_on_a_line() -> (TestEngine, [NodeIndex; 4]) {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
let c = graph.push_node((), "c", "x", 4.0);
let d = graph.push_node((), "d", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(b, c, 1.0, ());
graph.push_edge(c, d, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0)]);
engine.refresh_visible();
(engine, [a, b, c, d])
}
#[test]
fn pointer_moved_over_a_node_hovers_it_and_moving_to_empty_space_clears_it() {
let (mut engine, [a, b, _c, _d]) = chain4_engine_on_a_line();
assert!(engine.hovered().is_none());
assert!(!engine.focus.is_active());
engine.on_event(&PlatformEvent::PointerMoved { x: 100.0, y: 0.0 }); assert_eq!(engine.hovered(), Some(b));
assert!(engine.focus.is_active());
assert!(engine.focus.is_selected(u64::from(b)));
assert!(engine.focus.is_selected(u64::from(a)), "b's depth-1 neighbor a must be highlighted too");
engine.on_event(&PlatformEvent::PointerMoved { x: 700.0, y: 500.0 }); assert!(engine.hovered().is_none());
assert!(!engine.focus.is_active(), "moving off every node must clear the highlight entirely");
}
#[test]
fn hover_neighborhood_highlights_exactly_the_hovered_node_and_its_depth_one_adjacency() {
let (mut engine, [a, b, c, d]) = chain4_engine_on_a_line();
engine.on_event(&PlatformEvent::PointerMoved { x: 100.0, y: 0.0 }); assert_eq!(engine.hovered(), Some(b));
assert!(engine.focus.is_selected(u64::from(a)));
assert!(engine.focus.is_selected(u64::from(b)));
assert!(engine.focus.is_selected(u64::from(c)));
assert!(!engine.focus.is_selected(u64::from(d)), "d is 2 hops from b — outside a depth-1 hover neighborhood");
}
#[test]
fn hover_depth_zero_highlights_only_the_hovered_node_itself() {
let (mut engine, [a, b, c, _d]) = chain4_engine_on_a_line();
engine.set_hover_depth(0);
assert_eq!(engine.hover_depth(), 0);
engine.on_event(&PlatformEvent::PointerMoved { x: 100.0, y: 0.0 }); assert!(engine.focus.is_selected(u64::from(b)));
assert!(!engine.focus.is_selected(u64::from(a)));
assert!(!engine.focus.is_selected(u64::from(c)));
}
#[test]
fn click_selection_takes_precedence_over_a_concurrent_hover_and_resumes_on_clear() {
let (mut engine, [_a, b, _c, d]) = chain4_engine_on_a_line();
engine.select(d); assert!(engine.focus.is_selected(u64::from(d)));
engine.on_event(&PlatformEvent::PointerMoved { x: 100.0, y: 0.0 }); assert_eq!(engine.hovered(), Some(b));
assert!(engine.focus.is_selected(u64::from(d)), "click-selection must win over a concurrent hover");
assert!(!engine.focus.is_selected(u64::from(b)), "hover must not override an active click-selection");
engine.clear_selection();
assert!(engine.focus.is_selected(u64::from(b)), "clearing the selection must resume hover-driven focus for the node still under the cursor");
assert!(!engine.focus.is_selected(u64::from(d)));
}
#[test]
fn hover_pick_skips_recompute_for_sub_threshold_pointer_moves() {
let (mut engine, [_a, b, _c, _d]) = chain4_engine_on_a_line();
engine.on_event(&PlatformEvent::PointerMoved { x: 109.9, y: 0.0 }); assert_eq!(engine.hovered(), Some(b));
engine.on_event(&PlatformEvent::PointerMoved { x: 110.9, y: 0.0 });
assert_eq!(engine.hovered(), Some(b), "a <2px move must not trigger a re-pick — stale hover kept");
engine.on_event(&PlatformEvent::PointerMoved { x: 113.0, y: 0.0 });
assert_eq!(engine.hovered(), None, "a >=2px move re-picks and correctly clears the hover");
}
#[test]
fn label_density_defaults_and_set_label_density_updates_the_getter_and_marks_dirty() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(engine.label_density(), crate::label_grid::DEFAULT_LABEL_DENSITY);
assert_eq!(engine.labels_drawn_last_frame(), 0);
engine.clear_dirty();
engine.set_label_density(2.5);
assert_eq!(engine.label_density(), 2.5);
assert!(engine.dirty(), "changing label_density must mark the canvas dirty");
engine.set_label_density(-4.0);
assert_eq!(engine.label_density(), 0.0);
}
#[test]
fn label_halo_defaults_and_set_label_halo_updates_the_getter_and_marks_dirty() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(engine.label_halo(), DEFAULT_LABEL_HALO, "default halo must match the crate's own demo/showcase canvas background");
engine.clear_dirty();
engine.set_label_halo("#ffffff");
assert_eq!(engine.label_halo(), "#ffffff");
assert!(engine.dirty(), "changing label_halo must mark the canvas dirty");
}
#[test]
fn theme_defaults_to_dark_and_set_theme_updates_the_getter_and_marks_dirty() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(engine.theme().selection_ring_color, GraphTheme::dark().selection_ring_color);
engine.clear_dirty();
engine.set_theme(GraphTheme::light());
assert_eq!(engine.theme().selection_ring_color, GraphTheme::light().selection_ring_color);
assert!(engine.dirty(), "changing theme must mark the canvas dirty");
}
#[test]
fn label_lod_defaults_and_set_label_lod_updates_the_getter_and_marks_dirty() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(*engine.label_lod(), label_grid::LabelLodConfig::default());
engine.clear_dirty();
let custom = label_grid::LabelLodConfig { grid_cell_size_px: 50.0, ..label_grid::LabelLodConfig::default() };
engine.set_label_lod(custom);
assert_eq!(engine.label_lod().grid_cell_size_px, 50.0);
assert!(engine.dirty(), "changing label_lod must mark the canvas dirty");
}
#[test]
fn interaction_config_defaults_and_set_interaction_config_updates_the_getter() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(*engine.interaction_config(), GraphInteractionConfig::default());
let custom = GraphInteractionConfig { key_pan_speed_px_per_s: 999.0, ..GraphInteractionConfig::default() };
engine.set_interaction_config(custom);
assert_eq!(engine.interaction_config().key_pan_speed_px_per_s, 999.0);
}
#[test]
fn cull_margin_world_defaults_and_set_cull_margin_world_updates_the_getter_and_marks_dirty() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
assert_eq!(engine.cull_margin_world(), DEFAULT_CULL_MARGIN_WORLD);
engine.clear_dirty();
engine.set_cull_margin_world(200.0);
assert_eq!(engine.cull_margin_world(), 200.0);
assert!(engine.dirty(), "changing cull_margin_world must mark the canvas dirty");
engine.set_cull_margin_world(-10.0);
assert_eq!(engine.cull_margin_world(), 0.0);
}
#[test]
fn interaction_config_key_pan_speed_actually_changes_apply_held_nav_keys() {
let mut engine: TestEngine = GraphEngine::new(Graph::new(), ForceDirectedLayout::default());
engine.set_interaction_config(GraphInteractionConfig { key_pan_speed_px_per_s: 0.0, ..GraphInteractionConfig::default() });
let before = engine.camera.pan_x;
engine.on_key_down(KeyCode::ArrowRight);
engine.apply_held_nav_keys(1.0);
assert_eq!(engine.camera.pan_x, before, "a zeroed key_pan_speed_px_per_s override must produce zero pan");
}
#[test]
fn tick_view_advances_navigation_without_moving_static_particles() {
let (graph, a, _b, _c) = chain_graph();
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.seed_positions(&[(10.0, 20.0), (30.0, 40.0), (50.0, 60.0)]);
let before = engine.particles.clone();
let camera_before = engine.camera.pan_x;
engine.on_key_down(KeyCode::ArrowRight);
for _ in 0..1_000 {
let result = engine.tick_view(1.0 / 60.0);
assert!(result.settled);
assert_eq!(result.max_displacement, 0.0);
}
assert_eq!(engine.particles, before);
assert!(engine.camera.pan_x > camera_before);
assert_eq!(engine.particles[a.index()].x, 10.0);
}
#[test]
fn draw_populates_labels_drawn_last_frame_from_the_render_pass() {
use uzor_export::{render_to_png, ExportSpec};
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 400.0, 300.0));
engine.seed_positions(&[(0.0, 0.0), (50.0, 0.0)]);
engine.camera.zoom = 2.0;
let spec = ExportSpec { width_px: 400, height_px: 300, dpr: 1.0, background: None };
render_to_png(&spec, |ctx| engine.draw(ctx)).expect("headless render must succeed");
assert_eq!(engine.labels_drawn_last_frame(), 2, "both nodes sit in separate grid cells and must both draw a label");
}
#[test]
fn disabled_node_labels_render_no_text_and_ignore_label_contents() {
use uzor_export::{render_to_png, ExportSpec};
let build_engine = |first_label: &str, second_label: &str| {
let mut graph = Graph::new();
let a = graph.push_node((), first_label, "x", 4.0);
let b = graph.push_node((), second_label, "x", 4.0);
graph.push_edge(a, b, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
assert!(engine.node_labels_enabled(), "node labels must remain enabled by default");
engine.set_canvas_rect(Rect::new(0.0, 0.0, 400.0, 300.0));
engine.seed_positions(&[(0.0, 0.0), (50.0, 0.0)]);
engine.camera.zoom = 2.0;
engine.set_node_labels_enabled(false);
assert!(!engine.node_labels_enabled());
engine
};
let mut first = build_engine("label-a", "label-b");
let mut second = build_engine("completely-different-a", "completely-different-b");
let spec = ExportSpec { width_px: 400, height_px: 300, dpr: 1.0, background: None };
let first_png = render_to_png(&spec, |ctx| first.draw(ctx)).expect("first headless render must succeed");
let second_png = render_to_png(&spec, |ctx| second.draw(ctx)).expect("second headless render must succeed");
assert_eq!(first.labels_drawn_last_frame(), 0);
assert_eq!(second.labels_drawn_last_frame(), 0);
assert_eq!(first_png, second_png, "disabled labels must make label contents irrelevant to the rendered frame");
}
fn four_corner_square_engine() -> (TestEngine, [NodeIndex; 4]) {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "x", 4.0);
let c = graph.push_node((), "c", "x", 4.0);
let d = graph.push_node((), "d", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(b, d, 1.0, ());
graph.push_edge(d, c, 1.0, ());
graph.push_edge(c, a, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)]);
engine.refresh_visible();
(engine, [a, b, c, d])
}
#[test]
fn box_select_modes_produce_exact_expected_sets() {
let (mut engine, [a, b, c, d]) = four_corner_square_engine();
engine.box_select((-10.0, -10.0), (10.0, 110.0), SelectMode::Replace);
assert_eq!(engine.selection, [a, c].into_iter().collect());
engine.box_select((90.0, -10.0), (110.0, 110.0), SelectMode::Union);
assert_eq!(engine.selection, [a, b, c, d].into_iter().collect());
engine.box_select((-10.0, -10.0), (110.0, 10.0), SelectMode::Diff);
assert_eq!(engine.selection, [c, d].into_iter().collect());
engine.box_select((-10.0, -10.0), (110.0, 10.0), SelectMode::Diff);
assert_eq!(engine.selection, [a, b, c, d].into_iter().collect());
engine.box_select((300.0, 300.0), (310.0, 310.0), SelectMode::Replace);
assert!(engine.selection.is_empty());
}
#[test]
fn group_drag_preserves_relative_offsets_via_a_single_shared_delta() {
let (mut engine, [a, b, c, _d]) = four_corner_square_engine();
engine.apply_selection([a, b], SelectMode::Replace);
let a0 = engine.particles[a.index()];
let b0 = engine.particles[b.index()];
let c0 = engine.particles[c.index()];
engine.on_event(&PlatformEvent::PointerDown { x: 0.0, y: 0.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 30.0, y: -20.0 });
let a_fx = engine.particles[a.index()].fx.expect("a must be pinned mid-drag");
let a_fy = engine.particles[a.index()].fy.expect("a must be pinned mid-drag");
let b_fx = engine.particles[b.index()].fx.expect("b (also in the drag set) must be pinned too");
let b_fy = engine.particles[b.index()].fy.expect("b (also in the drag set) must be pinned too");
assert!((a_fx - (a0.x + 30.0)).abs() < 1e-4);
assert!((a_fy - (a0.y - 20.0)).abs() < 1e-4);
assert!((b_fx - (b0.x + 30.0)).abs() < 1e-4);
assert!((b_fy - (b0.y - 20.0)).abs() < 1e-4);
let rel_before = (b0.x - a0.x, b0.y - a0.y);
let rel_after = (b_fx - a_fx, b_fy - a_fy);
assert!((rel_after.0 - rel_before.0).abs() < 1e-4, "relative x-offset must be preserved exactly");
assert!((rel_after.1 - rel_before.1).abs() < 1e-4, "relative y-offset must be preserved exactly");
assert!(engine.particles[c.index()].fx.is_none());
assert_eq!(engine.particles[c.index()].x, c0.x);
assert_eq!(engine.particles[c.index()].y, c0.y);
engine.on_event(&PlatformEvent::PointerUp { x: 30.0, y: -20.0, button: MouseButton::Left });
assert_eq!(engine.selection, [a, b].into_iter().collect());
assert_eq!(engine.selected, Some(a), "the physically-grabbed anchor becomes the facts-panel value");
}
#[test]
fn dragging_a_non_selected_node_drags_just_it_and_replace_selects_it() {
let (mut engine, [a, b, c, _d]) = four_corner_square_engine();
engine.apply_selection([a, b], SelectMode::Replace);
engine.on_event(&PlatformEvent::PointerDown { x: 0.0, y: 100.0, button: MouseButton::Left }); engine.on_event(&PlatformEvent::PointerMoved { x: 50.0, y: 150.0 });
engine.on_event(&PlatformEvent::PointerUp { x: 50.0, y: 150.0, button: MouseButton::Left });
engine.tick(1.0 / 60.0);
assert_eq!(engine.selection, [c].into_iter().collect(), "selection replaces to just the dragged node");
assert_eq!(engine.selected, Some(c));
assert!(engine.particles[a.index()].fx.is_none(), "a (previously selected) must never have been pinned by this gesture");
assert!(engine.particles[b.index()].fx.is_none(), "b (previously selected) must never have been pinned by this gesture");
assert!(engine.is_pinned(c));
let cp = engine.particles[c.index()];
assert!((cp.x - 50.0).abs() < 1e-4);
assert!((cp.y - 150.0).abs() < 1e-4);
}
#[test]
fn shift_held_starts_a_box_select_drag_instead_of_panning_the_camera() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.on_event(&PlatformEvent::ModifiersChanged { modifiers: ModifierKeys::shift() });
let before_pan = (engine.camera.pan_x, engine.camera.pan_y);
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
assert!(engine.box_select_rect().is_some(), "Shift+mousedown on empty background must start a box-select, not a pan");
engine.on_event(&PlatformEvent::PointerMoved { x: 250.0, y: 220.0 });
assert_eq!((engine.camera.pan_x, engine.camera.pan_y), before_pan, "camera must not pan while box-selecting");
let rect = engine.box_select_rect().expect("still box-selecting mid-drag");
assert!((rect.x - 100.0).abs() < 1e-9);
assert!((rect.y - 100.0).abs() < 1e-9);
assert!((rect.width - 150.0).abs() < 1e-9);
assert!((rect.height - 120.0).abs() < 1e-9);
engine.on_event(&PlatformEvent::PointerUp { x: 250.0, y: 220.0, button: MouseButton::Left });
assert!(engine.box_select_rect().is_none(), "the rubber-band rect clears once the drag ends");
assert_eq!((engine.camera.pan_x, engine.camera.pan_y), before_pan, "camera must still not have panned");
engine.on_event(&PlatformEvent::ModifiersChanged { modifiers: ModifierKeys::none() });
engine.on_event(&PlatformEvent::PointerDown { x: 300.0, y: 300.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 310.0, y: 300.0 });
assert!((engine.camera.pan_x - (before_pan.0 + 10.0)).abs() < 1e-9, "plain drag (no modifier) still pans the camera");
}
#[test]
fn collapse_selection_round_trip_via_expand() {
let (mut engine, [a, b, c, d]) = four_corner_square_engine();
engine.apply_selection([b, d], SelectMode::Replace);
assert!(engine.selection_collapsed_group().is_none(), "nothing collapsed yet");
let b0 = engine.particles[b.index()];
let d0 = engine.particles[d.index()];
let id = engine.collapse_selection().expect("a non-empty selection collapses");
assert!(engine.is_collapsed(id));
assert_eq!(engine.selection_collapsed_group(), Some(id));
engine.refresh_visible();
assert!(engine.visible_nodes().contains(&b));
assert!(!engine.visible_nodes().contains(&d));
assert!(engine.visible_nodes().contains(&a), "untouched nodes stay visible");
assert!(engine.visible_nodes().contains(&c));
assert!(engine.expand_cluster(id));
assert!(engine.selection_collapsed_group().is_none(), "collapsed_group clears once expanded");
let b1 = engine.particles[b.index()];
let d1 = engine.particles[d.index()];
assert_eq!((b1.x, b1.y), (b0.x, b0.y), "expand restores the EXACT pre-collapse position");
assert_eq!((d1.x, d1.y), (d0.x, d0.y));
}
#[test]
fn pin_selection_and_unpin_selection_apply_to_every_member() {
let (mut engine, [a, b, _c, _d]) = four_corner_square_engine();
engine.apply_selection([a, b], SelectMode::Replace);
assert!(!engine.is_pinned(a) && !engine.is_pinned(b));
engine.pin_selection();
assert!(engine.is_pinned(a));
assert!(engine.is_pinned(b));
engine.unpin_selection();
assert!(!engine.is_pinned(a));
assert!(!engine.is_pinned(b));
engine.clear_selection();
assert!(engine.selection.is_empty());
assert!(engine.selected.is_none());
}
#[test]
fn multi_selection_also_takes_precedence_over_a_concurrent_hover_and_resumes_on_clear() {
let mut graph = Graph::new();
let p1 = graph.push_node((), "p1", "x", 4.0);
let p2 = graph.push_node((), "p2", "x", 4.0);
let q1 = graph.push_node((), "q1", "x", 4.0);
let q2 = graph.push_node((), "q2", "x", 4.0);
graph.push_edge(p1, p2, 1.0, ());
graph.push_edge(q1, q2, 1.0, ());
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(0.0, 0.0), (100.0, 0.0), (500.0, 0.0), (600.0, 0.0)]);
engine.refresh_visible();
engine.apply_selection([p1, p2], SelectMode::Replace);
assert!(engine.focus.is_selected(u64::from(p1)));
assert!(engine.focus.is_selected(u64::from(p2)));
engine.on_event(&PlatformEvent::PointerMoved { x: 500.0, y: 0.0 }); assert_eq!(engine.hovered(), Some(q1));
assert!(engine.focus.is_selected(u64::from(p1)), "multi-selection must win over a concurrent hover");
assert!(engine.focus.is_selected(u64::from(p2)));
assert!(!engine.focus.is_selected(u64::from(q1)), "hover must not override an active multi-selection");
assert!(!engine.focus.is_selected(u64::from(q2)));
engine.clear_selection();
assert!(engine.focus.is_selected(u64::from(q1)), "clearing a multi-selection must resume hover-driven focus");
assert!(engine.focus.is_selected(u64::from(q2)));
assert!(!engine.focus.is_selected(u64::from(p1)));
assert!(!engine.focus.is_selected(u64::from(p2)));
}
#[test]
fn zoom_to_node_transition_converges_to_the_target_with_monotonic_easing() {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
let canvas = Rect::new(0.0, 0.0, 800.0, 600.0);
engine.set_canvas_rect(canvas);
engine.seed_positions(&[(120.0, 40.0)]);
engine.pin_node(a);
let target_zoom = 3.0;
let target_pan = (canvas.width / 2.0 - 120.0 * target_zoom, canvas.height / 2.0 - 40.0 * target_zoom);
assert!(engine.zoom_to_node(a, 500.0, Some(target_zoom)));
assert!(engine.camera_transitioning());
let mut prev_zoom_dist = f64::MAX;
let mut prev_pan_dist = f64::MAX;
for _ in 0..40 {
engine.tick(1.0 / 60.0);
let zoom_dist = (engine.camera.zoom - target_zoom).abs();
let pan_dist =
((engine.camera.pan_x - target_pan.0).powi(2) + (engine.camera.pan_y - target_pan.1).powi(2)).sqrt();
assert!(zoom_dist <= prev_zoom_dist + 1e-9, "zoom must converge monotonically: {zoom_dist} > {prev_zoom_dist}");
assert!(pan_dist <= prev_pan_dist + 1e-9, "pan must converge monotonically: {pan_dist} > {prev_pan_dist}");
prev_zoom_dist = zoom_dist;
prev_pan_dist = pan_dist;
}
assert!(!engine.camera_transitioning(), "a 500ms transition at 60fps must finish within 40 ticks");
assert!((engine.camera.zoom - target_zoom).abs() < 1e-6);
let (sx, sy) = engine.camera.world_to_screen((120.0, 40.0), canvas);
assert!((sx - canvas.width / 2.0).abs() < 1e-6, "the node must land exactly centered once the transition completes");
assert!((sy - canvas.height / 2.0).abs() < 1e-6);
}
#[test]
fn keydown_arrow_pans_the_camera_every_tick_while_held_and_stops_on_keyup() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.tick(1.0 / 60.0); assert!(!engine.is_hot(), "an idle graph with nothing held must not be hot");
assert!(engine.on_event(&PlatformEvent::KeyDown { key: KeyCode::ArrowRight, modifiers: ModifierKeys::none() }));
assert!(engine.is_hot(), "a held nav key alone must keep the render loop hot");
let mut prev_pan_x = engine.camera.pan_x;
for _ in 0..5 {
engine.tick(1.0 / 60.0);
assert!(engine.camera.pan_x > prev_pan_x, "each tick while ArrowRight is held must pan further right");
prev_pan_x = engine.camera.pan_x;
}
assert!(engine.on_event(&PlatformEvent::KeyUp { key: KeyCode::ArrowRight, modifiers: ModifierKeys::none() }));
assert!(!engine.is_hot(), "releasing the only held nav key must let the loop go idle again");
let pan_after_release = engine.camera.pan_x;
engine.tick(1.0 / 60.0);
assert_eq!(engine.camera.pan_x, pan_after_release, "pan must stop changing once the key is released");
}
#[test]
fn set_local_root_restricts_the_visible_set_to_exactly_the_bfs_depth_k_neighborhood_and_restores_on_clear() {
let (mut engine, [a, b, c, d]) = chain4_engine_on_a_line();
engine.refresh_visible();
assert_eq!(engine.visible_nodes().len(), 4, "sanity: all 4 nodes visible with no restriction");
engine.set_local_root(Some(b), Some(1));
engine.refresh_visible();
let visible: HashSet<NodeIndex> = engine.visible_nodes().iter().copied().collect();
assert_eq!(visible, [a, b, c].into_iter().collect(), "depth-1 from b in a 4-chain is exactly {{a,b,c}}");
assert_eq!(engine.local_root(), Some((b, 1)));
engine.set_local_root(None, None);
engine.refresh_visible();
let visible_full: HashSet<NodeIndex> = engine.visible_nodes().iter().copied().collect();
assert_eq!(visible_full, [a, b, c, d].into_iter().collect(), "clearing local_root restores the full node set");
assert!(engine.local_root().is_none());
}
#[test]
fn set_local_root_default_depth_is_two() {
let (mut engine, [a, b, _c, d]) = chain4_engine_on_a_line();
engine.set_local_root(Some(a), None);
assert_eq!(engine.local_root(), Some((a, 2)));
engine.refresh_visible();
let visible: HashSet<NodeIndex> = engine.visible_nodes().iter().copied().collect();
assert!(!visible.contains(&d), "d is 3 hops from a — outside a depth-2 neighborhood");
assert!(visible.contains(&b));
}
fn three_node_chain_with_a_hideable_middle() -> (Graph<(), ()>, NodeIndex, NodeIndex, NodeIndex) {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let b = graph.push_node((), "b", "hidden", 4.0);
let c = graph.push_node((), "c", "x", 4.0);
graph.push_edge(a, b, 1.0, ());
graph.push_edge(b, c, 1.0, ());
(graph, a, b, c)
}
#[test]
fn filter_removes_a_node_from_render_and_from_the_force_topology_so_settled_positions_differ() {
let settle = |engine: &mut TestEngine| {
for _ in 0..400 {
engine.tick(1.0 / 60.0);
}
};
let (graph, a, _b, _c) = three_node_chain_with_a_hideable_middle();
let mut baseline: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
baseline.set_canvas_rect(Rect::new(-400.0, -300.0, 800.0, 600.0));
baseline.seed_positions(&[(-40.0, 0.0), (0.0, 0.0), (40.0, 0.0)]);
settle(&mut baseline);
let baseline_a = baseline.particles[a.index()];
let (graph2, a2, b2, c2) = three_node_chain_with_a_hideable_middle();
let mut filtered: TestEngine = GraphEngine::new(graph2, ForceDirectedLayout::default());
filtered.set_canvas_rect(Rect::new(-400.0, -300.0, 800.0, 600.0));
filtered.seed_positions(&[(-40.0, 0.0), (0.0, 0.0), (40.0, 0.0)]);
filtered.set_filter(Some(FilterSpec { categories: Some(vec!["x".to_owned()]), ..Default::default() }));
filtered.refresh_visible();
assert!(!filtered.visible_nodes().contains(&b2), "the filtered-out node must be excluded from render/pick");
assert!(filtered.visible_nodes().contains(&a2), "surviving nodes stay visible");
assert!(filtered.visible_nodes().contains(&c2));
settle(&mut filtered);
let filtered_a = filtered.particles[a2.index()];
let dist = ((baseline_a.x - filtered_a.x).powi(2) + (baseline_a.y - filtered_a.y).powi(2)).sqrt();
assert!(
dist > 1.0,
"losing the link-force pull toward the filtered-out node must measurably change where `a` settles: \
baseline ({}, {}) vs filtered ({}, {}), dist {dist}",
baseline_a.x,
baseline_a.y,
filtered_a.x,
filtered_a.y
);
}
#[test]
fn set_filter_none_clears_a_previous_filter() {
let (graph, _a, b, _c) = three_node_chain_with_a_hideable_middle();
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(-40.0, 0.0), (0.0, 0.0), (40.0, 0.0)]);
engine.set_filter(Some(FilterSpec { categories: Some(vec!["x".to_owned()]), ..Default::default() }));
engine.refresh_visible();
assert!(!engine.visible_nodes().contains(&b));
engine.set_filter(None);
assert!(engine.filter().is_none());
engine.refresh_visible();
assert!(engine.visible_nodes().contains(&b), "clearing the filter must restore the excluded node");
}
fn star_graph_with_categories() -> (Graph<(), ()>, NodeIndex, [NodeIndex; 4]) {
let mut graph = Graph::new();
let root = graph.push_node((), "root", "keep", 4.0);
let mut leaves = Vec::with_capacity(4);
for i in 0..4 {
let category = if i % 2 == 0 { "keep" } else { "drop" };
let leaf = graph.push_node((), format!("leaf{i}"), category, 4.0);
graph.push_edge(root, leaf, 1.0, ());
leaves.push(leaf);
}
(graph, root, [leaves[0], leaves[1], leaves[2], leaves[3]])
}
#[test]
fn filter_and_local_mode_compose_as_an_intersection() {
let (graph, root, [l0, l1, l2, l3]) = star_graph_with_categories();
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
engine.set_canvas_rect(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.seed_positions(&[(0.0, 0.0), (50.0, 0.0), (0.0, 50.0), (-50.0, 0.0), (0.0, -50.0)]);
engine.set_local_root(Some(root), Some(1));
engine.set_filter(Some(FilterSpec { categories: Some(vec!["keep".to_owned()]), ..Default::default() }));
engine.refresh_visible();
let visible: HashSet<NodeIndex> = engine.visible_nodes().iter().copied().collect();
assert_eq!(visible, [root, l0, l2].into_iter().collect());
assert!(!visible.contains(&l1), "l1 fails the filter even though it's in the local BFS set");
assert!(!visible.contains(&l3));
}
#[test]
fn pointer_left_finalizes_a_background_pan_exactly_like_a_pointer_up_would() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.on_event(&PlatformEvent::PointerDown { x: 300.0, y: 300.0, button: MouseButton::Left });
engine.on_event(&PlatformEvent::PointerMoved { x: 340.0, y: 300.0 });
assert!(matches!(engine.mode, PointerMode::PanningCamera { .. }), "fixture sanity: must genuinely be panning");
assert!(engine.on_event(&PlatformEvent::PointerLeft));
assert!(matches!(engine.mode, PointerMode::Idle), "PointerLeft must finalize the in-progress pan, same as a real PointerUp");
}
#[test]
fn window_defocus_finalizes_an_in_progress_node_drag_and_leaves_it_sticky_pinned() {
let (mut engine, a, _b) = two_node_chain_engine();
engine.on_event(&PlatformEvent::PointerDown { x: 100.0, y: 100.0, button: MouseButton::Left });
assert!(matches!(engine.mode, PointerMode::DraggingNode { .. }), "fixture sanity: must genuinely be dragging node a");
assert!(engine.layout.alpha_target() > 0.0, "a drag must hold the sustained alpha target while in progress");
assert!(engine.on_event(&PlatformEvent::WindowFocused(false)));
assert!(matches!(engine.mode, PointerMode::Idle), "losing window focus must finalize the in-progress node drag");
assert_eq!(engine.selected, Some(a), "a node drag must select the dragged node on finalize, same as a real PointerUp");
assert!(engine.is_pinned(a), "the default Sticky drag-end policy must leave the node pinned where the drag left it");
assert_eq!(engine.layout.alpha_target(), 0.0, "drag-end must clear the sustained alpha target regardless of how the drag ended");
}
#[test]
fn window_refocus_true_is_not_a_gesture_cancel() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
engine.on_event(&PlatformEvent::PointerDown { x: 300.0, y: 300.0, button: MouseButton::Left });
assert!(matches!(engine.mode, PointerMode::PanningCamera { .. }));
assert!(!engine.on_event(&PlatformEvent::WindowFocused(true)), "gaining focus is not a drag-cancel and must not be consumed as one");
assert!(matches!(engine.mode, PointerMode::PanningCamera { .. }), "gaining focus must not disturb an in-progress pan");
}
#[test]
fn pointer_left_with_nothing_in_progress_is_a_no_op() {
let mut engine = empty_engine_with_canvas(Rect::new(0.0, 0.0, 800.0, 600.0));
assert!(matches!(engine.mode, PointerMode::Idle));
assert!(!engine.on_event(&PlatformEvent::PointerLeft), "PointerLeft with no gesture in progress must not be reported as consumed");
}
#[test]
fn a_background_pan_started_mid_camera_transition_clears_the_transition_immediately() {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
let canvas = Rect::new(0.0, 0.0, 800.0, 600.0);
engine.set_canvas_rect(canvas);
engine.seed_positions(&[(120.0, 40.0)]);
engine.pin_node(a);
assert!(engine.zoom_to_node(a, 500.0, Some(3.0)));
assert!(engine.camera_transitioning());
engine.on_event(&PlatformEvent::PointerDown { x: 5.0, y: 5.0, button: MouseButton::Left });
assert!(!engine.camera_transitioning(), "starting a background pan must clear an in-flight camera transition");
}
#[test]
fn a_scroll_mid_camera_transition_clears_the_transition_immediately() {
let mut graph = Graph::new();
let a = graph.push_node((), "a", "x", 4.0);
let mut engine: TestEngine = GraphEngine::new(graph, ForceDirectedLayout::default());
let canvas = Rect::new(0.0, 0.0, 800.0, 600.0);
engine.set_canvas_rect(canvas);
engine.seed_positions(&[(120.0, 40.0)]);
engine.pin_node(a);
engine.on_event(&PlatformEvent::PointerMoved { x: 400.0, y: 300.0 });
assert!(engine.zoom_to_node(a, 500.0, Some(3.0)));
assert!(engine.camera_transitioning());
engine.on_event(&PlatformEvent::Scroll { dx: 0.0, dy: -1.0 });
assert!(!engine.camera_transitioning(), "a wheel-zoom must clear an in-flight camera transition");
}
}