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