Skip to content

Commit d80825b

Browse files
maxb2Matthew Andersonclaude
authored
feat: rise/set/transit times table for almanac tab (#15)
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: Matthew Anderson <matt@mandersience.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a31388b commit d80825b

4 files changed

Lines changed: 173 additions & 28 deletions

File tree

src/app.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ pub struct App {
105105

106106
pub selected_bodies: Vec<bool>,
107107
pub almanac_picker_sel: usize,
108+
pub almanac_show_times: bool,
108109

109110
pub forecasts: Option<Vec<HourlyForecast>>,
110111
pub weather_loading: bool,
@@ -152,6 +153,7 @@ impl App {
152153
almanac: AlmanacInfo { tracks: Vec::new(), current_step: 0 },
153154
selected_bodies: Vec::new(),
154155
almanac_picker_sel: 0,
156+
almanac_show_times: false,
155157
sun_moon: SunMoonInfo {
156158
sun_stereo: None,
157159
moon_stereo: None,

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ fn run(
195195
app.input_mode = InputMode::LocationPicker;
196196
app.picker_sel = app.location_index;
197197
}
198+
KeyCode::Char('t') if matches!(app.tab, Tab::Almanac) => {
199+
app.almanac_show_times = !app.almanac_show_times;
200+
}
198201
KeyCode::Char('t') | KeyCode::Char('T') => {
199202
app.input_mode = InputMode::EditingDatetime;
200203
app.input_buf = if let Some(tz) = app.timezone {

src/sky.rs

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use astronomy_engine_bindings::{
22
Astronomy_Ecliptic, Astronomy_Equator, Astronomy_HelioVector, Astronomy_Horizon,
3-
Astronomy_Illumination, astro_aberration_t_ABERRATION, astro_body_t_BODY_EARTH,
4-
astro_body_t_BODY_JUPITER, astro_body_t_BODY_MARS, astro_body_t_BODY_MERCURY,
5-
astro_body_t_BODY_MOON, astro_body_t_BODY_NEPTUNE, astro_body_t_BODY_SATURN,
6-
astro_body_t_BODY_SUN, astro_body_t_BODY_URANUS, astro_body_t_BODY_VENUS,
3+
Astronomy_Illumination, Astronomy_SearchRiseSetEx, astro_aberration_t_ABERRATION,
4+
astro_body_t_BODY_EARTH, astro_body_t_BODY_JUPITER, astro_body_t_BODY_MARS,
5+
astro_body_t_BODY_MERCURY, astro_body_t_BODY_MOON, astro_body_t_BODY_NEPTUNE,
6+
astro_body_t_BODY_SATURN, astro_body_t_BODY_SUN, astro_body_t_BODY_URANUS,
7+
astro_body_t_BODY_VENUS, astro_direction_t_DIRECTION_RISE, astro_direction_t_DIRECTION_SET,
78
astro_equator_date_t_EQUATOR_OF_DATE, astro_observer_t, astro_refraction_t_REFRACTION_NORMAL,
8-
astro_status_t_ASTRO_SUCCESS,
9+
astro_status_t_ASTRO_SUCCESS, astro_time_t,
910
};
1011
use chrono::{DateTime, Duration, TimeZone, Utc};
1112
use stellui::astro::{
@@ -236,8 +237,95 @@ pub struct AlmanacTrack {
236237
pub name: &'static str,
237238
pub symbol: &'static str,
238239
pub color_rgb: (u8, u8, u8),
239-
/// altitude in degrees (-90..90) for each step; index 0 = UTC midnight
240+
/// altitude in degrees (-90..90) for each step; index 0 = local midnight
240241
pub altitudes: [f64; ALMANAC_STEPS],
242+
pub rise: Option<DateTime<Utc>>,
243+
pub transit: Option<DateTime<Utc>>,
244+
pub transit_alt: Option<f64>,
245+
pub set: Option<DateTime<Utc>>,
246+
}
247+
248+
fn astro_time_to_utc(t: astro_time_t) -> DateTime<Utc> {
249+
// t.ut = days since J2000.0 (2000-01-01 12:00:00 UTC)
250+
use chrono::TimeZone;
251+
let j2000 = Utc.with_ymd_and_hms(2000, 1, 1, 12, 0, 0).unwrap();
252+
let micros = (t.ut * 86_400.0 * 1_000_000.0) as i64;
253+
j2000 + Duration::microseconds(micros)
254+
}
255+
256+
#[allow(clippy::type_complexity)]
257+
fn compute_rise_set_transit(
258+
body: i32,
259+
observer: astro_observer_t,
260+
day_start: DateTime<Utc>,
261+
altitudes: &[f64; ALMANAC_STEPS],
262+
height: f64,
263+
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<f64>) {
264+
let all_up = altitudes.iter().all(|&a| a > 0.0);
265+
let all_down = altitudes.iter().all(|&a| a <= 0.0);
266+
267+
// Transit: peak of altitude array with parabolic interpolation
268+
let peak_idx = altitudes
269+
.iter()
270+
.enumerate()
271+
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
272+
.map(|(i, _)| i)
273+
.unwrap_or(0);
274+
275+
let transit_alt = altitudes[peak_idx];
276+
277+
let offset = if peak_idx > 0 && peak_idx < ALMANAC_STEPS - 1 {
278+
let y0 = altitudes[peak_idx - 1];
279+
let y1 = altitudes[peak_idx];
280+
let y2 = altitudes[peak_idx + 1];
281+
let denom = 2.0 * (2.0 * y1 - y0 - y2);
282+
if denom.abs() > 1e-9 { (y0 - y2) / denom } else { 0.0 }
283+
} else {
284+
0.0
285+
};
286+
287+
let transit_mins = (peak_idx as f64 + offset) * 15.0;
288+
let transit = Some(day_start + Duration::seconds((transit_mins * 60.0) as i64));
289+
290+
if all_up || all_down {
291+
return (None, transit, None, Some(transit_alt));
292+
}
293+
294+
let start_time = astro_time_from_datetime(day_start);
295+
296+
let rise = unsafe {
297+
let result = Astronomy_SearchRiseSetEx(
298+
body,
299+
observer,
300+
astro_direction_t_DIRECTION_RISE,
301+
start_time,
302+
1.0,
303+
height,
304+
);
305+
if result.status == astro_status_t_ASTRO_SUCCESS {
306+
Some(astro_time_to_utc(result.time))
307+
} else {
308+
None
309+
}
310+
};
311+
312+
let set = unsafe {
313+
let result = Astronomy_SearchRiseSetEx(
314+
body,
315+
observer,
316+
astro_direction_t_DIRECTION_SET,
317+
start_time,
318+
1.0,
319+
height,
320+
);
321+
if result.status == astro_status_t_ASTRO_SUCCESS {
322+
Some(astro_time_to_utc(result.time))
323+
} else {
324+
None
325+
}
326+
};
327+
328+
(rise, transit, set, Some(transit_alt))
241329
}
242330

243331
pub struct AlmanacInfo {
@@ -305,7 +393,9 @@ pub fn compute_almanac(lat: f64, lon: f64, height: f64, datetime: DateTime<Utc>,
305393
}
306394
};
307395
}
308-
AlmanacTrack { name, symbol, color_rgb, altitudes }
396+
let (rise, transit, set, transit_alt) =
397+
compute_rise_set_transit(body, observer, day_start, &altitudes, height);
398+
AlmanacTrack { name, symbol, color_rgb, altitudes, rise, transit, transit_alt, set }
309399
}).collect();
310400

311401
AlmanacInfo { tracks, current_step }

src/ui.rs

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -801,7 +801,7 @@ fn render_status(f: &mut Frame, app: &App, area: ratatui::layout::Rect) {
801801
Tab::SolarSystem =>
802802
" [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [S/W/P/A]tab [Q]quit",
803803
Tab::Almanac =>
804-
" [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [b]bodies [S/W/P/A]tab [Q]quit",
804+
" [L]locations [T]time [Z]tz [N]now [Space]pause [,/.]speed [b]bodies [t]times [S/W/P/A]tab [Q]quit",
805805
};
806806

807807
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)
10701070
Style::default().fg(Color::DarkGray),
10711071
)),
10721072
Line::from(""),
1073-
Line::from(Span::styled(
1074-
" Body Alt",
1075-
Style::default().add_modifier(Modifier::BOLD),
1076-
)),
10771073
];
10781074

