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:
@@ -140,6 +140,135 @@ pub async fn get_calendar(
|
||||
Ok(Json(serde_json::json!({ "days": days })))
|
||||
}
|
||||
|
||||
/// Returns the next 14 nights ranked by composite night score.
|
||||
pub async fn get_best_nights(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use sqlx::Row;
|
||||
let today = chrono::Utc::now().naive_utc().date();
|
||||
|
||||
let mut nights: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for i in 0..14i64 {
|
||||
let date = today + chrono::Duration::days(i);
|
||||
let date_str = date.to_string();
|
||||
let moon_illum = moon_illum_for_date(date);
|
||||
|
||||
// Pull nightly cache stats for this night
|
||||
let cache_row = sqlx::query(
|
||||
r#"SELECT
|
||||
COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count,
|
||||
AVG(CASE WHEN nc.max_alt_deg >= 30 THEN nc.usable_min ELSE NULL END) as avg_usable_min
|
||||
FROM nightly_cache nc
|
||||
WHERE nc.night_date = ?"#,
|
||||
)
|
||||
.bind(&date_str)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (visible_count, avg_usable_min): (i64, f64) = cache_row.as_ref().map(|r| (
|
||||
r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0),
|
||||
r.try_get::<Option<f64>, _>("avg_usable_min").unwrap_or_default().unwrap_or(0.0),
|
||||
)).unwrap_or((0, 0.0));
|
||||
|
||||
// Moon score: low illumination = better
|
||||
let moon_score = 1.0 - moon_illum;
|
||||
// Dark hours score: normalize to 0–1 assuming max useful ~480 min
|
||||
let dark_score = (avg_usable_min / 480.0).min(1.0);
|
||||
// Composite (no weather score for future nights without forecast)
|
||||
let score = moon_score * 0.5 + dark_score * 0.5;
|
||||
|
||||
// Top 3 targets for this night
|
||||
let top_targets_rows = sqlx::query(
|
||||
r#"SELECT c.id, c.name, c.common_name, c.obj_type, nc.max_alt_deg, nc.usable_min, nc.recommended_filter
|
||||
FROM nightly_cache nc
|
||||
JOIN catalog c ON c.id = nc.catalog_id
|
||||
WHERE nc.night_date = ? AND nc.max_alt_deg >= 20
|
||||
ORDER BY nc.max_alt_deg DESC
|
||||
LIMIT 3"#,
|
||||
)
|
||||
.bind(&date_str)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let top_targets: Vec<serde_json::Value> = top_targets_rows.iter().map(|r| serde_json::json!({
|
||||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||
})).collect();
|
||||
|
||||
nights.push(serde_json::json!({
|
||||
"date": date_str,
|
||||
"score": (score * 100.0) as u32,
|
||||
"moon_illumination": moon_illum,
|
||||
"visible_count": visible_count,
|
||||
"avg_usable_min": avg_usable_min as u32,
|
||||
"top_targets": top_targets,
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
nights.sort_by(|a, b| {
|
||||
let sa = a["score"].as_u64().unwrap_or(0);
|
||||
let sb = b["score"].as_u64().unwrap_or(0);
|
||||
sb.cmp(&sa)
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "nights": nights })))
|
||||
}
|
||||
|
||||
/// Returns top 5 targets that peak this calendar month, preferring not-yet-imaged.
|
||||
pub async fn get_monthly_highlights(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use sqlx::Row;
|
||||
let today = chrono::Utc::now().naive_utc().date();
|
||||
let month_prefix = today.format("%Y-%m").to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT
|
||||
c.id, c.name, c.common_name, c.obj_type, c.constellation,
|
||||
MAX(nc.max_alt_deg) as peak_alt,
|
||||
MAX(nc.usable_min) as best_usable,
|
||||
nc.recommended_filter,
|
||||
nc.transit_utc,
|
||||
(SELECT COUNT(*) FROM imaging_log il
|
||||
WHERE il.catalog_id = c.id AND il.quality = 'keeper') as keeper_count
|
||||
FROM nightly_cache nc
|
||||
JOIN catalog c ON c.id = nc.catalog_id
|
||||
WHERE nc.night_date LIKE ?
|
||||
AND nc.max_alt_deg >= 20
|
||||
AND nc.is_visible_tonight = 1
|
||||
GROUP BY c.id
|
||||
ORDER BY keeper_count ASC, peak_alt DESC
|
||||
LIMIT 8"#,
|
||||
)
|
||||
.bind(format!("{}%", month_prefix))
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let highlights: Vec<serde_json::Value> = rows.iter().map(|r| serde_json::json!({
|
||||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"constellation": r.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||
"peak_alt": r.try_get::<Option<f64>, _>("peak_alt").unwrap_or_default(),
|
||||
"best_usable_min": r.try_get::<Option<i32>, _>("best_usable").unwrap_or_default(),
|
||||
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||
"keeper_count": r.try_get::<i64, _>("keeper_count").unwrap_or_default(),
|
||||
})).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"month": month_prefix,
|
||||
"highlights": highlights,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn get_calendar_date(
|
||||
State(state): State<AppState>,
|
||||
Path(date): Path<String>,
|
||||
|
||||
@@ -30,7 +30,7 @@ pub async fn list_all_gallery(
|
||||
"id": id,
|
||||
"catalog_id": &catalog_id,
|
||||
"filename": &filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
|
||||
@@ -60,7 +60,7 @@ pub async fn list_gallery(
|
||||
"id": id,
|
||||
"catalog_id": catalog_id,
|
||||
"filename": filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||
})
|
||||
@@ -147,7 +147,7 @@ pub async fn upload_image(
|
||||
"id": id,
|
||||
"catalog_id": catalog_id,
|
||||
"filename": filename,
|
||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
||||
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,15 @@ pub fn build_router(pool: SqlitePool) -> Router {
|
||||
.route("/api/targets/:id/filters", get(targets::get_filters))
|
||||
.route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler))
|
||||
.route("/api/targets/:id/yearly", get(targets::get_yearly))
|
||||
.route("/api/targets/:id/similar", get(targets::get_similar))
|
||||
.route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes))
|
||||
// Tonight
|
||||
.route("/api/tonight", get(tonight::get_tonight))
|
||||
// Calendar
|
||||
.route("/api/calendar", get(calendar::get_calendar))
|
||||
.route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows))
|
||||
.route("/api/calendar/best-nights", get(calendar::get_best_nights))
|
||||
.route("/api/calendar/monthly-highlights", get(calendar::get_monthly_highlights))
|
||||
.route("/api/calendar/:date", get(calendar::get_calendar_date))
|
||||
// Weather
|
||||
.route("/api/weather", get(weather::get_weather))
|
||||
@@ -103,8 +106,9 @@ pub fn build_router(pool: SqlitePool) -> Router {
|
||||
// Stats
|
||||
.route("/api/stats", get(stats::get_stats))
|
||||
// Static gallery files served via tower-http
|
||||
// Must be under /api/ so nginx proxies it to the backend, not the frontend.
|
||||
.nest_service(
|
||||
"/data/gallery",
|
||||
"/api/gallery/files",
|
||||
tower_http::services::ServeDir::new(gallery_dir),
|
||||
)
|
||||
.with_state(state)
|
||||
|
||||
@@ -73,7 +73,7 @@ pub struct SolarSystemObject {
|
||||
pub is_visible: bool, // alt > 15°
|
||||
}
|
||||
|
||||
fn fmt_ra(ra: f64) -> String {
|
||||
pub fn fmt_ra(ra: f64) -> String {
|
||||
let total_sec = (ra / 15.0) * 3600.0;
|
||||
let h = (total_sec / 3600.0) as u32;
|
||||
let m = ((total_sec % 3600.0) / 60.0) as u32;
|
||||
@@ -81,7 +81,7 @@ fn fmt_ra(ra: f64) -> String {
|
||||
format!("{:02}h {:02}m {:02}s", h, m, s)
|
||||
}
|
||||
|
||||
fn fmt_dec(dec: f64) -> String {
|
||||
pub fn fmt_dec(dec: f64) -> String {
|
||||
let sign = if dec < 0.0 { "-" } else { "+" };
|
||||
let abs = dec.abs();
|
||||
let d = abs as u32;
|
||||
|
||||
@@ -137,6 +137,168 @@ pub async fn get_stats(
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Integration gap detector: targets with one narrowband filter but missing the companion.
|
||||
// Pairs: sv220 ↔ c2 (for full SHO palette), uvir ↔ sv260 (broadband pair).
|
||||
let gaps_raw = sqlx::query(
|
||||
r#"SELECT
|
||||
l.catalog_id,
|
||||
c.name,
|
||||
c.common_name,
|
||||
c.obj_type,
|
||||
SUM(CASE WHEN l.filter_id = 'sv220' THEN l.integration_min ELSE 0 END) as sv220_min,
|
||||
SUM(CASE WHEN l.filter_id = 'c2' THEN l.integration_min ELSE 0 END) as c2_min,
|
||||
SUM(CASE WHEN l.filter_id = 'uvir' THEN l.integration_min ELSE 0 END) as uvir_min,
|
||||
SUM(CASE WHEN l.filter_id = 'sv260' THEN l.integration_min ELSE 0 END) as sv260_min
|
||||
FROM imaging_log l
|
||||
JOIN catalog c ON c.id = l.catalog_id
|
||||
WHERE l.quality IN ('keeper', 'needs_more')
|
||||
GROUP BY l.catalog_id
|
||||
HAVING (sv220_min > 0 AND c2_min = 0)
|
||||
OR (c2_min > 0 AND sv220_min = 0)
|
||||
OR (uvir_min > 0 AND sv260_min = 0)
|
||||
OR (sv260_min > 0 AND uvir_min = 0)
|
||||
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
|
||||
LIMIT 10"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let gaps: Vec<serde_json::Value> = gaps_raw.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
let sv220_min: i64 = r.try_get("sv220_min").unwrap_or(0);
|
||||
let c2_min: i64 = r.try_get("c2_min").unwrap_or(0);
|
||||
let uvir_min: i64 = r.try_get("uvir_min").unwrap_or(0);
|
||||
let sv260_min: i64 = r.try_get("sv260_min").unwrap_or(0);
|
||||
|
||||
let mut missing: Vec<&str> = Vec::new();
|
||||
if sv220_min > 0 && c2_min == 0 { missing.push("c2"); }
|
||||
if c2_min > 0 && sv220_min == 0 { missing.push("sv220"); }
|
||||
if uvir_min > 0 && sv260_min == 0 { missing.push("sv260"); }
|
||||
if sv260_min > 0 && uvir_min == 0 { missing.push("uvir"); }
|
||||
|
||||
serde_json::json!({
|
||||
"catalog_id": r.try_get::<String, _>("catalog_id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"sv220_min": sv220_min,
|
||||
"c2_min": c2_min,
|
||||
"uvir_min": uvir_min,
|
||||
"sv260_min": sv260_min,
|
||||
"missing_filters": missing,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Catalogue completion: per-catalogue keeper counts vs total observable
|
||||
struct CatEntry { name: &'static str, sql_filter: &'static str }
|
||||
let catalogues: &[CatEntry] = &[
|
||||
CatEntry { name: "Messier", sql_filter: "c.messier_num IS NOT NULL" },
|
||||
CatEntry { name: "Caldwell", sql_filter: "c.caldwell_num IS NOT NULL" },
|
||||
CatEntry { name: "Sharpless", sql_filter: "c.id LIKE 'Sh2-%'" },
|
||||
CatEntry { name: "LDN", sql_filter: "c.id LIKE 'LDN%'" },
|
||||
CatEntry { name: "VdB", sql_filter: "c.id LIKE 'VdB%'" },
|
||||
CatEntry { name: "NGC", sql_filter: "c.id LIKE 'NGC%'" },
|
||||
CatEntry { name: "IC", sql_filter: "c.id LIKE 'IC%'" },
|
||||
];
|
||||
|
||||
let mut catalogue_completion: Vec<serde_json::Value> = Vec::new();
|
||||
for cat in catalogues {
|
||||
let total_sql = format!("SELECT COUNT(DISTINCT c.id) FROM catalog c WHERE {}", cat.sql_filter);
|
||||
let keeper_sql = format!(
|
||||
"SELECT COUNT(DISTINCT l.catalog_id) FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality = 'keeper' AND {}",
|
||||
cat.sql_filter
|
||||
);
|
||||
let total: i64 = sqlx::query_scalar::<_, i64>(&total_sql)
|
||||
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||
let keepers: i64 = sqlx::query_scalar::<_, i64>(&keeper_sql)
|
||||
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||
if total > 0 {
|
||||
catalogue_completion.push(serde_json::json!({
|
||||
"name": cat.name,
|
||||
"total": total,
|
||||
"keepers": keepers,
|
||||
"pct": if total > 0 { (keepers as f64 / total as f64 * 100.0).round() as i64 } else { 0 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Session history timeline: all sessions with first gallery image if available
|
||||
let history_rows = sqlx::query(
|
||||
r#"SELECT
|
||||
l.session_date,
|
||||
l.catalog_id,
|
||||
COALESCE(c.name, l.catalog_id) as name,
|
||||
c.common_name,
|
||||
c.obj_type,
|
||||
l.filter_id,
|
||||
l.integration_min,
|
||||
l.quality,
|
||||
l.notes,
|
||||
g.filename as gallery_filename
|
||||
FROM imaging_log l
|
||||
LEFT JOIN catalog c ON c.id = l.catalog_id
|
||||
LEFT JOIN (
|
||||
SELECT catalog_id, MIN(filename) as filename
|
||||
FROM gallery
|
||||
GROUP BY catalog_id
|
||||
) g ON g.catalog_id = l.catalog_id
|
||||
ORDER BY l.session_date DESC, l.created_at DESC
|
||||
LIMIT 500"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let history: Vec<serde_json::Value> = history_rows.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
|
||||
let gallery_filename: Option<String> = r.try_get("gallery_filename").unwrap_or_default();
|
||||
let gallery_url = gallery_filename.map(|f| format!("/api/gallery/files/{}/{}", catalog_id, f));
|
||||
serde_json::json!({
|
||||
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||
"catalog_id": catalog_id,
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
|
||||
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||
"integration_min": r.try_get::<i64, _>("integration_min").unwrap_or(0),
|
||||
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||
"gallery_url": gallery_url,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
// Integration goals: keeper minutes per filter per target, for goal progress tracking
|
||||
let goals_raw = sqlx::query(
|
||||
r#"SELECT
|
||||
c.id, c.name, c.common_name, c.obj_type,
|
||||
SUM(CASE WHEN l.filter_id = 'sv220' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv220_min,
|
||||
SUM(CASE WHEN l.filter_id = 'c2' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as c2_min,
|
||||
SUM(CASE WHEN l.filter_id = 'uvir' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as uvir_min,
|
||||
SUM(CASE WHEN l.filter_id = 'sv260' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv260_min
|
||||
FROM imaging_log l
|
||||
JOIN catalog c ON c.id = l.catalog_id
|
||||
WHERE l.quality = 'keeper'
|
||||
GROUP BY l.catalog_id
|
||||
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
|
||||
LIMIT 30"#,
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let integration_goals: Vec<serde_json::Value> = goals_raw.iter().map(|r| {
|
||||
use sqlx::Row;
|
||||
serde_json::json!({
|
||||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"sv220_min": r.try_get::<i64, _>("sv220_min").unwrap_or(0),
|
||||
"c2_min": r.try_get::<i64, _>("c2_min").unwrap_or(0),
|
||||
"uvir_min": r.try_get::<i64, _>("uvir_min").unwrap_or(0),
|
||||
"sv260_min": r.try_get::<i64, _>("sv260_min").unwrap_or(0),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total_sessions": total_sessions,
|
||||
"total_integration_min": total_integration_min.unwrap_or(0),
|
||||
@@ -147,5 +309,9 @@ pub async fn get_stats(
|
||||
"quality": quality_stats,
|
||||
"top_targets": top_target_list,
|
||||
"guiding": guiding_data,
|
||||
"integration_gaps": gaps,
|
||||
"history": history,
|
||||
"catalogue_completion": catalogue_completion,
|
||||
"integration_goals": integration_goals,
|
||||
})))
|
||||
}
|
||||
|
||||
+362
-101
@@ -8,12 +8,46 @@ use crate::{
|
||||
astronomy::{
|
||||
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
|
||||
moon_position, HorizonPoint, MoonState, TonightWindow,
|
||||
coords::radec_to_altaz,
|
||||
time::local_sidereal_time,
|
||||
},
|
||||
config::{LAT, LON},
|
||||
filters::{get_workflow, recommend_filters},
|
||||
};
|
||||
|
||||
use super::{AppError, AppState};
|
||||
use super::solar_system::{fmt_ra, fmt_dec};
|
||||
|
||||
/// Look up (ra_deg, dec_deg, obj_type) from catalog first, then custom_targets.
|
||||
/// Returns None if the target is not found in either table or has no coordinates.
|
||||
async fn lookup_coords(pool: &sqlx::SqlitePool, id: &str) -> Result<Option<(f64, f64, String)>, AppError> {
|
||||
use sqlx::Row;
|
||||
if let Some(row) = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some((
|
||||
row.try_get("ra_deg").unwrap_or_default(),
|
||||
row.try_get("dec_deg").unwrap_or_default(),
|
||||
row.try_get("obj_type").unwrap_or_default(),
|
||||
)));
|
||||
}
|
||||
if let Some(row) = sqlx::query(
|
||||
"SELECT ra_deg, dec_deg, obj_type FROM custom_targets WHERE id = ? AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some((
|
||||
row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||
row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||
row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
|
||||
)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TargetsQuery {
|
||||
@@ -30,6 +64,7 @@ pub struct TargetsQuery {
|
||||
pub min_usable_min: Option<i32>,
|
||||
pub mosaic_only: Option<bool>,
|
||||
pub not_imaged: Option<bool>,
|
||||
pub show_custom: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
@@ -81,8 +116,17 @@ pub async fn list_targets(
|
||||
let mut bind_values: Vec<String> = vec![];
|
||||
|
||||
if let Some(ref t) = params.obj_type {
|
||||
conditions.push("c.obj_type = ?".to_string());
|
||||
bind_values.push(t.clone());
|
||||
let types: Vec<&str> = t.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if types.len() == 1 {
|
||||
conditions.push("c.obj_type = ?".to_string());
|
||||
bind_values.push(types[0].to_string());
|
||||
} else if !types.is_empty() {
|
||||
let placeholders = types.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||
conditions.push(format!("c.obj_type IN ({})", placeholders));
|
||||
for ty in &types {
|
||||
bind_values.push(ty.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref con) = params.constellation {
|
||||
conditions.push("c.constellation = ?".to_string());
|
||||
@@ -92,7 +136,7 @@ pub async fn list_targets(
|
||||
// Filter by filter suitability: use object-type compatibility, not moon-state-dependent recommended_filter.
|
||||
// This ensures these filters always return results regardless of current moon phase.
|
||||
match f.as_str() {
|
||||
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
|
||||
"uvir" => conditions.push("c.obj_type IN ('galaxy', 'galaxy_cluster', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()),
|
||||
"c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()),
|
||||
"sv260" => {}, // LP filter works for all object types — no restriction
|
||||
_ => {
|
||||
@@ -120,9 +164,9 @@ pub async fn list_targets(
|
||||
// (e.g. globular clusters, dark nebulae, open clusters) still appear.
|
||||
// Skip filter when search is active so you can find objects like M31 off-season.
|
||||
if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() {
|
||||
// Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg)
|
||||
// so freshly added VdB/LDN objects are visible before the first nightly precompute.
|
||||
conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string());
|
||||
// Use horizon-aware is_visible_tonight flag stored in nightly_cache.
|
||||
// Fall back to max_alt_deg >= 15 for rows that predate the flag (NULL).
|
||||
conditions.push("(nc.is_visible_tonight = 1 OR (nc.is_visible_tonight IS NULL AND nc.max_alt_deg >= 15) OR nc.catalog_id IS NULL)".to_string());
|
||||
}
|
||||
if let Some(ref s) = params.search {
|
||||
let like = format!("%{}%", s);
|
||||
@@ -130,6 +174,15 @@ pub async fn list_targets(
|
||||
let m_num: Option<i32> = s.trim()
|
||||
.strip_prefix(['M', 'm'])
|
||||
.and_then(|n| n.parse().ok());
|
||||
// Support C-number search (e.g. "C20" → caldwell_num = 20)
|
||||
let c_num: Option<i32> = s.trim()
|
||||
.strip_prefix(['C', 'c'])
|
||||
.and_then(|n| n.parse().ok());
|
||||
// Support Arp search (e.g. "Arp85" → arp_num = 85)
|
||||
let arp_num: Option<i32> = s.trim()
|
||||
.to_lowercase()
|
||||
.strip_prefix("arp")
|
||||
.and_then(|n| n.trim().parse().ok());
|
||||
if let Some(m) = m_num {
|
||||
conditions.push(format!(
|
||||
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})",
|
||||
@@ -138,6 +191,21 @@ pub async fn list_targets(
|
||||
bind_values.push(like.clone());
|
||||
bind_values.push(like.clone());
|
||||
bind_values.push(like);
|
||||
} else if let Some(c) = c_num {
|
||||
conditions.push(format!(
|
||||
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.caldwell_num = {})",
|
||||
c
|
||||
));
|
||||
bind_values.push(like.clone());
|
||||
bind_values.push(like.clone());
|
||||
bind_values.push(like);
|
||||
} else if let Some(a) = arp_num {
|
||||
conditions.push(format!(
|
||||
"(c.name LIKE ? OR c.common_name LIKE ? OR c.arp_num = {})",
|
||||
a
|
||||
));
|
||||
bind_values.push(like.clone());
|
||||
bind_values.push(like);
|
||||
} else {
|
||||
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
|
||||
bind_values.push(like.clone());
|
||||
@@ -147,10 +215,26 @@ pub async fn list_targets(
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
|
||||
// Fetch weather weight: go=1.0, marginal=0.7, nogo=0.3 (default 1.0 if no forecast)
|
||||
let weather_weight: f64 = sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT go_nogo FROM weather_cache WHERE id = 1"
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.flatten()
|
||||
.map(|s| match s.as_str() {
|
||||
"go" => 1.0,
|
||||
"marginal" => 0.7,
|
||||
"nogo" => 0.3,
|
||||
_ => 1.0,
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
|
||||
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
|
||||
// Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1
|
||||
// Targets outside 20–150% FOV fill are penalised (too small or too large single-panel).
|
||||
let best_score_expr = r#"(
|
||||
// Multiplied by weather_weight so cloudy nights rank all targets lower.
|
||||
let best_score_expr = format!(r#"(
|
||||
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
|
||||
+ CASE
|
||||
WHEN c.fov_fill_pct IS NULL THEN 0.15
|
||||
@@ -160,33 +244,47 @@ pub async fn list_targets(
|
||||
END
|
||||
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
|
||||
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
|
||||
) DESC"#;
|
||||
let sort_col = match params.sort.as_deref() {
|
||||
Some("transit") => "nc.transit_utc",
|
||||
Some("size") => "c.size_arcmin_maj DESC",
|
||||
Some("magnitude") => "c.mag_v",
|
||||
Some("difficulty") => "c.difficulty",
|
||||
Some("integration") => "total_integration DESC",
|
||||
Some("altitude") => "nc.max_alt_deg DESC",
|
||||
) * {weather_weight:.2} DESC"#, weather_weight = weather_weight);
|
||||
let sort_col_owned: String = match params.sort.as_deref() {
|
||||
Some("transit") => "nc.transit_utc".to_string(),
|
||||
Some("size") => "c.size_arcmin_maj DESC".to_string(),
|
||||
Some("magnitude") => "c.mag_v".to_string(),
|
||||
Some("difficulty") => "c.difficulty".to_string(),
|
||||
Some("integration") => "total_integration DESC".to_string(),
|
||||
Some("altitude") => "nc.max_alt_deg DESC".to_string(),
|
||||
Some("best_start") => "nc.best_start_utc ASC NULLS LAST".to_string(),
|
||||
_ => best_score_expr,
|
||||
};
|
||||
let sort_col = sort_col_owned.as_str();
|
||||
|
||||
// Compare tonight's altitude to the seasonal peak (max over next 90 days).
|
||||
// urgency: 'peak' >= 90%, 'rising' = 70–90% and date < peak date, 'declining' = 70–90% and date >= peak date, else null
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms,
|
||||
c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness,
|
||||
c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
|
||||
c.hubble_type, c.messier_num, c.caldwell_num, c.arp_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag,
|
||||
c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density,
|
||||
nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter,
|
||||
nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg,
|
||||
CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight,
|
||||
COALESCE(log_sum.total_min, 0) as total_integration
|
||||
COALESCE(nc.is_visible_tonight, CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END) as is_visible_tonight,
|
||||
COALESCE(log_sum.total_min, 0) as total_integration,
|
||||
seas.peak_alt as seasonal_peak_alt,
|
||||
seas.peak_date as seasonal_peak_date
|
||||
FROM catalog c
|
||||
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
|
||||
LEFT JOIN (
|
||||
SELECT catalog_id, SUM(integration_min) as total_min
|
||||
FROM imaging_log GROUP BY catalog_id
|
||||
) log_sum ON log_sum.catalog_id = c.id
|
||||
LEFT JOIN (
|
||||
SELECT catalog_id,
|
||||
MAX(max_alt_deg) as peak_alt,
|
||||
MIN(CASE WHEN max_alt_deg = (SELECT MAX(max_alt_deg) FROM nightly_cache n2 WHERE n2.catalog_id = n1.catalog_id AND n2.night_date BETWEEN '{today}' AND date('{today}', '+90 days')) THEN night_date ELSE NULL END) as peak_date
|
||||
FROM nightly_cache n1
|
||||
WHERE night_date BETWEEN '{today}' AND date('{today}', '+90 days')
|
||||
GROUP BY catalog_id
|
||||
) seas ON seas.catalog_id = c.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY {sort_col}
|
||||
LIMIT {limit} OFFSET {offset}
|
||||
@@ -209,8 +307,24 @@ pub async fn list_targets(
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
let mut items: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||
use sqlx::Row;
|
||||
let tonight_alt: f64 = row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default().unwrap_or(0.0);
|
||||
let peak_alt: f64 = row.try_get::<Option<f64>, _>("seasonal_peak_alt").unwrap_or_default().unwrap_or(0.0);
|
||||
let peak_date: Option<String> = row.try_get("seasonal_peak_date").unwrap_or_default();
|
||||
let urgency: serde_json::Value = if peak_alt >= 15.0 && tonight_alt >= 15.0 {
|
||||
let ratio = tonight_alt / peak_alt;
|
||||
if ratio >= 0.90 {
|
||||
serde_json::json!("peak")
|
||||
} else if ratio >= 0.70 {
|
||||
let before_peak = peak_date.as_deref().map(|d| d > today.as_str()).unwrap_or(true);
|
||||
serde_json::json!(if before_peak { "rising" } else { "declining" })
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
} else {
|
||||
serde_json::Value::Null
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||
@@ -227,6 +341,8 @@ pub async fn list_targets(
|
||||
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
|
||||
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
|
||||
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||
@@ -234,7 +350,7 @@ pub async fn list_targets(
|
||||
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||
"max_alt_deg": tonight_alt,
|
||||
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||
@@ -243,6 +359,8 @@ pub async fn list_targets(
|
||||
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
||||
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
|
||||
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
|
||||
"is_custom": false,
|
||||
"urgency": urgency,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
@@ -262,7 +380,82 @@ pub async fn list_targets(
|
||||
for val in &bind_values {
|
||||
count_query = count_query.bind(val);
|
||||
}
|
||||
let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
|
||||
let mut total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0);
|
||||
|
||||
// Append custom targets when show_custom is not explicitly false.
|
||||
// Only on page 1, and only when not mosaic_only or not_imaged filter is active.
|
||||
let show_custom = params.show_custom.unwrap_or(true);
|
||||
if show_custom && page == 1 && !params.mosaic_only.unwrap_or(false) {
|
||||
let custom_rows = sqlx::query(
|
||||
"SELECT id, name, obj_type, ra_deg, dec_deg, notes FROM custom_targets WHERE ra_deg IS NOT NULL AND dec_deg IS NOT NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
if !custom_rows.is_empty() {
|
||||
let jd = julian_date(chrono::Utc::now());
|
||||
let lst = local_sidereal_time(jd, LON);
|
||||
|
||||
// Apply search filter to custom targets if active
|
||||
let search_lower = params.search.as_deref().unwrap_or("").to_lowercase();
|
||||
|
||||
for row in &custom_rows {
|
||||
use sqlx::Row;
|
||||
let id: String = row.try_get("id").unwrap_or_default();
|
||||
let name: String = row.try_get("name").unwrap_or_default();
|
||||
|
||||
if !search_lower.is_empty()
|
||||
&& !id.to_lowercase().contains(&search_lower)
|
||||
&& !name.to_lowercase().contains(&search_lower)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let ra: f64 = row.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: f64 = row.try_get("dec_deg").unwrap_or_default();
|
||||
let obj_type: String = row.try_get("obj_type").unwrap_or_else(|_| "custom".to_string());
|
||||
let (alt, _az) = radec_to_altaz(ra, dec, lst, LAT);
|
||||
let current_alt = (alt * 10.0).round() / 10.0;
|
||||
|
||||
items.push(serde_json::json!({
|
||||
"id": &id,
|
||||
"name": &name,
|
||||
"common_name": serde_json::Value::Null,
|
||||
"obj_type": &obj_type,
|
||||
"ra_deg": ra,
|
||||
"dec_deg": dec,
|
||||
"ra_h": fmt_ra(ra),
|
||||
"dec_dms": fmt_dec(dec),
|
||||
"constellation": serde_json::Value::Null,
|
||||
"size_arcmin_maj": serde_json::Value::Null,
|
||||
"size_arcmin_min": serde_json::Value::Null,
|
||||
"mag_v": serde_json::Value::Null,
|
||||
"surface_brightness": serde_json::Value::Null,
|
||||
"hubble_type": serde_json::Value::Null,
|
||||
"messier_num": serde_json::Value::Null,
|
||||
"is_highlight": false,
|
||||
"fov_fill_pct": serde_json::Value::Null,
|
||||
"mosaic_flag": false,
|
||||
"mosaic_panels_w": 1,
|
||||
"mosaic_panels_h": 1,
|
||||
"difficulty": serde_json::Value::Null,
|
||||
"guide_star_density": serde_json::Value::Null,
|
||||
"max_alt_deg": current_alt,
|
||||
"usable_min": serde_json::Value::Null,
|
||||
"transit_utc": serde_json::Value::Null,
|
||||
"recommended_filter": serde_json::Value::Null,
|
||||
"best_start_utc": serde_json::Value::Null,
|
||||
"best_end_utc": serde_json::Value::Null,
|
||||
"moon_sep_deg": serde_json::Value::Null,
|
||||
"is_visible_tonight": alt > 15.0,
|
||||
"total_integration_min": 0,
|
||||
"is_custom": true,
|
||||
}));
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"items": items,
|
||||
@@ -276,12 +469,14 @@ pub async fn get_target(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use sqlx::Row;
|
||||
|
||||
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
|
||||
let m_num: Option<i32> = id.trim()
|
||||
.strip_prefix(['M', 'm'])
|
||||
.and_then(|n| n.parse().ok());
|
||||
|
||||
let row = if let Some(n) = m_num {
|
||||
let catalog_row = if let Some(n) = m_num {
|
||||
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
|
||||
.bind(n)
|
||||
.fetch_optional(&state.pool)
|
||||
@@ -291,34 +486,76 @@ pub async fn get_target(
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
}
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
};
|
||||
|
||||
if let Some(row) = catalog_row {
|
||||
return Ok(Json(serde_json::json!({
|
||||
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
|
||||
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
|
||||
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
|
||||
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
|
||||
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
|
||||
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
|
||||
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
|
||||
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
|
||||
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||
})));
|
||||
}
|
||||
|
||||
// Fall back to custom_targets
|
||||
let custom_row = sqlx::query("SELECT * FROM custom_targets WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
let ra: Option<f64> = custom_row.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: Option<f64> = custom_row.try_get("dec_deg").unwrap_or_default();
|
||||
let ra_h = ra.map(fmt_ra).unwrap_or_default();
|
||||
let dec_dms = dec.map(fmt_dec).unwrap_or_default();
|
||||
|
||||
use sqlx::Row;
|
||||
Ok(Json(serde_json::json!({
|
||||
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
|
||||
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
|
||||
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
|
||||
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
|
||||
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
|
||||
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
|
||||
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||
"id": custom_row.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": custom_row.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": serde_json::Value::Null,
|
||||
"obj_type": custom_row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
|
||||
"ra_deg": ra.unwrap_or(0.0),
|
||||
"dec_deg": dec.unwrap_or(0.0),
|
||||
"ra_h": ra_h,
|
||||
"dec_dms": dec_dms,
|
||||
"constellation": serde_json::Value::Null,
|
||||
"size_arcmin_maj": serde_json::Value::Null,
|
||||
"size_arcmin_min": serde_json::Value::Null,
|
||||
"pos_angle_deg": serde_json::Value::Null,
|
||||
"mag_v": serde_json::Value::Null,
|
||||
"surface_brightness": serde_json::Value::Null,
|
||||
"hubble_type": serde_json::Value::Null,
|
||||
"messier_num": serde_json::Value::Null,
|
||||
"is_highlight": false,
|
||||
"fov_fill_pct": serde_json::Value::Null,
|
||||
"mosaic_flag": false,
|
||||
"mosaic_panels_w": 1,
|
||||
"mosaic_panels_h": 1,
|
||||
"difficulty": serde_json::Value::Null,
|
||||
"guide_star_density": serde_json::Value::Null,
|
||||
"notes": custom_row.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||
"is_custom": true,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -361,16 +598,9 @@ pub async fn get_visibility(
|
||||
}
|
||||
|
||||
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.pool)
|
||||
let (ra, dec, obj_type) = lookup_coords(&state.pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
|
||||
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
|
||||
|
||||
let today = chrono::Utc::now().naive_utc().date();
|
||||
let (dusk, dawn) = astro_twilight(today, LAT, LON)
|
||||
@@ -421,15 +651,9 @@ pub async fn get_curve(
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// Always compute live at 1-minute resolution for the interactive chart.
|
||||
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
|
||||
let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
let (ra, dec, _obj_type) = lookup_coords(&state.pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
|
||||
|
||||
let date = chrono::Utc::now().naive_utc().date();
|
||||
let (dusk, dawn) = astro_twilight(date, LAT, LON)
|
||||
@@ -464,21 +688,17 @@ pub async fn get_filters(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
|
||||
|
||||
let tonight_row = sqlx::query(
|
||||
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
|
||||
)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
use sqlx::Row;
|
||||
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
|
||||
Some(r) => (
|
||||
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
|
||||
@@ -491,19 +711,7 @@ pub async fn get_filters(
|
||||
let now_jd = julian_date(chrono::Utc::now());
|
||||
let moon_alt = moon_altitude(now_jd, LAT, LON);
|
||||
|
||||
let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let moon_sep = match target_row {
|
||||
Some(r) => {
|
||||
let ra: f64 = r.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: f64 = r.try_get("dec_deg").unwrap_or_default();
|
||||
crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec)
|
||||
}
|
||||
None => 90.0,
|
||||
};
|
||||
let moon_sep = crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec);
|
||||
|
||||
let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
|
||||
Ok(Json(serde_json::json!({ "recommendations": recs })))
|
||||
@@ -513,14 +721,9 @@ pub async fn get_workflow_handler(
|
||||
State(state): State<AppState>,
|
||||
Path((id, filter_id)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
let (_ra, _dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
|
||||
let workflow = get_workflow(&obj_type, &filter_id);
|
||||
Ok(Json(serde_json::to_value(workflow).unwrap()))
|
||||
}
|
||||
@@ -540,16 +743,9 @@ pub async fn get_yearly(
|
||||
time::local_sidereal_time,
|
||||
};
|
||||
|
||||
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||
|
||||
use sqlx::Row;
|
||||
let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default();
|
||||
let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default();
|
||||
let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default();
|
||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", id)))?;
|
||||
|
||||
// Transit altitude: maximum the object can ever reach (constant for a DSO).
|
||||
let transit_alt = (90.0 - (LAT - dec).abs()).max(0.0_f64).min(90.0_f64);
|
||||
@@ -603,6 +799,71 @@ pub async fn get_yearly(
|
||||
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
|
||||
}
|
||||
|
||||
pub async fn get_similar(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
use sqlx::Row;
|
||||
|
||||
// Get the target's constellation, obj_type, and transit_utc from tonight's cache
|
||||
let target_row = sqlx::query(
|
||||
"SELECT c.constellation, c.obj_type, c.ra_deg, nc.transit_utc FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime') WHERE c.id = ?",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
let (constellation, obj_type, ra_deg, transit_utc): (Option<String>, String, f64, Option<String>) = match target_row {
|
||||
Some(ref r) => (
|
||||
r.try_get("constellation").unwrap_or(None),
|
||||
r.try_get("obj_type").unwrap_or_default(),
|
||||
r.try_get("ra_deg").unwrap_or_default(),
|
||||
r.try_get("transit_utc").unwrap_or(None),
|
||||
),
|
||||
None => return Ok(Json(serde_json::json!({ "similar": [] }))),
|
||||
};
|
||||
|
||||
// Find similar objects: same type + same constellation, ordered by RA proximity (≈ transit time proximity)
|
||||
// RA difference of 15° ≈ 1h in transit time
|
||||
let similar_rows = sqlx::query(
|
||||
r#"SELECT c.id, c.name, c.common_name, c.obj_type, c.size_arcmin_maj,
|
||||
c.fov_fill_pct, c.messier_num, nc.max_alt_deg, nc.transit_utc, nc.recommended_filter
|
||||
FROM catalog c
|
||||
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime')
|
||||
WHERE c.id != ?
|
||||
AND c.obj_type = ?
|
||||
AND c.constellation = ?
|
||||
AND ABS(c.ra_deg - ?) <= 25.0
|
||||
AND nc.max_alt_deg >= 15
|
||||
ORDER BY ABS(c.ra_deg - ?) ASC
|
||||
LIMIT 5"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&obj_type)
|
||||
.bind(&constellation)
|
||||
.bind(ra_deg)
|
||||
.bind(ra_deg)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let similar: Vec<serde_json::Value> = similar_rows.iter().map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||
"size_arcmin_maj": r.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||
"fov_fill_pct": r.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||
"messier_num": r.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(serde_json::json!({ "similar": similar, "target_transit": transit_utc })))
|
||||
}
|
||||
|
||||
pub async fn get_notes(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
|
||||
Reference in New Issue
Block a user