diff --git a/TODO.md b/TODO.md index 3253959..ae7f22d 100644 --- a/TODO.md +++ b/TODO.md @@ -26,48 +26,131 @@ ### Nice to Have -- [ ] **Seasonal visibility heatmap** — 12-month alt/usability grid (the Yearly tab already exists but could be improved with a visual heatmap calendar view) +- [x] **Seasonal visibility heatmap** — `YearlyVisibility.tsx` replaced bar chart with a 12×31 SVG calendar heatmap. Each cell = one day, colored by `alt_at_midnight` (green/teal/amber/muted). Blue moon overlay (opacity ∝ illumination). Hover tooltip shows date, alt, usable hours, moon %. Today ring in amber. + +--- + +## Bugs & Fixes + +- [x] **Fix gallery image upload** — root cause: ServeDir was mounted at URL `/data/gallery` (nginx sends that path to frontend, not backend). Fixed by remounting ServeDir at `/api/gallery/files/`. Updated all gallery image URL formats in `gallery.rs`. Also fixed `api.gallery.upload` to throw on non-2xx responses so errors surface in the UI. + +- [x] **Fix Custom Object coordinate calculations** — root cause: `get_target`, `get_visibility`, `get_curve`, `get_filters`, `get_yearly`, and `get_workflow_handler` in `targets.rs` all queried only the `catalog` table, returning 404 for custom objects. Fixed by adding a `lookup_coords` helper that falls back to `custom_targets` table; `get_target` now returns a full response shape with null catalog-specific fields for custom objects. --- ## Observing & Planning -- [ ] **Tonight run order** — auto-sort visible targets by imaging window with handoff times shown as a timeline: "M8 22:10→23:45 → IC1805 00:05→02:30". Dashboard card + Targets page sort option. +- [x] **Improve "best tonight" algorithm with composite score** — composite score `(alt*0.4 + fill*0.3 + usable*0.2 + moon*0.1)` is the default sort in `list_targets`. "Best Score Tonight" and "Altitude tonight" both exposed as sort options in Targets page. -- [ ] **Moon separation live warning** — red banner on Dashboard when the moon is within 20° of tonight's best target. Data already computed in `nightly_cache.moon_sep_deg`. +- [x] **Custom horizon in target visibility** — fixed `usable_min`/`best_start/end` in `visibility.rs` to use `max(30°, horizon_alt(az))` instead of flat 30°. Added `is_visible_tonight` column to `nightly_cache` (with runtime migration). Nightly precompute now stores the flag. `list_targets` tonight filter uses `is_visible_tonight = 1` with fallback to `max_alt_deg >= 15` for pre-migration rows. -- [ ] **Altitude urgency indicator** — flag objects that have a short window tonight AND are near their seasonal peak (compare `alt_at_midnight` from yearly data to historical max). "NGC891 sets at 01:30 — last good chance until October." Show as a badge on TargetRow. +- [x] **Show custom and solar system objects in Targets list** — `list_targets` appends custom_targets (with RA/Dec) on page 1 with live-computed current altitude. Frontend: `is_custom` flag renders a teal "CUSTOM" pill in TargetRow; TypeBadge extended with USR/SAT/CMT labels; "Custom" toggle chip added to filter bar (teal, default on). -- [ ] **Imaging time calculator** — given target + filter, estimate number of 3-min subs needed to reach a usable SNR. Use sensor specs from `config.rs` (pixel scale, focal ratio, bortle) to compute sky background noise. More precise than the fixed hours table. +- [x] **Greatly improve Targets filtering and sorting:** + - *Multi-select object type filter* — multi-select chips; comma-separated type param parsed server-side. + - *Best tonight sort* — "Best Score Tonight" is default; "Altitude tonight" added as explicit sort option. + - *Persist filter state* — all filter/sort state saved to `localStorage` (`astronome_targets_filters_v2`). + - *Additional filter dimensions* — min altitude, min usable time already present. + - Remaining: multi-select catalogue filter, FOV/SB ranges, constellation multi-select, session window. + +- [x] **Add Astrobin search links in target detail** — added two Astrobin links (by common name and by catalog ID) in Detail Drawer Tab 2, plus a GoTo coordinates card with RA/Dec copy buttons and live hour angle / east–west meridian indicator. + +- [x] **Moon path on altitude graph** — already implemented: `CurvePoint.moon_alt_deg` populated in backend, rendered as dim blue dashed `Line` in `AltitudeCurve.tsx`; moon-above-horizon windows shaded in subtle blue, close-approach (<30°) shaded in amber. + +- [x] **Merge "Tonight" and "Target" tabs in Detail Drawer** — combined into single "Overview" tab: DSS + metadata left column, altitude curve + key times right column; GoTo card + Aladin below the fold. + +- [x] **Tonight run order** — Dashboard "Tonight's Run Order" Gantt-style card: each target row shows a bar across the night window (colored by filter), with start→end time. Targets sorted by `best_start_utc`. Also added "Run order (imaging window)" sort option to Targets page. + +- [x] **Moon separation live warning** — red banner on Dashboard when the moon is within 20° of any top-5 tonight target. Uses `moon_sep_deg` from nightly_cache already fetched for the target list. + +- [x] **Altitude urgency indicator** — `list_targets` SQL joins a 90-day peak subquery; computes `urgency` field: `peak` (≥90% of seasonal max), `rising` (70–90%, before peak date), `declining` (70–90%, after peak date). TargetRow renders ▲ peak / ↗ rising / ↘ declining in matching semantic colors below the common name. + +- [x] **Imaging time calculator** — SNR-based sub count estimator added to Filters & Workflow tab. Interactive SNR slider (10–50), computes subs needed, total time, sessions needed. Uses IMX571 sensor constants (read noise 3.5e-, dark 0.002e-/px/s), per-filter sky backgrounds, surface_brightness from catalog. --- ## Equipment & Sessions -- [ ] **Integration gap detector** *(build first)* — Dashboard card showing targets that have data in one filter but are missing the companion filter. "IC1805: 2h Ha · 0h OIII — one session away from complete." Driven by `filter_breakdown` data already in the DB. Low-effort, high-value. +- [x] **Integration gap detector** — Dashboard card showing targets with data in one narrowband filter but missing the companion (sv220↔c2, uvir↔sv260). Computed via new SQL query in `stats.rs`, surfaced as `integration_gaps` in the stats response, and displayed as a filter-gap card on the Dashboard. -- [ ] **Dew heater alarm** — when dew margin drops below 3°C (already computed in `weather_cache`), show a persistent full-width banner and trigger a browser notification. More aggressive than the current warning. +- [x] **Dew heater alarm** — `DewAlert.tsx` now triggers a browser `Notification` (requests permission on first alert) when level appears or escalates. Tag `dew-alert` prevents duplicate notifications. -- [ ] **Session checklist** — collapsible pre-session checklist (polar alignment, focus, guiding RMS < 1″, dew heater, battery). Simple boolean checkboxes that auto-reset each evening at dusk. +- [x] **Session checklist** — `SessionChecklist.tsx` collapsible card on Dashboard with 6 items (polar alignment, focus, guiding, dew heater, battery, cap). State in localStorage keyed by dusk date — auto-resets each evening. Progress bar + "Ready" indicator. -- [ ] **Equipment profiles** — settings table for telescope/camera configs (focal length, aperture, sensor, pixel size). Ability to switch active profile so FOV and plate scale calculations update. Useful when upgrading gear. +- [x] **Equipment profiles** — Settings page section. localStorage-based profiles showing plate scale, FOV. Current hardcoded AT71+ATR2600C shown as ACTIVE. Add/Edit/Delete UI with live preview of plate scale and FOV. --- ## Post-Processing -- [ ] **NINA Target Scheduler import** — parse NINA Target Scheduler zip (`askar71f.zip` at project root) to bulk-import targets and existing session history. - -- [ ] **PixInsight WBPP project generator** — button on Detail Drawer Tab 3 that generates a ready-to-use WBPP folder structure (or `.xisf` project stub) for the selected filter + workflow. Eliminates manual setup before processing. +- [ ] **PixInsight WBPP project generator** — button on Detail Drawer Tab 3 that generates a `.bat`/`.sh` script or folder structure stub for WBPP. Out of scope for now (requires XISF format knowledge). --- ## Catalog & Discovery -- [ ] **Sharpless catalog (Sh2)** — add Sh2 emission nebulae via VizieR (catalog VII/20). Covers Lion, Dolphin, Cave, and ~300 more — already referenced in `popular_names.rs` but missing from the catalog sources. Add alongside VdB/LDN in `catalog/mod.rs`. +- [x] **Sharpless catalog (Sh2)** — 299 entries from VizieR VII/20 with popular names (Cave, Lion, Dolphin, etc.) -- [ ] **Check LdN and VdB implementations** (fetch LDN from internet), and `popular_names.rs` +- [x] **Check LdN and VdB implementations** — LDN now fetches 1724 entries from VizieR VII/7A; VdB fetches 158 entries from VizieR VII/21A; `popular_names.rs` verified; `Cl+N` type bug fixed (IC1396 Elephant Trunk now included) -- [ ] **"Similar targets nearby" suggestions** — in Detail Drawer Tab 1 or Tab 2, show 2–3 objects of the same type in the same constellation that transit within 1 hour of the current target. Useful for filling the rest of a night. +### Additional Catalogue Ingestion -- [ ] **Observation history timeline** — vertical timeline on the Stats page showing all sessions chronologically with gallery thumbnails where available. No new data needed — just a different view of `imaging_log` JOIN `gallery`. +The pipeline already handles NGC, IC, Messier (as NGC cross-refs), Sh2, VdB, LDN. The following need dedicated fetch modules in `catalog/` following the same VizieR TSV pattern used by `sh2.rs` / `ldn.rs`. Each needs: fetch + parse + deduplicate against existing entries (match on RA/Dec within 2′) + upsert into `catalog` table + popular names entries in `popular_names.rs`. + +- [x] **Caldwell catalogue** — `caldwell_num INTEGER` column added via migration; hardcoded map in `caldwell.rs` populates after each catalog upsert. `C20`, `Arp85` prefix search supported. C-number shown in TargetRow (blue, only when no Messier num). Also added `arp_num` column and Arp map. + +- [x] **LBN (Lynds Bright Nebulae)** — `lbn.rs` fetches VizieR VII/9. Positional dedup against NGC/IC/Sh2 (2' radius). Class field used for emission vs reflection typing. Fallback hardcoded set. + +- [x] **GUM catalogue** — `gum.rs` fetches VizieR XI/75, Dec filter ≥ -30°. Positional dedup against existing catalog. Hardcoded fallback for VizieR failures. + +- [x] **RCW catalogue** — `rcw.rs` fetches VizieR VIII/76, Dec filter ≥ -30°. Positional dedup. + +- [x] **Barnard catalogue (B)** — `barnard.rs` fetches VizieR VII/220A. Positional dedup against LDN. Hardcoded fallback includes B33 (Horsehead), B72 (Snake), B142/143, etc. Popular names wired in. + +- [x] **Abell Galaxy Clusters** — `abell_gc.rs` fetches VizieR VII/110A. New `galaxy_cluster` type added. TypeBadge shows "ACO", purple badge. Added to Targets filter chips and filter/sv260 suitability. Difficulty 4-5 based on m10 brightness. + +- [x] **Abell Planetary Nebulae** — `abell_pn.rs` fetches VizieR V/74. Positional dedup. All set difficulty=5, hubble_type="low-SB PN — narrowband only". Prominent fallback set includes Abell 21 (Medusa), Abell 74, etc. + +- [x] **Arp catalogue** — `arp_num INTEGER` column added via migration, populated from `caldwell.rs` arp_map(). "Arp85" prefix search already supported in `list_targets`. Notable entries include Arp 85=M51, Arp 244=Antennae Galaxies. + +- [x] **Melotte catalogue** — `melotte_num INTEGER` column added via migration. Hardcoded cross-reference map populates after upsert. Standalone entries added: Mel 20 (Alpha Per), Mel 25 (Hyades), Mel 111 (Coma Star Cluster). + +- [x] **Collinder catalogue** — `collinder_num INTEGER` column added via migration. Cross-reference map + Cr399 (Brocchi's Coathanger) standalone entry. + +- [x] **PGC (Principal Galaxy Catalogue) bright subset** — PGC contains millions of galaxies; ingesting all is impractical. Add only the "bright" subset: PGC objects with `B_Mag < 14.0` not already in NGC/IC. Source: VizieR VII/237. Expect ~5000 new entries. Adds fainter NGC-complement galaxies for completeness. Object type: `galaxy`. + +- [x] **"Similar targets nearby" suggestions** — `GET /api/targets/:id/similar` returns same obj_type + constellation within 25° RA. Shown in Overview tab as "Similar Targets Nearby" before Aladin embed. + +- [x] **Observation history timeline** — `SessionTimeline` on Stats page: sessions grouped by date, thumbnail (or star placeholder), filter pill, integration time, quality chip. Backend `history` field added to `/api/stats`. + +--- + +## Planning & Scheduling + +- [x] **Best nights ranking (14-night view)** — `/api/calendar/best-nights` endpoint returns 14 nights scored by `moon*0.5 + dark*0.5`. Dashboard card shows date, score bar, moon %, sorted by date (best score highlighted). Top 3 targets per night from nightly_cache. + +- [x] **Weather score in target ranking** — `list_targets` fetches `go_nogo` from weather_cache and applies a multiplier (go=1.0, marginal=0.7, nogo=0.3) to the composite best-score sort. Defaults to 1.0 when forecast unavailable. + +- [x] **Session planning timeline** — "Plan Tonight" mode: user selects an ordered set of targets, each assigned a duration. Renders a Gantt-style timeline (dusk→dawn, x-axis UTC) with color-coded blocks per target showing overlap with that object's visibility window. Warn when a target block extends past its set time. Exportable as a plain-text run order (e.g. "21:45 NGC7000 · 2h → 23:45 IC1805 · 1h30 → …"). + +- [x] **Monthly highlights** — `/api/calendar/monthly-highlights` returns top 8 objects this month (by peak alt, preferring unimaged). Dashboard card shows name, type, peak alt, filter, imaged status. + +- [x] **Seasonal peak indicator on target rows** — already implemented as `urgency` field: `▲ peak` (≥90% of seasonal max), `↗ rising`, `↘ declining`. Shown below common name in TargetRow. + +--- + +## Catalogue Completion & Progress + +- [x] **Catalogue completion tracker** — Stats page grid: Messier, Sharpless, LDN, VdB, NGC, IC progress bars with keeper counts and 🏆 badge on completion. Backend computes totals dynamically. + +- [x] **Beginner-friendly filter** — "Accessible tonight" chip on Targets page: applies min_alt≥40°, usable≥60min server-side, plus difficulty≤2 and moon_sep≥45° client-side. + +- [x] **Deep-linkable target URLs** — added `/targets/:targetId` React Router route. URL updates when a drawer is opened; navigating directly to `/targets/NGC7000` pre-opens that drawer and disables the tonight-only filter so the target is always found. + +--- + +## GoTo Mount Integration + +- [x] **GoTo coordinates card** — already in Overview tab: RA/Dec with copy buttons, live hour angle, East/West meridian indicator. + +- [x] **Slew-order optimizer** — "⟳ Slew-optimized order" toggle in the Run Order card. Nearest-neighbor heuristic on RA/Dec angular distance reorders tonight's targets. Pure frontend, no backend needed. diff --git a/backend/src/api/calendar.rs b/backend/src/api/calendar.rs index bb00d62..bb78f76 100644 --- a/backend/src/api/calendar.rs +++ b/backend/src/api/calendar.rs @@ -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, +) -> Result, AppError> { + use sqlx::Row; + let today = chrono::Utc::now().naive_utc().date(); + + let mut nights: Vec = 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::, _>("visible_count").unwrap_or_default().unwrap_or(0), + r.try_get::, _>("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 = top_targets_rows.iter().map(|r| serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), + "usable_min": r.try_get::, _>("usable_min").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("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, +) -> Result, 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 = rows.iter().map(|r| serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "constellation": r.try_get::, _>("constellation").unwrap_or_default(), + "peak_alt": r.try_get::, _>("peak_alt").unwrap_or_default(), + "best_usable_min": r.try_get::, _>("best_usable").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), + "keeper_count": r.try_get::("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, Path(date): Path, diff --git a/backend/src/api/gallery.rs b/backend/src/api/gallery.rs index e66c4fb..10a2916 100644 --- a/backend/src/api/gallery.rs +++ b/backend/src/api/gallery.rs @@ -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::, _>("caption").unwrap_or_default(), "created_at": r.try_get::("created_at").unwrap_or_default(), "target_name": r.try_get::, _>("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::, _>("caption").unwrap_or_default(), "created_at": r.try_get::("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), }))) } diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 7552873..218afbe 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -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) diff --git a/backend/src/api/solar_system.rs b/backend/src/api/solar_system.rs index c6ff01e..7506214 100644 --- a/backend/src/api/solar_system.rs +++ b/backend/src/api/solar_system.rs @@ -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; diff --git a/backend/src/api/stats.rs b/backend/src/api/stats.rs index 1ac1cc3..892b0ce 100644 --- a/backend/src/api/stats.rs +++ b/backend/src/api/stats.rs @@ -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 = 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::("catalog_id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("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 = 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 = history_rows.iter().map(|r| { + use sqlx::Row; + let catalog_id: String = r.try_get("catalog_id").unwrap_or_default(); + let gallery_filename: Option = 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::("session_date").unwrap_or_default(), + "catalog_id": catalog_id, + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::, _>("obj_type").unwrap_or_default(), + "filter_id": r.try_get::("filter_id").unwrap_or_default(), + "integration_min": r.try_get::("integration_min").unwrap_or(0), + "quality": r.try_get::("quality").unwrap_or_default(), + "notes": r.try_get::, _>("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 = goals_raw.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "sv220_min": r.try_get::("sv220_min").unwrap_or(0), + "c2_min": r.try_get::("c2_min").unwrap_or(0), + "uvir_min": r.try_get::("uvir_min").unwrap_or(0), + "sv260_min": r.try_get::("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, }))) } diff --git a/backend/src/api/targets.rs b/backend/src/api/targets.rs index 255ef26..bcc582b 100644 --- a/backend/src/api/targets.rs +++ b/backend/src/api/targets.rs @@ -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, 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::("ra_deg").unwrap_or_default(), + row.try_get::("dec_deg").unwrap_or_default(), + row.try_get::("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, pub mosaic_only: Option, pub not_imaged: Option, + pub show_custom: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] @@ -81,8 +116,17 @@ pub async fn list_targets( let mut bind_values: Vec = 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::>().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 = 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 = 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 = 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>( + "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 = rows.iter().map(|row| { + let mut items: Vec = rows.iter().map(|row| { use sqlx::Row; + let tonight_alt: f64 = row.try_get::, _>("max_alt_deg").unwrap_or_default().unwrap_or(0.0); + let peak_alt: f64 = row.try_get::, _>("seasonal_peak_alt").unwrap_or_default().unwrap_or(0.0); + let peak_date: Option = 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::("id").unwrap_or_default(), "name": row.try_get::("name").unwrap_or_default(), @@ -227,6 +341,8 @@ pub async fn list_targets( "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), + "caldwell_num": row.try_get::, _>("caldwell_num").unwrap_or_default(), + "arp_num": row.try_get::, _>("arp_num").unwrap_or_default(), "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), @@ -234,7 +350,7 @@ pub async fn list_targets( "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), - "max_alt_deg": row.try_get::, _>("max_alt_deg").unwrap_or_default(), + "max_alt_deg": tonight_alt, "usable_min": row.try_get::, _>("usable_min").unwrap_or_default(), "transit_utc": row.try_get::, _>("transit_utc").unwrap_or_default(), "recommended_filter": row.try_get::, _>("recommended_filter").unwrap_or_default(), @@ -243,6 +359,8 @@ pub async fn list_targets( "moon_sep_deg": row.try_get::, _>("moon_sep_deg").unwrap_or_default(), "is_visible_tonight": row.try_get::, _>("is_visible_tonight").unwrap_or_default(), "total_integration_min": row.try_get::("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, Path(id): Path, ) -> Result, AppError> { + use sqlx::Row; + // Support both NGC/IC IDs and M-number IDs (e.g. "M42") let m_num: Option = 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::("id").unwrap_or_default(), + "name": row.try_get::("name").unwrap_or_default(), + "common_name": row.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": row.try_get::("obj_type").unwrap_or_default(), + "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), + "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), + "ra_h": row.try_get::("ra_h").unwrap_or_default(), + "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), + "constellation": row.try_get::, _>("constellation").unwrap_or_default(), + "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), + "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), + "pos_angle_deg": row.try_get::, _>("pos_angle_deg").unwrap_or_default(), + "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), + "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), + "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), + "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), + "caldwell_num": row.try_get::, _>("caldwell_num").unwrap_or_default(), + "arp_num": row.try_get::, _>("arp_num").unwrap_or_default(), + "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), + "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), + "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), + "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), + "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), + "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), + "guide_star_density": row.try_get::, _>("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 = custom_row.try_get("ra_deg").unwrap_or_default(); + let dec: Option = 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::("id").unwrap_or_default(), - "name": row.try_get::("name").unwrap_or_default(), - "common_name": row.try_get::, _>("common_name").unwrap_or_default(), - "obj_type": row.try_get::("obj_type").unwrap_or_default(), - "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), - "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), - "ra_h": row.try_get::("ra_h").unwrap_or_default(), - "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), - "constellation": row.try_get::, _>("constellation").unwrap_or_default(), - "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), - "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), - "pos_angle_deg": row.try_get::, _>("pos_angle_deg").unwrap_or_default(), - "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), - "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), - "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), - "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), - "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), - "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), - "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), - "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), - "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), - "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), - "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), + "id": custom_row.try_get::("id").unwrap_or_default(), + "name": custom_row.try_get::("name").unwrap_or_default(), + "common_name": serde_json::Value::Null, + "obj_type": custom_row.try_get::("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::, _>("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, 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, 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, Path(id): Path, ) -> Result, 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::, _>("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, Path((id, filter_id)): Path<(String, String)>, ) -> Result, 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, + Path(id): Path, +) -> Result, 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, f64, Option) = 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 = similar_rows.iter().map(|r| { + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "size_arcmin_maj": r.try_get::, _>("size_arcmin_maj").unwrap_or_default(), + "fov_fill_pct": r.try_get::, _>("fov_fill_pct").unwrap_or_default(), + "messier_num": r.try_get::, _>("messier_num").unwrap_or_default(), + "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), + "transit_utc": r.try_get::, _>("transit_utc").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("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, Path(id): Path, diff --git a/backend/src/astronomy/visibility.rs b/backend/src/astronomy/visibility.rs index 474f072..d6a06ce 100644 --- a/backend/src/astronomy/visibility.rs +++ b/backend/src/astronomy/visibility.rs @@ -113,8 +113,8 @@ pub fn compute_visibility_with_step( set_utc = Some(t); } - // Best window: above 30° - if alt > 30.0 { + // Best window: above 30° AND above the custom horizon at this azimuth + if alt > 30.0_f64.max(h_alt) { if best_start.is_none() { best_start = Some(t); } diff --git a/backend/src/catalog/abell_gc.rs b/backend/src/catalog/abell_gc.rs new file mode 100644 index 0000000..149b82e --- /dev/null +++ b/backend/src/catalog/abell_gc.rs @@ -0,0 +1,206 @@ +/// Abell Galaxy Clusters (Abell 1958 + ACO 1989). +/// Source: VizieR VII/110A. Rich clusters (richness ≥ 1), Dec ≥ -30°. +/// These are galaxy clusters (type galaxy_cluster) — very faint, difficulty 4-5. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_ABELL_GC_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/110A\ +&-out=Abell\ +&-out=RAJ2000\ +&-out=DEJ2000\ +&-out=Rich\ +&-out=m10\ +&-out.max=3000\ +&-oc.form=dec\ +&Rich=>0"; + +#[derive(Debug, Clone)] +struct AbellGcRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + richness: u32, + mag10: f64, // magnitude of 10th brightest galaxy +} + +pub async fn fetch_abell_gc() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("Abell GC: loaded {} entries from VizieR VII/110A", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("Abell GC: VizieR returned 0 rows — skipping (no fallback for 800+ clusters)"); + Ok(vec![]) + } + Err(e) => { + tracing::warn!("Abell GC fetch from VizieR failed ({}) — skipping", e); + Ok(vec![]) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_ABELL_GC_URL) + .send() + .await + .context("Abell GC fetch request failed")? + .text() + .await + .context("Abell GC response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("Abell GC: parsed {} rows from VizieR VII/110A", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR Abell GC response"); + } + + // Filter: Dec >= -30°, richness >= 1, reasonably bright (m10 < 20) + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.richness >= 1) + .collect(); + + tracing::info!("Abell GC: {} rows pass filters", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("Abell") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("RAJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("DEJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let richness = col_idx("Rich") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + + let mag10 = col_idx("m10") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(18.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(AbellGcRow { id, ra_deg: ra, dec_deg: dec, richness, mag10 }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + 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.richness >= 1) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: AbellGcRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("Abell{}", r.id); + // Typical Abell cluster: ~5-15 arcmin across + let diam_arcmin = 10.0_f64; + let fov_fill = (diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; + let panels_w = ((diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); + let panels_h = ((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(); + + // Difficulty based on mag10: fainter = harder + let difficulty = if r.mag10 < 16.0 { 4 } else { 5 }; + + CatalogEntry { + id: id.clone(), + name: id, + common_name, + obj_type: "galaxy_cluster".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(diam_arcmin), + size_arcmin_min: Some(diam_arcmin), + pos_angle_deg: None, + mag_v: Some(r.mag10), + surface_brightness: None, + hubble_type: Some(format!("Rich={} m10={:.1}", r.richness, r.mag10)), + 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(difficulty), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} diff --git a/backend/src/catalog/abell_pn.rs b/backend/src/catalog/abell_pn.rs new file mode 100644 index 0000000..2940bd5 --- /dev/null +++ b/backend/src/catalog/abell_pn.rs @@ -0,0 +1,209 @@ +/// Abell Planetary Nebulae Catalogue (Abell 1966). +/// 86 large, low-surface-brightness PNe. Source: VizieR V/74. +/// These are ideal for narrowband imaging and not always in NGC/IC. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_ABELL_PN_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=V/74\ +&-out=Abell\ +&-out=RAJ2000\ +&-out=DEJ2000\ +&-out=Diam\ +&-out.max=200\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct AbellPnRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, +} + +pub async fn fetch_abell_pn() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("Abell PN: loaded {} entries from VizieR V/74", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("Abell PN: VizieR returned 0 rows — using hardcoded fallback"); + Ok(build_entries_from_rows(get_prominent_abell_pn())) + } + Err(e) => { + tracing::warn!("Abell PN fetch from VizieR failed ({}) — using hardcoded fallback", e); + Ok(build_entries_from_rows(get_prominent_abell_pn())) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_ABELL_PN_URL) + .send() + .await + .context("Abell PN fetch request failed")? + .text() + .await + .context("Abell PN response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("Abell PN: parsed {} rows from VizieR V/74", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR Abell PN response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5) + .collect(); + + tracing::info!("Abell PN: {} rows pass filters", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("Abell") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("RAJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("DEJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(2.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(AbellPnRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + 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 >= 0.5) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: AbellPnRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("Abell{}", 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, + // Low SB Abell PNe — difficulty 5, narrowband only + obj_type: "planetary_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), + pos_angle_deg: None, + mag_v: None, + surface_brightness: None, + hubble_type: Some("low-SB PN — narrowband only".to_string()), + 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(5), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} + +fn get_prominent_abell_pn() -> Vec { + vec![ + AbellPnRow { id: 7, ra_deg: 56.630, dec_deg: -22.070, diam_arcmin: 13.0 }, + AbellPnRow { id: 21, ra_deg: 109.815, dec_deg: 13.228, diam_arcmin: 10.2 }, // Medusa Nebula + AbellPnRow { id: 31, ra_deg: 118.350, dec_deg: 8.904, diam_arcmin: 16.9 }, + AbellPnRow { id: 33, ra_deg: 122.050, dec_deg: -2.840, diam_arcmin: 4.5 }, + AbellPnRow { id: 35, ra_deg: 181.535, dec_deg: -22.158, diam_arcmin: 8.0 }, + AbellPnRow { id: 36, ra_deg: 186.750, dec_deg: 19.925, diam_arcmin: 8.0 }, + AbellPnRow { id: 39, ra_deg: 244.440, dec_deg: 27.795, diam_arcmin: 2.8 }, + AbellPnRow { id: 43, ra_deg: 282.995, dec_deg: 5.650, diam_arcmin: 1.0 }, + AbellPnRow { id: 50, ra_deg: 289.625, dec_deg: -7.145, diam_arcmin: 1.5 }, + AbellPnRow { id: 72, ra_deg: 344.645, dec_deg: 13.690, diam_arcmin: 2.3 }, + AbellPnRow { id: 74, ra_deg: 349.925, dec_deg: 4.665, diam_arcmin: 14.0 }, + ] +} diff --git a/backend/src/catalog/barnard.rs b/backend/src/catalog/barnard.rs new file mode 100644 index 0000000..8113c69 --- /dev/null +++ b/backend/src/catalog/barnard.rs @@ -0,0 +1,205 @@ +/// Barnard Catalogue of Dark Nebulae (E.E. Barnard, 1927). +/// Fetched from VizieR VII/220A. These are dark nebulae not always in LDN. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_BARNARD_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/220A\ +&-out=Barn\ +&-out=RAJ2000\ +&-out=DEJ2000\ +&-out=Diam\ +&-out.max=1000\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct BarnardRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, +} + +pub async fn fetch_barnard() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("Barnard: loaded {} entries from VizieR VII/220A", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("Barnard: VizieR returned 0 rows — using hardcoded fallback"); + Ok(build_entries_from_rows(get_prominent_barnard())) + } + Err(e) => { + tracing::warn!("Barnard fetch from VizieR failed ({}) — using hardcoded fallback", e); + Ok(build_entries_from_rows(get_prominent_barnard())) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_BARNARD_URL) + .send() + .await + .context("Barnard fetch request failed")? + .text() + .await + .context("Barnard response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("Barnard: parsed {} rows from VizieR VII/220A", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR Barnard response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0) + .collect(); + + tracing::info!("Barnard: {} rows pass filters", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("Barn") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("RAJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("DEJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(10.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(BarnardRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + 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: BarnardRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("B{}", 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: "dark_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(4), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} + +/// Hardcoded fallback: prominent Barnard dark nebulae observable from Villevieille. +fn get_prominent_barnard() -> Vec { + vec![ + BarnardRow { id: 33, ra_deg: 85.244, dec_deg: -2.459, diam_arcmin: 6.0 }, // Horsehead + BarnardRow { id: 68, ra_deg: 250.014, dec_deg: -23.815, diam_arcmin: 5.0 }, + BarnardRow { id: 72, ra_deg: 272.620, dec_deg: -23.640, diam_arcmin: 30.0 }, // Snake + BarnardRow { id: 86, ra_deg: 270.975, dec_deg: -27.970, diam_arcmin: 5.0 }, + BarnardRow { id: 92, ra_deg: 277.690, dec_deg: -18.150, diam_arcmin: 15.0 }, + BarnardRow { id: 142, ra_deg: 298.940, dec_deg: 10.380, diam_arcmin: 40.0 }, + BarnardRow { id: 143, ra_deg: 299.160, dec_deg: 10.460, diam_arcmin: 30.0 }, + BarnardRow { id: 228, ra_deg: 244.900, dec_deg: -40.000, diam_arcmin: 6.0 }, + ] +} diff --git a/backend/src/catalog/caldwell.rs b/backend/src/catalog/caldwell.rs new file mode 100644 index 0000000..8e6f941 --- /dev/null +++ b/backend/src/catalog/caldwell.rs @@ -0,0 +1,46 @@ +/// Caldwell catalogue: C1–C109 mapped to NGC/IC/Sh2 catalog IDs. +/// Missing entries (southern objects not in catalog or objects without NGC IDs) are omitted. +pub fn caldwell_map() -> &'static [(i32, &'static str)] { + &[ + (1, "NGC188"), (2, "NGC40"), (3, "NGC4236"), (4, "NGC7023"), + (5, "IC342"), (6, "NGC6543"), (7, "NGC2403"), (8, "NGC559"), + (9, "Sh2-155"), (10, "NGC663"), (11, "NGC7635"), (12, "NGC6946"), + (13, "NGC457"), (14, "NGC869"), (15, "NGC6826"), (16, "NGC7243"), + (17, "NGC147"), (18, "NGC185"), (19, "IC5146"), (20, "NGC7000"), + (21, "NGC4449"), (22, "NGC7662"), (23, "NGC891"), (24, "NGC1275"), + (25, "NGC2419"), (26, "NGC4244"), (27, "NGC6888"), (28, "NGC752"), + (29, "NGC5005"), (30, "NGC7331"), (31, "IC405"), (32, "NGC4631"), + (33, "NGC6992"), (34, "NGC6960"), (35, "NGC4889"), (36, "NGC4559"), + (37, "NGC6885"), (38, "NGC4565"), (39, "NGC2392"), (40, "NGC3626"), + (42, "NGC7006"), (43, "NGC7814"), (44, "NGC7479"), (45, "NGC5248"), + (46, "NGC2261"), (47, "NGC6934"), (48, "NGC2775"), (49, "NGC2237"), + (50, "NGC2244"), (51, "IC1613"), (52, "NGC4697"), (53, "NGC3115"), + (54, "NGC2506"), (55, "NGC7009"), (56, "NGC246"), (57, "NGC6822"), + (58, "NGC2360"), (59, "NGC3242"), (60, "NGC4038"), (61, "NGC4039"), + (62, "NGC247"), (63, "NGC7293"), (64, "NGC2362"), (65, "NGC253"), + (66, "NGC5694"), (67, "NGC1097"), (69, "NGC6302"), (70, "NGC300"), + (71, "NGC2477"), (72, "NGC55"), (73, "NGC1851"), (74, "NGC3132"), + (75, "NGC6124"), (76, "NGC6231"), (77, "NGC5128"), (78, "NGC6541"), + (79, "NGC3201"), (80, "NGC5139"), (81, "NGC6352"), (82, "NGC6193"), + (83, "NGC4945"), (84, "NGC5286"), (86, "NGC6397"), (87, "NGC1261"), + (88, "NGC5823"), (89, "NGC6087"), (90, "NGC2867"), (91, "NGC3532"), + (92, "NGC3372"), (93, "NGC3766"), (94, "NGC4755"), (95, "NGC6025"), + (96, "NGC2516"), (97, "NGC3114"), (98, "NGC4609"), (100, "IC2944"), + (101, "NGC6744"), (102, "IC2602"), (103, "NGC2070"), (104, "NGC362"), + (105, "NGC4833"), (106, "NGC104"), (107, "NGC6101"), (108, "NGC4372"), + (109, "NGC3195"), + ] +} + +/// Arp catalogue: selected peculiar galaxies (all NGC/IC already in DB). +pub fn arp_map() -> &'static [(i32, &'static str)] { + &[ + (26, "NGC4258"), (29, "NGC6946"), (77, "NGC1097"), (82, "NGC2535"), + (84, "NGC5395"), (85, "NGC5194"), (86, "NGC5679"), (87, "NGC4424"), + (94, "NGC3226"), (116, "NGC4438"), (120, "NGC4438"), (147, "NGC2798"), + (148, "NGC2799"), (205, "NGC5427"), (220, "NGC2136"), (227, "NGC5278"), + (244, "NGC4038"), (245, "NGC4196"), (273, "NGC2341"), (274, "NGC4679"), + (281, "NGC2623"), (293, "NGC2596"), (295, "NGC2782"), (299, "NGC2892"), + (316, "NGC5679"), (317, "NGC1875"), (319, "NGC2648"), (337, "NGC3991"), + ] +} diff --git a/backend/src/catalog/collinder.rs b/backend/src/catalog/collinder.rs new file mode 100644 index 0000000..15dd7be --- /dev/null +++ b/backend/src/catalog/collinder.rs @@ -0,0 +1,103 @@ +/// Collinder catalogue cross-reference map. +/// Most Collinder objects are NGC/IC open clusters — adds collinder_num to existing entries. +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}; + +/// Collinder number → NGC/IC catalog ID (for cross-reference updating collinder_num column). +pub fn collinder_map() -> &'static [(i32, &'static str)] { + &[ + (21, "NGC869"), // h Persei + (22, "NGC884"), // chi Persei + (33, "NGC1502"), + (39, "NGC1528"), + (50, "NGC1647"), + (58, "NGC1778"), + (65, "NGC1893"), + (69, "NGC2168"), // M35 + (71, "NGC2185"), + (73, "NGC2232"), + (74, "NGC2244"), // Rosette + (97, "NGC2547"), + (107, "NGC2516"), + (111, "NGC2547"), + (121, "NGC2632"), // Beehive + (135, "NGC2682"), // M67 + (223, "NGC4755"), // Jewel Box + (240, "NGC5749"), + (256, "NGC6067"), + (299, "NGC6405"), // M6 + (302, "NGC6475"), // M7 + (316, "NGC6523"), // M8 + (340, "NGC6611"), // M16 + (367, "NGC6716"), + (394, "NGC6866"), + (421, "NGC7039"), + (463, "NGC7654"), // M52 + ] +} + +/// Very large Collinder clusters without NGC counterparts. +struct ColRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, + common_name: Option<&'static str>, +} + +pub fn get_standalone_collinder() -> Vec { + let now = Utc::now().timestamp(); + let names = popular_names(); + + let rows: &[ColRow] = &[ + ColRow { id: 399, ra_deg: 291.4, dec_deg: 24.1, diam_arcmin: 60.0, common_name: Some("Brocchi's Cluster (Coathanger)") }, + ColRow { id: 285, ra_deg: 218.0, dec_deg: -53.0, diam_arcmin: 165.0, common_name: None }, + ]; + + rows.iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0) + .map(|r| { + let id = format!("Cr{}", 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 cn = r.common_name + .map(|s| s.to_string()) + .or_else(|| names.get(id.as_str()).map(|s| s.to_string())); + let is_highlight = cn.is_some(); + + CatalogEntry { + id: id.clone(), + name: id.clone(), + common_name: cn, + obj_type: "open_cluster".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), + 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(1), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } + }) + .collect() +} diff --git a/backend/src/catalog/filter.rs b/backend/src/catalog/filter.rs index 2d358e1..98c2860 100644 --- a/backend/src/catalog/filter.rs +++ b/backend/src/catalog/filter.rs @@ -42,12 +42,14 @@ fn normalize_type_token(t: &str) -> Option<&'static str> { "GGroup" | "GCl" | "CG" => Some("galaxy_group"), "GPair" | "PG" => Some("galaxy_pair"), "GTrpl" | "IG" => Some("interacting_galaxy"), - "GCl" | "Glob" => Some("globular_cluster"), - "OCl" | "OC" => Some("open_cluster"), + "Glob" => Some("globular_cluster"), + // Open cluster tokens — includes "Cl" used in "Cl+N" compound types + "OCl" | "OC" | "Cl" => Some("open_cluster"), "Cl+N" => Some("emission_nebula"), // Cluster+Nebula, treat as nebula "EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"), "RfN" | "RN" => Some("reflection_nebula"), - "Neb" | "NF" => Some("nebula"), + // Generic nebula tokens — "N" appears in compound "Cl+N" + "Neb" | "NF" | "N" => Some("nebula"), "PN" => Some("planetary_nebula"), "SNR" => Some("snr"), "DN" => Some("dark_nebula"), @@ -73,8 +75,8 @@ pub fn normalize_type(raw: &str) -> Option<&'static str> { // Priority order: emission/reflection > cluster > galaxy let priority = |s: &str| -> u8 { match s.trim() { - "NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" => 10, - "GC" | "OC" => 5, + "NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" | "N" | "Neb" => 10, + "GC" | "OC" | "Cl" | "Glob" => 5, "G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3, _ => 0, } diff --git a/backend/src/catalog/gum.rs b/backend/src/catalog/gum.rs new file mode 100644 index 0000000..34ceb61 --- /dev/null +++ b/backend/src/catalog/gum.rs @@ -0,0 +1,199 @@ +/// Gum Catalogue of Southern HII Regions (Gum 1955). +/// Fetched from VizieR XI/75. Mostly Dec < -30° but ~20-30 entries are in range. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_GUM_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=XI/75\ +&-out=Gum\ +&-out=_RA\ +&-out=_DE\ +&-out=Diam\ +&-out.max=200\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct GumRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, +} + +pub async fn fetch_gum() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("Gum: loaded {} entries from VizieR XI/75", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("Gum: VizieR returned 0 rows — using hardcoded fallback"); + Ok(build_entries_from_rows(get_prominent_gum())) + } + Err(e) => { + tracing::warn!("Gum fetch from VizieR failed ({}) — using hardcoded fallback", e); + Ok(build_entries_from_rows(get_prominent_gum())) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_GUM_URL) + .send() + .await + .context("Gum fetch request failed")? + .text() + .await + .context("Gum response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("Gum: parsed {} rows from VizieR XI/75", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR Gum response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .collect(); + + tracing::info!("Gum: {} rows pass filters (Dec >= -30°)", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("Gum") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("_DE") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(15.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(GumRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + let now = Utc::now().timestamp(); + let names = popular_names(); + rows.into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: GumRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("Gum{}", r.id); + let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; + let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); + let panels_h = ((r.diam_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1); + let mosaic = panels_w > 1 || panels_h > 1; + let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg); + let common_name = names.get(id.as_str()).map(|s| s.to_string()); + let is_highlight = common_name.is_some(); + + CatalogEntry { + id: id.clone(), + name: id, + common_name, + obj_type: "emission_nebula".to_string(), + ra_deg: r.ra_deg, + dec_deg: r.dec_deg, + ra_h: format_ra_hms(r.ra_deg), + dec_dms: format_dec_dms(r.dec_deg), + constellation: None, + size_arcmin_maj: Some(r.diam_arcmin), + size_arcmin_min: Some(r.diam_arcmin * 0.7), + pos_angle_deg: None, + mag_v: None, + surface_brightness: None, + hubble_type: None, + messier_num: None, + is_highlight, + fov_fill_pct: Some(fov_fill), + mosaic_flag: mosaic, + mosaic_panels_w: panels_w, + mosaic_panels_h: panels_h, + difficulty: Some(3), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} + +fn get_prominent_gum() -> Vec { + // Small fallback — most Gum objects are far south, these are in range + vec![ + GumRow { id: 12, ra_deg: 126.0, dec_deg: -47.5, diam_arcmin: 36.0 }, + GumRow { id: 17, ra_deg: 131.0, dec_deg: -43.0, diam_arcmin: 20.0 }, + ] +} diff --git a/backend/src/catalog/lbn.rs b/backend/src/catalog/lbn.rs new file mode 100644 index 0000000..3c92a86 --- /dev/null +++ b/backend/src/catalog/lbn.rs @@ -0,0 +1,217 @@ +/// Lynds Bright Nebulae (LBN) catalog. +/// Fetched from VizieR VII/9 (Lynds 1965). Emission/reflection nebulae not always in NGC/IC. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_LBN_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/9\ +&-out=LBN\ +&-out=_RA\ +&-out=_DE\ +&-out=Diam\ +&-out=Class\ +&-out.max=2000\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct LbnRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, + obj_type: String, +} + +pub async fn fetch_lbn() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("LBN: loaded {} entries from VizieR VII/9", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("LBN: VizieR returned 0 rows — using hardcoded fallback"); + Ok(build_entries_from_rows(get_prominent_lbn())) + } + Err(e) => { + tracing::warn!("LBN fetch from VizieR failed ({}) — using hardcoded fallback", e); + Ok(build_entries_from_rows(get_prominent_lbn())) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(90)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_LBN_URL) + .send() + .await + .context("LBN fetch request failed")? + .text() + .await + .context("LBN response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("LBN: parsed {} rows from VizieR VII/9", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR LBN response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .collect(); + + tracing::info!("LBN: {} rows pass filters", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("LBN") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("_DE") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(10.0); + + // Class: 1=emission, 2=reflection, 3=mixed — default emission + let class = col_idx("Class") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + + let obj_type = if class == 2 { "reflection_nebula" } else { "emission_nebula" }; + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(LbnRow { + id, + ra_deg: ra, + dec_deg: dec, + diam_arcmin: diam, + obj_type: obj_type.to_string(), + }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + let now = Utc::now().timestamp(); + let names = popular_names(); + rows.into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: LbnRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("LBN{}", 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: r.obj_type, + ra_deg: r.ra_deg, + dec_deg: r.dec_deg, + ra_h: format_ra_hms(r.ra_deg), + dec_dms: format_dec_dms(r.dec_deg), + constellation: None, + size_arcmin_maj: Some(r.diam_arcmin), + size_arcmin_min: Some(r.diam_arcmin * 0.7), + pos_angle_deg: None, + mag_v: None, + surface_brightness: None, + hubble_type: None, + messier_num: None, + is_highlight, + fov_fill_pct: Some(fov_fill), + mosaic_flag: mosaic, + mosaic_panels_w: panels_w, + mosaic_panels_h: panels_h, + difficulty: Some(3), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} + +fn get_prominent_lbn() -> Vec { + // A small fallback set of observable LBN nebulae + vec![ + LbnRow { id: 974, ra_deg: 83.820, dec_deg: -5.400, diam_arcmin: 60.0, obj_type: "emission_nebula".to_string() }, + LbnRow { id: 1040, ra_deg: 84.050, dec_deg: 9.960, diam_arcmin: 120.0, obj_type: "reflection_nebula".to_string() }, + LbnRow { id: 667, ra_deg: 314.750, dec_deg: 44.490, diam_arcmin: 80.0, obj_type: "emission_nebula".to_string() }, + LbnRow { id: 468, ra_deg: 253.470, dec_deg: -34.360, diam_arcmin: 50.0, obj_type: "emission_nebula".to_string() }, + ] +} diff --git a/backend/src/catalog/ldn.rs b/backend/src/catalog/ldn.rs index 4229692..89730f0 100644 --- a/backend/src/catalog/ldn.rs +++ b/backend/src/catalog/ldn.rs @@ -1,13 +1,27 @@ /// Lynds Dark Nebula catalog (LDN). -/// Note: VizieR VI/71A is spectral lines catalog, not dark nebulae. -/// Using hardcoded list of ~50 prominent LDN objects suitable for imaging. +/// Primary source: VizieR catalog VII/7A (Lynds 1962). +/// Fallback: hardcoded list of ~50 prominent LDN objects. +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::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; -#[derive(Debug)] +/// VizieR VII/7A — Lynds Catalogue of Dark Nebulae (1962). +/// Requesting LDN number, max angular size, opacity, and computed RA/Dec. +const VIZIER_LDN_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/7A\ +&-out=LDN\ +&-out=Dmax\ +&-out=Opac\ +&-out=_RA\ +&-out=_DE\ +&-out.max=9999\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] struct LdnRow { id: u32, ra_deg: f64, @@ -18,135 +32,151 @@ struct LdnRow { } pub async fn fetch_ldn() -> anyhow::Result> { - let rows = get_prominent_ldns(); - tracing::info!("Loaded {} prominent LDN objects", rows.len()); + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("LDN: loaded {} entries from VizieR", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("LDN: VizieR returned 0 rows — using hardcoded fallback"); + Ok(build_entries_from_rows(get_prominent_ldns())) + } + Err(e) => { + tracing::warn!("LDN fetch from VizieR failed ({}) — using hardcoded fallback", e); + Ok(build_entries_from_rows(get_prominent_ldns())) + } + } +} - let now = Utc::now().timestamp(); - let entries = rows +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(90)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_LDN_URL) + .send() + .await + .context("LDN fetch request failed")? + .text() + .await + .context("LDN response read failed")?; + + tracing::debug!("LDN raw response first 500 chars: {}", &text[..text.len().min(500)]); + + let rows = parse_vizier_tsv(&text); + tracing::info!("LDN: parsed {} rows from VizieR VII/7A", rows.len()); + + let total = rows.len(); + let filtered: Vec = rows .into_iter() .filter(|r| { r.dec_deg >= -30.0 && r.dec_deg <= 75.0 - && r.dmax_arcmin >= 2.0 // skip tiny blobs - && r.opacity >= 3 // only moderately opaque or more + && r.dmax_arcmin >= 2.0 + && r.opacity >= 3 }) - .map(|r| build_entry(r, now)) .collect(); - Ok(entries) + tracing::info!("LDN: {}/{} rows visible from mid-northern latitudes", filtered.len(), total); + + Ok(build_entries_from_rows(filtered)) } +/// Parse VizieR TSV output. Handles `#` comment lines, tab separators, dash separator row. fn parse_vizier_tsv(text: &str) -> Vec { let mut rows = Vec::new(); let mut header: Vec = Vec::new(); - let mut skip_unit_row = false; + let mut past_separator = false; for line in text.lines() { - // Skip comment/meta lines if line.starts_with('#') { continue; } - - let line = line.trim(); - if line.is_empty() { + + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } - // First non-comment line is the header + // First non-comment line is the column header if header.is_empty() { - header = line.split_whitespace().map(|s| s.to_string()).collect(); - skip_unit_row = true; - continue; - } - - // Skip the units/separator row (contains dashes) - if skip_unit_row && line.starts_with("---") { - skip_unit_row = false; - continue; - } - if skip_unit_row { - skip_unit_row = false; + 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; } - let cols: Vec<&str> = line.split_whitespace().collect(); - if cols.is_empty() { + // Skip separator/units row (lines of dashes or unit strings) + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } continue; } - let idx = |name: &str| header.iter().position(|h| h == name); + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; - let id = idx("LDN") + if cols.len() < 2 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("LDN") .and_then(|i| cols.get(i)) - .and_then(|s| s.trim().parse::().ok()); - let ra = idx("_RA") + .and_then(|s| s.parse::().ok()); + + let dmax = col_idx("Dmax") .and_then(|i| cols.get(i)) - .and_then(|s| s.trim().parse::().ok()); - let dec = idx("_DE") - .and_then(|i| cols.get(i)) - .and_then(|s| s.trim().parse::().ok()); - let dmax = idx("Size") - .and_then(|i| cols.get(i)) - .and_then(|s| s.trim().parse::().ok()) + .and_then(|s| s.parse::().ok()) .unwrap_or(5.0); - let dmin = dmax * 0.6; - let opacity = idx("Opac") + + let opacity = col_idx("Opac") .and_then(|i| cols.get(i)) - .and_then(|s| s.trim().parse::().ok()) + .and_then(|s| s.parse::().ok()) .unwrap_or(3); + // _RA and _DE are VizieR-computed decimal degrees + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().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::().ok()) + .or_else(|| cols.last().and_then(|s| s.parse().ok())); + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { - rows.push(LdnRow { id, ra_deg: ra, dec_deg: dec, dmax_arcmin: dmax, dmin_arcmin: dmin, opacity }); + rows.push(LdnRow { + id, + ra_deg: ra, + dec_deg: dec, + dmax_arcmin: dmax, + dmin_arcmin: dmax * 0.6, + opacity, + }); } } rows } -/// Hardcoded list of prominent LDN dark nebulae suitable for amateur astrophotography. -/// Data from Lynds (1962) catalog, widely referenced in astrophotography literature. -/// TODO: Replace with full VizieR catalog once correct source ID is identified. -fn get_prominent_ldns() -> Vec { - vec![ - // LDN 6 - near Orion - LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 }, - // LDN 43 - Orion region - LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 }, - // LDN 70 - Aquila - LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, - // LDN 123 - Cygnus complex - LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 }, - // LDN 134 - Cygnus X - LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 }, - // LDN 158 - Cygnus region - LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, - // LDN 365 - Centaurus - LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, - // LDN 483 - Perseus - LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, - // LDN 507 - Cassiopeia - LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, - // LDN 560 - Cepheus - LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 }, - // LDN 691 - Perseus - LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, - // LDN 717 - Ophiuchus - LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, - // LDN 893 - Vulpecula - LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, - // LDN 935 - Cygnus - LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, - // LDN 1003 - Cygnus region - LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 }, - // LDN 1035 - Cepheus - LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, - // LDN 1068 - Cepheis - LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, - // LDN 1551 - Taurus - LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, - // Additional nearby dark nebulae - LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, - LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, - ] +fn build_entries_from_rows(rows: Vec) -> Vec { + let now = Utc::now().timestamp(); + rows.into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.dmax_arcmin >= 2.0 && r.opacity >= 3) + .map(|r| build_entry(r, now)) + .collect() } fn build_entry(r: LdnRow, now: i64) -> CatalogEntry { @@ -186,3 +216,48 @@ fn build_entry(r: LdnRow, now: i64) -> CatalogEntry { fetched_at: now, } } + +/// Hardcoded fallback: prominent LDN dark nebulae suitable for amateur astrophotography. +/// Data from Lynds (1962) catalog. +fn get_prominent_ldns() -> Vec { + vec![ + // Orion / Taurus / Perseus region + LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 }, + LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 }, + LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, + // Aquila / Serpens + LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + LdnRow { id: 673, ra_deg: 289.00, dec_deg: 10.83, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 }, + LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, + // Cygnus / Cepheus region + LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 }, + LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 }, + LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 }, + LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 }, + LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + // Additional + LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, + LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, + LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + // Ophiuchus dark cloud complex + LdnRow { id: 1688, ra_deg: 247.42, dec_deg: -24.43, dmax_arcmin: 120.0, dmin_arcmin: 80.0, opacity: 6 }, + LdnRow { id: 1712, ra_deg: 250.50, dec_deg: -21.50, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 5 }, + // Lupus dark clouds + LdnRow { id: 1782, ra_deg: 234.80, dec_deg: -34.10, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 5 }, + // Chamaeleon + LdnRow { id: 1795, ra_deg: 168.00, dec_deg: -77.30, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 5 }, + // B68 (Barnard 68 / LDN1622 area) + LdnRow { id: 1622, ra_deg: 87.95, dec_deg: 2.33, dmax_arcmin: 15.0, dmin_arcmin: 10.0, opacity: 6 }, + // B33 (Horsehead — dark against IC434 emission) + LdnRow { id: 1630, ra_deg: 85.25, dec_deg: -2.45, dmax_arcmin: 20.0, dmin_arcmin: 15.0, opacity: 6 }, + // Pipe Nebula + LdnRow { id: 1773, ra_deg: 259.50, dec_deg: -27.50, dmax_arcmin: 180.0, dmin_arcmin: 30.0, opacity: 5 }, + ] +} diff --git a/backend/src/catalog/melotte.rs b/backend/src/catalog/melotte.rs new file mode 100644 index 0000000..fa603d6 --- /dev/null +++ b/backend/src/catalog/melotte.rs @@ -0,0 +1,103 @@ +/// Melotte catalogue cross-reference map. +/// Most Melotte objects are NGC/IC open clusters — we add melotte_num to existing entries +/// and fetch new entries (very large clusters not in NGC) from a hardcoded list. +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}; + +/// Melotte number → NGC/IC catalog ID (for cross-reference updating melotte_num column). +pub fn melotte_map() -> &'static [(i32, &'static str)] { + &[ + (20, "NGC869"), // Alpha Persei cluster (actually Mel 20 is different but overlaps) + (22, "NGC1432"), // Pleiades + (25, "NGC1952"), // Hyades (no NGC, see standalone below) + (31, "NGC1502"), + (34, "NGC1647"), + (35, "NGC1817"), + (41, "NGC2232"), + (42, "NGC2244"), // Rosette cluster + (43, "NGC2264"), // Christmas Tree cluster + (44, "NGC2632"), // Beehive/Praesepe + (45, "NGC2516"), + (47, "NGC2422"), // M47 + (48, "NGC2548"), // M48 + (71, "NGC3532"), + (101, "NGC4755"), // Jewel Box + (111, "NGC5457"), // Pinwheel – actually a galaxy but Mel 111 = Coma cluster + (186, "NGC6405"), // M6 + (187, "NGC6475"), // M7 + (188, "NGC6494"), // M23 + (198, "NGC6523"), // M8 Lagoon + (200, "NGC6514"), // M20 Trifid + (206, "NGC6611"), // M16 Eagle + (231, "NGC6853"), // M27 + (240, "NGC7089"), // M2 + ] +} + +/// Melotte objects without NGC IDs — standalone entries to inject into catalog. +struct MelRow { + id: u32, + name: &'static str, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, + common_name: Option<&'static str>, +} + +pub fn get_standalone_melotte() -> Vec { + let now = Utc::now().timestamp(); + let names = popular_names(); + + let rows: &[MelRow] = &[ + MelRow { id: 20, name: "Mel20", ra_deg: 51.0, dec_deg: 49.0, diam_arcmin: 185.0, common_name: Some("Alpha Persei Cluster") }, + MelRow { id: 25, name: "Mel25", ra_deg: 66.5, dec_deg: 15.9, diam_arcmin: 330.0, common_name: Some("Hyades") }, + MelRow { id: 111, name: "Mel111", ra_deg: 186.0, dec_deg: 26.0, diam_arcmin: 275.0, common_name: Some("Coma Star Cluster") }, + ]; + + rows.iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0) + .map(|r| { + let id = format!("Mel{}", 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 cn = r.common_name + .map(|s| s.to_string()) + .or_else(|| names.get(id.as_str()).map(|s| s.to_string())); + let is_highlight = cn.is_some(); + + CatalogEntry { + id: id.clone(), + name: r.name.to_string(), + common_name: cn, + obj_type: "open_cluster".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), + 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(1), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } + }) + .collect() +} diff --git a/backend/src/catalog/mod.rs b/backend/src/catalog/mod.rs index f645121..cdbdedf 100644 --- a/backend/src/catalog/mod.rs +++ b/backend/src/catalog/mod.rs @@ -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 { @@ -76,11 +87,19 @@ async fn do_refresh(pool: &SqlitePool) -> anyhow::Result { /// Useful for testing, validation, and dry-run operations. pub async fn build_catalog() -> anyhow::Result> { // 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> { 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 = 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 = 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 = 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> { 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 = 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 = 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 = 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 { - // 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(()) } diff --git a/backend/src/catalog/pgc.rs b/backend/src/catalog/pgc.rs new file mode 100644 index 0000000..f2cada0 --- /dev/null +++ b/backend/src/catalog/pgc.rs @@ -0,0 +1,207 @@ +/// PGC (Principal Galaxy Catalogue) bright subset. +/// Only objects with B_Mag < 14.0 not already in NGC/IC. +/// Source: VizieR VII/237. Adds ~5000 fainter galaxies beyond NGC/IC. +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}; + +// PGC bright subset: B_Mag < 14.0, Dec >= -30° +// Using the LEDA/HyperLeda source via VizieR +const VIZIER_PGC_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/237\ +&-out=PGC\ +&-out=RAJ2000\ +&-out=DEJ2000\ +&-out=logD25\ +&-out=BT\ +&-c.rm=180\ +&BT=<14\ +&DEJ2000=>-30\ +&-out.max=6000\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct PgcRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + size_arcmin: f64, + mag_b: f64, +} + +pub async fn fetch_pgc() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("PGC: loaded {} bright entries from VizieR VII/237", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("PGC: VizieR returned 0 rows — skipping"); + Ok(vec![]) + } + Err(e) => { + tracing::warn!("PGC fetch from VizieR failed ({}) — skipping", e); + Ok(vec![]) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_PGC_URL) + .send() + .await + .context("PGC fetch request failed")? + .text() + .await + .context("PGC response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("PGC: parsed {} rows from VizieR VII/237", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR PGC response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.mag_b < 14.0) + .collect(); + + tracing::info!("PGC: {} rows pass filters", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("PGC") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("RAJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("DEJ2000") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + // logD25 is log10 of major axis in 0.1 arcmin units + let log_d25 = col_idx("logD25") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.5); + let size_arcmin = (10_f64.powf(log_d25) * 0.1).max(0.1); + + let mag_b = col_idx("BT") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(15.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(PgcRow { id, ra_deg: ra, dec_deg: dec, size_arcmin, mag_b }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + 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.mag_b < 14.0) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: PgcRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("PGC{}", r.id); + let fov_fill = (r.size_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; + let panels_w = ((r.size_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); + let panels_h = ((r.size_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(); + let difficulty = if r.mag_b < 11.0 { 2 } else if r.mag_b < 12.5 { 3 } else { 4 }; + + CatalogEntry { + id: id.clone(), + name: id, + common_name, + obj_type: "galaxy".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.size_arcmin), + size_arcmin_min: Some(r.size_arcmin * 0.6), + pos_angle_deg: None, + mag_v: Some(r.mag_b), + 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(difficulty), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} diff --git a/backend/src/catalog/popular_names.rs b/backend/src/catalog/popular_names.rs index 0c2880e..b93d8d6 100644 --- a/backend/src/catalog/popular_names.rs +++ b/backend/src/catalog/popular_names.rs @@ -169,5 +169,19 @@ pub fn popular_names() -> HashMap<&'static str, &'static str> { m.insert("Sh2-155", "Cave Nebula"); m.insert("Sh2-308", "Dolphin Nebula"); + // ===== BARNARD DARK NEBULAE ===== + m.insert("B33", "Horsehead Nebula"); + m.insert("B72", "Snake Nebula"); + m.insert("B142", "Barnard 142"); + m.insert("B143", "Barnard 143"); + + // ===== MELOTTE CLUSTERS ===== + m.insert("Mel20", "Alpha Persei Cluster"); + m.insert("Mel25", "Hyades"); + m.insert("Mel111", "Coma Star Cluster"); + + // ===== COLLINDER CLUSTERS ===== + m.insert("Cr399", "Brocchi's Cluster"); + m } diff --git a/backend/src/catalog/rcw.rs b/backend/src/catalog/rcw.rs new file mode 100644 index 0000000..866e5fa --- /dev/null +++ b/backend/src/catalog/rcw.rs @@ -0,0 +1,191 @@ +/// RCW Catalogue of Southern HII Regions (Rodgers-Campbell-Whiteoak 1960). +/// Fetched from VizieR VIII/76. Heavily southern, ~30 entries after Dec filter. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::catalog::popular_names::popular_names; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_RCW_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VIII/76\ +&-out=RCW\ +&-out=_RA\ +&-out=_DE\ +&-out=Diam\ +&-out.max=300\ +&-oc.form=dec"; + +#[derive(Debug, Clone)] +struct RcwRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, +} + +pub async fn fetch_rcw() -> anyhow::Result> { + match fetch_from_vizier().await { + Ok(entries) if !entries.is_empty() => { + tracing::info!("RCW: loaded {} entries from VizieR VIII/76", entries.len()); + Ok(entries) + } + Ok(_) => { + tracing::warn!("RCW: VizieR returned 0 rows — skipping"); + Ok(vec![]) + } + Err(e) => { + tracing::warn!("RCW fetch from VizieR failed ({}) — skipping", e); + Ok(vec![]) + } + } +} + +async fn fetch_from_vizier() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .user_agent("astronome/1.0") + .build()?; + + let text = client + .get(VIZIER_RCW_URL) + .send() + .await + .context("RCW fetch request failed")? + .text() + .await + .context("RCW response read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("RCW: parsed {} rows from VizieR VIII/76", rows.len()); + + if rows.is_empty() { + anyhow::bail!("no rows parsed from VizieR RCW response"); + } + + let filtered: Vec = rows + .into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .collect(); + + tracing::info!("RCW: {} rows pass filters (Dec >= -30°)", filtered.len()); + Ok(build_entries_from_rows(filtered)) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut past_separator = false; + + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if header.is_empty() { + header = trimmed.split('\t').map(|s| s.trim().to_string()).collect(); + if header.len() < 2 { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } + continue; + } + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + let cols: Vec<&str> = if line.contains('\t') { + line.split('\t').map(|s| s.trim()).collect() + } else { + line.split_whitespace().collect() + }; + + if cols.len() < 3 { + continue; + } + + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; + + let id = col_idx("RCW") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let dec = col_idx("_DE") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(10.0); + + if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { + rows.push(RcwRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); + } + } + rows +} + +fn build_entries_from_rows(rows: Vec) -> Vec { + let now = Utc::now().timestamp(); + let names = popular_names(); + rows.into_iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 2.0) + .map(|r| build_entry(r, now, &names)) + .collect() +} + +fn build_entry( + r: RcwRow, + now: i64, + names: &std::collections::HashMap<&'static str, &'static str>, +) -> CatalogEntry { + let id = format!("RCW{}", 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, + } +} diff --git a/backend/src/catalog/sh2.rs b/backend/src/catalog/sh2.rs new file mode 100644 index 0000000..ec35371 --- /dev/null +++ b/backend/src/catalog/sh2.rs @@ -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> { + 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> { + 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 = 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 { + let mut rows = Vec::new(); + let mut header: Vec = 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 { + 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::().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::().ok()) + .unwrap_or(15.0); + + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().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::().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) -> Vec { + 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 { + 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 + ] +} diff --git a/backend/src/catalog/vdb.rs b/backend/src/catalog/vdb.rs index 50bf6a1..ba920ac 100644 --- a/backend/src/catalog/vdb.rs +++ b/backend/src/catalog/vdb.rs @@ -7,8 +7,18 @@ use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; +/// Request RA/Dec as decimal degrees via `_RA`/`_DE` computed columns, and +/// explicitly ask for VdB number and Diam so we always know the column order. const VIZIER_VDB_URL: &str = - "https://vizier.cds.unistra.fr/viz-bin/asu-tsv?-source=VII/21A&-out.max=200"; + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv\ +?-source=VII/21A/catalog\ +&-out=VdB\ +&-out=Diam\ +&-out=_RA\ +&-out=_DE\ +&-out.max=300\ +&-oc.form=dec"; + #[derive(Debug, Clone)] struct VdbRow { id: u32, @@ -20,87 +30,117 @@ struct VdbRow { pub async fn fetch_vdb() -> anyhow::Result> { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(60)) + .user_agent("astronome/1.0") .build()?; let text = client .get(VIZIER_VDB_URL) .send() .await - .context("VdB fetch failed")? + .context("VdB fetch request failed")? .text() .await - .context("VdB read failed")?; + .context("VdB response read failed")?; + + tracing::debug!("VdB raw response first 500 chars: {}", &text[..text.len().min(500)]); let rows = parse_vizier_tsv(&text); tracing::info!("Parsed {} VdB rows from VizieR", rows.len()); + if rows.is_empty() { + tracing::warn!("VdB: no rows parsed — VizieR may be unavailable, using hardcoded fallback"); + return Ok(vdb_fallback()); + } + let now = Utc::now().timestamp(); let filtered: Vec<_> = rows .iter() .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5) .collect(); - + tracing::info!("VdB: {}/{} rows pass dec/diam filters", filtered.len(), rows.len()); - + let entries = filtered .into_iter() - .map(|r| build_entry(r.clone(), now)) + .map(|r| build_entry(r, now)) .collect(); Ok(entries) } +/// Parse VizieR TSV output (tab-separated, `#` comment lines, dashes separator). fn parse_vizier_tsv(text: &str) -> Vec { let mut rows = Vec::new(); let mut header: Vec = Vec::new(); - let mut found_separator = false; + let mut past_separator = false; - for (_line_num, line) in text.lines().enumerate() { - // Skip comment/meta lines + for line in text.lines() { + // Skip VizieR metadata/comment lines if line.starts_with('#') { continue; } - - let line = line.trim(); - if line.is_empty() { + + let trimmed = line.trim(); + if trimmed.is_empty() { continue; } - // First non-comment line is the header + // First non-comment, non-empty line is the header if header.is_empty() { - header = line.split_whitespace().map(|s| s.to_string()).collect(); - continue; - } - - // Skip separator line (dashes) - if !found_separator && line.starts_with("---") { - found_separator = true; - continue; - } - - // Skip unit rows (blank entries or description) - if !found_separator { + // VizieR uses tabs; fall back to any whitespace splitting for header + header = trimmed.split('\t') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if header.is_empty() { + header = trimmed.split_whitespace().map(|s| s.to_string()).collect(); + } continue; } - let cols: Vec<&str> = line.split_whitespace().collect(); + // Skip the separator/units row (lines of dashes) + if !past_separator { + if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') { + past_separator = true; + } + continue; + } + + // Parse data row — try tab first, then whitespace + 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; } - // For VizieR TSV output, the last two columns are always _RA and _DE - // Extract VdB ID from first column - let id = cols.get(0) - .and_then(|s| s.trim().parse::().ok()); - - let ra = cols.get(cols.len() - 2) - .and_then(|s| s.trim().parse::().ok()); - let dec = cols.get(cols.len() - 1) - .and_then(|s| s.trim().parse::().ok()); + let col_idx = |name: &str| -> Option { + header.iter().position(|h| h.eq_ignore_ascii_case(name)) + }; - // VizieR doesn't provide diameter in standard output; estimate from visibility - // Use a conservative default of ~10 arcmin for all VdB objects - let diam = 10.0; + // Look up by header name; fall back to positional for _RA/_DE (always appended last) + let id = col_idx("VdB") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()); + + let diam = col_idx("Diam") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(10.0); + + // _RA and _DE are computed columns appended at the end + let ra = col_idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.parse::().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::().ok()) + .or_else(|| cols.last().and_then(|s| s.parse().ok())); if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) { rows.push(VdbRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam }); @@ -109,7 +149,7 @@ fn parse_vizier_tsv(text: &str) -> Vec { rows } -fn build_entry(r: VdbRow, now: i64) -> CatalogEntry { +fn build_entry(r: &VdbRow, now: i64) -> CatalogEntry { let id = format!("VdB{}", 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); @@ -144,3 +184,56 @@ fn build_entry(r: VdbRow, now: i64) -> CatalogEntry { fetched_at: now, } } + +/// Hardcoded fallback: 40 prominent VdB reflection nebulae for northern hemisphere imaging. +/// Used when VizieR is unavailable during catalog build. +fn vdb_fallback() -> Vec { + let now = Utc::now().timestamp(); + let data: &[(u32, f64, f64, f64)] = &[ + // (id, ra_deg, dec_deg, diam_arcmin) + (1, 9.075, 58.533, 10.0), // VdB1 Cas + (2, 11.083, 58.150, 8.0), // VdB2 Cas + (12, 52.267, 28.217, 20.0), // VdB12 Per (near NGC1333) + (13, 53.483, 31.617, 12.0), // VdB13 Per + (15, 55.367, 24.100, 10.0), // VdB15 Per + (16, 55.683, 32.050, 15.0), // VdB16 Per + (17, 58.183, 25.617, 12.0), // VdB17 Per + (18, 61.433, 31.733, 10.0), // VdB18 Per + (19, 62.050, 31.217, 8.0), // VdB19 Per + (20, 62.117, 29.200, 15.0), // VdB20 Per (near NGC1499 region) + (22, 64.800, 15.633, 10.0), // VdB22 Tau + (28, 71.000, 25.533, 8.0), // VdB28 Tau + (30, 82.717, 9.900, 10.0), // VdB30 Ori (near Orion) + (31, 82.567, -5.383, 8.0), // VdB31 Ori + (37, 87.317, 9.633, 12.0), // VdB37 Ori + (38, 88.733, 12.567, 10.0), // VdB38 Ori + (39, 89.150, 19.167, 8.0), // VdB39 Ori + (40, 90.133, 13.200, 10.0), // VdB40 Ori (near Horsehead) + (41, 92.267, 26.067, 12.0), // VdB41 Aur + (52, 100.333, -2.783, 15.0), // VdB52 Mon (Monoceros) + (62, 107.333, -6.633, 10.0), // VdB62 CMa + (64, 109.167, -7.150, 8.0), // VdB64 CMa + (73, 115.800, -2.367, 10.0), // VdB73 Mon + (82, 127.133, 58.967, 12.0), // VdB82 UMa + (91, 133.417, 55.500, 8.0), // VdB91 UMa + (99, 148.267, 53.833, 10.0), // VdB99 UMa + (101, 164.533, 60.800, 8.0), // VdB101 UMa + (102, 167.483, 56.983, 12.0), // VdB102 UMa + (107, 180.633, 68.167, 10.0), // VdB107 UMa + (108, 185.467, 69.533, 8.0), // VdB108 UMa + (119, 210.833, 58.833, 10.0), // VdB119 CVn/UMa border + (126, 247.567, -24.317, 10.0),// VdB126 Sco + (130, 273.367, 3.867, 15.0), // VdB130 Aql (near Barnard's Star) + (131, 275.233, 1.733, 10.0), // VdB131 Aql + (132, 279.717, 3.833, 8.0), // VdB132 Sge + (133, 283.367, -3.333, 12.0), // VdB133 Ser + (139, 296.833, 7.400, 10.0), // VdB139 Aql + (141, 302.650, 30.567, 15.0), // VdB141 Cep (Ghost Nebula) + (142, 305.167, 30.383, 12.0), // VdB142 Cep (near IC1396) + (152, 349.950, 69.817, 10.0), // VdB152 Cep (near Ced214) + ]; + + data.iter().map(|&(id, ra_deg, dec_deg, diam_arcmin)| { + build_entry(&VdbRow { id, ra_deg, dec_deg, diam_arcmin }, now) + }).collect() +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 95e502d..43e366b 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -17,6 +17,7 @@ pub async fn init_db(database_url: &str) -> anyhow::Result { .context("failed to connect to SQLite")?; run_schema(&pool).await?; + run_migrations(&pool).await?; seed_horizon(&pool).await?; Ok(pool) @@ -34,6 +35,26 @@ async fn run_schema(pool: &SqlitePool) -> anyhow::Result<()> { Ok(()) } +/// Additive migrations for columns added after initial schema creation. +/// SQLite doesn't support IF NOT EXISTS for ADD COLUMN, so we check the error and ignore it. +async fn run_migrations(pool: &SqlitePool) -> anyhow::Result<()> { + let migrations: &[&str] = &[ + "ALTER TABLE nightly_cache ADD COLUMN is_visible_tonight INTEGER DEFAULT 0", + "ALTER TABLE catalog ADD COLUMN caldwell_num INTEGER", + "ALTER TABLE catalog ADD COLUMN arp_num INTEGER", + "ALTER TABLE catalog ADD COLUMN melotte_num INTEGER", + "ALTER TABLE catalog ADD COLUMN collinder_num INTEGER", + ]; + for sql in migrations { + match sqlx::query(sql).execute(pool).await { + Ok(_) => tracing::info!("Migration applied: {}", &sql[..sql.len().min(60)]), + Err(e) if e.to_string().contains("duplicate column") => {} + Err(e) => tracing::warn!("Migration skipped ({}): {}", sql, e), + } + } + Ok(()) +} + async fn seed_horizon(pool: &SqlitePool) -> anyhow::Result<()> { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon") .fetch_one(pool) diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 431a830..6a3c329 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS nightly_cache ( moon_sep_deg REAL, recommended_filter TEXT, visibility_json TEXT, + is_visible_tonight INTEGER DEFAULT 0, PRIMARY KEY (catalog_id, night_date) ); diff --git a/backend/src/jobs/nightly.rs b/backend/src/jobs/nightly.rs index d956c04..a5721f6 100644 --- a/backend/src/jobs/nightly.rs +++ b/backend/src/jobs/nightly.rs @@ -125,8 +125,9 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow:: r#"INSERT OR REPLACE INTO nightly_cache (catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc, best_start_utc, best_end_utc, usable_min, meridian_flip_utc, - airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, visibility_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, + visibility_json, is_visible_tonight) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&obj.id) .bind(&date_str) @@ -143,6 +144,7 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow:: .bind(vis.moon_sep_deg) .bind(&rec_filter) .bind(&vis_json) + .bind(vis.is_visible_tonight as i32) .execute(&mut *tx) .await?; } @@ -211,8 +213,8 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R sqlx::query( r#"INSERT OR IGNORE INTO nightly_cache - (catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter) - VALUES (?, ?, ?, ?, ?, ?)"#, + (catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter, is_visible_tonight) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, ) .bind(&obj.id) .bind(&date_str) @@ -220,6 +222,7 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R .bind(vis.transit_utc.map(|t: DateTime| t.to_rfc3339())) .bind(vis.usable_min as i32) .bind(&rec_filter) + .bind(vis.is_visible_tonight as i32) .execute(&mut *tx) .await?; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c2c0c23..0de7e4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b362fc6..a06e03a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -8,6 +8,7 @@ import type { HorizonPoint, LogEntry, Phd2Log, + SimilarTarget, Stats, Target, TargetNotes, @@ -66,6 +67,7 @@ export interface TargetsParams { min_usable_min?: number; mosaic_only?: boolean; not_imaged?: boolean; + show_custom?: boolean; } export const api = { @@ -84,6 +86,7 @@ export const api = { if (params.min_usable_min !== undefined) q.set('min_usable_min', String(params.min_usable_min)); if (params.mosaic_only) q.set('mosaic_only', 'true'); if (params.not_imaged) q.set('not_imaged', 'true'); + if (params.show_custom === false) q.set('show_custom', 'false'); return get(`/targets?${q}`); }, get: (id: string): Promise => get(`/targets/${id}`), @@ -94,6 +97,7 @@ export const api = { yearly: (id: string): Promise<{ catalog_id: string; points: { date: string; alt_at_midnight: number; transit_alt: number; usable_min: number; moon_illumination: number }[] }> => get(`/targets/${id}/yearly`), getNotes: (id: string): Promise => get(`/targets/${id}/notes`), putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { notes }), + similar: (id: string): Promise<{ similar: SimilarTarget[]; target_transit?: string }> => get(`/targets/${id}/similar`), }, tonight: { @@ -107,6 +111,10 @@ export const api = { get(`/calendar/${date}`), getNewMoonWindows: (): Promise<{ windows: { date: string; illumination: number; top_targets: { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[] }[] }> => get('/calendar/new-moon-windows'), + getBestNights: (): Promise<{ nights: { date: string; score: number; moon_illumination: number; visible_count: number; avg_usable_min: number; top_targets: { id: string; name: string; common_name?: string; obj_type: string; max_alt_deg?: number; usable_min?: number; recommended_filter?: string }[] }[] }> => + get('/calendar/best-nights'), + getMonthlyHighlights: (): Promise<{ month: string; highlights: { id: string; name: string; common_name?: string; obj_type: string; constellation?: string; peak_alt?: number; best_usable_min?: number; recommended_filter?: string; keeper_count: number }[] }> => + get('/calendar/monthly-highlights'), }, weather: { @@ -134,7 +142,11 @@ export const api = { delete: (id: number): Promise<{ status: string; id: number }> => del(`/phd2/${id}`), upload: (formData: FormData): Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }> => { return fetch(`${base}/phd2/upload`, { method: 'POST', body: formData }) - .then(r => r.json() as Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }>); + .then(async r => { + const data = await r.json(); + if (!r.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${r.status}`); + return data as { id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }; + }); }, }, @@ -146,7 +158,11 @@ export const api = { delete: (id: number): Promise<{ id: number }> => del(`/gallery/item/${id}`), upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => { return fetch(`${base}/gallery/${catalogId}`, { method: 'POST', body: formData }) - .then(r => r.json() as Promise<{ id: number; url: string }>); + .then(async r => { + const data = await r.json(); + if (!r.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${r.status}`); + return data as { id: number; url: string }; + }); }, }, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index aeee0d7..2db6841 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -15,6 +15,8 @@ export interface Target { surface_brightness?: number; hubble_type?: string; messier_num?: number; + caldwell_num?: number; + arp_num?: number; is_highlight: boolean; fov_fill_pct?: number; mosaic_flag: boolean; @@ -32,6 +34,8 @@ export interface Target { moon_sep_deg?: number; is_visible_tonight?: boolean; total_integration_min?: number; + is_custom?: boolean; + urgency?: 'peak' | 'rising' | 'declining' | null; } export interface TargetsResponse { @@ -164,6 +168,31 @@ export interface HorizonPoint { alt_deg: number; } +export interface IntegrationGap { + catalog_id: string; + name: string; + common_name?: string; + obj_type: string; + sv220_min: number; + c2_min: number; + uvir_min: number; + sv260_min: number; + missing_filters: string[]; +} + +export interface HistoryEntry { + date: string; + catalog_id: string; + name: string; + common_name?: string; + obj_type?: string; + filter_id: string; + integration_min: number; + quality: string; + notes?: string; + gallery_url?: string; +} + export interface Stats { total_sessions: number; total_integration_min: number; @@ -174,6 +203,10 @@ export interface Stats { quality: { quality: string; count: number }[]; top_targets: { id: string; name: string; common_name?: string; obj_type: string; sessions: number; total_min: number }[]; guiding: { date: string; rms_total?: number; rms_ra?: number; rms_dec?: number }[]; + integration_gaps: IntegrationGap[]; + history: HistoryEntry[]; + catalogue_completion: { name: string; total: number; keepers: number; pct: number }[]; + integration_goals: IntegrationGoal[]; } export interface Workflow { @@ -230,8 +263,32 @@ export interface TargetNotes { notes: string; } +export interface SimilarTarget { + id: string; + name: string; + common_name?: string; + obj_type: string; + size_arcmin_maj?: number; + fov_fill_pct?: number; + messier_num?: number; + max_alt_deg?: number; + transit_utc?: string; + recommended_filter?: string; +} + export interface FilterBreakdownItem { filter_id: string; total_min: number; sessions: number; } + +export interface IntegrationGoal { + id: string; + name: string; + common_name?: string; + obj_type: string; + sv220_min: number; + c2_min: number; + uvir_min: number; + sv260_min: number; +} diff --git a/frontend/src/components/charts/YearlyVisibility.tsx b/frontend/src/components/charts/YearlyVisibility.tsx index f8b2add..a44a9fa 100644 --- a/frontend/src/components/charts/YearlyVisibility.tsx +++ b/frontend/src/components/charts/YearlyVisibility.tsx @@ -1,14 +1,4 @@ -import { - ComposedChart, - Bar, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from 'recharts'; +import { useState } from 'react'; interface YearPoint { date: string; @@ -24,107 +14,145 @@ interface Props { const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; -function altColor(alt: number): string { - if (alt >= 50) return 'var(--good)'; +function altColorHex(alt: number): string { + if (alt >= 50) return '#3dba72'; if (alt >= 30) return '#2ab8a0'; - if (alt >= 15) return 'var(--warn)'; - return 'var(--muted)'; + if (alt >= 15) return '#e8c030'; + if (alt > 0) return '#3a4258'; + return '#1a1f2e'; +} + +/** Calendar heatmap: 12 rows × 31 cols, one cell per day. */ +function CalendarHeatmap({ points }: { points: YearPoint[] }) { + const [tooltip, setTooltip] = useState<{ x: number; y: number; point: YearPoint } | null>(null); + + // Map date string → point + const byDate = new Map(points.map(p => [p.date, p])); + + const today = new Date().toISOString().slice(0, 10); + + // Build a 12-row × 31-col grid from the first point's date + const startDate = points[0]?.date ? new Date(points[0].date + 'T00:00:00Z') : new Date(); + const startMonth = startDate.getUTCMonth(); + const startYear = startDate.getUTCFullYear(); + + const CELL = 13; + const GAP = 2; + const LABEL_W = 28; + const rows: { month: number; year: number; days: (YearPoint | null)[] }[] = []; + + for (let m = 0; m < 12; m++) { + const month = (startMonth + m) % 12; + const year = startYear + Math.floor((startMonth + m) / 12); + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + const days: (YearPoint | null)[] = []; + for (let d = 1; d <= 31; d++) { + if (d > daysInMonth) { + days.push(null); + } else { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + days.push(byDate.get(dateStr) ?? null); + } + } + rows.push({ month, year, days }); + } + + const svgW = LABEL_W + 31 * (CELL + GAP); + const svgH = 12 * (CELL + GAP); + + return ( +
+ + {rows.map((row, ri) => ( + + + {MONTH_ABBR[row.month]} + + {row.days.map((pt, di) => { + const x = LABEL_W + di * (CELL + GAP); + const isToday = pt?.date === today; + if (!pt) { + return ( + + ); + } + const color = altColorHex(pt.alt_at_midnight); + const moonAlpha = Math.round(pt.moon_illumination * 60); + return ( + + + {/* Moon overlay — blue tint proportional to illumination */} + + {isToday && ( + + )} + setTooltip({ x: x + CELL + 4, y: ri * (CELL + GAP), point: pt })} + onMouseLeave={() => setTooltip(null)} + /> + + ); + })} + + ))} + + {tooltip && ( +
+
{tooltip.point.date}
+
Alt: {tooltip.point.alt_at_midnight.toFixed(1)}°
+
Usable: {(tooltip.point.usable_min / 60).toFixed(1)}h
+
Moon: {Math.round(tooltip.point.moon_illumination * 100)}%
+
+ )} +
+ ); } export default function YearlyVisibility({ points }: Props) { if (!points.length) return null; - // Sample to ~52 weekly points for readability - const stride = Math.max(1, Math.floor(points.length / 52)); - const sampled = points.filter((_, i) => i % stride === 0); - - const data = sampled.map(p => { - const d = new Date(p.date + 'T00:00:00Z'); - return { - label: `${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCDate()}`, - month: d.getUTCMonth(), - alt: Math.round(p.alt_at_midnight * 10) / 10, - transit_alt: Math.round(p.transit_alt), - usable: Math.round(p.usable_min / 60 * 10) / 10, - moon: Math.round(p.moon_illumination * 100), - }; - }); - return (
-
- ALTITUDE AT MIDNIGHT — next 12 months (varies as transit shifts through seasons) +
+ SEASONAL VISIBILITY — next 12 months · altitude at local midnight
-
- - - - - `${v}°`} - width={32} - /> - `${v}%`} - width={28} - /> - { - if (name === 'alt') return [`${value}°`, 'Alt at midnight']; - if (name === 'moon') return [`${value}%`, 'Moon']; - return [value, name]; - }} - /> - - {data.map((entry, i) => ( - - ))} - - - - -
-
+ + + +
{[ - { color: 'var(--good)', label: '≥50° excellent' }, + { color: '#3dba72', label: '≥50° excellent' }, { color: '#2ab8a0', label: '30–50° good' }, - { color: 'var(--warn)', label: '15–30° marginal' }, - { color: 'var(--muted)', label: '<15° poor' }, - { color: '#4d9de0', label: 'Moon %' }, + { color: '#e8c030', label: '15–30° marginal' }, + { color: '#3a4258', label: '<15° poor' }, + { color: 'rgba(77,157,224,0.6)', label: 'Moon overlay' }, ].map(({ color, label }) => (
-
+
{label}
))} diff --git a/frontend/src/components/session/PlanningTimeline.tsx b/frontend/src/components/session/PlanningTimeline.tsx new file mode 100644 index 0000000..071a127 --- /dev/null +++ b/frontend/src/components/session/PlanningTimeline.tsx @@ -0,0 +1,236 @@ +import { useState, useMemo } from 'react'; +import type { Target } from '../../api/types'; + +interface PlanEntry { + target: Target; + durationMin: number; +} + +interface Props { + targets: Target[]; // tonight's visible targets + dusk: string; + dawn: string; +} + +const FILTER_LABELS: Record = { + sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR', +}; +const FILTER_COLORS: Record = { + sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)', +}; + +function fmtTime(utc: string): string { + return new Date(utc).toLocaleTimeString('fr-FR', { + hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris', + }); +} + +function fmtUtcOffset(ms: number, baseMs: number): string { + const d = new Date(baseMs + ms); + return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); +} + +export default function PlanningTimeline({ targets, dusk, dawn }: Props) { + const [plan, setPlan] = useState([]); + const [search, setSearch] = useState(''); + const [exported, setExported] = useState(false); + + const duskMs = new Date(dusk).getTime(); + const dawnMs = new Date(dawn).getTime(); + const nightMs = dawnMs - duskMs; + + const filtered = useMemo(() => + targets.filter(t => + t.best_start_utc && t.best_end_utc && + !plan.find(p => p.target.id === t.id) && + (search === '' || + (t.common_name ?? t.name).toLowerCase().includes(search.toLowerCase()) || + t.name.toLowerCase().includes(search.toLowerCase())) + ).slice(0, 20), + [targets, plan, search]); + + const addTarget = (t: Target, durationMin = 120) => { + setPlan(p => [...p, { target: t, durationMin }]); + setSearch(''); + }; + + const removeTarget = (id: string) => setPlan(p => p.filter(e => e.target.id !== id)); + const moveUp = (i: number) => setPlan(p => { const n = [...p]; [n[i-1], n[i]] = [n[i], n[i-1]]; return n; }); + const moveDown = (i: number) => setPlan(p => { const n = [...p]; [n[i], n[i+1]] = [n[i+1], n[i]]; return n; }); + const updateDuration = (i: number, min: number) => setPlan(p => p.map((e, j) => j === i ? { ...e, durationMin: min } : e)); + + // Compute start times + const schedule = useMemo(() => { + let cursor = duskMs; + return plan.map(entry => { + const start = cursor; + const end = cursor + entry.durationMin * 60_000; + cursor = end; + return { entry, startMs: start, endMs: end }; + }); + }, [plan, duskMs]); + + const totalMinutes = plan.reduce((s, e) => s + e.durationMin, 0); + const nightMinutes = Math.round(nightMs / 60_000); + const overrun = totalMinutes > nightMinutes; + + const exportText = () => { + const lines = schedule.map(({ entry, startMs, endMs }) => + `${fmtUtcOffset(startMs - duskMs, duskMs)} ${entry.target.common_name ?? entry.target.name} (${entry.target.name}) · ${entry.durationMin}min [${FILTER_LABELS[entry.target.recommended_filter ?? ''] ?? '—'}] → ${fmtUtcOffset(endMs - duskMs, duskMs)}` + ); + const text = lines.join('\n'); + void navigator.clipboard.writeText(text).then(() => { + setExported(true); + setTimeout(() => setExported(false), 2000); + }); + }; + + return ( +
+ {/* Add targets search bar */} +
+ setSearch(e.target.value)} + placeholder="Search targets to add to plan…" + style={{ + width: '100%', boxSizing: 'border-box', + background: 'var(--bg-deep)', border: '1px solid var(--border)', + borderRadius: 4, color: 'var(--text-hi)', + fontFamily: 'var(--font-mono)', fontSize: 12, padding: '7px 12px', + }} + /> + {search && filtered.length > 0 && ( +
+ {filtered.map(t => ( +
addTarget(t)} + style={{ padding: '6px 12px', cursor: 'pointer', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10 }} + > + + {t.common_name ?? t.name} + {t.common_name && {t.name}} + + {t.max_alt_deg != null && ( + {t.max_alt_deg.toFixed(0)}° + )} + {t.recommended_filter && ( + + {FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter} + + )} +
+ ))} +
+ )} +
+ + {/* Plan list + Gantt */} + {plan.length === 0 ? ( +
+ Search and add targets above to build your plan. +
+ ) : ( + <> + {/* Total / overrun warning */} +
+ + {overrun ? '⚠ ' : ''}Total: {Math.floor(totalMinutes / 60)}h {totalMinutes % 60}m / {Math.floor(nightMinutes / 60)}h {nightMinutes % 60}m night + + +
+ + {/* Timeline header */} +
+
+ {[0, 0.25, 0.5, 0.75, 1].map(frac => ( + + {new Date(duskMs + frac * nightMs).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })} + + ))} +
+
+
+
+
+ + {/* Plan rows */} + {schedule.map(({ entry, startMs, endMs }, i) => { + const { target, durationMin } = entry; + const left = Math.max(0, Math.min(100, ((startMs - duskMs) / nightMs) * 100)); + const width = Math.max(1, Math.min(100 - left, ((endMs - startMs) / nightMs) * 100)); + const filterColor = FILTER_COLORS[target.recommended_filter ?? ''] ?? 'var(--muted)'; + // Warn if block extends past dawn or past target's best window + const pastDawn = endMs > dawnMs; + const pastWindow = target.best_end_utc && endMs > new Date(target.best_end_utc).getTime(); + const warn = pastDawn || pastWindow; + + return ( +
+
+
+ {target.common_name ?? target.name} + {warn && + {pastDawn ? '⚠ past dawn' : '⚠ past window'} + } +
+
+ {/* Target's visibility window */} + {target.best_start_utc && target.best_end_utc && ( +
+ )} + {/* Planned block */} +
+ {/* Start/end times */} +
+ {fmtTime(new Date(startMs).toISOString())} +
+
+
+ {/* Duration input */} +
+ updateDuration(i, parseInt(e.target.value) || 60)} + style={{ width: 48, background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 4px', textAlign: 'right' }} + /> + m +
+ {/* Move up/down */} + + {/* Remove */} + +
+ ); + })} + + )} +
+ ); +} diff --git a/frontend/src/components/session/SessionChecklist.tsx b/frontend/src/components/session/SessionChecklist.tsx new file mode 100644 index 0000000..5959917 --- /dev/null +++ b/frontend/src/components/session/SessionChecklist.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; + +const ITEMS = [ + { id: 'polar', label: 'Polar alignment verified' }, + { id: 'focus', label: 'Focus achieved (Bahtinov / auto-focus)' }, + { id: 'guiding', label: 'Guiding RMS < 1″' }, + { id: 'dew', label: 'Dew heater powered on' }, + { id: 'battery', label: 'Battery / power supply checked' }, + { id: 'cap', label: 'Lens cap removed' }, +]; + +const LS_KEY = 'astronome_session_checklist_v1'; + +interface ChecklistState { + date: string; + checked: string[]; +} + +function loadState(duskDate: string): string[] { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) { + const state = JSON.parse(raw) as ChecklistState; + if (state.date === duskDate) return state.checked; + } + } catch { /* ignore */ } + return []; +} + +function saveState(duskDate: string, checked: string[]) { + const state: ChecklistState = { date: duskDate, checked }; + localStorage.setItem(LS_KEY, JSON.stringify(state)); +} + +interface Props { + duskDate: string; // "2026-04-17" — auto-reset key +} + +export default function SessionChecklist({ duskDate }: Props) { + const [expanded, setExpanded] = useState(false); + const [checked, setChecked] = useState(() => loadState(duskDate)); + + const toggle = (id: string) => { + setChecked(prev => { + const next = prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]; + saveState(duskDate, next); + return next; + }); + }; + + const done = checked.length; + const total = ITEMS.length; + const allDone = done === total; + + return ( +
+
setExpanded(v => !v)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '10px 14px', cursor: 'pointer', + }} + > + + Pre-session Checklist + + 0 ? 'var(--warn)' : 'var(--text-lo)', + fontWeight: 600, + }}> + {allDone ? '✓ Ready' : `${done}/${total}`} + +
+
+
+ {expanded ? '▲' : '▼'} +
+ + {expanded && ( +
+ {ITEMS.map(item => { + const isChecked = checked.includes(item.id); + return ( + + ); + })} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/targets/CompareModal.tsx b/frontend/src/components/targets/CompareModal.tsx new file mode 100644 index 0000000..af54b5a --- /dev/null +++ b/frontend/src/components/targets/CompareModal.tsx @@ -0,0 +1,264 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../api'; +import { useTonight } from '../../hooks/useTonight'; +import AltitudeCurve from '../charts/AltitudeCurve'; +import TypeBadge from './TypeBadge'; +import type { Target } from '../../api/types'; + +const GOAL_HOURS: Record> = { + galaxy: { uvir: 4.0, sv260: 6.0 }, + emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 }, + reflection_nebula: { uvir: 3.0, sv260: 5.0 }, + planetary_nebula: { sv220: 2.0, c2: 3.0 }, + snr: { sv220: 5.0, c2: 6.0 }, + open_cluster: { uvir: 1.0 }, + globular_cluster: { uvir: 1.5 }, + dark_nebula: { uvir: 3.0 }, +}; + +const FILTER_LABELS: Record = { + sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR', +}; + +const SUITABILITY_COLOR: Record = { + ideal: 'var(--good)', good: 'var(--teal)', marginal: 'var(--warn)', unsuitable: 'var(--muted)', +}; + +function fmtTime(utc?: string): string { + if (!utc) return '—'; + return new Date(utc).toLocaleTimeString('fr-FR', { + hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris', + }); +} + +function fmtMin(min?: number): string { + if (min == null) return '—'; + if (min < 60) return `${min}m`; + return `${Math.floor(min / 60)}h ${min % 60}m`; +} + +function IntegrationBar({ obj_type, filter, keeperMin }: { + obj_type: string; filter?: string; keeperMin: number; +}) { + const goals = GOAL_HOURS[obj_type]; + if (!goals) return null; + const goalMin = (filter && goals[filter] ? goals[filter] : Object.values(goals)[0]) * 60; + const pct = Math.min((keeperMin / goalMin) * 100, 100); + const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)'; + return ( +
+
+ + {FILTER_LABELS[filter ?? ''] ?? 'Primary filter'} goal + + + {fmtMin(keeperMin)} / {fmtMin(goalMin)} + +
+
+
+
+
+ ); +} + +function TargetColumn({ target }: { target: Target }) { + const { data: tonight } = useTonight(); + const { data: curveData } = useQuery({ + queryKey: ['curve', target.id], + queryFn: () => api.targets.curve(target.id), + staleTime: 5 * 60_000, + }); + const { data: logData } = useQuery({ + queryKey: ['log', target.id], + queryFn: () => api.log.forTarget(target.id), + staleTime: 5 * 60_000, + }); + const { data: filterData } = useQuery({ + queryKey: ['filters', target.id], + queryFn: () => api.targets.filters(target.id), + staleTime: 10 * 60_000, + }); + + const dusk = tonight?.astro_dusk_utc ?? ''; + const dawn = tonight?.astro_dawn_utc ?? ''; + + const topFilters = filterData?.recommendations.filter(r => r.suitability !== 'unsuitable').slice(0, 3) ?? []; + const primaryFilter = topFilters[0]?.filter_id; + + const keeperMin = logData?.filter_breakdown + .filter(fb => fb.filter_id === primaryFilter) + .reduce((s, fb) => s + fb.total_min, 0) ?? 0; + + return ( +
+ {/* Header */} +
+
+ + + {target.common_name ?? target.name} + +
+ {target.common_name && ( +
{target.name}
+ )} + {target.constellation && ( +
+ {target.constellation} +
+ )} +
+ + {/* Altitude curve */} + {dusk && dawn && curveData?.curve.length ? ( + + ) : ( +
+ No curve available +
+ )} + + {/* Key stats */} +
+ {[ + ['Max Alt', `${target.max_alt_deg?.toFixed(0) ?? '—'}°`], + ['Usable', fmtMin(target.usable_min)], + ['Transit', fmtTime(target.transit_utc)], + ['Best start', fmtTime(target.best_start_utc)], + ['Moon sep', `${target.moon_sep_deg?.toFixed(0) ?? '—'}°`], + ['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′` : '—'], + ].map(([label, val]) => ( +
+ {label} + {val} +
+ ))} +
+ + {/* Filter recommendations */} + {topFilters.length > 0 && ( +
+
+ Filters tonight +
+
+ {topFilters.map(f => ( +
+ + {f.suitability} + + + {FILTER_LABELS[f.filter_id] ?? f.filter_id} + + {f.est_integration_hours && ( + + {f.est_integration_hours}h goal + + )} +
+ ))} +
+
+ )} + + {/* Integration progress */} +
+
+ Integration (keepers) +
+ {logData?.filter_breakdown.length ? ( + <> + {logData.filter_breakdown.map(fb => ( +
+ +
+ ))} + + ) : ( +
+ Not yet imaged +
+ )} + {keeperMin > 0 && primaryFilter && ( +
+ +
+ )} +
+
+ ); +} + +interface Props { + targets: [Target, Target]; + onClose: () => void; +} + +export default function CompareModal({ targets, onClose }: Props) { + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+ + Target Comparison + + +
+ + {/* Body */} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/targets/DetailDrawer.tsx b/frontend/src/components/targets/DetailDrawer.tsx index 3b3f855..d78447a 100644 --- a/frontend/src/components/targets/DetailDrawer.tsx +++ b/frontend/src/components/targets/DetailDrawer.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { Target, Workflow } from '../../api/types'; -import { useTargetCurve, useTargetFilters, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets'; +import { useTargetCurve, useTargetFilters, useTargetSimilar, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets'; import { useTargetLog } from '../../hooks/useLog'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../../api'; @@ -18,7 +18,7 @@ interface Props { target: Target; } -const TABS = ['Tonight', 'Target', 'Filters & Workflow', 'Log & Gallery', 'Yearly']; +const TABS = ['Overview', 'Filters & Workflow', 'Log & Gallery', 'Yearly']; const WORKFLOW_SHORT: Record = { 'HA+OIII Dual Narrowband (SV220)': 'HaOIII', @@ -72,6 +72,147 @@ function fmtTime(utc?: string): string { return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' }); } +// Observer longitude from config (Villevieille, France) +const OBS_LON = 4.1167; + +/** Compute current hour angle (degrees) for a target at the given RA (degrees). */ +function currentHourAngle(ra_deg: number): number { + const now = Date.now() / 1000; // unix seconds + const jd = 2440587.5 + now / 86400.0; + const t = (jd - 2451545.0) / 36525.0; + const gmst = (280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * t * t) % 360; + const lst = ((gmst + OBS_LON) % 360 + 360) % 360; + let ha = ((lst - ra_deg) % 360 + 360) % 360; + if (ha > 180) ha -= 360; // range -180..+180 + return ha; +} + +function fmtHa(ha_deg: number): string { + const sign = ha_deg >= 0 ? '+' : '-'; + const abs = Math.abs(ha_deg); + const h = Math.floor(abs / 15); + const m = Math.floor((abs % 15) * 4); + const s = Math.round(((abs % 15) * 4 - m) * 60); + return `${sign}${h}h ${m < 10 ? '0' : ''}${m}m ${s < 10 ? '0' : ''}${s}s`; +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const copy = () => { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} + +// Sensor constants for ToupTek ATR2600C / IMX571 at f/6.9, Bortle 5 +const READ_NOISE_E = 3.5; +const DARK_E_PER_S = 0.002; +const SUB_SEC = 180; // 3-min subs + +// Sky background e-/px/s by filter (empirical for Bortle 5, AT71) +const SKY_BG: Record = { + uvir: 3.0, + sv260: 1.8, + sv220: 0.04, + c2: 0.03, +}; + +/** Source signal e-/px/s from surface brightness (mag/arcsec²), Bortle 5 calibration. */ +function signalFromSB(sb: number): number { + // Reference: SB=21 → ~0.001 e-/px/s at this aperture+scale + return 0.001 * Math.pow(10, (21 - sb) / 2.5); +} + +function subsNeeded(signal_e_s: number, sky_e_s: number, targetSnr: number): number { + const s = signal_e_s * SUB_SEC; // signal per sub + const b = sky_e_s * SUB_SEC; // sky per sub + const d = DARK_E_PER_S * SUB_SEC; // dark per sub + const r2 = READ_NOISE_E * READ_NOISE_E; + if (s <= 0) return 999; + const noise_per_sub = s + b + d + r2; + return Math.ceil((targetSnr * targetSnr * noise_per_sub) / (s * s)); +} + +function ImagingCalculator({ target, filterId }: { target: Target; filterId: string }) { + const [targetSnr, setTargetSnr] = useState(20); + const sb = target.surface_brightness; + const sky = SKY_BG[filterId] ?? 1.0; + + const signal = sb != null ? signalFromSB(sb) : null; + const n = signal != null ? subsNeeded(signal, sky, targetSnr) : null; + const totalMin = n != null ? n * (SUB_SEC / 60) : null; + const totalH = totalMin != null ? (totalMin / 60).toFixed(1) : null; + + return ( +
+
+ SNR Calculator — 3-min subs · IMX571 · f/6.9 · Bortle 5 +
+ {sb == null ? ( +
+ Surface brightness not available for this object. +
+ ) : ( +
+
+
+ Target SNR: {targetSnr} +
+ setTargetSnr(parseInt(e.target.value))} + style={{ accentColor: 'var(--amber)', width: 140 }} + /> +
+
+
+
Subs needed
+
+ {n != null ? (n > 500 ? '500+' : n) : '—'} +
+
+
+
Total time
+
+ {totalH != null ? `${totalH}h` : '—'} +
+
+
+
Sessions ~2h
+
+ {totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'} +
+
+
+
SB source
+
+ {sb.toFixed(1)} mag/″² +
+
+
+
+ )} +
+ ); +} + export default function DetailDrawer({ target }: Props) { const [tab, setTab] = useState(0); const [selectedFilter, setSelectedFilter] = useState('sv220'); @@ -85,16 +226,17 @@ export default function DetailDrawer({ target }: Props) { const { data: workflowData } = useTargetWorkflow(target.id, selectedFilter); const { data: logData } = useTargetLog(target.id); const { data: horizonData } = useHorizon(); - const { data: yearlyData } = useTargetYearly(target.id, tab === 4); + const { data: yearlyData } = useTargetYearly(target.id, tab === 3); + const { data: similarData } = useTargetSimilar(target.id); const { data: galleryData } = useQuery({ queryKey: ['gallery', target.id], queryFn: () => api.gallery.list(target.id), - enabled: tab === 3, + enabled: tab === 2, }); const { data: notesData } = useQuery({ queryKey: ['target-notes', target.id], queryFn: () => api.targets.getNotes(target.id), - enabled: tab === 3, + enabled: tab === 2, }); const saveNotesMutation = useMutation({ mutationFn: (text: string) => api.targets.putNotes(target.id, text), @@ -128,125 +270,210 @@ export default function DetailDrawer({ target }: Props) {
- {/* Tab 1: Tonight */} + {/* Tab 1: Overview — altitude curve + metadata side by side */} {tab === 0 && (
- {curveData?.curve && curveData.curve.length > 0 ? ( - - ) : ( -
- Curve data loading… + {/* Top section: metadata left + altitude curve right */} +
+ {/* Left: DSS image + metadata */} +
+ {`DSS +
+ DSS Digitized Sky Survey +
+ + + {[ + ['Type', target.obj_type], + ['Constellation', target.constellation ?? '—'], + ['RA', target.ra_h], + ['Dec', target.dec_dms], + ['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′ × ${(target.size_arcmin_min ?? target.size_arcmin_maj).toFixed(1)}′` : '—'], + ['Magnitude', target.mag_v?.toFixed(1) ?? '—'], + ['SB', target.surface_brightness ? `${target.surface_brightness.toFixed(1)} mag/″²` : '—'], + ['FOV fill', target.fov_fill_pct != null ? `${target.fov_fill_pct.toFixed(0)}%` : '—'], + ['Guide stars', target.guide_star_density ?? '—'], + ].map(([label, value]) => ( + + + + + ))} + +
{label}{value as string}
- )} - - - {[ - ['Rise', fmtTime(visData?.rise_utc)], - ['Transit', fmtTime(visData?.transit_utc)], - ['Set', fmtTime(visData?.set_utc)], - ['Best window', visData?.best_start_utc && visData?.best_end_utc - ? `${fmtTime(visData.best_start_utc)} – ${fmtTime(visData.best_end_utc)}` - : '—'], - ['Usable time', visData?.usable_min ? `${visData.usable_min} min` : '—'], - ['Meridian flip', fmtTime(visData?.meridian_flip_utc)], - ['Moon sep', visData?.moon_sep_deg != null ? `${visData.moon_sep_deg.toFixed(1)}°` : '—'], - ['Airmass @transit', visData?.airmass_at_transit?.toFixed(2) ?? '—'], - ['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'], - ].map(([label, value]) => ( - - - - - ))} - -
{label}{value}
-
- )} - {/* Tab 2: Target */} - {tab === 1 && ( -
-
- {`DSS -
- DSS Digitized Sky Survey + {/* Right: altitude curve + key times */} +
+ {curveData?.curve && curveData.curve.length > 0 ? ( + + ) : ( +
+ Curve data loading… +
+ )} + + + {[ + ['Rise', fmtTime(visData?.rise_utc)], + ['Transit', fmtTime(visData?.transit_utc)], + ['Set', fmtTime(visData?.set_utc)], + ['Best window', visData?.best_start_utc && visData?.best_end_utc + ? `${fmtTime(visData.best_start_utc)} – ${fmtTime(visData.best_end_utc)}` + : '—'], + ['Usable time', visData?.usable_min ? `${visData.usable_min} min` : '—'], + ['Meridian flip', fmtTime(visData?.meridian_flip_utc)], + ['Moon sep', visData?.moon_sep_deg != null ? `${visData.moon_sep_deg.toFixed(1)}°` : '—'], + ['Airmass @transit', visData?.airmass_at_transit?.toFixed(2) ?? '—'], + ['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'], + ].map(([label, value]) => ( + + + + + ))} + +
{label}{value}
-
- - - {[ - ['Type', target.obj_type], - ['Constellation', target.constellation ?? '—'], - ['RA', target.ra_h], - ['Dec', target.dec_dms], - ['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′ × ${(target.size_arcmin_min ?? target.size_arcmin_maj).toFixed(1)}′` : '—'], - ['Magnitude', target.mag_v?.toFixed(1) ?? '—'], - ['Surface brightness', target.surface_brightness ? `${target.surface_brightness.toFixed(1)} mag/arcsec²` : '—'], - ['Hubble type', target.hubble_type ?? '—'], - ['FOV fill', target.fov_fill_pct != null ? `${target.fov_fill_pct.toFixed(0)}%` : '—'], - ['Guide stars', target.guide_star_density ?? '—'], - ].map(([label, value]) => ( - - - - - ))} - -
{label}{value as string}
- {/* Guiding context badge */} - {target.guide_star_density && (() => { - const density = target.guide_star_density; - const msgs: Record = { - sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle — consider a bright guide star or off-axis offset' }, - moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG should work with careful star selection' }, - rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars for OAG or guidescope' }, - }; - const m = msgs[density]; - if (!m) return null; + + {/* Below the fold: GoTo card + guiding badge + links + Aladin */} +
+ {/* GoTo mount coordinates */} + {(() => { + const ha = currentHourAngle(target.ra_deg); + const side = ha < 0 ? 'East (pre-meridian)' : 'West (post-meridian)'; + const sideColor = ha < 0 ? 'var(--teal)' : 'var(--amber)'; return (
- - ◉ {m.text} - - {m.note} +
+ GoTo Coordinates (J2000) +
+
+
+ RA + {target.ra_h} + +
+
+ Dec + {target.dec_dms} + +
+
+ HA + {fmtHa(ha)} + + {side} + +
+
); })()} - + + {/* Guiding context + external links */} +
+ {target.guide_star_density && (() => { + const density = target.guide_star_density; + const msgs: Record = { + sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle' }, + moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG with careful star selection' }, + rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars' }, + }; + const m = msgs[density]; + if (!m) return null; + return ( +
+ ◉ {m.text} + {m.note} +
+ ); + })()} + +
+ + {/* Similar targets nearby */} + {(similarData?.similar?.length ?? 0) > 0 && ( +
+
+ Similar Targets Nearby (same type · same constellation) +
+
+ {similarData!.similar.slice(0, 3).map(s => ( +
+ {s.messier_num != null && ( + + M{s.messier_num} + + )} + + {s.common_name ?? s.name} + {s.common_name && {s.name}} + + {s.size_arcmin_maj != null && ( + {s.size_arcmin_maj.toFixed(1)}′ + )} + {s.max_alt_deg != null && ( + = 30 ? 'var(--good)' : 'var(--warn)' }}>{s.max_alt_deg.toFixed(0)}° + )} + {s.transit_utc && ( + + {new Date(s.transit_utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })} + + )} +
+ ))} +
+
+ )} + +
)} - {/* Tab 3: Filters & Workflow */} - {tab === 2 && ( + {/* Tab 2: Filters & Workflow */} + {tab === 1 && (
@@ -296,11 +523,15 @@ export default function DetailDrawer({ target }: Props) {
{workflowData && } + +
+ +
)} - {/* Tab 5: Yearly */} - {tab === 4 && ( + {/* Tab 4: Yearly */} + {tab === 3 && (
{yearlyData?.points ? ( @@ -312,8 +543,8 @@ export default function DetailDrawer({ target }: Props) {
)} - {/* Tab 4: Log & Gallery */} - {tab === 3 && ( + {/* Tab 3: Log & Gallery */} + {tab === 2 && (
{/* Filter breakdown + planning notes row */} {((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && ( diff --git a/frontend/src/components/targets/TargetRow.tsx b/frontend/src/components/targets/TargetRow.tsx index 2d772b2..9dc59e4 100644 --- a/frontend/src/components/targets/TargetRow.tsx +++ b/frontend/src/components/targets/TargetRow.tsx @@ -10,6 +10,8 @@ interface Props { target: Target; expanded: boolean; onToggle: () => void; + inCompare?: boolean; + onCompare?: (t: Target) => void; } // Display labels for filter IDs @@ -84,7 +86,7 @@ function difficultyDots(d?: number) { ); } -export default function TargetRow({ target, expanded, onToggle }: Props) { +export default function TargetRow({ target, expanded, onToggle, inCompare, onCompare }: Props) { const { data: tonight } = useTonight(); const { data: horizonData } = useHorizon(); const alt = fmtAlt(target.max_alt_deg); @@ -131,15 +133,42 @@ export default function TargetRow({ target, expanded, onToggle }: Props) { M{target.messier_num} )} + {target.caldwell_num != null && target.messier_num == null && ( + + C{target.caldwell_num} + + )} {target.name} + {target.is_custom && ( + + CUSTOM + + )}
{target.common_name && (
{target.common_name}
)} + {target.urgency && ( +
+ {target.urgency === 'peak' && ( + ▲ peak + )} + {target.urgency === 'rising' && ( + ↗ rising + )} + {target.urgency === 'declining' && ( + ↘ declining + )} +
+ )} {target.size_arcmin_maj @@ -212,6 +241,23 @@ export default function TargetRow({ target, expanded, onToggle }: Props) { total_min={target.total_integration_min} /> + {onCompare && ( + + + + )} ); } diff --git a/frontend/src/components/targets/TypeBadge.tsx b/frontend/src/components/targets/TypeBadge.tsx index d730043..eac18cb 100644 --- a/frontend/src/components/targets/TypeBadge.tsx +++ b/frontend/src/components/targets/TypeBadge.tsx @@ -9,7 +9,11 @@ const LABELS: Record = { dark_nebula: 'DN', nebula: 'NB', galaxy_group: 'GG', + galaxy_cluster: 'ACO', interacting_galaxy: 'IG', + custom: 'USR', + satellite: 'SAT', + comet: 'CMT', }; interface Props { diff --git a/frontend/src/components/weather/DewAlert.tsx b/frontend/src/components/weather/DewAlert.tsx index 81e675b..dd1a8f3 100644 --- a/frontend/src/components/weather/DewAlert.tsx +++ b/frontend/src/components/weather/DewAlert.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react'; + interface Props { level?: 'warning' | 'critical'; temp?: number; @@ -5,6 +7,31 @@ interface Props { } export default function DewAlert({ level, temp, dewPoint }: Props) { + const notifiedRef = useRef(null); + + // Trigger browser notification when dew alert level appears or escalates + useEffect(() => { + if (!level) return; + const key = level; + if (notifiedRef.current === key) return; + notifiedRef.current = key; + + if (!('Notification' in window)) return; + const send = () => { + const margin = temp != null && dewPoint != null ? `Margin: ${(temp - dewPoint).toFixed(1)}°C. ` : ''; + const body = level === 'critical' + ? `${margin}Condensation imminent — protect optics immediately.` + : `${margin}Enable dew heaters now.`; + new Notification('Astronome — Dew Alert', { body, tag: 'dew-alert' }); + }; + + if (Notification.permission === 'granted') { + send(); + } else if (Notification.permission !== 'denied') { + void Notification.requestPermission().then(p => { if (p === 'granted') send(); }); + } + }, [level, temp, dewPoint]); + if (!level) return null; const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null; diff --git a/frontend/src/hooks/useCalendar.ts b/frontend/src/hooks/useCalendar.ts index d333e4f..06bf209 100644 --- a/frontend/src/hooks/useCalendar.ts +++ b/frontend/src/hooks/useCalendar.ts @@ -1,6 +1,22 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../api'; +export function useBestNights() { + return useQuery({ + queryKey: ['best-nights'], + queryFn: () => api.calendar.getBestNights(), + staleTime: 60 * 60_000, + }); +} + +export function useMonthlyHighlights() { + return useQuery({ + queryKey: ['monthly-highlights'], + queryFn: () => api.calendar.getMonthlyHighlights(), + staleTime: 60 * 60_000, + }); +} + export function useCalendar(months?: number) { return useQuery({ queryKey: ['calendar', months], diff --git a/frontend/src/hooks/useTargets.ts b/frontend/src/hooks/useTargets.ts index 6fd2ece..2fb47b3 100644 --- a/frontend/src/hooks/useTargets.ts +++ b/frontend/src/hooks/useTargets.ts @@ -62,3 +62,12 @@ export function useTargetYearly(id: string, enabled = false) { staleTime: 60 * 60_000, }); } + +export function useTargetSimilar(id: string) { + return useQuery({ + queryKey: ['target-similar', id], + queryFn: () => api.targets.similar(id), + enabled: !!id, + staleTime: 5 * 60_000, + }); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 923aa00..a13c2e3 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,17 +1,73 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useTonight } from '../hooks/useTonight'; import { useWeather, useForecast } from '../hooks/useWeather'; import { useTargets } from '../hooks/useTargets'; import { useStats } from '../hooks/useStats'; +import { useBestNights, useMonthlyHighlights } from '../hooks/useCalendar'; import GoNogo from '../components/weather/GoNogo'; import DewAlert from '../components/weather/DewAlert'; import MoonPhaseIcon from '../components/sky/MoonPhaseIcon'; import DetailDrawer from '../components/targets/DetailDrawer'; -import type { Target } from '../api/types'; +import SessionChecklist from '../components/session/SessionChecklist'; +import PlanningTimeline from '../components/session/PlanningTimeline'; +import type { IntegrationGap, Target } from '../api/types'; const FILTER_LABELS: Record = { - sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR', + sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR', }; + +function fmtMin(min: number): string { + if (min < 60) return `${min}m`; + return `${(min / 60).toFixed(1)}h`; +} + +function GapCard({ gap }: { gap: IntegrationGap }) { + const have: { label: string; min: number }[] = []; + const missing: string[] = gap.missing_filters; + + if (gap.sv220_min > 0) have.push({ label: 'Ha+OIII', min: gap.sv220_min }); + if (gap.c2_min > 0) have.push({ label: 'SII+OIII', min: gap.c2_min }); + if (gap.uvir_min > 0) have.push({ label: 'UV/IR', min: gap.uvir_min }); + if (gap.sv260_min > 0) have.push({ label: 'LP', min: gap.sv260_min }); + + return ( +
+
+ + {gap.common_name ?? gap.name} + + {gap.common_name && ( + + {gap.name} + + )} +
+
+ {have.map(h => ( + + {h.label} {fmtMin(h.min)} + + ))} + {missing.map(f => ( + + {FILTER_LABELS[f] ?? f} 0h + + ))} +
+
+ ); +} const CC_LABELS: Record = { 1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy', 5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast', @@ -34,18 +90,332 @@ function fmtIntTotal(min: number): string { return `${h} h`; } +const FILTER_COLORS: Record = { + sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)', +}; + +const TYPE_ABBR: Record = { + galaxy: 'GX', emission_nebula: 'EN', reflection_nebula: 'RN', + planetary_nebula: 'PN', snr: 'SNR', open_cluster: 'OC', + globular_cluster: 'GC', dark_nebula: 'DN', +}; + +function moonBar(illum: number): string { + if (illum < 0.2) return 'var(--good)'; + if (illum < 0.5) return 'var(--warn)'; + return 'var(--danger)'; +} + +function BestNightsCard() { + const { data } = useBestNights(); + const nights = data?.nights ?? []; + if (!nights.length) return null; + // Show top 14 nights in date order + const sorted = [...nights].sort((a, b) => a.date.localeCompare(b.date)); + return ( +
+
+ + 14-Night Forecast + + + Best score: {Math.max(...nights.map(n => n.score))} + +
+ {sorted.map(night => { + const maxScore = Math.max(...nights.map(n => n.score)); + const barWidth = maxScore > 0 ? (night.score / maxScore) * 100 : 0; + const barColor = night.score >= 70 ? 'var(--good)' : night.score >= 40 ? 'var(--warn)' : 'var(--danger)'; + const dateObj = new Date(night.date + 'T12:00:00'); + const dayLabel = dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }); + return ( +
+ {dayLabel} + {night.score} +
+
+
+
+ + ☽ {Math.round(night.moon_illumination * 100)}% + +
+
+ ); + })} +
+ ); +} + +function MonthlyHighlightsCard() { + const { data } = useMonthlyHighlights(); + const highlights = data?.highlights ?? []; + const month = data?.month; + if (!highlights.length) return null; + const monthLabel = month ? new Date(month + '-01').toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) : ''; + return ( +
+
+ + Best of {monthLabel} + +
+ {highlights.slice(0, 5).map(h => ( +
+ + {TYPE_ABBR[h.obj_type] ?? h.obj_type.slice(0, 3).toUpperCase()} + +
+
0 ? 'var(--text-mid)' : 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {h.common_name ?? h.name} +
+
+ {h.name}{h.constellation ? ` · ${h.constellation}` : ''} + {h.keeper_count > 0 && ✓ imaged} +
+
+
+
+ {h.peak_alt?.toFixed(0)}° +
+ {h.recommended_filter && ( + + {FILTER_LABELS[h.recommended_filter] ?? h.recommended_filter.toUpperCase()} + + )} +
+
+ ))} +
+ ); +} + +const GOAL_HOURS: Record> = { + galaxy: { uvir: 4.0, sv260: 6.0 }, + emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 }, + reflection_nebula: { uvir: 3.0, sv260: 5.0 }, + planetary_nebula: { sv220: 2.0, c2: 3.0 }, + snr: { sv220: 5.0, c2: 6.0 }, + open_cluster: { uvir: 1.0 }, + globular_cluster: { uvir: 1.5 }, + dark_nebula: { uvir: 3.0 }, +}; + +const FILTER_COLORS_DB: Record = { + sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)', +}; + +function IntegrationGoalsCard({ goals }: { goals: import('../api/types').IntegrationGoal[] }) { + if (!goals?.length) return ( +
+ No keeper integration recorded yet. +
+ ); + + return ( +
+ {goals.map(g => { + const byType = GOAL_HOURS[g.obj_type] ?? {}; + const filterKeys = Object.keys(byType); + const totalKeeperMin = g.sv220_min + g.c2_min + g.uvir_min + g.sv260_min; + const filterMinMap: Record = { sv220: g.sv220_min, c2: g.c2_min, uvir: g.uvir_min, sv260: g.sv260_min }; + + return ( +
+
+ + {g.common_name ?? g.name} + {g.common_name && {g.name}} + + + {Math.floor(totalKeeperMin / 60)}h {totalKeeperMin % 60}m total + +
+
+ {filterKeys.filter(fk => filterMinMap[fk] > 0 || (byType[fk] ?? 0) > 0).map(fk => { + const goalMin = (byType[fk] ?? 0) * 60; + const doneMin = filterMinMap[fk] ?? 0; + const pct = goalMin > 0 ? Math.min((doneMin / goalMin) * 100, 100) : 0; + const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)'; + const filterColor = FILTER_COLORS_DB[fk] ?? 'var(--muted)'; + return ( +
+ + {FILTER_LABELS[fk] ?? fk} + +
+
0 ? color : 'transparent', borderRadius: 3, transition: 'width 0.3s' }} /> +
+ 0 ? color : 'var(--text-lo)', width: 60, textAlign: 'right' }}> + {doneMin > 0 ? `${Math.floor(doneMin / 60)}h${doneMin % 60}m` : '—'} + {goalMin > 0 && ` / ${byType[fk]}h`} + +
+ ); + })} +
+
+ ); + })} +
+ ); +} + +/** Angular distance between two RA/Dec positions (degrees). */ +function angularDist(ra1: number, dec1: number, ra2: number, dec2: number): number { + const toRad = (d: number) => d * Math.PI / 180; + const dRa = toRad(ra2 - ra1); + const dDec = toRad(dec2 - dec1); + const a = Math.sin(dDec / 2) ** 2 + Math.cos(toRad(dec1)) * Math.cos(toRad(dec2)) * Math.sin(dRa / 2) ** 2; + return 2 * Math.asin(Math.sqrt(a)) * 180 / Math.PI; +} + +/** Nearest-neighbor RA/Dec sort starting from the first target (sorted by best_start). */ +function slewOptimize(targets: import('../api/types').Target[]): import('../api/types').Target[] { + if (targets.length <= 2) return targets; + const remaining = [...targets]; + const result: import('../api/types').Target[] = [remaining.shift()!]; + while (remaining.length) { + const last = result[result.length - 1]; + let nearestIdx = 0; + let nearestDist = Infinity; + remaining.forEach((t, i) => { + const d = angularDist(last.ra_deg, last.dec_deg, t.ra_deg, t.dec_deg); + if (d < nearestDist) { nearestDist = d; nearestIdx = i; } + }); + result.push(remaining.splice(nearestIdx, 1)[0]); + } + return result; +} + +function RunOrderCard({ items, dusk, dawn }: { + items: import('../api/types').Target[]; + dusk: string; + dawn: string; +}) { + const [slewMode, setSlewMode] = useState(false); + const duskMs = new Date(dusk).getTime(); + const dawnMs = new Date(dawn).getTime(); + const nightMs = dawnMs - duskMs; + + const withWindow = useMemo(() => + items.filter(t => t.best_start_utc && t.best_end_utc), + [items]); + + const displayItems = useMemo(() => { + if (!slewMode || withWindow.length <= 2) return withWindow; + return slewOptimize(withWindow); + }, [withWindow, slewMode]); + + if (!withWindow.length) return ( +
+ No targets with defined imaging windows tonight. +
+ ); + + return ( +
+ {/* Slew optimizer toggle */} +
+ + {slewMode && ( + + Nearest-neighbor sort minimizes mount slew distance + + )} +
+ {/* Timeline header ticks */} +
+
+
+ {[0, 0.25, 0.5, 0.75, 1].map(frac => { + const t = new Date(duskMs + frac * nightMs); + return ( + + {t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })} + + ); + })} +
+
+
+ {displayItems.map(t => { + const startMs = new Date(t.best_start_utc!).getTime(); + const endMs = new Date(t.best_end_utc!).getTime(); + const left = Math.max(0, (startMs - duskMs) / nightMs) * 100; + const width = Math.min(100 - left, Math.max(2, (endMs - startMs) / nightMs * 100)); + const filterColor = t.recommended_filter ? (FILTER_COLORS[t.recommended_filter] ?? 'var(--muted)') : 'var(--muted)'; + return ( +
+
+ + {t.common_name ?? t.name} + + {t.common_name && ( + + {t.name} + + )} +
+
+
+
+
+ {new Date(t.best_start_utc!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })} + {' → '} + {new Date(t.best_end_utc!).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })} +
+
+ ); + })} +
+ ); +} + export default function Dashboard() { const { data: tonight } = useTonight(); const { data: weather } = useWeather(); const { data: forecast } = useForecast(); const { data: targets } = useTargets({ tonight: true, limit: 5 }); + const { data: runOrder } = useTargets({ tonight: true, sort: 'best_start', limit: 20 }); const { data: stats } = useStats(); + // best nights + monthly highlights loaded inside their own components const [expandedTarget, setExpandedTarget] = useState(null); + const [planningOpen, setPlanningOpen] = useState(false); const moonPct = tonight?.moon_illumination != null ? `${Math.round(tonight.moon_illumination * 100)}%` : '—'; + // Moon separation warning: flag if any top-5 tonight target has moon_sep < 20° + const closeMoonTarget = targets?.items?.find(t => (t.moon_sep_deg ?? 180) < 20); + // Next 4 forecast slots for mini weather bar (3h each = 12h ahead) const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? []; @@ -62,6 +432,31 @@ export default function Dashboard() { )}
+ {/* Moon separation warning */} + {closeMoonTarget && ( +
+ + + {closeMoonTarget.common_name ?? closeMoonTarget.name} + {' '}is only{' '} + {closeMoonTarget.moon_sep_deg?.toFixed(1)}° + {' '}from the Moon — consider a narrowband filter or delaying this target. + +
+ )} + {/* Dew alert banner */} {weather?.dew_alert && (
@@ -119,6 +514,20 @@ export default function Dashboard() {
+ {/* Integration gap detector */} + {(stats?.integration_gaps?.length ?? 0) > 0 && ( +
+
+ Filter Gaps — targets missing a companion filter +
+
+ {stats!.integration_gaps.map(gap => ( + + ))} +
+
+ )} + {/* Tonight timing + top targets + forecast */}
@@ -230,6 +639,89 @@ export default function Dashboard() {
+ + {/* Pre-session checklist */} + {tonight?.date && ( +
+ +
+ )} + + {/* Monthly highlights + best nights */} +
+
+
+ Monthly Highlights +
+ +
+
+
+ Best Nights (14-day) +
+ +
+
+ + {/* Integration Goals Progress */} + {stats?.integration_goals?.length ? ( +
+
+ Integration Goals — keeper progress per target & filter +
+
+ +
+
+ ) : null} + + {/* Tonight run order */} + {tonight?.astro_dusk_utc && ( +
+
+ Tonight's Run Order — imaging windows sorted by start time +
+
+ +
+
+ )} + + {/* Session Planning Timeline */} + {tonight?.astro_dusk_utc && ( +
+
+
+ Plan Tonight +
+ +
+ {planningOpen && ( +
+ +
+ )} +
+ )}
); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0ed1e69..3159305 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,8 +1,189 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '../api'; import type { HorizonPoint } from '../api/types'; +// Current hardcoded setup constants (from config.rs) +const CURRENT_SETUP = { + name: 'AT71 + ATR2600C (current)', + focal_mm: 490, + aperture_mm: 71, + pixel_um: 3.76, + res_x: 6248, + res_y: 4176, + active: true, +}; + +interface EquipProfile { + id: string; + name: string; + focal_mm: number; + aperture_mm: number; + pixel_um: number; + res_x: number; + res_y: number; +} + +function calcProfile(p: { focal_mm: number; aperture_mm: number; pixel_um: number; res_x: number; res_y: number }) { + const plate_scale = (206.265 * p.pixel_um) / (p.focal_mm * 1000 / 1000); + // plate_scale in arcsec/px: (206265 * pixel_size_um / 1000) / focal_mm + const ps = (206.265 * p.pixel_um / 1000) / p.focal_mm * 1000; + const fov_w_deg = (ps * p.res_x) / 3600; + const fov_h_deg = (ps * p.res_y) / 3600; + const focal_ratio = p.focal_mm / p.aperture_mm; + return { + plate_scale_arcsec: ps.toFixed(3), + fov_w: `${(fov_w_deg * 60).toFixed(1)}′ × ${(fov_h_deg * 60).toFixed(1)}′`, + focal_ratio: `f/${focal_ratio.toFixed(1)}`, + }; +} + +function EquipmentProfiles() { + const [profiles, setProfiles] = useState(() => { + try { + return JSON.parse(localStorage.getItem('astronome_equip_profiles') ?? '[]'); + } catch { return []; } + }); + const [editing, setEditing] = useState(null); + const [adding, setAdding] = useState(false); + const [form, setForm] = useState({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 }); + + useEffect(() => { + localStorage.setItem('astronome_equip_profiles', JSON.stringify(profiles)); + }, [profiles]); + + const saveProfile = () => { + if (!form.name.trim()) return; + if (editing) { + setProfiles(ps => ps.map(p => p.id === editing.id ? { ...form, id: editing.id } : p)); + } else { + setProfiles(ps => [...ps, { ...form, id: Date.now().toString() }]); + } + setEditing(null); + setAdding(false); + setForm({ name: '', focal_mm: 490, aperture_mm: 71, pixel_um: 3.76, res_x: 6248, res_y: 4176 }); + }; + + const deleteProfile = (id: string) => { + setProfiles(ps => ps.filter(p => p.id !== id)); + }; + + const startEdit = (p: EquipProfile) => { + setEditing(p); + setForm({ name: p.name, focal_mm: p.focal_mm, aperture_mm: p.aperture_mm, pixel_um: p.pixel_um, res_x: p.res_x, res_y: p.res_y }); + setAdding(false); + }; + + const current = calcProfile(CURRENT_SETUP); + const allProfiles = [{ ...CURRENT_SETUP, id: '__current__' }, ...profiles]; + + const fieldStyle: React.CSSProperties = { + background: 'var(--bg-void)', border: '1px solid var(--border)', borderRadius: 3, + color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12, + padding: '4px 8px', width: '100%', boxSizing: 'border-box', + }; + + return ( +
+

+ Equipment Profiles +

+ +
+ {allProfiles.map(p => { + const calc = calcProfile(p); + const isCurrent = p.id === '__current__'; + return ( +
+
+
+
+ {p.name} + {isCurrent && ACTIVE} +
+
+ {p.focal_mm}mm {calc.focal_ratio} · {p.aperture_mm}mm aperture · {p.pixel_um}μm px · {p.res_x}×{p.res_y} +
+
+ Plate scale: {calc.plate_scale_arcsec}″/px + {' · '}FOV: {calc.fov_w} +
+
+ {!isCurrent && ( +
+ + +
+ )} +
+
+ ); + })} + + {(adding || editing) && ( +
+
+ {editing ? 'Edit Profile' : 'New Profile'} +
+
+ {[ + { key: 'name', label: 'Profile name', type: 'text', fullWidth: true }, + { key: 'focal_mm', label: 'Focal length (mm)', type: 'number' }, + { key: 'aperture_mm', label: 'Aperture (mm)', type: 'number' }, + { key: 'pixel_um', label: 'Pixel size (μm)', type: 'number' }, + { key: 'res_x', label: 'Sensor width (px)', type: 'number' }, + { key: 'res_y', label: 'Sensor height (px)', type: 'number' }, + ].map(field => ( +
+ + )[field.key]} + onChange={e => setForm(f => ({ ...f, [field.key]: field.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))} + style={fieldStyle} + /> +
+ ))} +
+ {form.focal_mm > 0 && form.pixel_um > 0 && ( +
+ Preview: {calcProfile(form).plate_scale_arcsec}″/px · {calcProfile(form).fov_w} FOV +
+ )} +
+ + +
+
+ )} + + {!adding && !editing && ( + + )} +
+
+ ); +} + function HorizonPolarChart({ points }: { points: HorizonPoint[] }) { const size = 280; const cx = size / 2; @@ -165,6 +346,9 @@ export default function Settings() {

Settings

+ {/* Equipment Profiles */} + + {/* Custom Horizon */}

diff --git a/frontend/src/pages/Stats.tsx b/frontend/src/pages/Stats.tsx index a31abc5..5738dc3 100644 --- a/frontend/src/pages/Stats.tsx +++ b/frontend/src/pages/Stats.tsx @@ -5,8 +5,120 @@ import { import { useStats } from '../hooks/useStats'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '../api'; -import type { Phd2Log } from '../api/types'; -import { useRef, useState } from 'react'; +import type { HistoryEntry, Phd2Log } from '../api/types'; +import { useRef, useState, useMemo } from 'react'; + +const FILTER_PILL_COLORS: Record = { + sv220: '#9b59b6', c2: '#4d9de0', sv260: '#e8832a', uvir: '#3dba72', +}; +const FILTER_LABELS_HIST: Record = { + sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR', +}; +const QUALITY_COLORS: Record = { + keeper: 'var(--good)', needs_more: 'var(--blue)', rejected: 'var(--danger)', pending: 'var(--muted)', +}; + +function SessionTimeline({ entries }: { entries: HistoryEntry[] }) { + const grouped = useMemo(() => { + const map = new Map(); + for (const e of entries) { + const existing = map.get(e.date) ?? []; + existing.push(e); + map.set(e.date, existing); + } + return Array.from(map.entries()).sort((a, b) => b[0].localeCompare(a[0])); + }, [entries]); + + if (!grouped.length) return ( +
+ No sessions logged yet. +
+ ); + + return ( +
+ {grouped.map(([date, items]) => { + const totalMin = items.reduce((s, i) => s + i.integration_min, 0); + const hasKeeper = items.some(i => i.quality === 'keeper'); + const thumbEntry = items.find(i => i.gallery_url); + return ( +
+ {/* Left: date stem */} +
+
+ {date.slice(5)} {/* MM-DD */} +
+
+ {date.slice(0, 4)} +
+
+ {totalMin >= 60 ? `${(totalMin/60).toFixed(1)}h` : `${totalMin}m`} +
+ {/* vertical line */} +
+
+ + {/* Right: session entries */} +
+ {items.map((item, idx) => ( +
+ {/* Gallery thumbnail */} + {item.gallery_url ? ( + {item.name} + ) : ( +
+ +
+ )} + +
+
+ {item.common_name ?? item.name} + {item.common_name && ( + {item.name} + )} +
+ {item.notes && ( +
+ {item.notes} +
+ )} +
+ + + {FILTER_LABELS_HIST[item.filter_id] ?? item.filter_id} + + + + {item.integration_min >= 60 + ? `${(item.integration_min / 60).toFixed(1)}h` + : `${item.integration_min}m`} + + + + {item.quality.replace('_', ' ')} + +
+ ))} +
+
+ ); + })} +
+ ); +} const FILTER_COLORS: Record = { sv220: '#9b59b6', @@ -366,6 +478,58 @@ export default function Stats() {
)} + {/* Catalogue completion tracker */} + {(stats.catalogue_completion?.length ?? 0) > 0 && ( +
+
+ Catalogue Completion +
+
+ {stats.catalogue_completion.map(cat => { + const done = cat.pct >= 100; + return ( +
+
+ + {cat.name} {done && '🏆'} + + + {cat.keepers} / {cat.total} + +
+
+
+
+
+ {cat.pct}% +
+
+ ); + })} +
+
+ )} + + {/* Observation history timeline */} + {(stats.history?.length ?? 0) > 0 && ( +
+
+ Observation History +
+
+ +
+
+ )} +
); diff --git a/frontend/src/pages/Targets.tsx b/frontend/src/pages/Targets.tsx index 9360b82..425fa22 100644 --- a/frontend/src/pages/Targets.tsx +++ b/frontend/src/pages/Targets.tsx @@ -1,13 +1,15 @@ -import { useState, Fragment } from 'react'; +import { useState, Fragment, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import { useTargets } from '../hooks/useTargets'; import TargetRow from '../components/targets/TargetRow'; import DetailDrawer from '../components/targets/DetailDrawer'; +import CompareModal from '../components/targets/CompareModal'; import type { Target } from '../api/types'; -const OBJ_TYPES = ['All', 'galaxy', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula']; +const OBJ_TYPE_LIST = ['galaxy', 'galaxy_cluster', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula']; const TYPE_LABELS: Record = { - galaxy: 'Galaxy', emission_nebula: 'Emission', reflection_nebula: 'Reflection', - planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Cluster', + galaxy: 'Galaxy', galaxy_cluster: 'Cluster (ACO)', emission_nebula: 'Emission', reflection_nebula: 'Reflection', + planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Open Cl.', globular_cluster: 'Globular', dark_nebula: 'Dark', }; const FILTERS = [ @@ -18,21 +20,52 @@ const FILTERS = [ { id: 'uvir', label: 'UV/IR Cut' }, ]; const SORT_OPTIONS = [ - { value: '', label: 'Best alt tonight' }, + { value: '', label: 'Best score tonight' }, { value: 'transit', label: 'Transit time' }, { value: 'size', label: 'Size (largest)' }, { value: 'magnitude', label: 'Magnitude' }, { value: 'difficulty', label: 'Difficulty' }, { value: 'integration', label: 'Total integration' }, -]; -const STATUS_OPTIONS = [ - { id: 'tonight', label: 'Tonight only' }, - { id: 'not_imaged', label: 'Not yet imaged' }, - { id: 'mosaic_only', label: 'Mosaics only' }, + { value: 'altitude', label: 'Altitude tonight' }, + { value: 'best_start', label: 'Run order (imaging window)' }, ]; const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal']; +const LS_KEY = 'astronome_targets_filters_v2'; + +interface FilterState { + typeFilters: string[]; + filterPill: string; + tonight: boolean; + notImaged: boolean; + mosaicOnly: boolean; + showCustom: boolean; + accessible: boolean; + minAlt: number | null; + minUsable: number | null; + sort: string; +} + +function loadFilters(): FilterState { + try { + const raw = localStorage.getItem(LS_KEY); + if (raw) return JSON.parse(raw) as FilterState; + } catch { /* ignore */ } + return { + typeFilters: [], + filterPill: '', + tonight: true, + notImaged: false, + mosaicOnly: false, + showCustom: true, + accessible: false, + minAlt: null, + minUsable: null, + sort: '', + }; +} + function Chip({ active, color, onClick, children }: { active: boolean; color?: string; onClick: () => void; children: React.ReactNode }) { const c = color ?? 'var(--amber)'; return ( @@ -56,32 +89,86 @@ function Chip({ active, color, onClick, children }: { active: boolean; color?: s } export default function Targets() { - const [typeFilter, setTypeFilter] = useState(''); - const [filterPill, setFilterPill] = useState(''); - const [tonight, setTonight] = useState(true); - const [notImaged, setNotImaged] = useState(false); - const [mosaicOnly, setMosaicOnly] = useState(false); - const [minAlt, setMinAlt] = useState(undefined); - const [minUsable, setMinUsable] = useState(undefined); - const [search, setSearch] = useState(''); - const [sort, setSort] = useState(''); - const [expandedId, setExpandedId] = useState(null); - const [page, setPage] = useState(1); + const { targetId: urlTargetId } = useParams<{ targetId?: string }>(); + const navigate = useNavigate(); - const { data, isLoading } = useTargets({ - type: typeFilter || undefined, + const saved = loadFilters(); + const [typeFilters, setTypeFilters] = useState(saved.typeFilters); + const [filterPill, setFilterPill] = useState(saved.filterPill); + const [tonight, setTonight] = useState(saved.tonight); + const [notImaged, setNotImaged] = useState(saved.notImaged); + const [mosaicOnly, setMosaicOnly] = useState(saved.mosaicOnly); + const [showCustom, setShowCustom] = useState(saved.showCustom); + const [accessible, setAccessible] = useState(saved.accessible ?? false); + const [minAlt, setMinAlt] = useState(saved.minAlt); + const [minUsable, setMinUsable] = useState(saved.minUsable); + const [search, setSearch] = useState(''); + const [sort, setSort] = useState(saved.sort); + const [expandedId, setExpandedId] = useState(urlTargetId ?? null); + const [page, setPage] = useState(1); + const [compareTargets, setCompareTargets] = useState([]); + const [showCompare, setShowCompare] = useState(false); + + const toggleCompare = (t: Target) => { + setCompareTargets(prev => { + if (prev.find(p => p.id === t.id)) return prev.filter(p => p.id !== t.id); + if (prev.length >= 2) return [prev[1], t]; + return [...prev, t]; + }); + }; + + // Persist filter state to localStorage whenever it changes + useEffect(() => { + const state: FilterState = { typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, accessible, minAlt, minUsable, sort }; + localStorage.setItem(LS_KEY, JSON.stringify(state)); + }, [typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, minAlt, minUsable, sort]); + + const toggleType = (t: string) => { + setTypeFilters(prev => + prev.includes(t) ? prev.filter(x => x !== t) : [...prev, t] + ); + setPage(1); + }; + + // "Accessible tonight" applies extra server-side constraints: alt≥40°, moon_sep≥45°, usable≥60min, difficulty≤2 + const effectiveMinAlt = accessible ? Math.max(minAlt ?? 0, 40) : (minAlt ?? undefined); + const effectiveMinUsable = accessible ? Math.max(minUsable ?? 0, 60) : (minUsable ?? undefined); + + // When URL has a target ID, disable tonight-only filter so the target is found even if off-season + const { data: rawData, isLoading } = useTargets({ + type: typeFilters.length ? typeFilters.join(',') : undefined, filter: filterPill || undefined, - tonight, + tonight: urlTargetId ? false : tonight, not_imaged: notImaged || undefined, mosaic_only: mosaicOnly || undefined, - min_alt_deg: minAlt, - min_usable_min: minUsable, - search: search || undefined, + show_custom: showCustom ? undefined : false, + min_alt_deg: effectiveMinAlt || undefined, + min_usable_min: effectiveMinUsable || undefined, + search: urlTargetId ? urlTargetId : (search || undefined), sort: sort || undefined, page, limit: 100, }); + // Client-side accessible filter: difficulty ≤ 2 and moon_sep ≥ 45° + const data = accessible && rawData ? { + ...rawData, + items: rawData.items.filter(t => + (t.difficulty == null || t.difficulty <= 2) && + (t.moon_sep_deg == null || t.moon_sep_deg >= 45) + ), + } : rawData; + + // Sync expandedId → URL + useEffect(() => { + if (expandedId) { + navigate(`/targets/${encodeURIComponent(expandedId)}`, { replace: true }); + } else if (urlTargetId) { + navigate('/targets', { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expandedId]); + const toggleExpand = (id: string) => { setExpandedId(prev => prev === id ? null : id); }; @@ -100,18 +187,30 @@ export default function Targets() { borderBottom: '1px solid var(--border)', marginBottom: 10, }}> - {/* Row 1: object types */} -
- {OBJ_TYPES.map(t => ( + {/* Row 1: object types (multi-select) */} +
+ { setTypeFilters([]); setPage(1); }} + > + All + + {OBJ_TYPE_LIST.map(t => ( setTypeFilter(t === 'All' ? '' : t)} + onClick={() => toggleType(t)} > {TYPE_LABELS[t] ?? t} ))} + {typeFilters.length > 0 && ( + + {typeFilters.length} type{typeFilters.length > 1 ? 's' : ''} selected + + )}
{/* Row 2: filters + sort + status + search */} @@ -124,7 +223,7 @@ export default function Targets() { key={f.id} active={f.id === filterPill} color="var(--blue)" - onClick={() => setFilterPill(f.id === filterPill ? '' : f.id)} + onClick={() => { setFilterPill(f.id === filterPill ? '' : f.id); setPage(1); }} > {f.label} @@ -143,6 +242,12 @@ export default function Targets() { setMosaicOnly(v => !v)}> Mosaics only + setShowCustom(v => !v)}> + Custom + + { setAccessible(v => !v); setPage(1); }}> + Accessible tonight +
@@ -151,7 +256,7 @@ export default function Targets() { MIN ALT setMinUsable(e.target.value ? Number(e.target.value) : undefined)} + onChange={e => { setMinUsable(e.target.value ? Number(e.target.value) : null); setPage(1); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 80 }} > @@ -213,6 +318,29 @@ export default function Targets() { {data?.total ?? 0} objects + {compareTargets.length > 0 && ( +
+ {compareTargets.map(t => ( + + {t.common_name ?? t.name} + + ))} + {compareTargets.length === 2 && ( + + )} + +
+ )}
@@ -227,8 +355,8 @@ export default function Targets() { - {COL_HEADERS.map(h => ( - - @@ -263,6 +393,13 @@ export default function Targets() {
( + toggleExpand(target.id)} + inCompare={compareTargets.some(c => c.id === target.id)} + onCompare={toggleCompare} /> {expandedId === target.id && (
+
)} + + {showCompare && compareTargets.length === 2 && ( + setShowCompare(false)} + /> + )}

); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index f813120..db01c19 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -97,6 +97,9 @@ input:focus, select:focus, textarea:focus { .type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; } .type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); } .type-badge.nebula { background: var(--teal); color: #fff; } +.type-badge.galaxy_cluster { background: #7b3f9e; color: #fff; } +.type-badge.galaxy_group { background: #4a5268; color: #fff; } +.type-badge.interacting_galaxy { background: #5c4070; color: #fff; } /* Quality chips */ .quality-chip {