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