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, RawLine,
74    RawPoint, TextSettings, VisibilitySetting,
75};
76use egui::{epaint::CircleShape, 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    entities: HashMap<usize, Instant>,
139    min_size: (Option<f32>, Option<f32>),
140    max_size: (Option<f32>, Option<f32>),
141    /// Behavior and appearance configuration (zoom limits, visibility
142    /// thresholds and per-theme styles). See [`objects::MapSettings`].
143    pub settings: MapSettings,
144    menu_manager: Option<Rc<dyn ContextMenuManager>>,
145    node_template: Option<Rc<dyn NodeTemplate>>,
146    markers: HashMap<usize, usize>,
147}
148
149impl Default for Map {
150    /// Creates an empty map; equivalent to [`Map::new`].
151    fn default() -> Self {
152        Map::new()
153    }
154}
155
156impl Widget for &mut Map {
157    /// Renders the map, handling panning (drag), zooming (mouse wheel) and the
158    /// right-click context menu if one was installed.
159    fn ui(self, ui: &mut egui::Ui) -> Response {
160        let rect = self.calculate_widget_dimensions(ui);
161
162        // we define the initial coordinate as the center of such rectangle
163        self.reference.dist = rect.distance();
164
165        self.assign_visual_style(ui);
166
167        let canvas = egui::Frame::canvas(ui.style()).inner_margin(Margin::symmetric(3, 5));
168
169        let inner_response = canvas.show(ui, |ui| {
170            #[cfg(feature = "puffin")]
171            puffin::profile_scope!("paint_map");
172
173            if ui.is_rect_visible(self.map_area) {
174                let (resp, paint) =
175                    ui.allocate_painter(self.map_area.size(), egui::Sense::click_and_drag());
176                let vec = resp.drag_delta();
177                if vec.length() != 0.0 {
178                    #[cfg(feature = "puffin")]
179                    puffin::profile_scope!("calculating_points_in_visible_area");
180
181                    let coords = RawPoint::from(vec.to_pos2());
182                    let new_pos = self.reference.pos - (coords / self.zoom);
183                    self.set_pos(new_pos.into());
184                }
185                if self.zoom < self.settings.line_visible_zoom {
186                    // filling text settings
187                    let mut text_settings = TextSettings {
188                        size: 12.00 * self.zoom * 2.00,
189                        anchor: Align2::CENTER_CENTER,
190                        family: FontFamily::Proportional,
191                        text: String::new(),
192                        position: RawPoint::default(),
193                        text_color: ui.visuals().text_color(),
194                    };
195                    for label in &self.labels {
196                        text_settings.text.clone_from(&label.text);
197                        text_settings.position = RawPoint::from(label.center);
198                        self.paint_label(&paint, &text_settings);
199                    }
200                }
201
202                let rect_midpoint = RawPoint::from(self.map_area.center());
203                let min_point = self.current.pos - rect_midpoint;
204                let vec_points = &self.visible_points;
205                let hashm = &self.points;
206
207                // Safety net: drop stale notifications even if their node is
208                // outside the viewport and never finishes its animation.
209                let now = Instant::now();
210                self.entities
211                    .retain(|_, init| now.duration_since(*init).as_secs_f32() < 10.0);
212
213                self.paint_map_lines(&paint, &min_point);
214
215                if let Ok(nodes_to_remove) =
216                    self.paint_map_points(vec_points, hashm, &paint, ui, &min_point, &resp)
217                {
218                    for node in nodes_to_remove {
219                        self.entities.remove(&node);
220                    }
221                }
222
223                for marker in &self.markers {
224                    if let Some(point) = self.points.as_ref().unwrap().get(marker.1) {
225                        let adjusted_point = RawPoint::from(point.coords) * self.zoom - min_point;
226                        if let Some(template) = &self.node_template {
227                            template.marker_ui(ui, adjusted_point.into(), self.zoom);
228                        } else {
229                            let mut shapes = Vec::new();
230                            let color = if ui.visuals().dark_mode {
231                                Color32::LIGHT_GREEN
232                            } else {
233                                Color32::GREEN
234                            };
235                            let millis = std::time::SystemTime::now()
236                                .duration_since(std::time::UNIX_EPOCH)
237                                .unwrap_or_default()
238                                .as_millis();
239                            let mut transparency = (millis % 2550 / 5) as i64;
240                            if transparency > 255 {
241                                transparency = 255 - (transparency - 255)
242                            }
243                            let corrected_color = Color32::from_rgba_unmultiplied(
244                                color.r(),
245                                color.g(),
246                                color.b(),
247                                transparency as u8,
248                            );
249                            shapes.push(Shape::Circle(CircleShape::stroke(
250                                adjusted_point.into(),
251                                4.0 * self.zoom,
252                                Stroke::new(9.0 * self.zoom, corrected_color),
253                            )));
254                            ui.ctx().request_repaint();
255                            ui.painter().extend(shapes);
256                        }
257                    }
258                }
259
260                self.paint_sub_components(ui, self.map_area);
261
262                self.capture_mouse_events(ui, &resp);
263
264                if self.zoom != self.previous_zoom {
265                    #[cfg(feature = "puffin")]
266                    puffin::profile_scope!("calculating viewport with zoom");
267                    self.adjust_bounds();
268                    self.calculate_visible_points();
269                    self.previous_zoom = self.zoom;
270                }
271
272                if let Some(menu_mon) = &mut self.menu_manager {
273                    resp.context_menu(|ui| {
274                        menu_mon.ui(ui);
275                    });
276                }
277
278                #[cfg(feature = "debug_overlay")]
279                self.print_debug_info(paint, resp);
280            }
281        });
282        ui.allocate_space(self.map_area.size());
283        inner_response.response
284    }
285}
286
287impl Map {
288    /// Creates an empty map widget with default [`MapSettings`].
289    ///
290    /// The widget displays nothing until nodes are loaded with
291    /// [`Map::add_hashmap_points`].
292    pub fn new() -> Self {
293        let settings = MapSettings::default();
294        Self {
295            zoom: 1.0,
296            previous_zoom: 1.0,
297            map_area: Rect::NOTHING,
298            tree: None,
299            points: None,
300            labels: Vec::new(),
301            visible_points: Vec::new(),
302            current: MapBounds::default(),
303            reference: MapBounds::default(),
304            settings,
305            min_size: (None, None),
306            max_size: (None, None),
307            current_index: 0,
308            entities: HashMap::new(),
309            menu_manager: None,
310            node_template: None,
311            markers: HashMap::new(),
312            segments: None,
313        }
314    }
315
316    fn calculate_widget_dimensions(&mut self, ui: &mut Ui) -> RawLine {
317        let available = ui.available_rect_before_wrap();
318        let mut size = available.size();
319        if let Some(max_width) = self.max_size.0 {
320            size.x = size.x.min(max_width);
321        }
322        if let Some(max_height) = self.max_size.1 {
323            size.y = size.y.min(max_height);
324        }
325        if let Some(min_width) = self.min_size.0 {
326            size.x = size.x.max(min_width);
327        }
328        if let Some(min_height) = self.min_size.1 {
329            size.y = size.y.max(min_height);
330        }
331        self.map_area = Rect::from_min_size(available.min, size);
332        RawLine::new(
333            RawPoint::from(self.map_area.left_top()),
334            RawPoint::from(self.map_area.right_bottom()),
335        )
336    }
337
338    fn calculate_visible_points(&mut self) {
339        #[cfg(feature = "puffin")]
340        puffin::profile_scope!("calculate_visible_points");
341        if self.current.dist > 0.0
342            && self.current.dist < f32::INFINITY
343            && let Some(tree) = &self.tree
344        {
345            let center = self.current.pos / self.zoom;
346            let radius = self.current.dist.powi(2);
347            let point: [f32; 2] = center.into();
348            let vis_pos = tree.within(&point, radius, &squared_euclidean).unwrap();
349            self.visible_points.clear();
350            for point in vis_pos {
351                self.visible_points.push(point.1.cast_signed());
352            }
353        }
354    }
355
356    /// Loads the node set and (re)builds the spatial index.
357    ///
358    /// This replaces any previously loaded points, computes the bounding box of
359    /// the whole set, centers the view on its midpoint and refreshes the list
360    /// of visible nodes. It must be called at least once before the widget can
361    /// display anything.
362    ///
363    /// The kd-tree built here is what enables viewport culling and
364    /// nearest-neighbor hover lookups, so calling this method on every frame is
365    /// discouraged; call it only when the node set changes.
366    ///
367    /// # Examples
368    ///
369    /// ```
370    /// use egui_map::map::Map;
371    /// use egui_map::map::objects::MapPoint;
372    ///
373    /// let mut points = Vec::new();
374    /// points.push(MapPoint::new(1, [0.0, 0.0]));
375    /// points.push(MapPoint::new(2, [10.0, 10.0]));
376    ///
377    /// let mut map = Map::new();
378    /// map.add_points(points);
379    ///
380    /// // The view is centered on the midpoint of the loaded nodes.
381    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
382    /// ```
383    pub fn add_points(&mut self, points: Vec<MapPoint>) {
384        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
385        let mut hash_map = HashMap::new();
386        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
387        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
388        for entry in points {
389            for i in 0..min.components.len() {
390                if entry.coords[i] < min.components[i] {
391                    min.components[i] = entry.coords[i];
392                }
393                if entry.coords[i] > max.components[i] {
394                    max.components[i] = entry.coords[i];
395                }
396            }
397            let _result = tree.add(entry.coords, entry.get_id());
398            hash_map.insert(entry.get_id(), entry);
399        }
400        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
401        self.reference.min = min;
402        self.reference.max = max;
403        self.points = Some(hash_map);
404        self.tree = Some(tree);
405        self.reference.pos = RawLine::new(min, max).midpoint();
406        // we create a rect that include every node in the map
407        // Stupid fix because rect area could be infinite
408        // I need to implement a more elegant fix
409        if self.map_area.area() == 0.0 {
410            self.reference.dist = 3000.00;
411        } else {
412            let rect = RawLine::new(
413                RawPoint::from(self.map_area.left_top()),
414                RawPoint::from(self.map_area.right_bottom()),
415            );
416            self.reference.dist = rect.distance();
417        }
418        self.current = self.reference.clone();
419        self.calculate_visible_points();
420    }
421
422    /// Loads the node set and (re)builds the spatial index.
423    ///
424    /// This replaces any previously loaded points, computes the bounding box of
425    /// the whole set, centers the view on its midpoint and refreshes the list
426    /// of visible nodes. It must be called at least once before the widget can
427    /// display anything.
428    ///
429    /// The kd-tree built here is what enables viewport culling and
430    /// nearest-neighbor hover lookups, so calling this method on every frame is
431    /// discouraged; call it only when the node set changes.
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// use egui_map::map::Map;
437    /// use egui_map::map::objects::MapPoint;
438    /// use std::collections::HashMap;
439    ///
440    /// let mut points = HashMap::new();
441    /// points.insert(1, MapPoint::new(1, [0.0, 0.0]));
442    /// points.insert(2, MapPoint::new(2, [10.0, 10.0]));
443    ///
444    /// let mut map = Map::new();
445    /// map.add_hashmap_points(points);
446    ///
447    /// // The view is centered on the midpoint of the loaded nodes.
448    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
449    /// ```
450    //#[deprecated(since="0.2.3", note="please use `add_points` instead")]
451    pub fn add_hashmap_points(&mut self, hash_map: HashMap<usize, MapPoint>) {
452        #[cfg(feature = "puffin")]
453        puffin::profile_scope!("add_hashmap_points");
454        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
455        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
456        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
457
458        for entry in hash_map.iter() {
459            for i in 0..min.components.len() {
460                if entry.1.coords[i] < min.components[i] {
461                    min.components[i] = entry.1.coords[i];
462                }
463                if entry.1.coords[i] > max.components[i] {
464                    max.components[i] = entry.1.coords[i];
465                }
466            }
467            let _result = tree.add(entry.1.coords, *entry.0);
468        }
469
470        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
471        self.reference.min = min;
472        self.reference.max = max;
473        self.points = Some(hash_map);
474        self.tree = Some(tree);
475        self.reference.pos = RawLine::new(min, max).midpoint();
476        // we create a rect that include every node in the map
477        // Stupid fix because rect area could be infinite
478        // I need to implement a more elegant fix
479        if self.map_area.area() == 0.0 {
480            self.reference.dist = 3000.00;
481        } else {
482            let rect = RawLine::new(
483                RawPoint::from(self.map_area.left_top()),
484                RawPoint::from(self.map_area.right_bottom()),
485            );
486            self.reference.dist = rect.distance();
487        }
488        self.current = self.reference.clone();
489        self.calculate_visible_points();
490    }
491
492    /// Centers the view on the node with the given id.
493    ///
494    /// Does nothing if no points have been loaded yet or if `node_id` is
495    /// unknown.
496    pub fn set_pos_from_nodeid(&mut self, node_id: usize) {
497        #[cfg(feature = "puffin")]
498        puffin::profile_scope!("set_pos_from_nodeid");
499        if let Some(hash_map) = &self.points
500            && let Some(map_point) = hash_map.get(&node_id)
501        {
502            self.reference.pos = RawPoint::from(map_point.coords);
503            self.adjust_bounds();
504            self.calculate_visible_points();
505        }
506    }
507
508    /// Centers the view on the given map coordinates.
509    pub fn set_pos(&mut self, position: [f32; 2]) {
510        #[cfg(feature = "puffin")]
511        puffin::profile_scope!("set_pos");
512        let point = RawPoint::from(position);
513        self.reference.pos = point;
514        self.adjust_bounds();
515        self.calculate_visible_points();
516    }
517
518    /// Returns the map coordinates the view is currently centered on.
519    pub fn get_pos(&self) -> [f32; 2] {
520        #[cfg(feature = "puffin")]
521        puffin::profile_scope!("get_pos");
522        self.reference.pos.into()
523    }
524
525    /// Replaces the set of free-floating text labels drawn on the map.
526    ///
527    /// Labels are only rendered while the zoom level is below
528    /// [`MapSettings::line_visible_zoom`].
529    pub fn add_labels(&mut self, labels: Vec<MapLabel>) {
530        #[cfg(feature = "puffin")]
531        puffin::profile_scope!("add_labels");
532        self.labels = labels;
533    }
534
535    /// Replaces the set of connection lines between nodes.
536    ///
537    /// Lines are keyed by a connection id that the endpoint nodes must
538    /// reference through [`MapPoint::connections`] — push each line's key into
539    /// the `connections` of the nodes it joins. The segments are stored in an
540    /// R-tree keyed by bounding box: a line is drawn while its bounding box
541    /// intersects the viewport and the zoom level is above
542    /// [`MapSettings::line_visible_zoom`].
543    ///
544    /// See the [module-level example](self#connecting-nodes-with-lines) for
545    /// the complete wiring.
546    pub fn add_lines(&mut self, segments: Vec<MapSegment>) {
547        #[cfg(feature = "puffin")]
548        puffin::profile_scope!("add_lines");
549        // Intern the keys as Rc<str> and build the broad-phase spatial index
550        // over the line bounding boxes, so viewport culling and hit-testing
551        // discard whole regions without touching every segment.
552
553        self.segments = Some(rstar::RTree::bulk_load(segments));
554    }
555
556    /// Replaces the set of connection lines between nodes, from a map keyed
557    /// by the same `(usize, usize)` id used in [`MapSegment::id`] and
558    /// referenced by [`MapPoint::connections`].
559    ///
560    /// Equivalent to [`add_lines`](Self::add_lines) but avoids callers having
561    /// to collect their segments into a `Vec` first when they already have
562    /// them keyed in a `HashMap` (e.g. straight from an adapter that mirrors
563    /// them 1:1 by id, with no intermediate ordering to preserve).
564    pub fn add_hashmap_lines(&mut self, segments: HashMap<(usize, usize), MapSegment>) {
565        #[cfg(feature = "puffin")]
566        puffin::profile_scope!("add_hashmap_lines");
567        let segments: Vec<MapSegment> = segments.into_values().collect();
568        self.segments = Some(rstar::RTree::bulk_load(segments));
569    }
570
571    fn adjust_bounds(&mut self) {
572        #[cfg(feature = "puffin")]
573        puffin::profile_scope!("adjust_bounds");
574        self.current.max = self.reference.max * self.zoom;
575        self.current.min = self.reference.min * self.zoom;
576        self.current.dist = self.reference.dist / self.zoom;
577        self.current.pos = self.reference.pos * self.zoom;
578    }
579
580    fn capture_mouse_events(&mut self, ui: &Ui, _resp: &Response) {
581        #[cfg(feature = "puffin")]
582        puffin::profile_scope!("capture_mouse_events");
583        // capture MouseWheel Event for Zoom control change
584        if ui.rect_contains_pointer(self.map_area) {
585            ui.input(|x| {
586                #[cfg(feature = "puffin")]
587                puffin::profile_scope!("capture_mouse_events");
588
589                if !x.events.is_empty() {
590                    for event in &x.events {
591                        match event {
592                            Event::MouseWheel {
593                                unit: _,
594                                delta,
595                                modifiers,
596                                phase: _,
597                            } => {
598                                #[cfg(target_os = "macos")]
599                                let zoom_modifier = if modifiers.mac_cmd {
600                                    delta.y / 80.00
601                                } else {
602                                    delta.y / 400.00
603                                };
604
605                                #[cfg(not(target_os = "macos"))]
606                                let zoom_modifier = if modifiers.ctrl {
607                                    delta.y / 8.00
608                                } else {
609                                    delta.y / 40.00
610                                };
611
612                                let mut pre_zoom = self.zoom + zoom_modifier;
613                                if pre_zoom > self.settings.max_zoom {
614                                    pre_zoom = self.settings.max_zoom;
615                                }
616                                if pre_zoom < self.settings.min_zoom {
617                                    pre_zoom = self.settings.min_zoom;
618                                }
619                                self.zoom = pre_zoom;
620                            }
621                            _ => {
622                                continue;
623                            }
624                        };
625                    }
626                }
627            });
628        }
629    }
630
631    /// Sets the zoom factor.
632    ///
633    /// Values outside the [`MapSettings::min_zoom`]..=[`MapSettings::max_zoom`]
634    /// range are ignored.
635    pub fn set_zoom(&mut self, value: f32) {
636        if value >= self.settings.min_zoom && value <= self.settings.max_zoom {
637            self.zoom = value;
638        }
639    }
640
641    /// Returns the current zoom factor.
642    pub fn get_zoom(&mut self) -> f32 {
643        self.zoom
644    }
645
646    /// Returns the style for the current theme, falling back to the first
647    /// style if the current theme index has no entry.
648    fn current_style(&self) -> &MapStyle {
649        self.settings
650            .styles
651            .get(self.current_index)
652            .or(self.settings.styles.first())
653            .expect("MapSettings::styles must not be empty")
654    }
655
656    fn assign_visual_style(&mut self, ui_obj: &mut Ui) {
657        let style_index = ui_obj.visuals().dark_mode as usize;
658
659        if self.current_index != style_index {
660            #[cfg(feature = "puffin")]
661            puffin::profile_scope!("asign_visual_style");
662
663            self.current_index = style_index;
664            let map_style = self.settings.styles.get_mut(style_index).unwrap();
665            let visuals = &ui_obj.style().visuals;
666            map_style.background_color = visuals.extreme_bg_color;
667            map_style.border = Some(visuals.window_stroke);
668        }
669    }
670
671    #[cfg(feature = "debug_overlay")]
672    fn print_debug_info(&mut self, paint: Painter, resp: Response) {
673        #[cfg(feature = "puffin")]
674        puffin::profile_scope!("printing debug data");
675
676        let mut init_pos = Pos2::new(
677            self.map_area.left_top().x + 10.00,
678            self.map_area.left_top().y + 10.00,
679        );
680        let mut msg = "MIN:".to_string()
681            + self.current.min.components[0].to_string().as_str()
682            + ","
683            + self.current.min.components[1].to_string().as_str();
684        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
685        init_pos.y += 15.0;
686        msg = "MAX:".to_string()
687            + self.current.max.components[0].to_string().as_str()
688            + ","
689            + self.current.max.components[1].to_string().as_str();
690        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
691        init_pos.y += 15.0;
692        msg = "CUR:(".to_string()
693            + self.current.pos.components[0].to_string().as_str()
694            + ","
695            + self.current.pos.components[1].to_string().as_str()
696            + ")";
697        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
698        init_pos.y += 15.0;
699        msg = "DST:".to_string() + self.current.dist.to_string().as_str();
700        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
701        init_pos.y += 15.0;
702        msg = "ZOM:".to_string() + self.zoom.to_string().as_str();
703        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::GREEN, msg);
704        init_pos.y += 15.0;
705        msg = "REC:(".to_string()
706            + self.map_area.left_top().x.to_string().as_str()
707            + ","
708            + self.map_area.left_top().y.to_string().as_str()
709            + "),("
710            + self.map_area.right_bottom().x.to_string().as_str()
711            + ","
712            + self.map_area.right_bottom().y.to_string().as_str()
713            + ")";
714        paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
715        if let Some(points) = &self.points {
716            init_pos.y += 15.0;
717            msg = "NUM:".to_string() + points.len().to_string().as_str();
718            paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
719        }
720        if !self.visible_points.is_empty() {
721            init_pos.y += 15.0;
722            msg = "VIS:".to_string() + self.visible_points.len().to_string().as_str();
723            paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_GREEN, msg);
724        }
725        if let Some(pointer_pos) = resp.hover_pos() {
726            init_pos.y += 15.0;
727            msg = "HVR:".to_string()
728                + pointer_pos.x.to_string().as_str()
729                + ","
730                + pointer_pos.y.to_string().as_str();
731            paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::LIGHT_BLUE, msg);
732        }
733        let vec = resp.drag_delta();
734        if vec.length() != 0.0 {
735            init_pos.y += 15.0;
736            msg = "DRG:".to_string()
737                + vec.to_pos2().x.to_string().as_str()
738                + ","
739                + vec.to_pos2().y.to_string().as_str();
740            paint.debug_text(init_pos, Align2::LEFT_TOP, Color32::GOLD, msg);
741        }
742    }
743
744    fn paint_sub_components(&mut self, ui_obj: &mut Ui, rect: Rect) {
745        #[cfg(feature = "puffin")]
746        puffin::profile_scope!("map_ui_paint_sub_components");
747        let zoom_slider = egui::Slider::new(
748            &mut self.zoom,
749            self.settings.min_zoom..=self.settings.max_zoom,
750        )
751        .show_value(false)
752        .orientation(SliderOrientation::Vertical);
753        let mut pos1 = rect.right_top();
754        let mut pos2 = rect.right_top();
755        pos1.x -= 80.0;
756        pos1.y += 120.0;
757        pos2.x -= 60.0;
758        pos2.y += 240.0;
759
760        let sub_rect = egui::Rect::from_two_pos(pos1, pos2);
761        let ui_builder = egui::UiBuilder::new().clone().max_rect(sub_rect);
762        ui_obj.scope_builder(ui_builder, |ui_obj| {
763            ui_obj.add(zoom_slider);
764        });
765    }
766
767    fn paint_map_points(
768        &self,
769        vec_points: &Vec<isize>,
770        hashm: &Option<HashMap<usize, MapPoint>>,
771        paint: &Painter,
772        ui_obj: &mut Ui,
773        min_point: &RawPoint,
774        resp: &Response,
775    ) -> Result<Vec<usize>, ()> {
776        let mut nearest_id = None;
777        let mut nodes_to_remove = Vec::new();
778        let mut shape_vec = vec![];
779
780        if hashm.is_none() {
781            return Err(());
782        }
783        if vec_points.is_empty() {
784            return Err(());
785        }
786        // detecting the nearest hover node
787        if self.settings.node_text_visibility == VisibilitySetting::Hover
788            && resp.hovered()
789            && let Some(point) = resp.hover_pos()
790        {
791            let raw_point = RawPoint::from(point);
792            let hovered_map_point = (*min_point + raw_point) / self.zoom;
793            if let Ok(nearest_node) = self.tree.as_ref().unwrap().nearest(
794                &hovered_map_point.components,
795                1,
796                &squared_euclidean,
797            ) {
798                nearest_id = Some(nearest_node.first().unwrap().1);
799            }
800        }
801        // filling text settings
802        let mut text_settings = TextSettings {
803            size: 12.00 * self.zoom,
804            anchor: Align2::LEFT_BOTTOM,
805            family: FontFamily::Proportional,
806            text: String::new(),
807            position: RawPoint::default(),
808            text_color: ui_obj.visuals().text_color(),
809        };
810
811        // Drawing Points
812        for temp_point in vec_points {
813            let parsed_point = temp_point.cast_unsigned();
814            if let Some(system) = hashm.as_ref().unwrap().get(&parsed_point) {
815                #[cfg(feature = "puffin")]
816                puffin::profile_scope!("painting_points_m");
817                let viewport_point = RawPoint::from(system.coords) * self.zoom - min_point;
818                if let Some(node_template) = &self.node_template {
819                    if nearest_id.unwrap_or(&0usize) == &system.get_id() {
820                        node_template.selection_ui(ui_obj, viewport_point.into(), self.zoom);
821                    }
822                } else if self.zoom > self.settings.label_visible_zoom
823                    && self.settings.node_text_visibility == VisibilitySetting::Always
824                    || (self.settings.node_text_visibility == VisibilitySetting::Hover
825                        && nearest_id.unwrap_or(&0usize) == &system.get_id())
826                {
827                    let mut viewport_text = viewport_point;
828                    viewport_text.components[0] += 3.0 * self.zoom;
829                    viewport_text.components[1] -= 3.0 * self.zoom;
830                    text_settings.position = viewport_text;
831                    text_settings.text = system.get_name();
832                    self.paint_label(paint, &text_settings);
833                }
834
835                let system_id = system.get_id();
836                if let Some(init_time) = self.entities.get(&system_id) {
837                    if let Some(template) = &self.node_template {
838                        template.notification_ui(
839                            ui_obj,
840                            viewport_point.into(),
841                            self.zoom,
842                            *init_time,
843                            self.current_style().alert_color,
844                        );
845                    } else if Animation::pulse(
846                        paint,
847                        viewport_point,
848                        self.zoom,
849                        *init_time,
850                        self.current_style().alert_color,
851                    ) {
852                        ui_obj.ctx().request_repaint();
853                    } else {
854                        nodes_to_remove.push(system_id);
855                    }
856                }
857                if let Some(node_template) = &self.node_template {
858                    node_template.node_ui(ui_obj, viewport_point.into(), self.zoom, system);
859                } else {
860                    shape_vec.push(Shape::circle_filled(
861                        viewport_point.into(),
862                        4.00 * self.zoom,
863                        self.current_style().fill_color,
864                    ));
865                }
866            }
867        }
868        paint.extend(shape_vec);
869        Ok(nodes_to_remove)
870    }
871
872    fn paint_map_lines(&self, painter: &Painter, min_point: &RawPoint) {
873        #[cfg(feature = "puffin")]
874        puffin::profile_scope!("paint_map_lines");
875
876        // Drawing Lines
877        if self.zoom > self.settings.line_visible_zoom
878            && let Some(mut stroke) = self.current_style().line
879            && let Some(segments) = &self.segments
880        {
881            let mut shape_vec = vec![];
882            let transparency_range = self.zoom - self.settings.line_visible_zoom;
883            if (0.00..0.80).contains(&transparency_range) {
884                let mut tup_stroke = stroke.color.to_tuple();
885                let transparency = (self.zoom - self.settings.line_visible_zoom) / 0.80;
886                tup_stroke.3 = (255.0 * transparency).round() as u8;
887                let color = Color32::from_rgba_unmultiplied(
888                    tup_stroke.0,
889                    tup_stroke.1,
890                    tup_stroke.2,
891                    tup_stroke.3,
892                );
893                stroke = Stroke::new(stroke.width, color);
894            }
895            // Broad-phase: query the segment R-tree with the viewport AABB
896            // (in map coordinates), padded by the stroke width so lines at
897            // the very edge are not clipped prematurely.
898            let center = self.current.pos / self.zoom;
899            let padding = stroke.width / self.zoom;
900            let half = RawPoint::new(
901                self.map_area.width() / 2.0 / self.zoom + padding,
902                self.map_area.height() / 2.0 / self.zoom + padding,
903            );
904            let query = rstar::AABB::from_corners((center - half).into(), (center + half).into());
905            for segment in segments.locate_in_envelope_intersecting(query) {
906                let raw_line = segment.raw_line();
907                let pos_a = raw_line.points[0] * self.zoom - min_point;
908                let pos_b = raw_line.points[1] * self.zoom - min_point;
909                shape_vec.push(Shape::line_segment([pos_a.into(), pos_b.into()], stroke));
910            }
911            painter.extend(shape_vec);
912        }
913    }
914
915    fn paint_label(&self, paint: &Painter, text_settings: &TextSettings) {
916        #[cfg(feature = "puffin")]
917        puffin::profile_scope!("paint_label");
918        paint.text(
919            text_settings.position.into(),
920            text_settings.anchor,
921            text_settings.text.clone(),
922            FontId::new(text_settings.size, text_settings.family.clone()),
923            text_settings.text_color,
924        );
925    }
926
927    /// Triggers a notification highlight on the node `id_node`.
928    ///
929    /// By default the notification is rendered as a pulsing circle that starts
930    /// at `time` and plays for about 3.5 seconds; calling `notify` again for
931    /// the same node restarts the animation. The effect can be customized with
932    /// [`objects::NodeTemplate::notification_ui`].
933    pub fn notify(&mut self, id_node: usize, time: Instant) {
934        #[cfg(feature = "puffin")]
935        puffin::profile_scope!("notify");
936        self.entities
937            .entry(id_node)
938            .and_modify(|value| *value = time)
939            .or_insert(time);
940    }
941
942    /// Returns the id of the line closest to `point`, in map coordinates,
943    /// when it lies within `tolerance` map units of the segment.
944    ///
945    /// Broad-phase candidates are taken from the segment R-tree built by
946    /// [`Map::add_lines`]; the exact point-to-segment distance is then
947    /// computed against the line geometry and the closest match wins. Returns
948    /// `None` when no lines are loaded or every segment is farther than
949    /// `tolerance`. A negative `tolerance` behaves like `0.0`.
950    ///
951    /// To hit-test a mouse click, convert the screen position to map
952    /// coordinates first (`map = (screen + origin) / zoom`, see the
953    /// [coordinate model](self#coordinate-model)) and pick a tolerance scaled
954    /// by `1.0 / zoom` so it stays constant in screen pixels.
955    pub fn line_at(&self, point: [f32; 2], tolerance: f32) -> Option<(usize, usize)> {
956        #[cfg(feature = "puffin")]
957        puffin::profile_scope!("line_at");
958        let segments = self.segments.as_ref()?;
959        let tolerance = tolerance.max(0.0);
960
961        let center = RawPoint::from(point);
962        let padding = RawPoint::new(tolerance, tolerance);
963        let query = rstar::AABB::from_corners((center - padding).into(), (center + padding).into());
964
965        let mut closest: Option<(f32, (usize, usize))> = None;
966        for segment in segments.locate_in_envelope_intersecting(query) {
967            let distance = segment.raw_line().distance_to_point(center);
968            if distance <= tolerance && closest.as_ref().is_none_or(|(best, _)| distance < *best) {
969                closest = Some((distance, segment.id));
970            }
971        }
972        closest.map(|(_, id)| id)
973    }
974
975    /// Installs a right-click context menu whose contents are built by the
976    /// given [`ContextMenuManager`] implementation.
977    pub fn set_context_manager(&mut self, manager: Rc<dyn ContextMenuManager>) {
978        self.menu_manager = Some(manager);
979    }
980
981    /// Replaces the built-in node rendering with a custom [`NodeTemplate`]
982    /// implementation.
983    ///
984    /// The template takes over the drawing of nodes, selection highlights,
985    /// notification animations and markers — including the node name labels,
986    /// which the widget no longer draws once a template is installed. See the
987    /// [`NodeTemplate`] examples for custom shapes and animations.
988    pub fn set_node_template(&mut self, template: Rc<dyn NodeTemplate>) {
989        self.node_template = Some(template);
990    }
991
992    /// Adds the marker `id`, or moves it, so it points to the node `node_id`.
993    ///
994    /// Markers are drawn as a blinking ring around the target node unless a
995    /// custom [`objects::NodeTemplate::marker_ui`] is installed.
996    pub fn update_marker(&mut self, id: usize, node_id: usize) {
997        self.markers
998            .entry(id)
999            .and_modify(|value| *value = node_id)
1000            .or_insert(node_id);
1001    }
1002
1003    /// Sets the minimum width and/or height the widget should occupy, in egui
1004    /// points. `None` leaves the corresponding dimension unconstrained.
1005    pub fn allocate_at_least(&mut self, width: Option<f32>, height: Option<f32>) {
1006        self.min_size = (width, height);
1007    }
1008
1009    /// Sets the maximum width and/or height the widget should occupy, in egui
1010    /// points. `None` leaves the corresponding dimension unconstrained.
1011    pub fn allocate_at_most(&mut self, width: Option<f32>, height: Option<f32>) {
1012        self.max_size = (width, height);
1013    }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use std::time::Duration;
1020
1021    fn sample_points() -> Vec<MapPoint> {
1022        let mut map = Vec::new();
1023        map.push(MapPoint::new(1, [0.0, 0.0]));
1024        map.push(MapPoint::new(2, [10.0, 10.0]));
1025        map.push(MapPoint::new(3, [-10.0, -10.0]));
1026        map
1027    }
1028
1029    // ---------- construcción ----------
1030
1031    #[test]
1032    fn map_new_initial_state() {
1033        let map = Map::new();
1034        assert_eq!(map.zoom, 1.0);
1035        assert_eq!(map.previous_zoom, 1.0);
1036        assert!(map.points.is_none());
1037        assert!(map.segments.is_none());
1038        assert!(map.tree.is_none());
1039        assert!(map.labels.is_empty());
1040        assert!(map.visible_points.is_empty());
1041        assert!(map.markers.is_empty());
1042        assert!(map.entities.is_empty());
1043        assert_eq!(map.min_size, (None, None));
1044        assert_eq!(map.max_size, (None, None));
1045        assert_eq!(map.current_index, 0);
1046    }
1047
1048    #[test]
1049    fn map_default_equals_new() {
1050        let map = Map::default();
1051        assert_eq!(map.zoom, 1.0);
1052        assert!(map.points.is_none());
1053    }
1054
1055    // ---------- zoom ----------
1056
1057    #[test]
1058    fn set_zoom_within_range() {
1059        let mut map = Map::new();
1060        map.set_zoom(1.5);
1061        assert_eq!(map.get_zoom(), 1.5);
1062    }
1063
1064    #[test]
1065    fn set_zoom_at_exact_limits() {
1066        let mut map = Map::new();
1067        map.set_zoom(map.settings.min_zoom);
1068        assert_eq!(map.get_zoom(), 0.1);
1069        map.set_zoom(map.settings.max_zoom);
1070        assert_eq!(map.get_zoom(), 2.0);
1071    }
1072
1073    #[test]
1074    fn set_zoom_out_of_range_is_ignored() {
1075        let mut map = Map::new();
1076        let initial = map.get_zoom();
1077        map.set_zoom(0.05); // por debajo de min_zoom
1078        assert_eq!(map.get_zoom(), initial);
1079        map.set_zoom(2.5); // por encima de max_zoom
1080        assert_eq!(map.get_zoom(), initial);
1081    }
1082
1083    // ---------- puntos ----------
1084
1085    #[test]
1086    fn add_hashmap_points_computes_bounds() {
1087        let mut map = Map::new();
1088        map.add_points(sample_points());
1089
1090        assert_eq!(map.reference.min.components, [-10.0, -10.0]);
1091        assert_eq!(map.reference.max.components, [10.0, 10.0]);
1092        // pos es el punto medio del rectángulo que contiene todos los puntos
1093        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
1094        // map_area tiene área 0 antes de renderizar, así que dist es el valor fijo
1095        assert_eq!(map.reference.dist, 3000.0);
1096        // current se inicializa como copia de reference
1097        assert_eq!(map.current.min.components, map.reference.min.components);
1098        assert_eq!(map.current.max.components, map.reference.max.components);
1099        assert_eq!(map.current.pos.components, map.reference.pos.components);
1100        assert_eq!(map.current.dist, map.reference.dist);
1101        assert!(map.points.is_some());
1102        assert!(map.tree.is_some());
1103        assert_eq!(map.points.as_ref().unwrap().len(), 3);
1104    }
1105
1106    #[test]
1107    fn add_hashmap_points_populates_visible_points() {
1108        let mut map = Map::new();
1109        map.add_points(sample_points());
1110        // todos los puntos de muestra caen dentro del radio por defecto
1111        assert_eq!(map.visible_points.len(), 3);
1112    }
1113
1114    /// Renders one frame of `map` in a 500x500 viewport and returns the
1115    /// painted line segments.
1116    fn render_line_segments(map: &mut Map) -> Vec<[egui::Pos2; 2]> {
1117        use egui::{Context, RawInput, Shape};
1118        let ctx = Context::default();
1119        let input = RawInput {
1120            screen_rect: Some(egui::Rect::from_min_size(
1121                egui::Pos2::ZERO,
1122                egui::vec2(500.0, 500.0),
1123            )),
1124            ..RawInput::default()
1125        };
1126        let output = ctx.run_ui(input, |ui| {
1127            ui.add(&mut *map);
1128        });
1129        output
1130            .shapes
1131            .iter()
1132            .filter_map(|cs| match cs.shape {
1133                Shape::LineSegment { points, .. } => Some(points),
1134                _ => None,
1135            })
1136            .collect()
1137    }
1138
1139    #[test]
1140    fn segment_crossing_viewport_is_painted_even_with_far_endpoints() {
1141        // With the old endpoint-based rule this line was culled: both
1142        // endpoints sit beyond the point-culling radius. With the R-tree the
1143        // segment AABB intersects the viewport, so it is painted — no points
1144        // needed at all.
1145        let mut map = Map::new();
1146        map.set_zoom(1.0);
1147        let mut lines = Vec::new();
1148        lines.push(MapSegment::new((1, 2), [-4000.0, -1.0], [4000.0, 1.0]));
1149        map.add_lines(lines);
1150        map.set_pos([0.0, 0.0]);
1151
1152        let segments = render_line_segments(&mut map);
1153        assert_eq!(segments.len(), 1);
1154    }
1155
1156    #[test]
1157    fn segment_outside_viewport_is_not_painted() {
1158        let mut map = Map::new();
1159        map.set_zoom(1.0);
1160        let mut lines = Vec::new();
1161        lines.push(MapSegment::new(
1162            (1, 2),
1163            [10_000.0, 10_000.0],
1164            [10_100.0, 10_100.0],
1165        ));
1166        map.add_lines(lines);
1167        map.set_pos([0.0, 0.0]);
1168
1169        assert!(render_line_segments(&mut map).is_empty());
1170    }
1171
1172    #[test]
1173    fn add_lines_builds_segment_tree() {
1174        let mut map = Map::new();
1175        map.add_points(sample_points());
1176        let mut lines = Vec::new();
1177        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1178        map.add_lines(lines);
1179
1180        let tree = map
1181            .segments
1182            .as_ref()
1183            .expect("add_lines must build the segment tree");
1184        assert_eq!(tree.size(), 1);
1185
1186        // Broad-phase query: a viewport containing (0,0) must hit the segment;
1187        // a far-away viewport must not.
1188        let hit_query = rstar::AABB::from_corners([-1.0, -1.0], [1.0, 1.0]);
1189        let hits: Vec<_> = tree.locate_in_envelope_intersecting(hit_query).collect();
1190        assert_eq!(hits.len(), 1);
1191        assert_eq!(hits[0].id, (1, 2));
1192
1193        let miss_query = rstar::AABB::from_corners([100.0, 100.0], [200.0, 200.0]);
1194        assert_eq!(tree.locate_in_envelope_intersecting(miss_query).count(), 0);
1195    }
1196
1197    #[test]
1198    fn map_check_line_is_painted_on_first_frame() {
1199        use egui::{Context, RawInput, Shape};
1200
1201        // --- arrange ---
1202        let mut map = Map::new();
1203        map.set_zoom(1.0);
1204
1205        let mut point_a = MapPoint::new(0, [0.0, 0.0]);
1206        point_a.connections.push((0, 1));
1207        let mut point_b = MapPoint::new(1, [50.0, 50.0]);
1208        point_b.connections.push((0, 1));
1209
1210        let mut lines = Vec::new();
1211        lines.push(MapSegment::new((0, 1), point_a.coords, point_b.coords));
1212
1213        let mut points = Vec::new();
1214        points.push(point_a);
1215        points.push(point_b);
1216        // Load points before lines — the natural order shown in the examples.
1217        map.add_points(points);
1218        map.add_lines(lines);
1219
1220        map.set_pos([25.0, 25.0]);
1221
1222        // --- act: 1st frame (no CentralPanel — run_ui creates the root Ui) ---
1223        let ctx = Context::default();
1224        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 500.0));
1225        let input = RawInput {
1226            screen_rect: Some(screen),
1227            ..RawInput::default()
1228        };
1229
1230        let output1 = ctx.run_ui(input.clone(), |ui| {
1231            ui.add(&mut map);
1232        });
1233
1234        let segments1: Vec<[egui::Pos2; 2]> = output1
1235            .shapes
1236            .iter()
1237            .filter_map(|cs| match cs.shape {
1238                Shape::LineSegment { points, .. } => Some(points),
1239                _ => None,
1240            })
1241            .collect();
1242
1243        assert!(
1244            !segments1.is_empty(),
1245            "Frame 1: no LineSegment shapes painted (map lines did not draw)"
1246        );
1247
1248        // Expected projection of (0,0)->(50,50) with zoom=1, center=(25,25),
1249        // viewport 500x500: pos_a = (225, 225), pos_b = (275, 275). Tolerance ±2 px.
1250        let expected_a = egui::pos2(225.0, 225.0);
1251        let expected_b = egui::pos2(275.0, 275.0);
1252        let tolerance = 2.0;
1253        let found_on_frame1 = segments1.iter().any(|[p1, p2]| {
1254            let d_a1 = p1.distance(expected_a);
1255            let d_b1 = p2.distance(expected_b);
1256            let d_a2 = p2.distance(expected_a);
1257            let d_b2 = p1.distance(expected_b);
1258            (d_a1 < tolerance && d_b1 < tolerance) || (d_a2 < tolerance && d_b2 < tolerance)
1259        });
1260        assert!(
1261            found_on_frame1,
1262            "Frame 1: no LineSegment matches expected endpoints (~225,225 -> ~275,275); got {:?}",
1263            segments1
1264        );
1265
1266        // --- act: 2nd frame (unchanged) — detect duplicate-lines regression ---
1267        let output2 = ctx.run_ui(input, |ui| {
1268            ui.add(&mut map);
1269        });
1270
1271        let segments2: Vec<[egui::Pos2; 2]> = output2
1272            .shapes
1273            .iter()
1274            .filter_map(|cs| match cs.shape {
1275                Shape::LineSegment { points, .. } => Some(points),
1276                _ => None,
1277            })
1278            .collect();
1279
1280        assert_eq!(
1281            segments1.len(),
1282            segments2.len(),
1283            "Frame 2: expected {} line segments (no duplication across frames), got {}",
1284            segments1.len(),
1285            segments2.len()
1286        );
1287    }
1288
1289    // ---------- posición ----------
1290
1291    #[test]
1292    fn set_pos_and_get_pos_roundtrip() {
1293        let mut map = Map::new();
1294        map.set_pos([25.0, -35.0]);
1295        assert_eq!(map.get_pos(), [25.0, -35.0]);
1296    }
1297
1298    #[test]
1299    fn set_pos_from_nodeid_with_valid_id() {
1300        let mut map = Map::new();
1301        map.add_points(sample_points());
1302        map.set_pos_from_nodeid(2);
1303        assert_eq!(map.get_pos(), [10.0, 10.0]);
1304    }
1305
1306    #[test]
1307    fn set_pos_from_nodeid_with_invalid_id_keeps_position() {
1308        let mut map = Map::new();
1309        map.add_points(sample_points());
1310        let before = map.reference.pos.components;
1311        map.set_pos_from_nodeid(999);
1312        assert_eq!(map.reference.pos.components, before);
1313    }
1314
1315    #[test]
1316    fn set_pos_from_nodeid_without_points_does_nothing() {
1317        let mut map = Map::new();
1318        map.set_pos_from_nodeid(1);
1319        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
1320    }
1321
1322    // ---------- etiquetas y líneas ----------
1323
1324    #[test]
1325    fn add_labels_stores_labels() {
1326        let mut map = Map::new();
1327        let label = MapLabel {
1328            text: "Region".to_string(),
1329            center: Pos2::new(1.0, 2.0),
1330        };
1331        map.add_labels(vec![label]);
1332        assert_eq!(map.labels.len(), 1);
1333        assert_eq!(map.labels[0].text, "Region");
1334    }
1335
1336    #[test]
1337    fn add_lines_stores_lines() {
1338        let mut map = Map::new();
1339        let mut lines = Vec::new();
1340        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [1.0, 1.0]));
1341        map.add_lines(lines);
1342        let tree = map.segments.as_ref().unwrap();
1343        assert_eq!(tree.size(), 1);
1344        assert_eq!(
1345            tree.locate_in_envelope_intersecting(rstar::AABB::from_corners(
1346                [-1.0, -1.0],
1347                [2.0, 2.0],
1348            ))
1349            .next()
1350            .unwrap()
1351            .id,
1352            (1, 2)
1353        );
1354    }
1355
1356    // ---------- notificaciones y marcadores ----------
1357
1358    #[test]
1359    fn line_at_returns_closest_line_within_tolerance() {
1360        let mut map = Map::new();
1361        map.add_points(sample_points());
1362        let mut lines = Vec::new();
1363        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]));
1364        lines.push(MapSegment::new((3, 4), [20.0, -5.0], [20.0, 5.0]));
1365        map.add_lines(lines);
1366
1367        // 1.5 units above the horizontal segment.
1368        let hit = map.line_at([5.0, 1.5], 2.0).expect("line must be hit");
1369        assert_eq!(hit, (1, 2));
1370
1371        // Closest to the vertical segment.
1372        let hit = map.line_at([19.0, 0.0], 2.0).expect("line must be hit");
1373        assert_eq!(hit, (3, 4));
1374    }
1375
1376    #[test]
1377    fn line_at_returns_none_beyond_tolerance() {
1378        let mut map = Map::new();
1379        map.add_points(sample_points());
1380        let mut lines = Vec::new();
1381        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1382        map.add_lines(lines);
1383
1384        // Distance from (5,4) to the diagonal segment (0,0)-(10,10) is
1385        // |5-4|/sqrt(2) ~= 0.707.
1386        assert!(map.line_at([5.0, 4.0], 0.8).is_some());
1387        assert!(map.line_at([5.0, 4.0], 0.5).is_none());
1388        assert!(map.line_at([100.0, 100.0], 5.0).is_none());
1389    }
1390
1391    #[test]
1392    fn line_at_returns_none_without_lines() {
1393        let map = Map::new();
1394        assert!(map.line_at([0.0, 0.0], 10.0).is_none());
1395    }
1396
1397    #[test]
1398    fn line_at_negative_tolerance_behaves_like_zero() {
1399        let mut map = Map::new();
1400        map.add_points(sample_points());
1401        let mut lines = Vec::new();
1402        lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
1403        map.add_lines(lines);
1404
1405        // Exact point on the segment is hit even with tolerance clamped to 0.
1406        assert!(map.line_at([5.0, 5.0], -1.0).is_some());
1407        assert!(map.line_at([5.0, 5.1], -1.0).is_none());
1408    }
1409
1410    #[test]
1411    fn notify_inserts_and_updates_entities() {
1412        let mut map = Map::new();
1413        let t1 = Instant::now();
1414        map.notify(5, t1);
1415        assert_eq!(map.entities.get(&5), Some(&t1));
1416
1417        let t2 = t1 + Duration::from_secs(1);
1418        map.notify(5, t2);
1419        assert_eq!(map.entities.get(&5), Some(&t2));
1420        assert_eq!(map.entities.len(), 1);
1421    }
1422
1423    #[test]
1424    fn update_marker_inserts_and_updates() {
1425        let mut map = Map::new();
1426        map.update_marker(1, 100);
1427        assert_eq!(map.markers.get(&1), Some(&100));
1428        map.update_marker(1, 200);
1429        assert_eq!(map.markers.get(&1), Some(&200));
1430        assert_eq!(map.markers.len(), 1);
1431    }
1432
1433    // ---------- tamaño ----------
1434
1435    #[test]
1436    fn allocate_at_least_sets_min_size() {
1437        let mut map = Map::new();
1438        map.allocate_at_least(Some(100.0), None);
1439        assert_eq!(map.min_size, (Some(100.0), None));
1440    }
1441
1442    #[test]
1443    fn allocate_at_most_sets_max_size() {
1444        let mut map = Map::new();
1445        map.allocate_at_most(None, Some(200.0));
1446        assert_eq!(map.max_size, (None, Some(200.0)));
1447    }
1448
1449    // ---------- bounds ----------
1450
1451    #[test]
1452    fn adjust_bounds_scales_with_zoom() {
1453        let mut map = Map::new();
1454        map.reference.min = RawPoint::new(-10.0, -20.0);
1455        map.reference.max = RawPoint::new(10.0, 20.0);
1456        map.reference.pos = RawPoint::new(5.0, 5.0);
1457        map.reference.dist = 100.0;
1458        map.set_zoom(2.0);
1459        map.adjust_bounds();
1460
1461        assert_eq!(map.current.max.components, [20.0, 40.0]);
1462        assert_eq!(map.current.min.components, [-20.0, -40.0]);
1463        assert_eq!(map.current.pos.components, [10.0, 10.0]);
1464        assert_eq!(map.current.dist, 50.0);
1465    }
1466}