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