// UI rendering with ratatui use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ canvas::{Canvas, Line as CanvasLine, Map, MapResolution}, Block, Borders, List, ListItem, Paragraph, }, Frame, }; use crate::app::App; use crate::geo::LineSegment; use crate::model::{DelayStatus, TrainState}; fn delay_color(status: &DelayStatus) -> Color { match status { DelayStatus::OnTime | DelayStatus::Early => Color::Green, DelayStatus::Late => Color::Red, DelayStatus::Unknown => Color::Yellow, } } pub fn draw(frame: &mut Frame, app: &App) { // Main layout: map on top/left, info panel on right let main_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(82), Constraint::Percentage(18)]) .split(frame.area()); draw_map(frame, app, main_chunks[0]); draw_sidebar(frame, app, main_chunks[1]); } struct TrainDot { lon: f64, lat: f64, number: String, color: Color, is_selected: bool, } struct StationDot { lon: f64, lat: f64, code: String, is_selected: bool, } /// Map a 0.0–1.0 brightness factor to a colored RGB value. /// 0.0 = dimmest (stopped/slowest), 1.0 = brightest (fastest in current set). fn speed_adjusted_color(base: Color, factor: f64) -> Color { // Map factor 0.0–1.0 to brightness 0.35–1.0 let b = 0.35 + factor.clamp(0.0, 1.0) * 0.65; match base { Color::Green => Color::Rgb((40.0 * b) as u8, (220.0 * b) as u8, (40.0 * b) as u8), Color::Red => Color::Rgb((220.0 * b) as u8, (50.0 * b) as u8, (50.0 * b) as u8), Color::Yellow => Color::Rgb((200.0 * b) as u8, (200.0 * b) as u8, (50.0 * b) as u8), _ => base, } } /// Convert LineSegments into owned (x1,y1,x2,y2,color) tuples for the paint closure. fn segments_to_lines(segments: &[LineSegment], color: Color) -> Vec<(f64, f64, f64, f64, Color)> { let mut lines = Vec::new(); for seg in segments { for pair in seg.points.windows(2) { lines.push((pair[0].0, pair[0].1, pair[1].0, pair[1].1, color)); } } lines } fn draw_map(frame: &mut Frame, app: &App, area: Rect) { let vp = &app.viewport; let vp_width = vp.width(); // Copy layer visibility flags into owned bools for the 'static paint closure let show_coastlines = app.layers.coastlines; let show_land = app.layers.land; let show_lakes = app.layers.lakes; let show_urban = app.layers.urban; let show_rivers = app.layers.rivers; let show_states = app.layers.states; let show_roads = app.layers.roads; let show_cities = app.layers.cities; let show_routes = app.layers.routes; let show_stations = app.layers.stations; let show_trains = app.layers.trains; let selected_number = app.selected_train().map(|t| t.number.clone()); // Collect train dots into owned data for the 'static paint closure // Compute velocity range across active trains for relative brightness let active_trains: Vec<&crate::model::Train> = app .trains .iter() .filter(|t| t.state == TrainState::Active) .collect(); let max_velocity = active_trains .iter() .map(|t| t.velocity) .fold(1.0_f64, f64::max); // floor at 1.0 to avoid div by zero let dots: Vec = active_trains .iter() .map(|t| { let is_selected = selected_number.as_deref() == Some(&t.number); let base_color = delay_color(&t.delay_status()); // Normalize velocity: stopped=0.0, fastest train=1.0 let speed_factor = if t.velocity < 1.0 { 0.0 } else { t.velocity / max_velocity }; TrainDot { lon: t.lon, lat: t.lat, number: t.number.clone(), color: speed_adjusted_color(base_color, speed_factor), is_selected, } }) .collect(); // Collect station dots let selected_station_code = if app.station_mode { let visible_stations = app.visible_stations(); app.selected_station .and_then(|i| visible_stations.get(i)) .map(|s| s.code.clone()) } else { None }; let station_dots: Vec = app .visible_stations() .iter() .map(|s| StationDot { lon: s.lon, lat: s.lat, code: s.code.clone(), is_selected: selected_station_code.as_deref() == Some(&s.code), }) .collect(); // Pre-compute geo layer lines for the closure let state_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.state_boundaries, Color::Rgb(50, 50, 70))) .unwrap_or_default(); let route_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.routes, Color::Rgb(60, 80, 140))) .unwrap_or_default(); let river_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.rivers, Color::Rgb(30, 50, 80))) .unwrap_or_default(); let land_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.land, Color::Rgb(38, 42, 52))) .unwrap_or_default(); let lake_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.lakes, Color::Rgb(25, 45, 75))) .unwrap_or_default(); let urban_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.urban_areas, Color::Rgb(45, 45, 55))) .unwrap_or_default(); let road_lines = app .geo .as_ref() .map(|g| segments_to_lines(&g.roads, Color::Rgb(45, 48, 58))) .unwrap_or_default(); // City labels with greedy collision detection. // We approximate each label's bounding box in geo-coordinates and skip any // that would overlap a previously placed label or a train/station marker. let city_labels: Vec<(f64, f64, String)> = { // Approximate width of one character in geo-degrees. // Canvas area is (area.width - 2) chars wide (minus border), each braille // cell is 2 dots wide, but text chars occupy 1 full cell. let canvas_cols = (area.width.saturating_sub(2)) as f64; let char_w = if canvas_cols > 0.0 { vp_width / canvas_cols } else { 1.0 }; let char_h = char_w * 2.0; // character cells are roughly 2x taller than wide in geo space let min_pop = if vp_width > 40.0 { 2_000_000 } else if vp_width > 20.0 { 500_000 } else if vp_width > 10.0 { 100_000 } else { 30_000 }; // Occupied bounding boxes: (x_min, y_min, x_max, y_max) let mut occupied: Vec<(f64, f64, f64, f64)> = Vec::new(); // Reserve space for train markers (each ~2 chars wide) for dot in &dots { let pad = char_w * 2.0; let pad_h = char_h; occupied.push(( dot.lon - pad, dot.lat - pad_h, dot.lon + pad, dot.lat + pad_h, )); } // Reserve space for station markers for s in &station_dots { let pad = char_w; let pad_h = char_h * 0.5; occupied.push((s.lon - pad, s.lat - pad_h, s.lon + pad, s.lat + pad_h)); } let candidates: Vec<_> = app .geo .as_ref() .map(|g| { g.cities .iter() .filter(|c| c.population >= min_pop) .filter(|c| vp.contains(c.lon, c.lat)) .collect::>() }) .unwrap_or_default(); let mut result = Vec::new(); for city in candidates { let lx = city.lon + char_w; // offset right of the point let ly = city.lat; let label_w = city.name.len() as f64 * char_w; let label_h = char_h; let bbox = (lx, ly - label_h * 0.5, lx + label_w, ly + label_h * 0.5); // Check for collision with any occupied box let collides = occupied .iter() .any(|o| bbox.0 < o.2 && bbox.2 > o.0 && bbox.1 < o.3 && bbox.3 > o.1); if !collides { occupied.push(bbox); result.push((lx, ly, city.name.clone())); } } result }; let canvas = Canvas::default() .block( Block::default() .title(format!( " amtracker — {} active trains ", app.active_train_count() )) .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)), ) .x_bounds([vp.x_min, vp.x_max]) .y_bounds([vp.y_min, vp.y_max]) .paint(move |ctx| { // Layer 1: Coastlines (built-in ratatui world map) if show_coastlines { ctx.draw(&Map { resolution: MapResolution::High, color: Color::Rgb(35, 38, 45), }); } // Layer 2: Land polygons (enhanced coastlines) if show_land { for &(x1, y1, x2, y2, color) in &land_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 3: Lakes if show_lakes { for &(x1, y1, x2, y2, color) in &lake_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 4: Urban areas if show_urban { for &(x1, y1, x2, y2, color) in &urban_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 5: Rivers if show_rivers { for &(x1, y1, x2, y2, color) in &river_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 6: State boundaries if show_states { for &(x1, y1, x2, y2, color) in &state_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 7: Roads (only when zoomed in) if show_roads { for &(x1, y1, x2, y2, color) in &road_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 8: Amtrak routes if show_routes { for &(x1, y1, x2, y2, color) in &route_lines { ctx.draw(&CanvasLine { x1, y1, x2, y2, color, }); } } // Layer 10: Station markers if show_stations { let show_station_codes = vp_width < 20.0; for station in &station_dots { let (marker, color) = if station.is_selected { ("◉", Color::Cyan) } else { ("·", Color::Rgb(100, 110, 140)) }; ctx.print( station.lon, station.lat, Span::styled(marker, Style::default().fg(color)), ); if station.is_selected || show_station_codes { let label_color = if station.is_selected { Color::Cyan } else { Color::Rgb(80, 85, 100) }; ctx.print( station.lon + vp_width * 0.006, station.lat, Span::styled(station.code.clone(), Style::default().fg(label_color)), ); } } } // Layer 11: Train markers if show_trains { for dot in &dots { let marker = if dot.is_selected { "◉" } else { "○" }; ctx.print( dot.lon, dot.lat, Span::styled( marker, Style::default().fg(dot.color).add_modifier(Modifier::BOLD), ), ); if dot.is_selected { ctx.print( dot.lon + vp_width * 0.010, dot.lat, Span::styled( dot.number.clone(), Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), ), ); } } } // Layer 12: City labels — rendered LAST so text isn't overwritten by braille // Collision detection already filtered out labels that overlap trains/stations if show_cities { for (lon, lat, ref name) in &city_labels { ctx.print( *lon, *lat, Span::styled(name.clone(), Style::default().fg(Color::Rgb(140, 135, 120))), ); } } }) .marker(ratatui::symbols::Marker::Braille); frame.render_widget(canvas, area); } fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) { let search_height = if app.input_mode == crate::app::InputMode::Search { 3 } else { 0 }; let layers_height = if app.input_mode == crate::app::InputMode::Layers { 15 } else { 0 }; let sidebar_chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), // status bar Constraint::Length(search_height), // search bar (conditional) Constraint::Length(layers_height), // layers panel (conditional) Constraint::Min(10), // train list or detail Constraint::Length(5), // help ]) .split(area); draw_status_bar(frame, app, sidebar_chunks[0]); if app.input_mode == crate::app::InputMode::Search { draw_search_bar(frame, app, sidebar_chunks[1]); } if app.input_mode == crate::app::InputMode::Layers { draw_layers_panel(frame, app, sidebar_chunks[2]); } draw_train_panel(frame, app, sidebar_chunks[3]); draw_help(frame, sidebar_chunks[4]); } fn draw_search_bar(frame: &mut Frame, app: &App, area: Rect) { let input = Paragraph::new(Line::from(vec![ Span::styled("/ ", Style::default().fg(Color::Yellow)), Span::raw(&app.search_query), Span::styled( "_", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::SLOW_BLINK), ), ])) .block( Block::default() .title(" Search ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)), ); frame.render_widget(input, area); } fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) { let delayed = app.delayed_train_count(); let status = if delayed > 0 { format!( "{} active | {} delayed | {}", app.active_train_count(), delayed, &app.status_message ) } else { format!( "{} active | {}", app.active_train_count(), &app.status_message ) }; let paragraph = Paragraph::new(status).block( Block::default() .title(" Status ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)), ); frame.render_widget(paragraph, area); } fn draw_train_panel(frame: &mut Frame, app: &App, area: Rect) { // Station departure board mode if app.station_mode { draw_station_panel(frame, app, area); return; } if let Some(train) = app.selected_train() { // Detail view for selected train let delay = train.delay_status(); let color = delay_color(&delay); let lines = vec![ Line::from(vec![ Span::styled("Train ", Style::default().fg(Color::White)), Span::styled( format!("#{}", train.number), Style::default().fg(color).add_modifier(Modifier::BOLD), ), ]), Line::from(format!("Route: {}", train.route_name)), Line::from(format!("{} → {}", train.origin, train.destination)), Line::from(format!( "Speed: {:.0} mph | Heading: {}", train.velocity, train.heading )), Line::from(Span::styled( format!("{:?}", delay), Style::default().fg(color), )), Line::from(""), Line::from(Span::styled( "Stations:", Style::default().add_modifier(Modifier::BOLD), )), ]; let mut all_lines = lines; for stop in &train.stations { let stop_color = delay_color(&stop.delay_status()); let comment = stop.comment.as_deref().unwrap_or("--"); all_lines.push(Line::from(vec![ Span::styled( format!(" {} ", stop.code), Style::default().fg(Color::White), ), Span::styled(comment, Style::default().fg(stop_color)), ])); } let paragraph = Paragraph::new(all_lines).block( Block::default() .title(" Train Detail ") .borders(Borders::ALL) .border_style(Style::default().fg(color)), ); frame.render_widget(paragraph, area); } else { // Train list view let visible = app.visible_trains(); let items: Vec = visible .iter() .enumerate() .map(|(i, train)| { let delay = train.delay_status(); let color = delay_color(&delay); let selected = app.selected_index == Some(i); let marker = if selected { "▸" } else { " " }; let delay_text = match &delay { DelayStatus::OnTime => " OT".to_string(), DelayStatus::Early => " E".to_string(), DelayStatus::Late => { // Try to extract delay minutes from station comments let msg = train .stations .iter() .filter_map(|s| s.comment.as_deref()) .find(|c| c.to_uppercase().contains("LATE")) .unwrap_or("LATE"); format!(" {}", msg) } DelayStatus::Unknown => String::new(), }; ListItem::new(Line::from(vec![ Span::raw(marker), Span::styled( format!("#{}", train.number), Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::styled(delay_text, Style::default().fg(color)), ])) }) .collect(); let list = List::new(items).block( Block::default() .title(format!(" Trains ({}) ", visible.len())) .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)), ); frame.render_widget(list, area); } } fn draw_station_panel(frame: &mut Frame, app: &App, area: Rect) { let visible_stations = app.visible_stations(); let selected_station = app .selected_station .and_then(|i| visible_stations.get(i).cloned()); if let Some(station) = selected_station { let trains = app.trains_at_station(&station.code); let mut lines = vec![ Line::from(vec![ Span::styled( &station.code, Style::default() .fg(Color::White) .add_modifier(Modifier::BOLD), ), Span::raw(" "), Span::styled(&station.name, Style::default().fg(Color::Gray)), ]), Line::from(""), ]; if trains.is_empty() { lines.push(Line::from(Span::styled( "No trains", Style::default().fg(Color::DarkGray), ))); } else { for train in &trains { let delay = train.delay_status(); let color = delay_color(&delay); let delay_label = match &delay { DelayStatus::OnTime => "OT", DelayStatus::Early => "E", DelayStatus::Late => "LATE", DelayStatus::Unknown => "?", }; let dir = if train.heading.is_empty() { "".to_string() } else { format!(" {}", train.heading) }; lines.push(Line::from(vec![ Span::styled( format!("#{}", train.number), Style::default().fg(color).add_modifier(Modifier::BOLD), ), Span::styled(dir, Style::default().fg(Color::DarkGray)), Span::styled(format!(" {}", delay_label), Style::default().fg(color)), ])); } } let paragraph = Paragraph::new(lines).block( Block::default() .title(format!(" Station: {} ", station.code)) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan)), ); frame.render_widget(paragraph, area); } else { let paragraph = Paragraph::new("No station selected").block( Block::default() .title(" Stations ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)), ); frame.render_widget(paragraph, area); } } fn draw_layers_panel(frame: &mut Frame, app: &App, area: Rect) { fn status(on: bool) -> &'static str { if on { "ON" } else { "--" } } fn status_color(on: bool) -> Color { if on { Color::Green } else { Color::DarkGray } } let layers = &app.layers; let items = vec![ ("1", "Coastlines", layers.coastlines), ("2", "Land", layers.land), ("3", "Lakes", layers.lakes), ("4", "Urban", layers.urban), ("5", "Rivers", layers.rivers), ("6", "States", layers.states), ("7", "Roads", layers.roads), ("8", "Cities", layers.cities), ("9", "Routes", layers.routes), ("0", "Stations", layers.stations), ("-", "Trains", layers.trains), ]; let lines: Vec = items .iter() .map(|(key, name, on)| { Line::from(vec![ Span::styled(format!(" {} ", key), Style::default().fg(Color::Yellow)), Span::styled(format!("{:<12}", name), Style::default().fg(Color::White)), Span::styled(status(*on), Style::default().fg(status_color(*on))), ]) }) .collect(); let paragraph = Paragraph::new(lines).block( Block::default() .title(" Layers (Esc to close) ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::Yellow)), ); frame.render_widget(paragraph, area); } fn draw_help(frame: &mut Frame, area: Rect) { let help_text = vec![ Line::from("←↑↓→ pan | +/- zoom | ↹/⇧↹ select"), Line::from("Enter: jump | /: search | s: stations"), Line::from("l: layers | 0: reset | q: quit"), ]; let paragraph = Paragraph::new(help_text).block( Block::default() .title(" Help ") .borders(Borders::ALL) .border_style(Style::default().fg(Color::DarkGray)), ); frame.render_widget(paragraph, area); } #[cfg(test)] mod tests { use super::*; use crate::model::TrainState; use ratatui::{backend::TestBackend, Terminal}; fn make_test_train(num: &str, lon: f64, lat: f64) -> crate::model::Train { crate::model::Train { number: num.into(), route_name: "Test Route".into(), origin: "AAA".into(), destination: "BBB".into(), lon, lat, velocity: 55.0, heading: "NE".into(), state: TrainState::Active, status_msg: String::new(), stations: vec![], } } #[test] fn test_ui_renders_without_panic() { let backend = TestBackend::new(120, 40); let mut terminal = Terminal::new(backend).unwrap(); let app = App::new(); terminal .draw(|frame| draw(frame, &app)) .expect("draw should not panic"); } #[test] fn test_ui_renders_with_trains() { let backend = TestBackend::new(120, 40); let mut terminal = Terminal::new(backend).unwrap(); let mut app = App::new(); app.trains = vec![ make_test_train("785", -118.6, 34.2), make_test_train("316", -90.2, 38.6), ]; app.status_message = "Updated just now".into(); terminal .draw(|frame| draw(frame, &app)) .expect("draw should not panic with trains"); } #[test] fn test_ui_renders_with_selected_train() { let backend = TestBackend::new(120, 40); let mut terminal = Terminal::new(backend).unwrap(); let mut app = App::new(); app.trains = vec![ make_test_train("785", -118.6, 34.2), make_test_train("316", -90.2, 38.6), ]; app.selected_index = Some(0); terminal .draw(|frame| draw(frame, &app)) .expect("draw should not panic with selection"); } #[test] fn test_ui_contains_train_count_in_title() { let backend = TestBackend::new(120, 40); let mut terminal = Terminal::new(backend).unwrap(); let mut app = App::new(); app.trains = vec![ make_test_train("1", -87.6, 41.8), make_test_train("2", -90.0, 38.0), ]; terminal.draw(|frame| draw(frame, &app)).unwrap(); // Check that the buffer contains "2 active" let buf = terminal.backend().buffer().clone(); let content: String = buf .content .iter() .map(|cell| cell.symbol().chars().next().unwrap_or(' ')) .collect(); assert!( content.contains("2 active"), "buffer should contain '2 active trains', got: ...{}...", &content[..200.min(content.len())] ); } #[test] fn test_delay_colors() { assert_eq!(delay_color(&DelayStatus::OnTime), Color::Green); assert_eq!(delay_color(&DelayStatus::Early), Color::Green); assert_eq!(delay_color(&DelayStatus::Late), Color::Red); assert_eq!(delay_color(&DelayStatus::Unknown), Color::Yellow); } }