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//!
71//! ## Animating nodes and segments
72//!
73//! [`Map::node`] and [`Map::segment`] borrow a node or a segment already
74//! loaded into the widget and return a handle -- [`NodeHandle`] /
75//! [`SegmentHandle`] -- with one method per built-in effect. Effects come in
76//! two families: event-driven ones (`pulse`, `flash`, ...) play once from an
77//! [`Instant`] and stop on their own; lasting ones (`halo`, `comet`, ...) run
78//! until [`NodeHandle::clear`] / [`SegmentHandle::clear`] and keep the app
79//! repainting the whole time they're active.
80//!
81//! ```
82//! use egui_map::map::Map;
83//! use egui_map::map::objects::{MapPoint, MapSegment};
84//! use std::time::Instant;
85//!
86//! let mut map = Map::new();
87//! map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
88//! map.add_lines(vec![MapSegment::new((1, 1), [0.0, 0.0], [10.0, 0.0])]);
89//!
90//! if let Some(node) = map.node(1) {
91//!     node.pulse(Instant::now());
92//! }
93//! if let Some(segment) = map.segment((1, 1)) {
94//!     segment.comet();
95//! }
96//! ```
97//!
98//! To fully replace how an effect looks, install a [`objects::NodeTemplate`] /
99//! [`objects::SegmentTemplate`] and implement its `notification_ui` /
100//! `segment_notification_ui` and `marker_ui` / `segment_state_ui` hooks -- or
101//! call [`animation::Animation`]'s functions directly from either template if
102//! you only want to reuse the built-in look.
103
104use crate::map::animation::Animation;
105use crate::map::objects::{
106    CometDirection, ContextMenuManager, MapBounds, MapLabel, MapPoint, MapSegment, MapSettings,
107    MarkerContext, NodeAnimation, NodeContext, NotificationContext, RawLine, RawPoint,
108    SegmentAnimation, SelectionContext, SteadyAnimation, SteadySegmentAnimation, TextSettings,
109    VisibilitySetting,
110};
111use crate::map::theme::{ColorMode, MapTheme, Style, Theme, ThemeColors};
112use egui::{widgets::*, *};
113use kdtree::KdTree;
114use kdtree::distance::squared_euclidean;
115use std::collections::{HashMap, HashSet};
116use std::rc::Rc;
117use std::time::Instant;
118
119use self::objects::{NodeTemplate, SegmentTemplate};
120
121pub mod animation;
122pub mod objects;
123pub mod theme;
124
125/// How much more opaque a segment effect (`comet`, `dash`, `flash`, ...)
126/// stays than the segment's own zoom-based fade-in, in `paint_map_lines` --
127/// see `line_fade`/`effect_fade` there. Effects track the line's fade rather
128/// than ignoring it, but always keep this much of a head start so they read
129/// as at least as visible as the line beneath them instead of fading out in
130/// lockstep and risking disappearing into it.
131const SEGMENT_EFFECT_ALPHA_BOOST: f32 = 0.2;
132
133/// Returns `color` with its alpha multiplied by `factor` (clamped to
134/// `0.0..=1.0`), preserving whatever RGB the caller already set rather than
135/// replacing it outright.
136///
137/// `Color32`'s `r()`/`g()`/`b()` accessors return the *premultiplied*
138/// bytes it stores internally, not the original unmultiplied channels --
139/// feeding those straight back into [`Color32::from_rgba_unmultiplied`]
140/// with a new alpha would premultiply them a second time and darken the
141/// color instead of just fading it. Going through
142/// [`Color32::to_srgba_unmultiplied`] first recovers the true unmultiplied
143/// RGB so only the alpha actually changes.
144fn scale_alpha(color: Color32, factor: f32) -> Color32 {
145    let [r, g, b, a] = color.to_srgba_unmultiplied();
146    Color32::from_rgba_unmultiplied(r, g, b, (a as f32 * factor.clamp(0.0, 1.0)).round() as u8)
147}
148
149/// An interactive 2D map widget.
150///
151/// `Map` renders a set of nodes ([`objects::MapPoint`]), connection lines
152/// ([`objects::MapSegment`]) and text labels ([`objects::MapLabel`]). The user can
153/// pan the view by dragging and zoom with the mouse wheel (hold `Ctrl` — or
154/// `Cmd` on macOS — to zoom faster), or use the built-in zoom slider drawn at
155/// the top-right corner of the widget.
156///
157/// The map is fed through [`Map::add_hashmap_points`], which also builds the
158/// internal kd-tree used for viewport culling and nearest-node hover queries.
159/// Behavior and appearance are configured through the public
160/// [`settings`](Map::settings) field (see [`objects::MapSettings`]).
161///
162/// Rendering of nodes and their visual effects (selection highlight,
163/// notifications and markers) can be fully customized by installing a
164/// [`objects::NodeTemplate`] implementation with [`Map::set_node_template`],
165/// and segments likewise with [`objects::SegmentTemplate`] and
166/// [`Map::set_segment_template`]; a right-click context menu can be provided
167/// with [`Map::set_context_manager`].
168///
169/// # Examples
170///
171/// ```no_run
172/// # fn example(ui: &mut egui::Ui) {
173/// use egui_map::map::Map;
174/// use egui_map::map::objects::MapPoint;
175/// use std::collections::HashMap;
176///
177/// let mut points = HashMap::new();
178/// points.insert(1, MapPoint::new(1, [0.0, 0.0]));
179///
180/// let mut map = Map::new();
181/// map.add_hashmap_points(points);
182///
183/// // Every frame, inside your egui update logic:
184/// ui.add(&mut map);
185/// # }
186/// ```
187#[derive(Clone)]
188pub struct Map {
189    zoom: f32,
190    previous_zoom: f32,
191    points: Option<HashMap<usize, MapPoint>>,
192    segments: Option<rstar::RTree<MapSegment>>,
193    labels: Vec<MapLabel>,
194    tree: Option<KdTree<f32, usize, [f32; 2]>>,
195    visible_points: Vec<isize>,
196    map_area: Rect,
197    reference: MapBounds,
198    current: MapBounds,
199    current_index: usize,
200    notifications: HashMap<usize, Notification>,
201    node_states: HashMap<usize, NodeState>,
202    segment_notifications: HashMap<(usize, usize), SegmentNotification>,
203    segment_states: HashMap<(usize, usize), SegmentState>,
204    /// Ids of the segments currently loaded, kept alongside the R-tree so
205    /// [`Map::segment`] can check whether an id exists in O(1) instead of
206    /// scanning it.
207    segment_ids: HashSet<(usize, usize)>,
208    min_size: (Option<f32>, Option<f32>),
209    max_size: (Option<f32>, Option<f32>),
210    /// Behavior and appearance configuration (zoom limits, visibility
211    /// thresholds and per-theme styles). See [`objects::MapSettings`].
212    pub settings: MapSettings,
213    menu_manager: Option<Rc<dyn ContextMenuManager>>,
214    node_template: Option<Rc<dyn NodeTemplate>>,
215    segment_template: Option<Rc<dyn SegmentTemplate>>,
216    markers: HashMap<usize, usize>,
217    /// The active color palette. See [`Map::set_theme`].
218    theme: Rc<dyn MapTheme>,
219}
220
221/// A one-off effect attached to a node, with the moment it started.
222#[derive(Clone, Copy, Debug)]
223struct Notification {
224    started: Instant,
225    animation: NodeAnimation,
226    /// `None` falls back to the current style's `alert_color`.
227    color: Option<Color32>,
228}
229
230/// Lasting state attached to a node, drawn until it is cleared.
231#[derive(Clone, Copy, Debug)]
232struct NodeState {
233    animation: SteadyAnimation,
234    /// `None` falls back to the current style's `alert_color`.
235    color: Option<Color32>,
236}
237
238/// A one-off effect attached to a segment, with the moment it started.
239#[derive(Clone, Copy, Debug)]
240struct SegmentNotification {
241    started: Instant,
242    animation: SegmentAnimation,
243    /// `None` falls back to the current style's `alert_color`.
244    color: Option<Color32>,
245}
246
247/// Lasting state attached to a segment, drawn until it is cleared.
248#[derive(Clone, Copy, Debug)]
249struct SegmentState {
250    animation: SteadySegmentAnimation,
251    /// `None` falls back to the current style's `alert_color`.
252    color: Option<Color32>,
253}
254
255/// A borrowed node, obtained from [`Map::node`], that an animation can be
256/// attached to.
257///
258/// Modifiers such as [`NodeHandle::color`] come first; the effect method is the
259/// terminal call that writes everything at once. A modifier on its own does
260/// nothing, so there is no way to configure a notification that does not exist.
261///
262/// Effects come in two families, and which one you call decides how it ends:
263///
264/// - [`pulse`](Self::pulse), [`ripple`](Self::ripple),
265///   [`countdown`](Self::countdown), [`scale_in`](Self::scale_in) and
266///   [`crosshair`](Self::crosshair) play once from the [`Instant`] you pass and
267///   stop on their own.
268/// - [`halo`](Self::halo), [`blink`](Self::blink) and [`orbit`](Self::orbit)
269///   are lasting state: they run until [`clear`](Self::clear), and keep the app
270///   repainting the whole time.
271///
272/// A node can carry one of each at once; the state is drawn underneath the
273/// event.
274pub struct NodeHandle<'a> {
275    map: &'a mut Map,
276    id: usize,
277    color: Option<Color32>,
278}
279
280impl NodeHandle<'_> {
281    /// Overrides the colour of the effect about to be attached.
282    ///
283    /// Without this the effect uses the current style's `alert_color`.
284    pub fn color(mut self, color: Color32) -> Self {
285        self.color = Some(color);
286        self
287    }
288
289    fn notify_with(self, animation: NodeAnimation, at: Instant) {
290        self.map.notifications.insert(
291            self.id,
292            Notification {
293                started: at,
294                animation,
295                color: self.color,
296            },
297        );
298    }
299
300    fn set_state(self, animation: SteadyAnimation) {
301        self.map.node_states.insert(
302            self.id,
303            NodeState {
304                animation,
305                color: self.color,
306            },
307        );
308    }
309
310    /// Expanding, fading disc. Reads as "one thing happened here".
311    pub fn pulse(self, at: Instant) {
312        self.notify_with(NodeAnimation::Pulse, at);
313    }
314
315    /// Three staggered expanding rings. Reads as "activity is ongoing".
316    pub fn ripple(self, at: Instant) {
317        self.notify_with(NodeAnimation::Ripple, at);
318    }
319
320    /// A ring emptying clockwise. Reads as "how old is this information".
321    pub fn countdown(self, at: Instant) {
322        self.notify_with(NodeAnimation::CountdownArc, at);
323    }
324
325    /// A disc that overshoots and settles. For a node that just appeared.
326    pub fn scale_in(self, at: Instant) {
327        self.notify_with(NodeAnimation::ScaleIn, at);
328    }
329
330    /// Four ticks converging on the node. Reads as "target acquired".
331    pub fn crosshair(self, at: Instant) {
332        self.notify_with(NodeAnimation::Crosshair, at);
333    }
334
335    /// Lasting ring whose opacity breathes. Runs until [`Self::clear`].
336    pub fn halo(self) {
337        self.set_state(SteadyAnimation::Halo);
338    }
339
340    /// Lasting thick ring blinking on and off. Runs until [`Self::clear`].
341    pub fn blink(self) {
342        self.set_state(SteadyAnimation::Blink);
343    }
344
345    /// Lasting dot circling the node. Runs until [`Self::clear`].
346    pub fn orbit(self) {
347        self.set_state(SteadyAnimation::Orbit);
348    }
349
350    /// Removes both the notification and the lasting state of this node.
351    pub fn clear(self) {
352        self.map.notifications.remove(&self.id);
353        self.map.node_states.remove(&self.id);
354    }
355}
356
357/// A borrowed segment, obtained from [`Map::segment`], that an animation can
358/// be attached to.
359///
360/// Mirrors [`NodeHandle`], with the same modifier-then-terminal shape:
361///
362/// - [`flash`](Self::flash), [`comet_once`](Self::comet_once) and
363///   [`wipe`](Self::wipe) play once from the [`Instant`] you pass and stop on
364///   their own.
365/// - [`comet`](Self::comet), [`dash`](Self::dash), [`glow_band`](Self::glow_band)
366///   and [`chevrons`](Self::chevrons) are lasting state: they run until
367///   [`clear`](Self::clear), and keep the app repainting the whole time.
368///
369/// A segment can carry one of each at once; the state is drawn underneath the
370/// event, same as node effects.
371pub struct SegmentHandle<'a> {
372    map: &'a mut Map,
373    id: (usize, usize),
374    color: Option<Color32>,
375}
376
377impl SegmentHandle<'_> {
378    /// Overrides the colour of the effect about to be attached.
379    ///
380    /// Without this the effect uses the current style's `alert_color`.
381    pub fn color(mut self, color: Color32) -> Self {
382        self.color = Some(color);
383        self
384    }
385
386    /// Brief flash that fades back out. The segment analogue of
387    /// [`NodeHandle::pulse`] — reads as "something happened on this route".
388    pub fn flash(self, at: Instant) {
389        self.map.segment_notifications.insert(
390            self.id,
391            SegmentNotification {
392                started: at,
393                animation: SegmentAnimation::FlashDecay,
394                color: self.color,
395            },
396        );
397    }
398
399    /// Single dot pass from one endpoint to the other, then gone. The
400    /// event-driven counterpart to [`Self::comet`] — reads as "one thing
401    /// moved along this route just now" rather than "traffic keeps flowing
402    /// this way". `direction` picks which endpoint it starts from.
403    pub fn comet_once(self, at: Instant, direction: CometDirection) {
404        self.map.segment_notifications.insert(
405            self.id,
406            SegmentNotification {
407                started: at,
408                animation: SegmentAnimation::Comet(direction),
409                color: self.color,
410            },
411        );
412    }
413
414    /// Line drawing itself in from the first endpoint to the second, then
415    /// gone — reads as "this route was just established" rather than
416    /// "something travelled along it".
417    pub fn wipe(self, at: Instant) {
418        self.map.segment_notifications.insert(
419            self.id,
420            SegmentNotification {
421                started: at,
422                animation: SegmentAnimation::Wipe,
423                color: self.color,
424            },
425        );
426    }
427
428    /// Lasting dot travelling along the segment. Runs until
429    /// [`Self::clear`].
430    pub fn comet(self) {
431        self.map.segment_states.insert(
432            self.id,
433            SegmentState {
434                animation: SteadySegmentAnimation::Comet,
435                color: self.color,
436            },
437        );
438    }
439
440    /// Lasting dashed line, its pattern sliding along the segment
441    /// ("marching ants"). Runs until [`Self::clear`].
442    pub fn dash(self) {
443        self.map.segment_states.insert(
444            self.id,
445            SegmentState {
446                animation: SteadySegmentAnimation::Dash,
447                color: self.color,
448            },
449        );
450    }
451
452    /// Lasting band of brightness travelling the length of the segment and
453    /// looping. Reads as "flow", calmer than [`Self::dash`]. Runs until
454    /// [`Self::clear`].
455    pub fn glow_band(self) {
456        self.map.segment_states.insert(
457            self.id,
458            SegmentState {
459                animation: SteadySegmentAnimation::GlowBand,
460                color: self.color,
461            },
462        );
463    }
464
465    /// Lasting row of arrow shapes sliding along the segment, pointing the
466    /// way. Runs until [`Self::clear`].
467    pub fn chevrons(self) {
468        self.map.segment_states.insert(
469            self.id,
470            SegmentState {
471                animation: SteadySegmentAnimation::Chevrons,
472                color: self.color,
473            },
474        );
475    }
476
477    /// Removes both the notification and the lasting state of this segment.
478    pub fn clear(self) {
479        self.map.segment_notifications.remove(&self.id);
480        self.map.segment_states.remove(&self.id);
481    }
482}
483
484impl Default for Map {
485    /// Creates an empty map; equivalent to [`Map::new`].
486    fn default() -> Self {
487        Map::new()
488    }
489}
490
491impl Widget for &mut Map {
492    /// Renders the map, handling panning (drag), zooming (mouse wheel) and the
493    /// right-click context menu if one was installed.
494    fn ui(self, ui: &mut egui::Ui) -> Response {
495        let rect = self.calculate_widget_dimensions(ui);
496
497        // we define the initial coordinate as the center of such rectangle
498        let reference_dist = rect.distance();
499        // `reference.dist` is refreshed here every frame, but `current.dist` --
500        // the value the viewport cull actually queries with -- is only derived
501        // from it inside `adjust_bounds`, which used to run on a zoom change or
502        // a `set_pos` and nothing else. Resizing the window therefore left the
503        // cull radius stale until the next zoom, so the node set was culled
504        // against the *old* widget size (too few nodes after growing the
505        // window, too many after shrinking it). Tracked here so the bounds can
506        // be recomputed below, next to the zoom-change branch.
507        let resized = reference_dist != self.reference.dist;
508        self.reference.dist = reference_dist;
509
510        self.assign_visual_style(ui);
511
512        let canvas = egui::Frame::canvas(ui.style()).inner_margin(Margin::symmetric(3, 5));
513
514        // The frame consumes `total_margin` (inner margin + stroke width +
515        // outer margin) around whatever is drawn inside it. `map_area` is the
516        // widget's *whole* footprint, so the painter may only claim what is
517        // left after that margin. Allocating `map_area.size()` inside the
518        // frame made the frame grow to `map_area.size() + 2 * total_margin`
519        // and spill past the space the widget was given, which left the
520        // visible drawable region truncated on the right/bottom -- so its
521        // centre no longer matched the point `set_pos`/`set_pos_from_nodeid`
522        // centre on, and nodes were drawn half a margin off.
523        let frame_margin = canvas.total_margin().sum();
524        let painter_size = (self.map_area.size() - frame_margin).max(Vec2::ZERO);
525
526        let inner_response = canvas.show(ui, |ui| {
527            let _span = tracing::info_span!("paint_map").entered();
528
529            if ui.is_rect_visible(self.map_area) {
530                let (resp, paint) =
531                    ui.allocate_painter(painter_size, egui::Sense::click_and_drag());
532                let vec = resp.drag_delta();
533                if vec.length() != 0.0 {
534                    let _span = tracing::info_span!("calculating_points_in_visible_area").entered();
535
536                    let coords = RawPoint::from(vec.to_pos2());
537                    let new_pos = self.reference.pos - (coords / self.zoom);
538                    self.set_pos(new_pos.into());
539                }
540                if self.zoom < self.settings.line_visible_zoom {
541                    // filling text settings
542                    let mut text_settings = TextSettings {
543                        // Screen-space size: unlike the map geometry this is
544                        // NOT multiplied by the zoom, so the label stays just
545                        // as readable however far the map is zoomed out.
546                        size: self.settings.label_text_size,
547                        anchor: Align2::CENTER_CENTER,
548                        family: FontFamily::Proportional,
549                        text: String::new(),
550                        position: RawPoint::default(),
551                        text_color: ui.visuals().text_color(),
552                    };
553                    for label in &self.labels {
554                        text_settings.text.clone_from(&label.text);
555                        text_settings.position = RawPoint::from(label.center);
556                        self.paint_label(&paint, &text_settings);
557                    }
558                }
559
560                // Centre on the rect we actually paint into. Now that the
561                // painter is sized to the frame's content area this is exactly
562                // `map_area.center()`, but deriving it from `resp.rect` keeps
563                // projection, hover hit-testing and the frame in agreement if
564                // the frame's margins ever change.
565                let rect_midpoint = RawPoint::from(resp.rect.center());
566                let min_point = self.current.pos - rect_midpoint;
567                let vec_points = &self.visible_points;
568                let hashm = &self.points;
569
570                // Safety net: drop stale notifications even if their node/
571                // segment is outside the viewport and never finishes its
572                // animation.
573                let now = Instant::now();
574                self.notifications
575                    .retain(|_, n| now.duration_since(n.started).as_secs_f32() < 10.0);
576                self.segment_notifications
577                    .retain(|_, n| now.duration_since(n.started).as_secs_f32() < 10.0);
578
579                for segment in self.paint_map_lines(&paint, &min_point) {
580                    self.segment_notifications.remove(&segment);
581                }
582
583                if let Ok(nodes_to_remove) =
584                    self.paint_map_points(vec_points, hashm, &paint, ui, &min_point, &resp)
585                {
586                    for node in nodes_to_remove {
587                        self.notifications.remove(&node);
588                    }
589                }
590
591                for marker in &self.markers {
592                    if let Some(point) = self.points.as_ref().unwrap().get(marker.1) {
593                        let adjusted_point = RawPoint::from(point.coords) * self.zoom - min_point;
594                        if let Some(template) = &self.node_template {
595                            template.marker_ui(
596                                ui,
597                                MarkerContext {
598                                    position: adjusted_point.into(),
599                                    zoom: self.zoom,
600                                    kind: self.settings.marker_animation,
601                                    node_id: *marker.1,
602                                },
603                            );
604                        } else {
605                            let color = if ui.visuals().dark_mode {
606                                Color32::LIGHT_GREEN
607                            } else {
608                                Color32::GREEN
609                            };
610                            // Frame time, so every marker in this frame shares
611                            // one clock instead of each sampling the wall clock
612                            // at a slightly different moment.
613                            let time = ui.input(|i| i.time) as f32;
614                            let effect = match self.settings.marker_animation {
615                                SteadyAnimation::Blink => Animation::blink,
616                                SteadyAnimation::Halo => Animation::halo,
617                                SteadyAnimation::Orbit => Animation::orbit,
618                            };
619                            effect(ui.painter(), adjusted_point.into(), self.zoom, time, color);
620                            // Persistent effects never finish on their own.
621                            ui.ctx().request_repaint();
622                        }
623                    }
624                }
625
626                self.paint_sub_components(ui, self.map_area);
627
628                self.capture_mouse_events(ui, &resp);
629
630                if self.zoom != self.previous_zoom || resized {
631                    let _span = tracing::info_span!("calculating viewport with zoom").entered();
632                    self.adjust_bounds();
633                    self.calculate_visible_points();
634                    self.previous_zoom = self.zoom;
635                }
636
637                if let Some(menu_mon) = &mut self.menu_manager {
638                    resp.context_menu(|ui| {
639                        menu_mon.ui(ui);
640                    });
641                }
642
643                #[cfg(feature = "debug_overlay")]
644                self.print_debug_info(ui, &resp);
645            }
646        });
647        // `Frame::show` already allocated the frame's outer rect in the parent
648        // `Ui` (that is what `inner_response.response.rect` reports), so the
649        // widget must not allocate `map_area` a second time -- doing so made
650        // it consume twice its own height in the surrounding layout.
651        inner_response.response
652    }
653}
654
655impl Map {
656    /// Creates an empty map widget with default [`MapSettings`].
657    ///
658    /// The widget displays nothing until nodes are loaded with
659    /// [`Map::add_hashmap_points`].
660    pub fn new() -> Self {
661        let settings = MapSettings::default();
662        Self {
663            zoom: 1.0,
664            previous_zoom: 1.0,
665            map_area: Rect::NOTHING,
666            tree: None,
667            points: None,
668            labels: Vec::new(),
669            visible_points: Vec::new(),
670            current: MapBounds::default(),
671            reference: MapBounds::default(),
672            settings,
673            min_size: (None, None),
674            max_size: (None, None),
675            current_index: 0,
676            notifications: HashMap::new(),
677            node_states: HashMap::new(),
678            segment_notifications: HashMap::new(),
679            segment_states: HashMap::new(),
680            segment_ids: HashSet::new(),
681            menu_manager: None,
682            node_template: None,
683            segment_template: None,
684            markers: HashMap::new(),
685            segments: None,
686            theme: Rc::new(Theme::default()),
687        }
688    }
689
690    fn calculate_widget_dimensions(&mut self, ui: &mut Ui) -> RawLine {
691        let available = ui.available_rect_before_wrap();
692        let mut size = available.size();
693        if let Some(max_width) = self.max_size.0 {
694            size.x = size.x.min(max_width);
695        }
696        if let Some(max_height) = self.max_size.1 {
697            size.y = size.y.min(max_height);
698        }
699        if let Some(min_width) = self.min_size.0 {
700            size.x = size.x.max(min_width);
701        }
702        if let Some(min_height) = self.min_size.1 {
703            size.y = size.y.max(min_height);
704        }
705        self.map_area = Rect::from_min_size(available.min, size);
706        RawLine::new(
707            RawPoint::from(self.map_area.left_top()),
708            RawPoint::from(self.map_area.right_bottom()),
709        )
710    }
711
712    fn calculate_visible_points(&mut self) {
713        let _span = tracing::info_span!("calculate_visible_points").entered();
714        if self.current.dist > 0.0
715            && self.current.dist < f32::INFINITY
716            && let Some(tree) = &self.tree
717        {
718            let center = self.current.pos / self.zoom;
719            // `current.dist` is the *full* diagonal of the visible area
720            // expressed in map units (`reference.dist` is `RawLine::distance()`
721            // over the widget rect, divided by the zoom). A circle centred on
722            // the viewport only has to reach its corners to cover it, so the
723            // radius is the *half* diagonal -- the rect's circumradius.
724            // Querying with the full diagonal doubled the radius, i.e. covered
725            // 4x the area, and with the circle-over-rectangle slack that fed
726            // roughly 6x more nodes to the paint pass than are actually on
727            // screen. Halved here rather than at the `reference.dist`
728            // assignments so `dist` keeps meaning "diagonal" for the debug
729            // overlay and for `adjust_bounds`.
730            let radius = (self.current.dist / 2.0).powi(2);
731            let point: [f32; 2] = center.into();
732            let vis_pos = tree.within(&point, radius, &squared_euclidean).unwrap();
733            self.visible_points.clear();
734            for point in vis_pos {
735                self.visible_points.push(point.1.cast_signed());
736            }
737        }
738    }
739
740    /// Loads the node set and (re)builds the spatial index.
741    ///
742    /// This replaces any previously loaded points, computes the bounding box of
743    /// the whole set, centers the view on its midpoint and refreshes the list
744    /// of visible nodes. It must be called at least once before the widget can
745    /// display anything.
746    ///
747    /// The kd-tree built here is what enables viewport culling and
748    /// nearest-neighbor hover lookups, so calling this method on every frame is
749    /// discouraged; call it only when the node set changes.
750    ///
751    /// # Examples
752    ///
753    /// ```
754    /// use egui_map::map::Map;
755    /// use egui_map::map::objects::MapPoint;
756    ///
757    /// let mut points = Vec::new();
758    /// points.push(MapPoint::new(1, [0.0, 0.0]));
759    /// points.push(MapPoint::new(2, [10.0, 10.0]));
760    ///
761    /// let mut map = Map::new();
762    /// map.add_points(points);
763    ///
764    /// // The view is centered on the midpoint of the loaded nodes.
765    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
766    /// ```
767    pub fn add_points(&mut self, points: Vec<MapPoint>) {
768        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
769        let mut hash_map = HashMap::new();
770        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
771        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
772        for entry in points {
773            for i in 0..min.components.len() {
774                if entry.coords[i] < min.components[i] {
775                    min.components[i] = entry.coords[i];
776                }
777                if entry.coords[i] > max.components[i] {
778                    max.components[i] = entry.coords[i];
779                }
780            }
781            let _result = tree.add(entry.coords, entry.get_id());
782            hash_map.insert(entry.get_id(), entry);
783        }
784        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
785        self.reference.min = min;
786        self.reference.max = max;
787        self.points = Some(hash_map);
788        self.tree = Some(tree);
789        self.reference.pos = RawLine::new(min, max).midpoint();
790        // we create a rect that include every node in the map
791        // Stupid fix because rect area could be infinite
792        // I need to implement a more elegant fix
793        if self.map_area.area() == 0.0 {
794            self.reference.dist = 3000.00;
795        } else {
796            let rect = RawLine::new(
797                RawPoint::from(self.map_area.left_top()),
798                RawPoint::from(self.map_area.right_bottom()),
799            );
800            self.reference.dist = rect.distance();
801        }
802        self.current = self.reference.clone();
803        self.calculate_visible_points();
804    }
805
806    /// Loads the node set and (re)builds the spatial index.
807    ///
808    /// This replaces any previously loaded points, computes the bounding box of
809    /// the whole set, centers the view on its midpoint and refreshes the list
810    /// of visible nodes. It must be called at least once before the widget can
811    /// display anything.
812    ///
813    /// The kd-tree built here is what enables viewport culling and
814    /// nearest-neighbor hover lookups, so calling this method on every frame is
815    /// discouraged; call it only when the node set changes.
816    ///
817    /// # Examples
818    ///
819    /// ```
820    /// use egui_map::map::Map;
821    /// use egui_map::map::objects::MapPoint;
822    /// use std::collections::HashMap;
823    ///
824    /// let mut points = HashMap::new();
825    /// points.insert(1, MapPoint::new(1, [0.0, 0.0]));
826    /// points.insert(2, MapPoint::new(2, [10.0, 10.0]));
827    ///
828    /// let mut map = Map::new();
829    /// map.add_hashmap_points(points);
830    ///
831    /// // The view is centered on the midpoint of the loaded nodes.
832    /// assert_eq!(map.get_pos(), [5.0, 5.0]);
833    /// ```
834    //#[deprecated(since="0.2.3", note="please use `add_points` instead")]
835    pub fn add_hashmap_points(&mut self, hash_map: HashMap<usize, MapPoint>) {
836        let _span = tracing::info_span!("add_hashmap_points").entered();
837        let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
838        let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
839        let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
840
841        for entry in hash_map.iter() {
842            for i in 0..min.components.len() {
843                if entry.1.coords[i] < min.components[i] {
844                    min.components[i] = entry.1.coords[i];
845                }
846                if entry.1.coords[i] > max.components[i] {
847                    max.components[i] = entry.1.coords[i];
848                }
849            }
850            let _result = tree.add(entry.1.coords, *entry.0);
851        }
852
853        // We stablish the max and min coordinates in this map, this wont change until we change the point hash map
854        self.reference.min = min;
855        self.reference.max = max;
856        self.points = Some(hash_map);
857        self.tree = Some(tree);
858        self.reference.pos = RawLine::new(min, max).midpoint();
859        // we create a rect that include every node in the map
860        // Stupid fix because rect area could be infinite
861        // I need to implement a more elegant fix
862        if self.map_area.area() == 0.0 {
863            self.reference.dist = 3000.00;
864        } else {
865            let rect = RawLine::new(
866                RawPoint::from(self.map_area.left_top()),
867                RawPoint::from(self.map_area.right_bottom()),
868            );
869            self.reference.dist = rect.distance();
870        }
871        self.current = self.reference.clone();
872        self.calculate_visible_points();
873    }
874
875    /// Centers the view on the node with the given id.
876    ///
877    /// Returns `true` if the view moved. Returns `false` — leaving the view
878    /// untouched — when no points have been loaded yet or when `node_id` is
879    /// not among them; that case also emits a `tracing` warning, since a
880    /// silently ignored id is otherwise indistinguishable from a node that
881    /// was centered but drawn in the wrong place.
882    ///
883    /// A `false` here usually means the id belongs to a different set than
884    /// the one loaded through [`Map::add_hashmap_points`] — for example a
885    /// map showing only part of the universe, or ids coming from a different
886    /// query than the one that produced the nodes.
887    ///
888    /// ```
889    /// use egui_map::map::Map;
890    /// use egui_map::map::objects::MapPoint;
891    ///
892    /// let mut map = Map::new();
893    /// map.add_points(vec![MapPoint::new(1, [10.0, 20.0])]);
894    ///
895    /// assert!(map.set_pos_from_nodeid(1));
896    /// assert_eq!(map.get_pos(), [10.0, 20.0]);
897    ///
898    /// // Unknown id: the view stays where it was.
899    /// assert!(!map.set_pos_from_nodeid(999));
900    /// assert_eq!(map.get_pos(), [10.0, 20.0]);
901    /// ```
902    pub fn set_pos_from_nodeid(&mut self, node_id: usize) -> bool {
903        let _span = tracing::info_span!("set_pos_from_nodeid").entered();
904        if let Some(hash_map) = &self.points
905            && let Some(map_point) = hash_map.get(&node_id)
906        {
907            self.reference.pos = RawPoint::from(map_point.coords);
908            self.adjust_bounds();
909            self.calculate_visible_points();
910            true
911        } else {
912            tracing::warn!(
913                node_id,
914                loaded_nodes = self.points.as_ref().map_or(0, |p| p.len()),
915                "set_pos_from_nodeid: unknown node id, the view was left unchanged"
916            );
917            false
918        }
919    }
920
921    /// Centers the view on the given map coordinates.
922    pub fn set_pos(&mut self, position: [f32; 2]) {
923        let _span = tracing::info_span!("set_pos").entered();
924        let point = RawPoint::from(position);
925        self.reference.pos = point;
926        self.adjust_bounds();
927        self.calculate_visible_points();
928    }
929
930    /// Returns the map coordinates the view is currently centered on.
931    pub fn get_pos(&self) -> [f32; 2] {
932        let _span = tracing::info_span!("get_pos").entered();
933        self.reference.pos.into()
934    }
935
936    /// Replaces the set of free-floating text labels drawn on the map.
937    ///
938    /// Labels are only rendered while the zoom level is below
939    /// [`MapSettings::line_visible_zoom`].
940    pub fn add_labels(&mut self, labels: Vec<MapLabel>) {
941        let _span = tracing::info_span!("add_labels").entered();
942        self.labels = labels;
943    }
944
945    /// Replaces the set of connection lines between nodes.
946    ///
947    /// Lines are keyed by a connection id that the endpoint nodes must
948    /// reference through [`MapPoint::connections`] — push each line's key into
949    /// the `connections` of the nodes it joins. The segments are stored in an
950    /// R-tree keyed by bounding box: a line is drawn while its bounding box
951    /// intersects the viewport and the zoom level is above
952    /// [`MapSettings::line_visible_zoom`].
953    ///
954    /// See the [module-level example](self#connecting-nodes-with-lines) for
955    /// the complete wiring.
956    pub fn add_lines(&mut self, segments: Vec<MapSegment>) {
957        let _span = tracing::info_span!("add_lines").entered();
958        // Intern the keys as Rc<str> and build the broad-phase spatial index
959        // over the line bounding boxes, so viewport culling and hit-testing
960        // discard whole regions without touching every segment.
961
962        self.segment_ids = segments.iter().map(|s| s.id).collect();
963        self.segments = Some(rstar::RTree::bulk_load(segments));
964    }
965
966    /// Replaces the set of connection lines between nodes, from a map keyed
967    /// by the same `(usize, usize)` id used in [`MapSegment::id`] and
968    /// referenced by [`MapPoint::connections`].
969    ///
970    /// Equivalent to [`add_lines`](Self::add_lines) but avoids callers having
971    /// to collect their segments into a `Vec` first when they already have
972    /// them keyed in a `HashMap` (e.g. straight from an adapter that mirrors
973    /// them 1:1 by id, with no intermediate ordering to preserve).
974    pub fn add_hashmap_lines(&mut self, segments: HashMap<(usize, usize), MapSegment>) {
975        let _span = tracing::info_span!("add_hashmap_lines").entered();
976        let segments: Vec<MapSegment> = segments.into_values().collect();
977        self.segment_ids = segments.iter().map(|s| s.id).collect();
978        self.segments = Some(rstar::RTree::bulk_load(segments));
979    }
980
981    fn adjust_bounds(&mut self) {
982        let _span = tracing::info_span!("adjust_bounds").entered();
983        self.current.max = self.reference.max * self.zoom;
984        self.current.min = self.reference.min * self.zoom;
985        self.current.dist = self.reference.dist / self.zoom;
986        self.current.pos = self.reference.pos * self.zoom;
987    }
988
989    fn capture_mouse_events(&mut self, ui: &Ui, _resp: &Response) {
990        let _span = tracing::info_span!("capture_mouse_events").entered();
991        // capture MouseWheel Event for Zoom control change
992        if ui.rect_contains_pointer(self.map_area) {
993            ui.input(|x| {
994                let _span = tracing::info_span!("capture_mouse_events_input").entered();
995
996                if !x.events.is_empty() {
997                    for event in &x.events {
998                        match event {
999                            Event::MouseWheel {
1000                                unit: _,
1001                                delta,
1002                                modifiers,
1003                                phase: _,
1004                            } => {
1005                                #[cfg(target_os = "macos")]
1006                                let zoom_modifier = if modifiers.mac_cmd {
1007                                    delta.y / 80.00
1008                                } else {
1009                                    delta.y / 400.00
1010                                };
1011
1012                                #[cfg(not(target_os = "macos"))]
1013                                let zoom_modifier = if modifiers.ctrl {
1014                                    delta.y / 8.00
1015                                } else {
1016                                    delta.y / 40.00
1017                                };
1018
1019                                let mut pre_zoom = self.zoom + zoom_modifier;
1020                                if pre_zoom > self.settings.max_zoom {
1021                                    pre_zoom = self.settings.max_zoom;
1022                                }
1023                                if pre_zoom < self.settings.min_zoom {
1024                                    pre_zoom = self.settings.min_zoom;
1025                                }
1026                                self.zoom = pre_zoom;
1027                            }
1028                            _ => {
1029                                continue;
1030                            }
1031                        };
1032                    }
1033                }
1034            });
1035        }
1036    }
1037
1038    /// Sets the zoom factor.
1039    ///
1040    /// Values outside the [`MapSettings::min_zoom`]..=[`MapSettings::max_zoom`]
1041    /// range are ignored.
1042    pub fn set_zoom(&mut self, value: f32) {
1043        if value >= self.settings.min_zoom && value <= self.settings.max_zoom {
1044            self.zoom = value;
1045        }
1046    }
1047
1048    /// Returns the current zoom factor.
1049    pub fn get_zoom(&mut self) -> f32 {
1050        self.zoom
1051    }
1052
1053    /// Returns the style for the current theme, falling back to the first
1054    /// style if the current theme index has no entry.
1055    fn current_style(&self) -> &Style {
1056        self.settings
1057            .styles
1058            .get(self.current_index)
1059            .or(self.settings.styles.first())
1060            .expect("MapSettings::styles must not be empty")
1061    }
1062
1063    fn assign_visual_style(&mut self, ui_obj: &mut Ui) {
1064        let style_index = ui_obj.visuals().dark_mode as usize;
1065
1066        if self.current_index != style_index {
1067            let _span = tracing::info_span!("asign_visual_style").entered();
1068
1069            self.current_index = style_index;
1070            let map_style = self.settings.styles.get_mut(style_index).unwrap();
1071            let visuals = &ui_obj.style().visuals;
1072            map_style.background_color = visuals.extreme_bg_color;
1073        }
1074    }
1075
1076    /// The [`ColorMode`] the widget is currently painting with -- `Dark`
1077    /// when `current_index` selects the dark style slot, `Light` otherwise.
1078    fn color_mode(&self) -> ColorMode {
1079        ColorMode::from_dark_mode(self.current_index == 1)
1080    }
1081
1082    /// Resolves the color palette the widget paints with right now: the
1083    /// active [`MapTheme`]'s colors for the current [`ColorMode`]. This is
1084    /// the single, canonical source for every color the widget paints --
1085    /// unlike `settings.styles`, it can never drift out of sync with the
1086    /// installed theme because nothing caches it.
1087    fn theme_colors(&self) -> ThemeColors {
1088        self.theme.colors(self.color_mode())
1089    }
1090
1091    /// Floating debug read-out, compiled in only under the `debug_overlay`
1092    /// feature.
1093    ///
1094    /// Deliberately unobtrusive: it renders as a collapsed `dbg` toggle in the
1095    /// map's top-left corner with no background of its own, so it costs a few
1096    /// dim pixels until a developer clicks it open. egui remembers the
1097    /// open/closed state per widget instance, so it stays open across frames
1098    /// once expanded.
1099    #[cfg(feature = "debug_overlay")]
1100    fn print_debug_info(&mut self, ui: &mut Ui, resp: &Response) {
1101        let _span = tracing::info_span!("printing debug data").entered();
1102
1103        let p = |v: f32| format!("{v:.2}");
1104        let mut rows: Vec<(String, Color32)> = vec![
1105            (
1106                format!(
1107                    "MIN {}, {}",
1108                    p(self.current.min.components[0]),
1109                    p(self.current.min.components[1])
1110                ),
1111                Color32::LIGHT_GREEN,
1112            ),
1113            (
1114                format!(
1115                    "MAX {}, {}",
1116                    p(self.current.max.components[0]),
1117                    p(self.current.max.components[1])
1118                ),
1119                Color32::LIGHT_GREEN,
1120            ),
1121            (
1122                format!(
1123                    "CUR {}, {}",
1124                    p(self.current.pos.components[0]),
1125                    p(self.current.pos.components[1])
1126                ),
1127                Color32::LIGHT_GREEN,
1128            ),
1129            (
1130                format!("DST {}", p(self.current.dist)),
1131                Color32::LIGHT_GREEN,
1132            ),
1133            (format!("ZOM {}", self.zoom), Color32::GREEN),
1134            (
1135                format!(
1136                    "REC {}, {} .. {}, {}",
1137                    p(self.map_area.left_top().x),
1138                    p(self.map_area.left_top().y),
1139                    p(self.map_area.right_bottom().x),
1140                    p(self.map_area.right_bottom().y)
1141                ),
1142                Color32::LIGHT_GREEN,
1143            ),
1144        ];
1145        if let Some(points) = &self.points {
1146            rows.push((format!("NUM {}", points.len()), Color32::LIGHT_GREEN));
1147        }
1148        if !self.visible_points.is_empty() {
1149            rows.push((
1150                format!("VIS {}", self.visible_points.len()),
1151                Color32::LIGHT_GREEN,
1152            ));
1153        }
1154        if let Some(pointer_pos) = resp.hover_pos() {
1155            rows.push((
1156                format!("HVR {}, {}", p(pointer_pos.x), p(pointer_pos.y)),
1157                Color32::LIGHT_BLUE,
1158            ));
1159        }
1160        let drag = resp.drag_delta();
1161        if drag.length() != 0.0 {
1162            rows.push((format!("DRG {}, {}", p(drag.x), p(drag.y)), Color32::GOLD));
1163        }
1164
1165        // Drawn into a *detached* child `Ui` in the map's own layer.
1166        //
1167        // `new_child` alone does not call `advance_cursor_after_rect`, so the
1168        // overlay never contributes to the parent's `min_rect`. That matters:
1169        // the canvas `Frame` sizes itself from its content's `min_rect`, and
1170        // letting this grow it would push the frame past the space the widget
1171        // was given -- exactly the overflow that used to knock the map
1172        // off-centre. Being laid out after the map's painter also means the
1173        // toggle wins pointer input over the pan/zoom surface underneath.
1174        let overlay_rect = Rect::from_min_max(
1175            self.map_area.left_top() + Vec2::new(6.0, 6.0),
1176            self.map_area.right_bottom(),
1177        );
1178        let mut overlay_ui = ui.new_child(
1179            UiBuilder::new()
1180                .max_rect(overlay_rect)
1181                .layout(Layout::top_down(Align::Min)),
1182        );
1183        // No frame and no header background: the map shows straight through,
1184        // so this costs a few dim pixels until someone opens it.
1185        CollapsingHeader::new(RichText::new("dbg").monospace().small().weak())
1186            .id_salt("egui_map_debug_overlay")
1187            .default_open(false)
1188            .show_background(false)
1189            .show(&mut overlay_ui, |ui| {
1190                for (text, color) in rows {
1191                    ui.label(RichText::new(text).monospace().small().color(color));
1192                }
1193            });
1194    }
1195
1196    fn paint_sub_components(&mut self, ui_obj: &mut Ui, rect: Rect) {
1197        let _span = tracing::info_span!("map_ui_paint_sub_components").entered();
1198        let zoom_slider = egui::Slider::new(
1199            &mut self.zoom,
1200            self.settings.min_zoom..=self.settings.max_zoom,
1201        )
1202        .show_value(false)
1203        .orientation(SliderOrientation::Vertical);
1204        let mut pos1 = rect.right_top();
1205        let mut pos2 = rect.right_top();
1206        pos1.x -= 80.0;
1207        pos1.y += 120.0;
1208        pos2.x -= 60.0;
1209        pos2.y += 240.0;
1210
1211        let sub_rect = egui::Rect::from_two_pos(pos1, pos2);
1212        let ui_builder = egui::UiBuilder::new().clone().max_rect(sub_rect);
1213        ui_obj.scope_builder(ui_builder, |ui_obj| {
1214            ui_obj.add(zoom_slider);
1215        });
1216    }
1217
1218    fn paint_map_points(
1219        &self,
1220        vec_points: &Vec<isize>,
1221        hashm: &Option<HashMap<usize, MapPoint>>,
1222        paint: &Painter,
1223        ui_obj: &mut Ui,
1224        min_point: &RawPoint,
1225        resp: &Response,
1226    ) -> Result<Vec<usize>, ()> {
1227        // One span for the whole node pass, rather than one per node inside
1228        // the loop below. A per-node span made the *instrumentation* the
1229        // dominant cost: measured against this widget, a field-less Tracy zone
1230        // came to ~10.6 us per node, so at ~107 visible nodes it burned ~1.1 ms
1231        // of every frame while measuring nothing that a single zone around the
1232        // loop does not already report.
1233        let _span = tracing::info_span!("paint_map_points").entered();
1234        let mut nearest_id = None;
1235        let mut nodes_to_remove = Vec::new();
1236        let mut shape_vec = vec![];
1237
1238        if hashm.is_none() {
1239            return Err(());
1240        }
1241        if vec_points.is_empty() {
1242            return Err(());
1243        }
1244        // detecting the nearest hover node
1245        if self.settings.node_text_visibility == VisibilitySetting::Hover
1246            && resp.hovered()
1247            && let Some(point) = resp.hover_pos()
1248        {
1249            let raw_point = RawPoint::from(point);
1250            let hovered_map_point = (*min_point + raw_point) / self.zoom;
1251            if let Ok(nearest_node) = self.tree.as_ref().unwrap().nearest(
1252                &hovered_map_point.components,
1253                1,
1254                &squared_euclidean,
1255            ) {
1256                nearest_id = Some(nearest_node.first().unwrap().1);
1257            }
1258        }
1259        // filling text settings
1260        let mut text_settings = TextSettings {
1261            // Screen-space size: unlike the map geometry this is NOT
1262            // multiplied by the zoom, so a node name stays just as readable
1263            // when the map is zoomed all the way out.
1264            size: self.settings.node_text_size,
1265            anchor: Align2::LEFT_BOTTOM,
1266            family: FontFamily::Proportional,
1267            text: String::new(),
1268            position: RawPoint::default(),
1269            text_color: ui_obj.visuals().text_color(),
1270        };
1271
1272        // Drawing Points
1273        for temp_point in vec_points {
1274            let parsed_point = temp_point.cast_unsigned();
1275            if let Some(system) = hashm.as_ref().unwrap().get(&parsed_point) {
1276                let viewport_point = RawPoint::from(system.coords) * self.zoom - min_point;
1277                if let Some(node_template) = &self.node_template {
1278                    if nearest_id.unwrap_or(&0usize) == &system.get_id() {
1279                        node_template.selection_ui(
1280                            ui_obj,
1281                            SelectionContext {
1282                                position: viewport_point.into(),
1283                                zoom: self.zoom,
1284                                point: system,
1285                                color: self.theme_colors().selected,
1286                            },
1287                        );
1288                    }
1289                } else if self.zoom > self.settings.label_visible_zoom
1290                    && self.settings.node_text_visibility == VisibilitySetting::Always
1291                    || (self.settings.node_text_visibility == VisibilitySetting::Hover
1292                        && nearest_id.unwrap_or(&0usize) == &system.get_id())
1293                {
1294                    let mut viewport_text = viewport_point;
1295                    viewport_text.components[0] += 3.0 * self.zoom;
1296                    viewport_text.components[1] -= 3.0 * self.zoom;
1297                    text_settings.position = viewport_text;
1298                    text_settings.text = system.get_name();
1299                    self.paint_label(paint, &text_settings);
1300                }
1301
1302                let system_id = system.get_id();
1303
1304                // Persistent node state is drawn first so a notification --
1305                // the *event* -- sits on top of the *state*.
1306                if let Some(state) = self.node_states.get(&system_id) {
1307                    let color = state.color.unwrap_or(self.theme_colors().alert);
1308                    if let Some(template) = &self.node_template {
1309                        // There is no dedicated template hook for node state:
1310                        // `marker_ui` is the persistent-visual one, so state and
1311                        // markers share it -- `kind` is enough for a template to
1312                        // pick the right built-in effect either way, but the
1313                        // hook still cannot tell *which* of the two call sites
1314                        // (state vs. a `Map::update_marker` marker) it is.
1315                        template.marker_ui(
1316                            ui_obj,
1317                            MarkerContext {
1318                                position: viewport_point.into(),
1319                                zoom: self.zoom,
1320                                kind: state.animation,
1321                                node_id: system_id,
1322                            },
1323                        );
1324                    } else {
1325                        let effect = match state.animation {
1326                            SteadyAnimation::Blink => Animation::blink,
1327                            SteadyAnimation::Halo => Animation::halo,
1328                            SteadyAnimation::Orbit => Animation::orbit,
1329                        };
1330                        // Frame time, so every element animated this frame
1331                        // shares one clock instead of sampling its own.
1332                        let time = ui_obj.input(|i| i.time) as f32;
1333                        effect(paint, viewport_point.into(), self.zoom, time, color);
1334                    }
1335                    // Persistent effects never finish on their own.
1336                    ui_obj.ctx().request_repaint();
1337                }
1338
1339                if let Some(notification) = self.notifications.get(&system_id) {
1340                    let color = notification.color.unwrap_or(self.theme_colors().alert);
1341                    if let Some(template) = &self.node_template {
1342                        template.notification_ui(
1343                            ui_obj,
1344                            NotificationContext {
1345                                position: viewport_point.into(),
1346                                zoom: self.zoom,
1347                                initial_time: notification.started,
1348                                color,
1349                                kind: notification.animation,
1350                                node_id: system_id,
1351                            },
1352                        );
1353                    } else {
1354                        let effect = match notification.animation {
1355                            NodeAnimation::Pulse => Animation::pulse,
1356                            NodeAnimation::Ripple => Animation::ripple,
1357                            NodeAnimation::CountdownArc => Animation::countdown_arc,
1358                            NodeAnimation::ScaleIn => Animation::scale_in,
1359                            NodeAnimation::Crosshair => Animation::crosshair,
1360                        };
1361                        if effect(
1362                            paint,
1363                            viewport_point.into(),
1364                            self.zoom,
1365                            notification.started,
1366                            color,
1367                        ) {
1368                            ui_obj.ctx().request_repaint();
1369                        } else {
1370                            nodes_to_remove.push(system_id);
1371                        }
1372                    }
1373                }
1374                // The color requested for this node: its own override if it
1375                // has one, otherwise the active theme's node color -- the
1376                // single fallback both the built-in circle and a
1377                // `NodeTemplate` (via `NodeContext::color`) paint with.
1378                let node_color = system.color.unwrap_or(self.theme_colors().node);
1379                if let Some(node_template) = &self.node_template {
1380                    node_template.node_ui(
1381                        ui_obj,
1382                        NodeContext {
1383                            position: viewport_point.into(),
1384                            zoom: self.zoom,
1385                            point: system,
1386                            color: node_color,
1387                        },
1388                    );
1389                } else {
1390                    shape_vec.push(Shape::circle_filled(
1391                        viewport_point.into(),
1392                        4.00 * self.zoom,
1393                        node_color,
1394                    ));
1395                }
1396            }
1397        }
1398        paint.extend(shape_vec);
1399        Ok(nodes_to_remove)
1400    }
1401
1402    /// Draws the connection lines, plus any segment effects, returning the ids
1403    /// of segment notifications that finished this frame so the caller can
1404    /// drop them (same pattern as [`Map::paint_map_points`]'s return value).
1405    fn paint_map_lines(&self, painter: &Painter, min_point: &RawPoint) -> Vec<(usize, usize)> {
1406        let _span = tracing::info_span!("paint_map_lines").entered();
1407        let mut segments_to_remove = Vec::new();
1408
1409        if self.zoom <= self.settings.line_visible_zoom {
1410            return segments_to_remove;
1411        }
1412        let Some(segments) = &self.segments else {
1413            return segments_to_remove;
1414        };
1415
1416        // How far into its own zoom-based fade-in the *default* line stroke
1417        // has ramped: `0.0` right at `line_visible_zoom`, linearly up to
1418        // `1.0` once the zoom is 0.80 units past it, then staying there.
1419        // Segment effects reuse this below (`effect_fade`) so they fade in
1420        // step with the line they sit on instead of ignoring the zoom
1421        // entirely.
1422        let line_fade = ((self.zoom - self.settings.line_visible_zoom) / 0.80).clamp(0.0, 1.0);
1423
1424        // `style.line_width == None` only turns off the *default* stroke -- a
1425        // `SegmentTemplate` or a segment effect installed through
1426        // `Map::segment` still needs to run, e.g. for a consumer who draws
1427        // lines entirely on their own and only wants the built-in effects.
1428        // The stroke's color always comes live from the active theme, not
1429        // from `Style` -- there is no cached copy left to fall out of sync.
1430        let default_stroke = self.current_style().line_width.map(|width| {
1431            let segment_color = self.theme_colors().segment;
1432            let color = if line_fade >= 1.0 {
1433                segment_color
1434            } else {
1435                let mut tup_stroke = segment_color.to_tuple();
1436                tup_stroke.3 = (255.0 * line_fade).round() as u8;
1437                Color32::from_rgba_unmultiplied(
1438                    tup_stroke.0,
1439                    tup_stroke.1,
1440                    tup_stroke.2,
1441                    tup_stroke.3,
1442                )
1443            };
1444            Stroke::new(width, color)
1445        });
1446
1447        // Broad-phase: query the segment R-tree with the viewport AABB (in
1448        // map coordinates), padded by the stroke width -- when there is one
1449        // -- so lines at the very edge are not clipped prematurely.
1450        let center = self.current.pos / self.zoom;
1451        let padding = default_stroke.map(|s| s.width).unwrap_or(0.0) / self.zoom;
1452        let half = RawPoint::new(
1453            self.map_area.width() / 2.0 / self.zoom + padding,
1454            self.map_area.height() / 2.0 / self.zoom + padding,
1455        );
1456        let query = rstar::AABB::from_corners((center - half).into(), (center + half).into());
1457
1458        // Segment effects should always read as at least as visible as the
1459        // line they animate, not fade out in lockstep with it and risk
1460        // disappearing into it -- so their alpha tracks `line_fade` with a
1461        // fixed head start rather than mirroring it exactly.
1462        let effect_fade = (line_fade + SEGMENT_EFFECT_ALPHA_BOOST).min(1.0);
1463
1464        for segment in segments.locate_in_envelope_intersecting(query) {
1465            let raw_line = segment.raw_line();
1466            let pos_a: Pos2 = (raw_line.points[0] * self.zoom - min_point).into();
1467            let pos_b: Pos2 = (raw_line.points[1] * self.zoom - min_point).into();
1468
1469            // The default stroke is painted right away, not batched up and
1470            // flushed once after the whole loop -- an opaque line painted
1471            // *after* its own effect would completely cover it. That's fine
1472            // for nodes (their effects extend beyond the node's own small
1473            // circle, so the base shape painted last only covers the
1474            // center) but not for segments, where the effect runs along the
1475            // exact same path as the line underneath it.
1476            if let Some(template) = &self.segment_template {
1477                template.segment_ui(painter, pos_a, pos_b, self.zoom, segment);
1478            } else if let Some(stroke) = default_stroke {
1479                painter.add(Shape::line_segment([pos_a, pos_b], stroke));
1480            }
1481
1482            // Persistent segment state is drawn first so a notification --
1483            // the *event* -- sits on top of the *state*, same ordering as
1484            // node effects.
1485            if let Some(state) = self.segment_states.get(&segment.id) {
1486                let color = scale_alpha(
1487                    state.color.unwrap_or(self.theme_colors().alert),
1488                    effect_fade,
1489                );
1490                if let Some(template) = &self.segment_template {
1491                    let time = painter.ctx().input(|i| i.time) as f32;
1492                    template.segment_state_ui(painter, pos_a, pos_b, self.zoom, time, color);
1493                } else {
1494                    let effect = match state.animation {
1495                        SteadySegmentAnimation::Comet => Animation::comet,
1496                        SteadySegmentAnimation::Dash => Animation::dash,
1497                        SteadySegmentAnimation::GlowBand => Animation::glow_band,
1498                        SteadySegmentAnimation::Chevrons => Animation::chevrons,
1499                    };
1500                    let time = painter.ctx().input(|i| i.time) as f32;
1501                    effect(painter, pos_a, pos_b, self.zoom, time, color);
1502                }
1503                // Persistent effects never finish on their own.
1504                painter.ctx().request_repaint();
1505            }
1506
1507            if let Some(notification) = self.segment_notifications.get(&segment.id) {
1508                let color = scale_alpha(
1509                    notification.color.unwrap_or(self.theme_colors().alert),
1510                    effect_fade,
1511                );
1512                let still_playing = if let Some(template) = &self.segment_template {
1513                    template.segment_notification_ui(
1514                        painter,
1515                        pos_a,
1516                        pos_b,
1517                        self.zoom,
1518                        notification.started,
1519                        color,
1520                    )
1521                } else {
1522                    match notification.animation {
1523                        SegmentAnimation::FlashDecay => Animation::flash_decay(
1524                            painter,
1525                            pos_a,
1526                            pos_b,
1527                            self.zoom,
1528                            notification.started,
1529                            color,
1530                        ),
1531                        SegmentAnimation::Comet(direction) => Animation::comet_once(
1532                            painter,
1533                            pos_a,
1534                            pos_b,
1535                            self.zoom,
1536                            notification.started,
1537                            color,
1538                            direction,
1539                        ),
1540                        SegmentAnimation::Wipe => Animation::wipe(
1541                            painter,
1542                            pos_a,
1543                            pos_b,
1544                            self.zoom,
1545                            notification.started,
1546                            color,
1547                        ),
1548                    }
1549                };
1550                if still_playing {
1551                    painter.ctx().request_repaint();
1552                } else {
1553                    segments_to_remove.push(segment.id);
1554                }
1555            }
1556        }
1557        segments_to_remove
1558    }
1559
1560    fn paint_label(&self, paint: &Painter, text_settings: &TextSettings) {
1561        let _span = tracing::info_span!("paint_label").entered();
1562        paint.text(
1563            text_settings.position.into(),
1564            text_settings.anchor,
1565            text_settings.text.clone(),
1566            FontId::new(text_settings.size, text_settings.family.clone()),
1567            text_settings.text_color,
1568        );
1569    }
1570
1571    /// Triggers a pulsing notification on the node `id_node`.
1572    ///
1573    /// # Deprecated
1574    ///
1575    /// This only ever played one of the available effects. Use [`Map::node`]
1576    /// and pick the effect you want:
1577    ///
1578    /// ```
1579    /// # use egui_map::map::Map;
1580    /// # use egui_map::map::objects::MapPoint;
1581    /// # use std::time::Instant;
1582    /// # let mut map = Map::new();
1583    /// # map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
1584    /// # let time = Instant::now();
1585    /// if let Some(node) = map.node(1) {
1586    ///     node.pulse(time);
1587    /// }
1588    /// ```
1589    ///
1590    /// Note the one behavioural difference: `notify` accepts an id that was
1591    /// never loaded (the notification simply never draws), while [`Map::node`]
1592    /// returns `None` for it.
1593    #[deprecated(
1594        since = "0.4.0",
1595        note = "use `map.node(id)` and pick an effect, e.g. `if let Some(n) = map.node(id) { n.pulse(time) }`"
1596    )]
1597    pub fn notify(&mut self, id_node: usize, time: Instant) {
1598        let _span = tracing::info_span!("notify").entered();
1599        self.notifications.insert(
1600            id_node,
1601            Notification {
1602                started: time,
1603                animation: NodeAnimation::Pulse,
1604                color: None,
1605            },
1606        );
1607    }
1608
1609    /// Borrows the node `id` so an animation can be attached to it.
1610    ///
1611    /// Returns `None` when `id` was never loaded through
1612    /// [`Map::add_points`] / [`Map::add_hashmap_points`], so a stale or
1613    /// mistyped id is a compile-time-visible case rather than a silent no-op.
1614    ///
1615    /// The handle carries optional configuration that must be set *before* the
1616    /// effect, which is the terminal call:
1617    ///
1618    /// ```
1619    /// # use egui_map::map::Map;
1620    /// # use egui_map::map::objects::MapPoint;
1621    /// # use std::time::Instant;
1622    /// # let mut map = Map::new();
1623    /// # map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
1624    /// # let time = Instant::now();
1625    /// // a one-off event
1626    /// if let Some(node) = map.node(1) {
1627    ///     node.color(egui::Color32::RED).ripple(time);
1628    /// }
1629    ///
1630    /// // lasting state, until cleared
1631    /// if let Some(node) = map.node(1) {
1632    ///     node.halo();
1633    /// }
1634    ///
1635    /// assert!(map.node(999).is_none());
1636    /// ```
1637    pub fn node(&mut self, id: usize) -> Option<NodeHandle<'_>> {
1638        if !self
1639            .points
1640            .as_ref()
1641            .is_some_and(|points| points.contains_key(&id))
1642        {
1643            return None;
1644        }
1645        Some(NodeHandle {
1646            map: self,
1647            id,
1648            color: None,
1649        })
1650    }
1651
1652    /// Borrows the segment `id` so an animation can be attached to it.
1653    ///
1654    /// Returns `None` when `id` was never loaded through [`Map::add_lines`] /
1655    /// [`Map::add_hashmap_lines`], mirroring [`Map::node`]. Use
1656    /// [`Map::line_at`] to find the id of the segment under a point first,
1657    /// e.g. to flash the route the mouse is hovering.
1658    ///
1659    /// ```
1660    /// # use egui_map::map::Map;
1661    /// # use egui_map::map::objects::MapSegment;
1662    /// # use std::time::Instant;
1663    /// # let mut map = Map::new();
1664    /// # map.add_lines(vec![MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0])]);
1665    /// # let time = Instant::now();
1666    /// // a one-off event
1667    /// if let Some(segment) = map.segment((1, 2)) {
1668    ///     segment.color(egui::Color32::RED).flash(time);
1669    /// }
1670    ///
1671    /// // lasting state, until cleared
1672    /// if let Some(segment) = map.segment((1, 2)) {
1673    ///     segment.comet();
1674    /// }
1675    ///
1676    /// assert!(map.segment((404, 404)).is_none());
1677    /// ```
1678    pub fn segment(&mut self, id: (usize, usize)) -> Option<SegmentHandle<'_>> {
1679        if !self.segment_ids.contains(&id) {
1680            return None;
1681        }
1682        Some(SegmentHandle {
1683            map: self,
1684            id,
1685            color: None,
1686        })
1687    }
1688
1689    /// Returns the id of the line closest to `point`, in map coordinates,
1690    /// when it lies within `tolerance` map units of the segment.
1691    ///
1692    /// Broad-phase candidates are taken from the segment R-tree built by
1693    /// [`Map::add_lines`]; the exact point-to-segment distance is then
1694    /// computed against the line geometry and the closest match wins. Returns
1695    /// `None` when no lines are loaded or every segment is farther than
1696    /// `tolerance`. A negative `tolerance` behaves like `0.0`.
1697    ///
1698    /// To hit-test a mouse click, convert the screen position to map
1699    /// coordinates first (`map = (screen + origin) / zoom`, see the
1700    /// [coordinate model](self#coordinate-model)) and pick a tolerance scaled
1701    /// by `1.0 / zoom` so it stays constant in screen pixels.
1702    pub fn line_at(&self, point: [f32; 2], tolerance: f32) -> Option<(usize, usize)> {
1703        let _span = tracing::info_span!("line_at").entered();
1704        let segments = self.segments.as_ref()?;
1705        let tolerance = tolerance.max(0.0);
1706
1707        let center = RawPoint::from(point);
1708        let padding = RawPoint::new(tolerance, tolerance);
1709        let query = rstar::AABB::from_corners((center - padding).into(), (center + padding).into());
1710
1711        let mut closest: Option<(f32, (usize, usize))> = None;
1712        for segment in segments.locate_in_envelope_intersecting(query) {
1713            let distance = segment.raw_line().distance_to_point(center);
1714            if distance <= tolerance && closest.as_ref().is_none_or(|(best, _)| distance < *best) {
1715                closest = Some((distance, segment.id));
1716            }
1717        }
1718        closest.map(|(_, id)| id)
1719    }
1720
1721    /// Installs a right-click context menu whose contents are built by the
1722    /// given [`ContextMenuManager`] implementation.
1723    pub fn set_context_manager(&mut self, manager: Rc<dyn ContextMenuManager>) {
1724        self.menu_manager = Some(manager);
1725    }
1726
1727    /// Replaces the built-in node rendering with a custom [`NodeTemplate`]
1728    /// implementation.
1729    ///
1730    /// The template takes over the drawing of nodes, selection highlights,
1731    /// notification animations and markers — including the node name labels,
1732    /// which the widget no longer draws once a template is installed. See the
1733    /// [`NodeTemplate`] examples for custom shapes and animations.
1734    pub fn set_node_template(&mut self, template: Rc<dyn NodeTemplate>) {
1735        self.node_template = Some(template);
1736    }
1737
1738    /// Replaces the built-in segment rendering with a custom
1739    /// [`SegmentTemplate`] implementation.
1740    ///
1741    /// The template takes over the drawing of segments and their effects. See
1742    /// the [`SegmentTemplate`] examples for a custom line style and animation.
1743    pub fn set_segment_template(&mut self, template: Rc<dyn SegmentTemplate>) {
1744        self.segment_template = Some(template);
1745    }
1746
1747    /// Installs the color palette used to paint the map, replacing the
1748    /// default [`Theme::default`].
1749    ///
1750    /// Accepts any [`MapTheme`] implementation, including a built-in
1751    /// [`Theme`] variant -- e.g. `map.set_theme(Rc::new(Theme::ArticCyan))` --
1752    /// or a custom palette. The new colors are resolved live from
1753    /// `new_theme` on the very next frame, in whichever light/dark mode is
1754    /// active then -- there is nothing to eagerly refresh, since [`Style`]
1755    /// never caches theme colors.
1756    pub fn set_theme(&mut self, new_theme: Rc<dyn MapTheme>) {
1757        self.theme = new_theme;
1758    }
1759
1760    /// Adds the marker `id`, or moves it, so it points to the node `node_id`.
1761    ///
1762    /// Markers are drawn as a blinking ring around the target node unless a
1763    /// custom [`objects::NodeTemplate::marker_ui`] is installed.
1764    pub fn update_marker(&mut self, id: usize, node_id: usize) {
1765        self.markers
1766            .entry(id)
1767            .and_modify(|value| *value = node_id)
1768            .or_insert(node_id);
1769    }
1770
1771    /// Sets the minimum width and/or height the widget should occupy, in egui
1772    /// points. `None` leaves the corresponding dimension unconstrained.
1773    pub fn allocate_at_least(&mut self, width: Option<f32>, height: Option<f32>) {
1774        self.min_size = (width, height);
1775    }
1776
1777    /// Sets the maximum width and/or height the widget should occupy, in egui
1778    /// points. `None` leaves the corresponding dimension unconstrained.
1779    pub fn allocate_at_most(&mut self, width: Option<f32>, height: Option<f32>) {
1780        self.max_size = (width, height);
1781    }
1782}
1783
1784#[cfg(test)]
1785mod tests {
1786    use super::*;
1787    use std::time::Duration;
1788
1789    fn sample_points() -> Vec<MapPoint> {
1790        vec![
1791            MapPoint::new(1, [0.0, 0.0]),
1792            MapPoint::new(2, [10.0, 10.0]),
1793            MapPoint::new(3, [-10.0, -10.0]),
1794        ]
1795    }
1796
1797    // ---------- construcción ----------
1798
1799    #[test]
1800    fn map_new_initial_state() {
1801        let map = Map::new();
1802        assert_eq!(map.zoom, 1.0);
1803        assert_eq!(map.previous_zoom, 1.0);
1804        assert!(map.points.is_none());
1805        assert!(map.segments.is_none());
1806        assert!(map.tree.is_none());
1807        assert!(map.labels.is_empty());
1808        assert!(map.visible_points.is_empty());
1809        assert!(map.markers.is_empty());
1810        assert!(map.notifications.is_empty());
1811        assert!(map.node_states.is_empty());
1812        assert!(map.segment_notifications.is_empty());
1813        assert!(map.segment_states.is_empty());
1814        assert!(map.segment_ids.is_empty());
1815        assert_eq!(map.min_size, (None, None));
1816        assert_eq!(map.max_size, (None, None));
1817        assert_eq!(map.current_index, 0);
1818    }
1819
1820    #[test]
1821    fn map_default_equals_new() {
1822        let map = Map::default();
1823        assert_eq!(map.zoom, 1.0);
1824        assert!(map.points.is_none());
1825    }
1826
1827    // ---------- zoom ----------
1828
1829    #[test]
1830    fn set_zoom_within_range() {
1831        let mut map = Map::new();
1832        map.set_zoom(1.5);
1833        assert_eq!(map.get_zoom(), 1.5);
1834    }
1835
1836    #[test]
1837    fn set_zoom_at_exact_limits() {
1838        let mut map = Map::new();
1839        map.set_zoom(map.settings.min_zoom);
1840        assert_eq!(map.get_zoom(), 0.1);
1841        map.set_zoom(map.settings.max_zoom);
1842        assert_eq!(map.get_zoom(), 2.0);
1843    }
1844
1845    #[test]
1846    fn set_zoom_out_of_range_is_ignored() {
1847        let mut map = Map::new();
1848        let initial = map.get_zoom();
1849        map.set_zoom(0.05); // por debajo de min_zoom
1850        assert_eq!(map.get_zoom(), initial);
1851        map.set_zoom(2.5); // por encima de max_zoom
1852        assert_eq!(map.get_zoom(), initial);
1853    }
1854
1855    // ---------- puntos ----------
1856
1857    #[test]
1858    fn add_hashmap_points_computes_bounds() {
1859        let mut map = Map::new();
1860        map.add_points(sample_points());
1861
1862        assert_eq!(map.reference.min.components, [-10.0, -10.0]);
1863        assert_eq!(map.reference.max.components, [10.0, 10.0]);
1864        // pos es el punto medio del rectángulo que contiene todos los puntos
1865        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
1866        // map_area tiene área 0 antes de renderizar, así que dist es el valor fijo
1867        assert_eq!(map.reference.dist, 3000.0);
1868        // current se inicializa como copia de reference
1869        assert_eq!(map.current.min.components, map.reference.min.components);
1870        assert_eq!(map.current.max.components, map.reference.max.components);
1871        assert_eq!(map.current.pos.components, map.reference.pos.components);
1872        assert_eq!(map.current.dist, map.reference.dist);
1873        assert!(map.points.is_some());
1874        assert!(map.tree.is_some());
1875        assert_eq!(map.points.as_ref().unwrap().len(), 3);
1876    }
1877
1878    #[test]
1879    fn add_hashmap_points_populates_visible_points() {
1880        let mut map = Map::new();
1881        map.add_points(sample_points());
1882        // todos los puntos de muestra caen dentro del radio por defecto
1883        assert_eq!(map.visible_points.len(), 3);
1884    }
1885
1886    /// Renders one frame of `map` in a 500x500 viewport and returns the
1887    /// painted line segments.
1888    fn render_line_segments(map: &mut Map) -> Vec<[egui::Pos2; 2]> {
1889        use egui::{Context, RawInput, Shape};
1890        let ctx = Context::default();
1891        let input = RawInput {
1892            screen_rect: Some(egui::Rect::from_min_size(
1893                egui::Pos2::ZERO,
1894                egui::vec2(500.0, 500.0),
1895            )),
1896            ..RawInput::default()
1897        };
1898        let mut output = ctx.run_ui(input, |ui| {
1899            ui.add(&mut *map);
1900        });
1901        // `TexturesDelta` panics on drop if it still holds an unhandled
1902        // delta (e.g. the font atlas uploaded on the first frame) -- clear
1903        // it explicitly instead of letting `output` fall out of scope with
1904        // it untouched. Same fix as `render_with` in
1905        // `tests/segment_animations.rs`.
1906        output.textures_delta.clear();
1907        output
1908            .shapes
1909            .iter()
1910            .filter_map(|cs| match cs.shape {
1911                Shape::LineSegment { points, .. } => Some(points),
1912                _ => None,
1913            })
1914            .collect()
1915    }
1916
1917    #[test]
1918    fn segment_crossing_viewport_is_painted_even_with_far_endpoints() {
1919        // With the old endpoint-based rule this line was culled: both
1920        // endpoints sit beyond the point-culling radius. With the R-tree the
1921        // segment AABB intersects the viewport, so it is painted — no points
1922        // needed at all.
1923        let mut map = Map::new();
1924        map.set_zoom(1.0);
1925        let lines = vec![MapSegment::new((1, 2), [-4000.0, -1.0], [4000.0, 1.0])];
1926        map.add_lines(lines);
1927        map.set_pos([0.0, 0.0]);
1928
1929        let segments = render_line_segments(&mut map);
1930        assert_eq!(segments.len(), 1);
1931    }
1932
1933    #[test]
1934    fn segment_outside_viewport_is_not_painted() {
1935        let mut map = Map::new();
1936        map.set_zoom(1.0);
1937        let lines = vec![MapSegment::new(
1938            (1, 2),
1939            [10_000.0, 10_000.0],
1940            [10_100.0, 10_100.0],
1941        )];
1942        map.add_lines(lines);
1943        map.set_pos([0.0, 0.0]);
1944
1945        assert!(render_line_segments(&mut map).is_empty());
1946    }
1947
1948    #[test]
1949    fn add_lines_builds_segment_tree() {
1950        let mut map = Map::new();
1951        map.add_points(sample_points());
1952        let lines = vec![MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0])];
1953        map.add_lines(lines);
1954
1955        let tree = map
1956            .segments
1957            .as_ref()
1958            .expect("add_lines must build the segment tree");
1959        assert_eq!(tree.size(), 1);
1960
1961        // Broad-phase query: a viewport containing (0,0) must hit the segment;
1962        // a far-away viewport must not.
1963        let hit_query = rstar::AABB::from_corners([-1.0, -1.0], [1.0, 1.0]);
1964        let hits: Vec<_> = tree.locate_in_envelope_intersecting(hit_query).collect();
1965        assert_eq!(hits.len(), 1);
1966        assert_eq!(hits[0].id, (1, 2));
1967
1968        let miss_query = rstar::AABB::from_corners([100.0, 100.0], [200.0, 200.0]);
1969        assert_eq!(tree.locate_in_envelope_intersecting(miss_query).count(), 0);
1970    }
1971
1972    #[test]
1973    fn add_lines_populates_segment_ids() {
1974        let mut map = Map::new();
1975        map.add_lines(vec![
1976            MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]),
1977            MapSegment::new((3, 4), [1.0, 1.0], [2.0, 2.0]),
1978        ]);
1979        assert!(map.segment_ids.contains(&(1, 2)));
1980        assert!(map.segment_ids.contains(&(3, 4)));
1981        assert_eq!(map.segment_ids.len(), 2);
1982
1983        // Replacing the set of lines replaces the id index too.
1984        map.add_hashmap_lines(HashMap::from([(
1985            (5, 6),
1986            MapSegment::new((5, 6), [0.0, 0.0], [1.0, 1.0]),
1987        )]));
1988        assert_eq!(map.segment_ids, HashSet::from([(5, 6)]));
1989    }
1990
1991    #[test]
1992    fn map_check_line_is_painted_on_first_frame() {
1993        use egui::{Context, RawInput, Shape};
1994
1995        // --- arrange ---
1996        let mut map = Map::new();
1997        map.set_zoom(1.0);
1998
1999        let mut point_a = MapPoint::new(0, [0.0, 0.0]);
2000        point_a.connections.push((0, 1));
2001        let mut point_b = MapPoint::new(1, [50.0, 50.0]);
2002        point_b.connections.push((0, 1));
2003
2004        let lines = vec![MapSegment::new((0, 1), point_a.coords, point_b.coords)];
2005
2006        let points = vec![point_a, point_b];
2007        // Load points before lines — the natural order shown in the examples.
2008        map.add_points(points);
2009        map.add_lines(lines);
2010
2011        map.set_pos([25.0, 25.0]);
2012
2013        // --- act: 1st frame (no CentralPanel — run_ui creates the root Ui) ---
2014        let ctx = Context::default();
2015        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 500.0));
2016        let input = RawInput {
2017            screen_rect: Some(screen),
2018            ..RawInput::default()
2019        };
2020
2021        let mut output1 = ctx.run_ui(input.clone(), |ui| {
2022            ui.add(&mut map);
2023        });
2024
2025        let segments1: Vec<[egui::Pos2; 2]> = output1
2026            .shapes
2027            .iter()
2028            .filter_map(|cs| match cs.shape {
2029                Shape::LineSegment { points, .. } => Some(points),
2030                _ => None,
2031            })
2032            .collect();
2033        // `TexturesDelta` panics on drop if it still holds an unhandled
2034        // delta (e.g. the font atlas uploaded on the first frame) -- clear
2035        // it explicitly rather than letting `output1` fall out of scope
2036        // with it untouched.
2037        output1.textures_delta.clear();
2038
2039        assert!(
2040            !segments1.is_empty(),
2041            "Frame 1: no LineSegment shapes painted (map lines did not draw)"
2042        );
2043
2044        // Expected projection of (0,0)->(50,50) with zoom=1, center=(25,25),
2045        // viewport 500x500: pos_a = (225, 225), pos_b = (275, 275). Tolerance ±2 px.
2046        let expected_a = egui::pos2(225.0, 225.0);
2047        let expected_b = egui::pos2(275.0, 275.0);
2048        let tolerance = 2.0;
2049        let found_on_frame1 = segments1.iter().any(|[p1, p2]| {
2050            let d_a1 = p1.distance(expected_a);
2051            let d_b1 = p2.distance(expected_b);
2052            let d_a2 = p2.distance(expected_a);
2053            let d_b2 = p1.distance(expected_b);
2054            (d_a1 < tolerance && d_b1 < tolerance) || (d_a2 < tolerance && d_b2 < tolerance)
2055        });
2056        assert!(
2057            found_on_frame1,
2058            "Frame 1: no LineSegment matches expected endpoints (~225,225 -> ~275,275); got {:?}",
2059            segments1
2060        );
2061
2062        // --- act: 2nd frame (unchanged) — detect duplicate-lines regression ---
2063        let mut output2 = ctx.run_ui(input, |ui| {
2064            ui.add(&mut map);
2065        });
2066
2067        let segments2: Vec<[egui::Pos2; 2]> = output2
2068            .shapes
2069            .iter()
2070            .filter_map(|cs| match cs.shape {
2071                Shape::LineSegment { points, .. } => Some(points),
2072                _ => None,
2073            })
2074            .collect();
2075        output2.textures_delta.clear();
2076
2077        assert_eq!(
2078            segments1.len(),
2079            segments2.len(),
2080            "Frame 2: expected {} line segments (no duplication across frames), got {}",
2081            segments1.len(),
2082            segments2.len()
2083        );
2084    }
2085
2086    // ---------- posición ----------
2087
2088    #[test]
2089    fn set_pos_and_get_pos_roundtrip() {
2090        let mut map = Map::new();
2091        map.set_pos([25.0, -35.0]);
2092        assert_eq!(map.get_pos(), [25.0, -35.0]);
2093    }
2094
2095    #[test]
2096    fn set_pos_from_nodeid_with_valid_id() {
2097        let mut map = Map::new();
2098        map.add_points(sample_points());
2099        assert!(map.set_pos_from_nodeid(2));
2100        assert_eq!(map.get_pos(), [10.0, 10.0]);
2101    }
2102
2103    /// Regression: the widget used to allocate a painter of the full
2104    /// `map_area.size()` *inside* the canvas frame, so the frame grew by
2105    /// `2 * total_margin` and spilled out of the space the widget was given.
2106    /// The drawable region was then truncated on the right/bottom and its
2107    /// centre no longer matched `map_area.center()`, leaving a centred node
2108    /// visibly off-centre by half a margin.
2109    #[test]
2110    fn centered_node_is_painted_at_the_middle_of_the_drawable_area() {
2111        use egui::{Context, RawInput, Shape};
2112
2113        for screen_size in [egui::vec2(500.0, 500.0), egui::vec2(800.0, 400.0)] {
2114            for zoom in [1.0, 2.0] {
2115                let mut map = Map::new();
2116                map.set_zoom(zoom);
2117                map.add_points(vec![MapPoint::new(7, [123.0, 456.0])]);
2118                map.set_pos_from_nodeid(7);
2119
2120                let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, screen_size);
2121                let ctx = Context::default();
2122                let mut widget_rect = egui::Rect::NOTHING;
2123                let mut output = ctx.run_ui(
2124                    RawInput {
2125                        screen_rect: Some(screen),
2126                        ..RawInput::default()
2127                    },
2128                    |ui| {
2129                        widget_rect = ui.add(&mut map).rect;
2130                    },
2131                );
2132
2133                // The widget must stay inside the space it was handed.
2134                assert!(
2135                    screen.contains_rect(widget_rect),
2136                    "{screen_size:?} zoom {zoom}: widget rect {widget_rect:?} overflows {screen:?}"
2137                );
2138
2139                // The centred node must land at the middle of the region the
2140                // map actually paints into (the painter's clip rect).
2141                let (node_center, drawable) = output
2142                    .shapes
2143                    .iter()
2144                    .find_map(|cs| match &cs.shape {
2145                        Shape::Circle(circle) => Some((circle.center, cs.clip_rect)),
2146                        _ => None,
2147                    })
2148                    .expect("the node must be painted");
2149
2150                assert!(
2151                    node_center.distance(drawable.center()) < 0.5,
2152                    "{screen_size:?} zoom {zoom}: node painted at {node_center:?} but the \
2153                     drawable area {drawable:?} is centred at {:?}",
2154                    drawable.center()
2155                );
2156
2157                output.textures_delta.clear();
2158            }
2159        }
2160    }
2161
2162    #[test]
2163    fn set_pos_from_nodeid_with_invalid_id_keeps_position() {
2164        let mut map = Map::new();
2165        map.add_points(sample_points());
2166        let before = map.reference.pos.components;
2167        // An unknown id must report the failure instead of silently no-op'ing.
2168        assert!(!map.set_pos_from_nodeid(999));
2169        assert_eq!(map.reference.pos.components, before);
2170    }
2171
2172    #[test]
2173    fn set_pos_from_nodeid_without_points_does_nothing() {
2174        let mut map = Map::new();
2175        assert!(!map.set_pos_from_nodeid(1));
2176        assert_eq!(map.reference.pos.components, [0.0, 0.0]);
2177    }
2178
2179    // ---------- etiquetas y líneas ----------
2180
2181    #[test]
2182    fn add_labels_stores_labels() {
2183        let mut map = Map::new();
2184        let label = MapLabel {
2185            text: "Region".to_string(),
2186            center: Pos2::new(1.0, 2.0),
2187        };
2188        map.add_labels(vec![label]);
2189        assert_eq!(map.labels.len(), 1);
2190        assert_eq!(map.labels[0].text, "Region");
2191    }
2192
2193    #[test]
2194    fn add_lines_stores_lines() {
2195        let mut map = Map::new();
2196        let lines = vec![MapSegment::new((1, 2), [0.0, 0.0], [1.0, 1.0])];
2197        map.add_lines(lines);
2198        let tree = map.segments.as_ref().unwrap();
2199        assert_eq!(tree.size(), 1);
2200        assert_eq!(
2201            tree.locate_in_envelope_intersecting(rstar::AABB::from_corners(
2202                [-1.0, -1.0],
2203                [2.0, 2.0],
2204            ))
2205            .next()
2206            .unwrap()
2207            .id,
2208            (1, 2)
2209        );
2210    }
2211
2212    // ---------- notificaciones y marcadores ----------
2213
2214    #[test]
2215    fn line_at_returns_closest_line_within_tolerance() {
2216        let mut map = Map::new();
2217        map.add_points(sample_points());
2218        let lines = vec![
2219            MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]),
2220            MapSegment::new((3, 4), [20.0, -5.0], [20.0, 5.0]),
2221        ];
2222        map.add_lines(lines);
2223
2224        // 1.5 units above the horizontal segment.
2225        let hit = map.line_at([5.0, 1.5], 2.0).expect("line must be hit");
2226        assert_eq!(hit, (1, 2));
2227
2228        // Closest to the vertical segment.
2229        let hit = map.line_at([19.0, 0.0], 2.0).expect("line must be hit");
2230        assert_eq!(hit, (3, 4));
2231    }
2232
2233    #[test]
2234    fn line_at_returns_none_beyond_tolerance() {
2235        let mut map = Map::new();
2236        map.add_points(sample_points());
2237        let lines = vec![MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0])];
2238        map.add_lines(lines);
2239
2240        // Distance from (5,4) to the diagonal segment (0,0)-(10,10) is
2241        // |5-4|/sqrt(2) ~= 0.707.
2242        assert!(map.line_at([5.0, 4.0], 0.8).is_some());
2243        assert!(map.line_at([5.0, 4.0], 0.5).is_none());
2244        assert!(map.line_at([100.0, 100.0], 5.0).is_none());
2245    }
2246
2247    #[test]
2248    fn line_at_returns_none_without_lines() {
2249        let map = Map::new();
2250        assert!(map.line_at([0.0, 0.0], 10.0).is_none());
2251    }
2252
2253    #[test]
2254    fn line_at_negative_tolerance_behaves_like_zero() {
2255        let mut map = Map::new();
2256        map.add_points(sample_points());
2257        let lines = vec![MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0])];
2258        map.add_lines(lines);
2259
2260        // Exact point on the segment is hit even with tolerance clamped to 0.
2261        assert!(map.line_at([5.0, 5.0], -1.0).is_some());
2262        assert!(map.line_at([5.0, 5.1], -1.0).is_none());
2263    }
2264
2265    /// The deprecated shortcut must keep behaving exactly as it did: a pulse,
2266    /// restarted on every call, and tolerant of ids that were never loaded.
2267    #[test]
2268    #[allow(deprecated)]
2269    fn deprecated_notify_still_records_a_pulse() {
2270        let mut map = Map::new();
2271        let t1 = Instant::now();
2272        map.notify(5, t1);
2273        let recorded = map.notifications.get(&5).expect("notify must record");
2274        assert_eq!(recorded.started, t1);
2275        assert_eq!(recorded.animation, NodeAnimation::Pulse);
2276        assert_eq!(recorded.color, None);
2277
2278        let t2 = t1 + Duration::from_secs(1);
2279        map.notify(5, t2);
2280        assert_eq!(map.notifications.get(&5).unwrap().started, t2);
2281        assert_eq!(map.notifications.len(), 1);
2282    }
2283
2284    // ---------- NodeHandle ----------
2285
2286    fn map_with_nodes() -> Map {
2287        let mut map = Map::new();
2288        map.add_points(vec![
2289            MapPoint::new(1, [0.0, 0.0]),
2290            MapPoint::new(2, [10.0, 10.0]),
2291        ]);
2292        map
2293    }
2294
2295    #[test]
2296    fn node_returns_none_for_an_unknown_id() {
2297        let mut map = map_with_nodes();
2298        assert!(map.node(1).is_some());
2299        assert!(map.node(999).is_none());
2300        // and with nothing loaded at all
2301        assert!(Map::new().node(1).is_none());
2302    }
2303
2304    #[test]
2305    fn each_event_effect_records_its_own_animation() {
2306        let now = Instant::now();
2307        for (apply, expected) in [
2308            (
2309                Box::new(|n: NodeHandle| n.pulse(now)) as Box<dyn FnOnce(NodeHandle)>,
2310                NodeAnimation::Pulse,
2311            ),
2312            (
2313                Box::new(|n: NodeHandle| n.ripple(now)),
2314                NodeAnimation::Ripple,
2315            ),
2316            (
2317                Box::new(|n: NodeHandle| n.countdown(now)),
2318                NodeAnimation::CountdownArc,
2319            ),
2320            (
2321                Box::new(|n: NodeHandle| n.scale_in(now)),
2322                NodeAnimation::ScaleIn,
2323            ),
2324            (
2325                Box::new(|n: NodeHandle| n.crosshair(now)),
2326                NodeAnimation::Crosshair,
2327            ),
2328        ] {
2329            let mut map = map_with_nodes();
2330            apply(map.node(1).unwrap());
2331            let recorded = map.notifications.get(&1).expect("effect must be recorded");
2332            assert_eq!(recorded.animation, expected);
2333            assert_eq!(recorded.started, now);
2334            // an event effect must not leave lasting state behind
2335            assert!(map.node_states.is_empty());
2336        }
2337    }
2338
2339    #[test]
2340    fn each_steady_effect_records_lasting_state() {
2341        for (apply, expected) in [
2342            (
2343                Box::new(|n: NodeHandle| n.halo()) as Box<dyn FnOnce(NodeHandle)>,
2344                SteadyAnimation::Halo,
2345            ),
2346            (Box::new(|n: NodeHandle| n.blink()), SteadyAnimation::Blink),
2347            (Box::new(|n: NodeHandle| n.orbit()), SteadyAnimation::Orbit),
2348        ] {
2349            let mut map = map_with_nodes();
2350            apply(map.node(1).unwrap());
2351            assert_eq!(map.node_states.get(&1).unwrap().animation, expected);
2352            // lasting state must not masquerade as a notification
2353            assert!(map.notifications.is_empty());
2354        }
2355    }
2356
2357    #[test]
2358    fn color_modifier_reaches_both_families() {
2359        let mut map = map_with_nodes();
2360        map.node(1)
2361            .unwrap()
2362            .color(Color32::RED)
2363            .pulse(Instant::now());
2364        map.node(2).unwrap().color(Color32::BLUE).halo();
2365
2366        assert_eq!(map.notifications.get(&1).unwrap().color, Some(Color32::RED));
2367        assert_eq!(map.node_states.get(&2).unwrap().color, Some(Color32::BLUE));
2368    }
2369
2370    #[test]
2371    fn a_node_can_carry_state_and_a_notification_at_once() {
2372        let mut map = map_with_nodes();
2373        map.node(1).unwrap().halo();
2374        map.node(1).unwrap().ripple(Instant::now());
2375
2376        assert!(map.node_states.contains_key(&1));
2377        assert!(map.notifications.contains_key(&1));
2378    }
2379
2380    #[test]
2381    fn clear_removes_both_families_for_that_node_only() {
2382        let mut map = map_with_nodes();
2383        map.node(1).unwrap().halo();
2384        map.node(1).unwrap().ripple(Instant::now());
2385        map.node(2).unwrap().halo();
2386
2387        map.node(1).unwrap().clear();
2388
2389        assert!(!map.node_states.contains_key(&1));
2390        assert!(!map.notifications.contains_key(&1));
2391        assert!(map.node_states.contains_key(&2), "node 2 must be untouched");
2392    }
2393
2394    #[test]
2395    fn re_triggering_replaces_the_previous_effect() {
2396        let mut map = map_with_nodes();
2397        map.node(1).unwrap().pulse(Instant::now());
2398        map.node(1).unwrap().crosshair(Instant::now());
2399
2400        assert_eq!(map.notifications.len(), 1);
2401        assert_eq!(
2402            map.notifications.get(&1).unwrap().animation,
2403            NodeAnimation::Crosshair
2404        );
2405    }
2406
2407    // ---------- SegmentHandle ----------
2408
2409    fn map_with_segments() -> Map {
2410        let mut map = Map::new();
2411        map.add_lines(vec![
2412            MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]),
2413            MapSegment::new((3, 4), [0.0, 10.0], [10.0, 10.0]),
2414        ]);
2415        map
2416    }
2417
2418    #[test]
2419    fn segment_returns_none_for_an_unknown_id() {
2420        let mut map = map_with_segments();
2421        assert!(map.segment((1, 2)).is_some());
2422        assert!(map.segment((404, 404)).is_none());
2423        // and with nothing loaded at all
2424        assert!(Map::new().segment((1, 2)).is_none());
2425    }
2426
2427    #[test]
2428    fn flash_records_a_segment_notification() {
2429        let mut map = map_with_segments();
2430        let now = Instant::now();
2431        map.segment((1, 2)).unwrap().flash(now);
2432
2433        let recorded = map
2434            .segment_notifications
2435            .get(&(1, 2))
2436            .expect("flash must be recorded");
2437        assert_eq!(recorded.animation, SegmentAnimation::FlashDecay);
2438        assert_eq!(recorded.started, now);
2439        // an event effect must not leave lasting state behind
2440        assert!(map.segment_states.is_empty());
2441    }
2442
2443    #[test]
2444    fn each_event_segment_effect_records_its_own_notification() {
2445        for (apply, expected) in [
2446            (
2447                Box::new(|s: SegmentHandle, at: Instant| s.flash(at))
2448                    as Box<dyn FnOnce(SegmentHandle, Instant)>,
2449                SegmentAnimation::FlashDecay,
2450            ),
2451            (
2452                Box::new(|s: SegmentHandle, at: Instant| s.comet_once(at, CometDirection::Forward)),
2453                SegmentAnimation::Comet(CometDirection::Forward),
2454            ),
2455            (
2456                Box::new(|s: SegmentHandle, at: Instant| s.comet_once(at, CometDirection::Reverse)),
2457                SegmentAnimation::Comet(CometDirection::Reverse),
2458            ),
2459            (
2460                Box::new(|s: SegmentHandle, at: Instant| s.wipe(at)),
2461                SegmentAnimation::Wipe,
2462            ),
2463        ] {
2464            let mut map = map_with_segments();
2465            let now = Instant::now();
2466            apply(map.segment((1, 2)).unwrap(), now);
2467
2468            let recorded = map
2469                .segment_notifications
2470                .get(&(1, 2))
2471                .expect("the effect must be recorded");
2472            assert_eq!(recorded.animation, expected);
2473            assert_eq!(recorded.started, now);
2474            // an event effect must not leave lasting state behind
2475            assert!(map.segment_states.is_empty());
2476        }
2477    }
2478
2479    #[test]
2480    fn each_steady_segment_effect_records_lasting_state() {
2481        for (apply, expected) in [
2482            (
2483                Box::new(|s: SegmentHandle| s.comet()) as Box<dyn FnOnce(SegmentHandle)>,
2484                SteadySegmentAnimation::Comet,
2485            ),
2486            (
2487                Box::new(|s: SegmentHandle| s.dash()),
2488                SteadySegmentAnimation::Dash,
2489            ),
2490            (
2491                Box::new(|s: SegmentHandle| s.glow_band()),
2492                SteadySegmentAnimation::GlowBand,
2493            ),
2494            (
2495                Box::new(|s: SegmentHandle| s.chevrons()),
2496                SteadySegmentAnimation::Chevrons,
2497            ),
2498        ] {
2499            let mut map = map_with_segments();
2500            apply(map.segment((1, 2)).unwrap());
2501            assert_eq!(map.segment_states.get(&(1, 2)).unwrap().animation, expected);
2502            // lasting state must not masquerade as a notification
2503            assert!(map.segment_notifications.is_empty());
2504        }
2505    }
2506
2507    #[test]
2508    fn steady_segment_effect_alpha_tracks_the_lines_zoom_fade_with_a_head_start() {
2509        // At zoom 0.6 with the default `line_visible_zoom` of 0.2, the line's
2510        // own fade (`line_fade`) is exactly half-way through its 0.80-unit
2511        // ramp: `(0.6 - 0.2) / 0.80 == 0.5`. `comet` paints `color` on a
2512        // `Shape::Circle` with no alpha adjustment of its own, so whatever
2513        // alpha lands on screen must be exactly `scale_alpha`'s output --
2514        // `effect_fade = (0.5 + SEGMENT_EFFECT_ALPHA_BOOST).min(1.0) = 0.7`.
2515        let mut map = map_with_segments();
2516        map.set_zoom(0.6);
2517        let color = Color32::from_rgba_unmultiplied(10, 20, 30, 200);
2518        map.segment((1, 2)).unwrap().color(color).comet();
2519
2520        // `scale_alpha` goes through `Color32::to_srgba_unmultiplied` before
2521        // reconstructing the color, so the expected fill is whatever that
2522        // round trip actually produces -- not a hand-derived byte value,
2523        // which would be fragile against the gamma-aware LUT egui's
2524        // premultiply table uses internally.
2525        let expected_fill = scale_alpha(color, 0.7);
2526        assert_eq!(
2527            expected_fill.a(),
2528            140,
2529            "sanity-check the hand-derived alpha"
2530        );
2531
2532        let ctx = Context::default();
2533        let screen_rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
2534        let mut out = ctx.run_ui(
2535            RawInput {
2536                screen_rect: Some(screen_rect),
2537                ..RawInput::default()
2538            },
2539            |ui| {
2540                ui.add(&mut map);
2541            },
2542        );
2543        // Font-atlas texture deltas are dropped safely only when explicitly
2544        // cleared first -- see the identical pattern in `render_line_segments`
2545        // and `tests/segment_animations.rs`'s `render_with`.
2546        out.textures_delta.clear();
2547
2548        let comet_circle = out
2549            .shapes
2550            .iter()
2551            .find_map(|cs| match &cs.shape {
2552                Shape::Circle(c) => Some(*c),
2553                _ => None,
2554            })
2555            .expect("comet must paint a filled circle");
2556
2557        assert_eq!(
2558            comet_circle.fill, expected_fill,
2559            "the comet's fill must be exactly scale_alpha(color, effect_fade) -- the line's \
2560             zoom fade with the documented head start applied"
2561        );
2562    }
2563
2564    #[test]
2565    fn color_modifier_reaches_both_segment_families() {
2566        let mut map = map_with_segments();
2567        map.segment((1, 2))
2568            .unwrap()
2569            .color(Color32::RED)
2570            .flash(Instant::now());
2571        map.segment((3, 4)).unwrap().color(Color32::BLUE).comet();
2572
2573        assert_eq!(
2574            map.segment_notifications.get(&(1, 2)).unwrap().color,
2575            Some(Color32::RED)
2576        );
2577        assert_eq!(
2578            map.segment_states.get(&(3, 4)).unwrap().color,
2579            Some(Color32::BLUE)
2580        );
2581    }
2582
2583    #[test]
2584    fn a_segment_can_carry_state_and_a_notification_at_once() {
2585        let mut map = map_with_segments();
2586        map.segment((1, 2)).unwrap().comet();
2587        map.segment((1, 2)).unwrap().flash(Instant::now());
2588
2589        assert!(map.segment_states.contains_key(&(1, 2)));
2590        assert!(map.segment_notifications.contains_key(&(1, 2)));
2591    }
2592
2593    #[test]
2594    fn clear_removes_both_segment_families_for_that_id_only() {
2595        let mut map = map_with_segments();
2596        map.segment((1, 2)).unwrap().comet();
2597        map.segment((1, 2)).unwrap().flash(Instant::now());
2598        map.segment((3, 4)).unwrap().comet();
2599
2600        map.segment((1, 2)).unwrap().clear();
2601
2602        assert!(!map.segment_states.contains_key(&(1, 2)));
2603        assert!(!map.segment_notifications.contains_key(&(1, 2)));
2604        assert!(
2605            map.segment_states.contains_key(&(3, 4)),
2606            "segment (3, 4) must be untouched"
2607        );
2608    }
2609
2610    #[test]
2611    fn re_flashing_a_segment_restarts_it() {
2612        let mut map = map_with_segments();
2613        let t1 = Instant::now();
2614        map.segment((1, 2)).unwrap().flash(t1);
2615        let t2 = t1 + Duration::from_secs(1);
2616        map.segment((1, 2)).unwrap().flash(t2);
2617
2618        assert_eq!(map.segment_notifications.len(), 1);
2619        assert_eq!(map.segment_notifications.get(&(1, 2)).unwrap().started, t2);
2620    }
2621
2622    #[test]
2623    fn update_marker_inserts_and_updates() {
2624        let mut map = Map::new();
2625        map.update_marker(1, 100);
2626        assert_eq!(map.markers.get(&1), Some(&100));
2627        map.update_marker(1, 200);
2628        assert_eq!(map.markers.get(&1), Some(&200));
2629        assert_eq!(map.markers.len(), 1);
2630    }
2631
2632    // ---------- tamaño ----------
2633
2634    #[test]
2635    fn allocate_at_least_sets_min_size() {
2636        let mut map = Map::new();
2637        map.allocate_at_least(Some(100.0), None);
2638        assert_eq!(map.min_size, (Some(100.0), None));
2639    }
2640
2641    #[test]
2642    fn allocate_at_most_sets_max_size() {
2643        let mut map = Map::new();
2644        map.allocate_at_most(None, Some(200.0));
2645        assert_eq!(map.max_size, (None, Some(200.0)));
2646    }
2647
2648    // ---------- bounds ----------
2649
2650    #[test]
2651    fn adjust_bounds_scales_with_zoom() {
2652        let mut map = Map::new();
2653        map.reference.min = RawPoint::new(-10.0, -20.0);
2654        map.reference.max = RawPoint::new(10.0, 20.0);
2655        map.reference.pos = RawPoint::new(5.0, 5.0);
2656        map.reference.dist = 100.0;
2657        map.set_zoom(2.0);
2658        map.adjust_bounds();
2659
2660        assert_eq!(map.current.max.components, [20.0, 40.0]);
2661        assert_eq!(map.current.min.components, [-20.0, -40.0]);
2662        assert_eq!(map.current.pos.components, [10.0, 10.0]);
2663        assert_eq!(map.current.dist, 50.0);
2664    }
2665
2666    // ---------- theme ----------
2667
2668    #[test]
2669    fn theme_colors_are_resolved_live_from_the_installed_theme() {
2670        // `Style` no longer caches any color (see `theme.rs`), so there is
2671        // nothing for `set_theme` to eagerly refresh -- `theme_colors()`
2672        // must reflect the newly installed theme immediately, in whichever
2673        // light/dark mode is active, without waiting for a mode flip.
2674        let mut map = Map::new();
2675        map.set_theme(Rc::new(Theme::ArticCyan));
2676
2677        let light = Theme::ArticCyan.colors(ColorMode::Light);
2678        let dark = Theme::ArticCyan.colors(ColorMode::Dark);
2679
2680        assert_eq!(map.current_index, 0, "Map::new starts in light mode");
2681        assert_eq!(map.theme_colors(), light);
2682
2683        // Simulate the app flipping to dark mode: still the very same
2684        // installed theme, resolved for the other `ColorMode`.
2685        map.current_index = 1;
2686        assert_eq!(map.theme_colors(), dark);
2687    }
2688
2689    #[test]
2690    fn a_custom_map_theme_reaches_the_painted_node() {
2691        // End-to-end: a `MapTheme` installed through `set_theme` must be the
2692        // color a plain (un-templated, un-colored) node is actually painted
2693        // with, not just a value sitting in `settings.styles`.
2694        use crate::map::theme::ThemeColors;
2695        use egui::{Context, RawInput, Shape};
2696
2697        struct FixedPalette;
2698        impl MapTheme for FixedPalette {
2699            fn colors(&self, _mode: ColorMode) -> ThemeColors {
2700                ThemeColors {
2701                    node: Color32::from_rgb(1, 2, 3),
2702                    segment: Color32::from_rgb(4, 5, 6),
2703                    selected: Color32::from_rgb(7, 8, 9),
2704                    alert: Color32::from_rgb(10, 11, 12),
2705                    text: Color32::from_rgb(13, 14, 15),
2706                }
2707            }
2708        }
2709
2710        let mut map = Map::new();
2711        map.set_theme(Rc::new(FixedPalette));
2712        map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
2713
2714        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0));
2715        let ctx = Context::default();
2716        let mut output = ctx.run_ui(
2717            RawInput {
2718                screen_rect: Some(screen),
2719                ..RawInput::default()
2720            },
2721            |ui| {
2722                ui.add(&mut map);
2723            },
2724        );
2725        // `TexturesDelta` panics on drop if left unhandled.
2726        output.textures_delta.clear();
2727
2728        let fill = output
2729            .shapes
2730            .iter()
2731            .find_map(|cs| match &cs.shape {
2732                Shape::Circle(circle) => Some(circle.fill),
2733                _ => None,
2734            })
2735            .expect("the node must be painted");
2736
2737        assert_eq!(
2738            fill,
2739            Color32::from_rgb(1, 2, 3),
2740            "the node fill must come from the installed MapTheme, in either color mode"
2741        );
2742    }
2743
2744    #[test]
2745    fn a_custom_map_theme_reaches_the_selection_highlight() {
2746        // End-to-end: `SelectionContext::color` must be the installed
2747        // `MapTheme`'s `selected` color -- previously defined on every
2748        // `ThemeColors` but never actually consumed anywhere in painting --
2749        // and `SelectionContext::point` must be the node the widget
2750        // actually computed as nearest to the pointer.
2751        use crate::map::theme::ThemeColors;
2752        use egui::{Context, Event, RawInput};
2753        use std::cell::RefCell;
2754
2755        struct FixedPalette;
2756        impl MapTheme for FixedPalette {
2757            fn colors(&self, _mode: ColorMode) -> ThemeColors {
2758                ThemeColors {
2759                    node: Color32::from_rgb(1, 2, 3),
2760                    segment: Color32::from_rgb(4, 5, 6),
2761                    selected: Color32::from_rgb(7, 8, 9),
2762                    alert: Color32::from_rgb(10, 11, 12),
2763                    text: Color32::from_rgb(13, 14, 15),
2764                }
2765            }
2766        }
2767
2768        #[derive(Default)]
2769        struct RecordingTemplate {
2770            seen: RefCell<Option<(usize, Color32)>>,
2771        }
2772        impl NodeTemplate for RecordingTemplate {
2773            fn node_ui(&self, _ui: &mut Ui, _ctx: NodeContext) {}
2774            fn selection_ui(&self, _ui: &mut Ui, ctx: SelectionContext) {
2775                *self.seen.borrow_mut() = Some((ctx.point.get_id(), ctx.color));
2776            }
2777            fn notification_ui(&self, _ui: &mut Ui, _ctx: NotificationContext) -> bool {
2778                false
2779            }
2780            fn marker_ui(&self, _ui: &mut Ui, _ctx: MarkerContext) {}
2781        }
2782
2783        let mut map = Map::new();
2784        map.set_theme(Rc::new(FixedPalette));
2785        map.settings.node_text_visibility = VisibilitySetting::Hover;
2786        map.add_points(vec![MapPoint::new(9, [0.0, 0.0])]);
2787        let template = Rc::new(RecordingTemplate::default());
2788        map.set_node_template(template.clone());
2789
2790        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0));
2791        let ctx = Context::default();
2792        // Two passes: egui needs a frame to lay the widget out before its
2793        // `Response::hovered()` reflects a pointer position landed in the
2794        // same frame (same reasoning as `tests/debug_overlay.rs`).
2795        for pass in 0..2 {
2796            let events = if pass == 1 {
2797                vec![Event::PointerMoved(screen.center())]
2798            } else {
2799                Vec::new()
2800            };
2801            let mut output = ctx.run_ui(
2802                RawInput {
2803                    screen_rect: Some(screen),
2804                    events,
2805                    ..RawInput::default()
2806                },
2807                |ui| {
2808                    ui.add(&mut map);
2809                },
2810            );
2811            // `TexturesDelta` panics on drop if left unhandled.
2812            output.textures_delta.clear();
2813        }
2814
2815        let (id, color) = template
2816            .seen
2817            .borrow()
2818            .expect("selection_ui must be called while the pointer hovers the map");
2819        assert_eq!(
2820            id, 9,
2821            "the highlighted node must be the one under the pointer"
2822        );
2823        assert_eq!(
2824            color,
2825            Color32::from_rgb(7, 8, 9),
2826            "the highlight color must come from the installed MapTheme's `selected`"
2827        );
2828    }
2829}