a rust tui to view amtrak train status
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Improve map UX: direction carets, stations, search tooltips

- Shrink sidebar from 30% to 18% for better map aspect ratio
- Replace cluttered train number labels with directional carets
(▲▶▼◀ etc.) — only show label on selected train as tooltip
- Speed-based animation: trains pulse at rates proportional to
velocity (stopped=dim, slow=8-tick, medium=4-tick, fast=2-tick)
- Add 561 Amtrak stations to the map with zoom-level filtering
- Station mode (s key): departure-board view showing trains
arriving/departing the selected station
- Fetch and decrypt station data from Amtrak's station API

53 tests passing, zero clippy warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+682 -26
+2
.gitignore
··· 20 20 decrypt.js 21 21 utility.js 22 22 xdr.js 23 + stations_sample.json 24 + __pycache__/
+21 -1
src/api.rs
··· 4 4 use serde::Deserialize; 5 5 6 6 use crate::crypto::{self, DecryptionKeys}; 7 - use crate::model::{self, Train}; 7 + use crate::model::{self, Station, Train}; 8 8 9 9 const TRAINS_URL: &str = "https://maps.amtrak.com/services/MapDataService/trains/getTrainsData"; 10 + const STATIONS_URL: &str = "https://maps.amtrak.com/services/MapDataService/stations/trainStations"; 10 11 const ROUTES_URL: &str = "https://maps.amtrak.com/rttl/js/RoutesList.json"; 11 12 const ROUTES_V_URL: &str = "https://maps.amtrak.com/rttl/js/RoutesList.v.json"; 12 13 ··· 64 65 salt, 65 66 iv, 66 67 }) 68 + } 69 + 70 + pub async fn fetch_stations( 71 + client: &reqwest::Client, 72 + keys: &DecryptionKeys, 73 + ) -> Result<Vec<Station>> { 74 + let encrypted = client 75 + .get(STATIONS_URL) 76 + .send() 77 + .await 78 + .context("failed to fetch station data")? 79 + .text() 80 + .await 81 + .context("failed to read station data response")?; 82 + 83 + let json = crypto::decrypt_train_data(encrypted.trim(), keys) 84 + .context("failed to decrypt station data")?; 85 + 86 + model::parse_station_response(&json) 67 87 } 68 88 69 89 pub async fn fetch_trains(client: &reqwest::Client, keys: &DecryptionKeys) -> Result<Vec<Train>> {
+296 -1
src/app.rs
··· 1 1 // Application state - viewport, selected train, etc. 2 2 3 3 use crate::geo::GeoLayers; 4 - use crate::model::{DelayStatus, Train, TrainState}; 4 + use crate::model::{DelayStatus, Station, Train, TrainState}; 5 5 6 6 #[derive(Debug, Clone, PartialEq, Eq)] 7 7 pub enum InputMode { ··· 85 85 pub struct App { 86 86 pub viewport: Viewport, 87 87 pub trains: Vec<Train>, 88 + pub stations: Vec<Station>, 88 89 pub geo: Option<GeoLayers>, 89 90 pub selected_index: Option<usize>, 91 + pub selected_station: Option<usize>, 92 + pub station_mode: bool, 90 93 pub should_quit: bool, 91 94 pub last_update: Option<std::time::Instant>, 92 95 pub status_message: String, 93 96 pub input_mode: InputMode, 94 97 pub search_query: String, 98 + pub animation_tick: u64, 95 99 } 96 100 97 101 impl Default for App { ··· 106 110 Self { 107 111 viewport: Viewport::conus(), 108 112 trains: Vec::new(), 113 + stations: Vec::new(), 109 114 geo, 110 115 selected_index: None, 116 + selected_station: None, 117 + station_mode: false, 111 118 should_quit: false, 112 119 last_update: None, 113 120 status_message: "Loading...".into(), 114 121 input_mode: InputMode::Normal, 115 122 search_query: String::new(), 123 + animation_tick: 0, 116 124 } 117 125 } 118 126 ··· 212 220 self.selected_index = None; 213 221 } 214 222 223 + pub fn tick(&mut self) { 224 + self.animation_tick = self.animation_tick.wrapping_add(1); 225 + } 226 + 227 + pub fn is_train_bright(&self, velocity: f64) -> bool { 228 + if velocity == 0.0 { 229 + return false; 230 + } 231 + let period = if velocity < 20.0 { 232 + 8 233 + } else if velocity < 60.0 { 234 + 4 235 + } else { 236 + 2 237 + }; 238 + self.animation_tick.is_multiple_of(period) 239 + } 240 + 241 + pub fn zoom_detail_level(&self) -> u32 { 242 + let w = self.viewport.width(); 243 + if w > 40.0 { 244 + 4 245 + } else if w > 20.0 { 246 + 5 247 + } else if w > 10.0 { 248 + 6 249 + } else { 250 + 7 251 + } 252 + } 253 + 254 + pub fn visible_stations(&self) -> Vec<&Station> { 255 + let level = self.zoom_detail_level(); 256 + self.stations 257 + .iter() 258 + .filter(|s| s.zoom_level <= level) 259 + .filter(|s| self.viewport.contains(s.lon, s.lat)) 260 + .collect() 261 + } 262 + 263 + pub fn trains_at_station(&self, station_code: &str) -> Vec<&Train> { 264 + self.trains 265 + .iter() 266 + .filter(|t| t.state == TrainState::Active) 267 + .filter(|t| { 268 + t.origin == station_code 269 + || t.destination == station_code 270 + || t.stations.iter().any(|s| s.code == station_code) 271 + }) 272 + .collect() 273 + } 274 + 275 + pub fn toggle_station_mode(&mut self) { 276 + self.station_mode = !self.station_mode; 277 + if self.station_mode { 278 + let visible = self.visible_stations(); 279 + if !visible.is_empty() { 280 + self.selected_station = Some(0); 281 + } 282 + } else { 283 + self.selected_station = None; 284 + } 285 + } 286 + 287 + pub fn select_next_station(&mut self) { 288 + let count = self.visible_stations().len(); 289 + if count == 0 { 290 + self.selected_station = None; 291 + return; 292 + } 293 + self.selected_station = Some(match self.selected_station { 294 + None => 0, 295 + Some(i) => (i + 1) % count, 296 + }); 297 + } 298 + 299 + pub fn select_prev_station(&mut self) { 300 + let count = self.visible_stations().len(); 301 + if count == 0 { 302 + self.selected_station = None; 303 + return; 304 + } 305 + self.selected_station = Some(match self.selected_station { 306 + None => count - 1, 307 + Some(0) => count - 1, 308 + Some(i) => i - 1, 309 + }); 310 + } 311 + 215 312 pub fn reset_view(&mut self) { 216 313 self.viewport = Viewport::conus(); 217 314 self.selected_index = None; 315 + self.selected_station = None; 316 + self.station_mode = false; 218 317 self.search_query.clear(); 219 318 self.input_mode = InputMode::Normal; 220 319 } ··· 504 603 app.selected_index, None, 505 604 "selection should reset on search change" 506 605 ); 606 + } 607 + 608 + // --- Animation tick and brightness --- 609 + 610 + #[test] 611 + fn test_tick_increments() { 612 + let mut app = App::new(); 613 + assert_eq!(app.animation_tick, 0); 614 + app.tick(); 615 + assert_eq!(app.animation_tick, 1); 616 + app.tick(); 617 + assert_eq!(app.animation_tick, 2); 618 + } 619 + 620 + #[test] 621 + fn test_is_train_bright_stopped() { 622 + let app = App::new(); 623 + // velocity == 0 is always dim 624 + assert!(!app.is_train_bright(0.0)); 625 + } 626 + 627 + #[test] 628 + fn test_is_train_bright_slow() { 629 + let mut app = App::new(); 630 + // velocity < 20: bright every 8 ticks 631 + assert!(app.is_train_bright(10.0)); // tick 0 632 + app.tick(); // tick 1 633 + assert!(!app.is_train_bright(10.0)); 634 + for _ in 0..7 { 635 + app.tick(); 636 + } 637 + // tick 8 638 + assert!(app.is_train_bright(10.0)); 639 + } 640 + 641 + #[test] 642 + fn test_is_train_bright_medium() { 643 + let mut app = App::new(); 644 + // velocity < 60: bright every 4 ticks 645 + assert!(app.is_train_bright(40.0)); // tick 0 646 + app.tick(); 647 + assert!(!app.is_train_bright(40.0)); // tick 1 648 + app.tick(); 649 + app.tick(); 650 + app.tick(); 651 + // tick 4 652 + assert!(app.is_train_bright(40.0)); 653 + } 654 + 655 + #[test] 656 + fn test_is_train_bright_fast() { 657 + let mut app = App::new(); 658 + // velocity >= 60: bright every 2 ticks 659 + assert!(app.is_train_bright(80.0)); // tick 0 660 + app.tick(); 661 + assert!(!app.is_train_bright(80.0)); // tick 1 662 + app.tick(); 663 + assert!(app.is_train_bright(80.0)); // tick 2 664 + } 665 + 666 + // --- Station methods --- 667 + 668 + #[test] 669 + fn test_visible_stations_filters_by_viewport_and_zoom() { 670 + let mut app = App::new(); 671 + // Default CONUS viewport width is 70, so zoom_detail_level = 4 672 + app.stations = vec![ 673 + Station { 674 + code: "CHI".into(), 675 + name: "Chicago".into(), 676 + city: "Chicago".into(), 677 + state: "IL".into(), 678 + lon: -87.6, 679 + lat: 41.8, 680 + station_type: "Station".into(), 681 + zoom_level: 4, 682 + }, 683 + Station { 684 + code: "ABE".into(), 685 + name: "Aberdeen".into(), 686 + city: "Aberdeen".into(), 687 + state: "MD".into(), 688 + lon: -76.2, 689 + lat: 39.5, 690 + station_type: "Station".into(), 691 + zoom_level: 5, // too detailed for level 4 692 + }, 693 + Station { 694 + code: "LDN".into(), 695 + name: "London".into(), 696 + city: "London".into(), 697 + state: "UK".into(), 698 + lon: 0.0, 699 + lat: 51.5, 700 + station_type: "Station".into(), 701 + zoom_level: 4, // out of viewport 702 + }, 703 + ]; 704 + 705 + let visible = app.visible_stations(); 706 + assert_eq!(visible.len(), 1); 707 + assert_eq!(visible[0].code, "CHI"); 708 + } 709 + 710 + #[test] 711 + fn test_trains_at_station() { 712 + let mut app = App::new(); 713 + app.trains = vec![ 714 + Train { 715 + number: "1".into(), 716 + route_name: "Test".into(), 717 + origin: "CHI".into(), 718 + destination: "NYP".into(), 719 + lon: -87.6, 720 + lat: 41.8, 721 + velocity: 50.0, 722 + heading: "E".into(), 723 + state: TrainState::Active, 724 + status_msg: String::new(), 725 + stations: vec![ 726 + crate::model::StationStop { 727 + code: "CHI".into(), 728 + comment: None, 729 + }, 730 + crate::model::StationStop { 731 + code: "TOL".into(), 732 + comment: None, 733 + }, 734 + ], 735 + }, 736 + Train { 737 + number: "2".into(), 738 + route_name: "Test2".into(), 739 + origin: "NYP".into(), 740 + destination: "BOS".into(), 741 + lon: -73.9, 742 + lat: 40.7, 743 + velocity: 60.0, 744 + heading: "NE".into(), 745 + state: TrainState::Active, 746 + status_msg: String::new(), 747 + stations: vec![], 748 + }, 749 + ]; 750 + 751 + let at_chi = app.trains_at_station("CHI"); 752 + assert_eq!(at_chi.len(), 1); 753 + assert_eq!(at_chi[0].number, "1"); 754 + 755 + let at_nyp = app.trains_at_station("NYP"); 756 + assert_eq!(at_nyp.len(), 2); // train 1 destination + train 2 origin 757 + 758 + let at_tol = app.trains_at_station("TOL"); 759 + assert_eq!(at_tol.len(), 1); // train 1 has TOL in stations list 760 + } 761 + 762 + #[test] 763 + fn test_toggle_station_mode() { 764 + let mut app = App::new(); 765 + app.stations = vec![Station { 766 + code: "CHI".into(), 767 + name: "Chicago".into(), 768 + city: "Chicago".into(), 769 + state: "IL".into(), 770 + lon: -87.6, 771 + lat: 41.8, 772 + station_type: "Station".into(), 773 + zoom_level: 4, 774 + }]; 775 + 776 + assert!(!app.station_mode); 777 + app.toggle_station_mode(); 778 + assert!(app.station_mode); 779 + assert_eq!(app.selected_station, Some(0)); 780 + app.toggle_station_mode(); 781 + assert!(!app.station_mode); 782 + assert_eq!(app.selected_station, None); 783 + } 784 + 785 + #[test] 786 + fn test_zoom_detail_level() { 787 + let mut app = App::new(); 788 + // Default CONUS = 70 degrees wide 789 + assert_eq!(app.zoom_detail_level(), 4); 790 + 791 + app.viewport.x_min = -100.0; 792 + app.viewport.x_max = -70.0; // width = 30 793 + assert_eq!(app.zoom_detail_level(), 5); 794 + 795 + app.viewport.x_min = -90.0; 796 + app.viewport.x_max = -75.0; // width = 15 797 + assert_eq!(app.zoom_detail_level(), 6); 798 + 799 + app.viewport.x_min = -90.0; 800 + app.viewport.x_max = -82.0; // width = 8 801 + assert_eq!(app.zoom_detail_level(), 7); 507 802 } 508 803 509 804 #[test]
+40 -2
src/main.rs
··· 59 59 } 60 60 } 61 61 62 + // Fetch stations 63 + app.status_message = format!("{} | Fetching stations...", app.status_message); 64 + terminal.draw(|f| ui::draw(f, &app))?; 65 + match api::fetch_stations(&client, &keys).await { 66 + Ok(stations) => { 67 + let count = stations.len(); 68 + app.stations = stations; 69 + app.status_message = format!("Loaded {} stations", count); 70 + } 71 + Err(e) => { 72 + app.status_message = format!("Station error: {}", e); 73 + } 74 + } 75 + 62 76 loop { 77 + app.tick(); 63 78 terminal.draw(|f| ui::draw(f, &app))?; 64 79 65 80 // Poll for events with a short timeout so we can check for refresh ··· 97 112 (KeyCode::Down, _) => app.viewport.pan(0.0, -0.15), 98 113 99 114 // Selection 100 - (KeyCode::Tab, _) => app.select_next(), 101 - (KeyCode::BackTab, _) => app.select_prev(), 115 + (KeyCode::Tab, _) => { 116 + if app.station_mode { 117 + app.select_next_station(); 118 + } else { 119 + app.select_next(); 120 + } 121 + } 122 + (KeyCode::BackTab, _) => { 123 + if app.station_mode { 124 + app.select_prev_station(); 125 + } else { 126 + app.select_prev(); 127 + } 128 + } 102 129 103 130 // Jump to selected train 104 131 (KeyCode::Enter, _) => { ··· 111 138 112 139 // Search 113 140 (KeyCode::Char('/'), _) => app.enter_search(), 141 + 142 + // Station selection mode 143 + (KeyCode::Char('s'), _) => { 144 + if app.station_mode { 145 + // In station mode, Tab/Shift-Tab cycle stations 146 + // Toggle off 147 + app.toggle_station_mode(); 148 + } else { 149 + app.toggle_station_mode(); 150 + } 151 + } 114 152 115 153 // Reset view 116 154 (KeyCode::Char('0'), _) => app.reset_view(),
+141
src/model.rs
··· 167 167 } 168 168 } 169 169 170 + pub fn heading_to_caret(heading: &str) -> &'static str { 171 + match heading { 172 + "N" => "▲", 173 + "NE" => "◥", 174 + "E" => "▶", 175 + "SE" => "◢", 176 + "S" => "▼", 177 + "SW" => "◣", 178 + "W" => "◀", 179 + "NW" => "◤", 180 + _ => "●", 181 + } 182 + } 183 + 184 + #[derive(Debug, Clone)] 185 + pub struct Station { 186 + pub code: String, 187 + pub name: String, 188 + pub city: String, 189 + pub state: String, 190 + pub lon: f64, 191 + pub lat: f64, 192 + pub station_type: String, 193 + pub zoom_level: u32, 194 + } 195 + 196 + pub fn parse_station_response(json: &str) -> Result<Vec<Station>> { 197 + let data: serde_json::Value = 198 + serde_json::from_str(json).context("failed to parse station JSON")?; 199 + let response = data 200 + .get("StationsDataResponse") 201 + .context("missing StationsDataResponse key")?; 202 + let features = response 203 + .get("features") 204 + .and_then(|f| f.as_array()) 205 + .context("missing features array")?; 206 + 207 + let mut stations = Vec::new(); 208 + for feat in features { 209 + let geom = feat.get("geometry").and_then(|g| g.get("coordinates")); 210 + let props = feat.get("properties"); 211 + if let (Some(coords), Some(props)) = (geom, props) { 212 + let coords_arr = coords.as_array(); 213 + if let Some(coords_arr) = coords_arr { 214 + let lon = coords_arr.first().and_then(|v| v.as_f64()).unwrap_or(0.0); 215 + let lat = coords_arr.get(1).and_then(|v| v.as_f64()).unwrap_or(0.0); 216 + 217 + let get_str = |key: &str| -> String { 218 + props 219 + .get(key) 220 + .and_then(|v| v.as_str()) 221 + .unwrap_or("") 222 + .trim() 223 + .to_string() 224 + }; 225 + 226 + let zoom_level: u32 = get_str("MapZmLvl").parse().unwrap_or(7); 227 + 228 + stations.push(Station { 229 + code: get_str("Code"), 230 + name: get_str("StationName"), 231 + city: get_str("City"), 232 + state: get_str("State"), 233 + lon, 234 + lat, 235 + station_type: get_str("StaType"), 236 + zoom_level, 237 + }); 238 + } 239 + } 240 + } 241 + Ok(stations) 242 + } 243 + 170 244 pub fn parse_train_response(json: &str) -> Result<Vec<Train>> { 171 245 let collection: geojson::FeatureCollection = 172 246 serde_json::from_str(json).context("failed to parse GeoJSON")?; ··· 277 351 comment: Some("15 MI LATE".into()), 278 352 }); 279 353 assert_eq!(train.delay_status(), DelayStatus::Late); 354 + } 355 + 356 + #[test] 357 + fn test_heading_to_caret() { 358 + assert_eq!(heading_to_caret("N"), "▲"); 359 + assert_eq!(heading_to_caret("NE"), "◥"); 360 + assert_eq!(heading_to_caret("E"), "▶"); 361 + assert_eq!(heading_to_caret("SE"), "◢"); 362 + assert_eq!(heading_to_caret("S"), "▼"); 363 + assert_eq!(heading_to_caret("SW"), "◣"); 364 + assert_eq!(heading_to_caret("W"), "◀"); 365 + assert_eq!(heading_to_caret("NW"), "◤"); 366 + assert_eq!(heading_to_caret(""), "●"); 367 + assert_eq!(heading_to_caret("XYZ"), "●"); 368 + } 369 + 370 + #[test] 371 + fn test_parse_station_response() { 372 + let json = r#"{ 373 + "StationsDataResponse": { 374 + "type": "FeatureCollection", 375 + "features": [ 376 + { 377 + "type": "Feature", 378 + "id": 1, 379 + "geometry": { "type": "Point", "coordinates": [-76.163, 39.508] }, 380 + "properties": { 381 + "Code": "ABE", 382 + "StationName": "Aberdeen, MD", 383 + "City": "Aberdeen", 384 + "State": "MD", 385 + "StaType": "Station Building (with waiting room)", 386 + "MapZmLvl": "5" 387 + } 388 + }, 389 + { 390 + "type": "Feature", 391 + "id": 2, 392 + "geometry": { "type": "Point", "coordinates": [-74.501, 39.424] }, 393 + "properties": { 394 + "Code": "ABN", 395 + "StationName": "Absecon, NJ", 396 + "City": "Absecon", 397 + "State": "NJ", 398 + "StaType": "Platform with Shelter", 399 + "MapZmLvl": "5" 400 + } 401 + } 402 + ] 403 + } 404 + }"#; 405 + 406 + let stations = parse_station_response(json).unwrap(); 407 + assert_eq!(stations.len(), 2); 408 + assert_eq!(stations[0].code, "ABE"); 409 + assert_eq!(stations[0].city, "Aberdeen"); 410 + assert_eq!(stations[0].state, "MD"); 411 + assert_eq!(stations[0].zoom_level, 5); 412 + assert!((stations[0].lon - (-76.163)).abs() < 0.001); 413 + assert!((stations[0].lat - 39.508).abs() < 0.001); 414 + assert_eq!(stations[1].code, "ABN"); 415 + } 416 + 417 + #[test] 418 + fn test_parse_station_response_missing_key() { 419 + let json = r#"{"features": []}"#; 420 + assert!(parse_station_response(json).is_err()); 280 421 } 281 422 282 423 #[test]
+182 -22
src/ui.rs
··· 13 13 14 14 use crate::app::App; 15 15 use crate::geo::LineSegment; 16 - use crate::model::{DelayStatus, TrainState}; 16 + use crate::model::{heading_to_caret, DelayStatus, TrainState}; 17 17 18 18 fn delay_color(status: &DelayStatus) -> Color { 19 19 match status { ··· 27 27 // Main layout: map on top/left, info panel on right 28 28 let main_chunks = Layout::default() 29 29 .direction(Direction::Horizontal) 30 - .constraints([Constraint::Percentage(70), Constraint::Percentage(30)]) 30 + .constraints([Constraint::Percentage(82), Constraint::Percentage(18)]) 31 31 .split(frame.area()); 32 32 33 33 draw_map(frame, app, main_chunks[0]); ··· 38 38 lon: f64, 39 39 lat: f64, 40 40 number: String, 41 + caret: &'static str, 41 42 color: Color, 43 + is_bright: bool, 44 + is_selected: bool, 45 + } 46 + 47 + struct StationDot { 48 + lon: f64, 49 + lat: f64, 50 + code: String, 51 + is_selected: bool, 42 52 } 43 53 44 54 /// Convert LineSegments into owned (x1,y1,x2,y2,color) tuples for the paint closure. ··· 56 66 let vp = &app.viewport; 57 67 let vp_width = vp.width(); 58 68 69 + let selected_number = app.selected_train().map(|t| t.number.clone()); 70 + 59 71 // Collect train dots into owned data for the 'static paint closure 60 72 let dots: Vec<TrainDot> = app 61 73 .trains 62 74 .iter() 63 75 .filter(|t| t.state == TrainState::Active) 64 - .map(|t| TrainDot { 65 - lon: t.lon, 66 - lat: t.lat, 67 - number: t.number.clone(), 68 - color: delay_color(&t.delay_status()), 76 + .map(|t| { 77 + let is_selected = selected_number.as_deref() == Some(&t.number); 78 + TrainDot { 79 + lon: t.lon, 80 + lat: t.lat, 81 + number: t.number.clone(), 82 + caret: heading_to_caret(&t.heading), 83 + color: delay_color(&t.delay_status()), 84 + is_bright: app.is_train_bright(t.velocity), 85 + is_selected, 86 + } 87 + }) 88 + .collect(); 89 + 90 + // Collect station dots 91 + let selected_station_code = if app.station_mode { 92 + let visible_stations = app.visible_stations(); 93 + app.selected_station 94 + .and_then(|i| visible_stations.get(i)) 95 + .map(|s| s.code.clone()) 96 + } else { 97 + None 98 + }; 99 + let station_dots: Vec<StationDot> = app 100 + .visible_stations() 101 + .iter() 102 + .map(|s| StationDot { 103 + lon: s.lon, 104 + lat: s.lat, 105 + code: s.code.clone(), 106 + is_selected: selected_station_code.as_deref() == Some(&s.code), 69 107 }) 70 108 .collect(); 71 109 ··· 123 161 }); 124 162 } 125 163 126 - // Layer 4: Train markers — use ◆ character for visibility 164 + // Layer 4: Station markers 165 + for station in &station_dots { 166 + let color = if station.is_selected { 167 + Color::White 168 + } else { 169 + Color::Rgb(100, 100, 120) 170 + }; 171 + ctx.print( 172 + station.lon, 173 + station.lat, 174 + Span::styled("·", Style::default().fg(color)), 175 + ); 176 + if station.is_selected { 177 + ctx.print( 178 + station.lon + vp_width * 0.012, 179 + station.lat, 180 + Span::styled( 181 + station.code.clone(), 182 + Style::default() 183 + .fg(Color::White) 184 + .add_modifier(Modifier::BOLD), 185 + ), 186 + ); 187 + } 188 + } 189 + 190 + // Layer 5: Train markers — directional carets with speed animation 127 191 for dot in &dots { 192 + let fg = if dot.is_bright { 193 + dot.color 194 + } else { 195 + Color::DarkGray 196 + }; 128 197 ctx.print( 129 198 dot.lon, 130 199 dot.lat, 131 200 Span::styled( 132 - "◆", 133 - Style::default().fg(dot.color).add_modifier(Modifier::BOLD), 201 + dot.caret, 202 + Style::default().fg(fg).add_modifier(Modifier::BOLD), 134 203 ), 135 204 ); 136 - ctx.print( 137 - dot.lon + vp_width * 0.012, 138 - dot.lat, 139 - Span::styled(dot.number.clone(), Style::default().fg(dot.color)), 140 - ); 205 + // Only show number label for the selected train (tooltip) 206 + if dot.is_selected { 207 + ctx.print( 208 + dot.lon + vp_width * 0.012, 209 + dot.lat, 210 + Span::styled(dot.number.clone(), Style::default().fg(dot.color)), 211 + ); 212 + } 141 213 } 142 214 }) 143 215 .marker(ratatui::symbols::Marker::Braille); ··· 216 288 } 217 289 218 290 fn draw_train_panel(frame: &mut Frame, app: &App, area: Rect) { 291 + // Station departure board mode 292 + if app.station_mode { 293 + draw_station_panel(frame, app, area); 294 + return; 295 + } 296 + 219 297 if let Some(train) = app.selected_train() { 220 298 // Detail view for selected train 221 299 let delay = train.delay_status(); ··· 273 351 .iter() 274 352 .enumerate() 275 353 .map(|(i, train)| { 276 - let color = delay_color(&train.delay_status()); 354 + let delay = train.delay_status(); 355 + let color = delay_color(&delay); 277 356 let selected = app.selected_index == Some(i); 278 357 let marker = if selected { "▸" } else { " " }; 358 + let delay_text = match &delay { 359 + DelayStatus::OnTime => " OT".to_string(), 360 + DelayStatus::Early => " E".to_string(), 361 + DelayStatus::Late => { 362 + // Try to extract delay minutes from station comments 363 + let msg = train 364 + .stations 365 + .iter() 366 + .filter_map(|s| s.comment.as_deref()) 367 + .find(|c| c.to_uppercase().contains("LATE")) 368 + .unwrap_or("LATE"); 369 + format!(" {}", msg) 370 + } 371 + DelayStatus::Unknown => String::new(), 372 + }; 279 373 ListItem::new(Line::from(vec![ 280 374 Span::raw(marker), 281 375 Span::styled( 282 - format!("#{:<5}", train.number), 376 + format!("#{}", train.number), 283 377 Style::default().fg(color).add_modifier(Modifier::BOLD), 284 378 ), 285 - Span::styled( 286 - format!(" {}", train.route_name), 287 - Style::default().fg(Color::White), 288 - ), 379 + Span::styled(delay_text, Style::default().fg(color)), 289 380 ])) 290 381 }) 291 382 .collect(); ··· 300 391 } 301 392 } 302 393 394 + fn draw_station_panel(frame: &mut Frame, app: &App, area: Rect) { 395 + let visible_stations = app.visible_stations(); 396 + let selected_station = app 397 + .selected_station 398 + .and_then(|i| visible_stations.get(i).cloned()); 399 + 400 + if let Some(station) = selected_station { 401 + let trains = app.trains_at_station(&station.code); 402 + 403 + let mut lines = vec![ 404 + Line::from(vec![ 405 + Span::styled( 406 + &station.code, 407 + Style::default() 408 + .fg(Color::White) 409 + .add_modifier(Modifier::BOLD), 410 + ), 411 + Span::raw(" "), 412 + Span::styled(&station.name, Style::default().fg(Color::Gray)), 413 + ]), 414 + Line::from(""), 415 + ]; 416 + 417 + if trains.is_empty() { 418 + lines.push(Line::from(Span::styled( 419 + "No trains", 420 + Style::default().fg(Color::DarkGray), 421 + ))); 422 + } else { 423 + for train in &trains { 424 + let delay = train.delay_status(); 425 + let color = delay_color(&delay); 426 + let caret = heading_to_caret(&train.heading); 427 + let delay_label = match &delay { 428 + DelayStatus::OnTime => "OT", 429 + DelayStatus::Early => "E", 430 + DelayStatus::Late => "LATE", 431 + DelayStatus::Unknown => "?", 432 + }; 433 + lines.push(Line::from(vec![ 434 + Span::styled(caret, Style::default().fg(color)), 435 + Span::styled( 436 + format!(" #{}", train.number), 437 + Style::default().fg(color).add_modifier(Modifier::BOLD), 438 + ), 439 + Span::styled(format!(" {}", delay_label), Style::default().fg(color)), 440 + ])); 441 + } 442 + } 443 + 444 + let paragraph = Paragraph::new(lines).block( 445 + Block::default() 446 + .title(format!(" Station: {} ", station.code)) 447 + .borders(Borders::ALL) 448 + .border_style(Style::default().fg(Color::Cyan)), 449 + ); 450 + frame.render_widget(paragraph, area); 451 + } else { 452 + let paragraph = Paragraph::new("No station selected").block( 453 + Block::default() 454 + .title(" Stations ") 455 + .borders(Borders::ALL) 456 + .border_style(Style::default().fg(Color::DarkGray)), 457 + ); 458 + frame.render_widget(paragraph, area); 459 + } 460 + } 461 + 303 462 fn draw_help(frame: &mut Frame, area: Rect) { 304 463 let help_text = vec![ 305 464 Line::from("←↑↓→ pan | +/- zoom | ↹/⇧↹ select"), 306 - Line::from("Enter: jump | /: search | 0: reset | q: quit"), 465 + Line::from("Enter: jump | /: search | s: stations"), 466 + Line::from("0: reset | q: quit"), 307 467 ]; 308 468 let paragraph = Paragraph::new(help_text).block( 309 469 Block::default()