// Application state - viewport, selected train, etc. use crate::geo::GeoLayers; use crate::model::{DelayStatus, Station, Train, TrainState}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputMode { Normal, Search, Layers, } pub struct LayerVisibility { pub coastlines: bool, pub land: bool, pub lakes: bool, pub urban: bool, pub rivers: bool, pub states: bool, pub roads: bool, pub cities: bool, pub routes: bool, pub stations: bool, pub trains: bool, } impl Default for LayerVisibility { fn default() -> Self { Self { coastlines: true, land: true, lakes: true, urban: true, rivers: true, states: true, roads: true, cities: true, routes: true, stations: true, trains: true, } } } pub struct Viewport { pub x_min: f64, // west longitude pub x_max: f64, // east longitude pub y_min: f64, // south latitude pub y_max: f64, // north latitude } impl Viewport { pub fn conus() -> Self { Self { x_min: -130.0, x_max: -60.0, y_min: 22.0, y_max: 52.0, } } pub fn width(&self) -> f64 { self.x_max - self.x_min } pub fn height(&self) -> f64 { self.y_max - self.y_min } pub fn center(&self) -> (f64, f64) { ( (self.x_min + self.x_max) / 2.0, (self.y_min + self.y_max) / 2.0, ) } pub fn zoom_in(&mut self) { // Minimum viewport width ~3 degrees (~200 miles) — beyond this // there's not enough geodata to provide useful context if self.width() < 3.5 { return; } let (cx, cy) = self.center(); let new_w = self.width() * 0.7; let new_h = self.height() * 0.7; self.x_min = cx - new_w / 2.0; self.x_max = cx + new_w / 2.0; self.y_min = cy - new_h / 2.0; self.y_max = cy + new_h / 2.0; } pub fn zoom_out(&mut self) { let (cx, cy) = self.center(); let new_w = self.width() / 0.7; let new_h = self.height() / 0.7; self.x_min = cx - new_w / 2.0; self.x_max = cx + new_w / 2.0; self.y_min = cy - new_h / 2.0; self.y_max = cy + new_h / 2.0; } pub fn pan(&mut self, dx_frac: f64, dy_frac: f64) { let dx = self.width() * dx_frac; let dy = self.height() * dy_frac; self.x_min += dx; self.x_max += dx; self.y_min += dy; self.y_max += dy; } pub fn contains(&self, lon: f64, lat: f64) -> bool { lon >= self.x_min && lon <= self.x_max && lat >= self.y_min && lat <= self.y_max } pub fn center_on(&mut self, lon: f64, lat: f64, width: f64, height: f64) { self.x_min = lon - width / 2.0; self.x_max = lon + width / 2.0; self.y_min = lat - height / 2.0; self.y_max = lat + height / 2.0; } } pub struct App { pub viewport: Viewport, pub trains: Vec, pub stations: Vec, pub geo: Option, pub selected_index: Option, pub selected_station: Option, pub station_mode: bool, pub should_quit: bool, pub last_update: Option, pub status_message: String, pub input_mode: InputMode, pub search_query: String, pub animation_tick: u64, pub layers: LayerVisibility, } impl Default for App { fn default() -> Self { Self::new() } } impl App { pub fn new() -> Self { let geo = GeoLayers::load().ok(); Self { viewport: Viewport::conus(), trains: Vec::new(), stations: Vec::new(), geo, selected_index: None, selected_station: None, station_mode: false, should_quit: false, last_update: None, status_message: "Loading...".into(), input_mode: InputMode::Normal, search_query: String::new(), animation_tick: 0, layers: LayerVisibility::default(), } } pub fn toggle_layer(&mut self, layer: &str) { match layer { "coastlines" => self.layers.coastlines = !self.layers.coastlines, "land" => self.layers.land = !self.layers.land, "lakes" => self.layers.lakes = !self.layers.lakes, "urban" => self.layers.urban = !self.layers.urban, "rivers" => self.layers.rivers = !self.layers.rivers, "states" => self.layers.states = !self.layers.states, "roads" => self.layers.roads = !self.layers.roads, "cities" => self.layers.cities = !self.layers.cities, "routes" => self.layers.routes = !self.layers.routes, "stations" => self.layers.stations = !self.layers.stations, "trains" => self.layers.trains = !self.layers.trains, _ => {} } } fn matches_search(&self, train: &Train) -> bool { if self.search_query.is_empty() { return true; } let q = self.search_query.to_lowercase(); train.number.to_lowercase().contains(&q) || train.route_name.to_lowercase().contains(&q) || train.origin.to_lowercase().contains(&q) || train.destination.to_lowercase().contains(&q) } pub fn visible_trains(&self) -> Vec<&Train> { self.trains .iter() .filter(|t| t.state == TrainState::Active) .filter(|t| self.search_query.is_empty() || self.matches_search(t)) .filter(|t| self.viewport.contains(t.lon, t.lat)) .collect() } pub fn selected_train(&self) -> Option<&Train> { let visible = self.visible_trains(); self.selected_index.and_then(|i| visible.get(i).copied()) } pub fn select_next(&mut self) { let count = self.visible_trains().len(); if count == 0 { self.selected_index = None; return; } self.selected_index = Some(match self.selected_index { None => 0, Some(i) => (i + 1) % count, }); } pub fn select_prev(&mut self) { let count = self.visible_trains().len(); if count == 0 { self.selected_index = None; return; } self.selected_index = Some(match self.selected_index { None => count - 1, Some(0) => count - 1, Some(i) => i - 1, }); } pub fn active_train_count(&self) -> usize { self.trains .iter() .filter(|t| t.state == TrainState::Active) .count() } pub fn delayed_train_count(&self) -> usize { self.trains .iter() .filter(|t| t.state == TrainState::Active) .filter(|t| t.delay_status() == DelayStatus::Late) .count() } pub fn jump_to_selected(&mut self) { let visible = self.visible_trains(); if let Some(train) = self.selected_index.and_then(|i| visible.get(i).copied()) { let lon = train.lon; let lat = train.lat; // Zoom to roughly a 15x10 degree window centered on the train self.viewport.center_on(lon, lat, 15.0, 10.0); } } pub fn enter_search(&mut self) { self.input_mode = InputMode::Search; self.search_query.clear(); } pub fn exit_search(&mut self) { self.input_mode = InputMode::Normal; self.search_query.clear(); self.selected_index = None; } pub fn search_push(&mut self, c: char) { self.search_query.push(c); self.selected_index = None; } pub fn search_pop(&mut self) { self.search_query.pop(); self.selected_index = None; } pub fn tick(&mut self) { self.animation_tick = self.animation_tick.wrapping_add(1); } pub fn is_train_bright(&self, velocity: f64) -> bool { if velocity == 0.0 { return false; } let period = if velocity < 20.0 { 8 } else if velocity < 60.0 { 4 } else { 2 }; self.animation_tick.is_multiple_of(period) } pub fn zoom_detail_level(&self) -> u32 { let w = self.viewport.width(); if w > 50.0 { 4 } else if w > 30.0 { 5 } else if w > 15.0 { 6 } else { 7 // show all stations } } pub fn visible_stations(&self) -> Vec<&Station> { let level = self.zoom_detail_level(); self.stations .iter() .filter(|s| s.zoom_level <= level) .filter(|s| self.viewport.contains(s.lon, s.lat)) .collect() } pub fn trains_at_station(&self, station_code: &str) -> Vec<&Train> { self.trains .iter() .filter(|t| t.state == TrainState::Active) .filter(|t| { t.origin == station_code || t.destination == station_code || t.stations.iter().any(|s| s.code == station_code) }) .collect() } pub fn toggle_station_mode(&mut self) { self.station_mode = !self.station_mode; if self.station_mode { let visible = self.visible_stations(); if !visible.is_empty() { self.selected_station = Some(0); } } else { self.selected_station = None; } } pub fn select_next_station(&mut self) { let count = self.visible_stations().len(); if count == 0 { self.selected_station = None; return; } self.selected_station = Some(match self.selected_station { None => 0, Some(i) => (i + 1) % count, }); } pub fn select_prev_station(&mut self) { let count = self.visible_stations().len(); if count == 0 { self.selected_station = None; return; } self.selected_station = Some(match self.selected_station { None => count - 1, Some(0) => count - 1, Some(i) => i - 1, }); } pub fn reset_view(&mut self) { self.viewport = Viewport::conus(); self.selected_index = None; self.selected_station = None; self.station_mode = false; self.search_query.clear(); self.input_mode = InputMode::Normal; self.layers = LayerVisibility::default(); } } #[cfg(test)] mod tests { use super::*; fn make_train(num: &str, lon: f64, lat: f64, state: TrainState) -> Train { make_train_named(num, "Test", lon, lat, state) } fn make_train_named(num: &str, route: &str, lon: f64, lat: f64, state: TrainState) -> Train { Train { number: num.into(), route_name: route.into(), origin: "A".into(), destination: "B".into(), lon, lat, velocity: 50.0, heading: "N".into(), state, status_msg: String::new(), stations: vec![], } } // --- Viewport --- #[test] fn test_viewport_conus_defaults() { let vp = Viewport::conus(); assert!(vp.width() > 60.0); // US is about 70 degrees wide assert!(vp.height() > 25.0); let (cx, cy) = vp.center(); assert!(cx < -80.0 && cx > -110.0); // roughly center of US assert!(cy > 30.0 && cy < 45.0); } #[test] fn test_viewport_zoom_in_shrinks_bounds() { let mut vp = Viewport::conus(); let old_w = vp.width(); let old_h = vp.height(); let old_center = vp.center(); vp.zoom_in(); assert!(vp.width() < old_w); assert!(vp.height() < old_h); // Center should stay the same let new_center = vp.center(); assert!((new_center.0 - old_center.0).abs() < 0.001); assert!((new_center.1 - old_center.1).abs() < 0.001); } #[test] fn test_viewport_zoom_out_expands_bounds() { let mut vp = Viewport::conus(); let old_w = vp.width(); vp.zoom_out(); assert!(vp.width() > old_w); } #[test] fn test_viewport_pan() { let mut vp = Viewport::conus(); let old_center = vp.center(); vp.pan(0.1, 0.0); // pan right 10% let new_center = vp.center(); assert!(new_center.0 > old_center.0); assert!((new_center.1 - old_center.1).abs() < 0.001); } #[test] fn test_viewport_contains() { let vp = Viewport::conus(); // Chicago should be visible assert!(vp.contains(-87.6, 41.8)); // London should not assert!(!vp.contains(0.0, 51.5)); } // --- App --- #[test] fn test_visible_trains_filters_by_viewport_and_state() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), // Chicago - visible make_train("2", 0.0, 51.5, TrainState::Active), // London - out of bounds make_train("3", -90.0, 38.0, TrainState::Completed), // STL completed - filtered make_train("4", -118.0, 34.0, TrainState::Active), // LA - visible ]; let visible = app.visible_trains(); assert_eq!(visible.len(), 2); assert_eq!(visible[0].number, "1"); assert_eq!(visible[1].number, "4"); } #[test] fn test_select_next_wraps() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), make_train("2", -90.0, 38.0, TrainState::Active), ]; assert_eq!(app.selected_index, None); app.select_next(); assert_eq!(app.selected_index, Some(0)); app.select_next(); assert_eq!(app.selected_index, Some(1)); app.select_next(); assert_eq!(app.selected_index, Some(0)); // wraps } #[test] fn test_select_prev_wraps() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), make_train("2", -90.0, 38.0, TrainState::Active), ]; app.select_prev(); assert_eq!(app.selected_index, Some(1)); // starts from end app.select_prev(); assert_eq!(app.selected_index, Some(0)); app.select_prev(); assert_eq!(app.selected_index, Some(1)); // wraps } #[test] fn test_train_counts() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), make_train("2", -90.0, 38.0, TrainState::Active), make_train("3", -118.0, 34.0, TrainState::Completed), ]; assert_eq!(app.active_train_count(), 2); } // --- Jump to train --- #[test] fn test_jump_to_train_centers_viewport() { let mut app = App::new(); app.trains = vec![ make_train("785", -118.6, 34.2, TrainState::Active), // LA area make_train("316", -90.2, 38.6, TrainState::Active), // St Louis ]; app.selected_index = Some(0); app.jump_to_selected(); let (cx, cy) = app.viewport.center(); assert!( (cx - (-118.6)).abs() < 0.1, "center x should be near -118.6, got {}", cx ); assert!( (cy - 34.2).abs() < 0.1, "center y should be near 34.2, got {}", cy ); // Should have zoomed in (smaller than CONUS default of 70 degrees) assert!( app.viewport.width() < 30.0, "should be zoomed in, width={}", app.viewport.width() ); } #[test] fn test_jump_to_train_no_selection_is_noop() { let mut app = App::new(); app.trains = vec![make_train("1", -87.6, 41.8, TrainState::Active)]; let old_center = app.viewport.center(); app.jump_to_selected(); // no selection assert_eq!(app.viewport.center(), old_center); } // --- Input mode --- #[test] fn test_input_mode_starts_normal() { let app = App::new(); assert_eq!(app.input_mode, InputMode::Normal); } #[test] fn test_enter_search_mode() { let mut app = App::new(); app.enter_search(); assert_eq!(app.input_mode, InputMode::Search); assert_eq!(app.search_query, ""); } #[test] fn test_exit_search_clears_query() { let mut app = App::new(); app.enter_search(); app.search_query = "acela".into(); app.exit_search(); assert_eq!(app.input_mode, InputMode::Normal); assert_eq!(app.search_query, ""); } // --- Search filtering --- #[test] fn test_search_filters_by_train_number() { let mut app = App::new(); app.trains = vec![ make_train("785", -118.6, 34.2, TrainState::Active), make_train("316", -90.2, 38.6, TrainState::Active), make_train("2121", -87.6, 41.8, TrainState::Active), ]; app.search_query = "78".into(); let visible = app.visible_trains(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].number, "785"); } #[test] fn test_search_filters_by_route_name_case_insensitive() { let mut app = App::new(); app.trains = vec![ make_train_named("785", "Pacific Surfliner", -118.6, 34.2, TrainState::Active), make_train_named( "316", "Missouri River Runner", -90.2, 38.6, TrainState::Active, ), make_train_named("171", "Acela", -73.9, 40.7, TrainState::Active), ]; app.search_query = "acela".into(); let visible = app.visible_trains(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].number, "171"); } #[test] fn test_search_filters_by_origin_dest() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), make_train("2", -90.0, 38.0, TrainState::Active), ]; // Both have origin "A", destination "B" app.search_query = "A".into(); assert_eq!(app.visible_trains().len(), 2); app.search_query = "zzz".into(); assert_eq!(app.visible_trains().len(), 0); } #[test] fn test_empty_search_shows_all() { let mut app = App::new(); app.trains = vec![ make_train("1", -87.6, 41.8, TrainState::Active), make_train("2", -90.0, 38.0, TrainState::Active), ]; app.search_query = "".into(); assert_eq!(app.visible_trains().len(), 2); } #[test] fn test_selection_resets_when_search_changes() { let mut app = App::new(); app.trains = vec![ make_train("785", -118.6, 34.2, TrainState::Active), make_train("316", -90.2, 38.6, TrainState::Active), ]; app.selected_index = Some(1); app.search_push('7'); // type a character assert_eq!( app.selected_index, None, "selection should reset on search change" ); } // --- Animation tick and brightness --- #[test] fn test_tick_increments() { let mut app = App::new(); assert_eq!(app.animation_tick, 0); app.tick(); assert_eq!(app.animation_tick, 1); app.tick(); assert_eq!(app.animation_tick, 2); } #[test] fn test_is_train_bright_stopped() { let app = App::new(); // velocity == 0 is always dim assert!(!app.is_train_bright(0.0)); } #[test] fn test_is_train_bright_slow() { let mut app = App::new(); // velocity < 20: bright every 8 ticks assert!(app.is_train_bright(10.0)); // tick 0 app.tick(); // tick 1 assert!(!app.is_train_bright(10.0)); for _ in 0..7 { app.tick(); } // tick 8 assert!(app.is_train_bright(10.0)); } #[test] fn test_is_train_bright_medium() { let mut app = App::new(); // velocity < 60: bright every 4 ticks assert!(app.is_train_bright(40.0)); // tick 0 app.tick(); assert!(!app.is_train_bright(40.0)); // tick 1 app.tick(); app.tick(); app.tick(); // tick 4 assert!(app.is_train_bright(40.0)); } #[test] fn test_is_train_bright_fast() { let mut app = App::new(); // velocity >= 60: bright every 2 ticks assert!(app.is_train_bright(80.0)); // tick 0 app.tick(); assert!(!app.is_train_bright(80.0)); // tick 1 app.tick(); assert!(app.is_train_bright(80.0)); // tick 2 } // --- Station methods --- #[test] fn test_visible_stations_filters_by_viewport_and_zoom() { let mut app = App::new(); // Default CONUS viewport width is 70, so zoom_detail_level = 4 app.stations = vec![ Station { code: "CHI".into(), name: "Chicago".into(), city: "Chicago".into(), state: "IL".into(), lon: -87.6, lat: 41.8, station_type: "Station".into(), zoom_level: 4, }, Station { code: "ABE".into(), name: "Aberdeen".into(), city: "Aberdeen".into(), state: "MD".into(), lon: -76.2, lat: 39.5, station_type: "Station".into(), zoom_level: 5, // too detailed for level 4 }, Station { code: "LDN".into(), name: "London".into(), city: "London".into(), state: "UK".into(), lon: 0.0, lat: 51.5, station_type: "Station".into(), zoom_level: 4, // out of viewport }, ]; let visible = app.visible_stations(); assert_eq!(visible.len(), 1); assert_eq!(visible[0].code, "CHI"); } #[test] fn test_trains_at_station() { let mut app = App::new(); app.trains = vec![ Train { number: "1".into(), route_name: "Test".into(), origin: "CHI".into(), destination: "NYP".into(), lon: -87.6, lat: 41.8, velocity: 50.0, heading: "E".into(), state: TrainState::Active, status_msg: String::new(), stations: vec![ crate::model::StationStop { code: "CHI".into(), comment: None, }, crate::model::StationStop { code: "TOL".into(), comment: None, }, ], }, Train { number: "2".into(), route_name: "Test2".into(), origin: "NYP".into(), destination: "BOS".into(), lon: -73.9, lat: 40.7, velocity: 60.0, heading: "NE".into(), state: TrainState::Active, status_msg: String::new(), stations: vec![], }, ]; let at_chi = app.trains_at_station("CHI"); assert_eq!(at_chi.len(), 1); assert_eq!(at_chi[0].number, "1"); let at_nyp = app.trains_at_station("NYP"); assert_eq!(at_nyp.len(), 2); // train 1 destination + train 2 origin let at_tol = app.trains_at_station("TOL"); assert_eq!(at_tol.len(), 1); // train 1 has TOL in stations list } #[test] fn test_toggle_station_mode() { let mut app = App::new(); app.stations = vec![Station { code: "CHI".into(), name: "Chicago".into(), city: "Chicago".into(), state: "IL".into(), lon: -87.6, lat: 41.8, station_type: "Station".into(), zoom_level: 4, }]; assert!(!app.station_mode); app.toggle_station_mode(); assert!(app.station_mode); assert_eq!(app.selected_station, Some(0)); app.toggle_station_mode(); assert!(!app.station_mode); assert_eq!(app.selected_station, None); } #[test] fn test_zoom_detail_level() { let mut app = App::new(); // Default CONUS = 70 degrees wide assert_eq!(app.zoom_detail_level(), 4); app.viewport.x_min = -100.0; app.viewport.x_max = -60.0; // width = 40 assert_eq!(app.zoom_detail_level(), 5); app.viewport.x_min = -90.0; app.viewport.x_max = -70.0; // width = 20 assert_eq!(app.zoom_detail_level(), 6); app.viewport.x_min = -90.0; app.viewport.x_max = -80.0; // width = 10 assert_eq!(app.zoom_detail_level(), 7); } #[test] fn test_search_backspace() { let mut app = App::new(); app.search_query = "ace".into(); app.search_pop(); assert_eq!(app.search_query, "ac"); app.search_pop(); app.search_pop(); assert_eq!(app.search_query, ""); app.search_pop(); // should not panic on empty assert_eq!(app.search_query, ""); } // --- Layer visibility --- #[test] fn test_layer_visibility_defaults_all_true() { let layers = LayerVisibility::default(); assert!(layers.coastlines); assert!(layers.land); assert!(layers.lakes); assert!(layers.urban); assert!(layers.rivers); assert!(layers.states); assert!(layers.roads); assert!(layers.cities); assert!(layers.routes); assert!(layers.stations); assert!(layers.trains); } #[test] fn test_toggle_layer() { let mut app = App::new(); assert!(app.layers.lakes); app.toggle_layer("lakes"); assert!(!app.layers.lakes); app.toggle_layer("lakes"); assert!(app.layers.lakes); } #[test] fn test_toggle_all_layers() { let mut app = App::new(); let layer_names = [ "coastlines", "land", "lakes", "urban", "rivers", "states", "roads", "cities", "routes", "stations", "trains", ]; for name in &layer_names { app.toggle_layer(name); } assert!(!app.layers.coastlines); assert!(!app.layers.land); assert!(!app.layers.lakes); assert!(!app.layers.urban); assert!(!app.layers.rivers); assert!(!app.layers.states); assert!(!app.layers.roads); assert!(!app.layers.cities); assert!(!app.layers.routes); assert!(!app.layers.stations); assert!(!app.layers.trains); } #[test] fn test_toggle_unknown_layer_is_noop() { let mut app = App::new(); app.toggle_layer("nonexistent"); // should not panic assert!(app.layers.coastlines); // unchanged } #[test] fn test_reset_view_resets_layers() { let mut app = App::new(); app.toggle_layer("lakes"); app.toggle_layer("roads"); assert!(!app.layers.lakes); assert!(!app.layers.roads); app.reset_view(); assert!(app.layers.lakes); assert!(app.layers.roads); } #[test] fn test_layers_mode() { let mut app = App::new(); app.input_mode = InputMode::Layers; assert_eq!(app.input_mode, InputMode::Layers); } }