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:
+228
-106
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user