Skip to main content

egui_map/
map.rs

1//! Interactive map widget and the data types it renders.
2//!
3//! [`Map`] is an [`egui::Widget`] that draws a 2D set of nodes
4//! ([`objects::MapPoint`]), the connection lines between them
5//! ([`objects::MapSegment`]) and free-floating text labels
6//! ([`objects::MapLabel`]). Nodes are indexed in a kd-tree so that only the
7//! ones inside the current viewport are painted each frame.
8//!
9//! ## Coordinate model
10//!
11//! The widget works with two coordinate spaces:
12//!
13//! - **Map coordinates**: the logical position of your nodes, as loaded through
14//!   [`Map::add_hashmap_points`].
15//! - **Screen coordinates**: positions inside the widget's rectangle on screen.
16//!
17//! Both are related by the current zoom factor and viewport origin:
18//! `screen = map * zoom - origin`. Use [`Map::set_zoom`], [`Map::set_pos`] and
19//! [`Map::set_pos_from_nodeid`] to control the visible region.
20//!
21//! ## Connecting nodes with lines
22//!
23//! Lines are wired up in three steps:
24//!
25//! 1. Create the nodes as a [`HashMap`] keyed by node id.
26//! 2. For every connection, choose a unique `(usize, usize)` id -- typically
27//!    the pair of node ids it joins -- and push it into
28//!    [`MapPoint::connections`] of **both** endpoint nodes.
29//! 3. Load the nodes with [`Map::add_hashmap_points`], then load a
30//!    [`HashMap`] of [`MapSegment`] keyed by those same connection ids and
31//!    add it to the widget with [`Map::add_hashmap_lines`].
32//!
33//! ```
34//! use egui_map::map::Map;
35//! use egui_map::map::objects::{MapPoint, MapSegment};
36//! use std::collections::HashMap;
37//!
38//! // 1. Create the nodes.
39//! let mut points: HashMap<usize, MapPoint> = HashMap::new();
40//! points.insert(1, MapPoint::new(1, [0.0, 0.0]));
41//! points.insert(2, MapPoint::new(2, [10.0, 10.0]));
42//!
43//! // 2. Register the connection id on both endpoints.
44//! for id in [1, 2] {
45//!     points.get_mut(&id).unwrap().connections.push((1, 2));
46//! }
47//!
48//! let mut map = Map::new();
49//! map.add_hashmap_points(points);
50//!
51//! // 3. Provide the line geometry keyed by the same connection id.
52//! let mut lines: HashMap<(usize, usize), MapSegment> = HashMap::new();
53//! lines.insert((1, 2), MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
54//! map.add_hashmap_lines(lines);
55//! ```
56//!
57//! A line is only drawn while the zoom level is above
58//! [`MapSettings::line_visible_zoom`] and its bounding box intersects the
59//! viewport. Segments are culled broad-phase with an R-tree built by
60//! [`Map::add_lines`], so long lines crossing the view are drawn even when
61//! both endpoints lie outside of it.
62//!
63//! ## Custom node rendering
64//!
65//! Install a [`NodeTemplate`] implementation with [`Map::set_node_template`]
66//! to take over the rendering of nodes, selection highlights, notification
67//! animations and markers. Note that this replaces
68//! *all* built-in node rendering, including the node name labels: draw them
69//! yourself in [`NodeTemplate::node_ui`] if you need them.
70
71use crate::map::animation::Animation;
72use crate::map::objects::{
73    ContextMenuManager, MapBounds, MapLabel, MapPoint, MapSegment, MapSettings, MapStyle,
74    NodeAnimation, RawLine, RawPoint, SteadyAnimation, TextSettings, VisibilitySetting,
75};
76use egui::{widgets::*, *};
77use kdtree::KdTree;
78use kdtree::distance::squared_euclidean;
79use std::collections::HashMap;
80use std::rc::Rc;
81use std::time::Instant;
82
83use self::objects::NodeTemplate;
84
85pub mod animation;
86pub mod objects;
87
88/// An interactive 2D map widget.
89///
90/// `Map` renders a set of nodes ([`objects::MapPoint`]), connection lines
91/// ([`objects::MapSegment`]) and text labels ([`objects::MapLabel`]). The user can
92/// pan the view by dragging and zoom with the mouse wheel (hold `Ctrl` — or
93/// `Cmd` on macOS — to zoom faster), or use the built-in zoom slider drawn at
94/// the top-right corner of the widget.
95///
96/// The map is fed through [`Map::add_hashmap_points`], which also builds the
97/// internal kd-tree used for viewport culling and nearest-node hover queries.
98/// Behavior and appearance are configured through the public
99/// [`settings`](Map::settings) field (see [`objects::MapSettings`]).
100///
101/// Rendering of nodes and their visual effects (selection highlight,
102/// notifications and markers) can be fully customized by installing a
103/// [`objects::NodeTemplate`] implementation with [`Map::set_node_template`];
104/// likewise, a right-click context menu can be provided with
105/// [`Map::set_context_manager`].
106///
107/// # Examples
108///
109/// ```no_run
110/// # fn example(ui: &mut egui::Ui) {
111/// use egui_map::map::Map;
112/// use egui_map::map::objects::MapPoint;
113/// use std::collections::HashMap;
114///
115/// let mut points = HashMap::new();
116/// points.insert(1, MapPoint::new(1, [0.0, 0.0]));
117///
118/// let mut map = Map::new();
119/// map.add_hashmap_points(points);
120///
121/// // Every frame, inside your egui update logic:
122/// ui.add(&mut map);
123/// # }
124/// ```
125#[derive(Clone)]
126pub struct Map {
127    zoom: f32,
128    previous_zoom: f32,
129    points: Option<HashMap<usize, MapPoint>>,
130    segments: Option<rstar::RTree<MapSegment>>,
131    labels: Vec<MapLabel>,
132    tree: Option<KdTree<f32, usize, [f32; 2]>>,
133    visible_points: Vec<isize>,
134    map_area: Rect,
135    reference: MapBounds,
136    current: MapBounds,
137    current_index: usize,
138    notifications: HashMap<usize, Notification>,
139    node_states: HashMap<usize, NodeState>,
140    min_size: (Option<f32>, Option<f32>),
141    max_size: (Option<f32>, Option<f32>),
142    /// Behavior and appearance configuration (zoom limits, visibility
143    /// thresholds and per-theme styles). See [`objects::MapSettings`].
144    pub settings: MapSettings,
145    menu_manager: Option<Rc<dyn ContextMenuManager>>,
146    node_template: Option<Rc<dyn NodeTemplate>>,
147    markers: HashMap<usize, usize>,
148}
149
150/// A one-off effect attached to a node, with the moment it started.
151#[derive(Clone, Copy, Debug)]
152struct Notification {
153    started: Instant,
154    animation: NodeAnimation,
155    /// `None` falls back to the current style's `alert_color`.
156    color: Option<Color32>,
157}
158
159/// Lasting state attached to a node, drawn until it is cleared.
160#[derive(Clone, Copy, Debug)]
161struct NodeState {
162    animation: SteadyAnimation,
163    /// `None` falls back to the current style's `alert_color`.
164    color: Option<Color32>,
165}
166
167/// A borrowed node, obtained from [`Map::node`], that an animation can be
168/// attached to.
169///
170/// Modifiers such as [`NodeHandle::color`] come first; the effect method is the
171/// terminal call that writes everything at once. A modifier on its own does
172/// nothing, so there is no way to configure a notification that does not exist.
173///
174/// Effects come in two families, and which one you call decides how it ends:
175///
176/// - [`pulse`](Self::pulse), [`ripple`](Self::ripple),
177///   [`countdown`](Self::countdown), [`scale_in`](Self::scale_in) and
178///   [`crosshair`](Self::crosshair) play once from the [`Instant`] you pass and
179///   stop on their own.
180/// - [`halo`](Self::halo), [`blink`](Self::blink) and [`orbit`](Self::orbit)
181///   are lasting state: they run until [`clear`](Self::clear), and keep the app
182///   repainting the whole time.
183///
184/// A node can carry one of each at once; the state is drawn underneath the
185/// event.
186pub struct NodeHandle<'a> {
187    map: &'a mut Map,
188    id: usize,
189    color: Option<Color32>,
190}
191
192impl NodeHandle<'_> {
193    /// Overrides the colour of the effect about to be attached.
194    ///
195    /// Without this the effect uses the current style's `alert_color`.
196    pub fn color(mut self, color: Color32) -> Self {
197        self.color = Some(color);
198        self
199    }
200
201    fn notify_with(self, animation: NodeAnimation, at: Instant) {
202        self.map.notifications.insert(
203            self.id,
204            Notification {
205                started: at,
206                animation,
207                color: self.color,
208            },
209        );
210    }
211
212    fn set_state(self, animation: SteadyAnimation) {
213        self.map.node_states.insert(
214            self.id,
215            NodeState {
216                animation,
217                color: self.color,
218            },
219        );
220    }
221
222    /// Expanding, fading disc. Reads as "one thing happened here".
223    pub fn pulse(self, at: Instant) {
224        self.notify_with(NodeAnimation::Pulse, at);
225    }
226
227    /// Three staggered expanding rings. Reads as "activity is ongoing".
228    pub fn ripple(self, at: Instant) {
229        self.notify_with(NodeAnimation::Ripple, at);
230    }
231
232    /// A ring emptying clockwise. Reads as "how old is this information".
233    pub fn countdown(self, at: Instant) {
234        self.notify_with(NodeAnimation::CountdownArc, at);
235    }
236
237    /// A disc that overshoots and settles. For a node that just appeared.
238    pub fn scale_in(self, at: Instant) {
239        self.notify_with(NodeAnimation::ScaleIn, at);
240    }
241
242    /// Four ticks converging on the node. Reads as "target acquired".
243    pub fn crosshair(self, at: Instant) {
244        self.notify_with(NodeAnimation::Crosshair, at);
245    }
246
247    /// Lasting ring whose opacity breathes. Runs until [`Self::clear`].
248    pub fn halo(self) {
249        self.set_state(SteadyAnimation::Halo);
250    }
251
252    /// Lasting thick ring blinking on and off. Runs until [`Self::clear`].
253    pub fn blink(self) {
254        self.set_state(SteadyAnimation::Blink);
255    }
256
257    /// Lasting dot circling the node. Runs until [`Self::clear`].
258    pub fn orbit(self) {
259        self.set_state(SteadyAnimation::Orbit);
260    }
261
262    /// Removes both the notification and the lasting state of this node.
263    pub fn clear(self) {
264        self.map.notifications.remove(&self.id);
265        self.map.node_states.remove(&self.id);
266    }
267}
268
269impl Default for Map {
270    /// Creates an empty map; equivalent to [`Map::new`].
271    fn default() -> Self {
272        Map::new()
273    }
274}
275
276impl Widget for &mut Map {
277    /// Renders the map, handling panning (drag), zooming (mouse wheel) and the
278    /// right-click context menu if one was installed.
279    fn ui(self, ui: &mut egui::Ui) -> Response {
280        let rect = self.calculate_widget_dimensions(ui);
281
282        // we define the initial coordinate as the center of such rectangle
283        self.reference.dist = rect.distance();
284
285        self.assign_visual_style(ui);
286
287        let canvas = egui::Frame::canvas(ui.style()).inner_margin(Margin::symmetric(3, 5));
288
289        // The frame consumes `total_margin` (inner margin + stroke width +
290        // outer margin) around whatever is drawn inside it. `map_area` is the
291        // widget's *whole* footprint, so the painter may only claim what is
292        // left after that margin. Allocating `map_area.size()` inside the
293        // frame made the frame grow to `map_area.size() + 2 * total_margin`
294        // and spill past the space the widget was given, which left the
295        // visible drawable region truncated on the right/bottom -- so its
296        // centre no longer matched the point `set_pos`/`set_pos_from_nodeid`
297        // centre on, and nodes were drawn half a margin off.
298        let frame_margin = canvas.total_margin().sum();
299        let painter_size = (self.map_area.size() - frame_margin).max(Vec2::ZERO);
300
301        let inner_response = canvas.show(ui, |ui| {
302            let _span = tracing::info_span!("paint_map").entered();
303
304            if ui.is_rect_visible(self.map_area) {
305                let (resp, paint) =
306                    ui.allocate_painter(painter_size, egui::Sense::click_and_drag());
307                let vec = resp.drag_delta();
308                if vec.length() != 0.0 {
309                    let _span = tracing::info_span!("calculating_points_in_visible_area").entered();
310
311                    let coords = RawPoint::from(vec.to_pos2());
312                    let new_pos = self.reference.pos - (coords / self.zoom);
313                    self.set_pos(new_pos.into());
314                }
315                if self.zoom < self.settings.line_visible_zoom {
316                    // filling text settings
317                    let mut text_settings = TextSettings {
318                        // Screen-space size: unlike the map geometry this is
319                        // NOT multiplied by the zoom, so the label stays just
320                        // as readable however far the map is zoomed out.
321                        size: self.settings.label_text_size,
322                        anchor: Align2::CENTER_CENTER,
323                        family: FontFamily::Proportional,
324                        text: String::new(),
325                        position: RawPoint::default(),
326                        text_color: ui.visuals().text_color(),
327                    };
328                    for label in &self.labels {
329                        text_settings.text.clone_from(&label.text);
330                        text_settings.position = RawPoint::from(label.center);
331                        self.paint_label(&paint, &text_settings);
332                    }
333                }
334
335                // Centre on the rect we actually paint into. Now that the
336                // painter is sized to the frame's content area this is exactly
337                // `map_area.center()`, but deriving it from `resp.rect` keeps
338                // projection, hover hit-testing and the frame in agreement if
339                // the frame's margins ever change.
340                let rect_midpoint = RawPoint::from(resp.rect.center());
341                let min_point = self.current.pos - rect_midpoint;
342                let vec_points = &self.visible_points;
343                let hashm = &self.points;
344
345                // Safety net: drop stale notifications even if their node is
346                // outside the viewport and never finishes its animation.
347                let now = Instant::now();
348                self.notifications
349                    .retain(|_, n| now.duration_since(n.started).as_secs_f32() < 10.0);
350
351                self.paint_map_lines(&paint, &min_point);
352
353                if let Ok(nodes_to_remove) =
354                    self.paint_map_points(vec_points, hashm, &paint, ui, &min_point, &resp)
355                {
356                    for node in nodes_to_remove {
357                        self.notifications.remove(&node);
358                    }
359                }
360
361                for marker in &self.markers {
362                    if let Some(point) = self.points.as_ref().unwrap().get(marker.1) {
363                        let adjusted_point = RawPoint::from(point.coords) * self.zoom - min_point;
364                        if let Some(template) = &self.node_template {
365                            template.marker_ui(ui, adjusted_point.into(), self.zoom);
366                        } else {
367                            let color = if ui.visuals().dark_mode {
368                                Color32::LIGHT_GREEN
369                            } else {
370                                Color32::GREEN
371                            };
372                            // Frame time, so every marker in this frame shares
373                            // one clock instead of each sampling the wall clock
374                            // at a slightly different moment.
375                            let time = ui.input(|i| i.time) as f32;
376                            let effect = match self.settings.marker_animation {
377                                SteadyAnimation::Blink => Animation::blink,
378                                SteadyAnimation::Halo => Animation::halo,
379                                SteadyAnimation::Orbit => Animation::orbit,
380                            };
381                            effect(ui.painter(), adjusted_point.into(), self.zoom, time, color);
382                            // Persistent effects never finish on their own.
383                            ui.ctx().request_repaint();
384                        }
385                    }
386                }
387
388                self.paint_sub_components(ui, self.map_area);
389
390                self.capture_mouse_events(ui, &resp);
391
392                if self.zoom != self.previous_zoom {
393                    let _span = tracing::info_span!("calculating viewport with zoom").entered();
394                    self.adjust_bounds();
395                    self.calculate_visible_points();
396                    self.previous_zoom = self.zoom;
397                }
398
399                if let Some(menu_mon) = &mut self.menu_manager {
400                    resp.context_menu(|ui| {
401                        menu_mon.ui(ui);
402                    });
403                }
404
405                #[cfg(feature = "debug_overlay")]
406                self.print_debug_info(ui, &resp);
407            }
408        });
409        // `Frame::show` already allocated the frame's outer rect in the parent
410        // `Ui` (that is what `inner_response.response.rect` reports), so the
411        // widget must not allocate `map_area` a second time -- doing so made
412        // it consume twice its own height in the surrounding layout.
413        inner_response.response
414    }
415}
416
417impl Map {
418    /// Creates an empty map widget with default [`MapSettings`].
419    ///
420    /// The widget displays nothing until nodes are loaded with
421    /// [`Map::add_hashmap_points`].
422    pub fn new() -> Self {
423        let settings = MapSettings::default();
424        Self {
425            zoom: 1.0,
426            previous_zoom: 1.0,
427            map_area: Rect::NOTHING,
428            tree: None,
429            points: None,
430            labels: Vec::new(),
431            visible_points: Vec::new(),
432            current: MapBounds::default(),
433            reference: MapBounds::default(),
434            settings,
435            min_size: (None, None),
436            max_size: (None, None),
437            current_index: 0,
438            notifications: HashMap::new(),
439            node_states: HashMap::new(),
440            menu_manager: None,
441            node_template: None,
442            markers: HashMap::new(),
443            segments: None,
444        }
445    }
446
447    fn calculate_widget_dimensions(&mut self, ui: &mut Ui) -> RawLine {
448        let available = ui.available_rect_before_wrap();
449        let mut size = available.size();
450        if let Some(max_width) = self.max_size.0 {
451            size.x = size.x.min(max_width);
452        }
453        if let Some(max_height) = self.max_size.1 {
454            size.y = size.y.min(max_height);
455        }
456        if let Some(min_width) = self.min_size.0 {
457            size.x = size.x.max(min_width);
458        }
459        if let Some(min_height) = self.min_size.1 {
460            size.y = size.y.max(min_height);
461        }
462        self.map_area = Rect::from_min_size(available.min, size);
463        RawLine::new(
464            RawPoint::from(self.map_area.left_top()),
465            RawPoint::from(self.map_area.right_bottom()),
466        )
467    }
468
469    fn calculate_visible_points(&mut self) {
470        let _span = tracing::info_span!("calculate_visible_points").entered();
471        if self.current.dist > 0.0
472            && self.current.dist < f32::INFINITY
473            && let Some(tree) = &self.tree
474        {
475            let center = self.current.pos / self.zoom;
476            let radius = self.current.dist.powi(2);
477            let point: [f32; 2] = center.into();
478            let vis_pos = tree.within(&point, radius, &squared_euclidean).unwrap();
479            self.visible_points.clear();
480            for point in vis_pos {
481                self.visible_points.push(point.1.cast_signed());
482            }
483        }
484    }
485
486    /// Loads the node set and (re)builds the spatial index.
487    ///
488    /// This replaces any previously loaded points, computes the bounding box of
489    /// the whole set, centers the view on its midpoint and refreshes the list
490    /// of visible nodes. It must be called at least once before the widget can
491    /// display anything.
492    ///
493    /// The kd-tree built here is what enables viewport culling and
494    /// nearest-neighbor hover lookups, so calling this method on every frame is
495    /// discouraged; call it only when the node set changes.
496    ///
497    /// # Examples
498    ///
499    /// ```
500    /// use egui_map::map::Map;
501    /// use egui_map::map::objects::MapPoint;
502    ///
503    /// let mut points = Vec::new();
504    /// points.push(MapPoint::new(1, [0.0, 0.0]));
505    /// points.push(MapPoint::new(2, [10.0, 10.0]));
506    ///
507    /// let mut map = Map::new();
508    /// map.add_points(points);
509    ///
510    /// // The view is centered on the midpoint of the loaded nodes.
511    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
512    /// ```
513    pub fn add_points(&mut self, points: Vec<MapPoint>) {
514        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
515        let mut hash_map = HashMap::new();
516        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
517        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
518        for entry in points {
519            for i in 0..min.components.len() {
520                if entry.coords[i] < min.components[i] {
521                    min.components[i] = entry.coords[i];
522                }
523                if entry.coords[i] > max.components[i] {
524                    max.components[i] = entry.coords[i];
525                }
526            }
527            let _result = tree.add(entry.coords, entry.get_id());
528            hash_map.insert(entry.get_id(), entry);
529        }
530        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
531        self.reference.min = min;
532        self.reference.max = max;
533        self.points = Some(hash_map);
534        self.tree = Some(tree);
535        self.reference.pos = RawLine::new(min, max).midpoint();
536        // we create a rect that include every node in the map
537        // Stupid fix because rect area could be infinite
538        // I need to implement a more elegant fix
539        if self.map_area.area() == 0.0 {
540            self.reference.dist = 3000.00;
541        } else {
542            let rect = RawLine::new(
543                RawPoint::from(self.map_area.left_top()),
544                RawPoint::from(self.map_area.right_bottom()),
545            );
546            self.reference.dist = rect.distance();
547        }
548        self.current = self.reference.clone();
549        self.calculate_visible_points();
550    }
551
552    /// Loads the node set and (re)builds the spatial index.
553    ///
554    /// This replaces any previously loaded points, computes the bounding box of
555    /// the whole set, centers the view on its midpoint and refreshes the list
556    /// of visible nodes. It must be called at least once before the widget can
557    /// display anything.
558    ///
559    /// The kd-tree built here is what enables viewport culling and
560    /// nearest-neighbor hover lookups, so calling this method on every frame is
561    /// discouraged; call it only when the node set changes.
562    ///
563    /// # Examples
564    ///
565    /// ```
566    /// use egui_map::map::Map;
567    /// use egui_map::map::objects::MapPoint;
568    /// use std::collections::HashMap;
569    ///
570    /// let mut points = HashMap::new();
571    /// points.insert(1, MapPoint::new(1, [0.0, 0.0]));
572    /// points.insert(2, MapPoint::new(2, [10.0, 10.0]));
573    ///
574    /// let mut map = Map::new();
575    /// map.add_hashmap_points(points);
576    ///
577    /// // The view is centered on the midpoint of the loaded nodes.
578    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
579    /// ```
580    //#[deprecated(since="0.2.3", note="please use `add_points` instead")]
581    pub fn add_hashmap_points(&mut self, hash_map: HashMap<usize, MapPoint>) {
582        let _span = tracing::info_span!("add_hashmap_points").entered();
583        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
584        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
585        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
586
587        for entry in hash_map.iter() {
588            for i in 0..min.components.len() {
589                if entry.1.coords[i] < min.components[i] {
590                    min.components[i] = entry.1.coords[i];
591                }
592                if entry.1.coords[i] > max.components[i] {
593                    max.components[i] = entry.1.coords[i];
594                }
595            }
596            let _result = tree.add(entry.1.coords, *entry.0);
597        }
598
599        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
600        self.reference.min = min;
601        self.reference.max = max;
602        self.points = Some(hash_map);
603        self.tree = Some(tree);
604        self.reference.pos = RawLine::new(min, max).midpoint();
605        // we create a rect that include every node in the map
606        // Stupid fix because rect area could be infinite
607        // I need to implement a more elegant fix
608        if self.map_area.area() == 0.0 {
609            self.reference.dist = 3000.00;
610        } else {
611            let rect = RawLine::new(
612                RawPoint::from(self.map_area.left_top()),
613                RawPoint::from(self.map_area.right_bottom()),
614            );
615            self.reference.dist = rect.distance();
616        }
617        self.current = self.reference.clone();
618        self.calculate_visible_points();
619    }
620
621    /// Centers the view on the node with the given id.
622    ///
623    /// Returns `true` if the view moved. Returns `false` — leaving the view
624    /// untouched — when no points have been loaded yet or when `node_id` is
625    /// not among them; that case also emits a `tracing` warning, since a
626    /// silently ignored id is otherwise indistinguishable from a node that
627    /// was centered but drawn in the wrong place.
628    ///
629    /// A `false` here usually means the id belongs to a different set than
630    /// the one loaded through [`Map::add_hashmap_points`] — for example a
631    /// map showing only part of the universe, or ids coming from a different
632    /// query than the one that produced the nodes.
633    ///
634    /// ```
635    /// use egui_map::map::Map;
636    /// use egui_map::map::objects::MapPoint;
637    ///
638    /// let mut map = Map::new();
639    /// map.add_points(vec![MapPoint::new(1, [10.0, 20.0])]);
640    ///
641    /// assert!(map.set_pos_from_nodeid(1));
642    /// assert_eq!(map.get_pos(), [10.0, 20.0]);
643    ///
644    /// // Unknown id: the view stays where it was.
645    /// assert!(!map.set_pos_from_nodeid(999));
646    /// assert_eq!(map.get_pos(), [10.0, 20.0]);
647    /// ```
648    pub fn set_pos_from_nodeid(&mut self, node_id: usize) -> bool {
649        let _span = tracing::info_span!("set_pos_from_nodeid").entered();
650        if let Some(hash_map) = &self.points
651            && let Some(map_point) = hash_map.get(&node_id)
652        {
653            self.reference.pos = RawPoint::from(map_point.coords);
654            self.adjust_bounds();
655            self.calculate_visible_points();
656            true
657        } else {
658            tracing::warn!(
659                node_id,
660                loaded_nodes = self.points.as_ref().map_or(0, |p| p.len()),
661                "set_pos_from_nodeid: unknown node id, the view was left unchanged"
662            );
663            false
664        }
665    }
666
667    /// Centers the view on the given map coordinates.
668    pub fn set_pos(&mut self, position: [f32; 2]) {
669        let _span = tracing::info_span!("set_pos").entered();
670        let point = RawPoint::from(position);
671        self.reference.pos = point;
672        self.adjust_bounds();
673        self.calculate_visible_points();
674    }
675
676    /// Returns the map coordinates the view is currently centered on.
677    pub fn get_pos(&self) -> [f32; 2] {
678        let _span = tracing::info_span!("get_pos").entered();
679        self.reference.pos.into()
680    }
681
682    /// Replaces the set of free-floating text labels drawn on the map.
683    ///
684    /// Labels are only rendered while the zoom level is below
685    /// [`MapSettings::line_visible_zoom`].
686    pub fn add_labels(&mut self, labels: Vec<MapLabel>) {
687        let _span = tracing::info_span!("add_labels").entered();
688        self.labels = labels;
689    }
690
691    /// Replaces the set of connection lines between nodes.
692    ///
693    /// Lines are keyed by a connection id that the endpoint nodes must
694    /// reference through [`MapPoint::connections`] — push each line's key into
695    /// the `connections` of the nodes it joins. The segments are stored in an
696    /// R-tree keyed by bounding box: a line is drawn while its bounding box
697    /// intersects the viewport and the zoom level is above
698    /// [`MapSettings::line_visible_zoom`].
699    ///
700    /// See the [module-level example](self#connecting-nodes-with-lines) for
701    /// the complete wiring.
702    pub fn add_lines(&mut self, segments: Vec<MapSegment>) {
703        let _span = tracing::info_span!("add_lines").entered();
704        // Intern the keys as Rc<str> and build the broad-phase spatial index
705        // over the line bounding boxes, so viewport culling and hit-testing
706        // discard whole regions without touching every segment.
707
708        self.segments = Some(rstar::RTree::bulk_load(segments));
709    }
710
711    /// Replaces the set of connection lines between nodes, from a map keyed
712    /// by the same `(usize, usize)` id used in [`MapSegment::id`] and
713    /// referenced by [`MapPoint::connections`].
714    ///
715    /// Equivalent to [`add_lines`](Self::add_lines) but avoids callers having
716    /// to collect their segments into a `Vec` first when they already have
717    /// them keyed in a `HashMap` (e.g. straight from an adapter that mirrors
718    /// them 1:1 by id, with no intermediate ordering to preserve).
719    pub fn add_hashmap_lines(&mut self, segments: HashMap<(usize, usize), MapSegment>) {
720        let _span = tracing::info_span!("add_hashmap_lines").entered();
721        let segments: Vec<MapSegment> = segments.into_values().collect();
722        self.segments = Some(rstar::RTree::bulk_load(segments));
723    }
724
725    fn adjust_bounds(&mut self) {
726        let _span = tracing::info_span!("adjust_bounds").entered();
727        self.current.max = self.reference.max * self.zoom;
728        self.current.min = self.reference.min * self.zoom;
729        self.current.dist = self.reference.dist / self.zoom;
730        self.current.pos = self.reference.pos * self.zoom;
731    }
732
733    fn capture_mouse_events(&mut self, ui: &Ui, _resp: &Response) {
734        let _span = tracing::info_span!("capture_mouse_events").entered();
735        // capture MouseWheel Event for Zoom control change
736        if ui.rect_contains_pointer(self.map_area) {
737            ui.input(|x| {
738                let _span = tracing::info_span!("capture_mouse_events_input").entered();
739
740                if !x.events.is_empty() {
741                    for event in &x.events {
742                        match event {
743                            Event::MouseWheel {
744                                unit: _,
745                                delta,
746                                modifiers,
747                                phase: _,
748                            } => {
749                                #[cfg(target_os = "macos")]
750                                let zoom_modifier = if modifiers.mac_cmd {
751                                    delta.y / 80.00
752                                } else {
753                                    delta.y / 400.00
754                                };
755
756                                #[cfg(not(target_os = "macos"))]
757                                let zoom_modifier = if modifiers.ctrl {
758                                    delta.y / 8.00
759                                } else {
760                                    delta.y / 40.00
761                                };
762
763                                let mut pre_zoom = self.zoom + zoom_modifier;
764                                if pre_zoom > self.settings.max_zoom {
765                                    pre_zoom = self.settings.max_zoom;
766                                }
767                                if pre_zoom < self.settings.min_zoom {
768                                    pre_zoom = self.settings.min_zoom;
769                                }
770                                self.zoom = pre_zoom;
771                            }
772                            _ => {
773                                continue;
774                            }
775                        };
776                    }
777                }
778            });
779        }
780    }
781
782    /// Sets the zoom factor.
783    ///
784    /// Values outside the [`MapSettings::min_zoom`]..=[`MapSettings::max_zoom`]
785    /// range are ignored.
786    pub fn set_zoom(&mut self, value: f32) {
787        if value >= self.settings.min_zoom && value <= self.settings.max_zoom {
788            self.zoom = value;
789        }
790    }
791
792    /// Returns the current zoom factor.
793    pub fn get_zoom(&mut self) -> f32 {
794        self.zoom
795    }
796
797    /// Returns the style for the current theme, falling back to the first
798    /// style if the current theme index has no entry.
799    fn current_style(&self) -> &MapStyle {
800        self.settings
801            .styles
802            .get(self.current_index)
803            .or(self.settings.styles.first())
804            .expect("MapSettings::styles must not be empty")
805    }
806
807    fn assign_visual_style(&mut self, ui_obj: &mut Ui) {
808        let style_index = ui_obj.visuals().dark_mode as usize;
809
810        if self.current_index != style_index {
811            let _span = tracing::info_span!("asign_visual_style").entered();
812
813            self.current_index = style_index;
814            let map_style = self.settings.styles.get_mut(style_index).unwrap();
815            let visuals = &ui_obj.style().visuals;
816            map_style.background_color = visuals.extreme_bg_color;
817            map_style.border = Some(visuals.window_stroke);
818        }
819    }
820
821    /// Floating debug read-out, compiled in only under the `debug_overlay`
822    /// feature.
823    ///
824    /// Deliberately unobtrusive: it renders as a collapsed `dbg` toggle in the
825    /// map's top-left corner with no background of its own, so it costs a few
826    /// dim pixels until a developer clicks it open. egui remembers the
827    /// open/closed state per widget instance, so it stays open across frames
828    /// once expanded.
829    #[cfg(feature = "debug_overlay")]
830    fn print_debug_info(&mut self, ui: &mut Ui, resp: &Response) {
831        let _span = tracing::info_span!("printing debug data").entered();
832
833        let p = |v: f32| format!("{v:.2}");
834        let mut rows: Vec<(String, Color32)> = vec![
835            (
836                format!(
837                    "MIN {}, {}",
838                    p(self.current.min.components[0]),
839                    p(self.current.min.components[1])
840                ),
841                Color32::LIGHT_GREEN,
842            ),
843            (
844                format!(
845                    "MAX {}, {}",
846                    p(self.current.max.components[0]),
847                    p(self.current.max.components[1])
848                ),
849                Color32::LIGHT_GREEN,
850            ),
851            (
852                format!(
853                    "CUR {}, {}",
854                    p(self.current.pos.components[0]),
855                    p(self.current.pos.components[1])
856                ),
857                Color32::LIGHT_GREEN,
858            ),
859            (
860                format!("DST {}", p(self.current.dist)),
861                Color32::LIGHT_GREEN,
862            ),
863            (format!("ZOM {}", self.zoom), Color32::GREEN),
864            (
865                format!(
866                    "REC {}, {} .. {}, {}",
867                    p(self.map_area.left_top().x),
868                    p(self.map_area.left_top().y),
869                    p(self.map_area.right_bottom().x),
870                    p(self.map_area.right_bottom().y)
871                ),
872                Color32::LIGHT_GREEN,
873            ),
874        ];
875        if let Some(points) = &self.points {
876            rows.push((format!("NUM {}", points.len()), Color32::LIGHT_GREEN));
877        }
878        if !self.visible_points.is_empty() {
879            rows.push((
880                format!("VIS {}", self.visible_points.len()),
881                Color32::LIGHT_GREEN,
882            ));
883        }
884        if let Some(pointer_pos) = resp.hover_pos() {
885            rows.push((
886                format!("HVR {}, {}", p(pointer_pos.x), p(pointer_pos.y)),
887                Color32::LIGHT_BLUE,
888            ));
889        }
890        let drag = resp.drag_delta();
891        if drag.length() != 0.0 {
892            rows.push((format!("DRG {}, {}", p(drag.x), p(drag.y)), Color32::GOLD));
893        }
894
895        // Drawn into a *detached* child `Ui` in the map's own layer.
896        //
897        // `new_child` alone does not call `advance_cursor_after_rect`, so the
898        // overlay never contributes to the parent's `min_rect`. That matters:
899        // the canvas `Frame` sizes itself from its content's `min_rect`, and
900        // letting this grow it would push the frame past the space the widget
901        // was given -- exactly the overflow that used to knock the map
902        // off-centre. Being laid out after the map's painter also means the
903        // toggle wins pointer input over the pan/zoom surface underneath.
904        let overlay_rect = Rect::from_min_max(
905            self.map_area.left_top() + Vec2::new(6.0, 6.0),
906            self.map_area.right_bottom(),
907        );
908        let mut overlay_ui = ui.new_child(
909            UiBuilder::new()
910                .max_rect(overlay_rect)
911                .layout(Layout::top_down(Align::Min)),
912        );
913        // No frame and no header background: the map shows straight through,
914        // so this costs a few dim pixels until someone opens it.
915        CollapsingHeader::new(RichText::new("dbg").monospace().small().weak())
916            .id_salt("egui_map_debug_overlay")
917            .default_open(false)
918            .show_background(false)
919            .show(&mut overlay_ui, |ui| {
920                for (text, color) in rows {
921                    ui.label(RichText::new(text).monospace().small().color(color));
922                }
923            });
924    }
925
926    fn paint_sub_components(&mut self, ui_obj: &mut Ui, rect: Rect) {
927        let _span = tracing::info_span!("map_ui_paint_sub_components").entered();
928        let zoom_slider = egui::Slider::new(
929            &mut self.zoom,
930            self.settings.min_zoom..=self.settings.max_zoom,
931        )
932        .show_value(false)
933        .orientation(SliderOrientation::Vertical);
934        let mut pos1 = rect.right_top();
935        let mut pos2 = rect.right_top();
936        pos1.x -= 80.0;
937        pos1.y += 120.0;
938        pos2.x -= 60.0;
939        pos2.y += 240.0;
940
941        let sub_rect = egui::Rect::from_two_pos(pos1, pos2);
942        let ui_builder = egui::UiBuilder::new().clone().max_rect(sub_rect);
943        ui_obj.scope_builder(ui_builder, |ui_obj| {
944            ui_obj.add(zoom_slider);
945        });
946    }
947
948    fn paint_map_points(
949        &self,
950        vec_points: &Vec<isize>,
951        hashm: &Option<HashMap<usize, MapPoint>>,
952        paint: &Painter,
953        ui_obj: &mut Ui,
954        min_point: &RawPoint,
955        resp: &Response,
956    ) -> Result<Vec<usize>, ()> {
957        let mut nearest_id = None;
958        let mut nodes_to_remove = Vec::new();
959        let mut shape_vec = vec![];
960
961        if hashm.is_none() {
962            return Err(());
963        }
964        if vec_points.is_empty() {
965            return Err(());
966        }
967        // detecting the nearest hover node
968        if self.settings.node_text_visibility == VisibilitySetting::Hover
969            && resp.hovered()
970            && let Some(point) = resp.hover_pos()
971        {
972            let raw_point = RawPoint::from(point);
973            let hovered_map_point = (*min_point + raw_point) / self.zoom;
974            if let Ok(nearest_node) = self.tree.as_ref().unwrap().nearest(
975                &hovered_map_point.components,
976                1,
977                &squared_euclidean,
978            ) {
979                nearest_id = Some(nearest_node.first().unwrap().1);
980            }
981        }
982        // filling text settings
983        let mut text_settings = TextSettings {
984            // Screen-space size: unlike the map geometry this is NOT
985            // multiplied by the zoom, so a node name stays just as readable
986            // when the map is zoomed all the way out.
987            size: self.settings.node_text_size,
988            anchor: Align2::LEFT_BOTTOM,
989            family: FontFamily::Proportional,
990            text: String::new(),
991            position: RawPoint::default(),
992            text_color: ui_obj.visuals().text_color(),
993        };
994
995        // Drawing Points
996        for temp_point in vec_points {
997            let parsed_point = temp_point.cast_unsigned();
998            if let Some(system) = hashm.as_ref().unwrap().get(&parsed_point) {
999                let _span = tracing::info_span!("painting_points_m").entered();
1000                let viewport_point = RawPoint::from(system.coords) * self.zoom - min_point;
1001                if let Some(node_template) = &self.node_template {
1002                    if nearest_id.unwrap_or(&0usize) == &system.get_id() {
1003                        node_template.selection_ui(ui_obj, viewport_point.into(), self.zoom);
1004                    }
1005                } else if self.zoom > self.settings.label_visible_zoom
1006                    && self.settings.node_text_visibility == VisibilitySetting::Always
1007                    || (self.settings.node_text_visibility == VisibilitySetting::Hover
1008                        && nearest_id.unwrap_or(&0usize) == &system.get_id())
1009                {
1010                    let mut viewport_text = viewport_point;
1011                    viewport_text.components[0] += 3.0 * self.zoom;
1012                    viewport_text.components[1] -= 3.0 * self.zoom;
1013                    text_settings.position = viewport_text;
1014                    text_settings.text = system.get_name();
1015                    self.paint_label(paint, &text_settings);
1016                }
1017
1018                let system_id = system.get_id();
1019
1020                // Persistent node state is drawn first so a notification --
1021                // the *event* -- sits on top of the *state*.
1022                if let Some(state) = self.node_states.get(&system_id) {
1023                    let color = state.color.unwrap_or(self.current_style().alert_color);
1024                    if let Some(template) = &self.node_template {
1025                        // There is no dedicated template hook for node state:
1026                        // `marker_ui` is the persistent-visual one, so state and
1027                        // markers share it. Adding a hook would break every
1028                        // existing `NodeTemplate` implementor.
1029                        template.marker_ui(ui_obj, viewport_point.into(), self.zoom);
1030                    } else {
1031                        let effect = match state.animation {
1032                            SteadyAnimation::Blink => Animation::blink,
1033                            SteadyAnimation::Halo => Animation::halo,
1034                            SteadyAnimation::Orbit => Animation::orbit,
1035                        };
1036                        // Frame time, so every element animated this frame
1037                        // shares one clock instead of sampling its own.
1038                        let time = ui_obj.input(|i| i.time) as f32;
1039                        effect(paint, viewport_point.into(), self.zoom, time, color);
1040                    }
1041                    // Persistent effects never finish on their own.
1042                    ui_obj.ctx().request_repaint();
1043                }
1044
1045                if let Some(notification) = self.notifications.get(&system_id) {
1046                    let color = notification
1047                        .color
1048                        .unwrap_or(self.current_style().alert_color);
1049                    if let Some(template) = &self.node_template {
1050                        template.notification_ui(
1051                            ui_obj,
1052                            viewport_point.into(),
1053                            self.zoom,
1054                            notification.started,
1055                            color,
1056                        );
1057                    } else {
1058                        let effect = match notification.animation {
1059                            NodeAnimation::Pulse => Animation::pulse,
1060                            NodeAnimation::Ripple => Animation::ripple,
1061                            NodeAnimation::CountdownArc => Animation::countdown_arc,
1062                            NodeAnimation::ScaleIn => Animation::scale_in,
1063                            NodeAnimation::Crosshair => Animation::crosshair,
1064                        };
1065                        if effect(
1066                            paint,
1067                            viewport_point.into(),
1068                            self.zoom,
1069                            notification.started,
1070                            color,
1071                        ) {
1072                            ui_obj.ctx().request_repaint();
1073                        } else {
1074                            nodes_to_remove.push(system_id);
1075                        }
1076                    }
1077                }
1078                if let Some(node_template) = &self.node_template {
1079                    node_template.node_ui(ui_obj, viewport_point.into(), self.zoom, system);
1080                } else {
1081                    shape_vec.push(Shape::circle_filled(
1082                        viewport_point.into(),
1083                        4.00 * self.zoom,
1084                        system.color.unwrap_or(self.current_style().fill_color),
1085                    ));
1086                }
1087            }
1088        }
1089        paint.extend(shape_vec);
1090        Ok(nodes_to_remove)
1091    }
1092
1093    fn paint_map_lines(&self, painter: &Painter, min_point: &RawPoint) {
1094        let _span = tracing::info_span!("paint_map_lines").entered();
1095
1096        // Drawing Lines
1097        if self.zoom > self.settings.line_visible_zoom
1098            && let Some(mut stroke) = self.current_style().line
1099            && let Some(segments) = &self.segments
1100        {
1101            let mut shape_vec = vec![];
1102            let transparency_range = self.zoom - self.settings.line_visible_zoom;
1103            if (0.00..0.80).contains(&transparency_range) {
1104                let mut tup_stroke = stroke.color.to_tuple();
1105                let transparency = (self.zoom - self.settings.line_visible_zoom) / 0.80;
1106                tup_stroke.3 = (255.0 * transparency).round() as u8;
1107                let color = Color32::from_rgba_unmultiplied(
1108                    tup_stroke.0,
1109                    tup_stroke.1,
1110                    tup_stroke.2,
1111                    tup_stroke.3,
1112                );
1113                stroke = Stroke::new(stroke.width, color);
1114            }
1115            // Broad-phase: query the segment R-tree with the viewport AABB
1116            // (in map coordinates), padded by the stroke width so lines at
1117            // the very edge are not clipped prematurely.
1118            let center = self.current.pos / self.zoom;
1119            let padding = stroke.width / self.zoom;
1120            let half = RawPoint::new(
1121                self.map_area.width() / 2.0 / self.zoom + padding,
1122                self.map_area.height() / 2.0 / self.zoom + padding,
1123            );
1124            let query = rstar::AABB::from_corners((center - half).into(), (center + half).into());
1125            for segment in segments.locate_in_envelope_intersecting(query) {
1126                let raw_line = segment.raw_line();
1127                let pos_a = raw_line.points[0] * self.zoom - min_point;
1128                let pos_b = raw_line.points[1] * self.zoom - min_point;
1129                shape_vec.push(Shape::line_segment([pos_a.into(), pos_b.into()], stroke));
1130            }
1131            painter.extend(shape_vec);
1132        }
1133    }
1134
1135    fn paint_label(&self, paint: &Painter, text_settings: &TextSettings) {
1136        let _span = tracing::info_span!("paint_label").entered();
1137        paint.text(
1138            text_settings.position.into(),
1139            text_settings.anchor,
1140            text_settings.text.clone(),
1141            FontId::new(text_settings.size, text_settings.family.clone()),
1142            text_settings.text_color,
1143        );
1144    }
1145
1146    /// Triggers a pulsing notification on the node `id_node`.
1147    ///
1148    /// # Deprecated
1149    ///
1150    /// This only ever played one of the available effects. Use [`Map::node`]
1151    /// and pick the effect you want:
1152    ///
1153    /// ```
1154    /// # use egui_map::map::Map;
1155    /// # use egui_map::map::objects::MapPoint;
1156    /// # use std::time::Instant;
1157    /// # let mut map = Map::new();
1158    /// # map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
1159    /// # let time = Instant::now();
1160    /// if let Some(node) = map.node(1) {
1161    ///     node.pulse(time);
1162    /// }
1163    /// ```
1164    ///
1165    /// Note the one behavioural difference: `notify` accepts an id that was
1166    /// never loaded (the notification simply never draws), while [`Map::node`]
1167    /// returns `None` for it.
1168    #[deprecated(
1169        since = "0.4.0",
1170        note = "use `map.node(id)` and pick an effect, e.g. `if let Some(n) = map.node(id) { n.pulse(time) }`"
1171    )]
1172    pub fn notify(&mut self, id_node: usize, time: Instant) {
1173        let _span = tracing::info_span!("notify").entered();
1174        self.notifications.insert(
1175            id_node,
1176            Notification {
1177                started: time,
1178                animation: NodeAnimation::Pulse,
1179                color: None,
1180            },
1181        );
1182    }
1183
1184    /// Borrows the node `id` so an animation can be attached to it.
1185    ///
1186    /// Returns `None` when `id` was never loaded through
1187    /// [`Map::add_points`] / [`Map::add_hashmap_points`], so a stale or
1188    /// mistyped id is a compile-time-visible case rather than a silent no-op.
1189    ///
1190    /// The handle carries optional configuration that must be set *before* the
1191    /// effect, which is the terminal call:
1192    ///
1193    /// ```
1194    /// # use egui_map::map::Map;
1195    /// # use egui_map::map::objects::MapPoint;
1196    /// # use std::time::Instant;
1197    /// # let mut map = Map::new();
1198    /// # map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
1199    /// # let time = Instant::now();
1200    /// // a one-off event
1201    /// if let Some(node) = map.node(1) {
1202    ///     node.color(egui::Color32::RED).ripple(time);
1203    /// }
1204    ///
1205    /// // lasting state, until cleared
1206    /// if let Some(node) = map.node(1) {
1207    ///     node.halo();
1208    /// }
1209    ///
1210    /// assert!(map.node(999).is_none());
1211    /// ```
1212    pub fn node(&mut self, id: usize) -> Option<NodeHandle<'_>> {
1213        if !self
1214            .points
1215            .as_ref()
1216            .is_some_and(|points| points.contains_key(&id))
1217        {
1218            return None;
1219        }
1220        Some(NodeHandle {
1221            map: self,
1222            id,
1223            color: None,
1224        })
1225    }
1226
1227    /// Returns the id of the line closest to `point`, in map coordinates,
1228    /// when it lies within `tolerance` map units of the segment.
1229    ///
1230    /// Broad-phase candidates are taken from the segment R-tree built by
1231    /// [`Map::add_lines`]; the exact point-to-segment distance is then
1232    /// computed against the line geometry and the closest match wins. Returns
1233    /// `None` when no lines are loaded or every segment is farther than
1234    /// `tolerance`. A negative `tolerance` behaves like `0.0`.
1235    ///
1236    /// To hit-test a mouse click, convert the screen position to map
1237    /// coordinates first (`map = (screen + origin) / zoom`, see the
1238    /// [coordinate model](self#coordinate-model)) and pick a tolerance scaled
1239    /// by `1.0 / zoom` so it stays constant in screen pixels.
1240    pub fn line_at(&self, point: [f32; 2], tolerance: f32) -> Option<(usize, usize)> {
1241        let _span = tracing::info_span!("line_at").entered();
1242        let segments = self.segments.as_ref()?;
1243        let tolerance = tolerance.max(0.0);
1244
1245        let center = RawPoint::from(point);
1246        let padding = RawPoint::new(tolerance, tolerance);
1247        let query = rstar::AABB::from_corners((center - padding).into(), (center + padding).into());
1248
1249        let mut closest: Option<(f32, (usize, usize))> = None;
1250        for segment in segments.locate_in_envelope_intersecting(query) {
1251            let distance = segment.raw_line().distance_to_point(center);
1252            if distance <= tolerance && closest.as_ref().is_none_or(|(best, _)| distance < *best) {
1253                closest = Some((distance, segment.id));
1254            }
1255        }
1256        closest.map(|(_, id)| id)
1257    }
1258
1259    /// Installs a right-click context menu whose contents are built by the
1260    /// given [`ContextMenuManager`] implementation.
1261    pub fn set_context_manager(&mut self, manager: Rc<dyn ContextMenuManager>) {
1262        self.menu_manager = Some(manager);
1263    }
1264
1265    /// Replaces the built-in node rendering with a custom [`NodeTemplate`]
1266    /// implementation.
1267    ///
1268    /// The template takes over the drawing of nodes, selection highlights,
1269    /// notification animations and markers — including the node name labels,
1270    /// which the widget no longer draws once a template is installed. See the
1271    /// [`NodeTemplate`] examples for custom shapes and animations.
1272    pub fn set_node_template(&mut self, template: Rc<dyn NodeTemplate>) {
1273        self.node_template = Some(template);
1274    }
1275
1276    /// Adds the marker `id`, or moves it, so it points to the node `node_id`.
1277    ///
1278    /// Markers are drawn as a blinking ring around the target node unless a
1279    /// custom [`objects::NodeTemplate::marker_ui`] is installed.
1280    pub fn update_marker(&mut self, id: usize, node_id: usize) {
1281        self.markers
1282            .entry(id)
1283            .and_modify(|value| *value = node_id)
1284            .or_insert(node_id);
1285    }
1286
1287    /// Sets the minimum width and/or height the widget should occupy, in egui
1288    /// points. `None` leaves the corresponding dimension unconstrained.
1289    pub fn allocate_at_least(&mut self, width: Option<f32>, height: Option<f32>) {
1290        self.min_size = (width, height);
1291    }
1292
1293    /// Sets the maximum width and/or height the widget should occupy, in egui
1294    /// points. `None` leaves the corresponding dimension unconstrained.
1295    pub fn allocate_at_most(&mut self, width: Option<f32>, height: Option<f32>) {
1296        self.max_size = (width, height);
1297    }
1298}
1299
1300#[cfg(test)]
1301mod tests {
1302    use super::*;
1303    use std::time::Duration;
1304
1305    fn sample_points() -> Vec<MapPoint> {
1306        let mut map = Vec::new();
1307        map.push(MapPoint::new(1, [0.0, 0.0]));
1308        map.push(MapPoint::new(2, [10.0, 10.0]));
1309        map.push(MapPoint::new(3, [-10.0, -10.0]));
1310        map
1311    }
1312
1313    // ---------- construcción ----------
1314
1315    #[test]
1316    fn map_new_initial_state() {
1317        let map = Map::new();
1318        assert_eq!(map.zoom, 1.0);
1319        assert_eq!(map.previous_zoom, 1.0);
1320        assert!(map.points.is_none());
1321        assert!(map.segments.is_none());
1322        assert!(map.tree.is_none());
1323        assert!(map.labels.is_empty());
1324        assert!(map.visible_points.is_empty());
1325        assert!(map.markers.is_empty());
1326        assert!(map.notifications.is_empty());
1327        assert!(map.node_states.is_empty());
1328        assert_eq!(map.min_size, (None, None));
1329        assert_eq!(map.max_size, (None, None));
1330        assert_eq!(map.current_index, 0);
1331    }
1332
1333    #[test]
1334    fn map_default_equals_new() {
1335        let map = Map::default();
1336        assert_eq!(map.zoom, 1.0);
1337        assert!(map.points.is_none());
1338    }
1339
1340    // ---------- zoom ----------
1341
1342    #[test]
1343    fn set_zoom_within_range() {
1344        let mut map = Map::new();
1345        map.set_zoom(1.5);
1346        assert_eq!(map.get_zoom(), 1.5);
1347    }
1348
1349    #[test]
1350    fn set_zoom_at_exact_limits() {
1351        let mut map = Map::new();
1352        map.set_zoom(map.settings.min_zoom);
1353        assert_eq!(map.get_zoom(), 0.1);
1354        map.set_zoom(map.settings.max_zoom);
1355        assert_eq!(map.get_zoom(), 2.0);
1356    }
1357
1358    #[test]
1359    fn set_zoom_out_of_range_is_ignored() {
1360        let mut map = Map::new();
1361        let initial = map.get_zoom();
1362        map.set_zoom(0.05); // por debajo de min_zoom
1363        assert_eq!(map.get_zoom(), initial);
1364        map.set_zoom(2.5); // por encima de max_zoom
1365        assert_eq!(map.get_zoom(), initial);
1366    }
1367
1368    // ---------- puntos ----------
1369
1370    #[test]
1371    fn add_hashmap_points_computes_bounds() {
1372        let mut map = Map::new();
1373        map.add_points(sample_points());
1374
1375        assert_eq!(map.reference.min.components, [-10.0, -10.0]);
1376        assert_eq!(map.reference.max.components, [10.0, 10.0]);
1377        // pos es el punto medio del rectángulo que contiene todos los puntos
1378        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
1379        // map_area tiene área 0 antes de renderizar, así que dist es el valor fijo
1380        assert_eq!(map.reference.dist, 3000.0);
1381        // current se inicializa como copia de reference
1382        assert_eq!(map.current.min.components, map.reference.min.components);
1383        assert_eq!(map.current.max.components, map.reference.max.components);
1384        assert_eq!(map.current.pos.components, map.reference.pos.components);
1385        assert_eq!(map.current.dist, map.reference.dist);
1386        assert!(map.points.is_some());
1387        assert!(map.tree.is_some());
1388        assert_eq!(map.points.as_ref().unwrap().len(), 3);
1389    }
1390
1391    #[test]
1392    fn add_hashmap_points_populates_visible_points() {
1393        let mut map = Map::new();
1394        map.add_points(sample_points());
1395        // todos los puntos de muestra caen dentro del radio por defecto
1396        assert_eq!(map.visible_points.len(), 3);
1397    }
1398
1399    /// Renders one frame of `map` in a 500x500 viewport and returns the
1400    /// painted line segments.
1401    fn render_line_segments(map: &mut Map) -> Vec<[egui::Pos2; 2]> {
1402        use egui::{Context, RawInput, Shape};
1403        let ctx = Context::default();
1404        let input = RawInput {
1405            screen_rect: Some(egui::Rect::from_min_size(
1406                egui::Pos2::ZERO,
1407                egui::vec2(500.0, 500.0),
1408            )),
1409            ..RawInput::default()
1410        };
1411        let output = ctx.run_ui(input, |ui| {
1412            ui.add(&mut *map);
1413        });
1414        output
1415            .shapes
1416            .iter()
1417            .filter_map(|cs| match cs.shape {
1418                Shape::LineSegment { points, .. } => Some(points),
1419                _ => None,
1420            })
1421            .collect()
1422    }
1423
1424    #[test]
1425    fn segment_crossing_viewport_is_painted_even_with_far_endpoints() {
1426        // With the old endpoint-based rule this line was culled: both
1427        // endpoints sit beyond the point-culling radius. With the R-tree the
1428        // segment AABB intersects the viewport, so it is painted — no points
1429        // needed at all.
1430        let mut map = Map::new();
1431        map.set_zoom(1.0);
1432        let mut lines = Vec::new();
1433        lines.push(MapSegment::new((1, 2), [-4000.0, -1.0], [4000.0, 1.0]));
1434        map.add_lines(lines);
1435        map.set_pos([0.0, 0.0]);
1436
1437        let segments = render_line_segments(&mut map);
1438        assert_eq!(segments.len(), 1);
1439    }
1440
1441    #[test]
1442    fn segment_outside_viewport_is_not_painted() {
1443        let mut map = Map::new();
1444        map.set_zoom(1.0);
1445        let mut lines = Vec::new();
1446        lines.push(MapSegment::new(
1447            (1, 2),
1448            [10_000.0, 10_000.0],
1449            [10_100.0, 10_100.0],
1450        ));
1451        map.add_lines(lines);
1452        map.set_pos([0.0, 0.0]);
1453
1454        assert!(render_line_segments(&mut map).is_empty());
1455    }
1456
1457    #[test]
1458    fn add_lines_builds_segment_tree() {
1459        let mut map = Map::new();
1460        map.add_points(sample_points());
1461        let mut lines = Vec::new();
1462        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1463        map.add_lines(lines);
1464
1465        let tree = map
1466            .segments
1467            .as_ref()
1468            .expect("add_lines must build the segment tree");
1469        assert_eq!(tree.size(), 1);
1470
1471        // Broad-phase query: a viewport containing (0,0) must hit the segment;
1472        // a far-away viewport must not.
1473        let hit_query = rstar::AABB::from_corners([-1.0, -1.0], [1.0, 1.0]);
1474        let hits: Vec<_> = tree.locate_in_envelope_intersecting(hit_query).collect();
1475        assert_eq!(hits.len(), 1);
1476        assert_eq!(hits[0].id, (1, 2));
1477
1478        let miss_query = rstar::AABB::from_corners([100.0, 100.0], [200.0, 200.0]);
1479        assert_eq!(tree.locate_in_envelope_intersecting(miss_query).count(), 0);
1480    }
1481
1482    #[test]
1483    fn map_check_line_is_painted_on_first_frame() {
1484        use egui::{Context, RawInput, Shape};
1485
1486        // --- arrange ---
1487        let mut map = Map::new();
1488        map.set_zoom(1.0);
1489
1490        let mut point_a = MapPoint::new(0, [0.0, 0.0]);
1491        point_a.connections.push((0, 1));
1492        let mut point_b = MapPoint::new(1, [50.0, 50.0]);
1493        point_b.connections.push((0, 1));
1494
1495        let mut lines = Vec::new();
1496        lines.push(MapSegment::new((0, 1), point_a.coords, point_b.coords));
1497
1498        let mut points = Vec::new();
1499        points.push(point_a);
1500        points.push(point_b);
1501        // Load points before lines — the natural order shown in the examples.
1502        map.add_points(points);
1503        map.add_lines(lines);
1504
1505        map.set_pos([25.0, 25.0]);
1506
1507        // --- act: 1st frame (no CentralPanel — run_ui creates the root Ui) ---
1508        let ctx = Context::default();
1509        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 500.0));
1510        let input = RawInput {
1511            screen_rect: Some(screen),
1512            ..RawInput::default()
1513        };
1514
1515        let output1 = ctx.run_ui(input.clone(), |ui| {
1516            ui.add(&mut map);
1517        });
1518
1519        let segments1: Vec<[egui::Pos2; 2]> = output1
1520            .shapes
1521            .iter()
1522            .filter_map(|cs| match cs.shape {
1523                Shape::LineSegment { points, .. } => Some(points),
1524                _ => None,
1525            })
1526            .collect();
1527
1528        assert!(
1529            !segments1.is_empty(),
1530            "Frame 1: no LineSegment shapes painted (map lines did not draw)"
1531        );
1532
1533        // Expected projection of (0,0)->(50,50) with zoom=1, center=(25,25),
1534        // viewport 500x500: pos_a = (225, 225), pos_b = (275, 275). Tolerance ±2 px.
1535        let expected_a = egui::pos2(225.0, 225.0);
1536        let expected_b = egui::pos2(275.0, 275.0);
1537        let tolerance = 2.0;
1538        let found_on_frame1 = segments1.iter().any(|[p1, p2]| {
1539            let d_a1 = p1.distance(expected_a);
1540            let d_b1 = p2.distance(expected_b);
1541            let d_a2 = p2.distance(expected_a);
1542            let d_b2 = p1.distance(expected_b);
1543            (d_a1 < tolerance && d_b1 < tolerance) || (d_a2 < tolerance && d_b2 < tolerance)
1544        });
1545        assert!(
1546            found_on_frame1,
1547            "Frame 1: no LineSegment matches expected endpoints (~225,225 -> ~275,275); got {:?}",
1548            segments1
1549        );
1550
1551        // --- act: 2nd frame (unchanged) — detect duplicate-lines regression ---
1552        let output2 = ctx.run_ui(input, |ui| {
1553            ui.add(&mut map);
1554        });
1555
1556        let segments2: Vec<[egui::Pos2; 2]> = output2
1557            .shapes
1558            .iter()
1559            .filter_map(|cs| match cs.shape {
1560                Shape::LineSegment { points, .. } => Some(points),
1561                _ => None,
1562            })
1563            .collect();
1564
1565        assert_eq!(
1566            segments1.len(),
1567            segments2.len(),
1568            "Frame 2: expected {} line segments (no duplication across frames), got {}",
1569            segments1.len(),
1570            segments2.len()
1571        );
1572    }
1573
1574    // ---------- posición ----------
1575
1576    #[test]
1577    fn set_pos_and_get_pos_roundtrip() {
1578        let mut map = Map::new();
1579        map.set_pos([25.0, -35.0]);
1580        assert_eq!(map.get_pos(), [25.0, -35.0]);
1581    }
1582
1583    #[test]
1584    fn set_pos_from_nodeid_with_valid_id() {
1585        let mut map = Map::new();
1586        map.add_points(sample_points());
1587        assert!(map.set_pos_from_nodeid(2));
1588        assert_eq!(map.get_pos(), [10.0, 10.0]);
1589    }
1590
1591    /// Regression: the widget used to allocate a painter of the full
1592    /// `map_area.size()` *inside* the canvas frame, so the frame grew by
1593    /// `2 * total_margin` and spilled out of the space the widget was given.
1594    /// The drawable region was then truncated on the right/bottom and its
1595    /// centre no longer matched `map_area.center()`, leaving a centred node
1596    /// visibly off-centre by half a margin.
1597    #[test]
1598    fn centered_node_is_painted_at_the_middle_of_the_drawable_area() {
1599        use egui::{Context, RawInput, Shape};
1600
1601        for screen_size in [egui::vec2(500.0, 500.0), egui::vec2(800.0, 400.0)] {
1602            for zoom in [1.0, 2.0] {
1603                let mut map = Map::new();
1604                map.set_zoom(zoom);
1605                map.add_points(vec![MapPoint::new(7, [123.0, 456.0])]);
1606                map.set_pos_from_nodeid(7);
1607
1608                let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, screen_size);
1609                let ctx = Context::default();
1610                let mut widget_rect = egui::Rect::NOTHING;
1611                let mut output = ctx.run_ui(
1612                    RawInput {
1613                        screen_rect: Some(screen),
1614                        ..RawInput::default()
1615                    },
1616                    |ui| {
1617                        widget_rect = ui.add(&mut map).rect;
1618                    },
1619                );
1620
1621                // The widget must stay inside the space it was handed.
1622                assert!(
1623                    screen.contains_rect(widget_rect),
1624                    "{screen_size:?} zoom {zoom}: widget rect {widget_rect:?} overflows {screen:?}"
1625                );
1626
1627                // The centred node must land at the middle of the region the
1628                // map actually paints into (the painter's clip rect).
1629                let (node_center, drawable) = output
1630                    .shapes
1631                    .iter()
1632                    .find_map(|cs| match &cs.shape {
1633                        Shape::Circle(circle) => Some((circle.center, cs.clip_rect)),
1634                        _ => None,
1635                    })
1636                    .expect("the node must be painted");
1637
1638                assert!(
1639                    node_center.distance(drawable.center()) < 0.5,
1640                    "{screen_size:?} zoom {zoom}: node painted at {node_center:?} but the \
1641                     drawable area {drawable:?} is centred at {:?}",
1642                    drawable.center()
1643                );
1644
1645                output.textures_delta.clear();
1646            }
1647        }
1648    }
1649
1650    #[test]
1651    fn set_pos_from_nodeid_with_invalid_id_keeps_position() {
1652        let mut map = Map::new();
1653        map.add_points(sample_points());
1654        let before = map.reference.pos.components;
1655        // An unknown id must report the failure instead of silently no-op'ing.
1656        assert!(!map.set_pos_from_nodeid(999));
1657        assert_eq!(map.reference.pos.components, before);
1658    }
1659
1660    #[test]
1661    fn set_pos_from_nodeid_without_points_does_nothing() {
1662        let mut map = Map::new();
1663        assert!(!map.set_pos_from_nodeid(1));
1664        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
1665    }
1666
1667    // ---------- etiquetas y líneas ----------
1668
1669    #[test]
1670    fn add_labels_stores_labels() {
1671        let mut map = Map::new();
1672        let label = MapLabel {
1673            text: "Region".to_string(),
1674            center: Pos2::new(1.0, 2.0),
1675        };
1676        map.add_labels(vec![label]);
1677        assert_eq!(map.labels.len(), 1);
1678        assert_eq!(map.labels[0].text, "Region");
1679    }
1680
1681    #[test]
1682    fn add_lines_stores_lines() {
1683        let mut map = Map::new();
1684        let mut lines = Vec::new();
1685        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [1.0, 1.0]));
1686        map.add_lines(lines);
1687        let tree = map.segments.as_ref().unwrap();
1688        assert_eq!(tree.size(), 1);
1689        assert_eq!(
1690            tree.locate_in_envelope_intersecting(rstar::AABB::from_corners(
1691                [-1.0, -1.0],
1692                [2.0, 2.0],
1693            ))
1694            .next()
1695            .unwrap()
1696            .id,
1697            (1, 2)
1698        );
1699    }
1700
1701    // ---------- notificaciones y marcadores ----------
1702
1703    #[test]
1704    fn line_at_returns_closest_line_within_tolerance() {
1705        let mut map = Map::new();
1706        map.add_points(sample_points());
1707        let mut lines = Vec::new();
1708        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]));
1709        lines.push(MapSegment::new((3, 4), [20.0, -5.0], [20.0, 5.0]));
1710        map.add_lines(lines);
1711
1712        // 1.5 units above the horizontal segment.
1713        let hit = map.line_at([5.0, 1.5], 2.0).expect("line must be hit");
1714        assert_eq!(hit, (1, 2));
1715
1716        // Closest to the vertical segment.
1717        let hit = map.line_at([19.0, 0.0], 2.0).expect("line must be hit");
1718        assert_eq!(hit, (3, 4));
1719    }
1720
1721    #[test]
1722    fn line_at_returns_none_beyond_tolerance() {
1723        let mut map = Map::new();
1724        map.add_points(sample_points());
1725        let mut lines = Vec::new();
1726        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1727        map.add_lines(lines);
1728
1729        // Distance from (5,4) to the diagonal segment (0,0)-(10,10) is
1730        // |5-4|/sqrt(2) ~= 0.707.
1731        assert!(map.line_at([5.0, 4.0], 0.8).is_some());
1732        assert!(map.line_at([5.0, 4.0], 0.5).is_none());
1733        assert!(map.line_at([100.0, 100.0], 5.0).is_none());
1734    }
1735
1736    #[test]
1737    fn line_at_returns_none_without_lines() {
1738        let map = Map::new();
1739        assert!(map.line_at([0.0, 0.0], 10.0).is_none());
1740    }
1741
1742    #[test]
1743    fn line_at_negative_tolerance_behaves_like_zero() {
1744        let mut map = Map::new();
1745        map.add_points(sample_points());
1746        let mut lines = Vec::new();
1747        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1748        map.add_lines(lines);
1749
1750        // Exact point on the segment is hit even with tolerance clamped to 0.
1751        assert!(map.line_at([5.0, 5.0], -1.0).is_some());
1752        assert!(map.line_at([5.0, 5.1], -1.0).is_none());
1753    }
1754
1755    /// The deprecated shortcut must keep behaving exactly as it did: a pulse,
1756    /// restarted on every call, and tolerant of ids that were never loaded.
1757    #[test]
1758    #[allow(deprecated)]
1759    fn deprecated_notify_still_records_a_pulse() {
1760        let mut map = Map::new();
1761        let t1 = Instant::now();
1762        map.notify(5, t1);
1763        let recorded = map.notifications.get(&5).expect("notify must record");
1764        assert_eq!(recorded.started, t1);
1765        assert_eq!(recorded.animation, NodeAnimation::Pulse);
1766        assert_eq!(recorded.color, None);
1767
1768        let t2 = t1 + Duration::from_secs(1);
1769        map.notify(5, t2);
1770        assert_eq!(map.notifications.get(&5).unwrap().started, t2);
1771        assert_eq!(map.notifications.len(), 1);
1772    }
1773
1774    // ---------- NodeHandle ----------
1775
1776    fn map_with_nodes() -> Map {
1777        let mut map = Map::new();
1778        map.add_points(vec![
1779            MapPoint::new(1, [0.0, 0.0]),
1780            MapPoint::new(2, [10.0, 10.0]),
1781        ]);
1782        map
1783    }
1784
1785    #[test]
1786    fn node_returns_none_for_an_unknown_id() {
1787        let mut map = map_with_nodes();
1788        assert!(map.node(1).is_some());
1789        assert!(map.node(999).is_none());
1790        // and with nothing loaded at all
1791        assert!(Map::new().node(1).is_none());
1792    }
1793
1794    #[test]
1795    fn each_event_effect_records_its_own_animation() {
1796        let now = Instant::now();
1797        for (apply, expected) in [
1798            (
1799                Box::new(|n: NodeHandle| n.pulse(now)) as Box<dyn FnOnce(NodeHandle)>,
1800                NodeAnimation::Pulse,
1801            ),
1802            (
1803                Box::new(|n: NodeHandle| n.ripple(now)),
1804                NodeAnimation::Ripple,
1805            ),
1806            (
1807                Box::new(|n: NodeHandle| n.countdown(now)),
1808                NodeAnimation::CountdownArc,
1809            ),
1810            (
1811                Box::new(|n: NodeHandle| n.scale_in(now)),
1812                NodeAnimation::ScaleIn,
1813            ),
1814            (
1815                Box::new(|n: NodeHandle| n.crosshair(now)),
1816                NodeAnimation::Crosshair,
1817            ),
1818        ] {
1819            let mut map = map_with_nodes();
1820            apply(map.node(1).unwrap());
1821            let recorded = map.notifications.get(&1).expect("effect must be recorded");
1822            assert_eq!(recorded.animation, expected);
1823            assert_eq!(recorded.started, now);
1824            // an event effect must not leave lasting state behind
1825            assert!(map.node_states.is_empty());
1826        }
1827    }
1828
1829    #[test]
1830    fn each_steady_effect_records_lasting_state() {
1831        for (apply, expected) in [
1832            (
1833                Box::new(|n: NodeHandle| n.halo()) as Box<dyn FnOnce(NodeHandle)>,
1834                SteadyAnimation::Halo,
1835            ),
1836            (Box::new(|n: NodeHandle| n.blink()), SteadyAnimation::Blink),
1837            (Box::new(|n: NodeHandle| n.orbit()), SteadyAnimation::Orbit),
1838        ] {
1839            let mut map = map_with_nodes();
1840            apply(map.node(1).unwrap());
1841            assert_eq!(map.node_states.get(&1).unwrap().animation, expected);
1842            // lasting state must not masquerade as a notification
1843            assert!(map.notifications.is_empty());
1844        }
1845    }
1846
1847    #[test]
1848    fn color_modifier_reaches_both_families() {
1849        let mut map = map_with_nodes();
1850        map.node(1)
1851            .unwrap()
1852            .color(Color32::RED)
1853            .pulse(Instant::now());
1854        map.node(2).unwrap().color(Color32::BLUE).halo();
1855
1856        assert_eq!(map.notifications.get(&1).unwrap().color, Some(Color32::RED));
1857        assert_eq!(map.node_states.get(&2).unwrap().color, Some(Color32::BLUE));
1858    }
1859
1860    #[test]
1861    fn a_node_can_carry_state_and_a_notification_at_once() {
1862        let mut map = map_with_nodes();
1863        map.node(1).unwrap().halo();
1864        map.node(1).unwrap().ripple(Instant::now());
1865
1866        assert!(map.node_states.contains_key(&1));
1867        assert!(map.notifications.contains_key(&1));
1868    }
1869
1870    #[test]
1871    fn clear_removes_both_families_for_that_node_only() {
1872        let mut map = map_with_nodes();
1873        map.node(1).unwrap().halo();
1874        map.node(1).unwrap().ripple(Instant::now());
1875        map.node(2).unwrap().halo();
1876
1877        map.node(1).unwrap().clear();
1878
1879        assert!(!map.node_states.contains_key(&1));
1880        assert!(!map.notifications.contains_key(&1));
1881        assert!(map.node_states.contains_key(&2), "node 2 must be untouched");
1882    }
1883
1884    #[test]
1885    fn re_triggering_replaces_the_previous_effect() {
1886        let mut map = map_with_nodes();
1887        map.node(1).unwrap().pulse(Instant::now());
1888        map.node(1).unwrap().crosshair(Instant::now());
1889
1890        assert_eq!(map.notifications.len(), 1);
1891        assert_eq!(
1892            map.notifications.get(&1).unwrap().animation,
1893            NodeAnimation::Crosshair
1894        );
1895    }
1896
1897    #[test]
1898    fn update_marker_inserts_and_updates() {
1899        let mut map = Map::new();
1900        map.update_marker(1, 100);
1901        assert_eq!(map.markers.get(&1), Some(&100));
1902        map.update_marker(1, 200);
1903        assert_eq!(map.markers.get(&1), Some(&200));
1904        assert_eq!(map.markers.len(), 1);
1905    }
1906
1907    // ---------- tamaño ----------
1908
1909    #[test]
1910    fn allocate_at_least_sets_min_size() {
1911        let mut map = Map::new();
1912        map.allocate_at_least(Some(100.0), None);
1913        assert_eq!(map.min_size, (Some(100.0), None));
1914    }
1915
1916    #[test]
1917    fn allocate_at_most_sets_max_size() {
1918        let mut map = Map::new();
1919        map.allocate_at_most(None, Some(200.0));
1920        assert_eq!(map.max_size, (None, Some(200.0)));
1921    }
1922
1923    // ---------- bounds ----------
1924
1925    #[test]
1926    fn adjust_bounds_scales_with_zoom() {
1927        let mut map = Map::new();
1928        map.reference.min = RawPoint::new(-10.0, -20.0);
1929        map.reference.max = RawPoint::new(10.0, 20.0);
1930        map.reference.pos = RawPoint::new(5.0, 5.0);
1931        map.reference.dist = 100.0;
1932        map.set_zoom(2.0);
1933        map.adjust_bounds();
1934
1935        assert_eq!(map.current.max.components, [20.0, 40.0]);
1936        assert_eq!(map.current.min.components, [-20.0, -40.0]);
1937        assert_eq!(map.current.pos.components, [10.0, 10.0]);
1938        assert_eq!(map.current.dist, 50.0);
1939    }
1940}