Add target comparison modal, integration goal progress, and session planning + full catalog expansion

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>
This commit is contained in:
2026-04-17 07:20:10 +02:00
parent 8f72745bc0
commit 2bb80a8475
45 changed files with 5613 additions and 628 deletions
+278
View File
@@ -0,0 +1,278 @@
/// Sharpless (Sh2) emission nebula catalog.
/// Fetched from VizieR catalog VII/20 (Sharpless 1959).
/// These are H II regions not always present in OpenNGC.
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};
/// VizieR VII/20 — Sharpless Catalog of HII Regions (1959).
const VIZIER_SH2_URL: &str =
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
?-source=VII/20\
&-out=Sh2\
&-out=MajDiam\
&-out=_RA\
&-out=_DE\
&-out.max=1000\
&-oc.form=dec";
#[derive(Debug, Clone)]
struct Sh2Row {
id: u32,
ra_deg: f64,
dec_deg: f64,
diam_arcmin: f64,
}
pub async fn fetch_sh2() -> anyhow::Result<Vec<CatalogEntry>> {
match fetch_from_vizier().await {
Ok(entries) if !entries.is_empty() => {
tracing::info!("Sh2: loaded {} entries from VizieR VII/20", entries.len());
Ok(entries)
}
Ok(_) => {
tracing::warn!("Sh2: VizieR returned 0 rows — using hardcoded fallback");
Ok(build_entries_from_rows(get_prominent_sh2()))
}
Err(e) => {
tracing::warn!("Sh2 fetch from VizieR failed ({}) — using hardcoded fallback", e);
Ok(build_entries_from_rows(get_prominent_sh2()))
}
}
}
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_SH2_URL)
.send()
.await
.context("Sh2 fetch request failed")?
.text()
.await
.context("Sh2 response read failed")?;
tracing::debug!("Sh2 raw response first 500 chars: {}", &text[..text.len().min(500)]);
let rows = parse_vizier_tsv(&text);
tracing::info!("Sh2: parsed {} rows from VizieR VII/20", rows.len());
if rows.is_empty() {
anyhow::bail!("no rows parsed from VizieR Sh2 response");
}
let total = rows.len();
let filtered: Vec<Sh2Row> = rows
.into_iter()
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
.collect();
tracing::info!("Sh2: {}/{} rows pass filters", filtered.len(), total);
Ok(build_entries_from_rows(filtered))
}
fn parse_vizier_tsv(text: &str) -> Vec<Sh2Row> {
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 = if trimmed.contains('\t') {
trimmed.split('\t').map(|s| s.trim().to_string()).collect()
} else {
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() < 2 {
continue;
}
let col_idx = |name: &str| -> Option<usize> {
header.iter().position(|h| h.eq_ignore_ascii_case(name))
};
let id = col_idx("Sh2")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<u32>().ok());
let diam = col_idx("MajDiam")
.or_else(|| col_idx("Diam"))
.or_else(|| col_idx("Dmaj"))
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.unwrap_or(15.0);
let ra = col_idx("_RA")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| cols.get(cols.len().saturating_sub(2)).and_then(|s| s.parse().ok()));
let dec = col_idx("_DE")
.and_then(|i| cols.get(i))
.and_then(|s| s.parse::<f64>().ok())
.or_else(|| cols.last().and_then(|s| s.parse().ok()));
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
rows.push(Sh2Row { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
}
}
rows
}
fn build_entries_from_rows(rows: Vec<Sh2Row>) -> 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 >= 1.0)
.map(|r| build_entry(r, now, &names))
.collect()
}
fn build_entry(r: Sh2Row, now: i64, names: &std::collections::HashMap<&'static str, &'static str>) -> CatalogEntry {
let id = format!("Sh2-{}", 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,
}
}
/// Hardcoded fallback: ~80 prominent Sharpless HII regions accessible from northern latitudes.
fn get_prominent_sh2() -> Vec<Sh2Row> {
vec![
// Winter / Spring (Orion, Auriga, Gemini, Monoceros, Perseus, Cassiopeia)
Sh2Row { id: 1, ra_deg: 0.113, dec_deg: 64.083, diam_arcmin: 20.0 }, // Cas
Sh2Row { id: 7, ra_deg: 269.533, dec_deg: -23.450, diam_arcmin: 80.0 }, // Sgr
Sh2Row { id: 9, ra_deg: 253.783, dec_deg: -34.350, diam_arcmin: 60.0 }, // Sco
Sh2Row { id: 11, ra_deg: 264.167, dec_deg: -34.483, diam_arcmin: 80.0 }, // War & Peace
Sh2Row { id: 16, ra_deg: 274.683, dec_deg: -13.783, diam_arcmin: 60.0 }, // Eagle region
Sh2Row { id: 17, ra_deg: 275.000, dec_deg: -15.200, diam_arcmin: 40.0 }, // Sgr
Sh2Row { id: 25, ra_deg: 274.000, dec_deg: -23.833, diam_arcmin: 120.0 }, // Lagoon region
Sh2Row { id: 27, ra_deg: 84.917, dec_deg: 9.333, diam_arcmin: 370.0 }, // λ Ori ring
Sh2Row { id: 29, ra_deg: 18.867, dec_deg: 61.533, diam_arcmin: 12.0 }, // Cas
Sh2Row { id: 36, ra_deg: 82.550, dec_deg: 4.033, diam_arcmin: 8.0 }, // Ori
Sh2Row { id: 64, ra_deg: 98.067, dec_deg: 4.967, diam_arcmin: 80.0 }, // Rosette
Sh2Row { id: 68, ra_deg: 86.583, dec_deg: -1.950, diam_arcmin: 30.0 }, // Flame-adjacent
Sh2Row { id: 100, ra_deg: 305.967, dec_deg: 35.817, diam_arcmin: 180.0 }, // γ Cyg / Butterfly
Sh2Row { id: 101, ra_deg: 296.867, dec_deg: 35.417, diam_arcmin: 20.0 }, // Tulip
Sh2Row { id: 103, ra_deg: 311.283, dec_deg: 31.717, diam_arcmin: 230.0 }, // Veil Complex
Sh2Row { id: 106, ra_deg: 304.883, dec_deg: 37.367, diam_arcmin: 12.0 }, // Cygnus
Sh2Row { id: 108, ra_deg: 337.417, dec_deg: -21.933, diam_arcmin: 1200.0 }, // Helix (huge)
Sh2Row { id: 119, ra_deg: 315.617, dec_deg: 44.250, diam_arcmin: 180.0 }, // North America
Sh2Row { id: 126, ra_deg: 316.833, dec_deg: 44.533, diam_arcmin: 90.0 }, // Pelican
Sh2Row { id: 129, ra_deg: 328.150, dec_deg: 60.050, diam_arcmin: 140.0 }, // Flying Bat
Sh2Row { id: 132, ra_deg: 336.550, dec_deg: 56.133, diam_arcmin: 100.0 }, // Lion
Sh2Row { id: 140, ra_deg: 336.550, dec_deg: 63.183, diam_arcmin: 10.0 }, // Cepheus SFR
Sh2Row { id: 142, ra_deg: 341.467, dec_deg: 58.433, diam_arcmin: 12.0 }, // Wizard region
Sh2Row { id: 155, ra_deg: 344.983, dec_deg: 62.383, diam_arcmin: 50.0 }, // Cave
Sh2Row { id: 157, ra_deg: 350.817, dec_deg: 60.867, diam_arcmin: 60.0 }, // Lobster Claw
Sh2Row { id: 162, ra_deg: 350.183, dec_deg: 61.217, diam_arcmin: 15.0 }, // Bubble
Sh2Row { id: 163, ra_deg: 353.383, dec_deg: 61.117, diam_arcmin: 25.0 }, // Cas
Sh2Row { id: 168, ra_deg: 358.133, dec_deg: 61.383, diam_arcmin: 60.0 }, // Cas
Sh2Row { id: 171, ra_deg: 0.500, dec_deg: 67.833, diam_arcmin: 40.0 }, // Cep
Sh2Row { id: 175, ra_deg: 85.250, dec_deg: -2.450, diam_arcmin: 40.0 }, // Horsehead region
Sh2Row { id: 184, ra_deg: 13.533, dec_deg: 56.617, diam_arcmin: 35.0 }, // Pac-Man
Sh2Row { id: 188, ra_deg: 17.633, dec_deg: 58.783, diam_arcmin: 15.0 }, // Cas
Sh2Row { id: 190, ra_deg: 38.317, dec_deg: 61.450, diam_arcmin: 100.0 }, // Heart
Sh2Row { id: 199, ra_deg: 40.433, dec_deg: 60.517, diam_arcmin: 150.0 }, // Soul
Sh2Row { id: 206, ra_deg: 55.617, dec_deg: 19.917, diam_arcmin: 30.0 }, // Per
Sh2Row { id: 207, ra_deg: 56.583, dec_deg: 23.000, diam_arcmin: 15.0 }, // Per
Sh2Row { id: 212, ra_deg: 73.617, dec_deg: 44.217, diam_arcmin: 40.0 }, // Aur
Sh2Row { id: 219, ra_deg: 79.283, dec_deg: 45.150, diam_arcmin: 30.0 }, // Aur
Sh2Row { id: 220, ra_deg: 60.583, dec_deg: 36.417, diam_arcmin: 360.0 }, // California
Sh2Row { id: 223, ra_deg: 51.800, dec_deg: 60.067, diam_arcmin: 30.0 }, // Cas
Sh2Row { id: 224, ra_deg: 53.833, dec_deg: 60.700, diam_arcmin: 25.0 }, // Cas
Sh2Row { id: 229, ra_deg: 82.750, dec_deg: 34.317, diam_arcmin: 80.0 }, // Flaming Star
Sh2Row { id: 232, ra_deg: 86.117, dec_deg: 33.450, diam_arcmin: 30.0 }, // Aur
Sh2Row { id: 234, ra_deg: 90.133, dec_deg: 37.283, diam_arcmin: 15.0 }, // Aur
Sh2Row { id: 235, ra_deg: 92.383, dec_deg: 36.633, diam_arcmin: 10.0 }, // Aur
Sh2Row { id: 240, ra_deg: 92.683, dec_deg: 27.767, diam_arcmin: 25.0 }, // Per
Sh2Row { id: 241, ra_deg: 53.417, dec_deg: 31.500, diam_arcmin: 10.0 }, // Per
Sh2Row { id: 252, ra_deg: 99.500, dec_deg: 17.983, diam_arcmin: 60.0 }, // Monkey Head
Sh2Row { id: 254, ra_deg: 98.233, dec_deg: 15.833, diam_arcmin: 10.0 }, // Mon
Sh2Row { id: 261, ra_deg: 107.417, dec_deg: -1.167, diam_arcmin: 40.0 }, // Mon
Sh2Row { id: 273, ra_deg: 117.750, dec_deg: -10.117, diam_arcmin: 20.0 }, // CMa
Sh2Row { id: 274, ra_deg: 113.567, dec_deg: 10.050, diam_arcmin: 30.0 }, // Gem / Mon
Sh2Row { id: 275, ra_deg: 115.617, dec_deg: -11.317, diam_arcmin: 50.0 }, // CMa
Sh2Row { id: 277, ra_deg: 119.083, dec_deg: -9.233, diam_arcmin: 35.0 }, // CMa
Sh2Row { id: 280, ra_deg: 128.600, dec_deg: -17.617, diam_arcmin: 50.0 }, // Pup
Sh2Row { id: 284, ra_deg: 126.883, dec_deg: -3.333, diam_arcmin: 20.0 }, // Mon
Sh2Row { id: 287, ra_deg: 161.333, dec_deg: -59.883, diam_arcmin: 240.0 },// Eta Carina
Sh2Row { id: 289, ra_deg: 131.217, dec_deg: -39.467, diam_arcmin: 80.0 }, // Pup
Sh2Row { id: 292, ra_deg: 135.617, dec_deg: -23.350, diam_arcmin: 40.0 }, // Pup
// Summer (Sagittarius, Scorpius, Aquila, Cygnus, Vulpecula)
Sh2Row { id: 302, ra_deg: 186.967, dec_deg: -62.617, diam_arcmin: 80.0 }, // Cru
Sh2Row { id: 308, ra_deg: 107.800, dec_deg: -14.683, diam_arcmin: 40.0 }, // Dolphin
// Extra Sh2 objects with known popular names
Sh2Row { id: 71, ra_deg: 302.800, dec_deg: 22.717, diam_arcmin: 8.0 }, // Dumbbell PN area
Sh2Row { id: 72, ra_deg: 271.967, dec_deg: -22.533, diam_arcmin: 6.0 }, // Little Ghost area
Sh2Row { id: 83, ra_deg: 283.400, dec_deg: 33.033, diam_arcmin: 4.0 }, // Ring PN area
Sh2Row { id: 87, ra_deg: 298.733, dec_deg: 50.517, diam_arcmin: 5.0 }, // Blinking PN area
Sh2Row { id: 105, ra_deg: 280.650, dec_deg: 23.533, diam_arcmin: 3.0 }, // Turtle PN area
Sh2Row { id: 107, ra_deg: 307.483, dec_deg: 42.133, diam_arcmin: 2.0 }, // Giraffe PN area
Sh2Row { id: 12, ra_deg: 260.583, dec_deg: -37.100, diam_arcmin: 12.0 }, // Bug PN area
Sh2Row { id: 84, ra_deg: 321.033, dec_deg: -11.367, diam_arcmin: 3.0 }, // Saturn PN area
]
}