1079-
for (i, track) in app.almanac.tracks.iter().enumerate() {
1080-
let visible = app.selected_bodies.get(i).copied().unwrap_or(true);
1081-
let alt = track.altitudes[app.almanac.current_step];
1082-
let (r, g, b) = track.color_rgb;
1083-
let label = if alt > 0.0 {
1084-
format!(" {} {} {:.1}°", track.symbol, track.name, alt)
1085-
} else {
1086-
format!(" {} {} below", track.symbol, track.name)
1087-
};
1088-
let style = if visible {
1089-
Style::default().fg(Color::Rgb(r, g, b))
1090-
} else {
1091-
Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
1075+
if app.almanac_show_times {
1076+
text.push(Line::from(Span::styled(
1077+
" Rise Trans Set ",
1078+
Style::default().add_modifier(Modifier::BOLD),
1079+
)));
1080+
1081+
let fmt = |dt: Option<chrono::DateTime<chrono::Utc>>| -> String {
1082+
match dt {
1083+
None => "--:--".to_string(),
1084+
Some(utc) => {
1085+
if let Some(tz) = app.timezone {
1086+
utc.with_timezone(&tz).format("%H:%M").to_string()
1087+
} else {
1088+
utc.format("%H:%M").to_string()
1089+
}
1090+
}
1091+
}
10921092
};
1093-
text.push(Line::from(Span::styled(label, style)));
1093+
1094+
for (i, track) in app.almanac.tracks.iter().enumerate() {
1095+
let visible = app.selected_bodies.get(i).copied().unwrap_or(true);
1096+
let (r, g, b) = track.color_rgb;
1097+
1098+
let all_down = track.altitudes.iter().all(|&a| a <= 0.0);
1099+
let all_up = track.altitudes.iter().all(|&a| a > 0.0);
1100+
1101+
let label = if all_down {
1102+
format!(" {} {} below horizon", track.symbol, track.name)
1103+
} else if all_up {
1104+
let max_alt = track.transit_alt.map(|a| format!(" ({:.0}°)", a)).unwrap_or_default();
1105+
format!(" {} {} always up{}", track.symbol, track.name, max_alt)
1106+
} else {
1107+
format!(
1108+
" {} {} {} {}",
1109+
track.symbol,
1110+
fmt(track.rise),
1111+
fmt(track.transit),
1112+
fmt(track.set),
1113+
)
1114+
};
1115+
let style = if visible {
1116+
Style::default().fg(Color::Rgb(r, g, b))
1117+
} else {
1118+
Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
1119+
};
1120+
text.push(Line::from(Span::styled(label, style)));
1121+
}
1122+
} else {
1123+
text.push(Line::from(Span::styled(
1124+
" Body Alt",
1125+
Style::default().add_modifier(Modifier::BOLD),
1126+
)));
1127+
1128+
for (i, track) in app.almanac.tracks.iter().enumerate() {
1129+
let visible = app.selected_bodies.get(i).copied().unwrap_or(true);
1130+
let alt = track.altitudes[app.almanac.current_step];
1131+
let (r, g, b) = track.color_rgb;
1132+
let label = if alt > 0.0 {
1133+
format!(" {} {} {:.1}°", track.symbol, track.name, alt)
1134+
} else {
1135+
format!(" {} {} below", track.symbol, track.name)
1136+
};
1137+
let style = if visible {
1138+
Style::default().fg(Color::Rgb(r, g, b))
1139+
} else {
1140+
Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)
1141+
};
1142+
text.push(Line::from(Span::styled(label, style)));
1143+
}
10941144
}
10951145

1096-
let para =
1097-
Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(" Legend "));
1146+
let title = if app.almanac_show_times { " Times [t] " } else { " Legend [t] " };
1147+
let para = Paragraph::new(text).block(Block::default().borders(Borders::ALL).title(title));
10981148
f.render_widget(para, area);
10991149
}

0 commit comments

Comments
 (0)