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