diff --git a/Cargo.lock b/Cargo.lock index d683d90..fc7fbbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,9 +765,9 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "fixed" -version = "1.31.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af2cbf772fa6d1c11358f92ef554cb6b386201210bcf0e91fb7fba8a907fb40" +checksum = "85c6e0b89bf864acd20590dbdbad56f69aeb898abfc9443008fd7bd48b2cc85a" dependencies = [ "az", "bytemuck", diff --git a/docs/keymap.md b/docs/keymap.md index e3063a8..9972a33 100644 --- a/docs/keymap.md +++ b/docs/keymap.md @@ -4,19 +4,19 @@ | Key | Action | |---------------------|-----------------------------| -| `q`, `` | Quit | +| `` | Quit | | `?` | Toggle keymap popup | | `` | Dismiss popup | | `` / `` | Switch to next/previous tab | - ## World Map | Key | Action | |-----------------------------------------|---------------------------------| | `` | Select the nearest object | | `` | Deselect current object | -| `` / `` | Move the world map left/right | -| `[` / `]` | Move the world map left/right | +| `W` / `A` / `S` / `D` | Move the world map | +| `E` / `+` | Zoom the world map in | +| `Q` / `-` | Zoom the world map out | | `f` | Toggle object tracking (follow) | | `t` | Toggle day-night terminator | @@ -24,5 +24,16 @@ | Key | Action | |-----------------------------------------|------------------------------| +| `` / `` | Adjust time backward/forward | | `` / `` | Adjust time backward/forward | | `r` | Reset time offset | + +## Satellite Groups + +| Key | Action | +|-----------------------------------------|------------------------------| +| `` / `` | Move selection up/down | +| `/` | Search inside groups | +| `` / `` | Select/deselect group | +| `` / `` | Move selection up/down | + diff --git a/locales/de.yml b/locales/de.yml index 40ebb87..ef36b36 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -46,3 +46,7 @@ keymap.follow: "Objektverfolgung umschalten" keymap.terminator: "Tag-Nacht-Grenze umschalten" keymap.adjust_time: "Zeit zurück/vor" keymap.reset_time: "Zeitversatz zurücksetzen" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/locales/en.yml b/locales/en.yml index c092c25..dffc601 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -3,7 +3,7 @@ _version: 1 map.title: "World map" map.follow: "Follow" # Satellite groups -group.title: "Satellite groups" +group.title: "Satellite Groups" # Information info.title: "Info" info.name: "Name" @@ -35,13 +35,18 @@ keymap.title: "Keymap" keymap.global: "Global" keymap.world_map: "World Map" keymap.timeline: "Timeline" +keymap.satellite_groups: "Satellite Groups" keymap.quit: "Quit" keymap.help: "Toggle keymap popup" keymap.dismiss: "Dismiss popup" keymap.switch_tab: "Switch to next/previous tab" keymap.select: "Select the nearest object" keymap.deselect: "Deselect current object" -keymap.map_move: "Move the world map left/right" +keymap.map_move: "Move the world map" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" keymap.follow: "Toggle object tracking (follow)" keymap.terminator: "Toggle day-night terminator" keymap.adjust_time: "Adjust time backward/forward" diff --git a/locales/es.yml b/locales/es.yml index 9599b38..ae9beaa 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -46,3 +46,7 @@ keymap.follow: "Alternar seguimiento de objeto" keymap.terminator: "Alternar terminador día-noche" keymap.adjust_time: "Retroceder/avanzar tiempo" keymap.reset_time: "Restablecer desfase de tiempo" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/locales/fr.yml b/locales/fr.yml index e5e2dea..ce922b6 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -46,3 +46,7 @@ keymap.follow: "Basculer le suivi d'objet" keymap.terminator: "Basculer le terminateur jour-nuit" keymap.adjust_time: "Reculer/avancer le temps" keymap.reset_time: "Réinitialiser le décalage horaire" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/locales/ja.yml b/locales/ja.yml index e0d5489..9756085 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -46,3 +46,7 @@ keymap.follow: "オブジェクト追跡を切り替え" keymap.terminator: "昼夜境界線を切り替え" keymap.adjust_time: "時間を戻す/進める" keymap.reset_time: "時間オフセットをリセット" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/locales/ru.yml b/locales/ru.yml index 04da119..0f47bb2 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -46,3 +46,7 @@ keymap.follow: "Переключить отслеживание объекта" keymap.terminator: "Переключить терминатор день-ночь" keymap.adjust_time: "Время назад/вперёд" keymap.reset_time: "Сбросить смещение времени" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/locales/zh.yml b/locales/zh.yml index 721c077..94ddacd 100644 --- a/locales/zh.yml +++ b/locales/zh.yml @@ -46,3 +46,7 @@ keymap.follow: "切换目标跟随" keymap.terminator: "切换昼夜分界线" keymap.adjust_time: "时间后退/前进" keymap.reset_time: "重置时间偏移" +keymap.map_zoom: "Zoom the world map in/out" +keymap.group_move: "Move selection up/down" +keymap.group_search: "Search inside groups" +keymap.group_select: "Select/deselect group" diff --git a/src/app.rs b/src/app.rs index b434252..67cdf53 100644 --- a/src/app.rs +++ b/src/app.rs @@ -128,10 +128,6 @@ impl App { fn handle_key_events(&mut self, event: KeyEvent) { match event.code { - // Exit application on `Q`. - KeyCode::Char('q') => { - self.request_exit(); - } // Exit application on `Ctrl-C`. KeyCode::Char('c') if event.modifiers == KeyModifiers::CONTROL => { self.request_exit(); diff --git a/src/utils.rs b/src/utils.rs index 14d9ce5..27b0d25 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -86,7 +86,7 @@ pub fn subsolar_point(time: &DateTime) -> (f64, f64) { /// A vector of `(longitude, latitude)` pairs in degrees, representing the /// terminator line. pub fn calculate_terminator(time: &DateTime) -> Vec<(f64, f64)> { - const LON_STEP: usize = 5; + const LON_STEP: usize = 1; let (sub_lon, decl) = subsolar_point(time); let mut points = Vec::with_capacity(361 / LON_STEP); @@ -119,7 +119,7 @@ pub fn calculate_ground_track(object: &Object, time: &DateTime) -> Vec<(f64 /// /// See . pub fn calculate_visibility_area(position: &Lla) -> Vec<(f64, f64)> { - const AZIMUTH_STEP: usize = 10; + const AZIMUTH_STEP: usize = 2; let lat0_rad = position.lat.to_radians(); let lon0_rad = position.lon.to_radians(); diff --git a/src/widgets/keymap.rs b/src/widgets/keymap.rs index 4f37804..96f5074 100644 --- a/src/widgets/keymap.rs +++ b/src/widgets/keymap.rs @@ -7,7 +7,7 @@ use unicode_width::UnicodeWidthStr; // Global const GLOBAL_BINDINGS: &[(&str, &str)] = &[ - ("q, ", "keymap.quit"), + ("", "keymap.quit"), ("?", "keymap.help"), ("", "keymap.dismiss"), (" / ", "keymap.switch_tab"), @@ -17,22 +17,34 @@ const GLOBAL_BINDINGS: &[(&str, &str)] = &[ const MAP_BINDINGS: &[(&str, &str)] = &[ ("", "keymap.select"), ("", "keymap.deselect"), - (" / ", "keymap.map_move"), - ("[ / ]", "keymap.map_move"), + ("W / A / S / D", "keymap.map_move"), + ("E / +", "keymap.map_zoom"), + ("Q / -", "keymap.map_zoom"), ("f", "keymap.follow"), ("t", "keymap.terminator"), ]; // Timeline const TIMELINE_BINDINGS: &[(&str, &str)] = &[ + (" / ", "keymap.adjust_time"), (" / ", "keymap.adjust_time"), ("r", "keymap.reset_time"), ]; +// Satellite Groups +const GROUP_BINDINGS: &[(&str, &str)] = &[ + (" / ", "keymap.group_move"), + ("/", "keymap.group_search"), + ("", "keymap.group_select"), + ("", "keymap.group_select"), + (" / ", "keymap.group_move"), +]; + const SECTIONS: &[(&str, &[(&str, &str)])] = &[ ("keymap.global", GLOBAL_BINDINGS), ("keymap.world_map", MAP_BINDINGS), ("keymap.timeline", TIMELINE_BINDINGS), + ("group.title", GROUP_BINDINGS), ]; pub struct Keymap; diff --git a/src/widgets/satellite_groups.rs b/src/widgets/satellite_groups.rs index eba8aab..4a71201 100644 --- a/src/widgets/satellite_groups.rs +++ b/src/widgets/satellite_groups.rs @@ -5,10 +5,10 @@ use tokio::{sync::mpsc, task::AbortHandle}; use crate::{ app::States, config::SatelliteGroupsConfig, event::Event, group::Group, object::Object, - widgets::window_to_area, + shared_state::SharedState, widgets::window_to_area, }; use anyhow::Result; -use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ prelude::*, style::Styled, @@ -27,6 +27,12 @@ pub struct SatelliteGroupsState { list_entries: Vec, /// The current state of the list widget. list_state: ListState, + + /// The current search query. + search_query: String, + /// Whether the search mode is active. + is_searching: bool, + /// Timestamp of the last orbital elements update. last_update_instant: Instant, /// Duration that cached orbital elements remain valid before requiring a @@ -50,7 +56,7 @@ impl SatelliteGroupsState { .map(Group::from) .map(Entry::from) .collect(), - cache_lifetime: Duration::from_mins(config.cache_lifetime_mins), + cache_lifetime: Duration::from_secs(config.cache_lifetime_mins * 60), ..Self::default() } } @@ -112,15 +118,67 @@ impl SatelliteGroupsState { } fn scroll_up(&mut self) { - *self.list_state.offset_mut() = self.list_state.offset().saturating_sub(1); + let indices = self.filtered_indices(); + if indices.is_empty() { + self.list_state.select(None); + return; + } + let current = self.list_state.selected().unwrap_or(0); + let next = current.saturating_sub(1); + self.list_state.select(Some(next)); } fn scroll_down(&mut self) { - *self.list_state.offset_mut() = (self.list_state.offset() + 1).min(self.max_offset()); + let indices = self.filtered_indices(); + if indices.is_empty() { + self.list_state.select(None); + return; + } + let current = self.list_state.selected().unwrap_or(0); + let next = (current + 1).min(indices.len().saturating_sub(1)); + self.list_state.select(Some(next)); } - fn max_offset(&self) -> usize { + fn toggle_selected(&mut self, shared: &mut SharedState) { + let indices = self.filtered_indices(); + let Some(selected_index) = self.list_state.selected() else { + return; + }; + let actual_index = indices[selected_index]; + + let was_selected = self.list_entries[actual_index].selected; + self.list_entries[actual_index].selected = !was_selected; + shared.selected_object = None; + + if was_selected { + // Deselecting: cancel if loading + self.cancel_entry_loading(actual_index); + shared.objects.clear(); + self.reload_selected_entries(); + } else { + // Selecting: start loading + self.load_entry(actual_index); + } + } + + fn filtered_indices(&self) -> Vec { self.list_entries + .iter() + .enumerate() + .filter(|(_, entry)| { + self.search_query.is_empty() + || entry + .group + .label() + .to_lowercase() + .contains(&self.search_query.to_lowercase()) + }) + .map(|(i, _)| i) + .collect() + } + + fn max_offset(&self) -> usize { + self.filtered_indices() .len() .saturating_sub(self.inner_area.height as usize) } @@ -132,6 +190,8 @@ impl Default for SatelliteGroupsState { Self { list_entries: Default::default(), list_state: Default::default(), + search_query: String::new(), + is_searching: false, inner_area: Default::default(), cache_lifetime: Default::default(), last_update_instant: Instant::now(), @@ -143,7 +203,7 @@ impl Default for SatelliteGroupsState { impl Widget for SatelliteGroups<'_> { fn render(mut self, area: Rect, buf: &mut Buffer) { - let block = Self::block(); + let block = self.block(); self.state.inner_area = block.inner(area); block.render(area, buf); @@ -153,12 +213,18 @@ impl Widget for SatelliteGroups<'_> { } impl SatelliteGroups<'_> { - fn block() -> Block<'static> { - Block::bordered().title(t!("group.title").to_string().blue()) + fn block(&self) -> Block<'static> { + let mut title = t!("group.title").to_string(); + if self.state.is_searching { + title = format!("{}: {}", title, self.state.search_query); + } + Block::bordered().title(title.blue()) } fn list(&self) -> List<'static> { - let items = self.state.list_entries.iter().map(|entry| { + let indices = self.state.filtered_indices(); + let items = indices.into_iter().map(|index| { + let entry = &self.state.list_entries[index]; let icon = if entry.loading { "⋯" } else if entry.selected { @@ -233,6 +299,7 @@ pub fn handle_event(event: Event, states: &mut States) -> Result<()> { handle_update_event(states); Ok(()) } + Event::Key(event) => handle_key_event(event, states), Event::Mouse(event) => handle_mouse_event(event, states), _ => Ok(()), } @@ -254,6 +321,38 @@ fn handle_update_event(states: &mut States) { } } +fn handle_key_event(event: KeyEvent, states: &mut States) -> Result<()> { + let state = &mut states.satellite_groups_state; + + if state.is_searching { + match event.code { + KeyCode::Esc | KeyCode::Enter => { + state.is_searching = false; + } + KeyCode::Backspace => { + state.search_query.pop(); + } + KeyCode::Char(c) => { + state.search_query.push(c); + } + _ => {} + } + } else { + match event.code { + KeyCode::Char('/') => { + state.is_searching = true; + state.search_query.clear(); + } + KeyCode::Up => state.scroll_up(), + KeyCode::Down => state.scroll_down(), + KeyCode::Char(' ') => state.toggle_selected(&mut states.shared), + _ => {} + } + } + + Ok(()) +} + fn handle_mouse_event(event: MouseEvent, states: &mut States) -> Result<()> { let state = &mut states.satellite_groups_state; @@ -265,22 +364,7 @@ fn handle_mouse_event(event: MouseEvent, states: &mut States) -> Result<()> { match event.kind { MouseEventKind::Down(MouseButton::Left) => { - // Toggle selection of the clicked entry. - if let Some(index) = state.list_state.selected() { - let was_selected = state.list_entries[index].selected; - state.list_entries[index].selected = !was_selected; - states.shared.selected_object = None; - - if was_selected { - // Deselecting: cancel if loading - state.cancel_entry_loading(index); - states.shared.objects.clear(); - state.reload_selected_entries(); - } else { - // Selecting: start loading - state.load_entry(index); - } - } + state.toggle_selected(&mut states.shared); } MouseEventKind::ScrollUp => state.scroll_up(), MouseEventKind::ScrollDown => state.scroll_down(), @@ -289,7 +373,7 @@ fn handle_mouse_event(event: MouseEvent, states: &mut States) -> Result<()> { // Highlight the hovered entry. let row = local_mouse.y as usize + state.list_state.offset(); - let index = if row < state.list_entries.len() { + let index = if row < state.filtered_indices().len() { Some(row) } else { None diff --git a/src/widgets/timeline.rs b/src/widgets/timeline.rs index 1dcbe7a..d2e75f2 100644 --- a/src/widgets/timeline.rs +++ b/src/widgets/timeline.rs @@ -181,8 +181,11 @@ pub fn handle_event(event: Event, states: &mut States) -> Result<()> { } fn handle_key_event(event: KeyEvent, states: &mut States) -> Result<()> { - if let KeyCode::Char('r') = event.code { - states.shared.time.set_time_offset(chrono::Duration::zero()) + match event.code { + KeyCode::Char('r') => states.shared.time.set_time_offset(chrono::Duration::zero()), + KeyCode::Left => states.shared.time.rewind_time(states.timeline_state.time_delta), + KeyCode::Right => states.shared.time.advance_time(states.timeline_state.time_delta), + _ => {} } Ok(()) diff --git a/src/widgets/world_map.rs b/src/widgets/world_map.rs index e9d3fd2..793adc3 100644 --- a/src/widgets/world_map.rs +++ b/src/widgets/world_map.rs @@ -27,9 +27,16 @@ pub struct WorldMap<'a> { pub struct WorldMapState { /// Center longitude offset for horizontal map scrolling in degrees. lon_offset: f64, + /// Center latitude offset for vertical map scrolling in degrees. + lat_offset: f64, /// The amount of longitude (in degrees) to move the map when scrolling left /// or right. lon_delta: f64, + /// The amount of latitude (in degrees) to move the map when scrolling up + /// or down. + lat_delta: f64, + /// Zoom level. 1.0 is default (360x180). + zoom: f64, /// Whether to follow the selected object by adjusting the map longitude. follow_object: bool, @@ -58,6 +65,8 @@ impl WorldMapState { show_terminator: config.show_terminator, show_visibility_area: config.show_visibility_area, lon_delta: config.lon_delta_deg, + lat_delta: config.lon_delta_deg, + zoom: 1.0, map_color: config.map_color, trajectory_color: config.trajectory_color, terminator_color: config.terminator_color, @@ -68,12 +77,36 @@ impl WorldMapState { /// Scrolls the map view to the left. fn scroll_map_left(&mut self) { - self.lon_offset = wrap_longitude_deg(self.lon_offset - self.lon_delta); + self.lon_offset = wrap_longitude_deg(self.lon_offset - self.lon_delta / self.zoom); } /// Scrolls the map view to the right. fn scroll_map_right(&mut self) { - self.lon_offset = wrap_longitude_deg(self.lon_offset + self.lon_delta); + self.lon_offset = wrap_longitude_deg(self.lon_offset + self.lon_delta / self.zoom); + } + + /// Scrolls the map view up. + fn scroll_map_up(&mut self) { + self.lat_offset = (self.lat_offset + self.lat_delta / self.zoom).min(90.0); + } + + /// Scrolls the map view down. + fn scroll_map_down(&mut self) { + self.lat_offset = (self.lat_offset - self.lat_delta / self.zoom).max(-90.0); + } + + /// Zooms in the map. + fn zoom_in(&mut self) { + self.zoom *= 1.2; + } + + /// Zooms out the map. + fn zoom_out(&mut self) { + self.zoom = (self.zoom / 1.2).max(1.0); + // Reset offsets if zoomed all the way out to prevent weird behavior + if self.zoom <= 1.0 { + self.lat_offset = 0.0; + } } } @@ -126,38 +159,61 @@ impl WorldMap<'_> { wrap_longitude_deg(object_state.longitude() - self.state.lon_offset) * self.state.follow_smoothing; self.state.lon_offset = wrap_longitude_deg(self.state.lon_offset); + + self.state.lat_offset += + (object_state.latitude() - self.state.lat_offset) * self.state.follow_smoothing; } - let x_min = self.state.lon_offset - 180.0; - let x_max = self.state.lon_offset + 180.0; + let lon_span = 360.0 / self.state.zoom; + let lat_span = 180.0 / self.state.zoom; + + let x_min = self.state.lon_offset - lon_span / 2.0; + let x_max = self.state.lon_offset + lon_span / 2.0; + + let mut y_min = self.state.lat_offset - lat_span / 2.0; + let mut y_max = self.state.lat_offset + lat_span / 2.0; + + // Clamp latitude bounds + if y_min < -90.0 { + y_max += -90.0 - y_min; + y_min = -90.0; + } + if y_max > 90.0 { + y_min -= y_max - 90.0; + y_max = 90.0; + } + y_min = y_min.max(-90.0); + y_max = y_max.min(90.0); + + let y_bounds = [y_min, y_max]; // Adjust the rendering order to prevent the labels on the left map from being // covered by the right map let mut bounds_vec = Vec::new(); if x_min < -180.0 { - bounds_vec.push([x_min, x_max]); // Left side - bounds_vec.push([x_max, x_max + 360.0]); // Right side + bounds_vec.push([x_min, x_max]); + bounds_vec.push([x_min + 360.0, x_max + 360.0]); } else if x_max > 180.0 { - bounds_vec.push([-360.0 + x_min, x_min]); // Left side - bounds_vec.push([x_min, x_max]); // Right side + bounds_vec.push([x_min, x_max]); + bounds_vec.push([x_min - 360.0, x_max - 360.0]); } else { bounds_vec.push([x_min, x_max]); } for bounds in &bounds_vec { - self.render_bottom_layer(buf, *bounds); + self.render_bottom_layer(buf, *bounds, y_bounds); } for bounds in &bounds_vec { - self.render_top_layer(buf, *bounds); + self.render_top_layer(buf, *bounds, y_bounds); } } /// Renders the bottom layer of the world map, including the map and all /// objects. - fn render_bottom_layer(&self, buf: &mut Buffer, x_bounds: [f64; 2]) { + fn render_bottom_layer(&self, buf: &mut Buffer, x_bounds: [f64; 2], y_bounds: [f64; 2]) { Canvas::default() .x_bounds(x_bounds) - .y_bounds([-90.0, 90.0]) + .y_bounds(y_bounds) .paint(|ctx| { ctx.draw(&Map { color: self.state.map_color, @@ -174,10 +230,10 @@ impl WorldMap<'_> { /// Renders the top layer of the world map, including object highlights and /// trajectories. - fn render_top_layer(&self, buf: &mut Buffer, x_bounds: [f64; 2]) { + fn render_top_layer(&self, buf: &mut Buffer, x_bounds: [f64; 2], y_bounds: [f64; 2]) { Canvas::default() .x_bounds(x_bounds) - .y_bounds([-90.0, 90.0]) + .y_bounds(y_bounds) .paint(|ctx| { self.draw_object_highlight(ctx); if self.state.show_visibility_area { @@ -199,7 +255,8 @@ impl WorldMap<'_> { // Mark the subsolar point let (sub_lon, sub_lat) = subsolar_point(&self.shared.time.time()); - ctx.print( + Self::draw_text( + ctx, sub_lon.to_degrees(), sub_lat.to_degrees(), Self::SUBSOLAR_SYMBOL.yellow().bold(), @@ -225,7 +282,7 @@ impl WorldMap<'_> { }) .collect::>() { - ctx.print(state.longitude(), state.latitude(), text); + Self::draw_text(ctx, state.longitude(), state.latitude(), text); } } @@ -244,7 +301,7 @@ impl WorldMap<'_> { let text = Self::OBJECT_SYMBOL.light_green().slow_blink() + format!(" {object_name}").white(); let object_state = selected.predict(&self.shared.time.time()).unwrap(); - ctx.print(object_state.longitude(), object_state.latitude(), text); + Self::draw_text(ctx, object_state.longitude(), object_state.latitude(), text); } else if let Some(hovered) = &self.shared.hovered_object { // Highlight the hovered object let object_name = hovered.name().unwrap_or(Self::UNKNOWN_NAME); @@ -252,7 +309,7 @@ impl WorldMap<'_> { + " ".into() + object_name.to_string().white().reversed(); let object_state = hovered.predict(&self.shared.time.time()).unwrap(); - ctx.print(object_state.longitude(), object_state.latitude(), text); + Self::draw_text(ctx, object_state.longitude(), object_state.latitude(), text); } } @@ -270,31 +327,58 @@ impl WorldMap<'_> { let Some(ground_station) = &self.shared.ground_station else { return; }; - ctx.print( + Self::draw_text( + ctx, ground_station.position.lon, ground_station.position.lat, "*".light_cyan().bold() + format!(" {}", ground_station.name).light_cyan(), ); } + /// Draws text at multiple offsets to handle map wrapping. + fn draw_text<'a, T>(ctx: &mut Context<'a>, x: f64, y: f64, text: T) + where + T: Into> + Clone, + { + for offset in [-360.0, 0.0, 360.0] { + ctx.print(x + offset, y, text.clone()); + } + } + /// Draws lines between points. fn draw_lines(ctx: &mut Context, points: Vec<(f64, f64)>, color: Color) { - for window in points.windows(2) { - Self::draw_line(ctx, window[0], window[1], color); + if points.is_empty() { + return; + } + + let mut p1 = points[0]; + for &p2 in &points[1..] { + let mut p2_adj = p2; + let lon_diff = p2_adj.0 - p1.0; + + if lon_diff.abs() > 180.0 { + // Handle IDL crossing by making the longitude continuous + p2_adj.0 -= 360.0 * lon_diff.signum(); + Self::draw_line(ctx, p1, p2_adj, color); + } else if lon_diff.abs() > 90.0 && (p1.1.abs() > 70.0 || p2.1.abs() > 70.0) { + // Handle pole crossing: split the line at the pole to avoid a straight + // horizontal line across the map. + let y_pole = if p1.1 > 0.0 { 90.0 } else { -90.0 }; + Self::draw_line(ctx, p1, (p1.0, y_pole), color); + Self::draw_line(ctx, (p2.0, y_pole), p2, color); + } else { + Self::draw_line(ctx, p1, p2, color); + } + p1 = p2_adj; } } - /// Draws a line between two points. + /// Draws a line between two points, repeating it at 360-degree offsets to + /// handle map wrapping and zooming correctly. fn draw_line(ctx: &mut Context, (x1, y1): (f64, f64), (x2, y2): (f64, f64), color: Color) { - // Handle trajectory crossing the international date line - if (x1 - x2).abs() >= 180.0 { - let x_edge = if x1 > 0.0 { 180.0 } else { -180.0 }; - let y_midpoint = (y1 + y2) / 2.0; - ctx.draw(&canvas::Line::new(x1, y1, x_edge, y_midpoint, color)); - ctx.draw(&canvas::Line::new(-x_edge, y_midpoint, x2, y2, color)); - return; + for offset in [-360.0, 0.0, 360.0] { + ctx.draw(&canvas::Line::new(x1 + offset, y1, x2 + offset, y2, color)); } - ctx.draw(&canvas::Line::new(x1, y1, x2, y2, color)); } } @@ -308,8 +392,16 @@ pub fn handle_event(event: Event, states: &mut States) -> Result<()> { fn handle_key_event(event: KeyEvent, states: &mut States) -> Result<()> { match event.code { - KeyCode::Char('[') => states.world_map_state.scroll_map_left(), - KeyCode::Char(']') => states.world_map_state.scroll_map_right(), + KeyCode::Char('a') => states.world_map_state.scroll_map_left(), + KeyCode::Char('d') => states.world_map_state.scroll_map_right(), + KeyCode::Char('w') => states.world_map_state.scroll_map_up(), + KeyCode::Char('s') => states.world_map_state.scroll_map_down(), + KeyCode::Char('e') | KeyCode::Char('+') | KeyCode::Char('=') => { + states.world_map_state.zoom_in() + } + KeyCode::Char('q') | KeyCode::Char('-') | KeyCode::Char('_') => { + states.world_map_state.zoom_out() + } KeyCode::Char('f') => { states.world_map_state.follow_object = !states.world_map_state.follow_object; } @@ -339,12 +431,6 @@ fn handle_mouse_event(event: MouseEvent, states: &mut States) -> Result<()> { MouseEventKind::Down(MouseButton::Right) => { states.shared.selected_object = None; } - MouseEventKind::ScrollUp => { - states.world_map_state.scroll_map_left(); - } - MouseEventKind::ScrollDown => { - states.world_map_state.scroll_map_right(); - } _ => {} } states.shared.hovered_object = @@ -373,31 +459,56 @@ fn get_nearest_object_index( wrap_longitude_deg(state.longitude() - states.world_map_state.lon_offset), state.latitude(), inner_area, + &states.world_map_state, ); - (x as i32 - position.x as i32).abs() + (y as i32 - position.y as i32).abs() * 2 + ((x - position.x as f64).abs() + (y - position.y as f64).abs() * 2.0) as i32 }) .map(|(index, _)| index) } #[expect(dead_code)] /// Converts area coordinates to lon/lat coordinates. -fn area_to_lon_lat(x: u16, y: u16, area: Rect) -> (f64, f64) { +fn area_to_lon_lat(x: u16, y: u16, area: Rect, state: &WorldMapState) -> (f64, f64) { debug_assert!(x < area.width && y < area.height); debug_assert!(area.width > 0 && area.height > 0); + let lon_span = 360.0 / state.zoom; + let lat_span = 180.0 / state.zoom; + let normalized_x = x as f64 / area.width as f64; let normalized_y = y as f64 / area.height as f64; - let lon = -180.0 + normalized_x * 360.0; - let lat = 90.0 - normalized_y * 180.0; + let lon = state.lon_offset - lon_span / 2.0 + normalized_x * lon_span; + let lat = state.lat_offset + lat_span / 2.0 - normalized_y * lat_span; (lon, lat) } /// Converts lon/lat coordinates to area coordinates. -fn lon_lat_to_area(lon: f64, lat: f64, area: Rect) -> (u16, u16) { - debug_assert!((-180.0..=180.0).contains(&lon)); - debug_assert!((-90.0..=90.0).contains(&lat)); +fn lon_lat_to_area(lon_diff: f64, lat: f64, area: Rect, state: &WorldMapState) -> (f64, f64) { + let lon_span = 360.0 / state.zoom; + let lat_span = 180.0 / state.zoom; + + let x = (lon_diff / lon_span + 0.5) * area.width as f64; + + let mut y_min = state.lat_offset - lat_span / 2.0; + let mut y_max = state.lat_offset + lat_span / 2.0; + + if y_min < -90.0 { + y_max += -90.0 - y_min; + y_min = -90.0; + } + if y_max > 90.0 { + y_min -= y_max - 90.0; + y_max = 90.0; + } + y_min = y_min.max(-90.0); + y_max = y_max.min(90.0); + + let y_span = y_max - y_min; + let y = if y_span > 0.0 { + ((y_max - lat) / y_span) * area.height as f64 + } else { + 0.0 + }; - let x = ((lon + 180.0) * area.width as f64 / 360.0) - 1.0; - let y = ((90.0 - lat) * area.height as f64 / 180.0) - 1.0; - (x.round() as u16, y.round() as u16) + (x, y) }