use crate::map::animation::Animation;
use crate::map::objects::{
CometDirection, ContextMenuManager, MapBounds, MapLabel, MapPoint, MapSegment, MapSettings,
MarkerContext, NodeAnimation, NotificationContext, RawLine, RawPoint, SegmentAnimation,
SteadyAnimation, SteadySegmentAnimation, TextSettings, VisibilitySetting,
};
use crate::map::theme::{ColorMode, MapTheme, Style, Theme};
use egui::{widgets::*, *};
use kdtree::KdTree;
use kdtree::distance::squared_euclidean;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::time::Instant;
use self::objects::{NodeTemplate, SegmentTemplate};
pub mod animation;
pub mod objects;
pub mod theme;
const SEGMENT_EFFECT_ALPHA_BOOST: f32 = 0.2;
fn scale_alpha(color: Color32, factor: f32) -> Color32 {
let [r, g, b, a] = color.to_srgba_unmultiplied();
Color32::from_rgba_unmultiplied(r, g, b, (a as f32 * factor.clamp(0.0, 1.0)).round() as u8)
}
#[derive(Clone)]
pub struct Map {
zoom: f32,
previous_zoom: f32,
points: Option<HashMap<usize, MapPoint>>,
segments: Option<rstar::RTree<MapSegment>>,
labels: Vec<MapLabel>,
tree: Option<KdTree<f32, usize, [f32; 2]>>,
visible_points: Vec<isize>,
map_area: Rect,
reference: MapBounds,
current: MapBounds,
current_index: usize,
notifications: HashMap<usize, Notification>,
node_states: HashMap<usize, NodeState>,
segment_notifications: HashMap<(usize, usize), SegmentNotification>,
segment_states: HashMap<(usize, usize), SegmentState>,
segment_ids: HashSet<(usize, usize)>,
min_size: (Option<f32>, Option<f32>),
max_size: (Option<f32>, Option<f32>),
pub settings: MapSettings,
menu_manager: Option<Rc<dyn ContextMenuManager>>,
node_template: Option<Rc<dyn NodeTemplate>>,
segment_template: Option<Rc<dyn SegmentTemplate>>,
markers: HashMap<usize, usize>,
theme: Rc<dyn MapTheme>,
}
#[derive(Clone, Copy, Debug)]
struct Notification {
started: Instant,
animation: NodeAnimation,
color: Option<Color32>,
}
#[derive(Clone, Copy, Debug)]
struct NodeState {
animation: SteadyAnimation,
color: Option<Color32>,
}
#[derive(Clone, Copy, Debug)]
struct SegmentNotification {
started: Instant,
animation: SegmentAnimation,
color: Option<Color32>,
}
#[derive(Clone, Copy, Debug)]
struct SegmentState {
animation: SteadySegmentAnimation,
color: Option<Color32>,
}
pub struct NodeHandle<'a> {
map: &'a mut Map,
id: usize,
color: Option<Color32>,
}
impl NodeHandle<'_> {
pub fn color(mut self, color: Color32) -> Self {
self.color = Some(color);
self
}
fn notify_with(self, animation: NodeAnimation, at: Instant) {
self.map.notifications.insert(
self.id,
Notification {
started: at,
animation,
color: self.color,
},
);
}
fn set_state(self, animation: SteadyAnimation) {
self.map.node_states.insert(
self.id,
NodeState {
animation,
color: self.color,
},
);
}
pub fn pulse(self, at: Instant) {
self.notify_with(NodeAnimation::Pulse, at);
}
pub fn ripple(self, at: Instant) {
self.notify_with(NodeAnimation::Ripple, at);
}
pub fn countdown(self, at: Instant) {
self.notify_with(NodeAnimation::CountdownArc, at);
}
pub fn scale_in(self, at: Instant) {
self.notify_with(NodeAnimation::ScaleIn, at);
}
pub fn crosshair(self, at: Instant) {
self.notify_with(NodeAnimation::Crosshair, at);
}
pub fn halo(self) {
self.set_state(SteadyAnimation::Halo);
}
pub fn blink(self) {
self.set_state(SteadyAnimation::Blink);
}
pub fn orbit(self) {
self.set_state(SteadyAnimation::Orbit);
}
pub fn clear(self) {
self.map.notifications.remove(&self.id);
self.map.node_states.remove(&self.id);
}
}
pub struct SegmentHandle<'a> {
map: &'a mut Map,
id: (usize, usize),
color: Option<Color32>,
}
impl SegmentHandle<'_> {
pub fn color(mut self, color: Color32) -> Self {
self.color = Some(color);
self
}
pub fn flash(self, at: Instant) {
self.map.segment_notifications.insert(
self.id,
SegmentNotification {
started: at,
animation: SegmentAnimation::FlashDecay,
color: self.color,
},
);
}
pub fn comet_once(self, at: Instant, direction: CometDirection) {
self.map.segment_notifications.insert(
self.id,
SegmentNotification {
started: at,
animation: SegmentAnimation::Comet(direction),
color: self.color,
},
);
}
pub fn wipe(self, at: Instant) {
self.map.segment_notifications.insert(
self.id,
SegmentNotification {
started: at,
animation: SegmentAnimation::Wipe,
color: self.color,
},
);
}
pub fn comet(self) {
self.map.segment_states.insert(
self.id,
SegmentState {
animation: SteadySegmentAnimation::Comet,
color: self.color,
},
);
}
pub fn dash(self) {
self.map.segment_states.insert(
self.id,
SegmentState {
animation: SteadySegmentAnimation::Dash,
color: self.color,
},
);
}
pub fn glow_band(self) {
self.map.segment_states.insert(
self.id,
SegmentState {
animation: SteadySegmentAnimation::GlowBand,
color: self.color,
},
);
}
pub fn chevrons(self) {
self.map.segment_states.insert(
self.id,
SegmentState {
animation: SteadySegmentAnimation::Chevrons,
color: self.color,
},
);
}
pub fn clear(self) {
self.map.segment_notifications.remove(&self.id);
self.map.segment_states.remove(&self.id);
}
}
impl Default for Map {
fn default() -> Self {
Map::new()
}
}
impl Widget for &mut Map {
fn ui(self, ui: &mut egui::Ui) -> Response {
let rect = self.calculate_widget_dimensions(ui);
self.reference.dist = rect.distance();
self.assign_visual_style(ui);
let canvas = egui::Frame::canvas(ui.style()).inner_margin(Margin::symmetric(3, 5));
let frame_margin = canvas.total_margin().sum();
let painter_size = (self.map_area.size() - frame_margin).max(Vec2::ZERO);
let inner_response = canvas.show(ui, |ui| {
let _span = tracing::info_span!("paint_map").entered();
if ui.is_rect_visible(self.map_area) {
let (resp, paint) =
ui.allocate_painter(painter_size, egui::Sense::click_and_drag());
let vec = resp.drag_delta();
if vec.length() != 0.0 {
let _span = tracing::info_span!("calculating_points_in_visible_area").entered();
let coords = RawPoint::from(vec.to_pos2());
let new_pos = self.reference.pos - (coords / self.zoom);
self.set_pos(new_pos.into());
}
if self.zoom < self.settings.line_visible_zoom {
let mut text_settings = TextSettings {
size: self.settings.label_text_size,
anchor: Align2::CENTER_CENTER,
family: FontFamily::Proportional,
text: String::new(),
position: RawPoint::default(),
text_color: ui.visuals().text_color(),
};
for label in &self.labels {
text_settings.text.clone_from(&label.text);
text_settings.position = RawPoint::from(label.center);
self.paint_label(&paint, &text_settings);
}
}
let rect_midpoint = RawPoint::from(resp.rect.center());
let min_point = self.current.pos - rect_midpoint;
let vec_points = &self.visible_points;
let hashm = &self.points;
let now = Instant::now();
self.notifications
.retain(|_, n| now.duration_since(n.started).as_secs_f32() < 10.0);
self.segment_notifications
.retain(|_, n| now.duration_since(n.started).as_secs_f32() < 10.0);
for segment in self.paint_map_lines(&paint, &min_point) {
self.segment_notifications.remove(&segment);
}
if let Ok(nodes_to_remove) =
self.paint_map_points(vec_points, hashm, &paint, ui, &min_point, &resp)
{
for node in nodes_to_remove {
self.notifications.remove(&node);
}
}
for marker in &self.markers {
if let Some(point) = self.points.as_ref().unwrap().get(marker.1) {
let adjusted_point = RawPoint::from(point.coords) * self.zoom - min_point;
if let Some(template) = &self.node_template {
template.marker_ui(
ui,
MarkerContext {
position: adjusted_point.into(),
zoom: self.zoom,
kind: self.settings.marker_animation,
node_id: *marker.1,
},
);
} else {
let color = if ui.visuals().dark_mode {
Color32::LIGHT_GREEN
} else {
Color32::GREEN
};
let time = ui.input(|i| i.time) as f32;
let effect = match self.settings.marker_animation {
SteadyAnimation::Blink => Animation::blink,
SteadyAnimation::Halo => Animation::halo,
SteadyAnimation::Orbit => Animation::orbit,
};
effect(ui.painter(), adjusted_point.into(), self.zoom, time, color);
ui.ctx().request_repaint();
}
}
}
self.paint_sub_components(ui, self.map_area);
self.capture_mouse_events(ui, &resp);
if self.zoom != self.previous_zoom {
let _span = tracing::info_span!("calculating viewport with zoom").entered();
self.adjust_bounds();
self.calculate_visible_points();
self.previous_zoom = self.zoom;
}
if let Some(menu_mon) = &mut self.menu_manager {
resp.context_menu(|ui| {
menu_mon.ui(ui);
});
}
#[cfg(feature = "debug_overlay")]
self.print_debug_info(ui, &resp);
}
});
inner_response.response
}
}
impl Map {
pub fn new() -> Self {
let settings = MapSettings::default();
Self {
zoom: 1.0,
previous_zoom: 1.0,
map_area: Rect::NOTHING,
tree: None,
points: None,
labels: Vec::new(),
visible_points: Vec::new(),
current: MapBounds::default(),
reference: MapBounds::default(),
settings,
min_size: (None, None),
max_size: (None, None),
current_index: 0,
notifications: HashMap::new(),
node_states: HashMap::new(),
segment_notifications: HashMap::new(),
segment_states: HashMap::new(),
segment_ids: HashSet::new(),
menu_manager: None,
node_template: None,
segment_template: None,
markers: HashMap::new(),
segments: None,
theme: Rc::new(Theme::default()),
}
}
fn calculate_widget_dimensions(&mut self, ui: &mut Ui) -> RawLine {
let available = ui.available_rect_before_wrap();
let mut size = available.size();
if let Some(max_width) = self.max_size.0 {
size.x = size.x.min(max_width);
}
if let Some(max_height) = self.max_size.1 {
size.y = size.y.min(max_height);
}
if let Some(min_width) = self.min_size.0 {
size.x = size.x.max(min_width);
}
if let Some(min_height) = self.min_size.1 {
size.y = size.y.max(min_height);
}
self.map_area = Rect::from_min_size(available.min, size);
RawLine::new(
RawPoint::from(self.map_area.left_top()),
RawPoint::from(self.map_area.right_bottom()),
)
}
fn calculate_visible_points(&mut self) {
let _span = tracing::info_span!("calculate_visible_points").entered();
if self.current.dist > 0.0
&& self.current.dist < f32::INFINITY
&& let Some(tree) = &self.tree
{
let center = self.current.pos / self.zoom;
let radius = self.current.dist.powi(2);
let point: [f32; 2] = center.into();
let vis_pos = tree.within(&point, radius, &squared_euclidean).unwrap();
self.visible_points.clear();
for point in vis_pos {
self.visible_points.push(point.1.cast_signed());
}
}
}
pub fn add_points(&mut self, points: Vec<MapPoint>) {
let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
let mut hash_map = HashMap::new();
let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
for entry in points {
for i in 0..min.components.len() {
if entry.coords[i] < min.components[i] {
min.components[i] = entry.coords[i];
}
if entry.coords[i] > max.components[i] {
max.components[i] = entry.coords[i];
}
}
let _result = tree.add(entry.coords, entry.get_id());
hash_map.insert(entry.get_id(), entry);
}
self.reference.min = min;
self.reference.max = max;
self.points = Some(hash_map);
self.tree = Some(tree);
self.reference.pos = RawLine::new(min, max).midpoint();
if self.map_area.area() == 0.0 {
self.reference.dist = 3000.00;
} else {
let rect = RawLine::new(
RawPoint::from(self.map_area.left_top()),
RawPoint::from(self.map_area.right_bottom()),
);
self.reference.dist = rect.distance();
}
self.current = self.reference.clone();
self.calculate_visible_points();
}
pub fn add_hashmap_points(&mut self, hash_map: HashMap<usize, MapPoint>) {
let _span = tracing::info_span!("add_hashmap_points").entered();
let mut min = RawPoint::new(f32::INFINITY, f32::INFINITY);
let mut max = RawPoint::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
let mut tree = KdTree::<f32, usize, [f32; 2]>::new(2);
for entry in hash_map.iter() {
for i in 0..min.components.len() {
if entry.1.coords[i] < min.components[i] {
min.components[i] = entry.1.coords[i];
}
if entry.1.coords[i] > max.components[i] {
max.components[i] = entry.1.coords[i];
}
}
let _result = tree.add(entry.1.coords, *entry.0);
}
self.reference.min = min;
self.reference.max = max;
self.points = Some(hash_map);
self.tree = Some(tree);
self.reference.pos = RawLine::new(min, max).midpoint();
if self.map_area.area() == 0.0 {
self.reference.dist = 3000.00;
} else {
let rect = RawLine::new(
RawPoint::from(self.map_area.left_top()),
RawPoint::from(self.map_area.right_bottom()),
);
self.reference.dist = rect.distance();
}
self.current = self.reference.clone();
self.calculate_visible_points();
}
pub fn set_pos_from_nodeid(&mut self, node_id: usize) -> bool {
let _span = tracing::info_span!("set_pos_from_nodeid").entered();
if let Some(hash_map) = &self.points
&& let Some(map_point) = hash_map.get(&node_id)
{
self.reference.pos = RawPoint::from(map_point.coords);
self.adjust_bounds();
self.calculate_visible_points();
true
} else {
tracing::warn!(
node_id,
loaded_nodes = self.points.as_ref().map_or(0, |p| p.len()),
"set_pos_from_nodeid: unknown node id, the view was left unchanged"
);
false
}
}
pub fn set_pos(&mut self, position: [f32; 2]) {
let _span = tracing::info_span!("set_pos").entered();
let point = RawPoint::from(position);
self.reference.pos = point;
self.adjust_bounds();
self.calculate_visible_points();
}
pub fn get_pos(&self) -> [f32; 2] {
let _span = tracing::info_span!("get_pos").entered();
self.reference.pos.into()
}
pub fn add_labels(&mut self, labels: Vec<MapLabel>) {
let _span = tracing::info_span!("add_labels").entered();
self.labels = labels;
}
pub fn add_lines(&mut self, segments: Vec<MapSegment>) {
let _span = tracing::info_span!("add_lines").entered();
self.segment_ids = segments.iter().map(|s| s.id).collect();
self.segments = Some(rstar::RTree::bulk_load(segments));
}
pub fn add_hashmap_lines(&mut self, segments: HashMap<(usize, usize), MapSegment>) {
let _span = tracing::info_span!("add_hashmap_lines").entered();
let segments: Vec<MapSegment> = segments.into_values().collect();
self.segment_ids = segments.iter().map(|s| s.id).collect();
self.segments = Some(rstar::RTree::bulk_load(segments));
}
fn adjust_bounds(&mut self) {
let _span = tracing::info_span!("adjust_bounds").entered();
self.current.max = self.reference.max * self.zoom;
self.current.min = self.reference.min * self.zoom;
self.current.dist = self.reference.dist / self.zoom;
self.current.pos = self.reference.pos * self.zoom;
}
fn capture_mouse_events(&mut self, ui: &Ui, _resp: &Response) {
let _span = tracing::info_span!("capture_mouse_events").entered();
if ui.rect_contains_pointer(self.map_area) {
ui.input(|x| {
let _span = tracing::info_span!("capture_mouse_events_input").entered();
if !x.events.is_empty() {
for event in &x.events {
match event {
Event::MouseWheel {
unit: _,
delta,
modifiers,
phase: _,
} => {
#[cfg(target_os = "macos")]
let zoom_modifier = if modifiers.mac_cmd {
delta.y / 80.00
} else {
delta.y / 400.00
};
#[cfg(not(target_os = "macos"))]
let zoom_modifier = if modifiers.ctrl {
delta.y / 8.00
} else {
delta.y / 40.00
};
let mut pre_zoom = self.zoom + zoom_modifier;
if pre_zoom > self.settings.max_zoom {
pre_zoom = self.settings.max_zoom;
}
if pre_zoom < self.settings.min_zoom {
pre_zoom = self.settings.min_zoom;
}
self.zoom = pre_zoom;
}
_ => {
continue;
}
};
}
}
});
}
}
pub fn set_zoom(&mut self, value: f32) {
if value >= self.settings.min_zoom && value <= self.settings.max_zoom {
self.zoom = value;
}
}
pub fn get_zoom(&mut self) -> f32 {
self.zoom
}
fn current_style(&self) -> &Style {
self.settings
.styles
.get(self.current_index)
.or(self.settings.styles.first())
.expect("MapSettings::styles must not be empty")
}
fn assign_visual_style(&mut self, ui_obj: &mut Ui) {
let style_index = ui_obj.visuals().dark_mode as usize;
if self.current_index != style_index {
let _span = tracing::info_span!("asign_visual_style").entered();
self.current_index = style_index;
self.apply_theme_colors(style_index);
let map_style = self.settings.styles.get_mut(style_index).unwrap();
let visuals = &ui_obj.style().visuals;
map_style.background_color = visuals.extreme_bg_color;
map_style.border = Some(visuals.window_stroke);
}
}
fn apply_theme_colors(&mut self, index: usize) {
let mode = if index == 1 {
ColorMode::Dark
} else {
ColorMode::Light
};
let colors = self.theme.colors(mode);
if let Some(map_style) = self.settings.styles.get_mut(index) {
map_style.fill_color = colors.node;
map_style.text_color = colors.text;
map_style.alert_color = colors.alert;
if let Some(line) = map_style.line.as_mut() {
line.color = colors.segment;
}
}
}
#[cfg(feature = "debug_overlay")]
fn print_debug_info(&mut self, ui: &mut Ui, resp: &Response) {
let _span = tracing::info_span!("printing debug data").entered();
let p = |v: f32| format!("{v:.2}");
let mut rows: Vec<(String, Color32)> = vec![
(
format!(
"MIN {}, {}",
p(self.current.min.components[0]),
p(self.current.min.components[1])
),
Color32::LIGHT_GREEN,
),
(
format!(
"MAX {}, {}",
p(self.current.max.components[0]),
p(self.current.max.components[1])
),
Color32::LIGHT_GREEN,
),
(
format!(
"CUR {}, {}",
p(self.current.pos.components[0]),
p(self.current.pos.components[1])
),
Color32::LIGHT_GREEN,
),
(
format!("DST {}", p(self.current.dist)),
Color32::LIGHT_GREEN,
),
(format!("ZOM {}", self.zoom), Color32::GREEN),
(
format!(
"REC {}, {} .. {}, {}",
p(self.map_area.left_top().x),
p(self.map_area.left_top().y),
p(self.map_area.right_bottom().x),
p(self.map_area.right_bottom().y)
),
Color32::LIGHT_GREEN,
),
];
if let Some(points) = &self.points {
rows.push((format!("NUM {}", points.len()), Color32::LIGHT_GREEN));
}
if !self.visible_points.is_empty() {
rows.push((
format!("VIS {}", self.visible_points.len()),
Color32::LIGHT_GREEN,
));
}
if let Some(pointer_pos) = resp.hover_pos() {
rows.push((
format!("HVR {}, {}", p(pointer_pos.x), p(pointer_pos.y)),
Color32::LIGHT_BLUE,
));
}
let drag = resp.drag_delta();
if drag.length() != 0.0 {
rows.push((format!("DRG {}, {}", p(drag.x), p(drag.y)), Color32::GOLD));
}
let overlay_rect = Rect::from_min_max(
self.map_area.left_top() + Vec2::new(6.0, 6.0),
self.map_area.right_bottom(),
);
let mut overlay_ui = ui.new_child(
UiBuilder::new()
.max_rect(overlay_rect)
.layout(Layout::top_down(Align::Min)),
);
CollapsingHeader::new(RichText::new("dbg").monospace().small().weak())
.id_salt("egui_map_debug_overlay")
.default_open(false)
.show_background(false)
.show(&mut overlay_ui, |ui| {
for (text, color) in rows {
ui.label(RichText::new(text).monospace().small().color(color));
}
});
}
fn paint_sub_components(&mut self, ui_obj: &mut Ui, rect: Rect) {
let _span = tracing::info_span!("map_ui_paint_sub_components").entered();
let zoom_slider = egui::Slider::new(
&mut self.zoom,
self.settings.min_zoom..=self.settings.max_zoom,
)
.show_value(false)
.orientation(SliderOrientation::Vertical);
let mut pos1 = rect.right_top();
let mut pos2 = rect.right_top();
pos1.x -= 80.0;
pos1.y += 120.0;
pos2.x -= 60.0;
pos2.y += 240.0;
let sub_rect = egui::Rect::from_two_pos(pos1, pos2);
let ui_builder = egui::UiBuilder::new().clone().max_rect(sub_rect);
ui_obj.scope_builder(ui_builder, |ui_obj| {
ui_obj.add(zoom_slider);
});
}
fn paint_map_points(
&self,
vec_points: &Vec<isize>,
hashm: &Option<HashMap<usize, MapPoint>>,
paint: &Painter,
ui_obj: &mut Ui,
min_point: &RawPoint,
resp: &Response,
) -> Result<Vec<usize>, ()> {
let mut nearest_id = None;
let mut nodes_to_remove = Vec::new();
let mut shape_vec = vec![];
if hashm.is_none() {
return Err(());
}
if vec_points.is_empty() {
return Err(());
}
if self.settings.node_text_visibility == VisibilitySetting::Hover
&& resp.hovered()
&& let Some(point) = resp.hover_pos()
{
let raw_point = RawPoint::from(point);
let hovered_map_point = (*min_point + raw_point) / self.zoom;
if let Ok(nearest_node) = self.tree.as_ref().unwrap().nearest(
&hovered_map_point.components,
1,
&squared_euclidean,
) {
nearest_id = Some(nearest_node.first().unwrap().1);
}
}
let mut text_settings = TextSettings {
size: self.settings.node_text_size,
anchor: Align2::LEFT_BOTTOM,
family: FontFamily::Proportional,
text: String::new(),
position: RawPoint::default(),
text_color: ui_obj.visuals().text_color(),
};
for temp_point in vec_points {
let parsed_point = temp_point.cast_unsigned();
if let Some(system) = hashm.as_ref().unwrap().get(&parsed_point) {
let _span = tracing::info_span!("painting_points_m").entered();
let viewport_point = RawPoint::from(system.coords) * self.zoom - min_point;
if let Some(node_template) = &self.node_template {
if nearest_id.unwrap_or(&0usize) == &system.get_id() {
node_template.selection_ui(ui_obj, viewport_point.into(), self.zoom);
}
} else if self.zoom > self.settings.label_visible_zoom
&& self.settings.node_text_visibility == VisibilitySetting::Always
|| (self.settings.node_text_visibility == VisibilitySetting::Hover
&& nearest_id.unwrap_or(&0usize) == &system.get_id())
{
let mut viewport_text = viewport_point;
viewport_text.components[0] += 3.0 * self.zoom;
viewport_text.components[1] -= 3.0 * self.zoom;
text_settings.position = viewport_text;
text_settings.text = system.get_name();
self.paint_label(paint, &text_settings);
}
let system_id = system.get_id();
if let Some(state) = self.node_states.get(&system_id) {
let color = state.color.unwrap_or(self.current_style().alert_color);
if let Some(template) = &self.node_template {
template.marker_ui(
ui_obj,
MarkerContext {
position: viewport_point.into(),
zoom: self.zoom,
kind: state.animation,
node_id: system_id,
},
);
} else {
let effect = match state.animation {
SteadyAnimation::Blink => Animation::blink,
SteadyAnimation::Halo => Animation::halo,
SteadyAnimation::Orbit => Animation::orbit,
};
let time = ui_obj.input(|i| i.time) as f32;
effect(paint, viewport_point.into(), self.zoom, time, color);
}
ui_obj.ctx().request_repaint();
}
if let Some(notification) = self.notifications.get(&system_id) {
let color = notification
.color
.unwrap_or(self.current_style().alert_color);
if let Some(template) = &self.node_template {
template.notification_ui(
ui_obj,
NotificationContext {
position: viewport_point.into(),
zoom: self.zoom,
initial_time: notification.started,
color,
kind: notification.animation,
node_id: system_id,
},
);
} else {
let effect = match notification.animation {
NodeAnimation::Pulse => Animation::pulse,
NodeAnimation::Ripple => Animation::ripple,
NodeAnimation::CountdownArc => Animation::countdown_arc,
NodeAnimation::ScaleIn => Animation::scale_in,
NodeAnimation::Crosshair => Animation::crosshair,
};
if effect(
paint,
viewport_point.into(),
self.zoom,
notification.started,
color,
) {
ui_obj.ctx().request_repaint();
} else {
nodes_to_remove.push(system_id);
}
}
}
if let Some(node_template) = &self.node_template {
node_template.node_ui(ui_obj, viewport_point.into(), self.zoom, system);
} else {
shape_vec.push(Shape::circle_filled(
viewport_point.into(),
4.00 * self.zoom,
system.color.unwrap_or(self.current_style().fill_color),
));
}
}
}
paint.extend(shape_vec);
Ok(nodes_to_remove)
}
fn paint_map_lines(&self, painter: &Painter, min_point: &RawPoint) -> Vec<(usize, usize)> {
let _span = tracing::info_span!("paint_map_lines").entered();
let mut segments_to_remove = Vec::new();
if self.zoom <= self.settings.line_visible_zoom {
return segments_to_remove;
}
let Some(segments) = &self.segments else {
return segments_to_remove;
};
let line_fade = ((self.zoom - self.settings.line_visible_zoom) / 0.80).clamp(0.0, 1.0);
let default_stroke = self.current_style().line.map(|stroke| {
if line_fade >= 1.0 {
stroke
} else {
let mut tup_stroke = stroke.color.to_tuple();
tup_stroke.3 = (255.0 * line_fade).round() as u8;
let color = Color32::from_rgba_unmultiplied(
tup_stroke.0,
tup_stroke.1,
tup_stroke.2,
tup_stroke.3,
);
Stroke::new(stroke.width, color)
}
});
let center = self.current.pos / self.zoom;
let padding = default_stroke.map(|s| s.width).unwrap_or(0.0) / self.zoom;
let half = RawPoint::new(
self.map_area.width() / 2.0 / self.zoom + padding,
self.map_area.height() / 2.0 / self.zoom + padding,
);
let query = rstar::AABB::from_corners((center - half).into(), (center + half).into());
let effect_fade = (line_fade + SEGMENT_EFFECT_ALPHA_BOOST).min(1.0);
for segment in segments.locate_in_envelope_intersecting(query) {
let raw_line = segment.raw_line();
let pos_a: Pos2 = (raw_line.points[0] * self.zoom - min_point).into();
let pos_b: Pos2 = (raw_line.points[1] * self.zoom - min_point).into();
if let Some(template) = &self.segment_template {
template.segment_ui(painter, pos_a, pos_b, self.zoom, segment);
} else if let Some(stroke) = default_stroke {
painter.add(Shape::line_segment([pos_a, pos_b], stroke));
}
if let Some(state) = self.segment_states.get(&segment.id) {
let color = scale_alpha(
state.color.unwrap_or(self.current_style().alert_color),
effect_fade,
);
if let Some(template) = &self.segment_template {
let time = painter.ctx().input(|i| i.time) as f32;
template.segment_state_ui(painter, pos_a, pos_b, self.zoom, time, color);
} else {
let effect = match state.animation {
SteadySegmentAnimation::Comet => Animation::comet,
SteadySegmentAnimation::Dash => Animation::dash,
SteadySegmentAnimation::GlowBand => Animation::glow_band,
SteadySegmentAnimation::Chevrons => Animation::chevrons,
};
let time = painter.ctx().input(|i| i.time) as f32;
effect(painter, pos_a, pos_b, self.zoom, time, color);
}
painter.ctx().request_repaint();
}
if let Some(notification) = self.segment_notifications.get(&segment.id) {
let color = scale_alpha(
notification
.color
.unwrap_or(self.current_style().alert_color),
effect_fade,
);
let still_playing = if let Some(template) = &self.segment_template {
template.segment_notification_ui(
painter,
pos_a,
pos_b,
self.zoom,
notification.started,
color,
)
} else {
match notification.animation {
SegmentAnimation::FlashDecay => Animation::flash_decay(
painter,
pos_a,
pos_b,
self.zoom,
notification.started,
color,
),
SegmentAnimation::Comet(direction) => Animation::comet_once(
painter,
pos_a,
pos_b,
self.zoom,
notification.started,
color,
direction,
),
SegmentAnimation::Wipe => Animation::wipe(
painter,
pos_a,
pos_b,
self.zoom,
notification.started,
color,
),
}
};
if still_playing {
painter.ctx().request_repaint();
} else {
segments_to_remove.push(segment.id);
}
}
}
segments_to_remove
}
fn paint_label(&self, paint: &Painter, text_settings: &TextSettings) {
let _span = tracing::info_span!("paint_label").entered();
paint.text(
text_settings.position.into(),
text_settings.anchor,
text_settings.text.clone(),
FontId::new(text_settings.size, text_settings.family.clone()),
text_settings.text_color,
);
}
#[deprecated(
since = "0.4.0",
note = "use `map.node(id)` and pick an effect, e.g. `if let Some(n) = map.node(id) { n.pulse(time) }`"
)]
pub fn notify(&mut self, id_node: usize, time: Instant) {
let _span = tracing::info_span!("notify").entered();
self.notifications.insert(
id_node,
Notification {
started: time,
animation: NodeAnimation::Pulse,
color: None,
},
);
}
pub fn node(&mut self, id: usize) -> Option<NodeHandle<'_>> {
if !self
.points
.as_ref()
.is_some_and(|points| points.contains_key(&id))
{
return None;
}
Some(NodeHandle {
map: self,
id,
color: None,
})
}
pub fn segment(&mut self, id: (usize, usize)) -> Option<SegmentHandle<'_>> {
if !self.segment_ids.contains(&id) {
return None;
}
Some(SegmentHandle {
map: self,
id,
color: None,
})
}
pub fn line_at(&self, point: [f32; 2], tolerance: f32) -> Option<(usize, usize)> {
let _span = tracing::info_span!("line_at").entered();
let segments = self.segments.as_ref()?;
let tolerance = tolerance.max(0.0);
let center = RawPoint::from(point);
let padding = RawPoint::new(tolerance, tolerance);
let query = rstar::AABB::from_corners((center - padding).into(), (center + padding).into());
let mut closest: Option<(f32, (usize, usize))> = None;
for segment in segments.locate_in_envelope_intersecting(query) {
let distance = segment.raw_line().distance_to_point(center);
if distance <= tolerance && closest.as_ref().is_none_or(|(best, _)| distance < *best) {
closest = Some((distance, segment.id));
}
}
closest.map(|(_, id)| id)
}
pub fn set_context_manager(&mut self, manager: Rc<dyn ContextMenuManager>) {
self.menu_manager = Some(manager);
}
pub fn set_node_template(&mut self, template: Rc<dyn NodeTemplate>) {
self.node_template = Some(template);
}
pub fn set_segment_template(&mut self, template: Rc<dyn SegmentTemplate>) {
self.segment_template = Some(template);
}
pub fn set_theme(&mut self, new_theme: Rc<dyn MapTheme>) {
self.theme = new_theme;
for index in 0..self.settings.styles.len().min(2) {
self.apply_theme_colors(index);
}
}
pub fn update_marker(&mut self, id: usize, node_id: usize) {
self.markers
.entry(id)
.and_modify(|value| *value = node_id)
.or_insert(node_id);
}
pub fn allocate_at_least(&mut self, width: Option<f32>, height: Option<f32>) {
self.min_size = (width, height);
}
pub fn allocate_at_most(&mut self, width: Option<f32>, height: Option<f32>) {
self.max_size = (width, height);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn sample_points() -> Vec<MapPoint> {
let mut map = Vec::new();
map.push(MapPoint::new(1, [0.0, 0.0]));
map.push(MapPoint::new(2, [10.0, 10.0]));
map.push(MapPoint::new(3, [-10.0, -10.0]));
map
}
#[test]
fn map_new_initial_state() {
let map = Map::new();
assert_eq!(map.zoom, 1.0);
assert_eq!(map.previous_zoom, 1.0);
assert!(map.points.is_none());
assert!(map.segments.is_none());
assert!(map.tree.is_none());
assert!(map.labels.is_empty());
assert!(map.visible_points.is_empty());
assert!(map.markers.is_empty());
assert!(map.notifications.is_empty());
assert!(map.node_states.is_empty());
assert!(map.segment_notifications.is_empty());
assert!(map.segment_states.is_empty());
assert!(map.segment_ids.is_empty());
assert_eq!(map.min_size, (None, None));
assert_eq!(map.max_size, (None, None));
assert_eq!(map.current_index, 0);
}
#[test]
fn map_default_equals_new() {
let map = Map::default();
assert_eq!(map.zoom, 1.0);
assert!(map.points.is_none());
}
#[test]
fn set_zoom_within_range() {
let mut map = Map::new();
map.set_zoom(1.5);
assert_eq!(map.get_zoom(), 1.5);
}
#[test]
fn set_zoom_at_exact_limits() {
let mut map = Map::new();
map.set_zoom(map.settings.min_zoom);
assert_eq!(map.get_zoom(), 0.1);
map.set_zoom(map.settings.max_zoom);
assert_eq!(map.get_zoom(), 2.0);
}
#[test]
fn set_zoom_out_of_range_is_ignored() {
let mut map = Map::new();
let initial = map.get_zoom();
map.set_zoom(0.05); assert_eq!(map.get_zoom(), initial);
map.set_zoom(2.5); assert_eq!(map.get_zoom(), initial);
}
#[test]
fn add_hashmap_points_computes_bounds() {
let mut map = Map::new();
map.add_points(sample_points());
assert_eq!(map.reference.min.components, [-10.0, -10.0]);
assert_eq!(map.reference.max.components, [10.0, 10.0]);
assert_eq!(map.reference.pos.components, [0.0, 0.0]);
assert_eq!(map.reference.dist, 3000.0);
assert_eq!(map.current.min.components, map.reference.min.components);
assert_eq!(map.current.max.components, map.reference.max.components);
assert_eq!(map.current.pos.components, map.reference.pos.components);
assert_eq!(map.current.dist, map.reference.dist);
assert!(map.points.is_some());
assert!(map.tree.is_some());
assert_eq!(map.points.as_ref().unwrap().len(), 3);
}
#[test]
fn add_hashmap_points_populates_visible_points() {
let mut map = Map::new();
map.add_points(sample_points());
assert_eq!(map.visible_points.len(), 3);
}
fn render_line_segments(map: &mut Map) -> Vec<[egui::Pos2; 2]> {
use egui::{Context, RawInput, Shape};
let ctx = Context::default();
let input = RawInput {
screen_rect: Some(egui::Rect::from_min_size(
egui::Pos2::ZERO,
egui::vec2(500.0, 500.0),
)),
..RawInput::default()
};
let mut output = ctx.run_ui(input, |ui| {
ui.add(&mut *map);
});
output.textures_delta.clear();
output
.shapes
.iter()
.filter_map(|cs| match cs.shape {
Shape::LineSegment { points, .. } => Some(points),
_ => None,
})
.collect()
}
#[test]
fn segment_crossing_viewport_is_painted_even_with_far_endpoints() {
let mut map = Map::new();
map.set_zoom(1.0);
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [-4000.0, -1.0], [4000.0, 1.0]));
map.add_lines(lines);
map.set_pos([0.0, 0.0]);
let segments = render_line_segments(&mut map);
assert_eq!(segments.len(), 1);
}
#[test]
fn segment_outside_viewport_is_not_painted() {
let mut map = Map::new();
map.set_zoom(1.0);
let mut lines = Vec::new();
lines.push(MapSegment::new(
(1, 2),
[10_000.0, 10_000.0],
[10_100.0, 10_100.0],
));
map.add_lines(lines);
map.set_pos([0.0, 0.0]);
assert!(render_line_segments(&mut map).is_empty());
}
#[test]
fn add_lines_builds_segment_tree() {
let mut map = Map::new();
map.add_points(sample_points());
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
map.add_lines(lines);
let tree = map
.segments
.as_ref()
.expect("add_lines must build the segment tree");
assert_eq!(tree.size(), 1);
let hit_query = rstar::AABB::from_corners([-1.0, -1.0], [1.0, 1.0]);
let hits: Vec<_> = tree.locate_in_envelope_intersecting(hit_query).collect();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, (1, 2));
let miss_query = rstar::AABB::from_corners([100.0, 100.0], [200.0, 200.0]);
assert_eq!(tree.locate_in_envelope_intersecting(miss_query).count(), 0);
}
#[test]
fn add_lines_populates_segment_ids() {
let mut map = Map::new();
map.add_lines(vec![
MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]),
MapSegment::new((3, 4), [1.0, 1.0], [2.0, 2.0]),
]);
assert!(map.segment_ids.contains(&(1, 2)));
assert!(map.segment_ids.contains(&(3, 4)));
assert_eq!(map.segment_ids.len(), 2);
map.add_hashmap_lines(HashMap::from([(
(5, 6),
MapSegment::new((5, 6), [0.0, 0.0], [1.0, 1.0]),
)]));
assert_eq!(map.segment_ids, HashSet::from([(5, 6)]));
}
#[test]
fn map_check_line_is_painted_on_first_frame() {
use egui::{Context, RawInput, Shape};
let mut map = Map::new();
map.set_zoom(1.0);
let mut point_a = MapPoint::new(0, [0.0, 0.0]);
point_a.connections.push((0, 1));
let mut point_b = MapPoint::new(1, [50.0, 50.0]);
point_b.connections.push((0, 1));
let mut lines = Vec::new();
lines.push(MapSegment::new((0, 1), point_a.coords, point_b.coords));
let mut points = Vec::new();
points.push(point_a);
points.push(point_b);
map.add_points(points);
map.add_lines(lines);
map.set_pos([25.0, 25.0]);
let ctx = Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(500.0, 500.0));
let input = RawInput {
screen_rect: Some(screen),
..RawInput::default()
};
let mut output1 = ctx.run_ui(input.clone(), |ui| {
ui.add(&mut map);
});
let segments1: Vec<[egui::Pos2; 2]> = output1
.shapes
.iter()
.filter_map(|cs| match cs.shape {
Shape::LineSegment { points, .. } => Some(points),
_ => None,
})
.collect();
output1.textures_delta.clear();
assert!(
!segments1.is_empty(),
"Frame 1: no LineSegment shapes painted (map lines did not draw)"
);
let expected_a = egui::pos2(225.0, 225.0);
let expected_b = egui::pos2(275.0, 275.0);
let tolerance = 2.0;
let found_on_frame1 = segments1.iter().any(|[p1, p2]| {
let d_a1 = p1.distance(expected_a);
let d_b1 = p2.distance(expected_b);
let d_a2 = p2.distance(expected_a);
let d_b2 = p1.distance(expected_b);
(d_a1 < tolerance && d_b1 < tolerance) || (d_a2 < tolerance && d_b2 < tolerance)
});
assert!(
found_on_frame1,
"Frame 1: no LineSegment matches expected endpoints (~225,225 -> ~275,275); got {:?}",
segments1
);
let mut output2 = ctx.run_ui(input, |ui| {
ui.add(&mut map);
});
let segments2: Vec<[egui::Pos2; 2]> = output2
.shapes
.iter()
.filter_map(|cs| match cs.shape {
Shape::LineSegment { points, .. } => Some(points),
_ => None,
})
.collect();
output2.textures_delta.clear();
assert_eq!(
segments1.len(),
segments2.len(),
"Frame 2: expected {} line segments (no duplication across frames), got {}",
segments1.len(),
segments2.len()
);
}
#[test]
fn set_pos_and_get_pos_roundtrip() {
let mut map = Map::new();
map.set_pos([25.0, -35.0]);
assert_eq!(map.get_pos(), [25.0, -35.0]);
}
#[test]
fn set_pos_from_nodeid_with_valid_id() {
let mut map = Map::new();
map.add_points(sample_points());
assert!(map.set_pos_from_nodeid(2));
assert_eq!(map.get_pos(), [10.0, 10.0]);
}
#[test]
fn centered_node_is_painted_at_the_middle_of_the_drawable_area() {
use egui::{Context, RawInput, Shape};
for screen_size in [egui::vec2(500.0, 500.0), egui::vec2(800.0, 400.0)] {
for zoom in [1.0, 2.0] {
let mut map = Map::new();
map.set_zoom(zoom);
map.add_points(vec![MapPoint::new(7, [123.0, 456.0])]);
map.set_pos_from_nodeid(7);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, screen_size);
let ctx = Context::default();
let mut widget_rect = egui::Rect::NOTHING;
let mut output = ctx.run_ui(
RawInput {
screen_rect: Some(screen),
..RawInput::default()
},
|ui| {
widget_rect = ui.add(&mut map).rect;
},
);
assert!(
screen.contains_rect(widget_rect),
"{screen_size:?} zoom {zoom}: widget rect {widget_rect:?} overflows {screen:?}"
);
let (node_center, drawable) = output
.shapes
.iter()
.find_map(|cs| match &cs.shape {
Shape::Circle(circle) => Some((circle.center, cs.clip_rect)),
_ => None,
})
.expect("the node must be painted");
assert!(
node_center.distance(drawable.center()) < 0.5,
"{screen_size:?} zoom {zoom}: node painted at {node_center:?} but the \
drawable area {drawable:?} is centred at {:?}",
drawable.center()
);
output.textures_delta.clear();
}
}
}
#[test]
fn set_pos_from_nodeid_with_invalid_id_keeps_position() {
let mut map = Map::new();
map.add_points(sample_points());
let before = map.reference.pos.components;
assert!(!map.set_pos_from_nodeid(999));
assert_eq!(map.reference.pos.components, before);
}
#[test]
fn set_pos_from_nodeid_without_points_does_nothing() {
let mut map = Map::new();
assert!(!map.set_pos_from_nodeid(1));
assert_eq!(map.reference.pos.components, [0.0, 0.0]);
}
#[test]
fn add_labels_stores_labels() {
let mut map = Map::new();
let label = MapLabel {
text: "Region".to_string(),
center: Pos2::new(1.0, 2.0),
};
map.add_labels(vec![label]);
assert_eq!(map.labels.len(), 1);
assert_eq!(map.labels[0].text, "Region");
}
#[test]
fn add_lines_stores_lines() {
let mut map = Map::new();
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [0.0, 0.0], [1.0, 1.0]));
map.add_lines(lines);
let tree = map.segments.as_ref().unwrap();
assert_eq!(tree.size(), 1);
assert_eq!(
tree.locate_in_envelope_intersecting(rstar::AABB::from_corners(
[-1.0, -1.0],
[2.0, 2.0],
))
.next()
.unwrap()
.id,
(1, 2)
);
}
#[test]
fn line_at_returns_closest_line_within_tolerance() {
let mut map = Map::new();
map.add_points(sample_points());
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]));
lines.push(MapSegment::new((3, 4), [20.0, -5.0], [20.0, 5.0]));
map.add_lines(lines);
let hit = map.line_at([5.0, 1.5], 2.0).expect("line must be hit");
assert_eq!(hit, (1, 2));
let hit = map.line_at([19.0, 0.0], 2.0).expect("line must be hit");
assert_eq!(hit, (3, 4));
}
#[test]
fn line_at_returns_none_beyond_tolerance() {
let mut map = Map::new();
map.add_points(sample_points());
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
map.add_lines(lines);
assert!(map.line_at([5.0, 4.0], 0.8).is_some());
assert!(map.line_at([5.0, 4.0], 0.5).is_none());
assert!(map.line_at([100.0, 100.0], 5.0).is_none());
}
#[test]
fn line_at_returns_none_without_lines() {
let map = Map::new();
assert!(map.line_at([0.0, 0.0], 10.0).is_none());
}
#[test]
fn line_at_negative_tolerance_behaves_like_zero() {
let mut map = Map::new();
map.add_points(sample_points());
let mut lines = Vec::new();
lines.push(MapSegment::new((1, 2), [0.0, 0.0], [10.0, 10.0]));
map.add_lines(lines);
assert!(map.line_at([5.0, 5.0], -1.0).is_some());
assert!(map.line_at([5.0, 5.1], -1.0).is_none());
}
#[test]
#[allow(deprecated)]
fn deprecated_notify_still_records_a_pulse() {
let mut map = Map::new();
let t1 = Instant::now();
map.notify(5, t1);
let recorded = map.notifications.get(&5).expect("notify must record");
assert_eq!(recorded.started, t1);
assert_eq!(recorded.animation, NodeAnimation::Pulse);
assert_eq!(recorded.color, None);
let t2 = t1 + Duration::from_secs(1);
map.notify(5, t2);
assert_eq!(map.notifications.get(&5).unwrap().started, t2);
assert_eq!(map.notifications.len(), 1);
}
fn map_with_nodes() -> Map {
let mut map = Map::new();
map.add_points(vec![
MapPoint::new(1, [0.0, 0.0]),
MapPoint::new(2, [10.0, 10.0]),
]);
map
}
#[test]
fn node_returns_none_for_an_unknown_id() {
let mut map = map_with_nodes();
assert!(map.node(1).is_some());
assert!(map.node(999).is_none());
assert!(Map::new().node(1).is_none());
}
#[test]
fn each_event_effect_records_its_own_animation() {
let now = Instant::now();
for (apply, expected) in [
(
Box::new(|n: NodeHandle| n.pulse(now)) as Box<dyn FnOnce(NodeHandle)>,
NodeAnimation::Pulse,
),
(
Box::new(|n: NodeHandle| n.ripple(now)),
NodeAnimation::Ripple,
),
(
Box::new(|n: NodeHandle| n.countdown(now)),
NodeAnimation::CountdownArc,
),
(
Box::new(|n: NodeHandle| n.scale_in(now)),
NodeAnimation::ScaleIn,
),
(
Box::new(|n: NodeHandle| n.crosshair(now)),
NodeAnimation::Crosshair,
),
] {
let mut map = map_with_nodes();
apply(map.node(1).unwrap());
let recorded = map.notifications.get(&1).expect("effect must be recorded");
assert_eq!(recorded.animation, expected);
assert_eq!(recorded.started, now);
assert!(map.node_states.is_empty());
}
}
#[test]
fn each_steady_effect_records_lasting_state() {
for (apply, expected) in [
(
Box::new(|n: NodeHandle| n.halo()) as Box<dyn FnOnce(NodeHandle)>,
SteadyAnimation::Halo,
),
(Box::new(|n: NodeHandle| n.blink()), SteadyAnimation::Blink),
(Box::new(|n: NodeHandle| n.orbit()), SteadyAnimation::Orbit),
] {
let mut map = map_with_nodes();
apply(map.node(1).unwrap());
assert_eq!(map.node_states.get(&1).unwrap().animation, expected);
assert!(map.notifications.is_empty());
}
}
#[test]
fn color_modifier_reaches_both_families() {
let mut map = map_with_nodes();
map.node(1)
.unwrap()
.color(Color32::RED)
.pulse(Instant::now());
map.node(2).unwrap().color(Color32::BLUE).halo();
assert_eq!(map.notifications.get(&1).unwrap().color, Some(Color32::RED));
assert_eq!(map.node_states.get(&2).unwrap().color, Some(Color32::BLUE));
}
#[test]
fn a_node_can_carry_state_and_a_notification_at_once() {
let mut map = map_with_nodes();
map.node(1).unwrap().halo();
map.node(1).unwrap().ripple(Instant::now());
assert!(map.node_states.contains_key(&1));
assert!(map.notifications.contains_key(&1));
}
#[test]
fn clear_removes_both_families_for_that_node_only() {
let mut map = map_with_nodes();
map.node(1).unwrap().halo();
map.node(1).unwrap().ripple(Instant::now());
map.node(2).unwrap().halo();
map.node(1).unwrap().clear();
assert!(!map.node_states.contains_key(&1));
assert!(!map.notifications.contains_key(&1));
assert!(map.node_states.contains_key(&2), "node 2 must be untouched");
}
#[test]
fn re_triggering_replaces_the_previous_effect() {
let mut map = map_with_nodes();
map.node(1).unwrap().pulse(Instant::now());
map.node(1).unwrap().crosshair(Instant::now());
assert_eq!(map.notifications.len(), 1);
assert_eq!(
map.notifications.get(&1).unwrap().animation,
NodeAnimation::Crosshair
);
}
fn map_with_segments() -> Map {
let mut map = Map::new();
map.add_lines(vec![
MapSegment::new((1, 2), [0.0, 0.0], [10.0, 0.0]),
MapSegment::new((3, 4), [0.0, 10.0], [10.0, 10.0]),
]);
map
}
#[test]
fn segment_returns_none_for_an_unknown_id() {
let mut map = map_with_segments();
assert!(map.segment((1, 2)).is_some());
assert!(map.segment((404, 404)).is_none());
assert!(Map::new().segment((1, 2)).is_none());
}
#[test]
fn flash_records_a_segment_notification() {
let mut map = map_with_segments();
let now = Instant::now();
map.segment((1, 2)).unwrap().flash(now);
let recorded = map
.segment_notifications
.get(&(1, 2))
.expect("flash must be recorded");
assert_eq!(recorded.animation, SegmentAnimation::FlashDecay);
assert_eq!(recorded.started, now);
assert!(map.segment_states.is_empty());
}
#[test]
fn each_event_segment_effect_records_its_own_notification() {
for (apply, expected) in [
(
Box::new(|s: SegmentHandle, at: Instant| s.flash(at))
as Box<dyn FnOnce(SegmentHandle, Instant)>,
SegmentAnimation::FlashDecay,
),
(
Box::new(|s: SegmentHandle, at: Instant| s.comet_once(at, CometDirection::Forward)),
SegmentAnimation::Comet(CometDirection::Forward),
),
(
Box::new(|s: SegmentHandle, at: Instant| s.comet_once(at, CometDirection::Reverse)),
SegmentAnimation::Comet(CometDirection::Reverse),
),
(
Box::new(|s: SegmentHandle, at: Instant| s.wipe(at)),
SegmentAnimation::Wipe,
),
] {
let mut map = map_with_segments();
let now = Instant::now();
apply(map.segment((1, 2)).unwrap(), now);
let recorded = map
.segment_notifications
.get(&(1, 2))
.expect("the effect must be recorded");
assert_eq!(recorded.animation, expected);
assert_eq!(recorded.started, now);
assert!(map.segment_states.is_empty());
}
}
#[test]
fn each_steady_segment_effect_records_lasting_state() {
for (apply, expected) in [
(
Box::new(|s: SegmentHandle| s.comet()) as Box<dyn FnOnce(SegmentHandle)>,
SteadySegmentAnimation::Comet,
),
(
Box::new(|s: SegmentHandle| s.dash()),
SteadySegmentAnimation::Dash,
),
(
Box::new(|s: SegmentHandle| s.glow_band()),
SteadySegmentAnimation::GlowBand,
),
(
Box::new(|s: SegmentHandle| s.chevrons()),
SteadySegmentAnimation::Chevrons,
),
] {
let mut map = map_with_segments();
apply(map.segment((1, 2)).unwrap());
assert_eq!(map.segment_states.get(&(1, 2)).unwrap().animation, expected);
assert!(map.segment_notifications.is_empty());
}
}
#[test]
fn steady_segment_effect_alpha_tracks_the_lines_zoom_fade_with_a_head_start() {
let mut map = map_with_segments();
map.set_zoom(0.6);
let color = Color32::from_rgba_unmultiplied(10, 20, 30, 200);
map.segment((1, 2)).unwrap().color(color).comet();
let expected_fill = scale_alpha(color, 0.7);
assert_eq!(
expected_fill.a(),
140,
"sanity-check the hand-derived alpha"
);
let ctx = Context::default();
let screen_rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
let mut out = ctx.run_ui(
RawInput {
screen_rect: Some(screen_rect),
..RawInput::default()
},
|ui| {
ui.add(&mut map);
},
);
out.textures_delta.clear();
let comet_circle = out
.shapes
.iter()
.find_map(|cs| match &cs.shape {
Shape::Circle(c) => Some(*c),
_ => None,
})
.expect("comet must paint a filled circle");
assert_eq!(
comet_circle.fill, expected_fill,
"the comet's fill must be exactly scale_alpha(color, effect_fade) -- the line's \
zoom fade with the documented head start applied"
);
}
#[test]
fn color_modifier_reaches_both_segment_families() {
let mut map = map_with_segments();
map.segment((1, 2))
.unwrap()
.color(Color32::RED)
.flash(Instant::now());
map.segment((3, 4)).unwrap().color(Color32::BLUE).comet();
assert_eq!(
map.segment_notifications.get(&(1, 2)).unwrap().color,
Some(Color32::RED)
);
assert_eq!(
map.segment_states.get(&(3, 4)).unwrap().color,
Some(Color32::BLUE)
);
}
#[test]
fn a_segment_can_carry_state_and_a_notification_at_once() {
let mut map = map_with_segments();
map.segment((1, 2)).unwrap().comet();
map.segment((1, 2)).unwrap().flash(Instant::now());
assert!(map.segment_states.contains_key(&(1, 2)));
assert!(map.segment_notifications.contains_key(&(1, 2)));
}
#[test]
fn clear_removes_both_segment_families_for_that_id_only() {
let mut map = map_with_segments();
map.segment((1, 2)).unwrap().comet();
map.segment((1, 2)).unwrap().flash(Instant::now());
map.segment((3, 4)).unwrap().comet();
map.segment((1, 2)).unwrap().clear();
assert!(!map.segment_states.contains_key(&(1, 2)));
assert!(!map.segment_notifications.contains_key(&(1, 2)));
assert!(
map.segment_states.contains_key(&(3, 4)),
"segment (3, 4) must be untouched"
);
}
#[test]
fn re_flashing_a_segment_restarts_it() {
let mut map = map_with_segments();
let t1 = Instant::now();
map.segment((1, 2)).unwrap().flash(t1);
let t2 = t1 + Duration::from_secs(1);
map.segment((1, 2)).unwrap().flash(t2);
assert_eq!(map.segment_notifications.len(), 1);
assert_eq!(map.segment_notifications.get(&(1, 2)).unwrap().started, t2);
}
#[test]
fn update_marker_inserts_and_updates() {
let mut map = Map::new();
map.update_marker(1, 100);
assert_eq!(map.markers.get(&1), Some(&100));
map.update_marker(1, 200);
assert_eq!(map.markers.get(&1), Some(&200));
assert_eq!(map.markers.len(), 1);
}
#[test]
fn allocate_at_least_sets_min_size() {
let mut map = Map::new();
map.allocate_at_least(Some(100.0), None);
assert_eq!(map.min_size, (Some(100.0), None));
}
#[test]
fn allocate_at_most_sets_max_size() {
let mut map = Map::new();
map.allocate_at_most(None, Some(200.0));
assert_eq!(map.max_size, (None, Some(200.0)));
}
#[test]
fn adjust_bounds_scales_with_zoom() {
let mut map = Map::new();
map.reference.min = RawPoint::new(-10.0, -20.0);
map.reference.max = RawPoint::new(10.0, 20.0);
map.reference.pos = RawPoint::new(5.0, 5.0);
map.reference.dist = 100.0;
map.set_zoom(2.0);
map.adjust_bounds();
assert_eq!(map.current.max.components, [20.0, 40.0]);
assert_eq!(map.current.min.components, [-20.0, -40.0]);
assert_eq!(map.current.pos.components, [10.0, 10.0]);
assert_eq!(map.current.dist, 50.0);
}
#[test]
fn set_theme_refreshes_both_style_slots_immediately() {
let mut map = Map::new();
map.set_theme(Rc::new(Theme::ArticCyan));
let light = Theme::ArticCyan.colors(ColorMode::Light);
let dark = Theme::ArticCyan.colors(ColorMode::Dark);
let light_style = &map.settings.styles[0];
assert_eq!(light_style.fill_color, light.node);
assert_eq!(light_style.text_color, light.text);
assert_eq!(light_style.alert_color, light.alert);
assert_eq!(light_style.line.unwrap().color, light.segment);
let dark_style = &map.settings.styles[1];
assert_eq!(dark_style.fill_color, dark.node);
assert_eq!(dark_style.text_color, dark.text);
assert_eq!(dark_style.alert_color, dark.alert);
assert_eq!(dark_style.line.unwrap().color, dark.segment);
}
#[test]
fn a_custom_map_theme_reaches_the_painted_node() {
use crate::map::theme::ThemeColors;
use egui::{Context, RawInput, Shape};
struct FixedPalette;
impl MapTheme for FixedPalette {
fn colors(&self, _mode: ColorMode) -> ThemeColors {
ThemeColors {
node: Color32::from_rgb(1, 2, 3),
segment: Color32::from_rgb(4, 5, 6),
selected: Color32::from_rgb(7, 8, 9),
alert: Color32::from_rgb(10, 11, 12),
text: Color32::from_rgb(13, 14, 15),
}
}
}
let mut map = Map::new();
map.set_theme(Rc::new(FixedPalette));
map.add_points(vec![MapPoint::new(1, [0.0, 0.0])]);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 200.0));
let ctx = Context::default();
let mut output = ctx.run_ui(
RawInput {
screen_rect: Some(screen),
..RawInput::default()
},
|ui| {
ui.add(&mut map);
},
);
output.textures_delta.clear();
let fill = output
.shapes
.iter()
.find_map(|cs| match &cs.shape {
Shape::Circle(circle) => Some(circle.fill),
_ => None,
})
.expect("the node must be painted");
assert_eq!(
fill,
Color32::from_rgb(1, 2, 3),
"the node fill must come from the installed MapTheme, in either color mode"
);
}
}