From 89ea7b79f5af74d5bb0284df2abc5684df38a31b Mon Sep 17 00:00:00 2001 From: Matthew Anderson Date: Wed, 18 Mar 2026 20:43:25 -0500 Subject: [PATCH] feat: rise/set/transit times table for almanac tab Add [t] toggle on the Almanac tab to switch the legend panel between altitude view (existing) and a rise/transit/set times table (new). Times are computed via Astronomy_SearchRiseSetEx for rise/set and parabolic interpolation of the altitude array for transit. Circumpolar and never-rising bodies are detected and labelled accordingly. All times are shown in the observer's local timezone when available. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 2 + src/main.rs | 3 ++ src/sky.rs | 104 ++++++++++++++++++++++++++++++++++++++++++++++++---- src/ui.rs | 92 +++++++++++++++++++++++++++++++++++----------- 4 files changed, 173 insertions(+), 28 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0cba1cf..0f735b7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -105,6 +105,7 @@ pub struct App { pub selected_bodies: Vec, pub almanac_picker_sel: usize, + pub almanac_show_times: bool, pub forecasts: Option>, pub weather_loading: bool, @@ -152,6 +153,7 @@ impl App { almanac: AlmanacInfo { tracks: Vec::new(), current_step: 0 }, selected_bodies: Vec::new(), almanac_picker_sel: 0, + almanac_show_times: false, sun_moon: SunMoonInfo { sun_stereo: None, moon_stereo: None, diff --git a/src/main.rs b/src/main.rs index 2b812b5..16e60cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,6 +195,9 @@ fn run( app.input_mode = InputMode::LocationPicker; app.picker_sel = app.location_index; } + KeyCode::Char('t') if matches!(app.tab, Tab::Almanac) => { + app.almanac_show_times = !app.almanac_show_times; + } KeyCode::Char('t') | KeyCode::Char('T') => { app.input_mode = InputMode::EditingDatetime; app.input_buf = if let Some(tz) = app.timezone { diff --git a/src/sky.rs b/src/sky.rs index 136c325..5914bac 100644 --- a/src/sky.rs +++ b/src/sky.rs @@ -1,11 +1,12 @@ use astronomy_engine_bindings::{ Astronomy_Ecliptic, Astronomy_Equator, Astronomy_HelioVector, Astronomy_Horizon, - Astronomy_Illumination, astro_aberration_t_ABERRATION, astro_body_t_BODY_EARTH, - astro_body_t_BODY_JUPITER, astro_body_t_BODY_MARS, astro_body_t_BODY_MERCURY, - astro_body_t_BODY_MOON, astro_body_t_BODY_NEPTUNE, astro_body_t_BODY_SATURN, - astro_body_t_BODY_SUN, astro_body_t_BODY_URANUS, astro_body_t_BODY_VENUS, + Astronomy_Illumination, Astronomy_SearchRiseSetEx, astro_aberration_t_ABERRATION, + astro_body_t_BODY_EARTH, astro_body_t_BODY_JUPITER, astro_body_t_BODY_MARS, + astro_body_t_BODY_MERCURY, astro_body_t_BODY_MOON, astro_body_t_BODY_NEPTUNE, + astro_body_t_BODY_SATURN, astro_body_t_BODY_SUN, astro_body_t_BODY_URANUS, + astro_body_t_BODY_VENUS, astro_direction_t_DIRECTION_RISE, astro_direction_t_DIRECTION_SET, astro_equator_date_t_EQUATOR_OF_DATE, astro_observer_t, astro_refraction_t_REFRACTION_NORMAL, - astro_status_t_ASTRO_SUCCESS, + astro_status_t_ASTRO_SUCCESS, astro_time_t, }; use chrono::{DateTime, Duration, TimeZone, Utc}; use stellui::astro::{ @@ -236,8 +237,95 @@ pub struct AlmanacTrack { pub name: &'static str, pub symbol: &'static str, pub color_rgb: (u8, u8, u8), - /// altitude in degrees (-90..90) for each step; index 0 = UTC midnight + /// altitude in degrees (-90..90) for each step; index 0 = local midnight pub altitudes: [f64; ALMANAC_STEPS], + pub rise: Option>, + pub transit: Option>, + pub transit_alt: Option, + pub set: Option>, +} + +fn astro_time_to_utc(t: astro_time_t) -> DateTime { + // t.ut = days since J2000.0 (2000-01-01 12:00:00 UTC) + use chrono::TimeZone; + let j2000 = Utc.with_ymd_and_hms(2000, 1, 1, 12, 0, 0).unwrap(); + let micros = (t.ut * 86_400.0 * 1_000_000.0) as i64; + j2000 + Duration::microseconds(micros) +} + +#[allow(clippy::type_complexity)] +fn compute_rise_set_transit( + body: i32, + observer: astro_observer_t, + day_start: DateTime, + altitudes: &[f64; ALMANAC_STEPS], + height: f64, +) -> (Option>, Option>, Option>, Option) { + let all_up = altitudes.iter().all(|&a| a > 0.0); + let all_down = altitudes.iter().all(|&a| a <= 0.0); + + // Transit: peak of altitude array with parabolic interpolation + let peak_idx = altitudes + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) + .map(|(i, _)| i) + .unwrap_or(0); + + let transit_alt = altitudes[peak_idx]; + + let offset = if peak_idx > 0 && peak_idx < ALMANAC_STEPS - 1 { + let y0 = altitudes[peak_idx - 1]; + let y1 = altitudes[peak_idx]; + let y2 = altitudes[peak_idx + 1]; + let denom = 2.0 * (2.0 * y1 - y0 - y2); + if denom.abs() > 1e-9 { (y0 - y2) / denom } else { 0.0 } + } else { + 0.0 + }; + + let transit_mins = (peak_idx as f64 + offset) * 15.0; + let transit = Some(day_start + Duration::seconds((transit_mins * 60.0) as i64)); + + if all_up || all_down { + return (None, transit, None, Some(transit_alt)); + } + + let start_time = astro_time_from_datetime(day_start); + + let rise = unsafe { + let result = Astronomy_SearchRiseSetEx( + body, + observer, + astro_direction_t_DIRECTION_RISE, + start_time, + 1.0, + height, + ); + if result.status == astro_status_t_ASTRO_SUCCESS { + Some(astro_time_to_utc(result.time)) + } else { + None + } + }; + + let set = unsafe { + let result = Astronomy_SearchRiseSetEx( + body, + observer, + astro_direction_t_DIRECTION_SET, + start_time, + 1.0, + height, + ); + if result.status == astro_status_t_ASTRO_SUCCESS { + Some(astro_time_to_utc(result.time)) + } else { + None + } + }; + + (rise, transit, set, Some(transit_alt)) } pub struct AlmanacInfo { @@ -305,7 +393,9 @@ pub fn compute_almanac(lat: f64, lon: f64, height: f64, datetime: DateTime, } }; } - AlmanacTrack { name, symbol, color_rgb, altitudes } + let (rise, transit, set, transit_alt) = + compute_rise_set_transit(body, observer, day_start, &altitudes, height); + AlmanacTrack { name, symbol, color_rgb, altitudes, rise, transit, transit_alt, set } }).collect(); AlmanacInfo { tracks, current_step } diff --git a/src/ui.rs b/src/ui.rs index 5d4915b..06e4b26 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -801,7 +801,7 @@ fn render_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) { Tab::SolarSystem => " [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [S/W/P/A]tab [Q]quit", Tab::Almanac => - " [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [b]bodies [S/W/P/A]tab [Q]quit", + " [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [b]bodies [t]times [S/W/P/A]tab [Q]quit", }; let text = vec![Line::from(line1), Line::from(line2)]; @@ -1070,30 +1070,80 @@ fn render_almanac_legend(f: &mut Frame, app: &App, area: ratatui::layout::Rect) Style::default().fg(Color::DarkGray), )), Line::from(""), - Line::from(Span::styled( - " Body Alt", - Style::default().add_modifier(Modifier::BOLD), - )), ]; - for (i, track) in app.almanac.tracks.iter().enumerate() { - let visible = app.selected_bodies.get(i).copied().unwrap_or(true); - let alt = track.altitudes[app.almanac.current_step]; - let (r, g, b) = track.color_rgb; - let label = if alt > 0.0 { - format!(" {} {} {:.1}°", track.symbol, track.name, alt) - } else { - format!(" {} {} below", track.symbol, track.name) - }; - let style = if visible { - Style::default().fg(Color::Rgb(r, g, b)) - } else { - Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) + if app.almanac_show_times { + text.push(Line::from(Span::styled( + " Rise Trans Set ", + Style::default().add_modifier(Modifier::BOLD), + ))); + + let fmt = |dt: Option>| -> String { + match dt { + None => "--:--".to_string(), + Some(utc) => { + if let Some(tz) = app.timezone { + utc.with_timezone(&tz).format("%H:%M").to_string() + } else { + utc.format("%H:%M").to_string() + } + } + } }; - text.push(Line::from(Span::styled(label, style))); + + for (i, track) in app.almanac.tracks.iter().enumerate() { + let visible = app.selected_bodies.get(i).copied().unwrap_or(true); + let (r, g, b) = track.color_rgb; + + let all_down = track.altitudes.iter().all(|&a| a <= 0.0); + let all_up = track.altitudes.iter().all(|&a| a > 0.0); + + let label = if all_down { + format!(" {} {} below horizon", track.symbol, track.name) + } else if all_up { + let max_alt = track.transit_alt.map(|a| format!(" ({:.0}°)", a)).unwrap_or_default(); + format!(" {} {} always up{}", track.symbol, track.name, max_alt) + } else { + format!( + " {} {} {} {}", + track.symbol, + fmt(track.rise), + fmt(track.transit), + fmt(track.set), + ) + }; + let style = if visible { + Style::default().fg(Color::Rgb(r, g, b)) + } else { + Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) + }; + text.push(Line::from(Span::styled(label, style))); + } + } else { + text.push(Line::from(Span::styled( + " Body Alt", + Style::default().add_modifier(Modifier::BOLD), + ))); + + for (i, track) in app.almanac.tracks.iter().enumerate() { + let visible = app.selected_bodies.get(i).copied().unwrap_or(true); + let alt = track.altitudes[app.almanac.current_step]; + let (r, g, b) = track.color_rgb; + let label = if alt > 0.0 { + format!(" {} {} {:.1}°", track.symbol, track.name, alt) + } else { + format!(" {} {} below", track.symbol, track.name) + }; + let style = if visible { + Style::default().fg(Color::Rgb(r, g, b)) + } else { + Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM) + }; + text.push(Line::from(Span::styled(label, style))); + } } - let para = - Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(" Legend ")); + let title = if app.almanac_show_times { " Times [t] " } else { " Legend [t] " }; + let para = Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(title)); f.render_widget(para, area); }