Skip to main content

egui_map/
map.rs

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