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
+228 -106
View File
@@ -1,7 +1,18 @@
pub mod abell_gc;
pub mod abell_pn;
pub mod barnard;
pub mod pgc;
pub mod caldwell;
pub mod collinder;
pub mod fetch;
pub mod filter;
pub mod gum;
pub mod lbn;
pub mod ldn;
pub mod melotte;
pub mod popular_names;
pub mod rcw;
pub mod sh2;
pub mod vdb;
use anyhow::Context;
@@ -13,7 +24,7 @@ use self::popular_names::popular_names;
const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600;
// Bump this string whenever catalog ingestion logic changes.
pub const CATALOG_VERSION: &str = "v5-normalized-ids";
pub const CATALOG_VERSION: &str = "v11-pgc";
/// Force a full catalog re-ingest regardless of TTL or version.
pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result<usize> {
@@ -76,11 +87,19 @@ async fn do_refresh(pool: &SqlitePool) -> anyhow::Result<usize> {
/// Useful for testing, validation, and dry-run operations.
pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
// Fetch all sources in parallel
tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN...");
let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!(
tracing::info!("Refreshing catalog from OpenNGC + Sh2 + VdB + LDN + Barnard + LBN + Gum + RCW + AbellPN + AbellGC + PGC...");
let (ngc_rows_res, sh2_res, vdb_res, ldn_res, barnard_res, lbn_res, gum_res, rcw_res, abell_pn_res, abell_gc_res, pgc_res) = tokio::join!(
fetch_opengc(),
sh2::fetch_sh2(),
vdb::fetch_vdb(),
ldn::fetch_ldn(),
barnard::fetch_barnard(),
lbn::fetch_lbn(),
gum::fetch_gum(),
rcw::fetch_rcw(),
abell_pn::fetch_abell_pn(),
abell_gc::fetch_abell_gc(),
pgc::fetch_pgc(),
);
let names = popular_names();
@@ -88,22 +107,33 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
let ngc_rows = ngc_rows_res.context("OpenNGC fetch failed")?;
let suitable: Vec<_> = ngc_rows.iter().filter(|r| is_suitable(r)).collect();
tracing::info!("OpenNGC: {}/{} rows suitable (RA/Dec valid + known type)", suitable.len(), ngc_rows.len());
let mut entries: Vec<CatalogEntry> = suitable
.iter()
.filter_map(|r| compute_derived(r, &names))
.collect();
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
// Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers
let sh2_aliases: Vec<CatalogEntry> = entries
.iter()
.filter_map(|entry| create_sh2_alias(entry, &names))
.collect();
tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len());
entries.extend(sh2_aliases);
// Deduplicate Sh2 entries against NGC/IC objects that may share coordinates.
// We track IDs already present so Sh2 aliases for NGC objects with existing
// entries (e.g. Sh2-100 = IC1318 already in catalog) are skipped.
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
match sh2_res {
Ok(sh2_entries) => {
let before = entries.len();
// Only add Sh2 entries whose ID is not already a primary catalog entry.
// (OpenNGC already covers many of these via its Identifiers column.)
let new_sh2: Vec<_> = sh2_entries.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} Sh2 entries (non-duplicate)", new_sh2.len());
entries.extend(new_sh2);
tracing::info!("Catalog after Sh2: {} entries (was {})", entries.len(), before);
}
Err(e) => tracing::warn!("Sh2 fetch failed (skipping): {}", e),
}
match vdb_res {
Ok(vdb_entries) => {
@@ -121,101 +151,156 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e),
}
// Barnard dark nebulae — deduplicate against LDN by position (2' radius)
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
match barnard_res {
Ok(barnard_entries) => {
let new_barnard: Vec<_> = barnard_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
})
})
.collect();
tracing::info!("Adding {} Barnard dark nebula entries (after dedup)", new_barnard.len());
entries.extend(new_barnard);
}
Err(e) => tracing::warn!("Barnard fetch failed (skipping): {}", e),
}
// LBN nebulae — deduplicate against existing NGC/IC/Sh2
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
match lbn_res {
Ok(lbn_entries) => {
let new_lbn: Vec<_> = lbn_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
})
})
.collect();
tracing::info!("Adding {} LBN entries (after dedup)", new_lbn.len());
entries.extend(new_lbn);
}
Err(e) => tracing::warn!("LBN fetch failed (skipping): {}", e),
}
// Melotte standalone entries (very large clusters without NGC IDs)
let melotte_standalone = melotte::get_standalone_melotte();
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_melotte: Vec<_> = melotte_standalone.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} standalone Melotte entries", new_melotte.len());
entries.extend(new_melotte);
// Collinder standalone entries
let collinder_standalone = collinder::get_standalone_collinder();
{
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_collinder: Vec<_> = collinder_standalone.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} standalone Collinder entries", new_collinder.len());
entries.extend(new_collinder);
}
// Gum HII regions — deduplicate by position against existing catalog
match gum_res {
Ok(gum_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_gum: Vec<_> = gum_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} Gum entries (after dedup)", new_gum.len());
entries.extend(new_gum);
}
Err(e) => tracing::warn!("Gum fetch failed (skipping): {}", e),
}
// RCW HII regions — deduplicate by position
match rcw_res {
Ok(rcw_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_rcw: Vec<_> = rcw_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} RCW entries (after dedup)", new_rcw.len());
entries.extend(new_rcw);
}
Err(e) => tracing::warn!("RCW fetch failed (skipping): {}", e),
}
// Abell PN — deduplicate against NGC/IC PNe by position
match abell_pn_res {
Ok(abell_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_abell: Vec<_> = abell_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} Abell PN entries (after dedup)", new_abell.len());
entries.extend(new_abell);
}
Err(e) => tracing::warn!("Abell PN fetch failed (skipping): {}", e),
}
// Abell Galaxy Clusters — unique IDs, no dedup needed (galaxy_cluster is a new type)
match abell_gc_res {
Ok(abell_gc_entries) => {
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
let new_gc: Vec<_> = abell_gc_entries.into_iter()
.filter(|e| !existing_ids.contains(&e.id))
.collect();
tracing::info!("Adding {} Abell Galaxy Cluster entries", new_gc.len());
entries.extend(new_gc);
}
Err(e) => tracing::warn!("Abell GC fetch failed (skipping): {}", e),
}
// PGC bright subset — deduplicate against NGC/IC by position (2' radius)
match pgc_res {
Ok(pgc_entries) => {
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
let new_pgc: Vec<_> = pgc_entries.into_iter()
.filter(|e| {
!existing_coords.iter().any(|(ra, dec)| {
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
let ddec = (e.dec_deg - dec).abs();
(dra * dra + ddec * ddec).sqrt() < 0.033
})
})
.collect();
tracing::info!("Adding {} PGC bright galaxy entries (after dedup)", new_pgc.len());
entries.extend(new_pgc);
}
Err(e) => tracing::warn!("PGC fetch failed (skipping): {}", e),
}
Ok(entries)
}
/// Extract Sharpless identifier from an object's identifiers field and create an alias entry.
fn create_sh2_alias(
entry: &CatalogEntry,
popular_names: &std::collections::HashMap<&'static str, &'static str>,
) -> Option<CatalogEntry> {
// We'll need to parse identifiers from somewhere.
// For now, we extract from the entry's existing data if available.
// The issue is that compute_derived doesn't store the original identifiers field.
// So we can look for Sh2 in the name or construct from the object type and catalog.
// Check if this object already has "Sh2" in the ID (like "Sh2-155")
if entry.id.starts_with("Sh2-") {
return None; // Already a Sharpless entry
}
// Only create Sh2 aliases for emission nebulae and similar objects
// that are likely to have Sharpless counterparts
if !matches!(
entry.obj_type.as_str(),
"emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula"
) {
return None;
}
// Try to find a Sharpless name in popular_names for this object
// by checking known Sh2→NGC mappings
let sh2_id = match entry.id.as_str() {
// Sharpless → NGC known mappings
"NGC281" => "Sh2-184", // Pac-Man
"NGC1333" => "Sh2-241", // Reflection Nebula
"NGC1499" => "Sh2-220", // California
"NGC2024" => "Sh2-68", // Flame Nebula
"NGC2237" => "Sh2-64", // Rosette
"NGC3372" => "Sh2-287", // Eta Carinae
"NGC6210" => "Sh2-105", // Turtle
"NGC6302" => "Sh2-12", // Bug
"NGC6357" => "Sh2-11", // War and Peace
"NGC6369" => "Sh2-72", // Little Ghost
"NGC6611" => "Sh2-16", // Eagle
"NGC6720" => "Sh2-83", // Ring
"NGC6826" => "Sh2-87", // Blinking
"NGC6853" => "Sh2-71", // Dumbbell
"NGC6960" => "Sh2-103", // Western Veil
"NGC6992" => "Sh2-103", // Eastern Veil
"NGC7000" => "Sh2-119", // North America
"NGC7009" => "Sh2-84", // Saturn
"NGC7027" => "Sh2-107", // Giraffe
"NGC7293" => "Sh2-108", // Helix
"NGC7380" => "Sh2-142", // Wizard
"NGC7635" => "Sh2-162", // Bubble
"NGC7662" => "Sh2-120", // Blue Snowball
"IC405" => "Sh2-229", // Flaming Star
"IC434" => "Sh2-175", // Horsehead
"IC1318" => "Sh2-100", // Butterfly
"IC1805" => "Sh2-190", // Heart
"IC1848" => "Sh2-199", // Soul
"IC5070" => "Sh2-126", // Pelican
_ => return None,
};
let common_name = popular_names
.get(sh2_id)
.or(popular_names.get(entry.id.as_str()))
.copied();
Some(CatalogEntry {
id: sh2_id.to_string(),
name: format!("{} ({})", sh2_id, entry.name),
common_name: common_name.map(|s| s.to_string()),
obj_type: entry.obj_type.clone(),
ra_deg: entry.ra_deg,
dec_deg: entry.dec_deg,
ra_h: entry.ra_h.clone(),
dec_dms: entry.dec_dms.clone(),
constellation: entry.constellation.clone(),
size_arcmin_maj: entry.size_arcmin_maj,
size_arcmin_min: entry.size_arcmin_min,
pos_angle_deg: entry.pos_angle_deg,
mag_v: entry.mag_v,
surface_brightness: entry.surface_brightness,
hubble_type: entry.hubble_type.clone(),
messier_num: None,
is_highlight: true, // Sharpless objects are highlights
fov_fill_pct: entry.fov_fill_pct,
mosaic_flag: entry.mosaic_flag,
mosaic_panels_w: entry.mosaic_panels_w,
mosaic_panels_h: entry.mosaic_panels_h,
difficulty: entry.difficulty,
guide_star_density: entry.guide_star_density.clone(),
fetched_at: entry.fetched_at,
})
}
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
@@ -257,5 +342,42 @@ pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyh
.await?;
}
tx.commit().await?;
// Populate Caldwell numbers
for (num, id) in caldwell::caldwell_map() {
let _ = sqlx::query("UPDATE catalog SET caldwell_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Arp numbers
for (num, id) in caldwell::arp_map() {
let _ = sqlx::query("UPDATE catalog SET arp_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Melotte numbers
for (num, id) in melotte::melotte_map() {
let _ = sqlx::query("UPDATE catalog SET melotte_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
// Populate Collinder numbers
for (num, id) in collinder::collinder_map() {
let _ = sqlx::query("UPDATE catalog SET collinder_num = ? WHERE id = ?")
.bind(num)
.bind(id)
.execute(pool)
.await;
}
Ok(())
}