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