use std::sync::OnceLock;
use std::time::Instant;
use chrono::{DateTime, Utc};
use tzf_rs::DefaultFinder;
static TZ_FINDER: OnceLock<DefaultFinder> = OnceLock::new();
pub fn resolve_tz(lat: f64, lon: f64) -> Option<chrono_tz::Tz> {
let finder = TZ_FINDER.get_or_init(DefaultFinder::new);
let name = finder.get_tz_name(lon, lat);
if name.is_empty() { None } else { name.parse().ok() }
}
use crate::sky::{self, AlmanacInfo, BestTargetsInfo, ConjunctionsInfo, OrreryInfo, RenderedDso, RenderedJupiterMoon, RenderedPlanet, RenderedStar, SunMoonInfo};
use crate::weather::HourlyForecast;
pub const SKY_SPEED_PRESETS: &[(i64, &str)] = &[
(-86400, "-1d/s"),
(-3600, "-1h/s"),
(-600, "-10m/s"),
(-60, "-1m/s"),
(-10, "-10x"),
(-1, "-1x"),
(1, "1x"), (10, "10x"),
(60, "1m/s"),
(600, "10m/s"),
(3600, "1h/s"),
(86400, "1d/s"),
];
pub const ORRERY_SPEED_PRESETS: &[(i64, &str)] = &[
(-86400 * 365 * 10, "-10y/s"),
(-86400 * 365, "-1y/s"),
(-86400 * 30, "-1mo/s"),
(-86400 * 7, "-1w/s"),
(-86400, "-1d/s"),
(86400, "1d/s"), (86400 * 7, "1w/s"),
(86400 * 30, "1mo/s"),
(86400 * 365, "1y/s"),
(86400 * 365 * 10, "10y/s"),
];
pub enum Tab {
Sky,
Weather,
SolarSystem,
Almanac,
Targets,
Conjunctions,
}
pub enum InputMode {
Normal,
LocationPicker,
AddingLocation,
EditingDatetime,
EditingTimezone,
AlmanacBodyPicker,
FovInput,
ObjectSearch,
EyepieceCalc,
}
pub struct NewLocationDraft {
pub bufs: [String; 4], pub field: usize,
pub error: Option<String>,
}
pub struct FovDraft {
pub bufs: [String; 3], pub field: usize,
pub error: Option<String>,
}
pub struct CalcDraft {
pub adding_eyepiece: bool,
pub bufs: [String; 3],
pub field: usize,
pub error: Option<String>,
}
pub struct App {
pub tab: Tab,
pub input_mode: InputMode,
pub lat: f64,
pub lon: f64,
pub height: f64,
pub timezone: Option<chrono_tz::Tz>,
pub input_buf: String,
pub datetime: DateTime<Utc>,
pub live_mode: bool,
pub sky_speed_index: usize,
pub orrery_speed_index: usize,
pub time_paused: bool,
pub last_tick: Instant,
pub max_mag: f64,
pub test_mode: bool,
pub locations: Vec<crate::config::Location>,
pub location_index: usize,
pub picker_sel: usize,
pub new_loc_draft: Option<NewLocationDraft>,
pub stars: Vec<RenderedStar>,
pub sun_moon: SunMoonInfo,
pub planets: Vec<RenderedPlanet>,
pub jupiter_moons: Vec<RenderedJupiterMoon>,
pub dsos: Vec<RenderedDso>,
pub show_dsos: bool,
pub orrery: OrreryInfo,
pub almanac: AlmanacInfo,
pub selected_bodies: Vec<bool>,
pub almanac_picker_sel: usize,
pub almanac_show_times: bool,
pub best_targets: BestTargetsInfo,
pub best_targets_scroll: usize,
pub best_targets_valid: bool,
pub conjunctions: ConjunctionsInfo,
pub conjunctions_scroll: usize,
pub conjunctions_valid: bool,
pub conjunctions_ref_time: DateTime<Utc>,
pub forecasts: Option<Vec<HourlyForecast>>,
pub weather_loading: bool,
pub weather_error: Option<String>,
pub weather_scroll: usize,
pub fov_active: bool,
pub fov_alt: f64,
pub fov_az: f64,
pub fov_deg: f64,
pub fov_draft: Option<FovDraft>,
pub search_query: String,
pub search_sel: usize,
pub scopes: Vec<crate::config::Scope>,
pub scope_sel: usize,
pub eyepieces: Vec<crate::config::Eyepiece>,
pub ep_sel: usize,
pub calc_row: usize,
pub calc_draft: Option<CalcDraft>,
}
impl App {
pub fn new(
locations: Vec<crate::config::Location>,
scopes: Vec<crate::config::Scope>,
eyepieces: Vec<crate::config::Eyepiece>,
initial_index: usize,
max_mag_override: Option<f64>,
) -> Self {
let loc = &locations[initial_index];
let timezone_override = loc.timezone.as_deref().and_then(|s| s.parse().ok());
let lat = loc.lat;
let lon = loc.lon;
let height = loc.height;
let timezone = timezone_override.or_else(|| resolve_tz(lat, lon));
let mut app = Self {
tab: Tab::Sky,
input_mode: InputMode::Normal,
lat,
lon,
height,
timezone,
input_buf: String::new(),
datetime: Utc::now(),
live_mode: false,
sky_speed_index: 6,
orrery_speed_index: 5,
time_paused: false,
last_tick: Instant::now(),
max_mag: max_mag_override.unwrap_or(5.5),
test_mode: false,
locations,
location_index: initial_index,
picker_sel: initial_index,
new_loc_draft: None,
stars: Vec::new(),
planets: Vec::new(),
jupiter_moons: Vec::new(),
dsos: Vec::new(),
show_dsos: true,
orrery: OrreryInfo { planets: Vec::new() },
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,
moon_cycle_degrees: 0.0,
sun_alt: 0.0,
sun_az: 0.0,
moon_alt: 0.0,
moon_az: 0.0,
},
best_targets: BestTargetsInfo::default(),
best_targets_scroll: 0,
best_targets_valid: false,
conjunctions: ConjunctionsInfo::default(),
conjunctions_scroll: 0,
conjunctions_valid: false,
conjunctions_ref_time: Utc::now(),
forecasts: None,
weather_loading: false,
weather_error: None,
weather_scroll: 0,
fov_active: false,
fov_alt: 45.0,
fov_az: 180.0,
fov_deg: 30.0,
fov_draft: None,
search_query: String::new(),
search_sel: 0,
scopes,
scope_sel: 0,
eyepieces,
ep_sel: 0,
calc_row: 0,
calc_draft: None,
};
app.recompute();
app
}
pub fn switch_location(&mut self, index: usize) {
if index >= self.locations.len() { return; }
let loc = &self.locations[index];
let timezone_override = loc.timezone.as_deref().and_then(|s| s.parse().ok());
self.lat = loc.lat;
self.lon = loc.lon;
self.height = loc.height;
self.timezone = timezone_override.or_else(|| resolve_tz(self.lat, self.lon));
self.location_index = index;
self.best_targets_valid = false;
self.conjunctions_valid = false;
self.recompute();
}
pub fn recompute(&mut self) {
let drift = (self.datetime - self.conjunctions_ref_time).num_hours().abs();
if drift >= 6 {
self.conjunctions_valid = false;
}
self.stars = sky::compute_stars(
self.lat,
self.lon,
self.height,
self.datetime,
self.max_mag,
self.test_mode,
);
self.sun_moon = sky::compute_sun_moon(self.lat, self.lon, self.height, self.datetime);
self.planets = sky::compute_planets(self.lat, self.lon, self.height, self.datetime);
self.jupiter_moons = sky::compute_jupiter_moons(self.lat, self.lon, self.height, self.datetime);
self.dsos = sky::compute_dsos(self.lat, self.lon, self.height, self.datetime);
self.orrery = sky::compute_orrery(self.datetime);
if matches!(self.tab, Tab::Almanac) {
self.almanac = sky::compute_almanac(self.lat, self.lon, self.height, self.datetime, self.timezone);
while self.selected_bodies.len() < self.almanac.tracks.len() {
self.selected_bodies.push(true);
}
}
if matches!(self.tab, Tab::Targets) && !self.best_targets_valid {
self.best_targets = sky::compute_best_targets(self.lat, self.lon, self.height, self.datetime, self.timezone, self.max_mag);
self.best_targets_valid = true;
}
if matches!(self.tab, Tab::Conjunctions) && !self.conjunctions_valid {
self.conjunctions = sky::compute_conjunctions(self.lat, self.lon, self.height, self.datetime);
self.conjunctions_valid = true;
self.conjunctions_ref_time = self.datetime;
}
}
}
pub type SearchHit = (&'static str, String, f64, f64);
pub fn search_hits(app: &App, query: &str) -> Vec<SearchHit> {
let q = query.to_lowercase();
let mut hits: Vec<SearchHit> = Vec::new();
for dso in &app.dsos {
let matches = q.is_empty()
|| dso.catalog.to_lowercase().contains(&q)
|| dso.name.to_lowercase().contains(&q);
if matches {
let label = if dso.name.is_empty() {
dso.catalog.to_string()
} else {
format!("{} {}", dso.catalog, dso.name)
};
use stellui::dso::DsoKind;
let symbol = match dso.kind {
DsoKind::Galaxy => "⊙",
DsoKind::OpenCluster => "○",
DsoKind::GlobularCluster => "⊕",
DsoKind::Nebula => "☁",
DsoKind::PlanetaryNebula => "◎",
DsoKind::SupernovaRemnant | DsoKind::Other => "✦",
};
hits.push((symbol, label, dso.alt, dso.az));
}
}
for planet in &app.planets {
if q.is_empty() || planet.name.to_lowercase().contains(&q) {
hits.push((planet.symbol, planet.name.to_string(), planet.alt, planet.az));
}
}
if q.is_empty() || "sun".contains(&q) {
hits.push(("☀", "Sun".to_string(), app.sun_moon.sun_alt, app.sun_moon.sun_az));
}
if q.is_empty() || "moon".contains(&q) {
hits.push(("☽", "Moon".to_string(), app.sun_moon.moon_alt, app.sun_moon.moon_az));
}
hits.sort_by(|a, b| {
let a_up = a.2 >= 0.0;
let b_up = b.2 >= 0.0;
b_up.cmp(&a_up).then_with(|| a.1.cmp(&b.1))
});
hits
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDateTime, TimeZone, Utc};
#[test]
fn central_time_dst_after_spring_forward() {
let utc = Utc.from_utc_datetime(
&NaiveDateTime::parse_from_str("2026-03-11 21:00", "%Y-%m-%d %H:%M").unwrap()
);
let tz = "America/Chicago".parse::<chrono_tz::Tz>().unwrap();
let local = utc.with_timezone(&tz);
assert_eq!(local.format("%H:%M %Z").to_string(), "16:00 CDT");
}
#[test]
fn resolve_tz_chicago() {
let tz = resolve_tz(41.88, -87.63);
assert!(tz.is_some());
assert_eq!(tz.unwrap().name(), "America/Chicago");
}
}