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