Skip to main content

gpui_graph/view/
mod.rs

1mod paint;
2mod visual;
3
4use std::{
5    cell::{Cell, RefCell},
6    collections::{HashMap, HashSet},
7    rc::Rc,
8};
9
10use std::time::{Duration, Instant};
11
12use gpui::{
13    AnyElement, App, Bounds, ContentMask, Context, CursorStyle, DispatchPhase, Entity,
14    EventEmitter, FocusHandle, Focusable, Hitbox, HitboxBehavior, Hsla, InteractiveElement as _,
15    IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
16    PinchEvent, Pixels, Point, Render, ScrollDelta, ScrollWheelEvent, SharedString, Styled as _,
17    Window, canvas, div, point, px,
18};
19
20use crate::actions::{
21    Activate, ClearSelection, FitView, KEY_CONTEXT, OpenContextMenu, SelectDown, SelectLeft,
22    SelectRight, SelectUp, ZoomIn, ZoomOut,
23};
24
25use crate::geometry::EdgeGeometry;
26use crate::layout::{Body, ForceConfig, Simulation, Vec2};
27use crate::model::{Edge, GraphData, Node};
28use crate::style::{ColorBy, GraphColors, GraphMetrics, LabelMode, NodeSizing, Palette};
29use visual::{GroupInput, Vertex, Visual};
30
31const MIN_ZOOM: f32 = 0.05;
32const MAX_ZOOM: f32 = 6.;
33/// Pointer travel (px) before a press becomes a drag instead of a click.
34const DRAG_THRESHOLD: f32 = 3.;
35/// Pointer distance (px) within which an edge counts as hit.
36const EDGE_HIT_SLOP: f32 = 4.;
37/// With reduced motion, the layout is computed within this budget per frame
38/// instead of being animated.
39const REDUCED_MOTION_BUDGET: Duration = Duration::from_millis(120);
40
41/// What the user has selected or is pointing at.
42#[derive(Clone, Debug, Default)]
43#[non_exhaustive]
44pub enum Selection {
45    #[default]
46    None,
47    Node(Node),
48    Edge(Edge),
49    /// Several edges drawn as one, e.g. from a node into a collapsed group.
50    EdgeBundle(Vec<Edge>),
51    /// A collapsed group's super node.
52    Group(GroupInfo),
53}
54
55#[derive(Clone, Debug)]
56#[non_exhaustive]
57pub enum NetworkGraphEvent {
58    SelectionChanged(Selection),
59    /// Double-click on a node: the usual "expand neighbors" gesture in graph explorers.
60    NodeActivated(Node),
61    /// A group was collapsed or expanded, by the user or through the API.
62    GroupToggled {
63        group: SharedString,
64        collapsed: bool,
65    },
66    /// Right-click. `position` is in window coordinates, ready for a popover anchor.
67    ContextMenu {
68        target: Selection,
69        position: Point<Pixels>,
70    },
71}
72
73/// A category present in the graph, for legends.
74#[derive(Clone, Debug)]
75#[non_exhaustive]
76pub struct CategoryInfo {
77    pub name: SharedString,
78    pub color: Hsla,
79    pub count: usize,
80}
81
82/// A group (community) present in the graph.
83#[derive(Clone, Debug)]
84#[non_exhaustive]
85pub struct GroupInfo {
86    pub id: SharedString,
87    pub label: SharedString,
88    pub color: Hsla,
89    /// Number of member nodes.
90    pub size: usize,
91    pub collapsed: bool,
92}
93
94/// Builds tooltip content for the hovered item; `None` hides the tooltip.
95pub type TooltipBuilder =
96    Rc<dyn Fn(&Selection, &GraphColors, &mut Window, &mut App) -> Option<AnyElement>>;
97
98/// A hovered / hit item, by index into the visual graph. Transient.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100enum Item {
101    Vertex(usize),
102    Edge(usize),
103}
104
105/// A selected item by stable identity, so it survives collapse/expand and merges.
106#[derive(Clone, Debug, PartialEq, Eq)]
107enum SelectionKey {
108    Node(SharedString),
109    Group(SharedString),
110    /// Model edge indices of the selected visual edge.
111    Edges(Vec<usize>),
112}
113
114struct ModelEdge {
115    data: Edge,
116    source: usize,
117    target: usize,
118}
119
120struct GroupState {
121    id: SharedString,
122    label: SharedString,
123    members: Vec<usize>,
124    collapsed: bool,
125    /// Super node body while collapsed.
126    body: Body,
127    /// Members' centroid when collapsed; expanding moves them by the super node's travel.
128    anchor: Vec2,
129}
130
131enum DragKind {
132    Pan { start_pan: Point<Pixels> },
133    Vertex { ix: usize, was_pinned: bool },
134}
135
136struct Drag {
137    kind: DragKind,
138    start: Point<Pixels>,
139    moved: bool,
140}
141
142/// An interactive, force-directed network graph.
143///
144/// ```ignore
145/// let graph = cx.new(|cx| {
146///     NetworkGraph::new(cx)
147///         .with_data(GraphData::new(nodes, edges))
148///         .with_label_mode(LabelMode::Auto)
149/// });
150/// cx.subscribe(&graph, |this, _, event: &NetworkGraphEvent, cx| { /* ... */ }).detach();
151/// ```
152///
153/// Place it in a sized container: it fills its parent.
154pub struct NetworkGraph {
155    // Model: every node and edge, with persisted positions.
156    nodes: Vec<Node>,
157    node_bodies: Vec<Body>,
158    edges: Vec<ModelEdge>,
159    index: HashMap<SharedString, usize>,
160    degrees: Vec<usize>,
161    neighbors: Vec<Vec<usize>>,
162    categories: Vec<SharedString>,
163    groups: Vec<GroupState>,
164    group_index: HashMap<SharedString, usize>,
165    node_group: Vec<Option<usize>>,
166
167    // What is drawn and simulated.
168    vis: Visual,
169    sim: Simulation,
170
171    /// Screen position = bounds.center + pan + world * zoom.
172    pan: Point<Pixels>,
173    zoom: f32,
174    /// Keep fitting the view while the layout settles, until the user moves it.
175    auto_fit: bool,
176    bounds: Rc<Cell<Bounds<Pixels>>>,
177
178    hovered: Option<Item>,
179    selected: Option<SelectionKey>,
180    drag: Option<Drag>,
181    pointer: Option<Point<Pixels>>,
182    focus_handle: FocusHandle,
183    /// The last interaction came from the keyboard: show focus indicators.
184    keyboard_nav: bool,
185    /// Vertices whose label was drawn last frame; they win placement ties.
186    shown_labels: RefCell<HashSet<usize>>,
187
188    label_mode: LabelMode,
189    color_by: ColorBy,
190    arrows: bool,
191    edge_labels: bool,
192    /// Whether items outside the hovered / selected one's neighborhood fade.
193    focus_dimming: bool,
194    group_hulls: bool,
195    /// Dragged nodes stay where they are dropped.
196    sticky_drag: bool,
197    palette: Palette,
198    sizing: NodeSizing,
199    colors: Option<GraphColors>,
200    tooltip: Option<TooltipBuilder>,
201    tooltips: bool,
202}
203
204impl EventEmitter<NetworkGraphEvent> for NetworkGraph {}
205
206impl Focusable for NetworkGraph {
207    fn focus_handle(&self, _: &App) -> FocusHandle {
208        self.focus_handle.clone()
209    }
210}
211
212impl NetworkGraph {
213    pub fn new(cx: &mut Context<Self>) -> Self {
214        Self {
215            nodes: Vec::new(),
216            node_bodies: Vec::new(),
217            edges: Vec::new(),
218            index: HashMap::new(),
219            degrees: Vec::new(),
220            neighbors: Vec::new(),
221            categories: Vec::new(),
222            groups: Vec::new(),
223            group_index: HashMap::new(),
224            node_group: Vec::new(),
225            vis: Visual::default(),
226            sim: Simulation::new(ForceConfig::default()),
227            pan: Point::default(),
228            zoom: 1.,
229            auto_fit: true,
230            bounds: Rc::new(Cell::new(Bounds::default())),
231            hovered: None,
232            selected: None,
233            drag: None,
234            pointer: None,
235            focus_handle: cx.focus_handle().tab_stop(true),
236            keyboard_nav: false,
237            shown_labels: RefCell::default(),
238            label_mode: LabelMode::default(),
239            color_by: ColorBy::default(),
240            arrows: true,
241            edge_labels: true,
242            focus_dimming: true,
243            group_hulls: true,
244            sticky_drag: false,
245            palette: Palette::default(),
246            sizing: NodeSizing::default(),
247            colors: None,
248            tooltip: None,
249            tooltips: true,
250        }
251    }
252
253    // ---- construction options -----------------------------------------------
254
255    pub fn with_data(mut self, data: GraphData) -> Self {
256        self.load(data);
257        self
258    }
259
260    pub fn with_force_config(mut self, config: ForceConfig) -> Self {
261        self.sim.config = config;
262        self
263    }
264
265    pub fn with_label_mode(mut self, mode: LabelMode) -> Self {
266        self.label_mode = mode;
267        self
268    }
269
270    pub fn with_color_by(mut self, color_by: ColorBy) -> Self {
271        self.color_by = color_by;
272        self
273    }
274
275    /// Draw direction arrows on edges (default on).
276    pub fn arrows(mut self, arrows: bool) -> Self {
277        self.arrows = arrows;
278        self
279    }
280
281    /// Draw relationship labels on edges (default on).
282    pub fn edge_labels(mut self, edge_labels: bool) -> Self {
283        self.edge_labels = edge_labels;
284        self
285    }
286
287    /// Outline each expanded group with a rounded hull (default on).
288    pub fn group_hulls(mut self, group_hulls: bool) -> Self {
289        self.group_hulls = group_hulls;
290        self
291    }
292
293    /// Keep dragged nodes pinned where they are dropped (default off).
294    pub fn sticky_drag(mut self, sticky: bool) -> Self {
295        self.sticky_drag = sticky;
296        self
297    }
298
299    pub fn with_palette(mut self, palette: Palette) -> Self {
300        self.palette = palette;
301        self
302    }
303
304    pub fn with_node_sizing(mut self, sizing: NodeSizing) -> Self {
305        self.sizing = sizing;
306        self
307    }
308
309    /// Fixed colors instead of following the gpui-base theme.
310    pub fn with_colors(mut self, colors: GraphColors) -> Self {
311        self.colors = Some(colors);
312        self
313    }
314
315    /// Replace the built-in tooltip. Return `None` from the builder to hide it.
316    pub fn with_tooltip(
317        mut self,
318        builder: impl Fn(&Selection, &GraphColors, &mut Window, &mut App) -> Option<AnyElement>
319        + 'static,
320    ) -> Self {
321        self.tooltip = Some(Rc::new(builder));
322        self
323    }
324
325    /// Show tooltips for the hovered item (default on).
326    pub fn tooltips(mut self, tooltips: bool) -> Self {
327        self.tooltips = tooltips;
328        self
329    }
330
331    // ---- runtime settings ---------------------------------------------------
332
333    pub fn label_mode(&self) -> LabelMode {
334        self.label_mode
335    }
336
337    pub fn set_label_mode(&mut self, mode: LabelMode, cx: &mut Context<Self>) {
338        self.label_mode = mode;
339        cx.notify();
340    }
341
342    pub fn color_by(&self) -> ColorBy {
343        self.color_by
344    }
345
346    pub fn set_color_by(&mut self, color_by: ColorBy, cx: &mut Context<Self>) {
347        self.color_by = color_by;
348        cx.notify();
349    }
350
351    pub fn has_arrows(&self) -> bool {
352        self.arrows
353    }
354
355    pub fn set_arrows(&mut self, arrows: bool, cx: &mut Context<Self>) {
356        self.arrows = arrows;
357        cx.notify();
358    }
359
360    pub fn has_edge_labels(&self) -> bool {
361        self.edge_labels
362    }
363
364    /// Whether hovering or selecting an item fades everything outside its
365    /// neighborhood. Off, the item and its edges are still emphasized, and
366    /// every other node stays as readable — and as easy to aim at — as before.
367    pub fn set_focus_dimming(&mut self, dimming: bool, cx: &mut Context<Self>) {
368        if self.focus_dimming != dimming {
369            self.focus_dimming = dimming;
370            cx.notify();
371        }
372    }
373
374    pub fn focus_dimming(&self) -> bool {
375        self.focus_dimming
376    }
377
378    pub fn set_edge_labels(&mut self, edge_labels: bool, cx: &mut Context<Self>) {
379        self.edge_labels = edge_labels;
380        cx.notify();
381    }
382
383    pub fn has_group_hulls(&self) -> bool {
384        self.group_hulls
385    }
386
387    pub fn set_group_hulls(&mut self, group_hulls: bool, cx: &mut Context<Self>) {
388        self.group_hulls = group_hulls;
389        cx.notify();
390    }
391
392    pub fn is_sticky_drag(&self) -> bool {
393        self.sticky_drag
394    }
395
396    pub fn set_sticky_drag(&mut self, sticky: bool) {
397        self.sticky_drag = sticky;
398    }
399
400    pub fn has_tooltips(&self) -> bool {
401        self.tooltips
402    }
403
404    pub fn set_tooltips(&mut self, tooltips: bool, cx: &mut Context<Self>) {
405        self.tooltips = tooltips;
406        cx.notify();
407    }
408
409    pub fn set_force_config(&mut self, config: ForceConfig, cx: &mut Context<Self>) {
410        self.sim.config = config;
411        self.sim.reheat(0.5);
412        cx.notify();
413    }
414
415    pub fn set_colors(&mut self, colors: Option<GraphColors>, cx: &mut Context<Self>) {
416        self.colors = colors;
417        cx.notify();
418    }
419
420    // ---- data -------------------------------------------------------------
421
422    /// Replace the whole graph (a new query result) and reset the viewport.
423    pub fn set_data(&mut self, data: GraphData, cx: &mut Context<Self>) {
424        let had_selection = self.selected.is_some();
425        self.load(data);
426        if had_selection {
427            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
428        }
429        cx.notify();
430    }
431
432    /// Add nodes and edges to the current graph, e.g. expanding a node. Existing
433    /// ids are kept; new nodes start around `anchor` so the layout stays stable.
434    /// New members of a collapsed group join it without expanding it.
435    pub fn merge(&mut self, data: GraphData, anchor: Option<&str>, cx: &mut Context<Self>) {
436        self.sync_back();
437        self.insert(data, anchor);
438        cx.notify();
439    }
440
441    /// Remove nodes and every edge touching them.
442    pub fn remove_nodes(&mut self, ids: &[&str], cx: &mut Context<Self>) {
443        let doomed: Vec<bool> = self
444            .nodes
445            .iter()
446            .map(|n| ids.contains(&n.id.as_ref()))
447            .collect();
448        if !doomed.contains(&true) {
449            return;
450        }
451        self.sync_back();
452        let mut remap = vec![usize::MAX; self.nodes.len()];
453        let mut kept = 0;
454        for (i, &d) in doomed.iter().enumerate() {
455            if !d {
456                remap[i] = kept;
457                kept += 1;
458            }
459        }
460        let mut i = 0;
461        self.nodes.retain(|_| (!doomed[i], i += 1).0);
462        let mut i = 0;
463        self.node_bodies.retain(|_| (!doomed[i], i += 1).0);
464        self.edges.retain_mut(|e| {
465            let keep = !doomed[e.source] && !doomed[e.target];
466            e.source = remap[e.source];
467            e.target = remap[e.target];
468            keep
469        });
470        self.index = self
471            .nodes
472            .iter()
473            .enumerate()
474            .map(|(i, n)| (n.id.clone(), i))
475            .collect();
476        self.categories
477            .retain(|c| self.nodes.iter().any(|n| &n.category == c));
478        // Model edge indices shifted; an edge selection cannot be carried over.
479        if matches!(self.selected, Some(SelectionKey::Edges(_))) {
480            self.selected = None;
481        }
482        self.rebuild();
483        self.sim.reheat(0.3);
484        self.validate_selection(cx);
485        cx.notify();
486    }
487
488    pub fn clear(&mut self, cx: &mut Context<Self>) {
489        self.set_data(GraphData::default(), cx);
490    }
491
492    /// The current graph contents (including nodes inside collapsed groups).
493    pub fn data(&self) -> GraphData {
494        GraphData {
495            nodes: self.nodes.clone(),
496            edges: self.edges.iter().map(|e| e.data.clone()).collect(),
497        }
498    }
499
500    fn load(&mut self, data: GraphData) {
501        self.nodes.clear();
502        self.node_bodies.clear();
503        self.edges.clear();
504        self.index.clear();
505        self.categories.clear();
506        self.groups.clear();
507        self.group_index.clear();
508        self.vis = Visual::default();
509        self.hovered = None;
510        self.selected = None;
511        self.drag = None;
512        self.insert(data, None);
513        self.pan = Point::default();
514        self.zoom = 1.;
515        self.auto_fit = true;
516    }
517
518    /// Requires the model to be in sync with the visual graph (see `sync_back`).
519    fn insert(&mut self, data: GraphData, anchor: Option<&str>) {
520        let anchor_pos = anchor
521            .and_then(|id| self.index.get(id))
522            .map(|&i| self.vis.bodies[self.vis.node_slot[i]].pos);
523        let first_new = self.nodes.len();
524        for node in data.nodes {
525            if self.index.contains_key(&node.id) {
526                continue;
527            }
528            if !self.categories.contains(&node.category) {
529                self.categories.push(node.category.clone());
530            }
531            self.index.insert(node.id.clone(), self.nodes.len());
532            self.nodes.push(node);
533            self.node_bodies.push(Body::default());
534        }
535        match anchor_pos {
536            Some(center) => {
537                let added = self.nodes.len() - first_new;
538                for (k, body) in self.node_bodies[first_new..].iter_mut().enumerate() {
539                    let a = k as f32 / added.max(1) as f32 * std::f32::consts::TAU;
540                    body.pos = Vec2::new(center.x + 30. * a.cos(), center.y + 30. * a.sin());
541                }
542            }
543            None => Simulation::initialize_positions(&mut self.node_bodies, first_new),
544        }
545
546        for edge in data.edges {
547            let (Some(&s), Some(&t)) = (self.index.get(&edge.source), self.index.get(&edge.target))
548            else {
549                continue;
550            };
551            let duplicate = self
552                .edges
553                .iter()
554                .any(|e| e.source == s && e.target == t && e.data.label == edge.label);
555            if !duplicate {
556                self.edges.push(ModelEdge {
557                    data: edge,
558                    source: s,
559                    target: t,
560                });
561            }
562        }
563        self.rebuild();
564        self.sim.reheat(if anchor_pos.is_some() { 0.5 } else { 1. });
565    }
566
567    /// Persist simulated positions from the visual graph into the model.
568    fn sync_back(&mut self) {
569        for (v, vertex) in self.vis.vertices.iter().enumerate() {
570            let body = self.vis.bodies[v].clone();
571            match *vertex {
572                Vertex::Node(i) => self.node_bodies[i] = body,
573                Vertex::Group(g) => self.groups[g].body = body,
574            }
575        }
576    }
577
578    /// Recompute model topology and groups, then the visual graph.
579    fn rebuild(&mut self) {
580        let n = self.nodes.len();
581        self.degrees = vec![0; n];
582        self.neighbors = vec![Vec::new(); n];
583        for e in &self.edges {
584            self.degrees[e.source] += 1;
585            self.degrees[e.target] += 1;
586            if e.source != e.target {
587                self.neighbors[e.source].push(e.target);
588                self.neighbors[e.target].push(e.source);
589            }
590        }
591        for list in &mut self.neighbors {
592            list.sort_unstable();
593            list.dedup();
594        }
595        for (i, body) in self.node_bodies.iter_mut().enumerate() {
596            body.radius = self.nodes[i]
597                .size
598                .unwrap_or_else(|| self.sizing.radius(self.degrees[i]));
599        }
600
601        // Groups keep their state (collapsed, position, label) across rebuilds.
602        for g in &mut self.groups {
603            g.members.clear();
604        }
605        self.node_group = vec![None; n];
606        for (i, node) in self.nodes.iter().enumerate() {
607            let Some(id) = &node.group else { continue };
608            let g = *self.group_index.entry(id.clone()).or_insert_with(|| {
609                self.groups.push(GroupState {
610                    id: id.clone(),
611                    label: id.clone(),
612                    members: Vec::new(),
613                    collapsed: false,
614                    body: Body::default(),
615                    anchor: Vec2::default(),
616                });
617                self.groups.len() - 1
618            });
619            self.groups[g].members.push(i);
620            self.node_group[i] = Some(g);
621        }
622        if self.groups.iter().any(|g| g.members.is_empty()) {
623            self.groups.retain(|g| !g.members.is_empty());
624            self.group_index = self
625                .groups
626                .iter()
627                .enumerate()
628                .map(|(g, s)| (s.id.clone(), g))
629                .collect();
630            for (i, node) in self.nodes.iter().enumerate() {
631                self.node_group[i] = node.group.as_ref().map(|id| self.group_index[id]);
632            }
633        }
634        for g in &mut self.groups {
635            g.body.radius = (12. + (g.members.len() as f32).sqrt() * 3.).min(48.);
636        }
637
638        let groups: Vec<GroupInput> = self
639            .groups
640            .iter()
641            .map(|g| GroupInput {
642                collapsed: g.collapsed,
643                body: &g.body,
644            })
645            .collect();
646        let links: Vec<(usize, usize)> = self.edges.iter().map(|e| (e.source, e.target)).collect();
647        self.vis = Visual::build(&self.node_bodies, &self.node_group, &groups, &links);
648        self.hovered = None;
649        self.drag = None;
650        // Vertex indices changed.
651        self.shown_labels.take();
652    }
653
654    // ---- queries ------------------------------------------------------------
655
656    pub fn node_count(&self) -> usize {
657        self.nodes.len()
658    }
659
660    pub fn edge_count(&self) -> usize {
661        self.edges.len()
662    }
663
664    pub fn node(&self, id: &str) -> Option<&Node> {
665        self.index.get(id).map(|&i| &self.nodes[i])
666    }
667
668    pub fn degree(&self, id: &str) -> usize {
669        self.index.get(id).map_or(0, |&i| self.degrees[i])
670    }
671
672    pub fn neighbors_of(&self, id: &str) -> Vec<Node> {
673        self.index
674            .get(id)
675            .map(|&i| {
676                self.neighbors[i]
677                    .iter()
678                    .map(|&j| self.nodes[j].clone())
679                    .collect()
680            })
681            .unwrap_or_default()
682    }
683
684    /// Edges touching a node, in either direction.
685    pub fn edges_of(&self, id: &str) -> Vec<Edge> {
686        let Some(&i) = self.index.get(id) else {
687            return Vec::new();
688        };
689        self.edges
690            .iter()
691            .filter(|e| e.source == i || e.target == i)
692            .map(|e| e.data.clone())
693            .collect()
694    }
695
696    pub fn categories(&self) -> Vec<CategoryInfo> {
697        self.categories
698            .iter()
699            .enumerate()
700            .map(|(i, c)| CategoryInfo {
701                name: c.clone(),
702                color: self.palette.get(i),
703                count: self.nodes.iter().filter(|n| &n.category == c).count(),
704            })
705            .collect()
706    }
707
708    pub fn groups(&self) -> Vec<GroupInfo> {
709        (0..self.groups.len()).map(|g| self.group_info(g)).collect()
710    }
711
712    pub fn group(&self, id: &str) -> Option<GroupInfo> {
713        self.group_index.get(id).map(|&g| self.group_info(g))
714    }
715
716    /// Member nodes of a group.
717    pub fn group_members(&self, id: &str) -> Vec<Node> {
718        self.group_index
719            .get(id)
720            .map(|&g| {
721                self.groups[g]
722                    .members
723                    .iter()
724                    .map(|&i| self.nodes[i].clone())
725                    .collect()
726            })
727            .unwrap_or_default()
728    }
729
730    /// The colors in effect: the override, or the current theme.
731    pub fn resolved_colors(&self, cx: &App) -> GraphColors {
732        self.colors
733            .clone()
734            .unwrap_or_else(|| GraphColors::from_theme(cx))
735    }
736
737    /// The color a node is painted with.
738    pub fn color_of(&self, node: &Node, cx: &App) -> Hsla {
739        if let Some(c) = node.color {
740            return c;
741        }
742        match self.color_by {
743            ColorBy::Category => {
744                let ix = self
745                    .categories
746                    .iter()
747                    .position(|c| c == &node.category)
748                    .unwrap_or(0);
749                self.palette.get(ix)
750            }
751            ColorBy::Group => match node.group.as_ref().and_then(|g| self.group_index.get(g)) {
752                Some(&g) => self.group_color(g),
753                None => self.resolved_colors(cx).ungrouped,
754            },
755        }
756    }
757
758    pub fn selection(&self) -> Selection {
759        match &self.selected {
760            None => Selection::None,
761            Some(SelectionKey::Node(id)) => self
762                .node(id)
763                .cloned()
764                .map_or(Selection::None, Selection::Node),
765            Some(SelectionKey::Group(id)) => {
766                self.group(id).map_or(Selection::None, Selection::Group)
767            }
768            Some(SelectionKey::Edges(edges)) => self.edge_selection(edges),
769        }
770    }
771
772    pub fn zoom(&self) -> f32 {
773        self.zoom
774    }
775
776    /// Whether the layout is still moving.
777    pub fn is_simulating(&self) -> bool {
778        self.sim.is_active()
779    }
780
781    fn group_info(&self, g: usize) -> GroupInfo {
782        let s = &self.groups[g];
783        GroupInfo {
784            id: s.id.clone(),
785            label: s.label.clone(),
786            color: self.group_color(g),
787            size: s.members.len(),
788            collapsed: s.collapsed,
789        }
790    }
791
792    fn group_color(&self, g: usize) -> Hsla {
793        self.palette.get(g)
794    }
795
796    fn edge_selection(&self, edges: &[usize]) -> Selection {
797        match edges {
798            [] => Selection::None,
799            [e] => Selection::Edge(self.edges[*e].data.clone()),
800            many => {
801                Selection::EdgeBundle(many.iter().map(|&e| self.edges[e].data.clone()).collect())
802            }
803        }
804    }
805
806    fn key_of(&self, item: Item) -> SelectionKey {
807        match item {
808            Item::Vertex(v) => match self.vis.vertices[v] {
809                Vertex::Node(i) => SelectionKey::Node(self.nodes[i].id.clone()),
810                Vertex::Group(g) => SelectionKey::Group(self.groups[g].id.clone()),
811            },
812            Item::Edge(e) => SelectionKey::Edges(self.vis.edges[e].edges.clone()),
813        }
814    }
815
816    fn item_selection(&self, item: Item) -> Selection {
817        match item {
818            Item::Vertex(v) => match self.vis.vertices[v] {
819                Vertex::Node(i) => Selection::Node(self.nodes[i].clone()),
820                Vertex::Group(g) => Selection::Group(self.group_info(g)),
821            },
822            Item::Edge(e) => self.edge_selection(&self.vis.edges[e].edges),
823        }
824    }
825
826    /// The selection as a visual item, if it is currently drawn.
827    fn selected_item(&self) -> Option<Item> {
828        match self.selected.as_ref()? {
829            SelectionKey::Node(id) => {
830                let slot = self.vis.node_slot[*self.index.get(id)?];
831                matches!(self.vis.vertices[slot], Vertex::Node(_)).then_some(Item::Vertex(slot))
832            }
833            SelectionKey::Group(id) => {
834                self.vis.group_slot[*self.group_index.get(id)?].map(Item::Vertex)
835            }
836            SelectionKey::Edges(edges) => self
837                .vis
838                .edges
839                .iter()
840                .position(|e| &e.edges == edges)
841                .map(Item::Edge),
842        }
843    }
844
845    /// Drop a selection whose target no longer exists, notifying listeners.
846    fn validate_selection(&mut self, cx: &mut Context<Self>) {
847        if self.selected.is_some() && matches!(self.selection(), Selection::None) {
848            self.selected = None;
849            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
850        }
851    }
852
853    // ---- selection & viewport ---------------------------------------------
854
855    /// Select a node by id (`None` clears), optionally centering it.
856    pub fn select_node(&mut self, id: Option<&str>, center: bool, cx: &mut Context<Self>) {
857        let key = id
858            .filter(|id| self.index.contains_key(*id))
859            .map(|id| SelectionKey::Node(id.to_string().into()));
860        if let (Some(id), true) = (id, center)
861            && let Some(&i) = self.index.get(id)
862        {
863            let p = self.vis.bodies[self.vis.node_slot[i]].pos;
864            self.auto_fit = false;
865            self.pan = point(px(-p.x * self.zoom), px(-p.y * self.zoom));
866            cx.notify();
867        }
868        self.set_selected(key, cx);
869    }
870
871    fn set_selected(&mut self, key: Option<SelectionKey>, cx: &mut Context<Self>) {
872        if key == self.selected {
873            return;
874        }
875        self.selected = key;
876        cx.emit(NetworkGraphEvent::SelectionChanged(self.selection()));
877        cx.notify();
878    }
879
880    /// Pin or release a node at its current position.
881    pub fn set_pinned(&mut self, id: &str, pinned: bool, cx: &mut Context<Self>) {
882        let Some(&i) = self.index.get(id) else { return };
883        let v = self.vis.node_slot[i];
884        if matches!(self.vis.vertices[v], Vertex::Node(_)) {
885            let body = &mut self.vis.bodies[v];
886            body.fixed = pinned.then_some(body.pos);
887        } else {
888            let body = &mut self.node_bodies[i];
889            body.fixed = pinned.then_some(body.pos);
890        }
891        self.sim.reheat(0.1);
892        cx.notify();
893    }
894
895    pub fn is_pinned(&self, id: &str) -> bool {
896        self.index.get(id).is_some_and(|&i| {
897            let v = self.vis.node_slot[i];
898            match self.vis.vertices[v] {
899                Vertex::Node(_) => self.vis.bodies[v].fixed.is_some(),
900                Vertex::Group(_) => self.node_bodies[i].fixed.is_some(),
901            }
902        })
903    }
904
905    pub fn unpin_all(&mut self, cx: &mut Context<Self>) {
906        for b in self.vis.bodies.iter_mut().chain(&mut self.node_bodies) {
907            b.fixed = None;
908        }
909        self.sim.reheat(0.3);
910        cx.notify();
911    }
912
913    // ---- groups -------------------------------------------------------------
914
915    /// Collapse a group into a single super node.
916    pub fn collapse_group(&mut self, id: &str, cx: &mut Context<Self>) {
917        self.set_groups_collapsed(|g| g.id.as_ref() == id, true, cx);
918    }
919
920    pub fn expand_group(&mut self, id: &str, cx: &mut Context<Self>) {
921        self.set_groups_collapsed(|g| g.id.as_ref() == id, false, cx);
922    }
923
924    pub fn toggle_group(&mut self, id: &str, cx: &mut Context<Self>) {
925        if let Some(&g) = self.group_index.get(id) {
926            let collapse = !self.groups[g].collapsed;
927            self.set_groups_collapsed(|s| s.id.as_ref() == id, collapse, cx);
928        }
929    }
930
931    /// Community overview: every group becomes one node.
932    pub fn collapse_all_groups(&mut self, cx: &mut Context<Self>) {
933        self.set_groups_collapsed(|_| true, true, cx);
934    }
935
936    pub fn expand_all_groups(&mut self, cx: &mut Context<Self>) {
937        self.set_groups_collapsed(|_| true, false, cx);
938    }
939
940    pub fn set_group_label(
941        &mut self,
942        id: &str,
943        label: impl Into<SharedString>,
944        cx: &mut Context<Self>,
945    ) {
946        if let Some(&g) = self.group_index.get(id) {
947            self.groups[g].label = label.into();
948            cx.notify();
949        }
950    }
951
952    fn set_groups_collapsed(
953        &mut self,
954        filter: impl Fn(&GroupState) -> bool,
955        collapse: bool,
956        cx: &mut Context<Self>,
957    ) {
958        let targets: Vec<usize> = (0..self.groups.len())
959            .filter(|&g| filter(&self.groups[g]) && self.groups[g].collapsed != collapse)
960            .collect();
961        if targets.is_empty() {
962            return;
963        }
964        self.sync_back();
965        for &g in &targets {
966            let members = self.groups[g].members.clone();
967            if collapse {
968                let n = members.len().max(1) as f32;
969                let (sx, sy) = members.iter().fold((0., 0.), |(x, y), &i| {
970                    (x + self.node_bodies[i].pos.x, y + self.node_bodies[i].pos.y)
971                });
972                let centroid = Vec2::new(sx / n, sy / n);
973                let group = &mut self.groups[g];
974                group.anchor = centroid;
975                group.body.pos = centroid;
976                group.body.vel = Vec2::default();
977                group.body.fixed = None;
978            } else {
979                let group = &self.groups[g];
980                let (dx, dy) = (
981                    group.body.pos.x - group.anchor.x,
982                    group.body.pos.y - group.anchor.y,
983                );
984                for i in members {
985                    let b = &mut self.node_bodies[i];
986                    b.pos = Vec2::new(b.pos.x + dx, b.pos.y + dy);
987                    b.vel = Vec2::default();
988                    if let Some(f) = b.fixed {
989                        b.fixed = Some(Vec2::new(f.x + dx, f.y + dy));
990                    }
991                }
992            }
993            self.groups[g].collapsed = collapse;
994        }
995        // A selected super node no longer exists once expanded.
996        if let (false, Some(SelectionKey::Group(id))) = (collapse, &self.selected)
997            && targets.iter().any(|&g| &self.groups[g].id == id)
998        {
999            self.selected = None;
1000            cx.emit(NetworkGraphEvent::SelectionChanged(Selection::None));
1001        }
1002        self.rebuild();
1003        self.sim.reheat(0.4);
1004        for g in targets {
1005            cx.emit(NetworkGraphEvent::GroupToggled {
1006                group: self.groups[g].id.clone(),
1007                collapsed: collapse,
1008            });
1009        }
1010        cx.notify();
1011    }
1012
1013    // ---- viewport -----------------------------------------------------------
1014
1015    /// Fit everything into view and keep fitting while the layout settles.
1016    pub fn fit_view(&mut self, cx: &mut Context<Self>) {
1017        self.auto_fit = true;
1018        self.apply_fit(1.);
1019        cx.notify();
1020    }
1021
1022    pub fn zoom_by(&mut self, factor: f32, cx: &mut Context<Self>) {
1023        self.auto_fit = false;
1024        let center = self.bounds.get().center();
1025        self.zoom_around(center, factor);
1026        cx.notify();
1027    }
1028
1029    /// Restart the layout from scratch (d3 `simulation.alpha(1).restart()`).
1030    pub fn relayout(&mut self, cx: &mut Context<Self>) {
1031        for b in &mut self.vis.bodies {
1032            b.fixed = None;
1033        }
1034        Simulation::initialize_positions(&mut self.vis.bodies, 0);
1035        self.sim.reheat(1.);
1036        self.auto_fit = true;
1037        cx.notify();
1038    }
1039
1040    fn apply_fit(&mut self, blend: f32) {
1041        let bounds = self.bounds.get();
1042        if self.vis.bodies.is_empty() || bounds.size.width <= px(0.) {
1043            return;
1044        }
1045        let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
1046        for b in &self.vis.bodies {
1047            x0 = x0.min(b.pos.x - b.radius);
1048            y0 = y0.min(b.pos.y - b.radius);
1049            x1 = x1.max(b.pos.x + b.radius);
1050            y1 = y1.max(b.pos.y + b.radius);
1051        }
1052        let padding = 48.;
1053        let w = (bounds.size.width.as_f32() - padding * 2.).max(1.);
1054        let h = (bounds.size.height.as_f32() - padding * 2.).max(1.);
1055        let zoom = (w / (x1 - x0).max(1.))
1056            .min(h / (y1 - y0).max(1.))
1057            .clamp(MIN_ZOOM, 2.);
1058        let (cx, cy) = ((x0 + x1) / 2., (y0 + y1) / 2.);
1059        let target = point(px(-cx * zoom), px(-cy * zoom));
1060        self.zoom += (zoom - self.zoom) * blend;
1061        self.pan = self.pan + (target - self.pan) * blend;
1062    }
1063
1064    fn zoom_around(&mut self, anchor: Point<Pixels>, factor: f32) {
1065        let world = self.to_world(anchor);
1066        self.zoom = (self.zoom * factor).clamp(MIN_ZOOM, MAX_ZOOM);
1067        let center = self.bounds.get().center();
1068        self.pan = anchor - center - point(px(world.x * self.zoom), px(world.y * self.zoom));
1069    }
1070
1071    fn to_screen(&self, p: Vec2) -> Point<Pixels> {
1072        self.bounds.get().center() + self.pan + point(px(p.x * self.zoom), px(p.y * self.zoom))
1073    }
1074
1075    fn to_world(&self, p: Point<Pixels>) -> Vec2 {
1076        let local = p - self.bounds.get().center() - self.pan;
1077        Vec2::new(local.x.as_f32() / self.zoom, local.y.as_f32() / self.zoom)
1078    }
1079
1080    fn edge_geometry(&self, e: usize) -> EdgeGeometry {
1081        let e = &self.vis.edges[e];
1082        let a = self.to_screen(self.vis.bodies[e.source].pos);
1083        let b = self.to_screen(self.vis.bodies[e.target].pos);
1084        EdgeGeometry::new(a, b, e.curvature * 28. * self.zoom.min(1.5))
1085    }
1086
1087    fn hit_test(&self, p: Point<Pixels>) -> Option<Item> {
1088        let w = self.to_world(p);
1089        let slop = 3. / self.zoom;
1090        // Topmost first: vertices are painted in index order, above edges.
1091        let vertex = self.vis.bodies.iter().enumerate().rev().find_map(|(i, b)| {
1092            let (dx, dy) = (b.pos.x - w.x, b.pos.y - w.y);
1093            let r = b.radius + slop;
1094            (dx * dx + dy * dy <= r * r).then_some(i)
1095        });
1096        if let Some(v) = vertex {
1097            return Some(Item::Vertex(v));
1098        }
1099        let mut best = (EDGE_HIT_SLOP, None);
1100        for (i, e) in self.vis.edges.iter().enumerate() {
1101            let d = if e.source == e.target {
1102                let c = self.to_screen(self.vis.bodies[e.source].pos);
1103                let s = (self.vis.bodies[e.source].radius * self.zoom).max(6.);
1104                let center = point(c.x + px(s * 1.3), c.y - px(s * 1.3));
1105                let r = (p.x - center.x).as_f32().hypot((p.y - center.y).as_f32());
1106                (r - s * 0.9).abs()
1107            } else {
1108                self.edge_geometry(i).distance_to(p)
1109            };
1110            if d < best.0 {
1111                best = (d, Some(i));
1112            }
1113        }
1114        best.1.map(Item::Edge)
1115    }
1116
1117    // ---- keyboard -----------------------------------------------------------
1118
1119    fn on_zoom_in(&mut self, _: &ZoomIn, _: &mut Window, cx: &mut Context<Self>) {
1120        self.keyboard_nav = true;
1121        self.zoom_by(1.25, cx);
1122    }
1123
1124    fn on_zoom_out(&mut self, _: &ZoomOut, _: &mut Window, cx: &mut Context<Self>) {
1125        self.keyboard_nav = true;
1126        self.zoom_by(0.8, cx);
1127    }
1128
1129    fn on_fit_view(&mut self, _: &FitView, _: &mut Window, cx: &mut Context<Self>) {
1130        self.keyboard_nav = true;
1131        self.fit_view(cx);
1132    }
1133
1134    fn on_clear_selection(&mut self, _: &ClearSelection, _: &mut Window, cx: &mut Context<Self>) {
1135        self.keyboard_nav = true;
1136        if self.selected.is_some() {
1137            self.set_selected(None, cx);
1138        } else {
1139            cx.propagate();
1140        }
1141    }
1142
1143    fn on_activate(&mut self, _: &Activate, _: &mut Window, cx: &mut Context<Self>) {
1144        self.keyboard_nav = true;
1145        match self.selected.clone() {
1146            Some(SelectionKey::Node(id)) => {
1147                if let Some(node) = self.node(&id).cloned() {
1148                    cx.emit(NetworkGraphEvent::NodeActivated(node));
1149                }
1150            }
1151            Some(SelectionKey::Group(id)) => self.toggle_group(&id, cx),
1152            _ => cx.propagate(),
1153        }
1154    }
1155
1156    fn on_open_context_menu(
1157        &mut self,
1158        _: &OpenContextMenu,
1159        _: &mut Window,
1160        cx: &mut Context<Self>,
1161    ) {
1162        self.keyboard_nav = true;
1163        let position = match self.selected_item() {
1164            Some(Item::Vertex(v)) => self.to_screen(self.vis.bodies[v].pos),
1165            Some(Item::Edge(e)) => self.edge_geometry(e).midpoint(),
1166            None => self.bounds.get().center(),
1167        };
1168        cx.emit(NetworkGraphEvent::ContextMenu {
1169            target: self.selection(),
1170            position,
1171        });
1172    }
1173
1174    /// Move the selection to the nearest vertex in a direction (spatial navigation).
1175    /// With nothing selected, start from the vertex nearest the viewport center.
1176    fn select_toward(&mut self, dx: f32, dy: f32, cx: &mut Context<Self>) {
1177        self.keyboard_nav = true;
1178        let bodies = &self.vis.bodies;
1179        if bodies.is_empty() {
1180            return;
1181        }
1182        let from = match self.selected_item() {
1183            Some(Item::Vertex(v)) => Some(v),
1184            Some(Item::Edge(e)) => Some(self.vis.edges[e].source),
1185            None => None,
1186        };
1187        let target = match from {
1188            None => {
1189                let c = self.to_world(self.bounds.get().center());
1190                (0..bodies.len()).min_by(|&a, &b| {
1191                    let d = |i: usize| (bodies[i].pos.x - c.x).hypot(bodies[i].pos.y - c.y);
1192                    d(a).total_cmp(&d(b))
1193                })
1194            }
1195            Some(f) => {
1196                let p = bodies[f].pos;
1197                (0..bodies.len())
1198                    .filter(|&i| i != f)
1199                    .filter_map(|i| {
1200                        let (x, y) = (bodies[i].pos.x - p.x, bodies[i].pos.y - p.y);
1201                        let dist = x.hypot(y).max(1e-3);
1202                        let cos = (x * dx + y * dy) / dist;
1203                        // Within ±60° of the direction; prefer straight ahead.
1204                        (cos > 0.5).then_some((i, dist * (2. - cos)))
1205                    })
1206                    .min_by(|a, b| a.1.total_cmp(&b.1))
1207                    .map(|(i, _)| i)
1208            }
1209        };
1210        let Some(v) = target else { return };
1211        self.reveal(v);
1212        let key = self.key_of(Item::Vertex(v));
1213        self.set_selected(Some(key), cx);
1214        cx.notify();
1215    }
1216
1217    /// Pan just enough to bring a vertex into view.
1218    fn reveal(&mut self, v: usize) {
1219        let bounds = self.bounds.get();
1220        let margin = px(48.);
1221        let p = self.to_screen(self.vis.bodies[v].pos);
1222        let inner = Bounds::from_corners(
1223            bounds.origin + point(margin, margin),
1224            bounds.bottom_right() - point(margin, margin),
1225        );
1226        if !inner.contains(&p) {
1227            self.auto_fit = false;
1228            let clamped = point(
1229                p.x.clamp(inner.left(), inner.right()),
1230                p.y.clamp(inner.top(), inner.bottom()),
1231            );
1232            self.pan += clamped - p;
1233        }
1234    }
1235
1236    // ---- input --------------------------------------------------------------
1237
1238    fn on_mouse_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
1239        window.focus(&self.focus_handle, cx);
1240        self.keyboard_nav = false;
1241        let hit = self.hit_test(ev.position);
1242        if ev.button == MouseButton::Right {
1243            if let Some(item) = hit {
1244                self.set_selected(Some(self.key_of(item)), cx);
1245            }
1246            let target = hit.map_or(Selection::None, |item| self.item_selection(item));
1247            cx.emit(NetworkGraphEvent::ContextMenu {
1248                target,
1249                position: ev.position,
1250            });
1251            return;
1252        }
1253        if ev.click_count >= 2 {
1254            if let Some(Item::Vertex(v)) = hit {
1255                match self.vis.vertices[v] {
1256                    Vertex::Node(i) => {
1257                        cx.emit(NetworkGraphEvent::NodeActivated(self.nodes[i].clone()))
1258                    }
1259                    Vertex::Group(g) => {
1260                        let id = self.groups[g].id.clone();
1261                        self.expand_group(&id, cx);
1262                    }
1263                }
1264            }
1265            return;
1266        }
1267        let kind = match hit {
1268            Some(Item::Vertex(ix)) => DragKind::Vertex {
1269                ix,
1270                was_pinned: self.vis.bodies[ix].fixed.is_some(),
1271            },
1272            _ => DragKind::Pan {
1273                start_pan: self.pan,
1274            },
1275        };
1276        self.drag = Some(Drag {
1277            kind,
1278            start: ev.position,
1279            moved: false,
1280        });
1281    }
1282
1283    fn on_mouse_move(&mut self, ev: &MouseMoveEvent, inside: bool, cx: &mut Context<Self>) {
1284        self.pointer = inside.then_some(ev.position);
1285        if let Some(drag) = &mut self.drag {
1286            let delta = ev.position - drag.start;
1287            if !drag.moved {
1288                if delta.x.as_f32().hypot(delta.y.as_f32()) < DRAG_THRESHOLD {
1289                    return;
1290                }
1291                drag.moved = true;
1292            }
1293            match drag.kind {
1294                DragKind::Pan { start_pan } => {
1295                    self.auto_fit = false;
1296                    self.pan = start_pan + delta;
1297                }
1298                DragKind::Vertex { ix, .. } => {
1299                    let w = self.to_world(ev.position);
1300                    self.vis.bodies[ix].fixed = Some(w);
1301                    self.vis.bodies[ix].pos = w;
1302                    self.sim.alpha_target = 0.3;
1303                    self.sim.reheat(0.3);
1304                    self.auto_fit = false;
1305                }
1306            }
1307            cx.notify();
1308            return;
1309        }
1310        let hovered = if inside {
1311            self.hit_test(ev.position)
1312        } else {
1313            None
1314        };
1315        if hovered != self.hovered || hovered.is_some() {
1316            // Also re-render while hovering so the tooltip follows the pointer.
1317            self.hovered = hovered;
1318            cx.notify();
1319        }
1320    }
1321
1322    fn on_mouse_up(&mut self, ev: &MouseUpEvent, cx: &mut Context<Self>) {
1323        let Some(drag) = self.drag.take() else { return };
1324        match drag.kind {
1325            DragKind::Vertex { ix, was_pinned } => {
1326                self.sim.alpha_target = 0.;
1327                if !drag.moved {
1328                    let key = self.key_of(Item::Vertex(ix));
1329                    self.set_selected(Some(key), cx);
1330                } else if !(self.sticky_drag || was_pinned) {
1331                    self.vis.bodies[ix].fixed = None;
1332                }
1333            }
1334            DragKind::Pan { .. } => {
1335                if !drag.moved {
1336                    let key = self.hit_test(ev.position).map(|item| self.key_of(item));
1337                    self.set_selected(key, cx);
1338                }
1339            }
1340        }
1341        cx.notify();
1342    }
1343
1344    /// A trackpad's two-finger drag pans, as in maps and design tools, and a
1345    /// pinch zooms (`on_pinch`); a mouse wheel, or any scroll with ⌘ or Ctrl
1346    /// held, zooms around the pointer.
1347    fn on_scroll(&mut self, ev: &ScrollWheelEvent, cx: &mut Context<Self>) {
1348        self.auto_fit = false;
1349        match ev.delta {
1350            ScrollDelta::Pixels(delta) if !(ev.modifiers.platform || ev.modifiers.control) => {
1351                self.pan += delta;
1352            }
1353            _ => {
1354                let delta = ev.delta.pixel_delta(px(20.));
1355                let factor = (-delta.y.as_f32() * 0.0025).exp();
1356                self.zoom_around(ev.position, factor);
1357            }
1358        }
1359        cx.notify();
1360    }
1361
1362    fn on_pinch(&mut self, ev: &PinchEvent, cx: &mut Context<Self>) {
1363        self.auto_fit = false;
1364        self.zoom_around(ev.position, (1. + ev.delta).max(0.1));
1365        cx.notify();
1366    }
1367}
1368
1369impl Render for NetworkGraph {
1370    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1371        if self.sim.is_active() && !self.vis.bodies.is_empty() {
1372            let reduce_motion = cx.reduce_motion();
1373            let vis = &mut self.vis;
1374            if reduce_motion {
1375                // Settle off-screen instead of animating the layout.
1376                let deadline = Instant::now() + REDUCED_MOTION_BUDGET;
1377                while self.sim.is_active()
1378                    && self.sim.alpha_target == 0.
1379                    && Instant::now() < deadline
1380                {
1381                    self.sim
1382                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1383                }
1384                if self.sim.alpha_target != 0. {
1385                    self.sim
1386                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1387                }
1388            } else {
1389                // Small graphs can afford two ticks per frame and settle twice as fast.
1390                let ticks = if vis.bodies.len() > 1500 { 1 } else { 2 };
1391                for _ in 0..ticks {
1392                    self.sim
1393                        .tick(&mut vis.bodies, &vis.links, &vis.degrees, &vis.clusters);
1394                }
1395            }
1396            if self.auto_fit {
1397                self.apply_fit(if reduce_motion { 1. } else { 0.25 });
1398            }
1399            if self.sim.is_active() {
1400                window.request_animation_frame();
1401            }
1402        }
1403
1404        let colors = self.resolved_colors(cx);
1405        let metrics = GraphMetrics::from_theme(cx);
1406        let tooltip = self.tooltip_element(&colors, &metrics, window, cx);
1407        let entity = cx.entity();
1408        let bounds_cell = self.bounds.clone();
1409        let background = colors.background;
1410        let show_focus = self.keyboard_nav && self.focus_handle.is_focused(window);
1411
1412        div()
1413            .id("network-graph")
1414            .track_focus(&self.focus_handle)
1415            .key_context(KEY_CONTEXT)
1416            .on_action(cx.listener(Self::on_zoom_in))
1417            .on_action(cx.listener(Self::on_zoom_out))
1418            .on_action(cx.listener(Self::on_fit_view))
1419            .on_action(cx.listener(Self::on_clear_selection))
1420            .on_action(cx.listener(Self::on_activate))
1421            .on_action(cx.listener(Self::on_open_context_menu))
1422            .on_action(cx.listener(|this, _: &SelectLeft, _, cx| this.select_toward(-1., 0., cx)))
1423            .on_action(cx.listener(|this, _: &SelectRight, _, cx| this.select_toward(1., 0., cx)))
1424            .on_action(cx.listener(|this, _: &SelectUp, _, cx| this.select_toward(0., -1., cx)))
1425            .on_action(cx.listener(|this, _: &SelectDown, _, cx| this.select_toward(0., 1., cx)))
1426            .size_full()
1427            .relative()
1428            .overflow_hidden()
1429            .bg(background)
1430            .child(
1431                canvas(
1432                    move |bounds, window, _| {
1433                        bounds_cell.set(bounds);
1434                        window.insert_hitbox(bounds, HitboxBehavior::Normal)
1435                    },
1436                    move |bounds, hitbox: Hitbox, window, cx| {
1437                        register_input(&entity, &hitbox, window, cx);
1438                        window.with_content_mask(Some(ContentMask { bounds }), |window| {
1439                            entity.update(cx, |this, cx| {
1440                                this.paint_graph(bounds, &colors, &metrics, show_focus, window, cx)
1441                            });
1442                        });
1443                    },
1444                )
1445                .size_full(),
1446            )
1447            .children(tooltip)
1448    }
1449}
1450
1451/// Mouse handling is registered at the window level during paint so drags keep
1452/// tracking outside the graph's bounds.
1453fn register_input(
1454    entity: &Entity<NetworkGraph>,
1455    hitbox: &Hitbox,
1456    window: &mut Window,
1457    cx: &mut App,
1458) {
1459    let graph = entity.read(cx);
1460    // Pointing hand is reserved for links; hover state is shown by the halo.
1461    let cursor = match &graph.drag {
1462        Some(d) if d.moved => CursorStyle::ClosedHand,
1463        _ => CursorStyle::Arrow,
1464    };
1465    window.set_cursor_style(cursor, hitbox);
1466
1467    let (e, h) = (entity.clone(), hitbox.clone());
1468    window.on_mouse_event(move |ev: &MouseDownEvent, phase, window, cx| {
1469        let button_ok = matches!(ev.button, MouseButton::Left | MouseButton::Right);
1470        if phase == DispatchPhase::Bubble && button_ok && h.is_hovered(window) {
1471            e.update(cx, |this, cx| this.on_mouse_down(ev, window, cx));
1472        }
1473    });
1474    let (e, h) = (entity.clone(), hitbox.clone());
1475    window.on_mouse_event(move |ev: &MouseMoveEvent, phase, window, cx| {
1476        if phase != DispatchPhase::Bubble {
1477            return;
1478        }
1479        let inside = h.is_hovered(window);
1480        let graph = e.read(cx);
1481        if inside || graph.drag.is_some() || graph.hovered.is_some() {
1482            e.update(cx, |this, cx| this.on_mouse_move(ev, inside, cx));
1483        }
1484    });
1485    let e = entity.clone();
1486    window.on_mouse_event(move |ev: &MouseUpEvent, phase, _, cx| {
1487        if phase == DispatchPhase::Bubble && ev.button == MouseButton::Left {
1488            e.update(cx, |this, cx| this.on_mouse_up(ev, cx));
1489        }
1490    });
1491    let (e, h) = (entity.clone(), hitbox.clone());
1492    window.on_mouse_event(move |ev: &ScrollWheelEvent, phase, window, cx| {
1493        if phase == DispatchPhase::Bubble && h.should_handle_scroll(window) {
1494            e.update(cx, |this, cx| this.on_scroll(ev, cx));
1495        }
1496    });
1497    let (e, h) = (entity.clone(), hitbox.clone());
1498    window.on_mouse_event(move |ev: &PinchEvent, phase, window, cx| {
1499        if phase == DispatchPhase::Bubble && h.is_hovered(window) {
1500            e.update(cx, |this, cx| this.on_pinch(ev, cx));
1501        }
1502    });
1503}