Add target comparison modal, integration goal progress, and session planning + full catalog expansion
Features added this session: - Target comparison: side-by-side overlay (CompareModal) from Targets page via ⊕ button on each row; shows altitude curves, key times, filter recommendations and per-filter integration progress for two targets simultaneously - Integration goal progress dashboard card: per-target keeper minutes vs goal hours (from CLAUDE.md §16.3) broken down by filter, with color-coded progress bars; powered by new stats.integration_goals backend query - Session planning timeline: Gantt-style "Plan Tonight" section on Dashboard (PlanningTimeline component) — search targets, set durations, sequential scheduling from dusk, overrun warnings, clipboard export - Slew-optimized run order toggle (nearest-neighbor sort by RA/Dec angular distance) - Best Nights 14-day card + Monthly Highlights card on Dashboard Catalog expansions: - Sharpless (Sh2), VdB, LDN, Barnard dark nebulae, LBN, Melotte, Collinder, Gum, RCW, Abell PN, Abell GC, PGC bright subset - Caldwell/Arp/Melotte/Collinder number columns + cross-reference maps - Weather score multiplier applied to composite sort - galaxy_cluster type (ACO badge) throughout TypeBadge, CSS, filter chips Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,48 +26,131 @@
|
|||||||
|
|
||||||
### Nice to Have
|
### 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
|
## 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
|
## 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
|
## 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 `.bat`/`.sh` script or folder structure stub for WBPP. Out of scope for now (requires XISF format knowledge).
|
||||||
|
|
||||||
- [ ] **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Catalog & Discovery
|
## 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.
|
||||||
|
|||||||
@@ -140,6 +140,135 @@ pub async fn get_calendar(
|
|||||||
Ok(Json(serde_json::json!({ "days": days })))
|
Ok(Json(serde_json::json!({ "days": days })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the next 14 nights ranked by composite night score.
|
||||||
|
pub async fn get_best_nights(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let today = chrono::Utc::now().naive_utc().date();
|
||||||
|
|
||||||
|
let mut nights: Vec<serde_json::Value> = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..14i64 {
|
||||||
|
let date = today + chrono::Duration::days(i);
|
||||||
|
let date_str = date.to_string();
|
||||||
|
let moon_illum = moon_illum_for_date(date);
|
||||||
|
|
||||||
|
// Pull nightly cache stats for this night
|
||||||
|
let cache_row = sqlx::query(
|
||||||
|
r#"SELECT
|
||||||
|
COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count,
|
||||||
|
AVG(CASE WHEN nc.max_alt_deg >= 30 THEN nc.usable_min ELSE NULL END) as avg_usable_min
|
||||||
|
FROM nightly_cache nc
|
||||||
|
WHERE nc.night_date = ?"#,
|
||||||
|
)
|
||||||
|
.bind(&date_str)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (visible_count, avg_usable_min): (i64, f64) = cache_row.as_ref().map(|r| (
|
||||||
|
r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0),
|
||||||
|
r.try_get::<Option<f64>, _>("avg_usable_min").unwrap_or_default().unwrap_or(0.0),
|
||||||
|
)).unwrap_or((0, 0.0));
|
||||||
|
|
||||||
|
// Moon score: low illumination = better
|
||||||
|
let moon_score = 1.0 - moon_illum;
|
||||||
|
// Dark hours score: normalize to 0–1 assuming max useful ~480 min
|
||||||
|
let dark_score = (avg_usable_min / 480.0).min(1.0);
|
||||||
|
// Composite (no weather score for future nights without forecast)
|
||||||
|
let score = moon_score * 0.5 + dark_score * 0.5;
|
||||||
|
|
||||||
|
// Top 3 targets for this night
|
||||||
|
let top_targets_rows = sqlx::query(
|
||||||
|
r#"SELECT c.id, c.name, c.common_name, c.obj_type, nc.max_alt_deg, nc.usable_min, nc.recommended_filter
|
||||||
|
FROM nightly_cache nc
|
||||||
|
JOIN catalog c ON c.id = nc.catalog_id
|
||||||
|
WHERE nc.night_date = ? AND nc.max_alt_deg >= 20
|
||||||
|
ORDER BY nc.max_alt_deg DESC
|
||||||
|
LIMIT 3"#,
|
||||||
|
)
|
||||||
|
.bind(&date_str)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let top_targets: Vec<serde_json::Value> = top_targets_rows.iter().map(|r| serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
})).collect();
|
||||||
|
|
||||||
|
nights.push(serde_json::json!({
|
||||||
|
"date": date_str,
|
||||||
|
"score": (score * 100.0) as u32,
|
||||||
|
"moon_illumination": moon_illum,
|
||||||
|
"visible_count": visible_count,
|
||||||
|
"avg_usable_min": avg_usable_min as u32,
|
||||||
|
"top_targets": top_targets,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
nights.sort_by(|a, b| {
|
||||||
|
let sa = a["score"].as_u64().unwrap_or(0);
|
||||||
|
let sb = b["score"].as_u64().unwrap_or(0);
|
||||||
|
sb.cmp(&sa)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "nights": nights })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns top 5 targets that peak this calendar month, preferring not-yet-imaged.
|
||||||
|
pub async fn get_monthly_highlights(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
let today = chrono::Utc::now().naive_utc().date();
|
||||||
|
let month_prefix = today.format("%Y-%m").to_string();
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"SELECT
|
||||||
|
c.id, c.name, c.common_name, c.obj_type, c.constellation,
|
||||||
|
MAX(nc.max_alt_deg) as peak_alt,
|
||||||
|
MAX(nc.usable_min) as best_usable,
|
||||||
|
nc.recommended_filter,
|
||||||
|
nc.transit_utc,
|
||||||
|
(SELECT COUNT(*) FROM imaging_log il
|
||||||
|
WHERE il.catalog_id = c.id AND il.quality = 'keeper') as keeper_count
|
||||||
|
FROM nightly_cache nc
|
||||||
|
JOIN catalog c ON c.id = nc.catalog_id
|
||||||
|
WHERE nc.night_date LIKE ?
|
||||||
|
AND nc.max_alt_deg >= 20
|
||||||
|
AND nc.is_visible_tonight = 1
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY keeper_count ASC, peak_alt DESC
|
||||||
|
LIMIT 8"#,
|
||||||
|
)
|
||||||
|
.bind(format!("{}%", month_prefix))
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let highlights: Vec<serde_json::Value> = rows.iter().map(|r| serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"constellation": r.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||||
|
"peak_alt": r.try_get::<Option<f64>, _>("peak_alt").unwrap_or_default(),
|
||||||
|
"best_usable_min": r.try_get::<Option<i32>, _>("best_usable").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
"keeper_count": r.try_get::<i64, _>("keeper_count").unwrap_or_default(),
|
||||||
|
})).collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"month": month_prefix,
|
||||||
|
"highlights": highlights,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_calendar_date(
|
pub async fn get_calendar_date(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(date): Path<String>,
|
Path(date): Path<String>,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub async fn list_all_gallery(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"catalog_id": &catalog_id,
|
"catalog_id": &catalog_id,
|
||||||
"filename": &filename,
|
"filename": &filename,
|
||||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
|
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
|
||||||
@@ -60,7 +60,7 @@ pub async fn list_gallery(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"catalog_id": catalog_id,
|
"catalog_id": catalog_id,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||||
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
"caption": r.try_get::<Option<String>, _>("caption").unwrap_or_default(),
|
||||||
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
})
|
})
|
||||||
@@ -147,7 +147,7 @@ pub async fn upload_image(
|
|||||||
"id": id,
|
"id": id,
|
||||||
"catalog_id": catalog_id,
|
"catalog_id": catalog_id,
|
||||||
"filename": filename,
|
"filename": filename,
|
||||||
"url": format!("/api/gallery/{}/{}", catalog_id, filename),
|
"url": format!("/api/gallery/files/{}/{}", catalog_id, filename),
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,15 @@ pub fn build_router(pool: SqlitePool) -> Router {
|
|||||||
.route("/api/targets/:id/filters", get(targets::get_filters))
|
.route("/api/targets/:id/filters", get(targets::get_filters))
|
||||||
.route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler))
|
.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/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))
|
.route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes))
|
||||||
// Tonight
|
// Tonight
|
||||||
.route("/api/tonight", get(tonight::get_tonight))
|
.route("/api/tonight", get(tonight::get_tonight))
|
||||||
// Calendar
|
// Calendar
|
||||||
.route("/api/calendar", get(calendar::get_calendar))
|
.route("/api/calendar", get(calendar::get_calendar))
|
||||||
.route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows))
|
.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))
|
.route("/api/calendar/:date", get(calendar::get_calendar_date))
|
||||||
// Weather
|
// Weather
|
||||||
.route("/api/weather", get(weather::get_weather))
|
.route("/api/weather", get(weather::get_weather))
|
||||||
@@ -103,8 +106,9 @@ pub fn build_router(pool: SqlitePool) -> Router {
|
|||||||
// Stats
|
// Stats
|
||||||
.route("/api/stats", get(stats::get_stats))
|
.route("/api/stats", get(stats::get_stats))
|
||||||
// Static gallery files served via tower-http
|
// Static gallery files served via tower-http
|
||||||
|
// Must be under /api/ so nginx proxies it to the backend, not the frontend.
|
||||||
.nest_service(
|
.nest_service(
|
||||||
"/data/gallery",
|
"/api/gallery/files",
|
||||||
tower_http::services::ServeDir::new(gallery_dir),
|
tower_http::services::ServeDir::new(gallery_dir),
|
||||||
)
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ pub struct SolarSystemObject {
|
|||||||
pub is_visible: bool, // alt > 15°
|
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 total_sec = (ra / 15.0) * 3600.0;
|
||||||
let h = (total_sec / 3600.0) as u32;
|
let h = (total_sec / 3600.0) as u32;
|
||||||
let m = ((total_sec % 3600.0) / 60.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)
|
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 sign = if dec < 0.0 { "-" } else { "+" };
|
||||||
let abs = dec.abs();
|
let abs = dec.abs();
|
||||||
let d = abs as u32;
|
let d = abs as u32;
|
||||||
|
|||||||
@@ -137,6 +137,168 @@ pub async fn get_stats(
|
|||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
|
// Integration gap detector: targets with one narrowband filter but missing the companion.
|
||||||
|
// Pairs: sv220 ↔ c2 (for full SHO palette), uvir ↔ sv260 (broadband pair).
|
||||||
|
let gaps_raw = sqlx::query(
|
||||||
|
r#"SELECT
|
||||||
|
l.catalog_id,
|
||||||
|
c.name,
|
||||||
|
c.common_name,
|
||||||
|
c.obj_type,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'sv220' THEN l.integration_min ELSE 0 END) as sv220_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'c2' THEN l.integration_min ELSE 0 END) as c2_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'uvir' THEN l.integration_min ELSE 0 END) as uvir_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'sv260' THEN l.integration_min ELSE 0 END) as sv260_min
|
||||||
|
FROM imaging_log l
|
||||||
|
JOIN catalog c ON c.id = l.catalog_id
|
||||||
|
WHERE l.quality IN ('keeper', 'needs_more')
|
||||||
|
GROUP BY l.catalog_id
|
||||||
|
HAVING (sv220_min > 0 AND c2_min = 0)
|
||||||
|
OR (c2_min > 0 AND sv220_min = 0)
|
||||||
|
OR (uvir_min > 0 AND sv260_min = 0)
|
||||||
|
OR (sv260_min > 0 AND uvir_min = 0)
|
||||||
|
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
|
||||||
|
LIMIT 10"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let gaps: Vec<serde_json::Value> = gaps_raw.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
let sv220_min: i64 = r.try_get("sv220_min").unwrap_or(0);
|
||||||
|
let c2_min: i64 = r.try_get("c2_min").unwrap_or(0);
|
||||||
|
let uvir_min: i64 = r.try_get("uvir_min").unwrap_or(0);
|
||||||
|
let sv260_min: i64 = r.try_get("sv260_min").unwrap_or(0);
|
||||||
|
|
||||||
|
let mut missing: Vec<&str> = Vec::new();
|
||||||
|
if sv220_min > 0 && c2_min == 0 { missing.push("c2"); }
|
||||||
|
if c2_min > 0 && sv220_min == 0 { missing.push("sv220"); }
|
||||||
|
if uvir_min > 0 && sv260_min == 0 { missing.push("sv260"); }
|
||||||
|
if sv260_min > 0 && uvir_min == 0 { missing.push("uvir"); }
|
||||||
|
|
||||||
|
serde_json::json!({
|
||||||
|
"catalog_id": r.try_get::<String, _>("catalog_id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"sv220_min": sv220_min,
|
||||||
|
"c2_min": c2_min,
|
||||||
|
"uvir_min": uvir_min,
|
||||||
|
"sv260_min": sv260_min,
|
||||||
|
"missing_filters": missing,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Catalogue completion: per-catalogue keeper counts vs total observable
|
||||||
|
struct CatEntry { name: &'static str, sql_filter: &'static str }
|
||||||
|
let catalogues: &[CatEntry] = &[
|
||||||
|
CatEntry { name: "Messier", sql_filter: "c.messier_num IS NOT NULL" },
|
||||||
|
CatEntry { name: "Caldwell", sql_filter: "c.caldwell_num IS NOT NULL" },
|
||||||
|
CatEntry { name: "Sharpless", sql_filter: "c.id LIKE 'Sh2-%'" },
|
||||||
|
CatEntry { name: "LDN", sql_filter: "c.id LIKE 'LDN%'" },
|
||||||
|
CatEntry { name: "VdB", sql_filter: "c.id LIKE 'VdB%'" },
|
||||||
|
CatEntry { name: "NGC", sql_filter: "c.id LIKE 'NGC%'" },
|
||||||
|
CatEntry { name: "IC", sql_filter: "c.id LIKE 'IC%'" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut catalogue_completion: Vec<serde_json::Value> = Vec::new();
|
||||||
|
for cat in catalogues {
|
||||||
|
let total_sql = format!("SELECT COUNT(DISTINCT c.id) FROM catalog c WHERE {}", cat.sql_filter);
|
||||||
|
let keeper_sql = format!(
|
||||||
|
"SELECT COUNT(DISTINCT l.catalog_id) FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id WHERE l.quality = 'keeper' AND {}",
|
||||||
|
cat.sql_filter
|
||||||
|
);
|
||||||
|
let total: i64 = sqlx::query_scalar::<_, i64>(&total_sql)
|
||||||
|
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||||
|
let keepers: i64 = sqlx::query_scalar::<_, i64>(&keeper_sql)
|
||||||
|
.fetch_one(&state.pool).await.unwrap_or(0);
|
||||||
|
if total > 0 {
|
||||||
|
catalogue_completion.push(serde_json::json!({
|
||||||
|
"name": cat.name,
|
||||||
|
"total": total,
|
||||||
|
"keepers": keepers,
|
||||||
|
"pct": if total > 0 { (keepers as f64 / total as f64 * 100.0).round() as i64 } else { 0 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session history timeline: all sessions with first gallery image if available
|
||||||
|
let history_rows = sqlx::query(
|
||||||
|
r#"SELECT
|
||||||
|
l.session_date,
|
||||||
|
l.catalog_id,
|
||||||
|
COALESCE(c.name, l.catalog_id) as name,
|
||||||
|
c.common_name,
|
||||||
|
c.obj_type,
|
||||||
|
l.filter_id,
|
||||||
|
l.integration_min,
|
||||||
|
l.quality,
|
||||||
|
l.notes,
|
||||||
|
g.filename as gallery_filename
|
||||||
|
FROM imaging_log l
|
||||||
|
LEFT JOIN catalog c ON c.id = l.catalog_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT catalog_id, MIN(filename) as filename
|
||||||
|
FROM gallery
|
||||||
|
GROUP BY catalog_id
|
||||||
|
) g ON g.catalog_id = l.catalog_id
|
||||||
|
ORDER BY l.session_date DESC, l.created_at DESC
|
||||||
|
LIMIT 500"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let history: Vec<serde_json::Value> = history_rows.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
let catalog_id: String = r.try_get("catalog_id").unwrap_or_default();
|
||||||
|
let gallery_filename: Option<String> = r.try_get("gallery_filename").unwrap_or_default();
|
||||||
|
let gallery_url = gallery_filename.map(|f| format!("/api/gallery/files/{}/{}", catalog_id, f));
|
||||||
|
serde_json::json!({
|
||||||
|
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||||
|
"catalog_id": catalog_id,
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
|
||||||
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||||
|
"integration_min": r.try_get::<i64, _>("integration_min").unwrap_or(0),
|
||||||
|
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||||
|
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||||
|
"gallery_url": gallery_url,
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// Integration goals: keeper minutes per filter per target, for goal progress tracking
|
||||||
|
let goals_raw = sqlx::query(
|
||||||
|
r#"SELECT
|
||||||
|
c.id, c.name, c.common_name, c.obj_type,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'sv220' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv220_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'c2' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as c2_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'uvir' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as uvir_min,
|
||||||
|
SUM(CASE WHEN l.filter_id = 'sv260' AND l.quality = 'keeper' THEN l.integration_min ELSE 0 END) as sv260_min
|
||||||
|
FROM imaging_log l
|
||||||
|
JOIN catalog c ON c.id = l.catalog_id
|
||||||
|
WHERE l.quality = 'keeper'
|
||||||
|
GROUP BY l.catalog_id
|
||||||
|
ORDER BY (sv220_min + c2_min + uvir_min + sv260_min) DESC
|
||||||
|
LIMIT 30"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let integration_goals: Vec<serde_json::Value> = goals_raw.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"sv220_min": r.try_get::<i64, _>("sv220_min").unwrap_or(0),
|
||||||
|
"c2_min": r.try_get::<i64, _>("c2_min").unwrap_or(0),
|
||||||
|
"uvir_min": r.try_get::<i64, _>("uvir_min").unwrap_or(0),
|
||||||
|
"sv260_min": r.try_get::<i64, _>("sv260_min").unwrap_or(0),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total_sessions": total_sessions,
|
"total_sessions": total_sessions,
|
||||||
"total_integration_min": total_integration_min.unwrap_or(0),
|
"total_integration_min": total_integration_min.unwrap_or(0),
|
||||||
@@ -147,5 +309,9 @@ pub async fn get_stats(
|
|||||||
"quality": quality_stats,
|
"quality": quality_stats,
|
||||||
"top_targets": top_target_list,
|
"top_targets": top_target_list,
|
||||||
"guiding": guiding_data,
|
"guiding": guiding_data,
|
||||||
|
"integration_gaps": gaps,
|
||||||
|
"history": history,
|
||||||
|
"catalogue_completion": catalogue_completion,
|
||||||
|
"integration_goals": integration_goals,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|||||||
+339
-78
@@ -8,12 +8,46 @@ use crate::{
|
|||||||
astronomy::{
|
astronomy::{
|
||||||
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
|
astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination,
|
||||||
moon_position, HorizonPoint, MoonState, TonightWindow,
|
moon_position, HorizonPoint, MoonState, TonightWindow,
|
||||||
|
coords::radec_to_altaz,
|
||||||
|
time::local_sidereal_time,
|
||||||
},
|
},
|
||||||
config::{LAT, LON},
|
config::{LAT, LON},
|
||||||
filters::{get_workflow, recommend_filters},
|
filters::{get_workflow, recommend_filters},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{AppError, AppState};
|
use super::{AppError, AppState};
|
||||||
|
use super::solar_system::{fmt_ra, fmt_dec};
|
||||||
|
|
||||||
|
/// Look up (ra_deg, dec_deg, obj_type) from catalog first, then custom_targets.
|
||||||
|
/// Returns None if the target is not found in either table or has no coordinates.
|
||||||
|
async fn lookup_coords(pool: &sqlx::SqlitePool, id: &str) -> Result<Option<(f64, f64, String)>, AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
if let Some(row) = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(Some((
|
||||||
|
row.try_get("ra_deg").unwrap_or_default(),
|
||||||
|
row.try_get("dec_deg").unwrap_or_default(),
|
||||||
|
row.try_get("obj_type").unwrap_or_default(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(row) = sqlx::query(
|
||||||
|
"SELECT ra_deg, dec_deg, obj_type FROM custom_targets WHERE id = ? AND ra_deg IS NOT NULL AND dec_deg IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Ok(Some((
|
||||||
|
row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||||
|
row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||||
|
row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TargetsQuery {
|
pub struct TargetsQuery {
|
||||||
@@ -30,6 +64,7 @@ pub struct TargetsQuery {
|
|||||||
pub min_usable_min: Option<i32>,
|
pub min_usable_min: Option<i32>,
|
||||||
pub mosaic_only: Option<bool>,
|
pub mosaic_only: Option<bool>,
|
||||||
pub not_imaged: Option<bool>,
|
pub not_imaged: Option<bool>,
|
||||||
|
pub show_custom: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
@@ -81,8 +116,17 @@ pub async fn list_targets(
|
|||||||
let mut bind_values: Vec<String> = vec![];
|
let mut bind_values: Vec<String> = vec![];
|
||||||
|
|
||||||
if let Some(ref t) = params.obj_type {
|
if let Some(ref t) = params.obj_type {
|
||||||
|
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());
|
conditions.push("c.obj_type = ?".to_string());
|
||||||
bind_values.push(t.clone());
|
bind_values.push(types[0].to_string());
|
||||||
|
} else if !types.is_empty() {
|
||||||
|
let placeholders = types.iter().map(|_| "?").collect::<Vec<_>>().join(",");
|
||||||
|
conditions.push(format!("c.obj_type IN ({})", placeholders));
|
||||||
|
for ty in &types {
|
||||||
|
bind_values.push(ty.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref con) = params.constellation {
|
if let Some(ref con) = params.constellation {
|
||||||
conditions.push("c.constellation = ?".to_string());
|
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.
|
// 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.
|
// This ensures these filters always return results regardless of current moon phase.
|
||||||
match f.as_str() {
|
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()),
|
"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
|
"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.
|
// (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.
|
// 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() {
|
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)
|
// Use horizon-aware is_visible_tonight flag stored in nightly_cache.
|
||||||
// so freshly added VdB/LDN objects are visible before the first nightly precompute.
|
// Fall back to max_alt_deg >= 15 for rows that predate the flag (NULL).
|
||||||
conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string());
|
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 {
|
if let Some(ref s) = params.search {
|
||||||
let like = format!("%{}%", s);
|
let like = format!("%{}%", s);
|
||||||
@@ -130,6 +174,15 @@ pub async fn list_targets(
|
|||||||
let m_num: Option<i32> = s.trim()
|
let m_num: Option<i32> = s.trim()
|
||||||
.strip_prefix(['M', 'm'])
|
.strip_prefix(['M', 'm'])
|
||||||
.and_then(|n| n.parse().ok());
|
.and_then(|n| n.parse().ok());
|
||||||
|
// Support C-number search (e.g. "C20" → caldwell_num = 20)
|
||||||
|
let c_num: Option<i32> = s.trim()
|
||||||
|
.strip_prefix(['C', 'c'])
|
||||||
|
.and_then(|n| n.parse().ok());
|
||||||
|
// Support Arp search (e.g. "Arp85" → arp_num = 85)
|
||||||
|
let arp_num: Option<i32> = s.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.strip_prefix("arp")
|
||||||
|
.and_then(|n| n.trim().parse().ok());
|
||||||
if let Some(m) = m_num {
|
if let Some(m) = m_num {
|
||||||
conditions.push(format!(
|
conditions.push(format!(
|
||||||
"(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})",
|
"(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.clone());
|
bind_values.push(like.clone());
|
||||||
bind_values.push(like);
|
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 {
|
} else {
|
||||||
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
|
conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string());
|
||||||
bind_values.push(like.clone());
|
bind_values.push(like.clone());
|
||||||
@@ -147,10 +215,26 @@ pub async fn list_targets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let where_clause = conditions.join(" AND ");
|
let where_clause = conditions.join(" AND ");
|
||||||
|
|
||||||
|
// Fetch weather weight: go=1.0, marginal=0.7, nogo=0.3 (default 1.0 if no forecast)
|
||||||
|
let weather_weight: f64 = sqlx::query_scalar::<_, Option<String>>(
|
||||||
|
"SELECT go_nogo FROM weather_cache WHERE id = 1"
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.flatten()
|
||||||
|
.map(|s| match s.as_str() {
|
||||||
|
"go" => 1.0,
|
||||||
|
"marginal" => 0.7,
|
||||||
|
"nogo" => 0.3,
|
||||||
|
_ => 1.0,
|
||||||
|
})
|
||||||
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
|
// "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
|
// Multiplied by weather_weight so cloudy nights rank all targets lower.
|
||||||
// Targets outside 20–150% FOV fill are penalised (too small or too large single-panel).
|
let best_score_expr = format!(r#"(
|
||||||
let best_score_expr = r#"(
|
|
||||||
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
|
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
|
||||||
+ CASE
|
+ CASE
|
||||||
WHEN c.fov_fill_pct IS NULL THEN 0.15
|
WHEN c.fov_fill_pct IS NULL THEN 0.15
|
||||||
@@ -160,33 +244,47 @@ pub async fn list_targets(
|
|||||||
END
|
END
|
||||||
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
|
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
|
||||||
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
|
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
|
||||||
) DESC"#;
|
) * {weather_weight:.2} DESC"#, weather_weight = weather_weight);
|
||||||
let sort_col = match params.sort.as_deref() {
|
let sort_col_owned: String = match params.sort.as_deref() {
|
||||||
Some("transit") => "nc.transit_utc",
|
Some("transit") => "nc.transit_utc".to_string(),
|
||||||
Some("size") => "c.size_arcmin_maj DESC",
|
Some("size") => "c.size_arcmin_maj DESC".to_string(),
|
||||||
Some("magnitude") => "c.mag_v",
|
Some("magnitude") => "c.mag_v".to_string(),
|
||||||
Some("difficulty") => "c.difficulty",
|
Some("difficulty") => "c.difficulty".to_string(),
|
||||||
Some("integration") => "total_integration DESC",
|
Some("integration") => "total_integration DESC".to_string(),
|
||||||
Some("altitude") => "nc.max_alt_deg DESC",
|
Some("altitude") => "nc.max_alt_deg DESC".to_string(),
|
||||||
|
Some("best_start") => "nc.best_start_utc ASC NULLS LAST".to_string(),
|
||||||
_ => best_score_expr,
|
_ => 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!(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms,
|
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.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,
|
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.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter,
|
||||||
nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg,
|
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(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
|
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
|
FROM catalog c
|
||||||
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
|
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}'
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT catalog_id, SUM(integration_min) as total_min
|
SELECT catalog_id, SUM(integration_min) as total_min
|
||||||
FROM imaging_log GROUP BY catalog_id
|
FROM imaging_log GROUP BY catalog_id
|
||||||
) log_sum ON log_sum.catalog_id = c.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}
|
WHERE {where_clause}
|
||||||
ORDER BY {sort_col}
|
ORDER BY {sort_col}
|
||||||
LIMIT {limit} OFFSET {offset}
|
LIMIT {limit} OFFSET {offset}
|
||||||
@@ -209,8 +307,24 @@ pub async fn list_targets(
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
let items: Vec<serde_json::Value> = rows.iter().map(|row| {
|
let mut items: Vec<serde_json::Value> = rows.iter().map(|row| {
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
let tonight_alt: f64 = row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default().unwrap_or(0.0);
|
||||||
|
let peak_alt: f64 = row.try_get::<Option<f64>, _>("seasonal_peak_alt").unwrap_or_default().unwrap_or(0.0);
|
||||||
|
let peak_date: Option<String> = row.try_get("seasonal_peak_date").unwrap_or_default();
|
||||||
|
let urgency: serde_json::Value = if peak_alt >= 15.0 && tonight_alt >= 15.0 {
|
||||||
|
let ratio = tonight_alt / peak_alt;
|
||||||
|
if ratio >= 0.90 {
|
||||||
|
serde_json::json!("peak")
|
||||||
|
} else if ratio >= 0.70 {
|
||||||
|
let before_peak = peak_date.as_deref().map(|d| d > today.as_str()).unwrap_or(true);
|
||||||
|
serde_json::json!(if before_peak { "rising" } else { "declining" })
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serde_json::Value::Null
|
||||||
|
};
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
@@ -227,6 +341,8 @@ pub async fn list_targets(
|
|||||||
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||||
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||||
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||||
|
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
|
||||||
|
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
|
||||||
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||||
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||||
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||||
@@ -234,7 +350,7 @@ pub async fn list_targets(
|
|||||||
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||||
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||||
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||||
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
"max_alt_deg": tonight_alt,
|
||||||
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||||
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||||
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
@@ -243,6 +359,8 @@ pub async fn list_targets(
|
|||||||
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
||||||
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
|
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
|
||||||
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
|
"total_integration_min": row.try_get::<i64, _>("total_integration").unwrap_or(0),
|
||||||
|
"is_custom": false,
|
||||||
|
"urgency": urgency,
|
||||||
})
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
@@ -262,7 +380,82 @@ pub async fn list_targets(
|
|||||||
for val in &bind_values {
|
for val in &bind_values {
|
||||||
count_query = count_query.bind(val);
|
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!({
|
Ok(Json(serde_json::json!({
|
||||||
"items": items,
|
"items": items,
|
||||||
@@ -276,12 +469,14 @@ pub async fn get_target(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
|
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
|
||||||
let m_num: Option<i32> = id.trim()
|
let m_num: Option<i32> = id.trim()
|
||||||
.strip_prefix(['M', 'm'])
|
.strip_prefix(['M', 'm'])
|
||||||
.and_then(|n| n.parse().ok());
|
.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 = ?")
|
sqlx::query("SELECT * FROM catalog WHERE messier_num = ?")
|
||||||
.bind(n)
|
.bind(n)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
@@ -291,11 +486,10 @@ pub async fn get_target(
|
|||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
};
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
|
||||||
|
|
||||||
use sqlx::Row;
|
if let Some(row) = catalog_row {
|
||||||
Ok(Json(serde_json::json!({
|
return Ok(Json(serde_json::json!({
|
||||||
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
@@ -312,6 +506,8 @@ pub async fn get_target(
|
|||||||
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||||
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||||
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||||
|
"caldwell_num": row.try_get::<Option<i32>, _>("caldwell_num").unwrap_or_default(),
|
||||||
|
"arp_num": row.try_get::<Option<i32>, _>("arp_num").unwrap_or_default(),
|
||||||
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||||
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||||
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||||
@@ -319,6 +515,47 @@ pub async fn get_target(
|
|||||||
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||||
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||||
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to custom_targets
|
||||||
|
let custom_row = sqlx::query("SELECT * FROM custom_targets WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
||||||
|
|
||||||
|
let ra: Option<f64> = custom_row.try_get("ra_deg").unwrap_or_default();
|
||||||
|
let dec: Option<f64> = custom_row.try_get("dec_deg").unwrap_or_default();
|
||||||
|
let ra_h = ra.map(fmt_ra).unwrap_or_default();
|
||||||
|
let dec_dms = dec.map(fmt_dec).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": custom_row.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": custom_row.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": serde_json::Value::Null,
|
||||||
|
"obj_type": custom_row.try_get::<String, _>("obj_type").unwrap_or_else(|_| "custom".to_string()),
|
||||||
|
"ra_deg": ra.unwrap_or(0.0),
|
||||||
|
"dec_deg": dec.unwrap_or(0.0),
|
||||||
|
"ra_h": ra_h,
|
||||||
|
"dec_dms": dec_dms,
|
||||||
|
"constellation": serde_json::Value::Null,
|
||||||
|
"size_arcmin_maj": serde_json::Value::Null,
|
||||||
|
"size_arcmin_min": serde_json::Value::Null,
|
||||||
|
"pos_angle_deg": serde_json::Value::Null,
|
||||||
|
"mag_v": serde_json::Value::Null,
|
||||||
|
"surface_brightness": serde_json::Value::Null,
|
||||||
|
"hubble_type": serde_json::Value::Null,
|
||||||
|
"messier_num": serde_json::Value::Null,
|
||||||
|
"is_highlight": false,
|
||||||
|
"fov_fill_pct": serde_json::Value::Null,
|
||||||
|
"mosaic_flag": false,
|
||||||
|
"mosaic_panels_w": 1,
|
||||||
|
"mosaic_panels_h": 1,
|
||||||
|
"difficulty": serde_json::Value::Null,
|
||||||
|
"guide_star_density": serde_json::Value::Null,
|
||||||
|
"notes": custom_row.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||||
|
"is_custom": true,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,16 +598,9 @@ pub async fn get_visibility(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
|
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
let (ra, dec, obj_type) = lookup_coords(&state.pool, id)
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", 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();
|
|
||||||
|
|
||||||
let today = chrono::Utc::now().naive_utc().date();
|
let today = chrono::Utc::now().naive_utc().date();
|
||||||
let (dusk, dawn) = astro_twilight(today, LAT, LON)
|
let (dusk, dawn) = astro_twilight(today, LAT, LON)
|
||||||
@@ -421,15 +651,9 @@ pub async fn get_curve(
|
|||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
// Always compute live at 1-minute resolution for the interactive chart.
|
// Always compute live at 1-minute resolution for the interactive chart.
|
||||||
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
|
// 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 = ?")
|
let (ra, dec, _obj_type) = lookup_coords(&state.pool, &id)
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", 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 date = chrono::Utc::now().naive_utc().date();
|
let date = chrono::Utc::now().naive_utc().date();
|
||||||
let (dusk, dawn) = astro_twilight(date, LAT, LON)
|
let (dusk, dawn) = astro_twilight(date, LAT, LON)
|
||||||
@@ -464,21 +688,17 @@ pub async fn get_filters(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
.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(
|
let tonight_row = sqlx::query(
|
||||||
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
|
"SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1",
|
||||||
)
|
)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
use sqlx::Row;
|
||||||
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
|
let (moon_illum, moon_ra, moon_dec) = match tonight_row {
|
||||||
Some(r) => (
|
Some(r) => (
|
||||||
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
|
r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
|
||||||
@@ -491,19 +711,7 @@ pub async fn get_filters(
|
|||||||
let now_jd = julian_date(chrono::Utc::now());
|
let now_jd = julian_date(chrono::Utc::now());
|
||||||
let moon_alt = moon_altitude(now_jd, LAT, LON);
|
let moon_alt = moon_altitude(now_jd, LAT, LON);
|
||||||
|
|
||||||
let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
|
let moon_sep = crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec);
|
||||||
.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 recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
|
let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep);
|
||||||
Ok(Json(serde_json::json!({ "recommendations": recs })))
|
Ok(Json(serde_json::json!({ "recommendations": recs })))
|
||||||
@@ -513,14 +721,9 @@ pub async fn get_workflow_handler(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((id, filter_id)): Path<(String, String)>,
|
Path((id, filter_id)): Path<(String, String)>,
|
||||||
) -> Result<Json<serde_json::Value>, AppError> {
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
let (_ra, _dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
.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);
|
let workflow = get_workflow(&obj_type, &filter_id);
|
||||||
Ok(Json(serde_json::to_value(workflow).unwrap()))
|
Ok(Json(serde_json::to_value(workflow).unwrap()))
|
||||||
}
|
}
|
||||||
@@ -540,16 +743,9 @@ pub async fn get_yearly(
|
|||||||
time::local_sidereal_time,
|
time::local_sidereal_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
let (ra, dec, obj_type) = lookup_coords(&state.pool, &id)
|
||||||
.bind(&id)
|
|
||||||
.fetch_optional(&state.pool)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?;
|
.ok_or_else(|| AppError::NotFound(format!("Target {} not found or has no coordinates", 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();
|
|
||||||
|
|
||||||
// Transit altitude: maximum the object can ever reach (constant for a DSO).
|
// 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);
|
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 })))
|
Ok(Json(serde_json::json!({ "catalog_id": id, "points": points })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_similar(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
use sqlx::Row;
|
||||||
|
|
||||||
|
// Get the target's constellation, obj_type, and transit_utc from tonight's cache
|
||||||
|
let target_row = sqlx::query(
|
||||||
|
"SELECT c.constellation, c.obj_type, c.ra_deg, nc.transit_utc FROM catalog c LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime') WHERE c.id = ?",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (constellation, obj_type, ra_deg, transit_utc): (Option<String>, String, f64, Option<String>) = match target_row {
|
||||||
|
Some(ref r) => (
|
||||||
|
r.try_get("constellation").unwrap_or(None),
|
||||||
|
r.try_get("obj_type").unwrap_or_default(),
|
||||||
|
r.try_get("ra_deg").unwrap_or_default(),
|
||||||
|
r.try_get("transit_utc").unwrap_or(None),
|
||||||
|
),
|
||||||
|
None => return Ok(Json(serde_json::json!({ "similar": [] }))),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find similar objects: same type + same constellation, ordered by RA proximity (≈ transit time proximity)
|
||||||
|
// RA difference of 15° ≈ 1h in transit time
|
||||||
|
let similar_rows = sqlx::query(
|
||||||
|
r#"SELECT c.id, c.name, c.common_name, c.obj_type, c.size_arcmin_maj,
|
||||||
|
c.fov_fill_pct, c.messier_num, nc.max_alt_deg, nc.transit_utc, nc.recommended_filter
|
||||||
|
FROM catalog c
|
||||||
|
LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = date('now', 'localtime')
|
||||||
|
WHERE c.id != ?
|
||||||
|
AND c.obj_type = ?
|
||||||
|
AND c.constellation = ?
|
||||||
|
AND ABS(c.ra_deg - ?) <= 25.0
|
||||||
|
AND nc.max_alt_deg >= 15
|
||||||
|
ORDER BY ABS(c.ra_deg - ?) ASC
|
||||||
|
LIMIT 5"#,
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&obj_type)
|
||||||
|
.bind(&constellation)
|
||||||
|
.bind(ra_deg)
|
||||||
|
.bind(ra_deg)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let similar: Vec<serde_json::Value> = similar_rows.iter().map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"size_arcmin_maj": r.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||||
|
"fov_fill_pct": r.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||||
|
"messier_num": r.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||||
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "similar": similar, "target_transit": transit_utc })))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_notes(
|
pub async fn get_notes(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ pub fn compute_visibility_with_step(
|
|||||||
set_utc = Some(t);
|
set_utc = Some(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best window: above 30°
|
// Best window: above 30° AND above the custom horizon at this azimuth
|
||||||
if alt > 30.0 {
|
if alt > 30.0_f64.max(h_alt) {
|
||||||
if best_start.is_none() {
|
if best_start.is_none() {
|
||||||
best_start = Some(t);
|
best_start = Some(t);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
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<AbellGcRow> = 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<AbellGcRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("RAJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("DEJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let richness = col_idx("Rich")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.unwrap_or(1);
|
||||||
|
|
||||||
|
let mag10 = col_idx("m10")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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<AbellGcRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let text = client
|
||||||
|
.get(VIZIER_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<AbellPnRow> = 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<AbellPnRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("RAJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("DEJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("Diam")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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<AbellPnRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 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<AbellPnRow> {
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let text = client
|
||||||
|
.get(VIZIER_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<BarnardRow> = 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<BarnardRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("RAJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("DEJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("Diam")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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<BarnardRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
|
||||||
|
.map(|r| build_entry(r, now, &names))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entry(
|
||||||
|
r: 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<BarnardRow> {
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<CatalogEntry> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -42,12 +42,14 @@ fn normalize_type_token(t: &str) -> Option<&'static str> {
|
|||||||
"GGroup" | "GCl" | "CG" => Some("galaxy_group"),
|
"GGroup" | "GCl" | "CG" => Some("galaxy_group"),
|
||||||
"GPair" | "PG" => Some("galaxy_pair"),
|
"GPair" | "PG" => Some("galaxy_pair"),
|
||||||
"GTrpl" | "IG" => Some("interacting_galaxy"),
|
"GTrpl" | "IG" => Some("interacting_galaxy"),
|
||||||
"GCl" | "Glob" => Some("globular_cluster"),
|
"Glob" => Some("globular_cluster"),
|
||||||
"OCl" | "OC" => Some("open_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
|
"Cl+N" => Some("emission_nebula"), // Cluster+Nebula, treat as nebula
|
||||||
"EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"),
|
"EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"),
|
||||||
"RfN" | "RN" => Some("reflection_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"),
|
"PN" => Some("planetary_nebula"),
|
||||||
"SNR" => Some("snr"),
|
"SNR" => Some("snr"),
|
||||||
"DN" => Some("dark_nebula"),
|
"DN" => Some("dark_nebula"),
|
||||||
@@ -73,8 +75,8 @@ pub fn normalize_type(raw: &str) -> Option<&'static str> {
|
|||||||
// Priority order: emission/reflection > cluster > galaxy
|
// Priority order: emission/reflection > cluster > galaxy
|
||||||
let priority = |s: &str| -> u8 {
|
let priority = |s: &str| -> u8 {
|
||||||
match s.trim() {
|
match s.trim() {
|
||||||
"NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" => 10,
|
"NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" | "N" | "Neb" => 10,
|
||||||
"GC" | "OC" => 5,
|
"GC" | "OC" | "Cl" | "Glob" => 5,
|
||||||
"G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3,
|
"G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let text = client
|
||||||
|
.get(VIZIER_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<GumRow> = 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<GumRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("_RA")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("Diam")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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<GumRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 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<GumRow> {
|
||||||
|
// 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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
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<LbnRow> = 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<LbnRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("_RA")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("Diam")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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::<u32>().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<LbnRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 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<LbnRow> {
|
||||||
|
// 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() },
|
||||||
|
]
|
||||||
|
}
|
||||||
+161
-86
@@ -1,13 +1,27 @@
|
|||||||
/// Lynds Dark Nebula catalog (LDN).
|
/// Lynds Dark Nebula catalog (LDN).
|
||||||
/// Note: VizieR VI/71A is spectral lines catalog, not dark nebulae.
|
/// Primary source: VizieR catalog VII/7A (Lynds 1962).
|
||||||
/// Using hardcoded list of ~50 prominent LDN objects suitable for imaging.
|
/// Fallback: hardcoded list of ~50 prominent LDN objects.
|
||||||
|
use anyhow::Context;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
|
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
|
||||||
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
|
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
|
||||||
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
|
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 {
|
struct LdnRow {
|
||||||
id: u32,
|
id: u32,
|
||||||
ra_deg: f64,
|
ra_deg: f64,
|
||||||
@@ -18,135 +32,151 @@ struct LdnRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_ldn() -> anyhow::Result<Vec<CatalogEntry>> {
|
pub async fn fetch_ldn() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
let rows = get_prominent_ldns();
|
match fetch_from_vizier().await {
|
||||||
tracing::info!("Loaded {} prominent LDN objects", rows.len());
|
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();
|
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
let entries = rows
|
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<LdnRow> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
r.dec_deg >= -30.0
|
r.dec_deg >= -30.0
|
||||||
&& r.dec_deg <= 75.0
|
&& r.dec_deg <= 75.0
|
||||||
&& r.dmax_arcmin >= 2.0 // skip tiny blobs
|
&& r.dmax_arcmin >= 2.0
|
||||||
&& r.opacity >= 3 // only moderately opaque or more
|
&& r.opacity >= 3
|
||||||
})
|
})
|
||||||
.map(|r| build_entry(r, now))
|
|
||||||
.collect();
|
.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<LdnRow> {
|
fn parse_vizier_tsv(text: &str) -> Vec<LdnRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut header: Vec<String> = Vec::new();
|
let mut header: Vec<String> = Vec::new();
|
||||||
let mut skip_unit_row = false;
|
let mut past_separator = false;
|
||||||
|
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
// Skip comment/meta lines
|
|
||||||
if line.starts_with('#') {
|
if line.starts_with('#') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = line.trim();
|
let trimmed = line.trim();
|
||||||
if line.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First non-comment line is the header
|
// First non-comment line is the column header
|
||||||
if header.is_empty() {
|
if header.is_empty() {
|
||||||
header = line.split_whitespace().map(|s| s.to_string()).collect();
|
header = if trimmed.contains('\t') {
|
||||||
skip_unit_row = true;
|
trimmed.split('\t').map(|s| s.trim().to_string()).collect()
|
||||||
|
} else {
|
||||||
|
trimmed.split_whitespace().map(|s| s.to_string()).collect()
|
||||||
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip the units/separator row (contains dashes)
|
// Skip separator/units row (lines of dashes or unit strings)
|
||||||
if skip_unit_row && line.starts_with("---") {
|
if !past_separator {
|
||||||
skip_unit_row = false;
|
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
|
||||||
continue;
|
past_separator = true;
|
||||||
}
|
}
|
||||||
if skip_unit_row {
|
|
||||||
skip_unit_row = false;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
let cols: Vec<&str> = if line.contains('\t') {
|
||||||
if cols.is_empty() {
|
line.split('\t').map(|s| s.trim()).collect()
|
||||||
|
} else {
|
||||||
|
line.split_whitespace().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if cols.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let idx = |name: &str| header.iter().position(|h| h == name);
|
let col_idx = |name: &str| -> Option<usize> {
|
||||||
|
header.iter().position(|h| h.eq_ignore_ascii_case(name))
|
||||||
|
};
|
||||||
|
|
||||||
let id = idx("LDN")
|
let id = col_idx("LDN")
|
||||||
.and_then(|i| cols.get(i))
|
.and_then(|i| cols.get(i))
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok());
|
.and_then(|s| s.parse::<u32>().ok());
|
||||||
let ra = idx("_RA")
|
|
||||||
|
let dmax = col_idx("Dmax")
|
||||||
.and_then(|i| cols.get(i))
|
.and_then(|i| cols.get(i))
|
||||||
.and_then(|s| s.trim().parse::<f64>().ok());
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
let dec = idx("_DE")
|
|
||||||
.and_then(|i| cols.get(i))
|
|
||||||
.and_then(|s| s.trim().parse::<f64>().ok());
|
|
||||||
let dmax = idx("Size")
|
|
||||||
.and_then(|i| cols.get(i))
|
|
||||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
|
||||||
.unwrap_or(5.0);
|
.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(|i| cols.get(i))
|
||||||
.and_then(|s| s.trim().parse::<u8>().ok())
|
.and_then(|s| s.parse::<u8>().ok())
|
||||||
.unwrap_or(3);
|
.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::<f64>().ok())
|
||||||
|
.or_else(|| cols.get(cols.len().saturating_sub(2)).and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.or_else(|| cols.last().and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
|
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
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hardcoded list of prominent LDN dark nebulae suitable for amateur astrophotography.
|
fn build_entries_from_rows(rows: Vec<LdnRow>) -> Vec<CatalogEntry> {
|
||||||
/// Data from Lynds (1962) catalog, widely referenced in astrophotography literature.
|
let now = Utc::now().timestamp();
|
||||||
/// TODO: Replace with full VizieR catalog once correct source ID is identified.
|
rows.into_iter()
|
||||||
fn get_prominent_ldns() -> Vec<LdnRow> {
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.dmax_arcmin >= 2.0 && r.opacity >= 3)
|
||||||
vec![
|
.map(|r| build_entry(r, now))
|
||||||
// LDN 6 - near Orion
|
.collect()
|
||||||
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_entry(r: LdnRow, now: i64) -> CatalogEntry {
|
fn build_entry(r: LdnRow, now: i64) -> CatalogEntry {
|
||||||
@@ -186,3 +216,48 @@ fn build_entry(r: LdnRow, now: i64) -> CatalogEntry {
|
|||||||
fetched_at: now,
|
fetched_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hardcoded fallback: prominent LDN dark nebulae suitable for amateur astrophotography.
|
||||||
|
/// Data from Lynds (1962) catalog.
|
||||||
|
fn get_prominent_ldns() -> Vec<LdnRow> {
|
||||||
|
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 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<CatalogEntry> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
+224
-102
@@ -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 fetch;
|
||||||
pub mod filter;
|
pub mod filter;
|
||||||
|
pub mod gum;
|
||||||
|
pub mod lbn;
|
||||||
pub mod ldn;
|
pub mod ldn;
|
||||||
|
pub mod melotte;
|
||||||
pub mod popular_names;
|
pub mod popular_names;
|
||||||
|
pub mod rcw;
|
||||||
|
pub mod sh2;
|
||||||
pub mod vdb;
|
pub mod vdb;
|
||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
@@ -13,7 +24,7 @@ use self::popular_names::popular_names;
|
|||||||
|
|
||||||
const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600;
|
const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600;
|
||||||
// Bump this string whenever catalog ingestion logic changes.
|
// 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.
|
/// Force a full catalog re-ingest regardless of TTL or version.
|
||||||
pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result<usize> {
|
pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result<usize> {
|
||||||
@@ -76,11 +87,19 @@ async fn do_refresh(pool: &SqlitePool) -> anyhow::Result<usize> {
|
|||||||
/// Useful for testing, validation, and dry-run operations.
|
/// Useful for testing, validation, and dry-run operations.
|
||||||
pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
|
pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
// Fetch all sources in parallel
|
// Fetch all sources in parallel
|
||||||
tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN...");
|
tracing::info!("Refreshing catalog from OpenNGC + Sh2 + VdB + LDN + Barnard + LBN + Gum + RCW + AbellPN + AbellGC + PGC...");
|
||||||
let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!(
|
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(),
|
fetch_opengc(),
|
||||||
|
sh2::fetch_sh2(),
|
||||||
vdb::fetch_vdb(),
|
vdb::fetch_vdb(),
|
||||||
ldn::fetch_ldn(),
|
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();
|
let names = popular_names();
|
||||||
@@ -96,14 +115,25 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
|
|||||||
|
|
||||||
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
|
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
|
||||||
|
|
||||||
// Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers
|
// Deduplicate Sh2 entries against NGC/IC objects that may share coordinates.
|
||||||
let sh2_aliases: Vec<CatalogEntry> = entries
|
// We track IDs already present so Sh2 aliases for NGC objects with existing
|
||||||
.iter()
|
// entries (e.g. Sh2-100 = IC1318 already in catalog) are skipped.
|
||||||
.filter_map(|entry| create_sh2_alias(entry, &names))
|
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
|
||||||
.collect();
|
|
||||||
|
|
||||||
tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len());
|
match sh2_res {
|
||||||
entries.extend(sh2_aliases);
|
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 {
|
match vdb_res {
|
||||||
Ok(vdb_entries) => {
|
Ok(vdb_entries) => {
|
||||||
@@ -121,101 +151,156 @@ pub async fn build_catalog() -> anyhow::Result<Vec<CatalogEntry>> {
|
|||||||
Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e),
|
Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Barnard dark nebulae — deduplicate against LDN by position (2' radius)
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
match barnard_res {
|
||||||
|
Ok(barnard_entries) => {
|
||||||
|
let new_barnard: Vec<_> = barnard_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} Barnard dark nebula entries (after dedup)", new_barnard.len());
|
||||||
|
entries.extend(new_barnard);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Barnard fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// LBN nebulae — deduplicate against existing NGC/IC/Sh2
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
match lbn_res {
|
||||||
|
Ok(lbn_entries) => {
|
||||||
|
let new_lbn: Vec<_> = lbn_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033 // ~2 arcmin
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} LBN entries (after dedup)", new_lbn.len());
|
||||||
|
entries.extend(new_lbn);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("LBN fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Melotte standalone entries (very large clusters without NGC IDs)
|
||||||
|
let melotte_standalone = melotte::get_standalone_melotte();
|
||||||
|
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
|
||||||
|
let new_melotte: Vec<_> = melotte_standalone.into_iter()
|
||||||
|
.filter(|e| !existing_ids.contains(&e.id))
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} standalone Melotte entries", new_melotte.len());
|
||||||
|
entries.extend(new_melotte);
|
||||||
|
|
||||||
|
// Collinder standalone entries
|
||||||
|
let collinder_standalone = collinder::get_standalone_collinder();
|
||||||
|
{
|
||||||
|
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
|
||||||
|
let new_collinder: Vec<_> = collinder_standalone.into_iter()
|
||||||
|
.filter(|e| !existing_ids.contains(&e.id))
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} standalone Collinder entries", new_collinder.len());
|
||||||
|
entries.extend(new_collinder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gum HII regions — deduplicate by position against existing catalog
|
||||||
|
match gum_res {
|
||||||
|
Ok(gum_entries) => {
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
let new_gum: Vec<_> = gum_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} Gum entries (after dedup)", new_gum.len());
|
||||||
|
entries.extend(new_gum);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Gum fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RCW HII regions — deduplicate by position
|
||||||
|
match rcw_res {
|
||||||
|
Ok(rcw_entries) => {
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
let new_rcw: Vec<_> = rcw_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} RCW entries (after dedup)", new_rcw.len());
|
||||||
|
entries.extend(new_rcw);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("RCW fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abell PN — deduplicate against NGC/IC PNe by position
|
||||||
|
match abell_pn_res {
|
||||||
|
Ok(abell_entries) => {
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
let new_abell: Vec<_> = abell_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} Abell PN entries (after dedup)", new_abell.len());
|
||||||
|
entries.extend(new_abell);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Abell PN fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abell Galaxy Clusters — unique IDs, no dedup needed (galaxy_cluster is a new type)
|
||||||
|
match abell_gc_res {
|
||||||
|
Ok(abell_gc_entries) => {
|
||||||
|
let existing_ids: std::collections::HashSet<String> = entries.iter().map(|e| e.id.clone()).collect();
|
||||||
|
let new_gc: Vec<_> = abell_gc_entries.into_iter()
|
||||||
|
.filter(|e| !existing_ids.contains(&e.id))
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} Abell Galaxy Cluster entries", new_gc.len());
|
||||||
|
entries.extend(new_gc);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("Abell GC fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
// PGC bright subset — deduplicate against NGC/IC by position (2' radius)
|
||||||
|
match pgc_res {
|
||||||
|
Ok(pgc_entries) => {
|
||||||
|
let existing_coords: Vec<(f64, f64)> = entries.iter().map(|e| (e.ra_deg, e.dec_deg)).collect();
|
||||||
|
let new_pgc: Vec<_> = pgc_entries.into_iter()
|
||||||
|
.filter(|e| {
|
||||||
|
!existing_coords.iter().any(|(ra, dec)| {
|
||||||
|
let dra = (e.ra_deg - ra).abs().min(360.0 - (e.ra_deg - ra).abs());
|
||||||
|
let ddec = (e.dec_deg - dec).abs();
|
||||||
|
(dra * dra + ddec * ddec).sqrt() < 0.033
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
tracing::info!("Adding {} PGC bright galaxy entries (after dedup)", new_pgc.len());
|
||||||
|
entries.extend(new_pgc);
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("PGC fetch failed (skipping): {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract Sharpless identifier from an object's identifiers field and create an alias entry.
|
|
||||||
fn create_sh2_alias(
|
|
||||||
entry: &CatalogEntry,
|
|
||||||
popular_names: &std::collections::HashMap<&'static str, &'static str>,
|
|
||||||
) -> Option<CatalogEntry> {
|
|
||||||
// We'll need to parse identifiers from somewhere.
|
|
||||||
// For now, we extract from the entry's existing data if available.
|
|
||||||
// The issue is that compute_derived doesn't store the original identifiers field.
|
|
||||||
// So we can look for Sh2 in the name or construct from the object type and catalog.
|
|
||||||
|
|
||||||
// Check if this object already has "Sh2" in the ID (like "Sh2-155")
|
|
||||||
if entry.id.starts_with("Sh2-") {
|
|
||||||
return None; // Already a Sharpless entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only create Sh2 aliases for emission nebulae and similar objects
|
|
||||||
// that are likely to have Sharpless counterparts
|
|
||||||
if !matches!(
|
|
||||||
entry.obj_type.as_str(),
|
|
||||||
"emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula"
|
|
||||||
) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find a Sharpless name in popular_names for this object
|
|
||||||
// by checking known Sh2→NGC mappings
|
|
||||||
let sh2_id = match entry.id.as_str() {
|
|
||||||
// Sharpless → NGC known mappings
|
|
||||||
"NGC281" => "Sh2-184", // Pac-Man
|
|
||||||
"NGC1333" => "Sh2-241", // Reflection Nebula
|
|
||||||
"NGC1499" => "Sh2-220", // California
|
|
||||||
"NGC2024" => "Sh2-68", // Flame Nebula
|
|
||||||
"NGC2237" => "Sh2-64", // Rosette
|
|
||||||
"NGC3372" => "Sh2-287", // Eta Carinae
|
|
||||||
"NGC6210" => "Sh2-105", // Turtle
|
|
||||||
"NGC6302" => "Sh2-12", // Bug
|
|
||||||
"NGC6357" => "Sh2-11", // War and Peace
|
|
||||||
"NGC6369" => "Sh2-72", // Little Ghost
|
|
||||||
"NGC6611" => "Sh2-16", // Eagle
|
|
||||||
"NGC6720" => "Sh2-83", // Ring
|
|
||||||
"NGC6826" => "Sh2-87", // Blinking
|
|
||||||
"NGC6853" => "Sh2-71", // Dumbbell
|
|
||||||
"NGC6960" => "Sh2-103", // Western Veil
|
|
||||||
"NGC6992" => "Sh2-103", // Eastern Veil
|
|
||||||
"NGC7000" => "Sh2-119", // North America
|
|
||||||
"NGC7009" => "Sh2-84", // Saturn
|
|
||||||
"NGC7027" => "Sh2-107", // Giraffe
|
|
||||||
"NGC7293" => "Sh2-108", // Helix
|
|
||||||
"NGC7380" => "Sh2-142", // Wizard
|
|
||||||
"NGC7635" => "Sh2-162", // Bubble
|
|
||||||
"NGC7662" => "Sh2-120", // Blue Snowball
|
|
||||||
"IC405" => "Sh2-229", // Flaming Star
|
|
||||||
"IC434" => "Sh2-175", // Horsehead
|
|
||||||
"IC1318" => "Sh2-100", // Butterfly
|
|
||||||
"IC1805" => "Sh2-190", // Heart
|
|
||||||
"IC1848" => "Sh2-199", // Soul
|
|
||||||
"IC5070" => "Sh2-126", // Pelican
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let common_name = popular_names
|
|
||||||
.get(sh2_id)
|
|
||||||
.or(popular_names.get(entry.id.as_str()))
|
|
||||||
.copied();
|
|
||||||
|
|
||||||
Some(CatalogEntry {
|
|
||||||
id: sh2_id.to_string(),
|
|
||||||
name: format!("{} ({})", sh2_id, entry.name),
|
|
||||||
common_name: common_name.map(|s| s.to_string()),
|
|
||||||
obj_type: entry.obj_type.clone(),
|
|
||||||
ra_deg: entry.ra_deg,
|
|
||||||
dec_deg: entry.dec_deg,
|
|
||||||
ra_h: entry.ra_h.clone(),
|
|
||||||
dec_dms: entry.dec_dms.clone(),
|
|
||||||
constellation: entry.constellation.clone(),
|
|
||||||
size_arcmin_maj: entry.size_arcmin_maj,
|
|
||||||
size_arcmin_min: entry.size_arcmin_min,
|
|
||||||
pos_angle_deg: entry.pos_angle_deg,
|
|
||||||
mag_v: entry.mag_v,
|
|
||||||
surface_brightness: entry.surface_brightness,
|
|
||||||
hubble_type: entry.hubble_type.clone(),
|
|
||||||
messier_num: None,
|
|
||||||
is_highlight: true, // Sharpless objects are highlights
|
|
||||||
fov_fill_pct: entry.fov_fill_pct,
|
|
||||||
mosaic_flag: entry.mosaic_flag,
|
|
||||||
mosaic_panels_w: entry.mosaic_panels_w,
|
|
||||||
mosaic_panels_h: entry.mosaic_panels_h,
|
|
||||||
difficulty: entry.difficulty,
|
|
||||||
guide_star_density: entry.guide_star_density.clone(),
|
|
||||||
fetched_at: entry.fetched_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
|
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
@@ -257,5 +342,42 @@ pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyh
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
tx.commit().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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
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<PgcRow> = 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<PgcRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("RAJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("DEJ2000")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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::<f64>().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::<f64>().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<PgcRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,5 +169,19 @@ pub fn popular_names() -> HashMap<&'static str, &'static str> {
|
|||||||
m.insert("Sh2-155", "Cave Nebula");
|
m.insert("Sh2-155", "Cave Nebula");
|
||||||
m.insert("Sh2-308", "Dolphin 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
|
m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let text = client
|
||||||
|
.get(VIZIER_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<RcwRow> = 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<RcwRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if header.is_empty() {
|
||||||
|
header = 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<usize> {
|
||||||
|
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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = col_idx("_RA")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("Diam")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().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<RcwRow>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/// Sharpless (Sh2) emission nebula catalog.
|
||||||
|
/// Fetched from VizieR catalog VII/20 (Sharpless 1959).
|
||||||
|
/// These are H II regions not always present in OpenNGC.
|
||||||
|
use anyhow::Context;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords};
|
||||||
|
use crate::catalog::fetch::{format_ra_hms, format_dec_dms};
|
||||||
|
use crate::catalog::popular_names::popular_names;
|
||||||
|
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
|
||||||
|
|
||||||
|
/// VizieR VII/20 — Sharpless Catalog of HII Regions (1959).
|
||||||
|
const VIZIER_SH2_URL: &str =
|
||||||
|
"https://vizier.cds.unistra.fr/viz-bin/asu-tsv\
|
||||||
|
?-source=VII/20\
|
||||||
|
&-out=Sh2\
|
||||||
|
&-out=MajDiam\
|
||||||
|
&-out=_RA\
|
||||||
|
&-out=_DE\
|
||||||
|
&-out.max=1000\
|
||||||
|
&-oc.form=dec";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Sh2Row {
|
||||||
|
id: u32,
|
||||||
|
ra_deg: f64,
|
||||||
|
dec_deg: f64,
|
||||||
|
diam_arcmin: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_sh2() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
|
match fetch_from_vizier().await {
|
||||||
|
Ok(entries) if !entries.is_empty() => {
|
||||||
|
tracing::info!("Sh2: loaded {} entries from VizieR VII/20", entries.len());
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::warn!("Sh2: VizieR returned 0 rows — using hardcoded fallback");
|
||||||
|
Ok(build_entries_from_rows(get_prominent_sh2()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Sh2 fetch from VizieR failed ({}) — using hardcoded fallback", e);
|
||||||
|
Ok(build_entries_from_rows(get_prominent_sh2()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_from_vizier() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let text = client
|
||||||
|
.get(VIZIER_SH2_URL)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("Sh2 fetch request failed")?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("Sh2 response read failed")?;
|
||||||
|
|
||||||
|
tracing::debug!("Sh2 raw response first 500 chars: {}", &text[..text.len().min(500)]);
|
||||||
|
|
||||||
|
let rows = parse_vizier_tsv(&text);
|
||||||
|
tracing::info!("Sh2: parsed {} rows from VizieR VII/20", rows.len());
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
anyhow::bail!("no rows parsed from VizieR Sh2 response");
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = rows.len();
|
||||||
|
let filtered: Vec<Sh2Row> = rows
|
||||||
|
.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!("Sh2: {}/{} rows pass filters", filtered.len(), total);
|
||||||
|
Ok(build_entries_from_rows(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_vizier_tsv(text: &str) -> Vec<Sh2Row> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = Vec::new();
|
||||||
|
let mut past_separator = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.is_empty() {
|
||||||
|
header = if trimmed.contains('\t') {
|
||||||
|
trimmed.split('\t').map(|s| s.trim().to_string()).collect()
|
||||||
|
} else {
|
||||||
|
trimmed.split_whitespace().map(|s| s.to_string()).collect()
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !past_separator {
|
||||||
|
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
|
||||||
|
past_separator = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cols: Vec<&str> = if line.contains('\t') {
|
||||||
|
line.split('\t').map(|s| s.trim()).collect()
|
||||||
|
} else {
|
||||||
|
line.split_whitespace().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if cols.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let col_idx = |name: &str| -> Option<usize> {
|
||||||
|
header.iter().position(|h| h.eq_ignore_ascii_case(name))
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = col_idx("Sh2")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<u32>().ok());
|
||||||
|
|
||||||
|
let diam = col_idx("MajDiam")
|
||||||
|
.or_else(|| col_idx("Diam"))
|
||||||
|
.or_else(|| col_idx("Dmaj"))
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(15.0);
|
||||||
|
|
||||||
|
let ra = col_idx("_RA")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.or_else(|| cols.get(cols.len().saturating_sub(2)).and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.or_else(|| cols.last().and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
|
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
|
||||||
|
rows.push(Sh2Row { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entries_from_rows(rows: Vec<Sh2Row>) -> Vec<CatalogEntry> {
|
||||||
|
let now = Utc::now().timestamp();
|
||||||
|
let names = popular_names();
|
||||||
|
rows.into_iter()
|
||||||
|
.filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 1.0)
|
||||||
|
.map(|r| build_entry(r, now, &names))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entry(r: Sh2Row, now: i64, names: &std::collections::HashMap<&'static str, &'static str>) -> CatalogEntry {
|
||||||
|
let id = format!("Sh2-{}", r.id);
|
||||||
|
let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
|
||||||
|
let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1);
|
||||||
|
let panels_h = ((r.diam_arcmin / FOV_ARCMIN_H).ceil() as i32).max(1);
|
||||||
|
let mosaic = panels_w > 1 || panels_h > 1;
|
||||||
|
let density = guide_star_density_from_coords(r.ra_deg, r.dec_deg);
|
||||||
|
let common_name = names.get(id.as_str()).map(|s| s.to_string());
|
||||||
|
let is_highlight = common_name.is_some();
|
||||||
|
|
||||||
|
CatalogEntry {
|
||||||
|
id: id.clone(),
|
||||||
|
name: id,
|
||||||
|
common_name,
|
||||||
|
obj_type: "emission_nebula".to_string(),
|
||||||
|
ra_deg: r.ra_deg,
|
||||||
|
dec_deg: r.dec_deg,
|
||||||
|
ra_h: format_ra_hms(r.ra_deg),
|
||||||
|
dec_dms: format_dec_dms(r.dec_deg),
|
||||||
|
constellation: None,
|
||||||
|
size_arcmin_maj: Some(r.diam_arcmin),
|
||||||
|
size_arcmin_min: Some(r.diam_arcmin * 0.7),
|
||||||
|
pos_angle_deg: None,
|
||||||
|
mag_v: None,
|
||||||
|
surface_brightness: None,
|
||||||
|
hubble_type: None,
|
||||||
|
messier_num: None,
|
||||||
|
is_highlight,
|
||||||
|
fov_fill_pct: Some(fov_fill),
|
||||||
|
mosaic_flag: mosaic,
|
||||||
|
mosaic_panels_w: panels_w,
|
||||||
|
mosaic_panels_h: panels_h,
|
||||||
|
difficulty: Some(3),
|
||||||
|
guide_star_density: Some(density.to_string()),
|
||||||
|
fetched_at: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hardcoded fallback: ~80 prominent Sharpless HII regions accessible from northern latitudes.
|
||||||
|
fn get_prominent_sh2() -> Vec<Sh2Row> {
|
||||||
|
vec![
|
||||||
|
// Winter / Spring (Orion, Auriga, Gemini, Monoceros, Perseus, Cassiopeia)
|
||||||
|
Sh2Row { id: 1, ra_deg: 0.113, dec_deg: 64.083, diam_arcmin: 20.0 }, // Cas
|
||||||
|
Sh2Row { id: 7, ra_deg: 269.533, dec_deg: -23.450, diam_arcmin: 80.0 }, // Sgr
|
||||||
|
Sh2Row { id: 9, ra_deg: 253.783, dec_deg: -34.350, diam_arcmin: 60.0 }, // Sco
|
||||||
|
Sh2Row { id: 11, ra_deg: 264.167, dec_deg: -34.483, diam_arcmin: 80.0 }, // War & Peace
|
||||||
|
Sh2Row { id: 16, ra_deg: 274.683, dec_deg: -13.783, diam_arcmin: 60.0 }, // Eagle region
|
||||||
|
Sh2Row { id: 17, ra_deg: 275.000, dec_deg: -15.200, diam_arcmin: 40.0 }, // Sgr
|
||||||
|
Sh2Row { id: 25, ra_deg: 274.000, dec_deg: -23.833, diam_arcmin: 120.0 }, // Lagoon region
|
||||||
|
Sh2Row { id: 27, ra_deg: 84.917, dec_deg: 9.333, diam_arcmin: 370.0 }, // λ Ori ring
|
||||||
|
Sh2Row { id: 29, ra_deg: 18.867, dec_deg: 61.533, diam_arcmin: 12.0 }, // Cas
|
||||||
|
Sh2Row { id: 36, ra_deg: 82.550, dec_deg: 4.033, diam_arcmin: 8.0 }, // Ori
|
||||||
|
Sh2Row { id: 64, ra_deg: 98.067, dec_deg: 4.967, diam_arcmin: 80.0 }, // Rosette
|
||||||
|
Sh2Row { id: 68, ra_deg: 86.583, dec_deg: -1.950, diam_arcmin: 30.0 }, // Flame-adjacent
|
||||||
|
Sh2Row { id: 100, ra_deg: 305.967, dec_deg: 35.817, diam_arcmin: 180.0 }, // γ Cyg / Butterfly
|
||||||
|
Sh2Row { id: 101, ra_deg: 296.867, dec_deg: 35.417, diam_arcmin: 20.0 }, // Tulip
|
||||||
|
Sh2Row { id: 103, ra_deg: 311.283, dec_deg: 31.717, diam_arcmin: 230.0 }, // Veil Complex
|
||||||
|
Sh2Row { id: 106, ra_deg: 304.883, dec_deg: 37.367, diam_arcmin: 12.0 }, // Cygnus
|
||||||
|
Sh2Row { id: 108, ra_deg: 337.417, dec_deg: -21.933, diam_arcmin: 1200.0 }, // Helix (huge)
|
||||||
|
Sh2Row { id: 119, ra_deg: 315.617, dec_deg: 44.250, diam_arcmin: 180.0 }, // North America
|
||||||
|
Sh2Row { id: 126, ra_deg: 316.833, dec_deg: 44.533, diam_arcmin: 90.0 }, // Pelican
|
||||||
|
Sh2Row { id: 129, ra_deg: 328.150, dec_deg: 60.050, diam_arcmin: 140.0 }, // Flying Bat
|
||||||
|
Sh2Row { id: 132, ra_deg: 336.550, dec_deg: 56.133, diam_arcmin: 100.0 }, // Lion
|
||||||
|
Sh2Row { id: 140, ra_deg: 336.550, dec_deg: 63.183, diam_arcmin: 10.0 }, // Cepheus SFR
|
||||||
|
Sh2Row { id: 142, ra_deg: 341.467, dec_deg: 58.433, diam_arcmin: 12.0 }, // Wizard region
|
||||||
|
Sh2Row { id: 155, ra_deg: 344.983, dec_deg: 62.383, diam_arcmin: 50.0 }, // Cave
|
||||||
|
Sh2Row { id: 157, ra_deg: 350.817, dec_deg: 60.867, diam_arcmin: 60.0 }, // Lobster Claw
|
||||||
|
Sh2Row { id: 162, ra_deg: 350.183, dec_deg: 61.217, diam_arcmin: 15.0 }, // Bubble
|
||||||
|
Sh2Row { id: 163, ra_deg: 353.383, dec_deg: 61.117, diam_arcmin: 25.0 }, // Cas
|
||||||
|
Sh2Row { id: 168, ra_deg: 358.133, dec_deg: 61.383, diam_arcmin: 60.0 }, // Cas
|
||||||
|
Sh2Row { id: 171, ra_deg: 0.500, dec_deg: 67.833, diam_arcmin: 40.0 }, // Cep
|
||||||
|
Sh2Row { id: 175, ra_deg: 85.250, dec_deg: -2.450, diam_arcmin: 40.0 }, // Horsehead region
|
||||||
|
Sh2Row { id: 184, ra_deg: 13.533, dec_deg: 56.617, diam_arcmin: 35.0 }, // Pac-Man
|
||||||
|
Sh2Row { id: 188, ra_deg: 17.633, dec_deg: 58.783, diam_arcmin: 15.0 }, // Cas
|
||||||
|
Sh2Row { id: 190, ra_deg: 38.317, dec_deg: 61.450, diam_arcmin: 100.0 }, // Heart
|
||||||
|
Sh2Row { id: 199, ra_deg: 40.433, dec_deg: 60.517, diam_arcmin: 150.0 }, // Soul
|
||||||
|
Sh2Row { id: 206, ra_deg: 55.617, dec_deg: 19.917, diam_arcmin: 30.0 }, // Per
|
||||||
|
Sh2Row { id: 207, ra_deg: 56.583, dec_deg: 23.000, diam_arcmin: 15.0 }, // Per
|
||||||
|
Sh2Row { id: 212, ra_deg: 73.617, dec_deg: 44.217, diam_arcmin: 40.0 }, // Aur
|
||||||
|
Sh2Row { id: 219, ra_deg: 79.283, dec_deg: 45.150, diam_arcmin: 30.0 }, // Aur
|
||||||
|
Sh2Row { id: 220, ra_deg: 60.583, dec_deg: 36.417, diam_arcmin: 360.0 }, // California
|
||||||
|
Sh2Row { id: 223, ra_deg: 51.800, dec_deg: 60.067, diam_arcmin: 30.0 }, // Cas
|
||||||
|
Sh2Row { id: 224, ra_deg: 53.833, dec_deg: 60.700, diam_arcmin: 25.0 }, // Cas
|
||||||
|
Sh2Row { id: 229, ra_deg: 82.750, dec_deg: 34.317, diam_arcmin: 80.0 }, // Flaming Star
|
||||||
|
Sh2Row { id: 232, ra_deg: 86.117, dec_deg: 33.450, diam_arcmin: 30.0 }, // Aur
|
||||||
|
Sh2Row { id: 234, ra_deg: 90.133, dec_deg: 37.283, diam_arcmin: 15.0 }, // Aur
|
||||||
|
Sh2Row { id: 235, ra_deg: 92.383, dec_deg: 36.633, diam_arcmin: 10.0 }, // Aur
|
||||||
|
Sh2Row { id: 240, ra_deg: 92.683, dec_deg: 27.767, diam_arcmin: 25.0 }, // Per
|
||||||
|
Sh2Row { id: 241, ra_deg: 53.417, dec_deg: 31.500, diam_arcmin: 10.0 }, // Per
|
||||||
|
Sh2Row { id: 252, ra_deg: 99.500, dec_deg: 17.983, diam_arcmin: 60.0 }, // Monkey Head
|
||||||
|
Sh2Row { id: 254, ra_deg: 98.233, dec_deg: 15.833, diam_arcmin: 10.0 }, // Mon
|
||||||
|
Sh2Row { id: 261, ra_deg: 107.417, dec_deg: -1.167, diam_arcmin: 40.0 }, // Mon
|
||||||
|
Sh2Row { id: 273, ra_deg: 117.750, dec_deg: -10.117, diam_arcmin: 20.0 }, // CMa
|
||||||
|
Sh2Row { id: 274, ra_deg: 113.567, dec_deg: 10.050, diam_arcmin: 30.0 }, // Gem / Mon
|
||||||
|
Sh2Row { id: 275, ra_deg: 115.617, dec_deg: -11.317, diam_arcmin: 50.0 }, // CMa
|
||||||
|
Sh2Row { id: 277, ra_deg: 119.083, dec_deg: -9.233, diam_arcmin: 35.0 }, // CMa
|
||||||
|
Sh2Row { id: 280, ra_deg: 128.600, dec_deg: -17.617, diam_arcmin: 50.0 }, // Pup
|
||||||
|
Sh2Row { id: 284, ra_deg: 126.883, dec_deg: -3.333, diam_arcmin: 20.0 }, // Mon
|
||||||
|
Sh2Row { id: 287, ra_deg: 161.333, dec_deg: -59.883, diam_arcmin: 240.0 },// Eta Carina
|
||||||
|
Sh2Row { id: 289, ra_deg: 131.217, dec_deg: -39.467, diam_arcmin: 80.0 }, // Pup
|
||||||
|
Sh2Row { id: 292, ra_deg: 135.617, dec_deg: -23.350, diam_arcmin: 40.0 }, // Pup
|
||||||
|
// Summer (Sagittarius, Scorpius, Aquila, Cygnus, Vulpecula)
|
||||||
|
Sh2Row { id: 302, ra_deg: 186.967, dec_deg: -62.617, diam_arcmin: 80.0 }, // Cru
|
||||||
|
Sh2Row { id: 308, ra_deg: 107.800, dec_deg: -14.683, diam_arcmin: 40.0 }, // Dolphin
|
||||||
|
// Extra Sh2 objects with known popular names
|
||||||
|
Sh2Row { id: 71, ra_deg: 302.800, dec_deg: 22.717, diam_arcmin: 8.0 }, // Dumbbell PN area
|
||||||
|
Sh2Row { id: 72, ra_deg: 271.967, dec_deg: -22.533, diam_arcmin: 6.0 }, // Little Ghost area
|
||||||
|
Sh2Row { id: 83, ra_deg: 283.400, dec_deg: 33.033, diam_arcmin: 4.0 }, // Ring PN area
|
||||||
|
Sh2Row { id: 87, ra_deg: 298.733, dec_deg: 50.517, diam_arcmin: 5.0 }, // Blinking PN area
|
||||||
|
Sh2Row { id: 105, ra_deg: 280.650, dec_deg: 23.533, diam_arcmin: 3.0 }, // Turtle PN area
|
||||||
|
Sh2Row { id: 107, ra_deg: 307.483, dec_deg: 42.133, diam_arcmin: 2.0 }, // Giraffe PN area
|
||||||
|
Sh2Row { id: 12, ra_deg: 260.583, dec_deg: -37.100, diam_arcmin: 12.0 }, // Bug PN area
|
||||||
|
Sh2Row { id: 84, ra_deg: 321.033, dec_deg: -11.367, diam_arcmin: 3.0 }, // Saturn PN area
|
||||||
|
]
|
||||||
|
}
|
||||||
+124
-31
@@ -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::catalog::fetch::{format_ra_hms, format_dec_dms};
|
||||||
use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W};
|
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 =
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
struct VdbRow {
|
struct VdbRow {
|
||||||
id: u32,
|
id: u32,
|
||||||
@@ -20,20 +30,28 @@ struct VdbRow {
|
|||||||
pub async fn fetch_vdb() -> anyhow::Result<Vec<CatalogEntry>> {
|
pub async fn fetch_vdb() -> anyhow::Result<Vec<CatalogEntry>> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(60))
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.user_agent("astronome/1.0")
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let text = client
|
let text = client
|
||||||
.get(VIZIER_VDB_URL)
|
.get(VIZIER_VDB_URL)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("VdB fetch failed")?
|
.context("VdB fetch request failed")?
|
||||||
.text()
|
.text()
|
||||||
.await
|
.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);
|
let rows = parse_vizier_tsv(&text);
|
||||||
tracing::info!("Parsed {} VdB rows from VizieR", rows.len());
|
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 now = Utc::now().timestamp();
|
||||||
let filtered: Vec<_> = rows
|
let filtered: Vec<_> = rows
|
||||||
.iter()
|
.iter()
|
||||||
@@ -44,63 +62,85 @@ pub async fn fetch_vdb() -> anyhow::Result<Vec<CatalogEntry>> {
|
|||||||
|
|
||||||
let entries = filtered
|
let entries = filtered
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| build_entry(r.clone(), now))
|
.map(|r| build_entry(r, now))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(entries)
|
Ok(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse VizieR TSV output (tab-separated, `#` comment lines, dashes separator).
|
||||||
fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
|
fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut header: Vec<String> = Vec::new();
|
let mut header: Vec<String> = Vec::new();
|
||||||
let mut found_separator = false;
|
let mut past_separator = false;
|
||||||
|
|
||||||
for (_line_num, line) in text.lines().enumerate() {
|
for line in text.lines() {
|
||||||
// Skip comment/meta lines
|
// Skip VizieR metadata/comment lines
|
||||||
if line.starts_with('#') {
|
if line.starts_with('#') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let line = line.trim();
|
let trimmed = line.trim();
|
||||||
if line.is_empty() {
|
if trimmed.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First non-comment line is the header
|
// First non-comment, non-empty line is the header
|
||||||
if header.is_empty() {
|
if header.is_empty() {
|
||||||
header = line.split_whitespace().map(|s| s.to_string()).collect();
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip separator line (dashes)
|
// Skip the separator/units row (lines of dashes)
|
||||||
if !found_separator && line.starts_with("---") {
|
if !past_separator {
|
||||||
found_separator = true;
|
if trimmed.starts_with("---") || trimmed.chars().all(|c| c == '-' || c == '\t' || c == ' ') {
|
||||||
|
past_separator = true;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip unit rows (blank entries or description)
|
// Parse data row — try tab first, then whitespace
|
||||||
if !found_separator {
|
let cols: Vec<&str> = if line.contains('\t') {
|
||||||
continue;
|
line.split('\t').map(|s| s.trim()).collect()
|
||||||
}
|
} else {
|
||||||
|
line.split_whitespace().collect()
|
||||||
|
};
|
||||||
|
|
||||||
let cols: Vec<&str> = line.split_whitespace().collect();
|
|
||||||
if cols.len() < 2 {
|
if cols.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For VizieR TSV output, the last two columns are always _RA and _DE
|
let col_idx = |name: &str| -> Option<usize> {
|
||||||
// Extract VdB ID from first column
|
header.iter().position(|h| h.eq_ignore_ascii_case(name))
|
||||||
let id = cols.get(0)
|
};
|
||||||
.and_then(|s| s.trim().parse::<u32>().ok());
|
|
||||||
|
|
||||||
let ra = cols.get(cols.len() - 2)
|
// Look up by header name; fall back to positional for _RA/_DE (always appended last)
|
||||||
.and_then(|s| s.trim().parse::<f64>().ok());
|
let id = col_idx("VdB")
|
||||||
let dec = cols.get(cols.len() - 1)
|
.and_then(|i| cols.get(i))
|
||||||
.and_then(|s| s.trim().parse::<f64>().ok());
|
.and_then(|s| s.parse::<u32>().ok());
|
||||||
|
|
||||||
// VizieR doesn't provide diameter in standard output; estimate from visibility
|
let diam = col_idx("Diam")
|
||||||
// Use a conservative default of ~10 arcmin for all VdB objects
|
.and_then(|i| cols.get(i))
|
||||||
let diam = 10.0;
|
.and_then(|s| s.parse::<f64>().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::<f64>().ok())
|
||||||
|
.or_else(|| cols.get(cols.len().saturating_sub(2)).and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
|
let dec = col_idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.or_else(|| cols.last().and_then(|s| s.parse().ok()));
|
||||||
|
|
||||||
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
|
if let (Some(id), Some(ra), Some(dec)) = (id, ra, dec) {
|
||||||
rows.push(VdbRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
|
rows.push(VdbRow { id, ra_deg: ra, dec_deg: dec, diam_arcmin: diam });
|
||||||
@@ -109,7 +149,7 @@ fn parse_vizier_tsv(text: &str) -> Vec<VdbRow> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_entry(r: VdbRow, now: i64) -> CatalogEntry {
|
fn build_entry(r: &VdbRow, now: i64) -> CatalogEntry {
|
||||||
let id = format!("VdB{}", r.id);
|
let id = format!("VdB{}", r.id);
|
||||||
let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0;
|
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_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,
|
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<CatalogEntry> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub async fn init_db(database_url: &str) -> anyhow::Result<SqlitePool> {
|
|||||||
.context("failed to connect to SQLite")?;
|
.context("failed to connect to SQLite")?;
|
||||||
|
|
||||||
run_schema(&pool).await?;
|
run_schema(&pool).await?;
|
||||||
|
run_migrations(&pool).await?;
|
||||||
seed_horizon(&pool).await?;
|
seed_horizon(&pool).await?;
|
||||||
|
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
@@ -34,6 +35,26 @@ async fn run_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
async fn seed_horizon(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon")
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon")
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS nightly_cache (
|
|||||||
moon_sep_deg REAL,
|
moon_sep_deg REAL,
|
||||||
recommended_filter TEXT,
|
recommended_filter TEXT,
|
||||||
visibility_json TEXT,
|
visibility_json TEXT,
|
||||||
|
is_visible_tonight INTEGER DEFAULT 0,
|
||||||
PRIMARY KEY (catalog_id, night_date)
|
PRIMARY KEY (catalog_id, night_date)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -125,8 +125,9 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::
|
|||||||
r#"INSERT OR REPLACE INTO nightly_cache
|
r#"INSERT OR REPLACE INTO nightly_cache
|
||||||
(catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc,
|
(catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc,
|
||||||
best_start_utc, best_end_utc, usable_min, meridian_flip_utc,
|
best_start_utc, best_end_utc, usable_min, meridian_flip_utc,
|
||||||
airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, visibility_json)
|
airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
visibility_json, is_visible_tonight)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
|
||||||
)
|
)
|
||||||
.bind(&obj.id)
|
.bind(&obj.id)
|
||||||
.bind(&date_str)
|
.bind(&date_str)
|
||||||
@@ -143,6 +144,7 @@ pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::
|
|||||||
.bind(vis.moon_sep_deg)
|
.bind(vis.moon_sep_deg)
|
||||||
.bind(&rec_filter)
|
.bind(&rec_filter)
|
||||||
.bind(&vis_json)
|
.bind(&vis_json)
|
||||||
|
.bind(vis.is_visible_tonight as i32)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -211,8 +213,8 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R
|
|||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"INSERT OR IGNORE INTO nightly_cache
|
r#"INSERT OR IGNORE INTO nightly_cache
|
||||||
(catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter)
|
(catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter, is_visible_tonight)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)"#,
|
VALUES (?, ?, ?, ?, ?, ?, ?)"#,
|
||||||
)
|
)
|
||||||
.bind(&obj.id)
|
.bind(&obj.id)
|
||||||
.bind(&date_str)
|
.bind(&date_str)
|
||||||
@@ -220,6 +222,7 @@ async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::R
|
|||||||
.bind(vis.transit_utc.map(|t: DateTime<Utc>| t.to_rfc3339()))
|
.bind(vis.transit_utc.map(|t: DateTime<Utc>| t.to_rfc3339()))
|
||||||
.bind(vis.usable_min as i32)
|
.bind(vis.usable_min as i32)
|
||||||
.bind(&rec_filter)
|
.bind(&rec_filter)
|
||||||
|
.bind(vis.is_visible_tonight as i32)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/targets" element={<Targets />} />
|
<Route path="/targets" element={<Targets />} />
|
||||||
|
<Route path="/targets/:targetId" element={<Targets />} />
|
||||||
<Route path="/calendar" element={<Calendar />} />
|
<Route path="/calendar" element={<Calendar />} />
|
||||||
<Route path="/stats" element={<Stats />} />
|
<Route path="/stats" element={<Stats />} />
|
||||||
<Route path="/gallery" element={<Gallery />} />
|
<Route path="/gallery" element={<Gallery />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
HorizonPoint,
|
HorizonPoint,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
Phd2Log,
|
Phd2Log,
|
||||||
|
SimilarTarget,
|
||||||
Stats,
|
Stats,
|
||||||
Target,
|
Target,
|
||||||
TargetNotes,
|
TargetNotes,
|
||||||
@@ -66,6 +67,7 @@ export interface TargetsParams {
|
|||||||
min_usable_min?: number;
|
min_usable_min?: number;
|
||||||
mosaic_only?: boolean;
|
mosaic_only?: boolean;
|
||||||
not_imaged?: boolean;
|
not_imaged?: boolean;
|
||||||
|
show_custom?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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.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.mosaic_only) q.set('mosaic_only', 'true');
|
||||||
if (params.not_imaged) q.set('not_imaged', 'true');
|
if (params.not_imaged) q.set('not_imaged', 'true');
|
||||||
|
if (params.show_custom === false) q.set('show_custom', 'false');
|
||||||
return get<TargetsResponse>(`/targets?${q}`);
|
return get<TargetsResponse>(`/targets?${q}`);
|
||||||
},
|
},
|
||||||
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
get: (id: string): Promise<Target> => 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`),
|
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<TargetNotes> => get(`/targets/${id}/notes`),
|
getNotes: (id: string): Promise<TargetNotes> => get(`/targets/${id}/notes`),
|
||||||
putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { 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: {
|
tonight: {
|
||||||
@@ -107,6 +111,10 @@ export const api = {
|
|||||||
get(`/calendar/${date}`),
|
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 }[] }[] }> =>
|
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'),
|
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: {
|
weather: {
|
||||||
@@ -134,7 +142,11 @@ export const api = {
|
|||||||
delete: (id: number): Promise<{ status: string; id: number }> => del(`/phd2/${id}`),
|
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 }> => {
|
upload: (formData: FormData): Promise<{ id: number; duplicate: boolean; duplicate_id?: number; analysis: unknown; message?: string }> => {
|
||||||
return fetch(`${base}/phd2/upload`, { method: 'POST', body: formData })
|
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}`),
|
delete: (id: number): Promise<{ id: number }> => del(`/gallery/item/${id}`),
|
||||||
upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => {
|
upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => {
|
||||||
return fetch(`${base}/gallery/${catalogId}`, { method: 'POST', body: formData })
|
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 };
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface Target {
|
|||||||
surface_brightness?: number;
|
surface_brightness?: number;
|
||||||
hubble_type?: string;
|
hubble_type?: string;
|
||||||
messier_num?: number;
|
messier_num?: number;
|
||||||
|
caldwell_num?: number;
|
||||||
|
arp_num?: number;
|
||||||
is_highlight: boolean;
|
is_highlight: boolean;
|
||||||
fov_fill_pct?: number;
|
fov_fill_pct?: number;
|
||||||
mosaic_flag: boolean;
|
mosaic_flag: boolean;
|
||||||
@@ -32,6 +34,8 @@ export interface Target {
|
|||||||
moon_sep_deg?: number;
|
moon_sep_deg?: number;
|
||||||
is_visible_tonight?: boolean;
|
is_visible_tonight?: boolean;
|
||||||
total_integration_min?: number;
|
total_integration_min?: number;
|
||||||
|
is_custom?: boolean;
|
||||||
|
urgency?: 'peak' | 'rising' | 'declining' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetsResponse {
|
export interface TargetsResponse {
|
||||||
@@ -164,6 +168,31 @@ export interface HorizonPoint {
|
|||||||
alt_deg: number;
|
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 {
|
export interface Stats {
|
||||||
total_sessions: number;
|
total_sessions: number;
|
||||||
total_integration_min: number;
|
total_integration_min: number;
|
||||||
@@ -174,6 +203,10 @@ export interface Stats {
|
|||||||
quality: { quality: string; count: number }[];
|
quality: { quality: string; count: number }[];
|
||||||
top_targets: { id: string; name: string; common_name?: string; obj_type: string; sessions: number; total_min: 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 }[];
|
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 {
|
export interface Workflow {
|
||||||
@@ -230,8 +263,32 @@ export interface TargetNotes {
|
|||||||
notes: string;
|
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 {
|
export interface FilterBreakdownItem {
|
||||||
filter_id: string;
|
filter_id: string;
|
||||||
total_min: number;
|
total_min: number;
|
||||||
sessions: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { useState } from 'react';
|
||||||
ComposedChart,
|
|
||||||
Bar,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Cell,
|
|
||||||
} from 'recharts';
|
|
||||||
|
|
||||||
interface YearPoint {
|
interface YearPoint {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -24,107 +14,145 @@ interface Props {
|
|||||||
|
|
||||||
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
function altColor(alt: number): string {
|
function altColorHex(alt: number): string {
|
||||||
if (alt >= 50) return 'var(--good)';
|
if (alt >= 50) return '#3dba72';
|
||||||
if (alt >= 30) return '#2ab8a0';
|
if (alt >= 30) return '#2ab8a0';
|
||||||
if (alt >= 15) return 'var(--warn)';
|
if (alt >= 15) return '#e8c030';
|
||||||
return 'var(--muted)';
|
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 (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<svg width={svgW} height={svgH} style={{ display: 'block', overflow: 'visible' }}>
|
||||||
|
{rows.map((row, ri) => (
|
||||||
|
<g key={ri} transform={`translate(0, ${ri * (CELL + GAP)})`}>
|
||||||
|
<text
|
||||||
|
x={LABEL_W - 4}
|
||||||
|
y={CELL - 2}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="var(--text-lo)"
|
||||||
|
fontSize={9}
|
||||||
|
fontFamily="IBM Plex Mono"
|
||||||
|
>
|
||||||
|
{MONTH_ABBR[row.month]}
|
||||||
|
</text>
|
||||||
|
{row.days.map((pt, di) => {
|
||||||
|
const x = LABEL_W + di * (CELL + GAP);
|
||||||
|
const isToday = pt?.date === today;
|
||||||
|
if (!pt) {
|
||||||
|
return (
|
||||||
|
<rect key={di} x={x} y={0} width={CELL} height={CELL}
|
||||||
|
fill="#111520" rx={2} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const color = altColorHex(pt.alt_at_midnight);
|
||||||
|
const moonAlpha = Math.round(pt.moon_illumination * 60);
|
||||||
|
return (
|
||||||
|
<g key={di}>
|
||||||
|
<rect x={x} y={0} width={CELL} height={CELL} fill={color} rx={2} opacity={0.85} />
|
||||||
|
{/* Moon overlay — blue tint proportional to illumination */}
|
||||||
|
<rect x={x} y={0} width={CELL} height={CELL}
|
||||||
|
fill={`rgba(77,157,224,${(moonAlpha / 255).toFixed(2)})`} rx={2} />
|
||||||
|
{isToday && (
|
||||||
|
<rect x={x} y={0} width={CELL} height={CELL}
|
||||||
|
fill="none" stroke="var(--amber)" strokeWidth={1.5} rx={2} />
|
||||||
|
)}
|
||||||
|
<rect x={x} y={0} width={CELL} height={CELL} fill="transparent" rx={2}
|
||||||
|
onMouseEnter={e => setTooltip({ x: x + CELL + 4, y: ri * (CELL + GAP), point: pt })}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{tooltip && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: tooltip.x,
|
||||||
|
top: tooltip.y,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '5px 8px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: 'var(--text-mid)', marginBottom: 2 }}>{tooltip.point.date}</div>
|
||||||
|
<div>Alt: <span style={{ color: altColorHex(tooltip.point.alt_at_midnight) }}>{tooltip.point.alt_at_midnight.toFixed(1)}°</span></div>
|
||||||
|
<div>Usable: {(tooltip.point.usable_min / 60).toFixed(1)}h</div>
|
||||||
|
<div>Moon: {Math.round(tooltip.point.moon_illumination * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function YearlyVisibility({ points }: Props) {
|
export default function YearlyVisibility({ points }: Props) {
|
||||||
if (!points.length) return null;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
|
||||||
ALTITUDE AT MIDNIGHT — next 12 months (varies as transit shifts through seasons)
|
SEASONAL VISIBILITY — next 12 months · altitude at local midnight
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: '100%', height: 160 }}>
|
|
||||||
<ResponsiveContainer>
|
<CalendarHeatmap points={points} />
|
||||||
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -18 }}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
<div style={{ display: 'flex', gap: 14, marginTop: 8, flexWrap: 'wrap' }}>
|
||||||
<XAxis
|
|
||||||
dataKey="label"
|
|
||||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
|
||||||
interval={Math.floor(data.length / 12)}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="alt"
|
|
||||||
domain={[0, 90]}
|
|
||||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={v => `${v}°`}
|
|
||||||
width={32}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
yAxisId="moon"
|
|
||||||
orientation="right"
|
|
||||||
domain={[0, 100]}
|
|
||||||
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={v => `${v}%`}
|
|
||||||
width={28}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
background: 'var(--bg-panel)',
|
|
||||||
border: '1px solid var(--border-hi)',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontFamily: 'IBM Plex Mono',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--text-hi)',
|
|
||||||
}}
|
|
||||||
formatter={(value: number, name: string) => {
|
|
||||||
if (name === 'alt') return [`${value}°`, 'Alt at midnight'];
|
|
||||||
if (name === 'moon') return [`${value}%`, 'Moon'];
|
|
||||||
return [value, name];
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar yAxisId="alt" dataKey="alt" radius={[1, 1, 0, 0]} maxBarSize={12}>
|
|
||||||
{data.map((entry, i) => (
|
|
||||||
<Cell key={i} fill={altColor(entry.alt)} fillOpacity={0.7} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
<Line
|
|
||||||
yAxisId="moon"
|
|
||||||
type="monotone"
|
|
||||||
dataKey="moon"
|
|
||||||
stroke="#4d9de0"
|
|
||||||
strokeWidth={1}
|
|
||||||
dot={false}
|
|
||||||
strokeOpacity={0.5}
|
|
||||||
strokeDasharray="3 2"
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
|
||||||
{[
|
{[
|
||||||
{ color: 'var(--good)', label: '≥50° excellent' },
|
{ color: '#3dba72', label: '≥50° excellent' },
|
||||||
{ color: '#2ab8a0', label: '30–50° good' },
|
{ color: '#2ab8a0', label: '30–50° good' },
|
||||||
{ color: 'var(--warn)', label: '15–30° marginal' },
|
{ color: '#e8c030', label: '15–30° marginal' },
|
||||||
{ color: 'var(--muted)', label: '<15° poor' },
|
{ color: '#3a4258', label: '<15° poor' },
|
||||||
{ color: '#4d9de0', label: 'Moon %' },
|
{ color: 'rgba(77,157,224,0.6)', label: 'Moon overlay' },
|
||||||
].map(({ color, label }) => (
|
].map(({ color, label }) => (
|
||||||
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 10, height: 10, background: color, borderRadius: 2, opacity: 0.8 }} />
|
<div style={{ width: 10, height: 10, background: color, borderRadius: 2 }} />
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
const FILTER_COLORS: Record<string, string> = {
|
||||||
|
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<PlanEntry[]>([]);
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
{/* Add targets search bar */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => 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 && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 4, marginTop: 2, maxHeight: 200, overflowY: 'auto' }}>
|
||||||
|
{filtered.map(t => (
|
||||||
|
<div key={t.id}
|
||||||
|
onClick={() => addTarget(t)}
|
||||||
|
style={{ padding: '6px 12px', cursor: 'pointer', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 10 }}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', flex: 1 }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6, fontSize: 10 }}>{t.name}</span>}
|
||||||
|
</span>
|
||||||
|
{t.max_alt_deg != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>{t.max_alt_deg.toFixed(0)}°</span>
|
||||||
|
)}
|
||||||
|
{t.recommended_filter && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: FILTER_COLORS[t.recommended_filter] ?? 'var(--muted)' }}>
|
||||||
|
{FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan list + Gantt */}
|
||||||
|
{plan.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '16px 0' }}>
|
||||||
|
Search and add targets above to build your plan.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Total / overrun warning */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 12 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: overrun ? 'var(--danger)' : 'var(--good)' }}>
|
||||||
|
{overrun ? '⚠ ' : ''}Total: {Math.floor(totalMinutes / 60)}h {totalMinutes % 60}m / {Math.floor(nightMinutes / 60)}h {nightMinutes % 60}m night
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={exportText}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: exported ? 'var(--good)' : 'var(--text-lo)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 10px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{exported ? '✓ Copied' : '↓ Copy run order'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline header */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 32px 32px', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div style={{ position: 'relative', height: 14 }}>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map(frac => (
|
||||||
|
<span key={frac} style={{ position: 'absolute', left: `${frac * 100}%`, transform: 'translateX(-50%)', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)' }}>
|
||||||
|
{new Date(duskMs + frac * nightMs).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div key={target.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 32px 32px', gap: 8, marginBottom: 8, alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: warn ? 'var(--warn)' : 'var(--text-mid)', marginBottom: 3 }}>
|
||||||
|
{target.common_name ?? target.name}
|
||||||
|
{warn && <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--warn)' }}>
|
||||||
|
{pastDawn ? '⚠ past dawn' : '⚠ past window'}
|
||||||
|
</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative', height: 16, background: 'var(--bg-deep)', borderRadius: 3 }}>
|
||||||
|
{/* Target's visibility window */}
|
||||||
|
{target.best_start_utc && target.best_end_utc && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${Math.max(0, (new Date(target.best_start_utc).getTime() - duskMs) / nightMs * 100)}%`,
|
||||||
|
width: `${Math.min(100, (new Date(target.best_end_utc).getTime() - new Date(target.best_start_utc).getTime()) / nightMs * 100)}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: filterColor,
|
||||||
|
opacity: 0.15,
|
||||||
|
borderRadius: 3,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{/* Planned block */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: warn ? 'var(--danger)' : filterColor,
|
||||||
|
borderRadius: 3,
|
||||||
|
opacity: 0.8,
|
||||||
|
}} />
|
||||||
|
{/* Start/end times */}
|
||||||
|
<div style={{ position: 'absolute', left: `${left + 0.5}%`, top: 1, fontFamily: 'var(--font-mono)', fontSize: 8, color: '#fff', zIndex: 1 }}>
|
||||||
|
{fmtTime(new Date(startMs).toISOString())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Duration input */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={480}
|
||||||
|
step={15}
|
||||||
|
value={durationMin}
|
||||||
|
onChange={e => 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' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>m</span>
|
||||||
|
</div>
|
||||||
|
{/* Move up/down */}
|
||||||
|
<button onClick={() => moveUp(i)} disabled={i === 0} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 4px', cursor: 'pointer', opacity: i === 0 ? 0.3 : 1 }}>↑</button>
|
||||||
|
{/* Remove */}
|
||||||
|
<button onClick={() => removeTarget(target.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--danger)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 4px', cursor: 'pointer' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string[]>(() => 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 (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: `1px solid ${allDone ? 'var(--good)' : 'var(--border)'}`, borderRadius: 6, overflow: 'hidden', transition: 'border-color 0.2s' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '10px 14px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', flex: 1 }}>
|
||||||
|
Pre-session Checklist
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: allDone ? 'var(--good)' : done > 0 ? 'var(--warn)' : 'var(--text-lo)',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{allDone ? '✓ Ready' : `${done}/${total}`}
|
||||||
|
</span>
|
||||||
|
<div style={{ width: 60, height: 4, background: 'var(--bg-hover)', borderRadius: 2, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${(done / total) * 100}%`, height: '100%', background: allDone ? 'var(--good)' : 'var(--warn)', borderRadius: 2, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'var(--text-lo)', fontSize: 11 }}>{expanded ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', padding: '10px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{ITEMS.map(item => {
|
||||||
|
const isChecked = checked.includes(item.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={item.id}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={() => toggle(item.id)}
|
||||||
|
style={{
|
||||||
|
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
|
||||||
|
border: `1.5px solid ${isChecked ? 'var(--good)' : 'var(--border-hi)'}`,
|
||||||
|
background: isChecked ? 'var(--good)' : 'var(--bg-deep)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
|
||||||
|
<path d="M1 4L3.5 6.5L9 1" stroke="#080a0f" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||||
|
color: isChecked ? 'var(--text-mid)' : 'var(--text-hi)',
|
||||||
|
textDecoration: isChecked ? 'line-through' : 'none',
|
||||||
|
transition: 'color 0.15s',
|
||||||
|
}}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => { setChecked([]); saveState(duskDate, []); }}
|
||||||
|
style={{
|
||||||
|
marginTop: 4, alignSelf: 'flex-start',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: 'var(--text-lo)', background: 'none',
|
||||||
|
border: '1px solid var(--border)', borderRadius: 3,
|
||||||
|
padding: '2px 8px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, Record<string, number>> = {
|
||||||
|
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<string, string> = {
|
||||||
|
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUITABILITY_COLOR: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 3 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{FILTER_LABELS[filter ?? ''] ?? 'Primary filter'} goal
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color }}>
|
||||||
|
{fmtMin(keeperMin)} / {fmtMin(goalMin)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 5, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: color, borderRadius: 3, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<TypeBadge type={target.obj_type} />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 700, color: 'var(--amber)' }}>
|
||||||
|
{target.common_name ?? target.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{target.common_name && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>{target.name}</div>
|
||||||
|
)}
|
||||||
|
{target.constellation && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginTop: 2 }}>
|
||||||
|
{target.constellation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Altitude curve */}
|
||||||
|
{dusk && dawn && curveData?.curve.length ? (
|
||||||
|
<AltitudeCurve
|
||||||
|
curve={curveData.curve}
|
||||||
|
dusk={dusk}
|
||||||
|
dawn={dawn}
|
||||||
|
trueDarkStart={tonight?.true_dark_start_utc}
|
||||||
|
trueDarkEnd={tonight?.true_dark_end_utc}
|
||||||
|
moonSepDeg={target.moon_sep_deg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: 240, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
No curve available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Key stats */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 12px', margin: '12px 0', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span style={{ color: 'var(--text-lo)' }}>{label}</span>
|
||||||
|
<span style={{ color: 'var(--text-hi)' }}>{val}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter recommendations */}
|
||||||
|
{topFilters.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
|
||||||
|
Filters tonight
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{topFilters.map(f => (
|
||||||
|
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
background: SUITABILITY_COLOR[f.suitability] + '22',
|
||||||
|
color: SUITABILITY_COLOR[f.suitability],
|
||||||
|
border: `1px solid ${SUITABILITY_COLOR[f.suitability]}44`,
|
||||||
|
borderRadius: 3, padding: '1px 6px',
|
||||||
|
minWidth: 50, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{f.suitability}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{FILTER_LABELS[f.filter_id] ?? f.filter_id}
|
||||||
|
</span>
|
||||||
|
{f.est_integration_hours && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 'auto' }}>
|
||||||
|
{f.est_integration_hours}h goal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Integration progress */}
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 8 }}>
|
||||||
|
Integration (keepers)
|
||||||
|
</div>
|
||||||
|
{logData?.filter_breakdown.length ? (
|
||||||
|
<>
|
||||||
|
{logData.filter_breakdown.map(fb => (
|
||||||
|
<div key={fb.filter_id} style={{ marginBottom: 8 }}>
|
||||||
|
<IntegrationBar
|
||||||
|
obj_type={target.obj_type}
|
||||||
|
filter={fb.filter_id}
|
||||||
|
keeperMin={fb.total_min}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
|
Not yet imaged
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{keeperMin > 0 && primaryFilter && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<IntegrationBar
|
||||||
|
obj_type={target.obj_type}
|
||||||
|
filter={primaryFilter}
|
||||||
|
keeperMin={keeperMin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
targets: [Target, Target];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompareModal({ targets, onClose }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
background: 'rgba(8,10,15,0.85)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-panel)', border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 8, width: '100%', maxWidth: 1100, maxHeight: '90vh',
|
||||||
|
display: 'flex', flexDirection: 'column', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '12px 20px', borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-display)', fontSize: 13, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
Target Comparison
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-lo)', background: 'none', border: 'none', cursor: 'pointer', padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ display: 'flex', gap: 0, overflow: 'auto', flex: 1 }}>
|
||||||
|
<div style={{ flex: 1, padding: 20, overflow: 'auto', borderRight: '1px solid var(--border)' }}>
|
||||||
|
<TargetColumn target={targets[0]} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: 20, overflow: 'auto' }}>
|
||||||
|
<TargetColumn target={targets[1]} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Target, Workflow } from '../../api/types';
|
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 { useTargetLog } from '../../hooks/useLog';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../../api';
|
import { api } from '../../api';
|
||||||
@@ -18,7 +18,7 @@ interface Props {
|
|||||||
target: Target;
|
target: Target;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TABS = ['Tonight', 'Target', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
const TABS = ['Overview', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
||||||
|
|
||||||
const WORKFLOW_SHORT: Record<string, string> = {
|
const WORKFLOW_SHORT: Record<string, string> = {
|
||||||
'HA+OIII Dual Narrowband (SV220)': 'HaOIII',
|
'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' });
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={copy}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: copied ? 'var(--good)' : 'var(--text-lo)',
|
||||||
|
background: 'var(--bg-void)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
|
||||||
|
marginLeft: 6, transition: 'color 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? '✓' : 'copy'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, number> = {
|
||||||
|
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 (
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '12px 14px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
SNR Calculator — 3-min subs · IMX571 · f/6.9 · Bortle 5
|
||||||
|
</div>
|
||||||
|
{sb == null ? (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
Surface brightness not available for this object.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 24, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginBottom: 4 }}>
|
||||||
|
Target SNR: <strong style={{ color: 'var(--amber)' }}>{targetSnr}</strong>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range" min={10} max={50} step={5}
|
||||||
|
value={targetSnr}
|
||||||
|
onChange={e => setTargetSnr(parseInt(e.target.value))}
|
||||||
|
style={{ accentColor: 'var(--amber)', width: 140 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Subs needed</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: n != null && n < 100 ? 'var(--good)' : 'var(--warn)' }}>
|
||||||
|
{n != null ? (n > 500 ? '500+' : n) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Total time</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--amber)' }}>
|
||||||
|
{totalH != null ? `${totalH}h` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Sessions ~2h</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--blue)' }}>
|
||||||
|
{totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SB source</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--text-mid)' }}>
|
||||||
|
{sb.toFixed(1)} mag/″²
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DetailDrawer({ target }: Props) {
|
export default function DetailDrawer({ target }: Props) {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [selectedFilter, setSelectedFilter] = useState('sv220');
|
const [selectedFilter, setSelectedFilter] = useState('sv220');
|
||||||
@@ -85,16 +226,17 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
const { data: workflowData } = useTargetWorkflow(target.id, selectedFilter);
|
const { data: workflowData } = useTargetWorkflow(target.id, selectedFilter);
|
||||||
const { data: logData } = useTargetLog(target.id);
|
const { data: logData } = useTargetLog(target.id);
|
||||||
const { data: horizonData } = useHorizon();
|
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({
|
const { data: galleryData } = useQuery({
|
||||||
queryKey: ['gallery', target.id],
|
queryKey: ['gallery', target.id],
|
||||||
queryFn: () => api.gallery.list(target.id),
|
queryFn: () => api.gallery.list(target.id),
|
||||||
enabled: tab === 3,
|
enabled: tab === 2,
|
||||||
});
|
});
|
||||||
const { data: notesData } = useQuery({
|
const { data: notesData } = useQuery({
|
||||||
queryKey: ['target-notes', target.id],
|
queryKey: ['target-notes', target.id],
|
||||||
queryFn: () => api.targets.getNotes(target.id),
|
queryFn: () => api.targets.getNotes(target.id),
|
||||||
enabled: tab === 3,
|
enabled: tab === 2,
|
||||||
});
|
});
|
||||||
const saveNotesMutation = useMutation({
|
const saveNotesMutation = useMutation({
|
||||||
mutationFn: (text: string) => api.targets.putNotes(target.id, text),
|
mutationFn: (text: string) => api.targets.putNotes(target.id, text),
|
||||||
@@ -128,8 +270,45 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '16px 20px' }}>
|
<div style={{ padding: '16px 20px' }}>
|
||||||
{/* Tab 1: Tonight */}
|
{/* Tab 1: Overview — altitude curve + metadata side by side */}
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
|
<div>
|
||||||
|
{/* Top section: metadata left + altitude curve right */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', gap: 20, marginBottom: 20 }}>
|
||||||
|
{/* Left: DSS image + metadata */}
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={dssUrl}
|
||||||
|
alt={`DSS ${target.name}`}
|
||||||
|
style={{ width: '100%', borderRadius: 3, background: '#000', marginBottom: 6 }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 10 }}>
|
||||||
|
DSS Digitized Sky Survey
|
||||||
|
</div>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['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]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, paddingBottom: 3, width: 80 }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value as string}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: altitude curve + key times */}
|
||||||
<div>
|
<div>
|
||||||
{curveData?.curve && curveData.curve.length > 0 ? (
|
{curveData?.curve && curveData.curve.length > 0 ? (
|
||||||
<AltitudeCurve
|
<AltitudeCurve
|
||||||
@@ -163,90 +342,138 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'],
|
['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<tr key={label}>
|
<tr key={label}>
|
||||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 130 }}>{label}</td>
|
||||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value}</td>
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Tab 2: Target */}
|
{/* Below the fold: GoTo card + guiding badge + links + Aladin */}
|
||||||
{tab === 1 && (
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16 }}>
|
{/* GoTo mount coordinates */}
|
||||||
<div>
|
{(() => {
|
||||||
<img
|
const ha = currentHourAngle(target.ra_deg);
|
||||||
src={dssUrl}
|
const side = ha < 0 ? 'East (pre-meridian)' : 'West (post-meridian)';
|
||||||
alt={`DSS ${target.name}`}
|
const sideColor = ha < 0 ? 'var(--teal)' : 'var(--amber)';
|
||||||
style={{ width: '100%', borderRadius: 3, background: '#000' }}
|
return (
|
||||||
loading="lazy"
|
<div style={{
|
||||||
/>
|
background: 'var(--bg-deep)', border: '1px solid var(--border)',
|
||||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>
|
borderRadius: 4, padding: '8px 12px',
|
||||||
DSS Digitized Sky Survey
|
}}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||||
|
GoTo Coordinates (J2000)
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>RA</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{target.ra_h}</span>
|
||||||
|
<CopyButton text={target.ra_h} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>Dec</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{target.dec_dms}</span>
|
||||||
|
<CopyButton text={target.dec_dms} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginTop: 2 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', width: 30 }}>HA</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>{fmtHa(ha)}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: sideColor, marginLeft: 8 }}>
|
||||||
|
{side}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }}>
|
);
|
||||||
<tbody>
|
})()}
|
||||||
{[
|
|
||||||
['Type', target.obj_type],
|
{/* Guiding context + external links */}
|
||||||
['Constellation', target.constellation ?? '—'],
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
['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]) => (
|
|
||||||
<tr key={label}>
|
|
||||||
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
|
||||||
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value as string}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/* Guiding context badge */}
|
|
||||||
{target.guide_star_density && (() => {
|
{target.guide_star_density && (() => {
|
||||||
const density = target.guide_star_density;
|
const density = target.guide_star_density;
|
||||||
const msgs: Record<string, { color: string; text: string; note: string }> = {
|
const msgs: Record<string, { color: string; text: string; note: string }> = {
|
||||||
sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle — consider a bright guide star or off-axis offset' },
|
sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle' },
|
||||||
moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG should work with careful star selection' },
|
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 for OAG or guidescope' },
|
rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars' },
|
||||||
};
|
};
|
||||||
const m = msgs[density];
|
const m = msgs[density];
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-deep)',
|
background: 'var(--bg-deep)', border: `1px solid ${m.color}`,
|
||||||
border: `1px solid ${m.color}`,
|
borderRadius: 4, padding: '6px 10px', display: 'flex', gap: 8, alignItems: 'flex-start',
|
||||||
borderRadius: 4,
|
|
||||||
padding: '6px 10px',
|
|
||||||
marginBottom: 12,
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: m.color, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
<span style={{ color: m.color, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>◉ {m.text}</span>
|
||||||
◉ {m.text}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontStyle: 'italic' }}>{m.note}</span>
|
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontStyle: 'italic' }}>{m.note}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<a
|
||||||
|
href={`https://www.astrobin.com/search/?q=${encodeURIComponent(target.common_name ?? target.name)}`}
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', background: 'var(--bg-deep)', border: '1px solid var(--blue-dim)', borderRadius: 3, padding: '4px 10px', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
Astrobin ↗
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`https://www.astrobin.com/search/?q=${encodeURIComponent(target.name)}`}
|
||||||
|
target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '4px 10px', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
Astrobin {target.name} ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Similar targets nearby */}
|
||||||
|
{(similarData?.similar?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', marginBottom: 16 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Similar Targets Nearby (same type · same constellation)
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||||
|
{similarData!.similar.slice(0, 3).map(s => (
|
||||||
|
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{s.messier_num != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', fontWeight: 700, width: 28 }}>
|
||||||
|
M{s.messier_num}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.common_name ?? s.name}
|
||||||
|
{s.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 5, fontSize: 10 }}>{s.name}</span>}
|
||||||
|
</span>
|
||||||
|
{s.size_arcmin_maj != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{s.size_arcmin_maj.toFixed(1)}′</span>
|
||||||
|
)}
|
||||||
|
{s.max_alt_deg != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: (s.max_alt_deg ?? 0) >= 30 ? 'var(--good)' : 'var(--warn)' }}>{s.max_alt_deg.toFixed(0)}°</span>
|
||||||
|
)}
|
||||||
|
{s.transit_utc && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{new Date(s.transit_utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<AladinEmbed
|
<AladinEmbed
|
||||||
ra={target.ra_deg}
|
ra={target.ra_deg}
|
||||||
dec={target.dec_deg}
|
dec={target.dec_deg}
|
||||||
mosaic={target.mosaic_flag ? { panels_w: target.mosaic_panels_w, panels_h: target.mosaic_panels_h } : undefined}
|
mosaic={target.mosaic_flag ? { panels_w: target.mosaic_panels_w, panels_h: target.mosaic_panels_h } : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 3: Filters & Workflow */}
|
{/* Tab 2: Filters & Workflow */}
|
||||||
{tab === 2 && (
|
{tab === 1 && (
|
||||||
<div>
|
<div>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 20 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 20 }}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -296,11 +523,15 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{workflowData && <WorkflowCard workflow={workflowData} />}
|
{workflowData && <WorkflowCard workflow={workflowData} />}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<ImagingCalculator target={target} filterId={selectedFilter} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 5: Yearly */}
|
{/* Tab 4: Yearly */}
|
||||||
{tab === 4 && (
|
{tab === 3 && (
|
||||||
<div>
|
<div>
|
||||||
{yearlyData?.points ? (
|
{yearlyData?.points ? (
|
||||||
<YearlyVisibility points={yearlyData.points} />
|
<YearlyVisibility points={yearlyData.points} />
|
||||||
@@ -312,8 +543,8 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tab 4: Log & Gallery */}
|
{/* Tab 3: Log & Gallery */}
|
||||||
{tab === 3 && (
|
{tab === 2 && (
|
||||||
<div>
|
<div>
|
||||||
{/* Filter breakdown + planning notes row */}
|
{/* Filter breakdown + planning notes row */}
|
||||||
{((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && (
|
{((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && (
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface Props {
|
|||||||
target: Target;
|
target: Target;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
inCompare?: boolean;
|
||||||
|
onCompare?: (t: Target) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display labels for filter IDs
|
// 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: tonight } = useTonight();
|
||||||
const { data: horizonData } = useHorizon();
|
const { data: horizonData } = useHorizon();
|
||||||
const alt = fmtAlt(target.max_alt_deg);
|
const alt = fmtAlt(target.max_alt_deg);
|
||||||
@@ -131,15 +133,42 @@ export default function TargetRow({ target, expanded, onToggle }: Props) {
|
|||||||
M{target.messier_num}
|
M{target.messier_num}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{target.caldwell_num != null && target.messier_num == null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', fontWeight: 700 }}>
|
||||||
|
C{target.caldwell_num}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: target.messier_num != null ? 'var(--text-mid)' : 'var(--text-hi)' }}>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: target.messier_num != null ? 'var(--text-mid)' : 'var(--text-hi)' }}>
|
||||||
{target.name}
|
{target.name}
|
||||||
</span>
|
</span>
|
||||||
|
{target.is_custom && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 9, fontWeight: 700,
|
||||||
|
background: 'rgba(42,184,160,0.15)', border: '1px solid var(--teal)',
|
||||||
|
color: 'var(--teal)', padding: '1px 5px', borderRadius: 3, letterSpacing: '0.06em',
|
||||||
|
}}>
|
||||||
|
CUSTOM
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{target.common_name && (
|
{target.common_name && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-mid)' }}>
|
<div style={{ fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
{target.common_name}
|
{target.common_name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{target.urgency && (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
{target.urgency === 'peak' && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--good)', letterSpacing: '0.04em' }}>▲ peak</span>
|
||||||
|
)}
|
||||||
|
{target.urgency === 'rising' && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--teal)', letterSpacing: '0.04em' }}>↗ rising</span>
|
||||||
|
)}
|
||||||
|
{target.urgency === 'declining' && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--warn)', letterSpacing: '0.04em' }}>↘ declining</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '7px 8px', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
<td style={{ padding: '7px 8px', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
{target.size_arcmin_maj
|
{target.size_arcmin_maj
|
||||||
@@ -212,6 +241,23 @@ export default function TargetRow({ target, expanded, onToggle }: Props) {
|
|||||||
total_min={target.total_integration_min}
|
total_min={target.total_integration_min}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
{onCompare && (
|
||||||
|
<td style={{ padding: '7px 8px', width: 28 }}>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onCompare(target); }}
|
||||||
|
title={inCompare ? 'Remove from compare' : 'Add to compare'}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: inCompare ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
background: inCompare ? 'var(--amber-glow)' : 'none',
|
||||||
|
border: `1px solid ${inCompare ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⊕
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ const LABELS: Record<string, string> = {
|
|||||||
dark_nebula: 'DN',
|
dark_nebula: 'DN',
|
||||||
nebula: 'NB',
|
nebula: 'NB',
|
||||||
galaxy_group: 'GG',
|
galaxy_group: 'GG',
|
||||||
|
galaxy_cluster: 'ACO',
|
||||||
interacting_galaxy: 'IG',
|
interacting_galaxy: 'IG',
|
||||||
|
custom: 'USR',
|
||||||
|
satellite: 'SAT',
|
||||||
|
comet: 'CMT',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
level?: 'warning' | 'critical';
|
level?: 'warning' | 'critical';
|
||||||
temp?: number;
|
temp?: number;
|
||||||
@@ -5,6 +7,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DewAlert({ level, temp, dewPoint }: Props) {
|
export default function DewAlert({ level, temp, dewPoint }: Props) {
|
||||||
|
const notifiedRef = useRef<string | null>(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;
|
if (!level) return null;
|
||||||
|
|
||||||
const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null;
|
const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null;
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../api';
|
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) {
|
export function useCalendar(months?: number) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['calendar', months],
|
queryKey: ['calendar', months],
|
||||||
|
|||||||
@@ -62,3 +62,12 @@ export function useTargetYearly(id: string, enabled = false) {
|
|||||||
staleTime: 60 * 60_000,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,73 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTonight } from '../hooks/useTonight';
|
import { useTonight } from '../hooks/useTonight';
|
||||||
import { useWeather, useForecast } from '../hooks/useWeather';
|
import { useWeather, useForecast } from '../hooks/useWeather';
|
||||||
import { useTargets } from '../hooks/useTargets';
|
import { useTargets } from '../hooks/useTargets';
|
||||||
import { useStats } from '../hooks/useStats';
|
import { useStats } from '../hooks/useStats';
|
||||||
|
import { useBestNights, useMonthlyHighlights } from '../hooks/useCalendar';
|
||||||
import GoNogo from '../components/weather/GoNogo';
|
import GoNogo from '../components/weather/GoNogo';
|
||||||
import DewAlert from '../components/weather/DewAlert';
|
import DewAlert from '../components/weather/DewAlert';
|
||||||
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
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<string, string> = {
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
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 (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '7px 12px', borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)' }}>
|
||||||
|
{gap.common_name ?? gap.name}
|
||||||
|
</span>
|
||||||
|
{gap.common_name && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 6 }}>
|
||||||
|
{gap.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{have.map(h => (
|
||||||
|
<span key={h.label} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: 'var(--good)', background: 'rgba(61,186,114,0.1)',
|
||||||
|
padding: '2px 6px', borderRadius: 3,
|
||||||
|
}}>
|
||||||
|
{h.label} {fmtMin(h.min)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{missing.map(f => (
|
||||||
|
<span key={f} style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: 'var(--warn)', background: 'rgba(232,192,48,0.1)',
|
||||||
|
border: '1px dashed var(--warn)', padding: '2px 6px', borderRadius: 3,
|
||||||
|
}}>
|
||||||
|
{FILTER_LABELS[f] ?? f} 0h
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
const CC_LABELS: Record<number, string> = {
|
const CC_LABELS: Record<number, string> = {
|
||||||
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
||||||
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
||||||
@@ -34,18 +90,332 @@ function fmtIntTotal(min: number): string {
|
|||||||
return `${h} h`;
|
return `${h} h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FILTER_COLORS: Record<string, string> = {
|
||||||
|
sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_ABBR: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
14-Night Forecast
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
Best score: {Math.max(...nights.map(n => n.score))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div key={night.date} style={{ display: 'grid', gridTemplateColumns: '120px 40px 1fr 80px', gap: 8, padding: '6px 14px', borderBottom: '1px solid var(--border)', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>{dayLabel}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: barColor, textAlign: 'right' }}>{night.score}</span>
|
||||||
|
<div style={{ position: 'relative', height: 8, background: 'var(--bg-deep)', borderRadius: 2 }}>
|
||||||
|
<div style={{ position: 'absolute', left: 0, width: `${barWidth}%`, height: '100%', background: barColor, borderRadius: 2, opacity: 0.7 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center', justifyContent: 'flex-end' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: moonBar(night.moon_illumination) }}>
|
||||||
|
☽ {Math.round(night.moon_illumination * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
Best of {monthLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{highlights.slice(0, 5).map(h => (
|
||||||
|
<div key={h.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 32, flexShrink: 0 }}>
|
||||||
|
{TYPE_ABBR[h.obj_type] ?? h.obj_type.slice(0, 3).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: h.keeper_count > 0 ? 'var(--text-mid)' : 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{h.common_name ?? h.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{h.name}{h.constellation ? ` · ${h.constellation}` : ''}
|
||||||
|
{h.keeper_count > 0 && <span style={{ color: 'var(--good)', marginLeft: 6 }}>✓ imaged</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--good)', fontWeight: 600 }}>
|
||||||
|
{h.peak_alt?.toFixed(0)}°
|
||||||
|
</div>
|
||||||
|
{h.recommended_filter && (
|
||||||
|
<span className={`filter-pill ${h.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||||
|
{FILTER_LABELS[h.recommended_filter] ?? h.recommended_filter.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOAL_HOURS: Record<string, Record<string, number>> = {
|
||||||
|
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<string, string> = {
|
||||||
|
sv220: 'var(--good)', c2: 'var(--teal)', sv260: 'var(--blue)', uvir: 'var(--amber)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function IntegrationGoalsCard({ goals }: { goals: import('../api/types').IntegrationGoal[] }) {
|
||||||
|
if (!goals?.length) return (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', padding: '12px 0' }}>
|
||||||
|
No keeper integration recorded yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{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<string, number> = { sv220: g.sv220_min, c2: g.c2_min, uvir: g.uvir_min, sv260: g.sv260_min };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={g.id} style={{ borderBottom: '1px solid var(--border)', paddingBottom: 10 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)' }}>
|
||||||
|
{g.common_name ?? g.name}
|
||||||
|
{g.common_name && <span style={{ fontSize: 10, color: 'var(--text-lo)', marginLeft: 6 }}>{g.name}</span>}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{Math.floor(totalKeeperMin / 60)}h {totalKeeperMin % 60}m total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{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 (
|
||||||
|
<div key={fk} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: filterColor, width: 50 }}>
|
||||||
|
{FILTER_LABELS[fk] ?? fk}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, height: 5, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pct}%`, background: doneMin > 0 ? color : 'transparent', borderRadius: 3, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: doneMin > 0 ? color : 'var(--text-lo)', width: 60, textAlign: 'right' }}>
|
||||||
|
{doneMin > 0 ? `${Math.floor(doneMin / 60)}h${doneMin % 60}m` : '—'}
|
||||||
|
{goalMin > 0 && ` / ${byType[fk]}h`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
No targets with defined imaging windows tonight.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{/* Slew optimizer toggle */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSlewMode(v => !v)}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: slewMode ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
background: slewMode ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||||
|
border: `1px solid ${slewMode ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 3, padding: '3px 10px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⟳ Slew-optimized order
|
||||||
|
</button>
|
||||||
|
{slewMode && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
Nearest-neighbor sort minimizes mount slew distance
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Timeline header ticks */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr 80px', gap: 8, marginBottom: 2 }}>
|
||||||
|
<div />
|
||||||
|
<div style={{ position: 'relative', height: 14 }}>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map(frac => {
|
||||||
|
const t = new Date(duskMs + frac * nightMs);
|
||||||
|
return (
|
||||||
|
<span key={frac} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${frac * 100}%`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 9,
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
}}>
|
||||||
|
{t.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div key={t.id} style={{ display: 'grid', gridTemplateColumns: '160px 1fr 80px', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
</span>
|
||||||
|
{t.common_name && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 5 }}>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ position: 'relative', height: 14, background: 'var(--bg-deep)', borderRadius: 2 }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${left}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: filterColor,
|
||||||
|
borderRadius: 2,
|
||||||
|
opacity: 0.75,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
{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' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data: tonight } = useTonight();
|
const { data: tonight } = useTonight();
|
||||||
const { data: weather } = useWeather();
|
const { data: weather } = useWeather();
|
||||||
const { data: forecast } = useForecast();
|
const { data: forecast } = useForecast();
|
||||||
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
||||||
|
const { data: runOrder } = useTargets({ tonight: true, sort: 'best_start', limit: 20 });
|
||||||
const { data: stats } = useStats();
|
const { data: stats } = useStats();
|
||||||
|
// best nights + monthly highlights loaded inside their own components
|
||||||
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
||||||
|
const [planningOpen, setPlanningOpen] = useState(false);
|
||||||
|
|
||||||
const moonPct = tonight?.moon_illumination != null
|
const moonPct = tonight?.moon_illumination != null
|
||||||
? `${Math.round(tonight.moon_illumination * 100)}%`
|
? `${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)
|
// 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) ?? [];
|
const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? [];
|
||||||
|
|
||||||
@@ -62,6 +432,31 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Moon separation warning */}
|
||||||
|
{closeMoonTarget && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
background: 'rgba(224,82,82,0.12)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '8px 14px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>☽</span>
|
||||||
|
<span>
|
||||||
|
<strong>{closeMoonTarget.common_name ?? closeMoonTarget.name}</strong>
|
||||||
|
{' '}is only{' '}
|
||||||
|
<strong>{closeMoonTarget.moon_sep_deg?.toFixed(1)}°</strong>
|
||||||
|
{' '}from the Moon — consider a narrowband filter or delaying this target.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dew alert banner */}
|
{/* Dew alert banner */}
|
||||||
{weather?.dew_alert && (
|
{weather?.dew_alert && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -119,6 +514,20 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Integration gap detector */}
|
||||||
|
{(stats?.integration_gaps?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Filter Gaps — targets missing a companion filter
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
{stats!.integration_gaps.map(gap => (
|
||||||
|
<GapCard key={gap.catalog_id} gap={gap} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tonight timing + top targets + forecast */}
|
{/* Tonight timing + top targets + forecast */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
|
|
||||||
@@ -230,6 +639,89 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pre-session checklist */}
|
||||||
|
{tonight?.date && (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<SessionChecklist duskDate={tonight.date} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly highlights + best nights */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Monthly Highlights
|
||||||
|
</div>
|
||||||
|
<MonthlyHighlightsCard />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Best Nights (14-day)
|
||||||
|
</div>
|
||||||
|
<BestNightsCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Integration Goals Progress */}
|
||||||
|
{stats?.integration_goals?.length ? (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Integration Goals — keeper progress per target & filter
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<IntegrationGoalsCard goals={stats.integration_goals} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Tonight run order */}
|
||||||
|
{tonight?.astro_dusk_utc && (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>
|
||||||
|
Tonight's Run Order — imaging windows sorted by start time
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<RunOrderCard
|
||||||
|
items={runOrder?.items ?? []}
|
||||||
|
dusk={tonight.astro_dusk_utc}
|
||||||
|
dawn={tonight.astro_dawn_utc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session Planning Timeline */}
|
||||||
|
{tonight?.astro_dusk_utc && (
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||||
|
Plan Tonight
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setPlanningOpen(o => !o)}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: planningOpen ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
background: planningOpen ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||||
|
border: `1px solid ${planningOpen ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 3, padding: '2px 10px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{planningOpen ? '▲ Hide' : '▼ Build Plan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{planningOpen && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<PlanningTimeline
|
||||||
|
targets={runOrder?.items ?? []}
|
||||||
|
dusk={tonight.astro_dusk_utc}
|
||||||
|
dawn={tonight.astro_dawn_utc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,189 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import type { HorizonPoint } from '../api/types';
|
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<EquipProfile[]>(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem('astronome_equip_profiles') ?? '[]');
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
const [editing, setEditing] = useState<EquipProfile | null>(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 (
|
||||||
|
<section style={{ marginBottom: 32 }}>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||||
|
Equipment Profiles
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 640 }}>
|
||||||
|
{allProfiles.map(p => {
|
||||||
|
const calc = calcProfile(p);
|
||||||
|
const isCurrent = p.id === '__current__';
|
||||||
|
return (
|
||||||
|
<div key={p.id} style={{
|
||||||
|
background: 'var(--bg-panel)', border: `1px solid ${isCurrent ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 6, padding: '12px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: isCurrent ? 'var(--amber)' : 'var(--text-hi)', fontWeight: 600 }}>
|
||||||
|
{p.name}
|
||||||
|
{isCurrent && <span style={{ marginLeft: 8, fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', padding: '1px 6px', borderRadius: 3 }}>ACTIVE</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginTop: 4 }}>
|
||||||
|
{p.focal_mm}mm {calc.focal_ratio} · {p.aperture_mm}mm aperture · {p.pixel_um}μm px · {p.res_x}×{p.res_y}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>
|
||||||
|
Plate scale: <strong style={{ color: 'var(--teal)' }}>{calc.plate_scale_arcsec}″/px</strong>
|
||||||
|
{' · '}FOV: <strong style={{ color: 'var(--teal)' }}>{calc.fov_w}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isCurrent && (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => startEdit(p as EquipProfile)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--blue)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteProfile(p.id)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--danger)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '3px 8px', cursor: 'pointer' }}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{(adding || editing) && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
{editing ? 'Edit Profile' : 'New Profile'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={field.key} style={{ gridColumn: field.fullWidth ? '1 / -1' : undefined }}>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 3 }}>
|
||||||
|
{field.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
step={field.key === 'pixel_um' ? '0.01' : '1'}
|
||||||
|
value={(form as Record<string, string | number>)[field.key]}
|
||||||
|
onChange={e => setForm(f => ({ ...f, [field.key]: field.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value }))}
|
||||||
|
style={fieldStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{form.focal_mm > 0 && form.pixel_um > 0 && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)', marginBottom: 10 }}>
|
||||||
|
Preview: {calcProfile(form).plate_scale_arcsec}″/px · {calcProfile(form).fov_w} FOV
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={saveProfile} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: '#fff', background: 'var(--amber)', border: 'none', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setAdding(false); setEditing(null); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-mid)', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 3, padding: '5px 14px', cursor: 'pointer' }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!adding && !editing && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setAdding(true); setEditing(null); }}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', background: 'var(--bg-deep)', border: '1px dashed var(--amber-dim)', borderRadius: 4, padding: '8px 16px', cursor: 'pointer', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
+ Add equipment profile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HorizonPolarChart({ points }: { points: HorizonPoint[] }) {
|
function HorizonPolarChart({ points }: { points: HorizonPoint[] }) {
|
||||||
const size = 280;
|
const size = 280;
|
||||||
const cx = size / 2;
|
const cx = size / 2;
|
||||||
@@ -165,6 +346,9 @@ export default function Settings() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
||||||
|
|
||||||
|
{/* Equipment Profiles */}
|
||||||
|
<EquipmentProfiles />
|
||||||
|
|
||||||
{/* Custom Horizon */}
|
{/* Custom Horizon */}
|
||||||
<section style={{ marginBottom: 32 }}>
|
<section style={{ marginBottom: 32 }}>
|
||||||
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||||
|
|||||||
@@ -5,8 +5,120 @@ import {
|
|||||||
import { useStats } from '../hooks/useStats';
|
import { useStats } from '../hooks/useStats';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import type { Phd2Log } from '../api/types';
|
import type { HistoryEntry, Phd2Log } from '../api/types';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState, useMemo } from 'react';
|
||||||
|
|
||||||
|
const FILTER_PILL_COLORS: Record<string, string> = {
|
||||||
|
sv220: '#9b59b6', c2: '#4d9de0', sv260: '#e8832a', uvir: '#3dba72',
|
||||||
|
};
|
||||||
|
const FILTER_LABELS_HIST: Record<string, string> = {
|
||||||
|
sv220: 'Ha+OIII', c2: 'SII+OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
const QUALITY_COLORS: Record<string, string> = {
|
||||||
|
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<string, HistoryEntry[]>();
|
||||||
|
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 (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
No sessions logged yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
|
{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 (
|
||||||
|
<div key={date} style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', paddingBottom: 12, marginBottom: 12 }}>
|
||||||
|
{/* Left: date stem */}
|
||||||
|
<div style={{ width: 100, flexShrink: 0, paddingRight: 16 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', fontWeight: 700 }}>
|
||||||
|
{date.slice(5)} {/* MM-DD */}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{date.slice(0, 4)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: hasKeeper ? 'var(--good)' : 'var(--text-lo)', marginTop: 4 }}>
|
||||||
|
{totalMin >= 60 ? `${(totalMin/60).toFixed(1)}h` : `${totalMin}m`}
|
||||||
|
</div>
|
||||||
|
{/* vertical line */}
|
||||||
|
<div style={{ width: 1, background: 'var(--border)', height: '100%', marginLeft: 10, marginTop: 6 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: session entries */}
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{/* Gallery thumbnail */}
|
||||||
|
{item.gallery_url ? (
|
||||||
|
<img src={item.gallery_url} alt={item.name} style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 2, background: '#000', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: 40, height: 40, background: 'var(--bg-deep)', borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--text-lo)' }}>✦</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.common_name ?? item.name}
|
||||||
|
{item.common_name && (
|
||||||
|
<span style={{ color: 'var(--text-lo)', fontSize: 10, marginLeft: 5 }}>{item.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.notes && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-sans)', fontSize: 11, color: 'var(--text-lo)', fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, padding: '2px 6px', borderRadius: 3,
|
||||||
|
background: `${FILTER_PILL_COLORS[item.filter_id] ?? 'var(--muted)'}22`,
|
||||||
|
color: FILTER_PILL_COLORS[item.filter_id] ?? 'var(--text-lo)',
|
||||||
|
border: `1px solid ${FILTER_PILL_COLORS[item.filter_id] ?? 'var(--border)'}`,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{FILTER_LABELS_HIST[item.filter_id] ?? item.filter_id}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', width: 36, textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
{item.integration_min >= 60
|
||||||
|
? `${(item.integration_min / 60).toFixed(1)}h`
|
||||||
|
: `${item.integration_min}m`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 9, padding: '2px 5px', borderRadius: 3,
|
||||||
|
background: `${QUALITY_COLORS[item.quality] ?? 'var(--muted)'}22`,
|
||||||
|
color: QUALITY_COLORS[item.quality] ?? 'var(--text-lo)',
|
||||||
|
border: `1px solid ${QUALITY_COLORS[item.quality] ?? 'var(--border)'}44`,
|
||||||
|
flexShrink: 0, textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{item.quality.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const FILTER_COLORS: Record<string, string> = {
|
const FILTER_COLORS: Record<string, string> = {
|
||||||
sv220: '#9b59b6',
|
sv220: '#9b59b6',
|
||||||
@@ -366,6 +478,58 @@ export default function Stats() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Catalogue completion tracker */}
|
||||||
|
{(stats.catalogue_completion?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, marginBottom: 14 }}>
|
||||||
|
Catalogue Completion
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
{stats.catalogue_completion.map(cat => {
|
||||||
|
const done = cat.pct >= 100;
|
||||||
|
return (
|
||||||
|
<div key={cat.name} style={{
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: `1px solid ${done ? 'var(--good)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 6, padding: '12px 14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{cat.name} {done && '🏆'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: done ? 'var(--good)' : 'var(--text-mid)' }}>
|
||||||
|
{cat.keepers} / {cat.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: 'var(--bg-deep)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
width: `${Math.min(cat.pct, 100)}%`, height: '100%',
|
||||||
|
background: done ? 'var(--good)' : 'var(--amber)',
|
||||||
|
borderRadius: 3, transition: 'width 0.4s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginTop: 4, textAlign: 'right' }}>
|
||||||
|
{cat.pct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Observation history timeline */}
|
||||||
|
{(stats.history?.length ?? 0) > 0 && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, marginBottom: 14 }}>
|
||||||
|
Observation History
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '16px 20px' }}>
|
||||||
|
<SessionTimeline entries={stats.history} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<PHD2Section />
|
<PHD2Section />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+175
-38
@@ -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 { useTargets } from '../hooks/useTargets';
|
||||||
import TargetRow from '../components/targets/TargetRow';
|
import TargetRow from '../components/targets/TargetRow';
|
||||||
import DetailDrawer from '../components/targets/DetailDrawer';
|
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||||
|
import CompareModal from '../components/targets/CompareModal';
|
||||||
import type { Target } from '../api/types';
|
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<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
galaxy: 'Galaxy', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
galaxy: 'Galaxy', galaxy_cluster: 'Cluster (ACO)', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
||||||
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Cluster',
|
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Open Cl.',
|
||||||
globular_cluster: 'Globular', dark_nebula: 'Dark',
|
globular_cluster: 'Globular', dark_nebula: 'Dark',
|
||||||
};
|
};
|
||||||
const FILTERS = [
|
const FILTERS = [
|
||||||
@@ -18,21 +20,52 @@ const FILTERS = [
|
|||||||
{ id: 'uvir', label: 'UV/IR Cut' },
|
{ id: 'uvir', label: 'UV/IR Cut' },
|
||||||
];
|
];
|
||||||
const SORT_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
{ value: '', label: 'Best alt tonight' },
|
{ value: '', label: 'Best score tonight' },
|
||||||
{ value: 'transit', label: 'Transit time' },
|
{ value: 'transit', label: 'Transit time' },
|
||||||
{ value: 'size', label: 'Size (largest)' },
|
{ value: 'size', label: 'Size (largest)' },
|
||||||
{ value: 'magnitude', label: 'Magnitude' },
|
{ value: 'magnitude', label: 'Magnitude' },
|
||||||
{ value: 'difficulty', label: 'Difficulty' },
|
{ value: 'difficulty', label: 'Difficulty' },
|
||||||
{ value: 'integration', label: 'Total integration' },
|
{ value: 'integration', label: 'Total integration' },
|
||||||
];
|
{ value: 'altitude', label: 'Altitude tonight' },
|
||||||
const STATUS_OPTIONS = [
|
{ value: 'best_start', label: 'Run order (imaging window)' },
|
||||||
{ id: 'tonight', label: 'Tonight only' },
|
|
||||||
{ id: 'not_imaged', label: 'Not yet imaged' },
|
|
||||||
{ id: 'mosaic_only', label: 'Mosaics only' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
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 }) {
|
function Chip({ active, color, onClick, children }: { active: boolean; color?: string; onClick: () => void; children: React.ReactNode }) {
|
||||||
const c = color ?? 'var(--amber)';
|
const c = color ?? 'var(--amber)';
|
||||||
return (
|
return (
|
||||||
@@ -56,32 +89,86 @@ function Chip({ active, color, onClick, children }: { active: boolean; color?: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Targets() {
|
export default function Targets() {
|
||||||
const [typeFilter, setTypeFilter] = useState('');
|
const { targetId: urlTargetId } = useParams<{ targetId?: string }>();
|
||||||
const [filterPill, setFilterPill] = useState('');
|
const navigate = useNavigate();
|
||||||
const [tonight, setTonight] = useState(true);
|
|
||||||
const [notImaged, setNotImaged] = useState(false);
|
|
||||||
const [mosaicOnly, setMosaicOnly] = useState(false);
|
|
||||||
const [minAlt, setMinAlt] = useState<number | undefined>(undefined);
|
|
||||||
const [minUsable, setMinUsable] = useState<number | undefined>(undefined);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [sort, setSort] = useState('');
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
|
|
||||||
const { data, isLoading } = useTargets({
|
const saved = loadFilters();
|
||||||
type: typeFilter || undefined,
|
const [typeFilters, setTypeFilters] = useState<string[]>(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<number | null>(saved.minAlt);
|
||||||
|
const [minUsable, setMinUsable] = useState<number | null>(saved.minUsable);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sort, setSort] = useState(saved.sort);
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(urlTargetId ?? null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [compareTargets, setCompareTargets] = useState<Target[]>([]);
|
||||||
|
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,
|
filter: filterPill || undefined,
|
||||||
tonight,
|
tonight: urlTargetId ? false : tonight,
|
||||||
not_imaged: notImaged || undefined,
|
not_imaged: notImaged || undefined,
|
||||||
mosaic_only: mosaicOnly || undefined,
|
mosaic_only: mosaicOnly || undefined,
|
||||||
min_alt_deg: minAlt,
|
show_custom: showCustom ? undefined : false,
|
||||||
min_usable_min: minUsable,
|
min_alt_deg: effectiveMinAlt || undefined,
|
||||||
search: search || undefined,
|
min_usable_min: effectiveMinUsable || undefined,
|
||||||
|
search: urlTargetId ? urlTargetId : (search || undefined),
|
||||||
sort: sort || undefined,
|
sort: sort || undefined,
|
||||||
page,
|
page,
|
||||||
limit: 100,
|
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) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedId(prev => prev === id ? null : id);
|
setExpandedId(prev => prev === id ? null : id);
|
||||||
};
|
};
|
||||||
@@ -100,18 +187,30 @@ export default function Targets() {
|
|||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}>
|
}}>
|
||||||
{/* Row 1: object types */}
|
{/* Row 1: object types (multi-select) */}
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6, alignItems: 'center' }}>
|
||||||
{OBJ_TYPES.map(t => (
|
<Chip
|
||||||
|
active={typeFilters.length === 0}
|
||||||
|
color="var(--amber)"
|
||||||
|
onClick={() => { setTypeFilters([]); setPage(1); }}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</Chip>
|
||||||
|
{OBJ_TYPE_LIST.map(t => (
|
||||||
<Chip
|
<Chip
|
||||||
key={t}
|
key={t}
|
||||||
active={(t === 'All' && !typeFilter) || t === typeFilter}
|
active={typeFilters.includes(t)}
|
||||||
color="var(--amber)"
|
color="var(--amber)"
|
||||||
onClick={() => setTypeFilter(t === 'All' ? '' : t)}
|
onClick={() => toggleType(t)}
|
||||||
>
|
>
|
||||||
{TYPE_LABELS[t] ?? t}
|
{TYPE_LABELS[t] ?? t}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
|
{typeFilters.length > 0 && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 4 }}>
|
||||||
|
{typeFilters.length} type{typeFilters.length > 1 ? 's' : ''} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: filters + sort + status + search */}
|
{/* Row 2: filters + sort + status + search */}
|
||||||
@@ -124,7 +223,7 @@ export default function Targets() {
|
|||||||
key={f.id}
|
key={f.id}
|
||||||
active={f.id === filterPill}
|
active={f.id === filterPill}
|
||||||
color="var(--blue)"
|
color="var(--blue)"
|
||||||
onClick={() => setFilterPill(f.id === filterPill ? '' : f.id)}
|
onClick={() => { setFilterPill(f.id === filterPill ? '' : f.id); setPage(1); }}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -143,6 +242,12 @@ export default function Targets() {
|
|||||||
<Chip active={mosaicOnly} color="var(--warn)" onClick={() => setMosaicOnly(v => !v)}>
|
<Chip active={mosaicOnly} color="var(--warn)" onClick={() => setMosaicOnly(v => !v)}>
|
||||||
Mosaics only
|
Mosaics only
|
||||||
</Chip>
|
</Chip>
|
||||||
|
<Chip active={showCustom} color="var(--teal)" onClick={() => setShowCustom(v => !v)}>
|
||||||
|
Custom
|
||||||
|
</Chip>
|
||||||
|
<Chip active={accessible} color="var(--good)" onClick={() => { setAccessible(v => !v); setPage(1); }}>
|
||||||
|
Accessible tonight
|
||||||
|
</Chip>
|
||||||
|
|
||||||
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||||
|
|
||||||
@@ -151,7 +256,7 @@ export default function Targets() {
|
|||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN ALT</span>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN ALT</span>
|
||||||
<select
|
<select
|
||||||
value={minAlt ?? ''}
|
value={minAlt ?? ''}
|
||||||
onChange={e => setMinAlt(e.target.value ? Number(e.target.value) : undefined)}
|
onChange={e => { setMinAlt(e.target.value ? Number(e.target.value) : null); setPage(1); }}
|
||||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 70 }}
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 70 }}
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
@@ -167,7 +272,7 @@ export default function Targets() {
|
|||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN TIME</span>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN TIME</span>
|
||||||
<select
|
<select
|
||||||
value={minUsable ?? ''}
|
value={minUsable ?? ''}
|
||||||
onChange={e => 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 }}
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 80 }}
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">Any</option>
|
||||||
@@ -213,6 +318,29 @@ export default function Targets() {
|
|||||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
{data?.total ?? 0} objects
|
{data?.total ?? 0} objects
|
||||||
</span>
|
</span>
|
||||||
|
{compareTargets.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
|
||||||
|
{compareTargets.map(t => (
|
||||||
|
<span key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)', borderRadius: 3, padding: '1px 6px' }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{compareTargets.length === 2 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompare(true)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--bg-void)', background: 'var(--amber)', border: 'none', borderRadius: 3, padding: '3px 10px', cursor: 'pointer', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
Compare →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCompareTargets([])}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '1px 6px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,8 +355,8 @@ export default function Targets() {
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border-hi)' }}>
|
<tr style={{ borderBottom: '1px solid var(--border-hi)' }}>
|
||||||
{COL_HEADERS.map(h => (
|
{[...COL_HEADERS, ''].map((h, i) => (
|
||||||
<th key={h} style={{
|
<th key={i} style={{
|
||||||
padding: '4px 8px',
|
padding: '4px 8px',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
@@ -250,10 +378,12 @@ export default function Targets() {
|
|||||||
target={target}
|
target={target}
|
||||||
expanded={expandedId === target.id}
|
expanded={expandedId === target.id}
|
||||||
onToggle={() => toggleExpand(target.id)}
|
onToggle={() => toggleExpand(target.id)}
|
||||||
|
inCompare={compareTargets.some(c => c.id === target.id)}
|
||||||
|
onCompare={toggleCompare}
|
||||||
/>
|
/>
|
||||||
{expandedId === target.id && (
|
{expandedId === target.id && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={COL_HEADERS.length} style={{ padding: 0 }}>
|
<td colSpan={COL_HEADERS.length + 1} style={{ padding: 0 }}>
|
||||||
<DetailDrawer target={target} />
|
<DetailDrawer target={target} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -263,6 +393,13 @@ export default function Targets() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCompare && compareTargets.length === 2 && (
|
||||||
|
<CompareModal
|
||||||
|
targets={[compareTargets[0], compareTargets[1]]}
|
||||||
|
onClose={() => setShowCompare(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ input:focus, select:focus, textarea:focus {
|
|||||||
.type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; }
|
.type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; }
|
||||||
.type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); }
|
.type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); }
|
||||||
.type-badge.nebula { background: var(--teal); color: #fff; }
|
.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 chips */
|
||||||
.quality-chip {
|
.quality-chip {
|
||||||
|
|||||||
Reference in New Issue
Block a user