2bb80a8475
Features added this session: - Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously - Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query - Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export - Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance) - Best Nights 14-day card + Monthly Highlights card on Dashboard Catalog expansions: - Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset - Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps - Weather score multiplier applied to composite sort - galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
6.1 KiB
Rust
200 lines
6.1 KiB
Rust
/// Gum Catalogue of Southern HII Regions (Gum 1955).
|
|
/// Fetched from VizieR XI/75. Mostly Dec < -30° but ~20-30 entries are in range.
|
|
use anyhow::Context;
|
|
use chrono::Utc;
|
|
|
|
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
|
|
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
|
|
use crate::catalog::popular_names::popular_names;
|
|
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
|
|
|
|
const VIZIER_GUM_URL: &str =
|
|
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
|
|
?-source=XI/75\
|
|
&-out=Gum\
|
|
&-out=_RA\
|
|
&-out=_DE\
|
|
&-out=Diam\
|
|
&-out.max=200\
|
|
&-oc.form=dec";
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct GumRow {
|
|
id: u32,
|
|
ra_deg: f64,
|
|
dec_deg: f64,
|
|
diam_arcmin: f64,
|
|
}
|
|
|
|
pub async fn fetch_gum() -> anyhow::Result<Vec<CatalogEntry>> {
|
|
match fetch_from_vizier().await {
|
|
Ok(entries) if !entries.is_empty() => {
|
|
tracing::info!("Gum: loaded {} entries from VizieR XI/75", entries.len());
|
|
Ok(entries)
|
|
}
|
|
Ok(_) => {
|
|
tracing::warn!("Gum: VizieR returned 0 rows — using hardcoded fallback");
|
|
Ok(build_entries_from_rows(get_prominent_gum()))
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Gum fetch from VizieR failed ({}) — using hardcoded fallback", e);
|
|
Ok(build_entries_from_rows(get_prominent_gum()))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.user_agent("astronome/1.0")
|
|
.build()?;
|
|
|
|
let text = client
|
|
.get(VIZIER_GUM_URL)
|
|
.send()
|
|
.await
|
|
.context("Gum fetch request failed")?
|
|
.text()
|
|
.await
|
|
.context("Gum response read failed")?;
|
|
|
|
let rows = parse_vizier_tsv(&text);
|
|
tracing::info!("Gum: parsed {} rows from VizieR XI/75", rows.len());
|
|
|
|
if rows.is_empty() {
|
|
anyhow::bail!("no rows parsed from VizieR Gum response");
|
|
}
|
|
|
|
let filtered: Vec<GumRow> = rows
|
|
.into_iter()
|
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0)
|
|
.collect();
|
|
|
|
tracing::info!("Gum: {} rows pass filters (Dec >= -30°)", filtered.len());
|
|
Ok(build_entries_from_rows(filtered))
|
|
}
|
|
|
|
fn parse_vizier_tsv(text: &str) -> Vec<GumRow> {
|
|
let mut rows = Vec::new();
|
|
let mut header: Vec<String> = Vec::new();
|
|
let mut past_separator = false;
|
|
|
|
for line in text.lines() {
|
|
if line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
if header.is_empty() {
|
|
header = trimmed.split('\t').map(|s| s.trim().to_string()).collect();
|
|
if header.len() < 2 {
|
|
header = trimmed.split_whitespace().map(|s| s.to_string()).collect();
|
|
}
|
|
continue;
|
|
}
|
|
if !past_separator {
|
|
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
|
|
past_separator = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let cols: Vec<&str> = if line.contains('\t') {
|
|
line.split('\t').map(|s| s.trim()).collect()
|
|
} else {
|
|
line.split_whitespace().collect()
|
|
};
|
|
|
|
if cols.len() < 3 {
|
|
continue;
|
|
}
|
|
|
|
let col_idx = |name: &str| -> Option<usize> {
|
|
header.iter().position(|h| h.eq_ignore_ascii_case(name))
|
|
};
|
|
|
|
let id = col_idx("Gum")
|
|
.and_then(|i| cols.get(i))
|
|
.and_then(|s| s.parse::<u32>().ok());
|
|
|
|
let ra = col_idx("_RA")
|
|
.and_then(|i| cols.get(i))
|
|
.and_then(|s| s.parse::<f64>().ok());
|
|
|
|
let dec = col_idx("_DE")
|
|
.and_then(|i| cols.get(i))
|
|
.and_then(|s| s.parse::<f64>().ok());
|
|
|
|
let diam = col_idx("Diam")
|
|
.and_then(|i| cols.get(i))
|
|
.and_then(|s| s.parse::<f64>().ok())
|
|
.unwrap_or(15.0);
|
|
|
|
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
|
|
rows.push(GumRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
fn build_entries_from_rows(rows: Vec<GumRow>) -> Vec<CatalogEntry> {
|
|
let now = Utc::now().timestamp();
|
|
let names = popular_names();
|
|
rows.into_iter()
|
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0)
|
|
.map(|r| build_entry(r, now, &names))
|
|
.collect()
|
|
}
|
|
|
|
fn build_entry(
|
|
r: GumRow,
|
|
now: i64,
|
|
names: &std::collections::HashMap<&'static str, &'static str>,
|
|
) -> CatalogEntry {
|
|
let id = format!("Gum{}", r.id);
|
|
let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
|
|
let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
|
|
let panels_h = ((r.diam_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1);
|
|
let mosaic = panels_w > 1 || panels_h > 1;
|
|
let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg);
|
|
let common_name = names.get(id.as_str()).map(|s| s.to_string());
|
|
let is_highlight = common_name.is_some();
|
|
|
|
CatalogEntry {
|
|
id: id.clone(),
|
|
name: id,
|
|
common_name,
|
|
obj_type: "emission_nebula".to_string(),
|
|
ra_deg: r.ra_deg,
|
|
dec_deg: r.dec_deg,
|
|
ra_h: format_ra_hms(r.ra_deg),
|
|
dec_dms: format_dec_dms(r.dec_deg),
|
|
constellation: None,
|
|
size_arcmin_maj: Some(r.diam_arcmin),
|
|
size_arcmin_min: Some(r.diam_arcmin * 0.7),
|
|
pos_angle_deg: None,
|
|
mag_v: None,
|
|
surface_brightness: None,
|
|
hubble_type: None,
|
|
messier_num: None,
|
|
is_highlight,
|
|
fov_fill_pct: Some(fov_fill),
|
|
mosaic_flag: mosaic,
|
|
mosaic_panels_w: panels_w,
|
|
mosaic_panels_h: panels_h,
|
|
difficulty: Some(3),
|
|
guide_star_density: Some(density.to_string()),
|
|
fetched_at: now,
|
|
}
|
|
}
|
|
|
|
fn get_prominent_gum() -> Vec<GumRow> {
|
|
// Small fallback — most Gum objects are far south, these are in range
|
|
vec![
|
|
GumRow { id: 12, ra_deg: 126.0, dec_deg: -47.5, diam_arcmin: 36.0 },
|
|
GumRow { id: 17, ra_deg: 131.0, dec_deg: -43.0, diam_arcmin: 20.0 },
|
|
]
|
|
}
|