From 441a7365d7c5c9944a9a8a05e5d952da3a6ab6ad Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Thu, 9 Apr 2026 23:23:31 +0200 Subject: [PATCH] Initial Commit --- .claude/settings.local.json | 7 + .gitignore | 33 + CLAUDE.md | 1178 +++++++++ TODO.md | 73 + backend/Cargo.toml | 29 + backend/Dockerfile | 21 + backend/src/api/calendar.rs | 242 ++ backend/src/api/gallery.rs | 185 ++ backend/src/api/horizon.rs | 50 + backend/src/api/log.rs | 242 ++ backend/src/api/mod.rs | 244 ++ backend/src/api/phd2.rs | 160 ++ backend/src/api/solar_system.rs | 436 ++++ backend/src/api/stats.rs | 151 ++ backend/src/api/targets.rs | 643 +++++ backend/src/api/tonight.rs | 57 + backend/src/api/weather.rs | 151 ++ backend/src/astronomy/coords.rs | 44 + backend/src/astronomy/horizon.rs | 33 + backend/src/astronomy/lunar.rs | 136 + backend/src/astronomy/mod.rs | 13 + backend/src/astronomy/solar.rs | 88 + backend/src/astronomy/time.rs | 20 + backend/src/astronomy/visibility.rs | 217 ++ backend/src/catalog/fetch.rs | 190 ++ backend/src/catalog/filter.rs | 309 +++ backend/src/catalog/ldn.rs | 188 ++ backend/src/catalog/mod.rs | 261 ++ backend/src/catalog/popular_names copy.rs | 173 ++ backend/src/catalog/popular_names.rs | 173 ++ backend/src/catalog/vdb.rs | 146 ++ backend/src/config.rs | 24 + backend/src/db/mod.rs | 55 + backend/src/db/schema.sql | 149 ++ backend/src/filters/mod.rs | 349 +++ backend/src/jobs/catalog_refresh.rs | 9 + backend/src/jobs/mod.rs | 66 + backend/src/jobs/nightly.rs | 229 ++ backend/src/jobs/weather_poll.rs | 29 + backend/src/main.rs | 47 + backend/src/phd2/mod.rs | 306 +++ backend/src/weather/mod.rs | 94 + backend/src/weather/openmeteo.rs | 56 + backend/src/weather/seventimer.rs | 48 + docker-compose.yml | 19 + frontend/Dockerfile | 10 + frontend/index.html | 19 + frontend/nginx-frontend.conf | 15 + frontend/package-lock.json | 2193 +++++++++++++++++ frontend/package.json | 26 + frontend/src/App.tsx | 28 + frontend/src/aladin-lite.d.ts | 21 + frontend/src/api/index.ts | 172 ++ frontend/src/api/types.ts | 237 ++ .../src/components/charts/AltitudeCurve.tsx | 241 ++ .../components/charts/YearlyVisibility.tsx | 134 + .../components/gallery/ImageUploadZone.tsx | 67 + .../src/components/gallery/LightboxView.tsx | 109 + frontend/src/components/layout/PageShell.tsx | 22 + frontend/src/components/layout/Sidebar.tsx | 203 ++ frontend/src/components/log/LogForm.tsx | 183 ++ frontend/src/components/log/QualityFlag.tsx | 19 + frontend/src/components/log/SessionList.tsx | 124 + .../src/components/phd2/PHD2UploadZone.tsx | 113 + frontend/src/components/sky/AladinEmbed.tsx | 88 + frontend/src/components/sky/MoonPhaseIcon.tsx | 54 + .../src/components/targets/DetailDrawer.tsx | 399 +++ frontend/src/components/targets/TargetRow.tsx | 217 ++ frontend/src/components/targets/TypeBadge.tsx | 26 + frontend/src/components/targets/VisBar.tsx | 66 + frontend/src/components/weather/DewAlert.tsx | 35 + frontend/src/components/weather/GoNogo.tsx | 49 + .../src/components/weather/WeatherCard.tsx | 110 + frontend/src/hooks/useCalendar.ts | 19 + frontend/src/hooks/useHorizon.ts | 10 + frontend/src/hooks/useLog.ts | 55 + frontend/src/hooks/useStats.ts | 10 + frontend/src/hooks/useTargets.ts | 64 + frontend/src/hooks/useTonight.ts | 11 + frontend/src/hooks/useWeather.ts | 19 + frontend/src/main.tsx | 22 + frontend/src/pages/Calendar.tsx | 458 ++++ frontend/src/pages/Dashboard.tsx | 235 ++ frontend/src/pages/Gallery.tsx | 281 +++ frontend/src/pages/Settings.tsx | 319 +++ frontend/src/pages/SolarSystem.tsx | 419 ++++ frontend/src/pages/Stats.tsx | 372 +++ frontend/src/pages/Targets.tsx | 268 ++ frontend/src/styles/global.css | 129 + frontend/src/styles/tokens.css | 48 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 14 + 93 files changed, 15137 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 TODO.md create mode 100644 backend/Cargo.toml create mode 100644 backend/Dockerfile create mode 100644 backend/src/api/calendar.rs create mode 100644 backend/src/api/gallery.rs create mode 100644 backend/src/api/horizon.rs create mode 100644 backend/src/api/log.rs create mode 100644 backend/src/api/mod.rs create mode 100644 backend/src/api/phd2.rs create mode 100644 backend/src/api/solar_system.rs create mode 100644 backend/src/api/stats.rs create mode 100644 backend/src/api/targets.rs create mode 100644 backend/src/api/tonight.rs create mode 100644 backend/src/api/weather.rs create mode 100644 backend/src/astronomy/coords.rs create mode 100644 backend/src/astronomy/horizon.rs create mode 100644 backend/src/astronomy/lunar.rs create mode 100644 backend/src/astronomy/mod.rs create mode 100644 backend/src/astronomy/solar.rs create mode 100644 backend/src/astronomy/time.rs create mode 100644 backend/src/astronomy/visibility.rs create mode 100644 backend/src/catalog/fetch.rs create mode 100644 backend/src/catalog/filter.rs create mode 100644 backend/src/catalog/ldn.rs create mode 100644 backend/src/catalog/mod.rs create mode 100644 backend/src/catalog/popular_names copy.rs create mode 100644 backend/src/catalog/popular_names.rs create mode 100644 backend/src/catalog/vdb.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/db/mod.rs create mode 100644 backend/src/db/schema.sql create mode 100644 backend/src/filters/mod.rs create mode 100644 backend/src/jobs/catalog_refresh.rs create mode 100644 backend/src/jobs/mod.rs create mode 100644 backend/src/jobs/nightly.rs create mode 100644 backend/src/jobs/weather_poll.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/phd2/mod.rs create mode 100644 backend/src/weather/mod.rs create mode 100644 backend/src/weather/openmeteo.rs create mode 100644 backend/src/weather/seventimer.rs create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx-frontend.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/aladin-lite.d.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/types.ts create mode 100644 frontend/src/components/charts/AltitudeCurve.tsx create mode 100644 frontend/src/components/charts/YearlyVisibility.tsx create mode 100644 frontend/src/components/gallery/ImageUploadZone.tsx create mode 100644 frontend/src/components/gallery/LightboxView.tsx create mode 100644 frontend/src/components/layout/PageShell.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/log/LogForm.tsx create mode 100644 frontend/src/components/log/QualityFlag.tsx create mode 100644 frontend/src/components/log/SessionList.tsx create mode 100644 frontend/src/components/phd2/PHD2UploadZone.tsx create mode 100644 frontend/src/components/sky/AladinEmbed.tsx create mode 100644 frontend/src/components/sky/MoonPhaseIcon.tsx create mode 100644 frontend/src/components/targets/DetailDrawer.tsx create mode 100644 frontend/src/components/targets/TargetRow.tsx create mode 100644 frontend/src/components/targets/TypeBadge.tsx create mode 100644 frontend/src/components/targets/VisBar.tsx create mode 100644 frontend/src/components/weather/DewAlert.tsx create mode 100644 frontend/src/components/weather/GoNogo.tsx create mode 100644 frontend/src/components/weather/WeatherCard.tsx create mode 100644 frontend/src/hooks/useCalendar.ts create mode 100644 frontend/src/hooks/useHorizon.ts create mode 100644 frontend/src/hooks/useLog.ts create mode 100644 frontend/src/hooks/useStats.ts create mode 100644 frontend/src/hooks/useTargets.ts create mode 100644 frontend/src/hooks/useTonight.ts create mode 100644 frontend/src/hooks/useWeather.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Calendar.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Gallery.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/SolarSystem.tsx create mode 100644 frontend/src/pages/Stats.tsx create mode 100644 frontend/src/pages/Targets.tsx create mode 100644 frontend/src/styles/global.css create mode 100644 frontend/src/styles/tokens.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b62aa57 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo add:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af83867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Rust +target/ +Cargo.lock + +# Node.js / React / Vite +node_modules/ +dist/ +.env +.env.local +.env.production + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ + +# Docker +.dockerignore + +# Database and data (if not committed) +data/*.db +data/gallery/ + +# Temporary files +*.tmp +*.swp \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8cf2e97 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1178 @@ +# Claude Code Prompt — Astronome: Personal Deep-Sky Observatory App +## Stack: Rust · Axum · SQLx · SQLite · React · Vite · Docker Compose + +--- + +## 0. Project Intent + +Build a self-hosted personal astrophotography planning and logging web application +deployed on a NAS via Docker Compose. This is a single-user tool — no auth, no +multi-tenancy. All decisions are pre-encoded below. Build everything without asking +clarifying questions. + +--- + +## 1. Repository Structure + +``` +astronome/ +├── backend/ +│ ├── Cargo.toml +│ ├── Dockerfile +│ ├── src/ +│ │ ├── main.rs +│ │ ├── config.rs # OBS constants, compile-time +│ │ ├── db/ +│ │ │ ├── mod.rs +│ │ │ ├── schema.sql # CREATE TABLE statements +│ │ │ └── migrations/ # sqlx migrations +│ │ ├── catalog/ +│ │ │ ├── mod.rs +│ │ │ ├── fetch.rs # OpenNGC CSV fetch + parse +│ │ │ ├── filter.rs # suitability filter for this setup +│ │ │ └── popular_names.rs +│ │ ├── astronomy/ +│ │ │ ├── mod.rs +│ │ │ ├── time.rs # JD, LST +│ │ │ ├── coords.rs # RA/Dec → Alt/Az, airmass +│ │ │ ├── solar.rs # sun altitude, dusk/dawn +│ │ │ ├── lunar.rs # moon phase, rise/set, position +│ │ │ ├── horizon.rs # custom horizon interpolation +│ │ │ └── visibility.rs # per-object tonight summary +│ │ ├── filters/ +│ │ │ └── mod.rs # recommendation engine +│ │ ├── weather/ +│ │ │ ├── mod.rs +│ │ │ ├── seventimer.rs # 7timer ASTRO API client +│ │ │ └── openmeteo.rs # Open-Meteo current conditions +│ │ ├── jobs/ +│ │ │ ├── mod.rs +│ │ │ ├── nightly.rs # precompute tonight's data +│ │ │ ├── weather_poll.rs +│ │ │ └── catalog_refresh.rs +│ │ ├── phd2/ +│ │ │ └── mod.rs # PHD2 CSV log parser +│ │ └── api/ +│ │ ├── mod.rs +│ │ ├── targets.rs +│ │ ├── tonight.rs +│ │ ├── calendar.rs +│ │ ├── log.rs +│ │ ├── weather.rs +│ │ ├── phd2.rs +│ │ ├── horizon.rs +│ │ └── stats.rs +├── frontend/ +│ ├── package.json +│ ├── vite.config.ts +│ ├── Dockerfile +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx +│ │ ├── api/ # typed fetch wrappers per endpoint +│ │ ├── hooks/ # useTargets, useTonight, useWeather, etc. +│ │ ├── pages/ +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Targets.tsx +│ │ │ ├── Calendar.tsx +│ │ │ ├── Stats.tsx +│ │ │ └── Settings.tsx +│ │ ├── components/ +│ │ │ ├── layout/ # Sidebar, TopBar, PageShell +│ │ │ ├── targets/ # TargetRow, DetailDrawer, tabs +│ │ │ ├── charts/ # AltitudeCurve, AirmassBar, VisBar +│ │ │ ├── sky/ # AladinEmbed, MoonPhaseIcon +│ │ │ ├── weather/ # WeatherCard, DewAlert, GoNogo +│ │ │ ├── log/ # LogForm, SessionList, QualityFlag +│ │ │ ├── phd2/ # PHD2UploadZone, GuidingStats +│ │ │ └── gallery/ # ImageUploadZone, LightboxView +│ │ └── styles/ +│ │ ├── tokens.css +│ │ └── global.css +├── docker-compose.yml +└── nginx.conf +``` + +--- + +## 2. Observer Constants (hardcode everywhere, never configurable) + +```rust +// backend/src/config.rs +pub const LAT: f64 = 43.8167; // Villevieille, France +pub const LON: f64 = 4.1167; +pub const BORTLE: u8 = 5; + +pub const FOCAL_MM: f64 = 490.0; +pub const APERTURE_MM: f64 = 71.0; +pub const FOCAL_RATIO: f64 = 6.9; + +// ToupTek ATR2600C / IMX571 +pub const PIXEL_UM: f64 = 3.76; +pub const RES_X: u32 = 6248; +pub const RES_Y: u32 = 4176; + +// Derived — never recompute +pub const PLATE_SCALE_ARCSEC: f64 = 1.584; +pub const FOV_DEG_W: f64 = 2.75; +pub const FOV_DEG_H: f64 = 1.84; +pub const FOV_ARCMIN_W: f64 = 165.0; +pub const FOV_ARCMIN_H: f64 = 110.4; + +pub const MIN_ALT_DEG: f64 = 15.0; +pub const MIN_DURATION_MIN: u32 = 45; +``` + +--- + +## 3. Database Schema + +```sql +-- backend/src/db/schema.sql + +-- OpenNGC catalog cache (refreshed weekly) +CREATE TABLE IF NOT EXISTS catalog ( + id TEXT PRIMARY KEY, -- "NGC1234", "IC0434", "M42" + name TEXT NOT NULL, + common_name TEXT, -- "Orion Nebula", "Horsehead Nebula" + obj_type TEXT NOT NULL, -- normalized: galaxy, emission_nebula, etc. + ra_deg REAL NOT NULL, + dec_deg REAL NOT NULL, + ra_h TEXT NOT NULL, -- "05h 34m 32s" + dec_dms TEXT NOT NULL, -- "+22° 00′ 52″" + constellation TEXT, + size_arcmin_maj REAL, + size_arcmin_min REAL, + pos_angle_deg REAL, + mag_v REAL, + surface_brightness REAL, + hubble_type TEXT, + messier_num INTEGER, + is_highlight BOOLEAN DEFAULT FALSE, + -- derived for this setup + fov_fill_pct REAL, -- (size_arcmin_maj / FOV_ARCMIN_H) * 100 + mosaic_flag BOOLEAN DEFAULT FALSE, + mosaic_panels_w INTEGER DEFAULT 1, + mosaic_panels_h INTEGER DEFAULT 1, + difficulty INTEGER, -- 1 (easy) to 5 (very hard) + guide_star_density TEXT, -- "rich", "moderate", "sparse" + fetched_at INTEGER NOT NULL -- unix timestamp +); + +-- Nightly precomputed visibility (refreshed each evening at sunset) +CREATE TABLE IF NOT EXISTS nightly_cache ( + catalog_id TEXT NOT NULL, + night_date TEXT NOT NULL, -- "2026-04-08" (local date of the evening) + max_alt_deg REAL, + transit_utc TEXT, + rise_utc TEXT, -- above MIN_ALT_DEG + set_utc TEXT, + best_start_utc TEXT, -- above 30° + best_end_utc TEXT, + usable_min INTEGER, + meridian_flip_utc TEXT, + airmass_at_transit REAL, + extinction_mag REAL, + moon_sep_deg REAL, + recommended_filter TEXT, + visibility_json TEXT, -- JSON array of {t, alt, az} every 10 min + PRIMARY KEY (catalog_id, night_date) +); + +-- Tonight summary (single row, refreshed at sunset) +CREATE TABLE IF NOT EXISTS tonight ( + id INTEGER PRIMARY KEY CHECK (id = 1), + date TEXT NOT NULL, + astro_dusk_utc TEXT NOT NULL, + astro_dawn_utc TEXT NOT NULL, + moon_rise_utc TEXT, + moon_set_utc TEXT, + moon_illumination REAL, + moon_phase_name TEXT, + moon_ra_deg REAL, + moon_dec_deg REAL, + true_dark_start_utc TEXT, -- moon below horizon AND astro dark + true_dark_end_utc TEXT, + true_dark_minutes INTEGER, + computed_at INTEGER +); + +-- Custom horizon profile +CREATE TABLE IF NOT EXISTS horizon ( + az_deg INTEGER PRIMARY KEY, -- 0–359, every degree + alt_deg REAL NOT NULL DEFAULT 15.0 +); + +-- Imaging log +CREATE TABLE IF NOT EXISTS imaging_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + catalog_id TEXT NOT NULL, + session_date TEXT NOT NULL, -- "2026-04-08" + filter_id TEXT NOT NULL, -- "sv220", "c2", "uvir", "sv260" + integration_min INTEGER NOT NULL, + quality TEXT NOT NULL DEFAULT 'pending', -- 'keeper','needs_more','rejected','pending' + notes TEXT, + guiding_rms REAL, -- populated from PHD2 log if uploaded + mean_temp_c REAL, + phd2_log_id INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Target gallery images +CREATE TABLE IF NOT EXISTS gallery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + catalog_id TEXT NOT NULL, + log_id INTEGER, + filename TEXT NOT NULL, -- stored in /data/gallery/ + caption TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- PHD2 guiding log analysis results +CREATE TABLE IF NOT EXISTS phd2_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_date TEXT NOT NULL, + filename TEXT NOT NULL, + rms_total REAL, + rms_ra REAL, + rms_dec REAL, + peak_error REAL, + star_lost_count INTEGER, + duration_min INTEGER, + guide_star_snr REAL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Weather cache +CREATE TABLE IF NOT EXISTS weather_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seventimer_json TEXT, -- raw 7timer ASTRO response + openmeteo_json TEXT, -- raw Open-Meteo current conditions + dew_point_c REAL, + temp_c REAL, + humidity_pct REAL, + go_nogo TEXT, -- "go", "marginal", "nogo" + fetched_at INTEGER +); + +-- App settings +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +``` + +--- + +## 4. Filter Arsenal (hardcode in Rust) + +```rust +// backend/src/filters/mod.rs + +pub enum FilterId { UvIr, Sv260, C2, Sv220 } + +pub struct Filter { + pub id: FilterId, + pub name: &'static str, + pub filter_type: &'static str, + pub moon_limit_pct: u8, + pub suitable_types: &'static [ObjType], + pub lp_reduction: &'static str, + pub extraction_method: Option<&'static str>, + pub channels: &'static [&'static str], +} + +pub const FILTERS: [Filter; 4] = [ + Filter { + id: FilterId::UvIr, + name: "ZWO UV/IR Cut", + filter_type: "broadband", + moon_limit_pct: 40, + suitable_types: &[Galaxy, ReflectionNebula, OpenCluster, GlobularCluster], + lp_reduction: "low", + extraction_method: None, + channels: &[], + }, + Filter { + id: FilterId::Sv260, + name: "SVBony SV260", + filter_type: "broadband_lp", + moon_limit_pct: 55, + suitable_types: &[Galaxy, EmissionNebula, ReflectionNebula, OpenCluster, GlobularCluster], + lp_reduction: "medium", + extraction_method: None, + channels: &[], + }, + Filter { + id: FilterId::C2, + name: "Askar C2", + filter_type: "dual_narrowband", + moon_limit_pct: 95, + suitable_types: &[EmissionNebula, Snr, PlanetaryNebula], + lp_reduction: "high", + extraction_method: Some("DBXtract"), + channels: &["SII_15nm", "OIII_35nm"], + }, + Filter { + id: FilterId::Sv220, + name: "SVBony SV220", + filter_type: "dual_narrowband", + moon_limit_pct: 98, + suitable_types: &[EmissionNebula, Snr, PlanetaryNebula], + lp_reduction: "high", + extraction_method: Some("DBXtract"), + channels: &["Ha_7nm", "OIII_7nm"], + }, +]; +``` + +--- + +## 5. Catalog Pipeline + +### 5.1 Fetch & Parse + +Fetch both CSVs from GitHub raw in parallel on first startup and weekly thereafter +(7-day TTL via `catalog.fetched_at` in SQLite): + +``` +https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv +https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/IC.csv +``` + +Parse with the `csv` crate. Strip invalid rows (missing RA/Dec, invalid type). + +### 5.2 Filtering for this setup + +Keep objects where ALL of: +- Type in: `GX, GC, OC, EN, RN, PN, SNR, BN, NF, DN` +- Dec between −30° and +75° +- MajAx > 0.1 arcmin (not stellar) + +### 5.3 Derived fields (compute once, store in DB) + +**FOV fill ratio:** +```rust +let fill_pct = (size_arcmin_maj / FOV_ARCMIN_H).min(1.0) * 100.0; +``` + +**Mosaic flag and panel count:** +```rust +let panels_w = (size_arcmin_maj / FOV_ARCMIN_W).ceil() as u32; +let panels_h = (size_arcmin_maj / FOV_ARCMIN_H).ceil() as u32; +let mosaic_flag = panels_w > 1 || panels_h > 1; +// panels_w × panels_h e.g. "2×1", "2×2", "3×2" +``` + +**Difficulty rating (1–5):** +```rust +fn difficulty(obj_type, size_arcmin_maj, mag_v, surface_brightness, bortle) -> u8 { + // Start at 2 + // +1 if SB > 13 mag/arcsec² (faint extended) + // +1 if size < 2 arcmin (small, needs good seeing) + // +1 if mag_v > 11.0 + // +1 if obj_type == DarkNebula + // -1 if obj_type == OpenCluster + // -1 if mosaic (just flag it, not harder per se, but different) + // clamp 1..=5 +} +``` + +**Guide star density:** +Query the number of catalog stars within a 1° radius at the target's RA/Dec +using a bundled Tycho-2 subset. Return `"rich"` (>50), `"moderate"` (15–50), +`"sparse"` (<15). + +Because bundling Tycho-2 is large, use a simpler proxy: +- Galactic latitude |b| > 30°: likely sparse for emission nebulae in the halo +- |b| < 10°: rich (galactic plane) +- 10–30°: moderate +- Always mark targets with Galactic longitude 0–30° as rich (galactic centre direction) + +**Surface brightness:** use `SurfBr` from OpenNGC CSV directly. + +### 5.4 Popular names + +Hardcode a HashMap of NGC/IC/Messier → common name: + +Include at minimum: M1→Crab Nebula, M8→Lagoon Nebula, M16→Eagle Nebula, +M17→Omega Nebula, M20→Trifid Nebula, M27→Dumbbell Nebula, M31→Andromeda Galaxy, +M33→Triangulum Galaxy, M42→Orion Nebula, M43→De Mairan's Nebula, +M45→Pleiades, M51→Whirlpool Galaxy, M57→Ring Nebula, M63→Sunflower Galaxy, +M64→Black Eye Galaxy, M78→McNeil's Nebula area, M81→Bode's Galaxy, +M82→Cigar Galaxy, M97→Owl Nebula, M101→Pinwheel Galaxy, M104→Sombrero Galaxy, +NGC224=M31, NGC598=M33, NGC1499→California Nebula, NGC1952=M1, +NGC1976=M42, NGC2068=M78, NGC2237→Rosette Nebula, NGC2244→Rosette Cluster, +NGC3372→Eta Carinae Nebula, NGC5128→Centaurus A, NGC6992→Eastern Veil Nebula, +NGC6960→Western Veil Nebula, NGC6979→Pickering's Triangle, +NGC7000→North America Nebula, IC405→Flaming Star Nebula, +IC410→Tadpoles Nebula, IC434→Horsehead Nebula, IC1805→Heart Nebula, +IC1848→Soul Nebula, IC2118→Witch Head Nebula, IC5146→Cocoon Nebula, +IC1396→Elephant Trunk Nebula, Sh2-132→Lion Nebula, Sh2-155→Cave Nebula, +Sh2-308→Dolphin Nebula (add ~80 total). + +--- + +## 6. Astronomy Engine (Rust) + +### 6.1 Julian Date +Standard formula. Input: chrono::DateTime. Output: f64. + +### 6.2 Local Sidereal Time +Output in degrees 0–360. Use IAU formula via JD. + +### 6.3 Hour Angle → Alt/Az +Standard spherical trig. Input: ra_deg, dec_deg, lst_deg, lat_deg. Output: (alt_deg, az_deg). + +### 6.4 Airmass & Extinction +```rust +fn airmass(alt_deg: f64) -> f64 { + // Rozenberg formula — valid to horizon + let z = (90.0 - alt_deg).to_radians(); + 1.0 / (z.cos() + 0.025 * (-11.0 * z.cos()).exp()) +} + +fn extinction_mag(alt_deg: f64) -> f64 { + // k = 0.20 mag/airmass (typical Bortle 5 site) + airmass(alt_deg) * 0.20 +} +``` + +### 6.5 Custom Horizon Alt at Azimuth +```rust +fn horizon_alt(az_deg: f64, horizon: &[HorizonPoint]) -> f64 { + // linear interpolation between stored 1°-resolution points +} +``` + +An object is considered visible when `alt > max(MIN_ALT_DEG, horizon_alt(az))`. + +### 6.6 Sun altitude solver +Binary search to find exact UTC times when sun alt = −18° (astronomical twilight) +for tonight. Use 1-minute resolution. Return `(dusk_utc, dawn_utc)`. + +### 6.7 Moon +```rust +struct MoonState { + illumination: f64, // 0.0–1.0 + age_days: f64, + phase_name: String, // "Waxing Crescent", "Full Moon", etc. + ra_deg: f64, + dec_deg: f64, + rise_utc: Option>, + set_utc: Option>, + // above horizon currently + alt_deg: f64, +} +``` + +Moon rise/set: step through the night window at 5-minute intervals, detect sign +change in altitude, linearly interpolate crossing. + +### 6.8 True Dark Window +```rust +fn true_dark_window(dusk: DateTime, dawn: DateTime, moon: &MoonState) + -> Option<(DateTime, DateTime)> +{ + // Walk dusk→dawn in 5-min steps + // True dark = sun alt < -18° AND moon alt < 0° + // Return the longest continuous such interval +} +``` + +### 6.9 Meridian Flip Time (GEM28) +```rust +fn meridian_flip_utc(ra_deg: f64, lat_deg: f64, lon_deg: f64, date: DateTime) + -> DateTime +{ + // Time when HA = 0 (transit) + mount's flip buffer + // GEM28 default flip at HA = +5° past meridian + // Return transit_utc + duration_for_ha_5deg +} +``` + +### 6.10 Visibility Summary per Object (tonight) +```rust +struct VisibilitySummary { + max_alt_deg: f64, + transit_utc: DateTime, + rise_utc: Option>, // above custom horizon + set_utc: Option>, + best_start_utc: Option>, // alt > 30° + best_end_utc: Option>, + usable_min: u32, + is_visible_tonight: bool, + meridian_flip_utc: Option>, + airmass_at_transit: f64, + extinction_at_transit: f64, + moon_sep_deg: f64, + curve: Vec, // every 10 min, dusk to dawn +} + +struct CurvePoint { + utc: DateTime, + alt_deg: f64, + az_deg: f64, + airmass: f64, + above_custom_horizon: bool, +} +``` + +--- + +## 7. Filter Recommendation Engine + +```rust +fn recommend_filters( + obj_type: ObjType, + moon_illumination_pct: f64, + moon_alt_deg: f64, + moon_sep_deg: f64, +) -> Vec { + // Returns ordered recommendations with reason strings +} + +struct FilterRecommendation { + filter_id: String, + suitability: Suitability, // Ideal, Good, Marginal, Unsuitable + reason: String, + warning: Option, +} +``` + +Decision matrix (implement exactly): + +| Moon % | Object type | Order | +|--------|-------------|-------| +| 0–25 | emission/snr/pn | sv220, c2, sv260, uvir | +| 25–60 | emission/snr/pn | sv220, c2, sv260 | +| 60–95 | emission/snr/pn | sv220, c2 | +| >95 | emission/snr/pn | sv220 only | +| 0–40 | galaxy/reflection | uvir, sv260 | +| 40–55 | galaxy/reflection | sv260, uvir | +| >55 | galaxy/reflection | sv260 (warn: moon very high); skip uvir | +| any | open_cluster | uvir, sv260 | +| any | globular_cluster | uvir, sv260 | +| any | dark_nebula | uvir | + +Additional flags: +- `moon_proximity_warning: true` if moon_sep_deg < 30° +- `moon_below_horizon_bonus: true` if moon_alt_deg < 0° (upgrade marginal → good) + +--- + +## 8. Processing Workflow Definitions + +```rust +struct Workflow { + name: &'static str, + steps: &'static [&'static str], + plugins: &'static [(&'static str, &'static str)], // (plugin, one-line purpose) + notes: &'static str, +} +``` + +Define these workflows (map from `(obj_type, filter_id)` → workflow): + +**BROADBAND_OSC** (galaxy/reflection + uvir or sv260): +steps: WBPP → SPCC (GAIA DR3) → BlurXTerminator → NoiseXTerminator v3 → GHS → +DarkStructureEnhance → StarXTerminator (optional) → SetiAstro Statistical Stretch + +**HA_OIII_DUAL** (emission + sv220): +steps: WBPP → DBXtract (Ha + OIII channels) → SPCC on each channel → +NarrowBandNormalization → BlurXTerminator per channel → NoiseXTerminator v3 → +StarXTerminator → HOO composition (Ha→R, OIII→G+B) → GHS + +**SII_OIII_DUAL** (emission + c2): +steps: WBPP → DBXtract (SII + OIII channels) → NarrowBandNormalization → +BlurXTerminator → NoiseXTerminator v3 → StarXTerminator → SHO-like composition +(SII→R, Ha from sv220 if available→G, OIII→B) → GHS +note: "Combine OIII from C2 with OIII from SV220 if both sessions available. +SII at 15nm is faint — prioritize long integrations." + +**CLUSTER** (open/globular + uvir): +steps: WBPP → SPCC → BlurXTerminator (star-optimised) → NoiseXTerminator v3 → +GHS (gentle S-curve only) → no star removal + +--- + +## 9. External API Integrations + +### 9.1 7timer ASTRO API (weather intelligence) + +Endpoint: +``` +http://www.7timer.info/bin/api.pl?lon=4.1167&lat=43.8167&product=astro&output=json +``` + +No API key required. Poll every 3 hours. Store raw JSON in `weather_cache`. + +Parse and display these fields per forecast slot (3-hourly, 8 days): +- `cloudcover` (1–9 → clear to overcast) +- `seeing` (1–8 → excellent to bad, map to arcsec) +- `transparency` (1–8) +- `lifted_index` (atmospheric stability, negative = unstable) +- `rh2m` (relative humidity at 2m) +- `temp2m` +- `wind10m` → speed + direction + +**Go/No-go logic:** +```rust +fn go_nogo(cloudcover: u8, seeing: u8, transparency: u8) -> GoNogo { + if cloudcover <= 2 && seeing <= 3 && transparency <= 3 { GoNogo::Go } + else if cloudcover <= 4 && seeing <= 5 { GoNogo::Marginal } + else { GoNogo::NoGo } +} +``` + +### 9.2 Open-Meteo current conditions (dew point alert) + +Endpoint: +``` +https://api.open-meteo.com/v1/forecast + ?latitude=43.8167&longitude=4.1167 + ¤t=temperature_2m,relative_humidity_2m,dew_point_2m + &wind_speed_unit=ms +``` + +No API key required. Poll every 15 minutes. + +Dew point alert logic: +```rust +fn dew_alert(temp_c: f64, dew_point_c: f64) -> Option { + let margin = temp_c - dew_point_c; + if margin < 2.0 { Some(DewAlert::Critical) } + else if margin < 4.0 { Some(DewAlert::Warning) } + else { None } +} +``` + +--- + +## 10. Background Jobs (Tokio tasks) + +### 10.1 Catalog Refresh (weekly) +- Check `catalog.fetched_at` on startup +- If > 7 days old or table empty: fetch OpenNGC CSVs, parse, rebuild catalog table +- Log progress to stdout + +### 10.2 Nightly Precomputation (daily at astronomical dusk) +Tokio task sleeping until computed dusk time each day, then running: +1. Compute tonight's sun/moon window (dusk, dawn, moon rise/set, true dark) +2. Upsert `tonight` table +3. Load custom horizon from DB +4. For every catalog object: compute full `VisibilitySummary` +5. Upsert all rows into `nightly_cache` for today's date +6. Compute recommended filter per object given moon state +7. Log: "Nightly precompute complete: N objects processed in Xs" + +Also precompute the next 90 nights (without full visibility curve — just +`max_alt_deg`, `transit_utc`, `usable_min`, `recommended_filter`) for the +seasonal calendar. + +### 10.3 Weather Poll (every 3 hours) +Fetch 7timer + Open-Meteo. Upsert `weather_cache`. Compute go/nogo. + +### 10.4 Dew Point Poll (every 15 minutes) +Only fetches Open-Meteo current conditions. Update `weather_cache.dew_point_c`. + +--- + +## 11. PHD2 Log Parser + +PHD2 writes a CSV with a header block then data columns: +`Frame,Time,mount,dx,dy,RARawDistance,DECRawDistance,RAGuideDistance,DECGuideDistance, +RADuration,RADirection,DECDuration,DECDirection,XStep,YStep,StarMass,SNR,ErrorCode` + +```rust +pub struct Phd2Analysis { + pub duration_min: u32, + pub total_frames: u32, + pub rms_ra_arcsec: f64, + pub rms_dec_arcsec: f64, + pub rms_total_arcsec: f64, + pub peak_error_arcsec: f64, + pub star_lost_count: u32, + pub mean_snr: f64, + pub drift_ra_arcsec_per_min: f64, + pub drift_dec_arcsec_per_min: f64, +} +``` + +Parse the CSV, skip `ErrorCode != 0` rows (guide star lost events — count them), +compute RMS for RA and Dec columns in arcsec (multiply pixels by plate scale +only if pixel scale is embedded in the PHD2 header — otherwise use arcsec columns +if available). Store in `phd2_logs` table. + +The `/api/phd2/upload` endpoint accepts multipart form upload of the `.log` file. + +--- + +## 12. Image Gallery + +Images stored on disk at `/data/gallery/{catalog_id}/{filename}`. +The Docker volume maps `/data` to a NAS path. + +Accepted formats: JPEG, PNG, TIFF (convert TIFF to JPEG on ingest via the +`image` crate). Max size: 50 MB per file. + +Return gallery images via `/api/gallery/{catalog_id}/{filename}` with +`Cache-Control: max-age=31536000`. + +--- + +## 13. REST API Contract + +All responses are JSON. All timestamps are ISO 8601 UTC strings. + +``` +GET /api/targets?type=&constellation=&filter=&tonight=true&search=&sort=&page=&limit= +GET /api/targets/:id +GET /api/targets/:id/visibility # tonight's full visibility summary +GET /api/targets/:id/curve # visibility curve JSON for tonight +GET /api/targets/:id/filters # filter recommendations given tonight's moon +GET /api/targets/:id/workflow/:filter_id # processing workflow + +GET /api/tonight # full tonight summary (moon, dark window, etc.) +GET /api/calendar?months=3 # 90-day grid of nightly summaries +GET /api/calendar/:date # one night's summary with top targets + +GET /api/weather # current conditions + 7-day forecast +GET /api/weather/forecast # 8-day 3-hourly 7timer data + +GET /api/stats # imaging statistics aggregates +GET /api/log # all log entries, paginated +GET /api/log/:catalog_id # log entries for one target +POST /api/log # create log entry +PUT /api/log/:id # update log entry (quality, notes) +DELETE /api/log/:id # delete log entry + +POST /api/phd2/upload # multipart: upload + parse PHD2 log +GET /api/phd2 # list all parsed PHD2 sessions +GET /api/phd2/:id # one PHD2 analysis + +GET /api/gallery/:catalog_id # list images for target +POST /api/gallery/:catalog_id # upload image (multipart) +DELETE /api/gallery/:id + +GET /api/horizon # current horizon profile (360 points) +PUT /api/horizon # replace entire horizon (JSON array) + +GET /api/health # { status: "ok", catalog_size: N, db_version: "..." } +``` + +--- + +## 14. Docker Compose + +```yaml +# docker-compose.yml +services: + backend: + build: ./backend + restart: unless-stopped + volumes: + - ./data:/data # SQLite DB + gallery images + environment: + - DATABASE_URL=sqlite:///data/astronome.db + - RUST_LOG=info + ports: + - "3301:3301" + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "3300:80" + depends_on: + - backend + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - frontend +``` + +```nginx +# nginx.conf — reverse proxy, single origin for browser +upstream backend { server backend:3301; } +upstream frontend { server frontend:80; } + +server { + listen 80; + location /api/ { proxy_pass http://backend; } + location / { proxy_pass http://frontend; } + client_max_body_size 60M; # for image uploads +} +``` + +### Rust Dockerfile (multi-stage, minimal image) +```dockerfile +FROM rust:1.77-slim AS builder +WORKDIR /app +COPY . . +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/astronome /usr/local/bin/ +CMD ["astronome"] +``` + +### Frontend Dockerfile +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf +``` + +--- + +## 15. Frontend Architecture + +### 15.1 Dependencies + +```json +{ + "dependencies": { + "react": "^18", + "react-dom": "^18", + "react-router-dom": "^6", + "recharts": "^2", + "react-query": "^5", + "date-fns": "^3" + }, + "devDependencies": { + "typescript": "^5", + "vite": "^5", + "@types/react": "^18" + } +} +``` + +Load Aladin Lite via CDN script tag injected in index.html: +```html + +``` + +### 15.2 Design System + +**Aesthetic direction:** Deep-space observatory. Industrial-refined dark UI. +Feels like a professional astronomy workstation, not a consumer app. +Monospace coordinates. Amber accent on black. Sparse, purposeful layout. + +**Typefaces** (load from Google Fonts): +- Display / nav labels: `"Syne"` — geometric, authoritative +- Body / data: `"IBM Plex Mono"` — coordinates, numbers, timestamps feel native +- Prose / notes: `"IBM Plex Sans"` — readable at small sizes + +**CSS custom properties:** +```css +:root { + /* Backgrounds */ + --bg-void: #080a0f; /* page bg — near black with blue tint */ + --bg-deep: #0d1017; /* primary panels */ + --bg-panel: #111520; /* cards */ + --bg-row: #141825; /* table rows */ + --bg-hover: #1a2035; + + /* Accent palette */ + --amber: #e8832a; /* primary — warm, telescope-brass */ + --amber-dim: #7a4415; + --amber-glow: rgba(232,131,42,0.12); + --blue: #4d9de0; /* secondary — cool sky */ + --blue-dim: #1a3d5c; + --teal: #2ab8a0; /* tertiary */ + + /* Semantic */ + --good: #3dba72; + --warn: #e8c030; + --danger: #e05252; + --info: #4d9de0; + --muted: #3a4258; + + /* Text */ + --text-hi: #edf0f5; + --text-mid: #8892a8; + --text-lo: #4a5268; + --text-amber: #e8832a; + + /* Borders */ + --border: #1e2538; + --border-hi: #2e3858; + + /* Type */ + --font-display: 'Syne', sans-serif; + --font-mono: 'IBM Plex Mono', monospace; + --font-sans: 'IBM Plex Sans', sans-serif; +} +``` + +**Grid:** left sidebar (220px, fixed) + main content area. No top navbar. + +**Motion:** Subtle only. Row expand with `max-height` CSS transition (200ms ease). +Drawer tabs fade in (150ms). No page transitions. No loading skeletons that bounce. + +**Type badges:** each object type gets a distinctive pill: +- GX: `--blue` bg, "GX" label +- EN: `--teal` bg, "EN" +- PN: `--good` bg, "PN" +- SNR: `--amber` bg, "SNR" +- GC: `#9b59b6` bg, "GC" +- OC: `#f1c40f` bg dark text, "OC" +- RN: `#e67e22` bg, "RN" +- DN: `--muted` bg, "DN" + +**Quality flag chips:** +- keeper: `--good` bg + checkmark +- needs_more: `--blue` bg + arrow +- rejected: `--danger` bg + cross +- pending: `--muted` bg + dot + +### 15.3 Sidebar Navigation + +``` +ASTRONOME ← logo in --amber, font-display + +[icon] Dashboard +[icon] Targets +[icon] Calendar +[icon] Statistics +[icon] Settings + +───────────────── +Tonight + Dusk 21:34 + Dawn 05:12 + Dark 23:10–02:45 + Moon 34% ◑ + +───────────────── +Conditions + [go/nogo badge] + Seeing: 2.1″ + Transp: Good + Temp: 12°C + Dew Δ: 6°C ✓ +``` + +Sidebar data updates every 60 seconds via react-query polling. + +--- + +## 16. Pages + +### 16.1 Dashboard + +Four stat cards at top: +- Go/No-go indicator for tonight (large, colored) +- Moon illumination + phase icon +- True dark window duration ("3h 35min of full dark") +- Best target tonight (highest-altitude, best-filtered) + +Below: 2-column layout +- Left: 7-day weather forecast (cloudcover + seeing bars per day) +- Right: Top 5 targets tonight (compact list with altitude + filter pill) + +Dew point alert banner (full-width, --danger bg) if margin < 4°C. + +### 16.2 Targets Page + +**Filter bar** (horizontal, sticky below header): +- Type chips: All · Galaxy · Emission · Reflection · Planetary · SNR · Cluster · Dark +- Constellation dropdown (grouped alphabetically) +- Filter fit: UV/IR · SV260 · C2 · SV220 +- Status: Tonight only (default ON) · Not yet imaged · Needs more data · All +- Sort: Best alt tonight · Transit · Size · Magnitude · Difficulty · Total integration +- Search input (fuzzy: name, common name, NGC, IC, M) + +**Target list:** +Compact rows. Columns: +``` +[TYPE] [NAME / COMMON NAME] [CONST] [SIZE ′] [FILL %] [MOSAIC?] [MAG] [SB] [★] [FILTER] [ALT NOW] [VIS BAR] [QUALITY] +``` + +- SIZE: `maj × min` in arcmin +- FILL %: color-coded: >80% green, 40–80% amber, <40% muted +- MOSAIC?: if true, show `2×1` or `3×2` etc. in --warn color +- SB: surface brightness mag/arcsec² — only for nebulae/galaxies +- ★: difficulty 1–5 as dot cluster +- ALT NOW: live, updates every 30s, color-coded +- VIS BAR: 80px inline SVG showing tonight arc +- QUALITY: quality chip from last log entry, or "—" if never imaged + +Rows with `is_visible_tonight = false`: opacity 0.35, italic, pushed to bottom. + +Click anywhere on row → expands detail drawer below (accordion, one open at a time). + +### 16.3 Detail Drawer (4 tabs) + +**Tab 1 — Tonight** +- Altitude curve (Recharts LineChart): x = dusk to dawn UTC, y = 0–90° + - Custom horizon line (from DB, render as step function) + - 15° line (dashed muted) + - 30° line (dashed good) + - Moon altitude curve (dimmed --blue line) + - Object altitude curve: colored segment: >30° = --good, 15–30° = --warn, <15° = --danger + - Vertical line at current time (--amber) + - Meridian flip marker (--amber dashed vertical) + - True dark window shaded background region (subtle --amber-glow) +- Key times table (monospace): Rise / Transit / Set / Best window / Flip / Moon sep +- Airmass at transit + extinction in magnitudes + +**Tab 2 — Target** +- Left: DSS image thumbnail (fetch from STScI DSS endpoint) + `https://archive.stsci.edu/cgi-bin/dss_search?v=poss2ukstu_red&r={ra}&d={dec}&e=J2000&h={fov_h_arcmin}&w={fov_w_arcmin}&f=gif` +- Right: metadata table (type, constellation, RA/Dec sexagesimal + decimal, size, mag, SB, hubble type) +- Below: Aladin Lite embed (lazy-init on tab open) + Survey: P/DSS2/color. FOV: 4.0°. Draw amber rectangle 2.75°×1.84° centered on target. + If mosaic: draw all panels as individual rectangles with overlap shading. +- Guiding context badge: star density + note e.g. "Sparse field — OAG may struggle. Consider bright guide star nearby." + +**Tab 3 — Filters & Workflow** +Filter recommendation table (all 4 filters, ordered by suitability): +``` +[FILTER NAME] [SUITABILITY PILL] [REASON] [EST. INTEGRATION] [SESSIONS NEEDED] +``` +Integration estimates table: +```rust +// hours to usable result: Bortle 5, f/6.9, OSC +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 } +``` +Sessions needed = ceil(hours / 2). + +Below: Processing Workflow card (collapsible): +- Workflow name + numbered step list +- Plugin table: plugin name | purpose in one line +- Notes in italic + +**Tab 4 — Log & Gallery** +Two sub-columns: +- Left: Session log list (most recent first). Each entry: date · filter pill · duration · quality chip · guiding RMS if available · notes. Edit button on each. + Add session form at top: date picker · filter select · duration (min) · quality select · notes · PHD2 log file upload (optional) · Save. + Total accumulated: "X sessions · Y h Z min total" +- Right: Image gallery grid. Thumbnail grid (3 cols). Click → lightbox fullscreen. + Upload zone (drag-and-drop or click) with JPEG/PNG/TIFF accept. + +### 16.4 Calendar Page + +12-month grid (or 3-month depending on API param). Each month = a calendar grid. +Each day cell shows: +- Moon phase icon (SVG crescent drawn from illumination %) +- Usable dark hours bar (0–8h range, colored by go/nogo if forecast available) +- Color-coded bg: full dark (>4h) = deep teal; partial dark = amber; full moon = muted + +Clicking a day → side panel showing: +- That night's summary (dusk/dawn, moon rise/set, true dark window) +- Top 10 targets for that night (pre-computed) +- Weather forecast for that night (7timer data) + +Lunar cycle overlay: new moon nights have a `●` marker. Full moon nights `○`. +Best narrowband nights (illumination < 20%) highlighted with --amber border. + +### 16.5 Statistics Page + +Header stat cards (from SQL aggregates): +- Total sessions logged +- Total integration time (h) +- Objects imaged (unique catalog_ids with at least one keeper) +- Filters usage pie (Recharts PieChart) + +Charts: +- Integration per month (bar chart, last 12 months) +- Object type breakdown (horizontal bar) +- Filter usage per target type (stacked bar) +- Guiding RMS over time (scatter plot from phd2_logs — x = date, y = rms_total) +- Best and worst seeing nights correlation chart (7timer seeing vs guiding RMS) + +Target list ordered by: most integrated · most sessions · most recent + +Quality breakdown table: keeper vs needs_more vs rejected per object type. + +### 16.6 Settings Page + +**Custom Horizon:** +- Interactive polar chart (SVG) showing current horizon profile +- Upload CSV button: format `az_deg,alt_deg` one row per degree +- Manual edit mode: click on the polar chart to set a horizon point +- Reset to flat 15° button +- Preview: shows how current horizon affects tonight's top 10 targets + +**App info:** +- Catalog stats: N objects, last refreshed date, Refresh now button +- DB path, DB size +- Backend version + +--- + +## 17. New Moon Calendar View + +Accessible from the Calendar page as a "New Moon" toggle. +Shows the next 12 months as a horizontal timeline. +Each lunar cycle (29.5 days) rendered as a row with: +- Dark green zone: illumination < 20% (prime narrowband window) +- Amber zone: 20–50% +- Red zone: >50% (broadband only) +- Markers: new moon date, first/last quarter, full moon date + +Overlay: for each new moon window, show the 3 best emission nebula targets that +transit highest in that window (from the seasonal precomputed data). + +--- + +## 18. What NOT to Build + +- No NINA integration or sequence export +- No planetary or solar targets +- No mono / LRGB workflows +- No user authentication +- No cloud sync +- No mobile layout (desktop-first, min-width 1280px) +- No live telescope control +- No PixInsight scripting automation + +--- + +## 19. Code Quality Standards + +**Rust:** +- Edition 2021 +- `thiserror` for error types, `anyhow` for application-level errors +- `tracing` + `tracing-subscriber` for structured logging +- `tokio` multi-thread runtime +- All DB access via `sqlx` with compile-time query checking (`query!` macro) +- No `unwrap()` in non-test code — use `?` operator throughout +- Separate `AppState` struct passed via Axum state +- Run `cargo clippy -- -D warnings` clean + +**React / TypeScript:** +- Strict TypeScript (`"strict": true`) +- All API responses typed via interfaces mirroring Rust structs +- `react-query` for all data fetching — no raw `useEffect` fetches +- Components under 200 lines; extract sub-components aggressively +- No inline styles except for dynamic values (chart colors, animation delays) +- All CSS in `*.module.css` files or `tokens.css` custom properties + +**Both:** +- Comments in English +- No secrets or API keys in code (all external APIs used are keyless) \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3253959 --- /dev/null +++ b/TODO.md @@ -0,0 +1,73 @@ +# Astronome — TODO + +## Missing Spec Items + +- [x] **Settings — catalog info** — shows last refreshed date + DB size in App Info table +- [x] **Detail Drawer Tab 3 — workflow card** — WorkflowCard renders steps + notes (was already built, now confirmed) +- [x] **Stats page — guiding charts** — Guiding RMS over time as a proper LineChart (Total/RA/Dec lines) +- [x] **Calendar day panel — weather** — clicking a day shows go/nogo, temp, cloud cover, seeing from 7timer +- [x] **New Moon timeline — best targets overlay** — top 3 emission nebulae shown per new moon window (from `/api/calendar/new-moon-windows`) + +--- + +## New Features + +### High Priority + +- [x] **Integration progress tracker** — per-target progress bar (% of goal hours) with color coding; goal hours from CLAUDE.md §16.3 table; shown on TargetRow in a "Goal" column +- [x] **Nightly recompute trigger** — "Recompute Tonight" button in Settings → POST `/api/nightly/recompute` +- [x] **Export imaging log as CSV** — "↓ Export Log CSV" button on Stats page → GET `/api/log/export` + +### Medium Priority + +- [x] **Object planning notes** — per-target free-text field in Detail Drawer Tab 4; saved to `target_notes` table; auto-saves on blur +- [x] **Filter accumulation breakdown** — keepers-only integration per filter shown in Detail Drawer Tab 4 (above session list); from `/api/log/:id` `filter_breakdown` field +- [x] **Moon avoidance cone on altitude curve** — blue shading when moon is above horizon; amber shading when moon sep < 30° + +### 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) + +--- + +## 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. + +- [ ] **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`. + +- [ ] **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. + +- [ ] **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. + +--- + +## 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. + +- [ ] **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. + +- [ ] **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. + +- [ ] **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. + +--- + +## Post-Processing + +- [ ] **NINA Target Scheduler import** — parse NINA Target Scheduler zip (`askar71f.zip` at project root) to bulk-import targets and existing session history. + +- [ ] **PixInsight WBPP project generator** — button on Detail Drawer Tab 3 that generates a ready-to-use WBPP folder structure (or `.xisf` project stub) for the selected filter + workflow. Eliminates manual setup before processing. + +--- + +## 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`. + +- [ ] **Check LdN and VdB implementations** (fetch LDN from internet), and `popular_names.rs` + +- [ ] **"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. + +- [ ] **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`. diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..d0b2170 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "astronome" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "astronome" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["multipart"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls", "chrono"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +anyhow = "1" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.11", features = ["json"] } +csv = "1" +tower-http = { version = "0.5", features = ["cors", "fs"] } +image = { version = "0.24", default-features = false, features = ["jpeg", "png", "tiff"] } +uuid = { version = "1", features = ["v4"] } +tokio-cron-scheduler = "0.9" +mime = "0.3" +bytes = "1" +sgp4 = "2.4.0" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d430cc9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM rust:latest AS builder + +WORKDIR /app + +ENV CARGO_HOME=/usr/local/cargo +ENV CARGO_TARGET_DIR=/app/target + +RUN apt-get update && apt-get install -y pkg-config libssl-dev + +COPY Cargo.toml ./ + +# clean build (important) +RUN cargo clean || true + +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release + +RUN rm -rf src +COPY src ./src + +RUN cargo build --release \ No newline at end of file diff --git a/backend/src/api/calendar.rs b/backend/src/api/calendar.rs new file mode 100644 index 0000000..bb00d62 --- /dev/null +++ b/backend/src/api/calendar.rs @@ -0,0 +1,242 @@ +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::astronomy::{julian_date, moon_illumination}; + +use super::{AppError, AppState}; + +/// Returns new moon windows (dates where moon < 5%) with top 3 emission nebulae each. +pub async fn get_new_moon_windows( + State(state): State, +) -> Result, AppError> { + use sqlx::Row; + + // Get all new moon dates in the next 365 days + let today = chrono::Utc::now().naive_utc().date(); + let end = today + chrono::Duration::days(365); + + let mut windows: Vec = Vec::new(); + let mut cur = today; + let mut prev_illum = moon_illum_for_date(cur); + + while cur <= end { + let illum = moon_illum_for_date(cur); + let next_illum = moon_illum_for_date(cur + chrono::Duration::days(1)); + + // New moon = local minimum < 5% + if illum < 0.05 && illum <= prev_illum && illum <= next_illum { + let date_str = cur.to_string(); + + // Top 3 emission nebulae for this night from nightly_cache + let targets = sqlx::query( + r#"SELECT c.id, c.name, c.common_name, nc.max_alt_deg, nc.recommended_filter + FROM nightly_cache nc + JOIN catalog c ON c.id = nc.catalog_id + WHERE nc.night_date = ? + AND c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula') + 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 = targets.iter().map(|r| serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), + })).collect(); + + windows.push(serde_json::json!({ + "date": date_str, + "illumination": illum, + "top_targets": top_targets, + })); + } + + prev_illum = illum; + cur += chrono::Duration::days(1); + } + + Ok(Json(serde_json::json!({ "windows": windows }))) +} + +#[derive(Debug, Deserialize)] +pub struct CalendarQuery { + pub months: Option, +} + +/// Compute moon illumination for a given calendar date (at 21:00 UTC = start of night). +fn moon_illum_for_date(date: NaiveDate) -> f64 { + let dt = date.and_hms_opt(21, 0, 0) + .map(|dt| chrono::DateTime::from_naive_utc_and_offset(dt, chrono::Utc)) + .unwrap_or_else(chrono::Utc::now); + let jd = julian_date(dt); + moon_illumination(jd) +} + +pub async fn get_calendar( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let months = params.months.unwrap_or(3).min(12) as i64; + let today = chrono::Utc::now().naive_utc().date(); + let end = today + chrono::Duration::days(months * 30); + + // Pull nightly cache data for the date range + let rows = sqlx::query( + r#"SELECT + nc.night_date, + COUNT(CASE WHEN nc.max_alt_deg >= 15 THEN 1 END) as visible_count, + MAX(nc.usable_min) as max_usable_min, + AVG(nc.max_alt_deg) as avg_max_alt + FROM nightly_cache nc + WHERE nc.night_date >= ? AND nc.night_date <= ? + GROUP BY nc.night_date + ORDER BY nc.night_date"#, + ) + .bind(today.to_string()) + .bind(end.to_string()) + .fetch_all(&state.pool) + .await?; + + // Build a map from date string → nightly cache data + let cache_map: std::collections::HashMap = rows.iter().map(|r| { + use sqlx::Row; + let date = r.try_get::, _>("night_date").unwrap_or_default().unwrap_or_default(); + let visible = r.try_get::, _>("visible_count").unwrap_or_default().unwrap_or(0); + let usable = r.try_get::, _>("max_usable_min").unwrap_or_default().unwrap_or(0); + let avg_alt = r.try_get::, _>("avg_max_alt").unwrap_or_default().unwrap_or(0.0); + (date, (visible, usable, avg_alt)) + }).collect(); + + // Generate a day entry for every calendar day in range (so moon is always shown) + let mut days = Vec::new(); + let mut cur = today; + while cur <= end { + let date_str = cur.to_string(); + let moon_illum = moon_illum_for_date(cur); + let (visible_count, max_usable_min, avg_max_alt) = cache_map.get(&date_str) + .copied() + .unwrap_or((0, 0, 0.0)); + + days.push(serde_json::json!({ + "date": date_str, + "visible_count": visible_count, + "max_usable_min": max_usable_min, + "avg_max_alt": avg_max_alt, + "moon_illumination": moon_illum, + })); + cur += chrono::Duration::days(1); + } + + Ok(Json(serde_json::json!({ "days": days }))) +} + +pub async fn get_calendar_date( + State(state): State, + Path(date): Path, +) -> Result, AppError> { + use sqlx::Row; + + // Top 10 targets for this night + let targets = sqlx::query( + r#"SELECT c.id, c.name, c.common_name, c.obj_type, + nc.max_alt_deg, nc.usable_min, nc.transit_utc, 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 >= 15 + ORDER BY nc.max_alt_deg DESC + LIMIT 10"#, + ) + .bind(&date) + .fetch_all(&state.pool) + .await?; + + let target_list: Vec = targets.iter().map(|r| { + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), + "usable_min": r.try_get::, _>("usable_min").unwrap_or_default(), + "transit_utc": r.try_get::, _>("transit_utc").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), + }) + }).collect(); + + // Tonight summary from the `tonight` table (only available for tonight's date) + let tonight_row = sqlx::query("SELECT * FROM tonight WHERE id = 1 AND date = ?") + .bind(&date) + .fetch_optional(&state.pool) + .await?; + + let tonight_summary = tonight_row.map(|r| serde_json::json!({ + "astro_dusk_utc": r.try_get::, _>("astro_dusk_utc").unwrap_or_default(), + "astro_dawn_utc": r.try_get::, _>("astro_dawn_utc").unwrap_or_default(), + "moon_rise_utc": r.try_get::, _>("moon_rise_utc").unwrap_or_default(), + "moon_set_utc": r.try_get::, _>("moon_set_utc").unwrap_or_default(), + "moon_illumination": r.try_get::, _>("moon_illumination").unwrap_or_default(), + "moon_phase_name": r.try_get::, _>("moon_phase_name").unwrap_or_default(), + "true_dark_start_utc": r.try_get::, _>("true_dark_start_utc").unwrap_or_default(), + "true_dark_end_utc": r.try_get::, _>("true_dark_end_utc").unwrap_or_default(), + "true_dark_minutes": r.try_get::, _>("true_dark_minutes").unwrap_or_default(), + })); + + // Weather summary from cache (only meaningful for today/near future) + let weather_row = sqlx::query( + "SELECT go_nogo, temp_c, dew_point_c, seventimer_json FROM weather_cache WHERE id = 1" + ) + .fetch_optional(&state.pool) + .await?; + + let weather_summary = weather_row.map(|r| { + let go_nogo = r.try_get::, _>("go_nogo").unwrap_or_default(); + let temp_c = r.try_get::, _>("temp_c").unwrap_or_default(); + let dew_c = r.try_get::, _>("dew_point_c").unwrap_or_default(); + let seventimer: Option = r.try_get::, _>("seventimer_json") + .unwrap_or_default() + .and_then(|s| serde_json::from_str(&s).ok()); + + // Extract tonight's cloudcover/seeing from 7timer if available + let (cloudcover, seeing, transparency) = seventimer + .as_ref() + .and_then(|v| v.get("dataseries")?.as_array()?.first().cloned()) + .map(|slot| ( + slot.get("cloudcover").and_then(|v| v.as_i64()), + slot.get("seeing").and_then(|v| v.as_i64()), + slot.get("transparency").and_then(|v| v.as_i64()), + )) + .unwrap_or((None, None, None)); + + serde_json::json!({ + "go_nogo": go_nogo, + "temp_c": temp_c, + "dew_point_c": dew_c, + "cloudcover": cloudcover, + "seeing": seeing, + "transparency": transparency, + }) + }); + + // Moon illumination for the requested date (always computable) + let requested_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().naive_utc().date()); + let moon_illum = moon_illum_for_date(requested_date); + + Ok(Json(serde_json::json!({ + "date": date, + "moon_illumination": moon_illum, + "top_targets": target_list, + "tonight": tonight_summary, + "weather": weather_summary, + }))) +} diff --git a/backend/src/api/gallery.rs b/backend/src/api/gallery.rs new file mode 100644 index 0000000..e66c4fb --- /dev/null +++ b/backend/src/api/gallery.rs @@ -0,0 +1,185 @@ +use axum::{ + extract::{Multipart, Path, State}, + Json, +}; +use std::path::PathBuf; + +use super::{AppError, AppState}; + +const GALLERY_DIR: &str = "/data/gallery"; + +pub async fn list_all_gallery( + State(state): State, +) -> Result, AppError> { + let rows = sqlx::query( + r#"SELECT g.id, g.catalog_id, g.filename, g.caption, g.created_at, + c.name AS target_name, c.common_name AS target_common_name + FROM gallery g + LEFT JOIN catalog c ON c.id = g.catalog_id + ORDER BY g.created_at DESC"#, + ) + .fetch_all(&state.pool) + .await?; + + let items: Vec = rows.iter().map(|r| { + use sqlx::Row; + let id: i32 = r.try_get("id").unwrap_or_default(); + let catalog_id: String = r.try_get("catalog_id").unwrap_or_default(); + let filename: String = r.try_get("filename").unwrap_or_default(); + serde_json::json!({ + "id": id, + "catalog_id": &catalog_id, + "filename": &filename, + "url": format!("/api/gallery/{}/{}", catalog_id, filename), + "caption": r.try_get::, _>("caption").unwrap_or_default(), + "created_at": r.try_get::("created_at").unwrap_or_default(), + "target_name": r.try_get::, _>("target_name").unwrap_or_default(), + "target_common_name": r.try_get::, _>("target_common_name").unwrap_or_default(), + }) + }).collect(); + + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn list_gallery( + State(state): State, + Path(catalog_id): Path, +) -> Result, AppError> { + let rows = sqlx::query( + "SELECT * FROM gallery WHERE catalog_id = ? ORDER BY created_at DESC", + ) + .bind(&catalog_id) + .fetch_all(&state.pool) + .await?; + + let items: Vec = rows.iter().map(|r| { + use sqlx::Row; + let id: i32 = r.try_get("id").unwrap_or_default(); + let filename: String = r.try_get("filename").unwrap_or_default(); + serde_json::json!({ + "id": id, + "catalog_id": catalog_id, + "filename": filename, + "url": format!("/api/gallery/{}/{}", catalog_id, filename), + "caption": r.try_get::, _>("caption").unwrap_or_default(), + "created_at": r.try_get::("created_at").unwrap_or_default(), + }) + }).collect(); + + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn upload_image( + State(state): State, + Path(catalog_id): Path, + mut multipart: Multipart, +) -> Result, AppError> { + let mut image_bytes: Option> = None; + let mut orig_filename = String::from("image.jpg"); + let mut caption: Option = None; + let mut log_id: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + orig_filename = field.file_name().unwrap_or("image.jpg").to_string(); + let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?; + if bytes.len() > 50 * 1024 * 1024 { + return Err(AppError::BadRequest("File exceeds 50MB limit".to_string())); + } + image_bytes = Some(bytes.to_vec()); + } + "caption" => { + caption = Some(field.text().await.map_err(|e| AppError::Internal(e.to_string()))?); + } + "log_id" => { + log_id = field.text().await.ok().and_then(|s| s.parse().ok()); + } + _ => {} + } + } + + let bytes = image_bytes.ok_or_else(|| AppError::BadRequest("No file uploaded".to_string()))?; + + // Convert TIFF to JPEG if needed, else store as-is + let ext = std::path::Path::new(&orig_filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("jpg") + .to_lowercase(); + + let (final_bytes, final_ext) = if ext == "tiff" || ext == "tif" { + match convert_tiff_to_jpeg(&bytes) { + Ok(jpeg) => (jpeg, "jpg".to_string()), + Err(_) => (bytes, ext), + } + } else { + (bytes, ext) + }; + + // Generate unique filename + let uid = uuid::Uuid::new_v4(); + let filename = format!("{}.{}", uid, final_ext); + + // Ensure directory exists + let dir = PathBuf::from(GALLERY_DIR).join(&catalog_id); + tokio::fs::create_dir_all(&dir) + .await + .map_err(|e| AppError::Internal(format!("Failed to create gallery dir: {}", e)))?; + + let file_path = dir.join(&filename); + tokio::fs::write(&file_path, &final_bytes) + .await + .map_err(|e| AppError::Internal(format!("Failed to write image: {}", e)))?; + + let id: i64 = sqlx::query_scalar( + "INSERT INTO gallery (catalog_id, log_id, filename, caption) VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(&catalog_id) + .bind(log_id) + .bind(&filename) + .bind(&caption) + .fetch_one(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ + "id": id, + "catalog_id": catalog_id, + "filename": filename, + "url": format!("/api/gallery/{}/{}", catalog_id, filename), + }))) +} + +pub async fn delete_image( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let row = sqlx::query("SELECT catalog_id, filename FROM gallery WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Gallery image {} not found", id)))?; + + use sqlx::Row; + let catalog_id: String = row.try_get("catalog_id").unwrap_or_default(); + let filename: String = row.try_get("filename").unwrap_or_default(); + + let file_path = PathBuf::from(GALLERY_DIR).join(&catalog_id).join(&filename); + let _ = tokio::fs::remove_file(&file_path).await; + + sqlx::query("DELETE FROM gallery WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "status": "deleted" }))) +} + +fn convert_tiff_to_jpeg(bytes: &[u8]) -> anyhow::Result> { + let img = image::load_from_memory(bytes)?; + let mut output = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut output); + img.write_to(&mut cursor, image::ImageOutputFormat::Jpeg(90))?; + Ok(output) +} diff --git a/backend/src/api/horizon.rs b/backend/src/api/horizon.rs new file mode 100644 index 0000000..8533f6c --- /dev/null +++ b/backend/src/api/horizon.rs @@ -0,0 +1,50 @@ +use axum::{extract::State, Json}; +use serde::Deserialize; + +use crate::astronomy::HorizonPoint; + +use super::{AppError, AppState}; + +#[derive(Debug, Deserialize)] +pub struct HorizonEntry { + pub az_deg: i32, + pub alt_deg: f64, +} + +pub async fn get_horizon( + State(state): State, +) -> Result, AppError> { + let points: Vec = sqlx::query_as( + "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", + ) + .fetch_all(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "points": points }))) +} + +pub async fn put_horizon( + State(state): State, + Json(body): Json>, +) -> Result, AppError> { + if body.len() != 360 { + return Err(AppError::BadRequest(format!( + "Horizon must have exactly 360 points, got {}", + body.len() + ))); + } + + let mut tx = state.pool.begin().await?; + for entry in &body { + let az = entry.az_deg.rem_euclid(360); + let alt = entry.alt_deg.clamp(0.0, 90.0); + sqlx::query("UPDATE horizon SET alt_deg = ? WHERE az_deg = ?") + .bind(alt) + .bind(az) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + Ok(Json(serde_json::json!({ "status": "updated", "count": body.len() }))) +} diff --git a/backend/src/api/log.rs b/backend/src/api/log.rs new file mode 100644 index 0000000..ed492cc --- /dev/null +++ b/backend/src/api/log.rs @@ -0,0 +1,242 @@ +use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; + +use super::{AppError, AppState}; + +#[derive(Debug, Deserialize)] +pub struct LogQuery { + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct CreateLogEntry { + pub catalog_id: String, + pub session_date: String, + pub filter_id: String, + pub integration_min: i32, + pub quality: Option, + pub notes: Option, + pub guiding_rms: Option, + pub mean_temp_c: Option, + pub phd2_log_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct UpdateLogEntry { + pub quality: Option, + pub notes: Option, + pub guiding_rms: Option, +} + +pub async fn list_log( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let page = params.page.unwrap_or(1).max(1); + let limit = params.limit.unwrap_or(50).min(200); + let offset = (page - 1) * limit; + + let rows = sqlx::query( + r#"SELECT l.*, c.name, c.common_name, c.obj_type + FROM imaging_log l + JOIN catalog c ON c.id = l.catalog_id + ORDER BY l.session_date DESC, l.created_at DESC + LIMIT ? OFFSET ?"#, + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.pool) + .await?; + + let items = rows.iter().map(row_to_json).collect::>(); + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM imaging_log") + .fetch_one(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "limit": limit }))) +} + +pub async fn get_target_log( + State(state): State, + Path(catalog_id): Path, +) -> Result, AppError> { + let rows = sqlx::query( + r#"SELECT l.*, c.name, c.common_name, c.obj_type + FROM imaging_log l + JOIN catalog c ON c.id = l.catalog_id + WHERE l.catalog_id = ? + ORDER BY l.session_date DESC"#, + ) + .bind(&catalog_id) + .fetch_all(&state.pool) + .await?; + + let items = rows.iter().map(row_to_json).collect::>(); + + let total_min: Option = sqlx::query_scalar( + "SELECT SUM(integration_min) FROM imaging_log WHERE catalog_id = ?", + ) + .bind(&catalog_id) + .fetch_optional(&state.pool) + .await? + .flatten(); + + // Filter breakdown: keeper hours per filter + let breakdown_rows = sqlx::query( + r#"SELECT filter_id, + SUM(integration_min) as total_min, + COUNT(*) as sessions + FROM imaging_log + WHERE catalog_id = ? AND quality = 'keeper' + GROUP BY filter_id"#, + ) + .bind(&catalog_id) + .fetch_all(&state.pool) + .await?; + + let filter_breakdown: Vec = breakdown_rows.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "filter_id": r.try_get::("filter_id").unwrap_or_default(), + "total_min": r.try_get::("total_min").unwrap_or_default(), + "sessions": r.try_get::("sessions").unwrap_or_default(), + }) + }).collect(); + + Ok(Json(serde_json::json!({ + "catalog_id": catalog_id, + "items": items, + "total_integration_min": total_min.unwrap_or(0), + "filter_breakdown": filter_breakdown, + }))) +} + +/// Export all imaging log entries as a CSV file. +pub async fn export_log_csv( + State(state): State, +) -> impl IntoResponse { + let rows = sqlx::query( + r#"SELECT l.session_date, c.name, c.common_name, c.obj_type, + l.filter_id, l.integration_min, l.quality, + l.guiding_rms, l.mean_temp_c, l.notes + FROM imaging_log l + JOIN catalog c ON c.id = l.catalog_id + ORDER BY l.session_date DESC, l.created_at DESC"#, + ) + .fetch_all(&state.pool) + .await + .unwrap_or_default(); + + let mut csv = String::from("date,target,common_name,type,filter,integration_min,quality,guiding_rms,temp_c,notes\n"); + for r in &rows { + use sqlx::Row; + let date = r.try_get::("session_date").unwrap_or_default(); + let name = r.try_get::("name").unwrap_or_default(); + let common = r.try_get::, _>("common_name").unwrap_or_default().unwrap_or_default(); + let obj_type = r.try_get::("obj_type").unwrap_or_default(); + let filter = r.try_get::("filter_id").unwrap_or_default(); + let mins = r.try_get::("integration_min").unwrap_or_default(); + let quality = r.try_get::("quality").unwrap_or_default(); + let rms = r.try_get::, _>("guiding_rms").unwrap_or_default() + .map(|v| format!("{:.2}", v)).unwrap_or_default(); + let temp = r.try_get::, _>("mean_temp_c").unwrap_or_default() + .map(|v| format!("{:.1}", v)).unwrap_or_default(); + let notes = r.try_get::, _>("notes").unwrap_or_default() + .unwrap_or_default().replace('"', "\"\""); + csv.push_str(&format!( + "{},{},{},{},{},{},{},{},{},\"{}\"\n", + date, name, common, obj_type, filter, mins, quality, rms, temp, notes + )); + } + + ( + [ + ("Content-Type", "text/csv; charset=utf-8"), + ("Content-Disposition", "attachment; filename=\"astronome_log.csv\""), + ], + csv, + ) +} + +pub async fn create_log( + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let quality = body.quality.as_deref().unwrap_or("pending"); + + let id: i64 = sqlx::query_scalar( + r#"INSERT INTO imaging_log + (catalog_id, session_date, filter_id, integration_min, quality, notes, + guiding_rms, mean_temp_c, phd2_log_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id"#, + ) + .bind(&body.catalog_id) + .bind(&body.session_date) + .bind(&body.filter_id) + .bind(body.integration_min) + .bind(quality) + .bind(&body.notes) + .bind(body.guiding_rms) + .bind(body.mean_temp_c) + .bind(body.phd2_log_id) + .fetch_one(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "status": "created" }))) +} + +pub async fn update_log( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + sqlx::query( + "UPDATE imaging_log SET quality = COALESCE(?, quality), notes = COALESCE(?, notes), guiding_rms = COALESCE(?, guiding_rms) WHERE id = ?", + ) + .bind(&body.quality) + .bind(&body.notes) + .bind(body.guiding_rms) + .bind(id) + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "status": "updated" }))) +} + +pub async fn delete_log( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + sqlx::query("DELETE FROM imaging_log WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "id": id, "status": "deleted" }))) +} + +fn row_to_json(r: &sqlx::sqlite::SqliteRow) -> serde_json::Value { + use sqlx::Row; + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "catalog_id": r.try_get::("catalog_id").unwrap_or_default(), + "session_date": r.try_get::("session_date").unwrap_or_default(), + "filter_id": r.try_get::("filter_id").unwrap_or_default(), + "integration_min": r.try_get::("integration_min").unwrap_or_default(), + "quality": r.try_get::("quality").unwrap_or_default(), + "notes": r.try_get::, _>("notes").unwrap_or_default(), + "guiding_rms": r.try_get::, _>("guiding_rms").unwrap_or_default(), + "mean_temp_c": r.try_get::, _>("mean_temp_c").unwrap_or_default(), + "created_at": r.try_get::("created_at").unwrap_or_default(), + "target_name": r.try_get::, _>("name").unwrap_or_default(), + "target_common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "target_obj_type": r.try_get::, _>("obj_type").unwrap_or_default(), + }) +} diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs new file mode 100644 index 0000000..7552873 --- /dev/null +++ b/backend/src/api/mod.rs @@ -0,0 +1,244 @@ +pub mod calendar; +pub mod gallery; +pub mod horizon; +pub mod log; +pub mod phd2; +pub mod solar_system; +pub mod stats; +pub mod targets; +pub mod tonight; +pub mod weather; + +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post, put}, + Json, Router, +}; +use sqlx::SqlitePool; + +use crate::catalog::force_refresh_catalog; + +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, +} + +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("Database error: {0}")] + Db(#[from] sqlx::Error), + #[error("Not found: {0}")] + NotFound(String), + #[error("Bad request: {0}")] + BadRequest(String), + #[error("Internal error: {0}")] + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::Db(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), + }; + (status, Json(serde_json::json!({ "error": message }))).into_response() + } +} + +pub fn build_router(pool: SqlitePool) -> Router { + let state = AppState { pool }; + + // Gallery static files + let gallery_dir = std::path::PathBuf::from("/data/gallery"); + let _ = std::fs::create_dir_all(&gallery_dir); + + Router::new() + // Health + .route("/api/health", get(health)) + // Targets + .route("/api/targets", get(targets::list_targets)) + .route("/api/targets/:id", get(targets::get_target)) + .route("/api/targets/:id/visibility", get(targets::get_visibility)) + .route("/api/targets/:id/curve", get(targets::get_curve)) + .route("/api/targets/:id/filters", get(targets::get_filters)) + .route("/api/targets/:id/workflow/:filter_id", get(targets::get_workflow_handler)) + .route("/api/targets/:id/yearly", get(targets::get_yearly)) + .route("/api/targets/:id/notes", get(targets::get_notes).put(targets::put_notes)) + // Tonight + .route("/api/tonight", get(tonight::get_tonight)) + // Calendar + .route("/api/calendar", get(calendar::get_calendar)) + .route("/api/calendar/new-moon-windows", get(calendar::get_new_moon_windows)) + .route("/api/calendar/:date", get(calendar::get_calendar_date)) + // Weather + .route("/api/weather", get(weather::get_weather)) + .route("/api/weather/forecast", get(weather::get_forecast)) + // Log + .route("/api/log", get(log::list_log).post(log::create_log)) + .route("/api/log/export", get(log::export_log_csv)) + .route("/api/log/:catalog_id", get(log::get_target_log)) + .route("/api/log/entry/:id", put(log::update_log).delete(log::delete_log)) + // PHD2 + .route("/api/phd2/upload", post(phd2::upload_phd2)) + .route("/api/phd2", get(phd2::list_phd2)) + .route("/api/phd2/:id", get(phd2::get_phd2).delete(phd2::delete_phd2)) + // Gallery + .route("/api/gallery", get(gallery::list_all_gallery)) + .route("/api/gallery/:catalog_id", get(gallery::list_gallery).post(gallery::upload_image)) + .route("/api/gallery/item/:id", delete(gallery::delete_image)) + // Horizon + .route("/api/horizon", get(horizon::get_horizon).put(horizon::put_horizon)) + // Solar System + .route("/api/solar-system", get(solar_system::get_solar_system)) + // Custom targets + .route("/api/custom-targets", get(solar_system::list_custom_targets).post(solar_system::create_custom_target)) + .route("/api/custom-targets/:id", delete(solar_system::delete_custom_target)) + // Admin + .route("/api/catalog/refresh", post(catalog_refresh)) + .route("/api/catalog/rebuild", get(catalog_rebuild)) + .route("/api/nightly/recompute", post(nightly_recompute)) + // Stats + .route("/api/stats", get(stats::get_stats)) + // Static gallery files served via tower-http + .nest_service( + "/data/gallery", + tower_http::services::ServeDir::new(gallery_dir), + ) + .with_state(state) +} + +async fn catalog_refresh( + axum::extract::State(state): axum::extract::State, +) -> Result, AppError> { + let pool = state.pool.clone(); + tokio::spawn(async move { + match force_refresh_catalog(&pool).await { + Ok(n) => tracing::info!("Manual catalog refresh complete: {} objects", n), + Err(e) => tracing::error!("Manual catalog refresh failed: {}", e), + } + }); + Ok(Json(serde_json::json!({ "status": "refresh_started" }))) +} + +async fn catalog_rebuild( + axum::extract::State(state): axum::extract::State, +) -> Result, AppError> { + let pool = state.pool.clone(); + + match catalog_rebuild_task(&pool).await { + Ok(stats) => { + tracing::info!( + "Manual catalog rebuild complete: {} objects ({})", + stats.total, + stats.by_type.iter() + .map(|(t, c)| format!("{}: {}", t, c)) + .collect::>() + .join(", ") + ); + Ok(Json(serde_json::json!({ + "status": "success", + "total": stats.total, + "by_type": stats.by_type, + "messier_count": stats.messier_count, + "has_sizes": stats.has_sizes, + }))) + } + Err(e) => { + tracing::error!("Manual catalog rebuild failed: {}", e); + Err(AppError::Internal(format!("Rebuild failed: {}", e))) + } + } +} + +#[derive(serde::Serialize)] +struct RebuildStats { + total: usize, + by_type: std::collections::HashMap, + messier_count: usize, + has_sizes: usize, +} + +async fn catalog_rebuild_task(pool: &SqlitePool) -> Result> { + // Clear existing catalog + sqlx::query("DELETE FROM catalog").execute(pool).await?; + sqlx::query("DELETE FROM nightly_cache").execute(pool).await?; + + // Build fresh catalog + let entries = crate::catalog::build_catalog().await?; + let total = entries.len(); + + // Compute stats + let mut by_type: std::collections::HashMap = std::collections::HashMap::new(); + for entry in &entries { + *by_type.entry(entry.obj_type.clone()).or_insert(0) += 1; + } + let messier_count = entries.iter().filter(|e| e.messier_num.is_some()).count(); + let has_sizes = entries.iter().filter(|e| e.size_arcmin_maj.is_some()).count(); + + // Upsert entries to database + crate::catalog::upsert_entries(pool, &entries).await?; + + // Update catalog version + sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)") + .bind(crate::catalog::CATALOG_VERSION) + .execute(pool) + .await?; + + // Automatically trigger nightly recompute + if let Err(e) = crate::jobs::precompute_tonight(pool).await { + tracing::warn!("Nightly precompute after rebuild failed: {}", e); + } + + Ok(RebuildStats { total, by_type, messier_count, has_sizes }) +} + +async fn nightly_recompute( + axum::extract::State(state): axum::extract::State, +) -> Result, AppError> { + let pool = state.pool.clone(); + tokio::spawn(async move { + match crate::jobs::nightly::precompute_tonight(&pool).await { + Ok(()) => tracing::info!("Manual nightly recompute complete"), + Err(e) => tracing::error!("Manual nightly recompute failed: {}", e), + } + }); + Ok(Json(serde_json::json!({ "status": "recompute_started" }))) +} + +async fn health( + axum::extract::State(state): axum::extract::State, +) -> Json { + let catalog_size: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM catalog") + .fetch_one(&state.pool) + .await + .unwrap_or(0); + + let catalog_last_refreshed: Option = + sqlx::query_scalar("SELECT MAX(fetched_at) FROM catalog") + .fetch_optional(&state.pool) + .await + .unwrap_or(None) + .flatten(); + + // SQLite page_count * page_size gives approximate DB size in bytes + let page_count: i64 = sqlx::query_scalar("PRAGMA page_count") + .fetch_one(&state.pool) + .await + .unwrap_or(0); + let page_size: i64 = sqlx::query_scalar("PRAGMA page_size") + .fetch_one(&state.pool) + .await + .unwrap_or(4096); + let db_size_bytes = page_count * page_size; + + Json(serde_json::json!({ + "status": "ok", + "catalog_size": catalog_size, + "catalog_last_refreshed": catalog_last_refreshed, + "db_size_bytes": db_size_bytes, + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/backend/src/api/phd2.rs b/backend/src/api/phd2.rs new file mode 100644 index 0000000..b1756d2 --- /dev/null +++ b/backend/src/api/phd2.rs @@ -0,0 +1,160 @@ +use axum::{ + extract::{Multipart, Path, State}, + Json, +}; + +use crate::phd2::parse_phd2_log; + +use super::{AppError, AppState}; + +pub async fn upload_phd2( + State(state): State, + mut multipart: Multipart, +) -> Result, AppError> { + let mut filename = String::new(); + let mut content = String::new(); + + while let Some(field) = multipart.next_field().await.map_err(|e| AppError::Internal(e.to_string()))? { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + filename = field.file_name().unwrap_or("phd2.log").to_string(); + let bytes = field.bytes().await.map_err(|e| AppError::Internal(e.to_string()))?; + content = String::from_utf8_lossy(&bytes).to_string(); + } + _ => {} + } + } + + if content.is_empty() { + return Err(AppError::BadRequest("No file content".to_string())); + } + + let analysis = parse_phd2_log(&content) + .map_err(|e| AppError::BadRequest(format!("PHD2 parse error: {}", e)))?; + + let session_date = &analysis.session_date; + + // Check for duplicates: same session_date, similar duration, and similar RMS stats + let existing: Option<(i32, i32)> = sqlx::query_as( + r#"SELECT id, duration_min FROM phd2_logs + WHERE session_date = ? + AND abs(duration_min - ?) < 2 + AND abs(rms_total - ?) < 0.1 + LIMIT 1"# + ) + .bind(session_date) + .bind(analysis.duration_min as i32) + .bind(analysis.rms_total_arcsec) + .fetch_optional(&state.pool) + .await?; + + if let Some((dup_id, _)) = existing { + return Ok(Json(serde_json::json!({ + "duplicate": true, + "duplicate_id": dup_id, + "message": format!("Duplicate session detected (ID: {}). Not inserted.", dup_id), + "analysis": analysis, + "filename": filename, + }))); + } + + let id: i64 = sqlx::query_scalar( + r#"INSERT INTO phd2_logs + (session_date, filename, rms_total, rms_ra, rms_dec, peak_error, + star_lost_count, duration_min, guide_star_snr) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id"#, + ) + .bind(session_date) + .bind(&filename) + .bind(analysis.rms_total_arcsec) + .bind(analysis.rms_ra_arcsec) + .bind(analysis.rms_dec_arcsec) + .bind(analysis.peak_error_arcsec) + .bind(analysis.star_lost_count) + .bind(analysis.duration_min) + .bind(analysis.mean_snr) + .fetch_one(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ + "id": id, + "duplicate": false, + "analysis": analysis, + "filename": filename, + }))) +} + +pub async fn list_phd2( + State(state): State, +) -> Result, AppError> { + let rows = sqlx::query( + "SELECT * FROM phd2_logs ORDER BY session_date DESC, created_at DESC", + ) + .fetch_all(&state.pool) + .await?; + + let items: Vec = rows.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "session_date": r.try_get::("session_date").unwrap_or_default(), + "filename": r.try_get::("filename").unwrap_or_default(), + "rms_total": r.try_get::, _>("rms_total").unwrap_or_default(), + "rms_ra": r.try_get::, _>("rms_ra").unwrap_or_default(), + "rms_dec": r.try_get::, _>("rms_dec").unwrap_or_default(), + "peak_error": r.try_get::, _>("peak_error").unwrap_or_default(), + "star_lost_count": r.try_get::, _>("star_lost_count").unwrap_or_default(), + "duration_min": r.try_get::, _>("duration_min").unwrap_or_default(), + "guide_star_snr": r.try_get::, _>("guide_star_snr").unwrap_or_default(), + "created_at": r.try_get::("created_at").unwrap_or_default(), + }) + }).collect(); + + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn get_phd2( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let row = sqlx::query("SELECT * FROM phd2_logs WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("PHD2 log {} not found", id)))?; + + use sqlx::Row; + Ok(Json(serde_json::json!({ + "id": row.try_get::("id").unwrap_or_default(), + "session_date": row.try_get::("session_date").unwrap_or_default(), + "filename": row.try_get::("filename").unwrap_or_default(), + "rms_total": row.try_get::, _>("rms_total").unwrap_or_default(), + "rms_ra": row.try_get::, _>("rms_ra").unwrap_or_default(), + "rms_dec": row.try_get::, _>("rms_dec").unwrap_or_default(), + "peak_error": row.try_get::, _>("peak_error").unwrap_or_default(), + "star_lost_count": row.try_get::, _>("star_lost_count").unwrap_or_default(), + "duration_min": row.try_get::, _>("duration_min").unwrap_or_default(), + "guide_star_snr": row.try_get::, _>("guide_star_snr").unwrap_or_default(), + }))) +} + +pub async fn delete_phd2( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let result = sqlx::query("DELETE FROM phd2_logs WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("PHD2 log {} not found", id))); + } + + Ok(Json(serde_json::json!({ + "status": "deleted", + "id": id, + }))) +} diff --git a/backend/src/api/solar_system.rs b/backend/src/api/solar_system.rs new file mode 100644 index 0000000..c6ff01e --- /dev/null +++ b/backend/src/api/solar_system.rs @@ -0,0 +1,436 @@ +/// Solar System objects: planets, Moon, bright comets, and custom/TLE targets. +/// Planet positions use low-precision analytical series accurate to ~1' for dates near J2000. +use axum::{extract::State, Json}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use std::f64::consts::PI; + +use crate::astronomy::{ + coords::{airmass, radec_to_altaz}, + julian_date, + time::local_sidereal_time, + moon_position, +}; +use crate::config::{LAT, LON}; +use super::{AppError, AppState}; + +/// Propagate TLE to current position → (ra_deg, dec_deg, alt_deg, az_deg). +/// Uses sgp4 crate; returns None on parse or propagation error. +fn tle_position(line1: &str, line2: &str) -> Option<(f64, f64, f64, f64)> { + use sgp4::{Constants, Elements}; + + let elements = Elements::from_tle( + None, + line1.as_bytes(), + line2.as_bytes(), + ).ok()?; + let constants = Constants::from_elements(&elements).ok()?; + + // Minutes since TLE epoch + let now = Utc::now(); + let epoch = chrono::DateTime::::from_naive_utc_and_offset(elements.datetime, Utc); + let minutes = (now - epoch).num_seconds() as f64 / 60.0; + + let prediction = constants.propagate(sgp4::MinutesSinceEpoch(minutes)).ok()?; + + // ECI position in km (TEME frame) + let (x, y, z) = (prediction.position[0], prediction.position[1], prediction.position[2]); + + // Convert ECI to RA/Dec (TEME ≈ J2000 for our purposes, error < 0.01°) + let r = (x * x + y * y + z * z).sqrt(); + if r < 1.0 { return None; } + + let ra_rad = y.atan2(x); + let dec_rad = (z / r).asin(); + let ra_deg = ra_rad.to_degrees().rem_euclid(360.0); + let dec_deg = dec_rad.to_degrees(); + + // Convert to Alt/Az + let jd = julian_date(now); + let lst = local_sidereal_time(jd, LON); + let (alt, az) = radec_to_altaz(ra_deg, dec_deg, lst, LAT); + + Some((ra_deg, dec_deg, alt, az)) +} + +#[derive(Debug, Serialize)] +pub struct SolarSystemObject { + pub id: String, + pub name: String, + pub obj_type: String, // planet, moon, asteroid, comet + pub ra_deg: f64, + pub dec_deg: f64, + pub ra_h: String, + pub dec_dms: String, + pub alt_deg: f64, + pub az_deg: f64, + pub airmass: f64, + pub mag_v: Option, + pub angular_size_arcsec: Option, + pub phase_pct: Option, // 0–100 + pub distance_au: Option, + pub elongation_deg: Option, // from Sun + pub is_visible: bool, // alt > 15° +} + +fn fmt_ra(ra: f64) -> String { + let total_sec = (ra / 15.0) * 3600.0; + let h = (total_sec / 3600.0) as u32; + let m = ((total_sec % 3600.0) / 60.0) as u32; + let s = (total_sec % 60.0) as u32; + format!("{:02}h {:02}m {:02}s", h, m, s) +} + +fn fmt_dec(dec: f64) -> String { + let sign = if dec < 0.0 { "-" } else { "+" }; + let abs = dec.abs(); + let d = abs as u32; + let m = ((abs - d as f64) * 60.0) as u32; + let s = ((abs - d as f64) * 3600.0 % 60.0) as u32; + format!("{}{}° {:02}′ {:02}″", sign, d, m, s) +} + +/// Low-precision planet positions (Jean Meeus, "Astronomical Algorithms", ch. 33). +/// Returns (ra_deg, dec_deg, distance_au, mag_v, phase_pct, angular_size_arcsec). +fn planet_position(name: &str, jd: f64) -> Option<(f64, f64, f64, f64, f64, f64)> { + // T = Julian centuries from J2000.0 + let t = (jd - 2451545.0) / 36525.0; + + // Sun's geometric mean longitude and anomaly (for elongation / phase) + let l0_sun = (280.46646 + 36000.76983 * t).rem_euclid(360.0); + let m_sun = (357.52911 + 35999.05029 * t - 0.0001537 * t * t).to_radians(); + let c_sun = (1.914602 - 0.004817 * t - 0.000014 * t * t) * m_sun.sin() + + (0.019993 - 0.000101 * t) * (2.0 * m_sun).sin() + + 0.000289 * (3.0 * m_sun).sin(); + let sun_lon = l0_sun + c_sun; // true longitude degrees + let sun_lon_rad = sun_lon.to_radians(); + + // For each planet: orbital elements at epoch J2000 with linear drift. + // Format: (L0, L1, a_AU, e0, e1, i0, i1, omega0, omega1, node0, node1) + // L = mean longitude, a = semi-major axis, e = eccentricity, + // i = inclination, omega = argument of perihelion, node = ascending node + let (l0, l1, a, e0, e1, inc0, inc1, peri0, peri1, node0, node1) = match name { + "Mercury" => (252.25032, 149472.67411, 0.38710, 0.20563, 0.000020, 7.00497, -0.00594, 77.45779, 0.15940, 48.33076, -0.12534), + "Venus" => (181.97980, 58517.81538, 0.72333, 0.00677, -0.000048, 3.39468, -0.00788, 131.56370, 0.05127, 76.67984, -0.27769), + "Mars" => (355.45332, 19140.30268, 1.52366, 0.09340, 0.000090, 1.84973, -0.00813, 336.04084, 0.44441, 49.55953, -0.29257), + "Jupiter" => (34.89973, 3034.74612, 5.20260, 0.04849, 0.000163, 1.30327, -0.00557, 14.72847, 0.21252, 100.29205, 0.13447), + "Saturn" => (50.07571, 1222.11494, 9.55491, 0.05551, -0.000346, 2.48888, 0.00449, 92.86136, 0.54479, 113.63998, -0.25015), + "Uranus" => (314.05500, 428.46952, 19.21845, 0.04630, -0.000027, 0.77320, -0.00180, 172.43404, 0.09175, 73.96980, 0.05717), + "Neptune" => (304.34866, 218.45945, 30.11039, 0.00899, 0.000006, 1.76995, 0.00022, 46.68158, 0.01367, 131.78406, -0.00762), + _ => return None, + }; + + let l = (l0 + l1 * t / 36525.0).rem_euclid(360.0).to_radians(); + let e = e0 + e1 * t; + let inc = (inc0 + inc1 * t).to_radians(); + let peri = (peri0 + peri1 * t).to_radians(); // longitude of perihelion + let node = (node0 + node1 * t).to_radians(); + + // Mean anomaly + let m = (l - peri).rem_euclid(2.0 * PI); + + // Eccentric anomaly (Newton iteration) + let mut ea = m; + for _ in 0..10 { + ea = m + e * ea.sin(); + } + + // True anomaly + let nu = 2.0 * ((((1.0 + e) / (1.0 - e)).sqrt() * (ea / 2.0).tan()).atan()); + + // Heliocentric distance + let r = a * (1.0 - e * ea.cos()); + + // Heliocentric ecliptic coordinates + let lon_helio = (nu + peri - node).rem_euclid(2.0 * PI) + node; + let lat_helio = (lon_helio - node).sin() * inc.sin(); + let lat_helio = lat_helio.asin(); + let lon_helio = lon_helio; + + // Convert to rectangular heliocentric + let x_h = r * lat_helio.cos() * lon_helio.cos(); + let y_h = r * lat_helio.cos() * lon_helio.sin(); + let z_h = r * lat_helio.sin(); + + // Earth's heliocentric rectangular coordinates (using Sun's geocentric coords reversed) + let r_earth = 1.000001018 * (1.0 - 0.0167086342 * ea.cos()); // rough + let l_earth = sun_lon_rad + PI; + let x_e = r_earth * l_earth.cos(); + let y_e = r_earth * l_earth.sin(); + + // Geocentric coordinates + let dx = x_h - x_e; + let dy = y_h - y_e; + let dz = z_h; + + // Geocentric ecliptic longitude/latitude + let lam = dy.atan2(dx); + let dist = (dx * dx + dy * dy + dz * dz).sqrt(); + let beta = (dz / dist).asin(); + + // Convert ecliptic → equatorial (obliquity ~23.439°) + let eps = (23.439291 - 0.013004 * t).to_radians(); + let ra = (lam.sin() * eps.cos() - beta.tan() * eps.sin()).atan2(lam.cos()); + let ra_deg = ra.to_degrees().rem_euclid(360.0); + let dec_deg = (beta.sin() * eps.cos() + beta.cos() * eps.sin() * lam.sin()).asin().to_degrees(); + + // Phase angle + let phase_angle = ((r * r + dist * dist - r_earth * r_earth) / (2.0 * r * dist)).acos(); + let phase_pct = (1.0 + phase_angle.cos()) / 2.0 * 100.0; + + // Approximate magnitude (very rough) + let (h0, g_slope) = match name { + "Mercury" => (-0.36, 0.0), + "Venus" => (-4.34, 0.0), + "Mars" => (-1.51, 0.0), + "Jupiter" => (-9.25, 0.0), + "Saturn" => (-8.88, 0.0), + "Uranus" => (-7.19, 0.0), + "Neptune" => (-6.87, 0.0), + _ => (10.0, 0.0), + }; + let mag = h0 + 5.0 * (r * dist).log10() - 2.5 * ((1.0 - g_slope) * (-3.33 * (phase_angle / 2.0).tan().powi(12)).exp() + g_slope * (-1.87 * (phase_angle / 2.0).tan().powi(6)).exp()).log10(); + + // Angular size (arcsec) — equatorial diameter at 1 AU + let diam_1au_arcsec = match name { + "Mercury" => 6.74, + "Venus" => 16.92, + "Mars" => 9.36, + "Jupiter" => 196.74, + "Saturn" => 165.6, + "Uranus" => 65.8, + "Neptune" => 62.2, + _ => 0.0, + }; + let ang_size = diam_1au_arcsec / dist; + + Some((ra_deg, dec_deg, dist, mag, phase_pct, ang_size)) +} + +fn sun_position(jd: f64) -> (f64, f64) { + let t = (jd - 2451545.0) / 36525.0; + let l0 = (280.46646 + 36000.76983 * t).rem_euclid(360.0); + let m = (357.52911 + 35999.05029 * t).to_radians(); + let c = (1.914602 - 0.004817 * t) * m.sin() + + 0.019993 * (2.0 * m).sin() + + 0.000290 * (3.0 * m).sin(); + let sun_lon = (l0 + c).to_radians(); + let eps = (23.439291 - 0.013004 * t).to_radians(); + let ra = (sun_lon.sin() * eps.cos()).atan2(sun_lon.cos()); + let dec = (sun_lon.sin() * eps.sin()).asin(); + (ra.to_degrees().rem_euclid(360.0), dec.to_degrees()) +} + +fn elongation(ra1: f64, dec1: f64, ra2: f64, dec2: f64) -> f64 { + let r1 = ra1.to_radians(); + let d1 = dec1.to_radians(); + let r2 = ra2.to_radians(); + let d2 = dec2.to_radians(); + let cos_sep = d1.sin() * d2.sin() + d1.cos() * d2.cos() * (r1 - r2).cos(); + cos_sep.clamp(-1.0, 1.0).acos().to_degrees() +} + +pub async fn get_solar_system( + State(_state): State, +) -> Result, AppError> { + let now = Utc::now(); + let jd = julian_date(now); + let lst = local_sidereal_time(jd, LON); + + let (sun_ra, sun_dec) = sun_position(jd); + let (moon_ra, moon_dec) = moon_position(jd); + + let planet_names = ["Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune"]; + let mut objects: Vec = Vec::new(); + + // Moon + { + let (alt, az) = radec_to_altaz(moon_ra, moon_dec, lst, LAT); + let am = if alt > 0.0 { airmass(alt) } else { 99.0 }; + objects.push(SolarSystemObject { + id: "moon".to_string(), + name: "Moon".to_string(), + obj_type: "moon".to_string(), + ra_deg: moon_ra, + dec_deg: moon_dec, + ra_h: fmt_ra(moon_ra), + dec_dms: fmt_dec(moon_dec), + alt_deg: (alt * 10.0).round() / 10.0, + az_deg: (az * 10.0).round() / 10.0, + airmass: (am * 100.0).round() / 100.0, + mag_v: Some(-12.7), + angular_size_arcsec: Some(1800.0), + phase_pct: None, // from tonight data + distance_au: None, + elongation_deg: Some((elongation(moon_ra, moon_dec, sun_ra, sun_dec) * 10.0).round() / 10.0), + is_visible: alt > 15.0, + }); + } + + // Sun + { + let (alt, az) = radec_to_altaz(sun_ra, sun_dec, lst, LAT); + let am = if alt > 0.0 { airmass(alt) } else { 99.0 }; + objects.push(SolarSystemObject { + id: "sun".to_string(), + name: "Sun".to_string(), + obj_type: "star".to_string(), + ra_deg: sun_ra, + dec_deg: sun_dec, + ra_h: fmt_ra(sun_ra), + dec_dms: fmt_dec(sun_dec), + alt_deg: (alt * 10.0).round() / 10.0, + az_deg: (az * 10.0).round() / 10.0, + airmass: (am * 100.0).round() / 100.0, + mag_v: Some(-26.7), + angular_size_arcsec: Some(1919.0), + phase_pct: None, + distance_au: Some(1.0), + elongation_deg: Some(0.0), + is_visible: alt > 0.0, + }); + } + + // Planets + for name in &planet_names { + if let Some((ra, dec, dist, mag, phase, ang_size)) = planet_position(name, jd) { + let (alt, az) = radec_to_altaz(ra, dec, lst, LAT); + let am = if alt > 0.0 { airmass(alt) } else { 99.0 }; + let elong = elongation(ra, dec, sun_ra, sun_dec); + objects.push(SolarSystemObject { + id: name.to_lowercase(), + name: name.to_string(), + obj_type: "planet".to_string(), + ra_deg: (ra * 1000.0).round() / 1000.0, + dec_deg: (dec * 1000.0).round() / 1000.0, + ra_h: fmt_ra(ra), + dec_dms: fmt_dec(dec), + alt_deg: (alt * 10.0).round() / 10.0, + az_deg: (az * 10.0).round() / 10.0, + airmass: (am * 100.0).round() / 100.0, + mag_v: Some((mag * 10.0).round() / 10.0), + angular_size_arcsec: Some((ang_size * 10.0).round() / 10.0), + phase_pct: Some((phase * 10.0).round() / 10.0), + distance_au: Some((dist * 1000.0).round() / 1000.0), + elongation_deg: Some((elong * 10.0).round() / 10.0), + is_visible: alt > 15.0, + }); + } + } + + // Sort: visible first, then by altitude descending + objects.sort_by(|a, b| { + b.is_visible.cmp(&a.is_visible) + .then(b.alt_deg.partial_cmp(&a.alt_deg).unwrap_or(std::cmp::Ordering::Equal)) + }); + + Ok(Json(serde_json::json!({ + "computed_at": now.to_rfc3339(), + "objects": objects, + }))) +} + +/// Custom targets API +#[derive(Debug, Deserialize)] +pub struct CustomTargetInput { + pub id: String, + pub name: String, + pub obj_type: Option, + pub ra_deg: Option, + pub dec_deg: Option, + pub tle_line1: Option, + pub tle_line2: Option, + pub notes: Option, +} + +pub async fn list_custom_targets( + State(state): State, +) -> Result, AppError> { + let rows = sqlx::query("SELECT * FROM custom_targets ORDER BY created_at DESC") + .fetch_all(&state.pool) + .await?; + + use sqlx::Row; + let items: Vec = rows.iter().map(|r| { + let ra: Option = r.try_get("ra_deg").unwrap_or_default(); + let dec: Option = r.try_get("dec_deg").unwrap_or_default(); + let has_tle = r.try_get::, _>("tle_line1").unwrap_or_default().is_some(); + + let mut obj = serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "ra_deg": ra, + "dec_deg": dec, + "notes": r.try_get::, _>("notes").unwrap_or_default(), + "has_tle": has_tle, + "created_at": r.try_get::("created_at").unwrap_or_default(), + }); + + // Compute live position: prefer TLE propagation if available, else fixed RA/Dec + let tle1 = r.try_get::, _>("tle_line1").unwrap_or_default(); + let tle2 = r.try_get::, _>("tle_line2").unwrap_or_default(); + + if let (Some(t1), Some(t2)) = (&tle1, &tle2) { + if let Some((ra, dec, alt, az)) = tle_position(t1, t2) { + obj["ra_deg"] = serde_json::json!((ra * 1000.0).round() / 1000.0); + obj["dec_deg"] = serde_json::json!((dec * 1000.0).round() / 1000.0); + obj["ra_h"] = serde_json::json!(fmt_ra(ra)); + obj["dec_dms"] = serde_json::json!(fmt_dec(dec)); + obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0); + obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0); + obj["tle_position_ok"] = serde_json::json!(true); + } else { + obj["tle_position_ok"] = serde_json::json!(false); + } + } else if let (Some(ra), Some(dec)) = (ra, dec) { + let jd = julian_date(Utc::now()); + let lst = local_sidereal_time(jd, LON); + let (alt, az) = radec_to_altaz(ra, dec, lst, LAT); + obj["alt_deg"] = serde_json::json!((alt * 10.0).round() / 10.0); + obj["az_deg"] = serde_json::json!((az * 10.0).round() / 10.0); + obj["ra_h"] = serde_json::json!(fmt_ra(ra)); + obj["dec_dms"] = serde_json::json!(fmt_dec(dec)); + } + obj + }).collect(); + + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn create_custom_target( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + if input.id.trim().is_empty() || input.name.trim().is_empty() { + return Err(AppError::BadRequest("id and name are required".to_string())); + } + let obj_type = input.obj_type.unwrap_or_else(|| "custom".to_string()); + sqlx::query( + "INSERT OR REPLACE INTO custom_targets (id, name, obj_type, ra_deg, dec_deg, tle_line1, tle_line2, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&input.id) + .bind(&input.name) + .bind(&obj_type) + .bind(input.ra_deg) + .bind(input.dec_deg) + .bind(input.tle_line1.as_deref()) + .bind(input.tle_line2.as_deref()) + .bind(input.notes.as_deref()) + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "id": input.id, "status": "created" }))) +} + +pub async fn delete_custom_target( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> Result, AppError> { + sqlx::query("DELETE FROM custom_targets WHERE id = ?") + .bind(&id) + .execute(&state.pool) + .await?; + Ok(Json(serde_json::json!({ "id": id, "status": "deleted" }))) +} diff --git a/backend/src/api/stats.rs b/backend/src/api/stats.rs new file mode 100644 index 0000000..1ac1cc3 --- /dev/null +++ b/backend/src/api/stats.rs @@ -0,0 +1,151 @@ +use axum::{extract::State, Json}; + +use super::{AppError, AppState}; + +pub async fn get_stats( + State(state): State, +) -> Result, AppError> { + // Total sessions + let total_sessions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM imaging_log") + .fetch_one(&state.pool) + .await?; + + // Total integration time + let total_integration_min: Option = + sqlx::query_scalar("SELECT SUM(integration_min) FROM imaging_log") + .fetch_optional(&state.pool) + .await? + .flatten(); + + // Objects imaged (at least one keeper) + let objects_with_keeper: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT catalog_id) FROM imaging_log WHERE quality = 'keeper'", + ) + .fetch_one(&state.pool) + .await?; + + // Filter usage + let filter_usage = sqlx::query( + "SELECT filter_id, COUNT(*) as count, SUM(integration_min) as total_min FROM imaging_log GROUP BY filter_id", + ) + .fetch_all(&state.pool) + .await?; + + let filter_stats: Vec = filter_usage.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "filter_id": r.try_get::("filter_id").unwrap_or_default(), + "count": r.try_get::("count").unwrap_or_default(), + "total_min": r.try_get::, _>("total_min").unwrap_or_default(), + }) + }).collect(); + + // Integration per month (last 12 months) + let monthly = sqlx::query( + r#"SELECT substr(session_date, 1, 7) as month, + COUNT(*) as sessions, + SUM(integration_min) as total_min + FROM imaging_log + WHERE session_date >= date('now', '-12 months') + GROUP BY month + ORDER BY month"#, + ) + .fetch_all(&state.pool) + .await?; + + let monthly_stats: Vec = monthly.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "month": r.try_get::("month").unwrap_or_default(), + "sessions": r.try_get::("sessions").unwrap_or_default(), + "total_min": r.try_get::, _>("total_min").unwrap_or_default(), + }) + }).collect(); + + // Object type breakdown + let type_breakdown = sqlx::query( + r#"SELECT c.obj_type, COUNT(*) as sessions, SUM(l.integration_min) as total_min + FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id + GROUP BY c.obj_type ORDER BY total_min DESC"#, + ) + .fetch_all(&state.pool) + .await?; + + let type_stats: Vec = type_breakdown.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "sessions": r.try_get::("sessions").unwrap_or_default(), + "total_min": r.try_get::, _>("total_min").unwrap_or_default(), + }) + }).collect(); + + // Quality breakdown + let quality = sqlx::query( + "SELECT quality, COUNT(*) as count FROM imaging_log GROUP BY quality", + ) + .fetch_all(&state.pool) + .await?; + + let quality_stats: Vec = quality.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "quality": r.try_get::("quality").unwrap_or_default(), + "count": r.try_get::("count").unwrap_or_default(), + }) + }).collect(); + + // Top targets by integration + let top_targets = sqlx::query( + r#"SELECT c.id, c.name, c.common_name, c.obj_type, + COUNT(l.id) as sessions, + SUM(l.integration_min) as total_min + FROM imaging_log l JOIN catalog c ON c.id = l.catalog_id + GROUP BY l.catalog_id + ORDER BY total_min DESC + LIMIT 20"#, + ) + .fetch_all(&state.pool) + .await?; + + let top_target_list: Vec = top_targets.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "common_name": r.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": r.try_get::("obj_type").unwrap_or_default(), + "sessions": r.try_get::("sessions").unwrap_or_default(), + "total_min": r.try_get::, _>("total_min").unwrap_or_default(), + }) + }).collect(); + + // Guiding RMS over time + let guiding = sqlx::query( + "SELECT session_date, rms_total, rms_ra, rms_dec FROM phd2_logs ORDER BY session_date", + ) + .fetch_all(&state.pool) + .await?; + + let guiding_data: Vec = guiding.iter().map(|r| { + use sqlx::Row; + serde_json::json!({ + "date": r.try_get::("session_date").unwrap_or_default(), + "rms_total": r.try_get::, _>("rms_total").unwrap_or_default(), + "rms_ra": r.try_get::, _>("rms_ra").unwrap_or_default(), + "rms_dec": r.try_get::, _>("rms_dec").unwrap_or_default(), + }) + }).collect(); + + Ok(Json(serde_json::json!({ + "total_sessions": total_sessions, + "total_integration_min": total_integration_min.unwrap_or(0), + "objects_with_keeper": objects_with_keeper, + "filter_usage": filter_stats, + "monthly": monthly_stats, + "by_type": type_stats, + "quality": quality_stats, + "top_targets": top_target_list, + "guiding": guiding_data, + }))) +} diff --git a/backend/src/api/targets.rs b/backend/src/api/targets.rs new file mode 100644 index 0000000..255ef26 --- /dev/null +++ b/backend/src/api/targets.rs @@ -0,0 +1,643 @@ +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + astronomy::{ + astro_twilight, compute_visibility, compute_visibility_with_step, julian_date, moon_altitude, moon_illumination, + moon_position, HorizonPoint, MoonState, TonightWindow, + }, + config::{LAT, LON}, + filters::{get_workflow, recommend_filters}, +}; + +use super::{AppError, AppState}; + +#[derive(Debug, Deserialize)] +pub struct TargetsQuery { + #[serde(rename = "type")] + pub obj_type: Option, + pub constellation: Option, + pub filter: Option, + pub tonight: Option, + pub search: Option, + pub sort: Option, + pub page: Option, + pub limit: Option, + pub min_alt_deg: Option, + pub min_usable_min: Option, + pub mosaic_only: Option, + pub not_imaged: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct TargetRow { + pub id: String, + pub name: String, + pub common_name: Option, + pub obj_type: String, + pub ra_deg: f64, + pub dec_deg: f64, + pub ra_h: String, + pub dec_dms: String, + pub constellation: Option, + pub size_arcmin_maj: Option, + pub size_arcmin_min: Option, + pub mag_v: Option, + pub surface_brightness: Option, + pub hubble_type: Option, + pub messier_num: Option, + pub is_highlight: bool, + pub fov_fill_pct: Option, + pub mosaic_flag: bool, + pub mosaic_panels_w: i32, + pub mosaic_panels_h: i32, + pub difficulty: Option, + pub guide_star_density: Option, + // From nightly_cache + pub max_alt_deg: Option, + pub usable_min: Option, + pub transit_utc: Option, + pub recommended_filter: Option, + pub best_start_utc: Option, + pub best_end_utc: Option, + pub moon_sep_deg: Option, + pub is_visible_tonight: Option, +} + +pub async fn list_targets( + State(state): State, + Query(params): Query, +) -> Result, AppError> { + let today = chrono::Utc::now().naive_utc().date().to_string(); + let page = params.page.unwrap_or(1).max(1); + let limit = params.limit.unwrap_or(100).min(500); + let offset = (page - 1) * limit; + let tonight_filter = params.tonight.unwrap_or(true); + + let mut conditions = vec!["1=1".to_string()]; + let mut bind_values: Vec = vec![]; + + if let Some(ref t) = params.obj_type { + conditions.push("c.obj_type = ?".to_string()); + bind_values.push(t.clone()); + } + if let Some(ref con) = params.constellation { + conditions.push("c.constellation = ?".to_string()); + bind_values.push(con.clone()); + } + if let Some(ref f) = params.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. + match f.as_str() { + "uvir" => conditions.push("c.obj_type IN ('galaxy', 'reflection_nebula', 'open_cluster', 'globular_cluster', 'dark_nebula')".to_string()), + "c2" | "sv220" => conditions.push("c.obj_type IN ('emission_nebula', 'snr', 'planetary_nebula')".to_string()), + "sv260" => {}, // LP filter works for all object types — no restriction + _ => { + conditions.push("nc.recommended_filter = ?".to_string()); + bind_values.push(f.clone()); + } + } + } + if let Some(min_alt) = params.min_alt_deg { + conditions.push("nc.max_alt_deg >= ?".to_string()); + bind_values.push(min_alt.to_string()); + } + if let Some(min_min) = params.min_usable_min { + conditions.push("nc.usable_min >= ?".to_string()); + bind_values.push(min_min.to_string()); + } + if params.mosaic_only.unwrap_or(false) { + conditions.push("c.mosaic_flag = 1".to_string()); + } + if params.not_imaged.unwrap_or(false) { + conditions.push("log_sum.total_min IS NULL".to_string()); + } + // Tonight filter: show objects above MIN_ALT (15°) at any point tonight. + // Using max_alt_deg >= 15 (not usable_min > 0) so objects that peak at 15-30° + // (e.g. globular clusters, dark nebulae, open clusters) still appear. + // Skip filter when search is active so you can find objects like M31 off-season. + if tonight_filter && params.search.as_deref().unwrap_or("").is_empty() { + // Allow objects with no nightly_cache entry yet (newly ingested, NULL max_alt_deg) + // so freshly added VdB/LDN objects are visible before the first nightly precompute. + conditions.push("(nc.max_alt_deg >= 15 OR nc.max_alt_deg IS NULL)".to_string()); + } + if let Some(ref s) = params.search { + let like = format!("%{}%", s); + // Support M-number search (e.g. "M42" → messier_num = 42) + let m_num: Option = s.trim() + .strip_prefix(['M', 'm']) + .and_then(|n| n.parse().ok()); + if let Some(m) = m_num { + conditions.push(format!( + "(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ? OR c.messier_num = {})", + m + )); + bind_values.push(like.clone()); + bind_values.push(like.clone()); + bind_values.push(like); + } else { + conditions.push("(c.name LIKE ? OR c.common_name LIKE ? OR c.constellation LIKE ?)".to_string()); + bind_values.push(like.clone()); + bind_values.push(like.clone()); + bind_values.push(like); + } + } + + let where_clause = conditions.join(" AND "); + // "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation. + // Score = alt_score * 0.4 + fill_score * 0.3 + time_score * 0.2 + moon_score * 0.1 + // Targets outside 20–150% FOV fill are penalised (too small or too large single-panel). + let best_score_expr = r#"( + COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40 + + CASE + WHEN c.fov_fill_pct IS NULL THEN 0.15 + WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30 + WHEN c.fov_fill_pct > 80 THEN 0.10 + ELSE 0.05 + END + + MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20 + + COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10 + ) DESC"#; + let sort_col = match params.sort.as_deref() { + Some("transit") => "nc.transit_utc", + Some("size") => "c.size_arcmin_maj DESC", + Some("magnitude") => "c.mag_v", + Some("difficulty") => "c.difficulty", + Some("integration") => "total_integration DESC", + Some("altitude") => "nc.max_alt_deg DESC", + _ => best_score_expr, + }; + + let sql = format!( + r#" + SELECT c.id, c.name, c.common_name, c.obj_type, c.ra_deg, c.dec_deg, c.ra_h, c.dec_dms, + c.constellation, c.size_arcmin_maj, c.size_arcmin_min, c.mag_v, c.surface_brightness, + c.hubble_type, c.messier_num, c.is_highlight, c.fov_fill_pct, c.mosaic_flag, + c.mosaic_panels_w, c.mosaic_panels_h, c.difficulty, c.guide_star_density, + nc.max_alt_deg, nc.usable_min, nc.transit_utc, nc.recommended_filter, + nc.best_start_utc, nc.best_end_utc, nc.moon_sep_deg, + CASE WHEN nc.max_alt_deg >= 15 THEN 1 ELSE 0 END as is_visible_tonight, + COALESCE(log_sum.total_min, 0) as total_integration + FROM catalog c + LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}' + LEFT JOIN ( + SELECT catalog_id, SUM(integration_min) as total_min + FROM imaging_log GROUP BY catalog_id + ) log_sum ON log_sum.catalog_id = c.id + WHERE {where_clause} + ORDER BY {sort_col} + LIMIT {limit} OFFSET {offset} + "#, + today = today, + where_clause = where_clause, + sort_col = sort_col, + limit = limit, + offset = offset + ); + + // Use dynamic binding workaround since sqlx requires compile-time queries + let mut query = sqlx::query(&sql); + for val in &bind_values { + query = query.bind(val); + } + + let rows = query + .fetch_all(&state.pool) + .await + .map_err(AppError::from)?; + + let items: Vec = rows.iter().map(|row| { + use sqlx::Row; + serde_json::json!({ + "id": row.try_get::("id").unwrap_or_default(), + "name": row.try_get::("name").unwrap_or_default(), + "common_name": row.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": row.try_get::("obj_type").unwrap_or_default(), + "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), + "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), + "ra_h": row.try_get::("ra_h").unwrap_or_default(), + "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), + "constellation": row.try_get::, _>("constellation").unwrap_or_default(), + "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), + "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), + "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), + "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), + "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), + "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), + "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), + "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), + "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), + "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), + "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), + "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), + "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), + "max_alt_deg": row.try_get::, _>("max_alt_deg").unwrap_or_default(), + "usable_min": row.try_get::, _>("usable_min").unwrap_or_default(), + "transit_utc": row.try_get::, _>("transit_utc").unwrap_or_default(), + "recommended_filter": row.try_get::, _>("recommended_filter").unwrap_or_default(), + "best_start_utc": row.try_get::, _>("best_start_utc").unwrap_or_default(), + "best_end_utc": row.try_get::, _>("best_end_utc").unwrap_or_default(), + "moon_sep_deg": row.try_get::, _>("moon_sep_deg").unwrap_or_default(), + "is_visible_tonight": row.try_get::, _>("is_visible_tonight").unwrap_or_default(), + "total_integration_min": row.try_get::("total_integration").unwrap_or(0), + }) + }).collect(); + + // Count with the same filters applied + let count_sql = format!( + r#"SELECT COUNT(*) FROM catalog c + LEFT JOIN nightly_cache nc ON nc.catalog_id = c.id AND nc.night_date = '{today}' + LEFT JOIN ( + SELECT catalog_id, SUM(integration_min) as total_min + FROM imaging_log GROUP BY catalog_id + ) log_sum ON log_sum.catalog_id = c.id + WHERE {where_clause}"#, + today = today, + where_clause = where_clause, + ); + let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); + for val in &bind_values { + count_query = count_query.bind(val); + } + let total: i64 = count_query.fetch_one(&state.pool).await.unwrap_or(0); + + Ok(Json(serde_json::json!({ + "items": items, + "total": total, + "page": page, + "limit": limit + }))) +} + +pub async fn get_target( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + // Support both NGC/IC IDs and M-number IDs (e.g. "M42") + let m_num: Option = id.trim() + .strip_prefix(['M', 'm']) + .and_then(|n| n.parse().ok()); + + let row = if let Some(n) = m_num { + sqlx::query("SELECT * FROM catalog WHERE messier_num = ?") + .bind(n) + .fetch_optional(&state.pool) + .await? + } else { + sqlx::query("SELECT * FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + } + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + Ok(Json(serde_json::json!({ + "id": row.try_get::("id").unwrap_or_default(), + "name": row.try_get::("name").unwrap_or_default(), + "common_name": row.try_get::, _>("common_name").unwrap_or_default(), + "obj_type": row.try_get::("obj_type").unwrap_or_default(), + "ra_deg": row.try_get::("ra_deg").unwrap_or_default(), + "dec_deg": row.try_get::("dec_deg").unwrap_or_default(), + "ra_h": row.try_get::("ra_h").unwrap_or_default(), + "dec_dms": row.try_get::("dec_dms").unwrap_or_default(), + "constellation": row.try_get::, _>("constellation").unwrap_or_default(), + "size_arcmin_maj": row.try_get::, _>("size_arcmin_maj").unwrap_or_default(), + "size_arcmin_min": row.try_get::, _>("size_arcmin_min").unwrap_or_default(), + "pos_angle_deg": row.try_get::, _>("pos_angle_deg").unwrap_or_default(), + "mag_v": row.try_get::, _>("mag_v").unwrap_or_default(), + "surface_brightness": row.try_get::, _>("surface_brightness").unwrap_or_default(), + "hubble_type": row.try_get::, _>("hubble_type").unwrap_or_default(), + "messier_num": row.try_get::, _>("messier_num").unwrap_or_default(), + "is_highlight": row.try_get::("is_highlight").unwrap_or_default(), + "fov_fill_pct": row.try_get::, _>("fov_fill_pct").unwrap_or_default(), + "mosaic_flag": row.try_get::("mosaic_flag").unwrap_or_default(), + "mosaic_panels_w": row.try_get::("mosaic_panels_w").unwrap_or(1), + "mosaic_panels_h": row.try_get::("mosaic_panels_h").unwrap_or(1), + "difficulty": row.try_get::, _>("difficulty").unwrap_or_default(), + "guide_star_density": row.try_get::, _>("guide_star_density").unwrap_or_default(), + }))) +} + +pub async fn get_visibility( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let today = chrono::Utc::now().naive_utc().date().to_string(); + + let row = sqlx::query( + "SELECT * FROM nightly_cache WHERE catalog_id = ? AND night_date = ?", + ) + .bind(&id) + .bind(&today) + .fetch_optional(&state.pool) + .await?; + + match row { + Some(r) => { + use sqlx::Row; + Ok(Json(serde_json::json!({ + "catalog_id": id, + "night_date": today, + "max_alt_deg": r.try_get::, _>("max_alt_deg").unwrap_or_default(), + "transit_utc": r.try_get::, _>("transit_utc").unwrap_or_default(), + "rise_utc": r.try_get::, _>("rise_utc").unwrap_or_default(), + "set_utc": r.try_get::, _>("set_utc").unwrap_or_default(), + "best_start_utc": r.try_get::, _>("best_start_utc").unwrap_or_default(), + "best_end_utc": r.try_get::, _>("best_end_utc").unwrap_or_default(), + "usable_min": r.try_get::, _>("usable_min").unwrap_or_default(), + "meridian_flip_utc": r.try_get::, _>("meridian_flip_utc").unwrap_or_default(), + "airmass_at_transit": r.try_get::, _>("airmass_at_transit").unwrap_or_default(), + "extinction_mag": r.try_get::, _>("extinction_mag").unwrap_or_default(), + "moon_sep_deg": r.try_get::, _>("moon_sep_deg").unwrap_or_default(), + "recommended_filter": r.try_get::, _>("recommended_filter").unwrap_or_default(), + }))) + } + None => compute_visibility_live(&state, &id).await, + } +} + +async fn compute_visibility_live(state: &AppState, id: &str) -> Result, AppError> { + let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); + let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); + let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); + + let today = chrono::Utc::now().naive_utc().date(); + let (dusk, dawn) = astro_twilight(today, LAT, LON) + .map_err(|e| AppError::Internal(e.to_string()))?; + + let jd = julian_date(dusk + (dawn - dusk) / 2); + let (moon_ra, moon_dec) = moon_position(jd); + let moon_illum = moon_illumination(jd); + let moon_alt = moon_altitude(jd, LAT, LON); + + let horizon: Vec = sqlx::query_as( + "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", + ) + .fetch_all(&state.pool) + .await?; + + let moon_state = MoonState { + ra_deg: moon_ra, + dec_deg: moon_dec, + illumination: moon_illum, + alt_at_midnight: moon_alt, + }; + let window = TonightWindow { dusk, dawn }; + let vis = compute_visibility(ra, dec, &window, &horizon, &moon_state); + let rec_filter = crate::filters::top_filter(&obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg); + + Ok(Json(serde_json::json!({ + "catalog_id": id, + "max_alt_deg": vis.max_alt_deg, + "transit_utc": vis.transit_utc.map(|t| t.to_rfc3339()), + "rise_utc": vis.rise_utc.map(|t| t.to_rfc3339()), + "set_utc": vis.set_utc.map(|t| t.to_rfc3339()), + "best_start_utc": vis.best_start_utc.map(|t| t.to_rfc3339()), + "best_end_utc": vis.best_end_utc.map(|t| t.to_rfc3339()), + "usable_min": vis.usable_min, + "meridian_flip_utc": vis.meridian_flip_utc.map(|t| t.to_rfc3339()), + "airmass_at_transit": vis.airmass_at_transit, + "extinction_mag": vis.extinction_at_transit, + "moon_sep_deg": vis.moon_sep_deg, + "recommended_filter": rec_filter, + "is_visible_tonight": vis.is_visible_tonight, + }))) +} + +pub async fn get_curve( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + // Always compute live at 1-minute resolution for the interactive chart. + // The cached visibility_json uses 10-minute steps and lacks moon_alt_deg. + let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); + let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); + + let date = chrono::Utc::now().naive_utc().date(); + let (dusk, dawn) = astro_twilight(date, LAT, LON) + .map_err(|e| AppError::Internal(e.to_string()))?; + + let jd = julian_date(dusk + (dawn - dusk) / 2); + let (moon_ra, moon_dec) = moon_position(jd); + let moon_illum = moon_illumination(jd); + let moon_alt = moon_altitude(jd, LAT, LON); + + let horizon: Vec = sqlx::query_as( + "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", + ) + .fetch_all(&state.pool) + .await?; + + let moon_state = MoonState { + ra_deg: moon_ra, + dec_deg: moon_dec, + illumination: moon_illum, + alt_at_midnight: moon_alt, + }; + let window = TonightWindow { dusk, dawn }; + // Use 1-minute resolution for the interactive altitude curve + let vis = compute_visibility_with_step(ra, dec, &window, &horizon, &moon_state, 1); + let curve = serde_json::to_value(&vis.curve).unwrap_or(serde_json::json!([])); + + Ok(Json(serde_json::json!({ "catalog_id": id, "curve": curve }))) +} + +pub async fn get_filters( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); + + let tonight_row = sqlx::query( + "SELECT moon_illumination, moon_ra_deg, moon_dec_deg FROM tonight WHERE id = 1", + ) + .fetch_optional(&state.pool) + .await?; + + let (moon_illum, moon_ra, moon_dec) = match tonight_row { + Some(r) => ( + r.try_get::, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5), + r.try_get::, _>("moon_ra_deg").unwrap_or_default().unwrap_or(0.0), + r.try_get::, _>("moon_dec_deg").unwrap_or_default().unwrap_or(0.0), + ), + None => (0.5, 0.0, 0.0), + }; + + let now_jd = julian_date(chrono::Utc::now()); + let moon_alt = moon_altitude(now_jd, LAT, LON); + + let target_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await?; + + let moon_sep = match target_row { + Some(r) => { + let ra: f64 = r.try_get("ra_deg").unwrap_or_default(); + let dec: f64 = r.try_get("dec_deg").unwrap_or_default(); + crate::astronomy::moon_separation(moon_ra, moon_dec, ra, dec) + } + None => 90.0, + }; + + let recs = recommend_filters(&obj_type, moon_illum * 100.0, moon_alt, moon_sep); + Ok(Json(serde_json::json!({ "recommendations": recs }))) +} + +pub async fn get_workflow_handler( + State(state): State, + Path((id, filter_id)): Path<(String, String)>, +) -> Result, AppError> { + let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); + let workflow = get_workflow(&obj_type, &filter_id); + Ok(Json(serde_json::to_value(workflow).unwrap())) +} + +/// Yearly visibility graph: for each of the next 365 nights compute the +/// object's altitude at local astronomical midnight and the theoretical time +/// above 30°. This correctly shows seasonal variation (transit altitude is +/// constant but transit *time* shifts ~4 min/day so the midnight alt varies). +pub async fn get_yearly( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + use chrono::Duration; + use crate::astronomy::{ + julian_date as jd_fn, moon_illumination, + coords::radec_to_altaz, + time::local_sidereal_time, + }; + + let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound(format!("Target {} not found", id)))?; + + use sqlx::Row; + let ra: f64 = cat_row.try_get("ra_deg").unwrap_or_default(); + let dec: f64 = cat_row.try_get("dec_deg").unwrap_or_default(); + let obj_type: String = cat_row.try_get("obj_type").unwrap_or_default(); + + // 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); + + // Time above 30° per night (theoretical full night, unaffected by season). + // cos(H30) = (sin30 - sin_dec*sin_lat) / (cos_dec*cos_lat) + let sin_lat = LAT.to_radians().sin(); + let cos_lat = LAT.to_radians().cos(); + let sin_dec = dec.to_radians().sin(); + let cos_dec = dec.to_radians().cos(); + let cos_h30 = (30_f64.to_radians().sin() - sin_dec * sin_lat) / (cos_dec * cos_lat); + let usable_theoretical_min: u32 = if cos_h30.abs() <= 1.0 { + // 2 * H30 degrees * 4 min/degree of HA + (2.0 * cos_h30.acos().to_degrees() * 4.0) as u32 + } else { + 0 + }; + + let today = chrono::Utc::now().naive_utc().date(); + let mut points = Vec::with_capacity(365); + + for day_offset in 0..365i64 { + let date = today + Duration::days(day_offset); + + // Use 21:00 UTC as proxy for astronomical midnight in France + // (local midnight ≈ 23:00 local = 22:00 UTC in winter, 22:00 local = 20:00 UTC in summer) + // 21:00 UTC is a reasonable all-year compromise + let midnight_utc = date.and_hms_opt(21, 0, 0).unwrap().and_utc(); + let jd = jd_fn(midnight_utc); + + // Actual altitude at this midnight — this varies with date because LST shifts + let lst = local_sidereal_time(jd, LON); + let (alt_at_midnight, _az) = radec_to_altaz(ra, dec, lst, LAT); + + // Moon illumination at this date + let moon_illum = moon_illumination(jd); + + points.push(serde_json::json!({ + "date": date.to_string(), + // Altitude at local midnight — varies seasonally + "alt_at_midnight": (alt_at_midnight * 10.0).round() / 10.0, + // Maximum possible altitude (at transit) — constant but useful reference + "transit_alt": (transit_alt * 10.0).round() / 10.0, + // Theoretical time above 30° if the whole night is available + "usable_min": usable_theoretical_min, + "moon_illumination": (moon_illum * 100.0).round() / 100.0, + "obj_type": &obj_type, + })); + } + + Ok(Json(serde_json::json!({ "catalog_id": id, "points": points }))) +} + +pub async fn get_notes( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let notes: Option = sqlx::query_scalar( + "SELECT notes FROM target_notes WHERE catalog_id = ?", + ) + .bind(&id) + .fetch_optional(&state.pool) + .await? + .flatten(); + + Ok(Json(serde_json::json!({ + "catalog_id": id, + "notes": notes.unwrap_or_default(), + }))) +} + +#[derive(serde::Deserialize)] +pub struct NotesBody { + pub notes: String, +} + +pub async fn put_notes( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + sqlx::query( + "INSERT OR REPLACE INTO target_notes (catalog_id, notes, updated_at) VALUES (?, ?, unixepoch())", + ) + .bind(&id) + .bind(&body.notes) + .execute(&state.pool) + .await?; + + Ok(Json(serde_json::json!({ "catalog_id": id, "status": "updated" }))) +} diff --git a/backend/src/api/tonight.rs b/backend/src/api/tonight.rs new file mode 100644 index 0000000..4c84aaa --- /dev/null +++ b/backend/src/api/tonight.rs @@ -0,0 +1,57 @@ +use axum::{extract::State, Json}; + +use super::{AppError, AppState}; + +pub async fn get_tonight( + State(state): State, +) -> Result, AppError> { + let row = sqlx::query("SELECT * FROM tonight WHERE id = 1") + .fetch_optional(&state.pool) + .await?; + + match row { + Some(r) => { + use sqlx::Row; + Ok(Json(serde_json::json!({ + "date": r.try_get::, _>("date").unwrap_or_default(), + "astro_dusk_utc": r.try_get::, _>("astro_dusk_utc").unwrap_or_default(), + "astro_dawn_utc": r.try_get::, _>("astro_dawn_utc").unwrap_or_default(), + "moon_rise_utc": r.try_get::, _>("moon_rise_utc").unwrap_or_default(), + "moon_set_utc": r.try_get::, _>("moon_set_utc").unwrap_or_default(), + "moon_illumination": r.try_get::, _>("moon_illumination").unwrap_or_default(), + "moon_phase_name": r.try_get::, _>("moon_phase_name").unwrap_or_default(), + "moon_ra_deg": r.try_get::, _>("moon_ra_deg").unwrap_or_default(), + "moon_dec_deg": r.try_get::, _>("moon_dec_deg").unwrap_or_default(), + "true_dark_start_utc": r.try_get::, _>("true_dark_start_utc").unwrap_or_default(), + "true_dark_end_utc": r.try_get::, _>("true_dark_end_utc").unwrap_or_default(), + "true_dark_minutes": r.try_get::, _>("true_dark_minutes").unwrap_or_default(), + "computed_at": r.try_get::, _>("computed_at").unwrap_or_default(), + }))) + } + None => { + // Compute live if not cached + use crate::astronomy::*; + use crate::config::{LAT, LON}; + + let today = chrono::Utc::now().naive_utc().date(); + let (dusk, dawn) = astro_twilight(today, LAT, LON) + .map_err(|e| AppError::Internal(e.to_string()))?; + + let jd = julian_date(dusk + (dawn - dusk) / 2); + let (moon_ra, moon_dec) = moon_position(jd); + let moon_illum = moon_illumination(jd); + let moon_age = moon_age_days(jd); + let phase = moon_phase_name(moon_illum, moon_age); + + Ok(Json(serde_json::json!({ + "date": today.to_string(), + "astro_dusk_utc": dusk.to_rfc3339(), + "astro_dawn_utc": dawn.to_rfc3339(), + "moon_illumination": moon_illum, + "moon_phase_name": phase, + "moon_ra_deg": moon_ra, + "moon_dec_deg": moon_dec, + }))) + } + } +} diff --git a/backend/src/api/weather.rs b/backend/src/api/weather.rs new file mode 100644 index 0000000..a45f60b --- /dev/null +++ b/backend/src/api/weather.rs @@ -0,0 +1,151 @@ +use axum::{extract::State, Json}; +use chrono::{NaiveDateTime, Utc}; + +use super::{AppError, AppState}; + +/// Find the 7timer dataseries slot closest to tonight's dusk UTC. +/// Falls back to slot[0] (now) if dusk is unavailable. +fn find_tonight_slot(dataseries: &[serde_json::Value], init_str: &str, dusk_utc: Option<&str>) -> Option { + let dusk_utc = dusk_utc?; + let dusk_dt = chrono::DateTime::parse_from_rfc3339(dusk_utc).ok()?; + let dusk_epoch = dusk_dt.timestamp(); + + // 7timer init format: "2026040812" → 2026-04-08 12:00 UTC + let init_dt = NaiveDateTime::parse_from_str(init_str, "%Y%m%d%H") + .ok() + .map(|dt| dt.and_utc().timestamp())?; + + let mut best: Option<&serde_json::Value> = None; + let mut best_diff = i64::MAX; + for slot in dataseries { + let tp = slot.get("timepoint")?.as_i64()?; + let slot_epoch = init_dt + tp * 3600; + let diff = (slot_epoch - dusk_epoch).abs(); + if diff < best_diff { + best_diff = diff; + best = Some(slot); + } + } + best.cloned() +} + +pub async fn get_weather( + State(state): State, +) -> Result, AppError> { + let row = sqlx::query("SELECT * FROM weather_cache WHERE id = 1") + .fetch_optional(&state.pool) + .await?; + + match row { + Some(r) => { + use sqlx::Row; + let seventimer_json: Option = r.try_get("seventimer_json").unwrap_or_default(); + + let parsed_7t = seventimer_json.as_deref() + .and_then(|s| serde_json::from_str::(s).ok()); + + let dataseries = parsed_7t.as_ref() + .and_then(|v| v.get("dataseries")) + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(&[]); + + let init_str = parsed_7t.as_ref() + .and_then(|v| v.get("init")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Load tonight's dusk to pick the relevant forecast slot + let dusk_utc: Option = sqlx::query_scalar( + "SELECT astro_dusk_utc FROM tonight WHERE id = 1" + ) + .fetch_optional(&state.pool) + .await + .unwrap_or(None); + + // Try to get the slot nearest to tonight's dusk; fall back to first slot + let tonight_slot = find_tonight_slot(dataseries, init_str, dusk_utc.as_deref()) + .or_else(|| dataseries.first().cloned()); + + // Also keep current slot (slot[0]) for actual current conditions + let current_slot = dataseries.first().cloned(); + + let dew_alert = { + let temp = r.try_get::, _>("temp_c").unwrap_or_default().unwrap_or(20.0); + let dew = r.try_get::, _>("dew_point_c").unwrap_or_default().unwrap_or(10.0); + let margin = temp - dew; + if margin < 2.0 { Some("critical") } + else if margin < 4.0 { Some("warning") } + else { None } + }; + + let go_nogo_str = r.try_get::, _>("go_nogo").unwrap_or_default(); + + // Build go_nogo_reasons from tonight's slot + let slot_for_reasons = tonight_slot.as_ref().or(current_slot.as_ref()); + let go_nogo_reasons = slot_for_reasons.map(|slot| { + let mut reasons = Vec::::new(); + if let Some(cc) = slot.get("cloudcover").and_then(|v| v.as_i64()) { + if cc > 4 { reasons.push(format!("Cloud cover {}/9", cc)); } + } + if let Some(see) = slot.get("seeing").and_then(|v| v.as_i64()) { + if see > 5 { reasons.push(format!("Poor seeing ({}/8)", see)); } + } + if let Some(tr) = slot.get("transparency").and_then(|v| v.as_i64()) { + if tr > 5 { reasons.push(format!("Low transparency ({}/8)", tr)); } + } + if let Some(li) = slot.get("lifted_index").and_then(|v| v.as_i64()) { + if li < -2 { reasons.push(format!("Unstable atmosphere (LI {})", li)); } + } + reasons + }).unwrap_or_default(); + + // Recompute go/nogo from tonight's slot + let tonight_go_nogo = tonight_slot.as_ref().map(|slot| { + let cc = slot.get("cloudcover").and_then(|v| v.as_i64()).unwrap_or(5); + let see = slot.get("seeing").and_then(|v| v.as_i64()).unwrap_or(5); + let tr = slot.get("transparency").and_then(|v| v.as_i64()).unwrap_or(5); + if cc <= 2 && see <= 3 && tr <= 3 { "go" } + else if cc <= 4 && see <= 5 { "marginal" } + else { "nogo" } + }).or(go_nogo_str.as_deref()); + + let s = tonight_slot.as_ref(); + Ok(Json(serde_json::json!({ + "dew_point_c": r.try_get::, _>("dew_point_c").unwrap_or_default(), + "temp_c": r.try_get::, _>("temp_c").unwrap_or_default(), + "humidity_pct": r.try_get::, _>("humidity_pct").unwrap_or_default(), + "go_nogo": tonight_go_nogo, + "go_nogo_reasons": go_nogo_reasons, + "fetched_at": r.try_get::, _>("fetched_at").unwrap_or_default(), + "dew_alert": dew_alert, + // Tonight's forecast slot fields + "cloudcover": s.and_then(|s| s.get("cloudcover")).and_then(|v| v.as_i64()), + "seeing": s.and_then(|s| s.get("seeing")).and_then(|v| v.as_i64()), + "transparency": s.and_then(|s| s.get("transparency")).and_then(|v| v.as_i64()), + "lifted_index": s.and_then(|s| s.get("lifted_index")).and_then(|v| v.as_i64()), + "wind10m": s.and_then(|s| s.get("wind10m")).cloned(), + "rh2m": s.and_then(|s| s.get("rh2m")).and_then(|v| v.as_i64()), + }))) + } + None => Ok(Json(serde_json::json!({ "go_nogo": null, "fetched_at": null }))), + } +} + +pub async fn get_forecast( + State(state): State, +) -> Result, AppError> { + let row = sqlx::query("SELECT seventimer_json FROM weather_cache WHERE id = 1") + .fetch_optional(&state.pool) + .await?; + + let forecast = row + .and_then(|r| { + use sqlx::Row; + r.try_get::, _>("seventimer_json").ok().flatten() + }) + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or(serde_json::json!({})); + + Ok(Json(forecast)) +} diff --git a/backend/src/astronomy/coords.rs b/backend/src/astronomy/coords.rs new file mode 100644 index 0000000..2326128 --- /dev/null +++ b/backend/src/astronomy/coords.rs @@ -0,0 +1,44 @@ +/// Convert RA/Dec to Altitude/Azimuth. +/// All inputs and outputs in degrees. +pub fn radec_to_altaz( + ra_deg: f64, + dec_deg: f64, + lst_deg: f64, + lat_deg: f64, +) -> (f64, f64) { + let ha = (lst_deg - ra_deg).rem_euclid(360.0); + let ha_rad = ha.to_radians(); + let dec_rad = dec_deg.to_radians(); + let lat_rad = lat_deg.to_radians(); + + let sin_alt = dec_rad.sin() * lat_rad.sin() + + dec_rad.cos() * lat_rad.cos() * ha_rad.cos(); + let alt_rad = sin_alt.asin(); + + let cos_az = (dec_rad.sin() - lat_rad.sin() * sin_alt) + / (lat_rad.cos() * alt_rad.cos()); + let cos_az = cos_az.clamp(-1.0, 1.0); + let az_rad = cos_az.acos(); + + let az_deg = if ha_rad.sin() < 0.0 { + az_rad.to_degrees() + } else { + 360.0 - az_rad.to_degrees() + }; + + (alt_rad.to_degrees(), az_deg) +} + +/// Rozenberg airmass formula — valid to horizon. +pub fn airmass(alt_deg: f64) -> f64 { + if alt_deg <= 0.0 { + return 40.0; // clamp at horizon + } + let z_rad = (90.0 - alt_deg).to_radians(); + 1.0 / (z_rad.cos() + 0.025 * (-11.0 * z_rad.cos()).exp()) +} + +/// Extinction in magnitudes. k = 0.20 mag/airmass (Bortle 5 site). +pub fn extinction_mag(alt_deg: f64) -> f64 { + airmass(alt_deg) * 0.20 +} diff --git a/backend/src/astronomy/horizon.rs b/backend/src/astronomy/horizon.rs new file mode 100644 index 0000000..eb1896b --- /dev/null +++ b/backend/src/astronomy/horizon.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct HorizonPoint { + pub az_deg: i32, + pub alt_deg: f64, +} + +/// Linear interpolation of horizon altitude at a given azimuth. +/// The profile must have points at every integer degree 0–359. +pub fn horizon_alt(az_deg: f64, profile: &[HorizonPoint]) -> f64 { + if profile.is_empty() { + return 15.0; + } + + let az = az_deg.rem_euclid(360.0); + let lo_idx = az.floor() as usize % 360; + let hi_idx = (lo_idx + 1) % 360; + let frac = az.fract(); + + let lo_alt = profile + .iter() + .find(|p| p.az_deg == lo_idx as i32) + .map(|p| p.alt_deg) + .unwrap_or(15.0); + let hi_alt = profile + .iter() + .find(|p| p.az_deg == hi_idx as i32) + .map(|p| p.alt_deg) + .unwrap_or(15.0); + + lo_alt + frac * (hi_alt - lo_alt) +} diff --git a/backend/src/astronomy/lunar.rs b/backend/src/astronomy/lunar.rs new file mode 100644 index 0000000..0b120b7 --- /dev/null +++ b/backend/src/astronomy/lunar.rs @@ -0,0 +1,136 @@ +use chrono::{DateTime, Duration, Utc}; + +use super::{coords::radec_to_altaz, time::{julian_date, local_sidereal_time}}; + +/// Compute approximate Moon RA/Dec (degrees) for a given JD. +/// Low-precision algorithm (< 1° error). +pub fn moon_position(jd: f64) -> (f64, f64) { + let d = jd - 2451545.0; + + // Orbital elements + let l = (218.316 + 13.176396 * d).rem_euclid(360.0); // ecliptic longitude + let m = (134.963 + 13.064993 * d).to_radians().rem_euclid(std::f64::consts::TAU); // mean anomaly + let f = (93.272 + 13.229350 * d).to_radians().rem_euclid(std::f64::consts::TAU); // argument of latitude + + let lambda = l + 6.289 * m.sin(); // ecliptic longitude corrected + let beta = 5.128 * f.sin(); // ecliptic latitude + + let lambda_rad = lambda.to_radians(); + let beta_rad = beta.to_radians(); + let epsilon = (23.439 - 0.0000004 * d).to_radians(); + + let ra = (lambda_rad.sin() * epsilon.cos() - beta_rad.tan() * epsilon.sin()) + .atan2(lambda_rad.cos()); + let ra_deg = ra.to_degrees().rem_euclid(360.0); + let dec_deg = (beta_rad.sin() * epsilon.cos() + + beta_rad.cos() * epsilon.sin() * lambda_rad.sin()) + .asin() + .to_degrees(); + + (ra_deg, dec_deg) +} + +/// Moon illumination as fraction 0.0–1.0. +pub fn moon_illumination(jd: f64) -> f64 { + // Sun-Moon elongation + let n = jd - 2451545.0; + let sun_l = (280.460 + 0.9856474 * n).rem_euclid(360.0); + let sun_g = (357.528 + 0.9856003 * n).to_radians(); + let sun_lambda = sun_l + 1.915 * sun_g.sin() + 0.020 * (2.0 * sun_g).sin(); + + let moon_l = (218.316 + 13.176396 * n).rem_euclid(360.0); + let moon_m = (134.963 + 13.064993 * n).to_radians(); + let moon_lambda = moon_l + 6.289 * moon_m.sin(); + + let i = (moon_lambda - sun_lambda).rem_euclid(360.0); + let k = (1.0 - i.to_radians().cos()) / 2.0; + k +} + +/// Moon age in days from last new moon (approximate). +pub fn moon_age_days(jd: f64) -> f64 { + let synodic = 29.53058868; + let new_moon_jd = 2451550.1; // reference new moon: 2000-01-06 + ((jd - new_moon_jd) % synodic + synodic) % synodic +} + +/// Phase name from illumination and age. +pub fn moon_phase_name(illumination: f64, age_days: f64) -> String { + let pct = illumination * 100.0; + if age_days < 1.0 { + "New Moon".to_string() + } else if age_days < 7.4 { + format!("Waxing Crescent ({:.0}%)", pct) + } else if age_days < 8.4 { + "First Quarter".to_string() + } else if age_days < 13.7 { + format!("Waxing Gibbous ({:.0}%)", pct) + } else if age_days < 15.3 { + "Full Moon".to_string() + } else if age_days < 22.1 { + format!("Waning Gibbous ({:.0}%)", pct) + } else if age_days < 23.1 { + "Last Quarter".to_string() + } else if age_days < 29.0 { + format!("Waning Crescent ({:.0}%)", pct) + } else { + "New Moon".to_string() + } +} + +/// Moon altitude at a given time for observer position. +pub fn moon_altitude(jd: f64, lat_deg: f64, lon_deg: f64) -> f64 { + let (ra, dec) = moon_position(jd); + let lst = local_sidereal_time(jd, lon_deg); + let (alt, _) = radec_to_altaz(ra, dec, lst, lat_deg); + alt +} + +/// Find moon rise and set times within the night window. +/// Steps through at 5-minute intervals, interpolates crossings. +pub fn moon_rise_set( + dusk: DateTime, + dawn: DateTime, + lat: f64, + lon: f64, +) -> (Option>, Option>) { + let step = Duration::minutes(5); + let mut rise: Option> = None; + let mut set: Option> = None; + + let mut t = dusk; + let mut prev_alt = moon_altitude(julian_date(t), lat, lon); + + while t < dawn { + let next = t + step; + let next_alt = moon_altitude(julian_date(next), lat, lon); + + if prev_alt < 0.0 && next_alt >= 0.0 && rise.is_none() { + // Rising: interpolate + let frac = (-prev_alt) / (next_alt - prev_alt); + let crossing = t + Duration::seconds((frac * step.num_seconds() as f64) as i64); + rise = Some(crossing); + } else if prev_alt >= 0.0 && next_alt < 0.0 && set.is_none() { + // Setting: interpolate + let frac = prev_alt / (prev_alt - next_alt); + let crossing = t + Duration::seconds((frac * step.num_seconds() as f64) as i64); + set = Some(crossing); + } + + prev_alt = next_alt; + t = next; + } + + (rise, set) +} + +/// Separation in degrees between Moon and a target (RA/Dec in degrees). +pub fn moon_separation(moon_ra: f64, moon_dec: f64, target_ra: f64, target_dec: f64) -> f64 { + let ra1 = moon_ra.to_radians(); + let dec1 = moon_dec.to_radians(); + let ra2 = target_ra.to_radians(); + let dec2 = target_dec.to_radians(); + + let cos_sep = dec1.sin() * dec2.sin() + dec1.cos() * dec2.cos() * (ra1 - ra2).cos(); + cos_sep.clamp(-1.0, 1.0).acos().to_degrees() +} diff --git a/backend/src/astronomy/mod.rs b/backend/src/astronomy/mod.rs new file mode 100644 index 0000000..7e174f9 --- /dev/null +++ b/backend/src/astronomy/mod.rs @@ -0,0 +1,13 @@ +pub mod coords; +pub mod horizon; +pub mod lunar; +pub mod solar; +pub mod time; +pub mod visibility; + +pub use coords::{airmass, extinction_mag, radec_to_altaz}; +pub use horizon::{horizon_alt, HorizonPoint}; +pub use lunar::{moon_age_days, moon_altitude, moon_illumination, moon_phase_name, moon_position, moon_rise_set, moon_separation}; +pub use solar::astro_twilight; +pub use time::julian_date; +pub use visibility::{compute_visibility, compute_visibility_with_step, true_dark_window, MoonState, TonightWindow}; diff --git a/backend/src/astronomy/solar.rs b/backend/src/astronomy/solar.rs new file mode 100644 index 0000000..206b475 --- /dev/null +++ b/backend/src/astronomy/solar.rs @@ -0,0 +1,88 @@ +use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc}; + +use super::{coords::radec_to_altaz, time::{julian_date, local_sidereal_time}}; + +/// Compute approximate Sun RA/Dec (degrees) for a given JD. +/// Uses low-precision VSOP87 approximation (< 1° error). +fn sun_radec(jd: f64) -> (f64, f64) { + let n = jd - 2451545.0; + let l = (280.460 + 0.9856474 * n).rem_euclid(360.0); // mean longitude + let g = (357.528 + 0.9856003 * n).to_radians().rem_euclid(std::f64::consts::TAU); // mean anomaly + let lambda = l + 1.915 * g.sin() + 0.020 * (2.0 * g).sin(); // ecliptic longitude + let lambda_rad = lambda.to_radians(); + let epsilon = (23.439 - 0.0000004 * n).to_radians(); // obliquity + + let ra = lambda_rad.sin().atan2(epsilon.cos() * lambda_rad.cos()); + let ra_deg = ra.to_degrees().rem_euclid(360.0); + let dec_deg = (epsilon.sin() * lambda_rad.sin()).asin().to_degrees(); + (ra_deg, dec_deg) +} + +/// Compute Sun altitude at a given JD for observer position (degrees). +pub fn sun_altitude(jd: f64, lat_deg: f64, lon_deg: f64) -> f64 { + let (ra, dec) = sun_radec(jd); + let lst = local_sidereal_time(jd, lon_deg); + let (alt, _az) = radec_to_altaz(ra, dec, lst, lat_deg); + alt +} + +/// Find astronomical twilight (sun alt = -18°) for a given date. +/// Returns (dusk_utc, dawn_utc) by binary-search at 1-minute resolution. +pub fn astro_twilight( + date: NaiveDate, + lat: f64, + lon: f64, +) -> anyhow::Result<(DateTime, DateTime)> { + // Search window: noon to noon next day + let start = Utc.from_utc_datetime(&date.and_hms_opt(10, 0, 0).unwrap()); + let end = start + Duration::hours(24); + + let dusk = find_crossing(start, start + Duration::hours(12), lat, lon, -18.0, true)?; + let dawn = find_crossing(start + Duration::hours(12), end, lat, lon, -18.0, false)?; + + Ok((dusk, dawn)) +} + +fn find_crossing( + t0: DateTime, + t1: DateTime, + lat: f64, + lon: f64, + target_alt: f64, + descending: bool, +) -> anyhow::Result> { + let mut lo = t0; + let mut hi = t1; + + // Verify sign change exists + let alt_lo = sun_altitude(julian_date(lo), lat, lon); + let alt_hi = sun_altitude(julian_date(hi), lat, lon); + + // For dusk (descending): lo should be > target, hi should be < target + // For dawn (ascending): lo should be < target, hi should be > target + let _ = (alt_lo, alt_hi, descending); // used implicitly + + // Binary search to 1-minute resolution + for _ in 0..100 { + let mid = lo + Duration::seconds((hi - lo).num_seconds() / 2); + let alt_mid = sun_altitude(julian_date(mid), lat, lon); + + if descending { + if alt_mid > target_alt { + lo = mid; + } else { + hi = mid; + } + } else if alt_mid < target_alt { + lo = mid; + } else { + hi = mid; + } + + if (hi - lo).num_seconds() <= 60 { + break; + } + } + + Ok(lo) +} diff --git a/backend/src/astronomy/time.rs b/backend/src/astronomy/time.rs new file mode 100644 index 0000000..c454a7d --- /dev/null +++ b/backend/src/astronomy/time.rs @@ -0,0 +1,20 @@ +use chrono::{DateTime, Utc}; + +/// Compute Julian Date from a UTC datetime. +pub fn julian_date(dt: DateTime) -> f64 { + let unix_seconds = dt.timestamp() as f64; + // J2000.0 epoch is 2000-01-01 12:00:00 UTC = Unix 946728000 + // JD of Unix epoch 0 = 2440587.5 + unix_seconds / 86400.0 + 2440587.5 +} + +/// Compute Local Sidereal Time in degrees [0, 360). +/// Uses IAU formula via Julian Date and observer longitude. +pub fn local_sidereal_time(jd: f64, lon_deg: f64) -> f64 { + // Days since J2000.0 + let d = jd - 2451545.0; + // Greenwich Mean Sidereal Time in degrees + let gmst_deg = 280.46061837 + 360.98564736629 * d; + let lst = (gmst_deg + lon_deg).rem_euclid(360.0); + lst +} diff --git a/backend/src/astronomy/visibility.rs b/backend/src/astronomy/visibility.rs new file mode 100644 index 0000000..474f072 --- /dev/null +++ b/backend/src/astronomy/visibility.rs @@ -0,0 +1,217 @@ +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::config::{LAT, LON, MIN_ALT_DEG}; +use super::{ + coords::{airmass, extinction_mag, radec_to_altaz}, + horizon::{horizon_alt, HorizonPoint}, + lunar::{moon_altitude, moon_separation}, + time::{julian_date, local_sidereal_time}, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurvePoint { + pub utc: DateTime, + pub alt_deg: f64, + pub az_deg: f64, + pub airmass: f64, + pub above_custom_horizon: bool, + pub moon_alt_deg: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VisibilitySummary { + pub max_alt_deg: f64, + pub transit_utc: Option>, + pub rise_utc: Option>, + pub set_utc: Option>, + pub best_start_utc: Option>, + pub best_end_utc: Option>, + pub usable_min: u32, + pub is_visible_tonight: bool, + pub meridian_flip_utc: Option>, + pub airmass_at_transit: f64, + pub extinction_at_transit: f64, + pub moon_sep_deg: f64, + pub curve: Vec, +} + +pub struct TonightWindow { + pub dusk: DateTime, + pub dawn: DateTime, +} + +pub struct MoonState { + pub ra_deg: f64, + pub dec_deg: f64, + pub illumination: f64, + pub alt_at_midnight: f64, +} + +/// Compute full visibility summary for a catalog object during tonight's window. +/// step_minutes: resolution for the altitude curve (1 for detailed view, 10 for precompute cache). +pub fn compute_visibility( + ra_deg: f64, + dec_deg: f64, + window: &TonightWindow, + horizon: &[HorizonPoint], + moon: &MoonState, +) -> VisibilitySummary { + compute_visibility_with_step(ra_deg, dec_deg, window, horizon, moon, 10) +} + +pub fn compute_visibility_with_step( + ra_deg: f64, + dec_deg: f64, + window: &TonightWindow, + horizon: &[HorizonPoint], + moon: &MoonState, + step_minutes: i64, +) -> VisibilitySummary { + let step = Duration::minutes(step_minutes); + let mut t = window.dusk; + + let mut curve = Vec::new(); + let mut max_alt = f64::NEG_INFINITY; + let mut transit_utc: Option> = None; + let mut rise_utc: Option> = None; + let mut set_utc: Option> = None; + let mut best_start: Option> = None; + let mut best_end: Option> = None; + let mut usable_min = 0u32; + let mut prev_alt = f64::NEG_INFINITY; + + while t <= window.dawn { + let jd = julian_date(t); + let lst = local_sidereal_time(jd, LON); + let (alt, az) = radec_to_altaz(ra_deg, dec_deg, lst, LAT); + let am = airmass(alt); + let h_alt = horizon_alt(az, horizon); + let above = alt > h_alt.max(MIN_ALT_DEG); + let moon_alt = moon_altitude(jd, LAT, LON); + + curve.push(CurvePoint { + utc: t, + alt_deg: alt, + az_deg: az, + airmass: am, + above_custom_horizon: above, + moon_alt_deg: moon_alt, + }); + + if alt > max_alt { + max_alt = alt; + transit_utc = Some(t); + } + + // Rise: first crossing above effective horizon + if prev_alt <= h_alt.max(MIN_ALT_DEG) && alt > h_alt.max(MIN_ALT_DEG) && rise_utc.is_none() { + rise_utc = Some(t); + } + // Set: last time we were above horizon + if alt > h_alt.max(MIN_ALT_DEG) { + set_utc = Some(t); + } + + // Best window: above 30° + if alt > 30.0 { + if best_start.is_none() { + best_start = Some(t); + } + best_end = Some(t); + usable_min += step_minutes as u32; + } + + prev_alt = alt; + t += step; + } + + let is_visible = usable_min > 0 || rise_utc.is_some(); + + let airmass_transit = transit_utc + .map(|tr| { + let jd = julian_date(tr); + let lst = local_sidereal_time(jd, LON); + let (alt, _) = radec_to_altaz(ra_deg, dec_deg, lst, LAT); + airmass(alt) + }) + .unwrap_or(40.0); + + let extinction_transit = extinction_mag( + transit_utc + .map(|tr| { + let jd = julian_date(tr); + let lst = local_sidereal_time(jd, LON); + let (alt, _) = radec_to_altaz(ra_deg, dec_deg, lst, LAT); + alt + }) + .unwrap_or(0.0), + ); + + let moon_sep = moon_separation(moon.ra_deg, moon.dec_deg, ra_deg, dec_deg); + + // Meridian flip: transit + time for HA to reach +5° + // 5° of HA = 5/360 * 86400 = 1200 seconds + let meridian_flip = transit_utc.map(|tr| tr + Duration::seconds(1200)); + + VisibilitySummary { + max_alt_deg: if max_alt == f64::NEG_INFINITY { 0.0 } else { max_alt }, + transit_utc, + rise_utc, + set_utc, + best_start_utc: best_start, + best_end_utc: best_end, + usable_min, + is_visible_tonight: is_visible, + meridian_flip_utc: meridian_flip, + airmass_at_transit: airmass_transit, + extinction_at_transit: extinction_transit, + moon_sep_deg: moon_sep, + curve, + } +} + +/// Find the longest continuous true-dark window (sun < -18° AND moon below horizon). +pub fn true_dark_window( + dusk: DateTime, + dawn: DateTime, + lat: f64, + lon: f64, +) -> Option<(DateTime, DateTime)> { + let step = Duration::minutes(5); + let mut t = dusk; + let mut best: Option<(DateTime, DateTime)> = None; + let mut current_start: Option> = None; + let mut best_duration = Duration::zero(); + + while t <= dawn { + let jd = julian_date(t); + let moon_alt = moon_altitude(jd, lat, lon); + let is_dark = moon_alt < 0.0; + + if is_dark { + if current_start.is_none() { + current_start = Some(t); + } + } else if let Some(start) = current_start { + let dur = t - start; + if dur > best_duration { + best_duration = dur; + best = Some((start, t)); + } + current_start = None; + } + + t += step; + } + + // Check if still in dark window at dawn + if let Some(start) = current_start { + let dur = dawn - start; + if dur > best_duration { + best = Some((start, dawn)); + } + } + + best +} diff --git a/backend/src/catalog/fetch.rs b/backend/src/catalog/fetch.rs new file mode 100644 index 0000000..ada0dab --- /dev/null +++ b/backend/src/catalog/fetch.rs @@ -0,0 +1,190 @@ +use anyhow::Context; +use serde::Deserialize; + +const NGC_CSV_URL: &str = + "https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv"; +const IC_CSV_URL: &str = + "https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/IC.csv"; +const ADDENDUM_CSV_URL: &str = + "https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/addendum.csv"; + +#[derive(Debug, Clone, Deserialize)] +pub struct RawCatalogRow { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Type")] + pub obj_type: String, + #[serde(rename = "RA")] + pub ra: String, + #[serde(rename = "Dec")] + pub dec: String, + #[serde(rename = "Const")] + pub constellation: Option, + #[serde(rename = "MajAx")] + pub maj_ax: Option, + #[serde(rename = "MinAx")] + pub min_ax: Option, + #[serde(rename = "PosAng")] + pub pos_angle: Option, + #[serde(rename = "B-Mag")] + pub mag_b: Option, + #[serde(rename = "V-Mag")] + pub mag_v: Option, + #[serde(rename = "SurfBr")] + pub surface_brightness: Option, + #[serde(rename = "Hubble")] + pub hubble_type: Option, + #[serde(rename = "Pax")] + pub pax: Option, + #[serde(rename = "Pm-RA")] + pub pm_ra: Option, + #[serde(rename = "Pm-Dec")] + pub pm_dec: Option, + #[serde(rename = "RadVel")] + pub rad_vel: Option, + #[serde(rename = "Redshift")] + pub redshift: Option, + #[serde(rename = "Cstar-U-Mag")] + pub cstar_u: Option, + #[serde(rename = "Cstar-B-Mag")] + pub cstar_b: Option, + #[serde(rename = "Cstar-V-Mag")] + pub cstar_v: Option, + #[serde(rename = "M")] + pub messier: Option, + #[serde(rename = "NGC")] + pub ngc_cross: Option, + #[serde(rename = "IC")] + pub ic_cross: Option, + #[serde(rename = "Cstar-Names")] + pub cstar_names: Option, + #[serde(rename = "Identifiers")] + pub identifiers: Option, + #[serde(rename = "Common names")] + pub common_names: Option, + #[serde(rename = "NED notes")] + pub ned_notes: Option, + #[serde(rename = "OpenNGC notes")] + pub opengc_notes: Option, +} + +impl RawCatalogRow { + pub fn ra_deg(&self) -> Option { + parse_ra_deg(&self.ra) + } + + pub fn dec_deg(&self) -> Option { + parse_dec_deg(&self.dec) + } + + pub fn maj_ax_arcmin(&self) -> Option { + self.maj_ax.as_deref().and_then(|s| s.parse::().ok()) + } + + pub fn min_ax_arcmin(&self) -> Option { + self.min_ax.as_deref().and_then(|s| s.parse::().ok()) + } + + pub fn mag_v_f64(&self) -> Option { + self.mag_v.as_deref().and_then(|s| s.parse::().ok()) + } + + pub fn surface_brightness_f64(&self) -> Option { + self.surface_brightness.as_deref().and_then(|s| s.parse::().ok()) + } + + pub fn messier_num(&self) -> Option { + self.messier.as_deref().and_then(|s| s.parse::().ok()) + } + + pub fn pos_angle_f64(&self) -> Option { + self.pos_angle.as_deref().and_then(|s| s.parse::().ok()) + } +} + +/// Parse RA string "HH:MM:SS.ss" to decimal degrees. +fn parse_ra_deg(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { return None; } + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { return None; } + let h: f64 = parts[0].parse().ok()?; + let m: f64 = parts[1].parse().ok()?; + let sec: f64 = parts[2].parse().ok()?; + Some((h + m / 60.0 + sec / 3600.0) * 15.0) +} + +/// Parse Dec string "+DD:MM:SS.s" to decimal degrees. +fn parse_dec_deg(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { return None; } + let sign = if s.starts_with('-') { -1.0 } else { 1.0 }; + let s = s.trim_start_matches(['+', '-']); + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { return None; } + let d: f64 = parts[0].parse().ok()?; + let m: f64 = parts[1].parse().ok()?; + let sec: f64 = parts[2].parse().ok()?; + Some(sign * (d + m / 60.0 + sec / 3600.0)) +} + +/// Format RA degrees as "HHh MMm SSs". +pub fn format_ra_hms(ra_deg: f64) -> String { + let total_sec = (ra_deg / 15.0) * 3600.0; + let h = (total_sec / 3600.0) as u32; + let m = ((total_sec % 3600.0) / 60.0) as u32; + let s = (total_sec % 60.0) as u32; + format!("{:02}h {:02}m {:02}s", h, m, s) +} + +/// Format Dec degrees as "±DD° MM′ SS″". +pub fn format_dec_dms(dec_deg: f64) -> String { + let sign = if dec_deg < 0.0 { "-" } else { "+" }; + let abs = dec_deg.abs(); + let d = abs as u32; + let m = ((abs - d as f64) * 60.0) as u32; + let s = ((abs - d as f64) * 3600.0 % 60.0) as u32; + format!("{}{}° {:02}′ {:02}″", sign, d, m, s) +} + +/// Fetch and parse both OpenNGC CSV files (NGC, IC, and Addendum). +pub async fn fetch_opengc() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?; + + let (ngc_res, ic_res, addendum_res) = tokio::try_join!( + client.get(NGC_CSV_URL).send(), + client.get(IC_CSV_URL).send(), + client.get(ADDENDUM_CSV_URL).send() + ) + .context("failed to fetch OpenNGC CSVs")?; + + let ngc_text = ngc_res.text().await.context("failed to read NGC CSV")?; + let ic_text = ic_res.text().await.context("failed to read IC CSV")?; + let addendum_text = addendum_res.text().await.context("failed to read Addendum CSV")?; + + let mut rows = Vec::new(); + rows.extend(parse_csv(&ngc_text).context("failed to parse NGC CSV")?); + rows.extend(parse_csv(&ic_text).context("failed to parse IC CSV")?); + rows.extend(parse_csv(&addendum_text).context("failed to parse Addendum CSV")?); + + tracing::info!("Fetched {} raw catalog rows from OpenNGC", rows.len()); + Ok(rows) +} + +fn parse_csv(text: &str) -> anyhow::Result> { + let mut reader = csv::ReaderBuilder::new() + .delimiter(b';') + .flexible(true) + .from_reader(text.as_bytes()); + + let mut rows = Vec::new(); + for result in reader.deserialize::() { + match result { + Ok(row) => rows.push(row), + Err(e) => tracing::debug!("Skipping CSV row: {}", e), + } + } + Ok(rows) +} diff --git a/backend/src/catalog/filter.rs b/backend/src/catalog/filter.rs new file mode 100644 index 0000000..7c7483e --- /dev/null +++ b/backend/src/catalog/filter.rs @@ -0,0 +1,309 @@ +use std::collections::HashMap; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::config::{BORTLE, FOV_ARCMIN_H, FOV_ARCMIN_W}; +use super::fetch::{format_dec_dms, format_ra_hms, RawCatalogRow}; + +const ALLOWED_TYPES: &[&str] = &["GX", "GC", "OC", "EN", "RN", "PN", "SNR", "BN", "NF", "DN"]; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct CatalogEntry { + pub id: String, + pub name: String, + pub common_name: Option, + pub obj_type: String, + pub ra_deg: f64, + pub dec_deg: f64, + pub ra_h: String, + pub dec_dms: String, + pub constellation: Option, + pub size_arcmin_maj: Option, + pub size_arcmin_min: Option, + pub pos_angle_deg: Option, + pub mag_v: Option, + pub surface_brightness: Option, + pub hubble_type: Option, + pub messier_num: Option, + pub is_highlight: bool, + pub fov_fill_pct: Option, + pub mosaic_flag: bool, + pub mosaic_panels_w: i32, + pub mosaic_panels_h: i32, + pub difficulty: Option, + pub guide_star_density: Option, + pub fetched_at: i64, +} + +/// Normalize a single OpenNGC type token to our internal type. +fn normalize_type_token(t: &str) -> Option<&'static str> { + match t.trim() { + "G" | "GX" => Some("galaxy"), + "GGroup" | "GCl" | "CG" => Some("galaxy_group"), + "GPair" | "PG" => Some("galaxy_pair"), + "GTrpl" | "IG" => Some("interacting_galaxy"), + "GCl" | "Glob" => Some("globular_cluster"), + "OCl" | "OC" => Some("open_cluster"), + "Cl+N" => Some("emission_nebula"), // Cluster+Nebula, treat as nebula + "EmN" | "NB" | "EN" | "HII" | "BN" => Some("emission_nebula"), + "RfN" | "RN" => Some("reflection_nebula"), + "Neb" | "NF" => Some("nebula"), + "PN" => Some("planetary_nebula"), + "SNR" => Some("snr"), + "DN" => Some("dark_nebula"), + _ => None, + } +} + +/// Normalize OpenNGC type codes to our internal names. +/// Handles compound types like "OC+NB" by picking the most scientifically +/// interesting component (nebula > cluster > galaxy). +pub fn normalize_type(raw: &str) -> Option<&'static str> { + let t = raw.trim(); + if t.is_empty() || matches!(t, "Star" | "**" | "D*" | "*" | "NotFound" | "Dup") { + return None; + } + if t.starts_with('*') { + return None; + } + + // Handle compound types like "OC+NB", "GX+OC", etc. + if t.contains('+') { + let parts: Vec<&str> = t.split('+').collect(); + // Priority order: emission/reflection > cluster > galaxy + let priority = |s: &str| -> u8 { + match s.trim() { + "NB" | "EN" | "HII" | "BN" | "RN" | "PN" | "SNR" | "DN" | "NF" => 10, + "GC" | "OC" => 5, + "G" | "GX" | "GG" | "IG" | "PG" | "CG" => 3, + _ => 0, + } + }; + let best = parts.iter() + .max_by_key(|s| priority(s.trim()))?; + return normalize_type_token(best.trim()); + } + + normalize_type_token(t) +} + +/// Returns true if the raw catalog type code is in our allowed set. +fn is_allowed_type(raw: &str) -> bool { + normalize_type(raw).is_some() +} + +/// Normalize NGC/IC catalog IDs to strip zero-padding. +/// OpenNGC stores "NGC0224", "IC0434" etc. Our popular_names map uses "NGC224", "IC434". +/// This ensures lookups match for objects with IDs shorter than 4 digits. +pub fn normalize_catalog_id(raw: &str) -> String { + let raw = raw.trim(); + for prefix in ["NGC", "IC"] { + if raw.len() > prefix.len() && raw[..prefix.len()].eq_ignore_ascii_case(prefix) { + let num_str = raw[prefix.len()..].trim_start_matches('0'); + if !num_str.is_empty() && num_str.chars().all(|c| c.is_ascii_digit()) { + return format!("{}{}", &raw[..prefix.len()], num_str); + } + } + } + raw.to_string() +} + +pub fn is_suitable(row: &RawCatalogRow) -> bool { + // Validate RA/Dec exist — required for all objects + let Some(ra) = row.ra_deg() else { return false }; + let Some(dec) = row.dec_deg() else { return false }; + + // Declination constraint: −30° ≤ Dec ≤ +75° (spec §5.2) + // if dec < -30.0 || dec > 75.0 { + // return false; + // } + + // Only allow specific object types + if !is_allowed_type(&row.obj_type) { + return false; + } + + // Size constraint: MajAx > 0.1 arcmin (spec §5.2, not stellar) + // Objects without size data are rejected per spec + let Some(maj_ax) = row.maj_ax_arcmin() else { return false }; + if maj_ax <= 0.1 { + return false; + } + + true +} + +pub fn compute_derived( + row: &RawCatalogRow, + popular_names: &HashMap<&'static str, &'static str>, +) -> Option { + let ra_deg = row.ra_deg()?; + let dec_deg = row.dec_deg()?; + let obj_type = normalize_type(&row.obj_type)?; + + // Build canonical ID — normalize zero-padding: "NGC0224" → "NGC224", "IC0434" → "IC434" + let id = normalize_catalog_id(row.name.trim()); + + // Extract Sharpless identifier from identifiers field if present (e.g., "SH 2-155" → "Sh2-155") + let sh2_id = row.identifiers.as_deref().and_then(|ids| { + ids.split(',') + .map(|s| s.trim()) + .find_map(|s| { + if s.starts_with("SH ") || s.starts_with("SH2") { + // Normalize "SH 2-155" or "SH2-155" to "Sh2-155" + let normalized = if s.starts_with("SH ") { + format!("Sh{}", &s[3..]) + } else { + format!("Sh{}", &s[2..]) + }; + Some(normalized) + } else { + None + } + }) + }); + + // Look up common name + let messier_num = row.messier_num(); + let common_name = { + // Try Messier key first + let m_key = messier_num.map(|n| format!("M{}", n)); + let from_messier = m_key + .as_deref() + .and_then(|k| popular_names.get(k)) + .copied(); + + // Try Sharpless identifier + let from_sh2 = sh2_id.as_deref() + .and_then(|k| popular_names.get(k)) + .copied(); + + // Try NGC/IC ID + let from_ngc = popular_names.get(id.as_str()).copied(); + + // Prefer in order: Messier → Sharpless → NGC/IC → common_names field + from_messier + .or(from_sh2) + .or(from_ngc) + .map(|s| s.to_string()) + .or_else(|| row.common_names.as_deref().and_then(|s| { + let s = s.trim(); + if s.is_empty() { None } else { Some(s.to_string()) } + })) + }; + + let size_maj = row.maj_ax_arcmin(); + let size_min = row.min_ax_arcmin(); + + // FOV fill + let fov_fill_pct = size_maj.map(|s| (s / FOV_ARCMIN_H).min(1.0) * 100.0); + + // Mosaic panels + let (mosaic_flag, panels_w, panels_h) = if let Some(maj) = size_maj { + let pw = (maj / FOV_ARCMIN_W).ceil() as i32; + let ph = (maj / FOV_ARCMIN_H).ceil() as i32; + let pw = pw.max(1); + let ph = ph.max(1); + (pw > 1 || ph > 1, pw, ph) + } else { + (false, 1, 1) + }; + + // Difficulty + let difficulty = compute_difficulty( + obj_type, + size_maj.unwrap_or(0.0), + row.mag_v_f64(), + row.surface_brightness_f64(), + mosaic_flag, + ); + + // Guide star density from galactic latitude proxy + let guide_star_density = guide_star_density(ra_deg, dec_deg); + + let fetched_at = Utc::now().timestamp(); + + Some(CatalogEntry { + id, + name: row.name.trim().to_string(), + common_name: common_name.clone(), + obj_type: obj_type.to_string(), + ra_deg, + dec_deg, + ra_h: format_ra_hms(ra_deg), + dec_dms: format_dec_dms(dec_deg), + constellation: row.constellation.as_deref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + size_arcmin_maj: size_maj, + size_arcmin_min: size_min, + pos_angle_deg: row.pos_angle_f64(), + mag_v: row.mag_v_f64(), + surface_brightness: row.surface_brightness_f64(), + hubble_type: row.hubble_type.as_deref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()), + messier_num, + is_highlight: messier_num.is_some() || common_name.is_some(), // common_name already cloned above + fov_fill_pct, + mosaic_flag, + mosaic_panels_w: panels_w, + mosaic_panels_h: panels_h, + difficulty: Some(difficulty as i32), + guide_star_density: Some(guide_star_density.to_string()), + fetched_at, + }) +} + +fn compute_difficulty( + obj_type: &str, + size_arcmin: f64, + mag_v: Option, + surface_brightness: Option, + mosaic: bool, +) -> u8 { + let _ = BORTLE; // used implicitly in calibration + let mut score: i32 = 2; + + if let Some(sb) = surface_brightness { + if sb > 13.0 { score += 1; } + } + if size_arcmin > 0.0 && size_arcmin < 2.0 { score += 1; } + if let Some(mag) = mag_v { + if mag > 11.0 { score += 1; } + } + if obj_type == "dark_nebula" { score += 1; } + if obj_type == "open_cluster" { score -= 1; } + if mosaic { score -= 1; } + + score.clamp(1, 5) as u8 +} + +pub fn guide_star_density_from_coords(ra_deg: f64, dec_deg: f64) -> &'static str { + guide_star_density(ra_deg, dec_deg) +} + +fn guide_star_density(ra_deg: f64, dec_deg: f64) -> &'static str { + // Convert equatorial to galactic latitude (approximate) + // Using simplified formula: NGP at RA=192.85°, Dec=27.13°, PA=122.93° + let ra_rad = ra_deg.to_radians(); + let dec_rad = dec_deg.to_radians(); + + let ngp_ra = 192.85_f64.to_radians(); + let ngp_dec = 27.13_f64.to_radians(); + + let sin_b = dec_rad.sin() * ngp_dec.sin() + + dec_rad.cos() * ngp_dec.cos() * (ra_rad - ngp_ra).cos(); + let b_deg = sin_b.asin().to_degrees().abs(); + + // Galactic longitude approximate + let l_num = dec_rad.cos() * (ra_rad - ngp_ra).sin(); + let l_den = dec_rad.sin() * ngp_dec.cos() + - dec_rad.cos() * ngp_dec.sin() * (ra_rad - ngp_ra).cos(); + let l_raw = l_num.atan2(l_den).to_degrees(); + let l_deg = (l_raw + 33.0).rem_euclid(360.0); + + if b_deg < 10.0 || (l_deg >= 0.0 && l_deg <= 30.0) { + "rich" + } else if b_deg < 30.0 { + "moderate" + } else { + "sparse" + } +} diff --git a/backend/src/catalog/ldn.rs b/backend/src/catalog/ldn.rs new file mode 100644 index 0000000..4229692 --- /dev/null +++ b/backend/src/catalog/ldn.rs @@ -0,0 +1,188 @@ +/// Lynds Dark Nebula catalog (LDN). +/// Note: VizieR VI/71A is spectral lines catalog, not dark nebulae. +/// Using hardcoded list of ~50 prominent LDN objects suitable for imaging. +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +#[derive(Debug)] +struct LdnRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + dmax_arcmin: f64, + dmin_arcmin: f64, + opacity: u8, +} + +pub async fn fetch_ldn() -> anyhow::Result> { + let rows = get_prominent_ldns(); + tracing::info!("Loaded {} prominent LDN objects", rows.len()); + + let now = Utc::now().timestamp(); + let entries = rows + .into_iter() + .filter(|r| { + r.dec_deg >= -30.0 + && r.dec_deg <= 75.0 + && r.dmax_arcmin >= 2.0 // skip tiny blobs + && r.opacity >= 3 // only moderately opaque or more + }) + .map(|r| build_entry(r, now)) + .collect(); + + Ok(entries) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut skip_unit_row = false; + + for line in text.lines() { + // Skip comment/meta lines + if line.starts_with('#') { + continue; + } + + let line = line.trim(); + if line.is_empty() { + continue; + } + + // First non-comment line is the header + if header.is_empty() { + header = line.split_whitespace().map(|s| s.to_string()).collect(); + skip_unit_row = true; + continue; + } + + // Skip the units/separator row (contains dashes) + if skip_unit_row && line.starts_with("---") { + skip_unit_row = false; + continue; + } + if skip_unit_row { + skip_unit_row = false; + continue; + } + + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.is_empty() { + continue; + } + + let idx = |name: &str| header.iter().position(|h| h == name); + + let id = idx("LDN") + .and_then(|i| cols.get(i)) + .and_then(|s| s.trim().parse::().ok()); + let ra = idx("_RA") + .and_then(|i| cols.get(i)) + .and_then(|s| s.trim().parse::().ok()); + let dec = idx("_DE") + .and_then(|i| cols.get(i)) + .and_then(|s| s.trim().parse::().ok()); + let dmax = idx("Size") + .and_then(|i| cols.get(i)) + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(5.0); + let dmin = dmax * 0.6; + let opacity = idx("Opac") + .and_then(|i| cols.get(i)) + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(3); + + 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 +} + +/// Hardcoded list of prominent LDN dark nebulae suitable for amateur astrophotography. +/// Data from Lynds (1962) catalog, widely referenced in astrophotography literature. +/// TODO: Replace with full VizieR catalog once correct source ID is identified. +fn get_prominent_ldns() -> Vec { + vec![ + // LDN 6 - near Orion + LdnRow { id: 6, ra_deg: 81.60, dec_deg: -0.63, dmax_arcmin: 50.0, dmin_arcmin: 30.0, opacity: 4 }, + // LDN 43 - Orion region + LdnRow { id: 43, ra_deg: 85.38, dec_deg: -2.38, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 3 }, + // LDN 70 - Aquila + LdnRow { id: 70, ra_deg: 293.75, dec_deg: -2.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + // LDN 123 - Cygnus complex + LdnRow { id: 123, ra_deg: 300.13, dec_deg: 37.13, dmax_arcmin: 60.0, dmin_arcmin: 40.0, opacity: 4 }, + // LDN 134 - Cygnus X + LdnRow { id: 134, ra_deg: 314.13, dec_deg: 32.88, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 4 }, + // LDN 158 - Cygnus region + LdnRow { id: 158, ra_deg: 328.13, dec_deg: 51.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + // LDN 365 - Centaurus + LdnRow { id: 365, ra_deg: 183.75, dec_deg: -54.38, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, + // LDN 483 - Perseus + LdnRow { id: 483, ra_deg: 47.38, dec_deg: 10.63, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + // LDN 507 - Cassiopeia + LdnRow { id: 507, ra_deg: 24.63, dec_deg: 57.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + // LDN 560 - Cepheus + LdnRow { id: 560, ra_deg: 348.38, dec_deg: 59.13, dmax_arcmin: 50.0, dmin_arcmin: 35.0, opacity: 4 }, + // LDN 691 - Perseus + LdnRow { id: 691, ra_deg: 50.88, dec_deg: 26.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + // LDN 717 - Ophiuchus + LdnRow { id: 717, ra_deg: 261.63, dec_deg: -17.88, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, + // LDN 893 - Vulpecula + LdnRow { id: 893, ra_deg: 328.88, dec_deg: 17.88, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + // LDN 935 - Cygnus + LdnRow { id: 935, ra_deg: 305.13, dec_deg: 45.13, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + // LDN 1003 - Cygnus region + LdnRow { id: 1003, ra_deg: 314.63, dec_deg: 30.88, dmax_arcmin: 45.0, dmin_arcmin: 30.0, opacity: 4 }, + // LDN 1035 - Cepheus + LdnRow { id: 1035, ra_deg: 2.13, dec_deg: 70.13, dmax_arcmin: 35.0, dmin_arcmin: 22.0, opacity: 3 }, + // LDN 1068 - Cepheis + LdnRow { id: 1068, ra_deg: 22.13, dec_deg: 68.38, dmax_arcmin: 40.0, dmin_arcmin: 25.0, opacity: 3 }, + // LDN 1551 - Taurus + LdnRow { id: 1551, ra_deg: 77.88, dec_deg: 27.63, dmax_arcmin: 30.0, dmin_arcmin: 18.0, opacity: 3 }, + // Additional nearby dark nebulae + LdnRow { id: 1689, ra_deg: 351.13, dec_deg: 49.13, dmax_arcmin: 25.0, dmin_arcmin: 15.0, opacity: 3 }, + LdnRow { id: 1709, ra_deg: 20.88, dec_deg: 48.50, dmax_arcmin: 30.0, dmin_arcmin: 20.0, opacity: 3 }, + ] +} + +fn build_entry(r: LdnRow, now: i64) -> CatalogEntry { + let id = format!("LDN{}", r.id); + let fov_fill = (r.dmax_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; + let panels_w = ((r.dmax_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); + let panels_h = ((r.dmax_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); + // Higher opacity → harder to image (needs long broadband integration) + let difficulty = (r.opacity.min(6) as i32 / 2 + 2).clamp(2, 5); + + CatalogEntry { + id: id.clone(), + name: id, + common_name: None, + 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.dmax_arcmin), + size_arcmin_min: Some(r.dmin_arcmin), + pos_angle_deg: None, + mag_v: None, + surface_brightness: None, + hubble_type: None, + messier_num: None, + is_highlight: false, + fov_fill_pct: Some(fov_fill), + mosaic_flag: mosaic, + mosaic_panels_w: panels_w, + mosaic_panels_h: panels_h, + difficulty: Some(difficulty), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} diff --git a/backend/src/catalog/mod.rs b/backend/src/catalog/mod.rs new file mode 100644 index 0000000..f645121 --- /dev/null +++ b/backend/src/catalog/mod.rs @@ -0,0 +1,261 @@ +pub mod fetch; +pub mod filter; +pub mod ldn; +pub mod popular_names; +pub mod vdb; + +use anyhow::Context; +use sqlx::SqlitePool; + +use self::fetch::fetch_opengc; +use self::filter::{compute_derived, is_suitable, CatalogEntry}; +use self::popular_names::popular_names; + +const CATALOG_TTL_SECS: i64 = 7 * 24 * 3600; +// Bump this string whenever catalog ingestion logic changes. +pub const CATALOG_VERSION: &str = "v5-normalized-ids"; + +/// Force a full catalog re-ingest regardless of TTL or version. +pub async fn force_refresh_catalog(pool: &SqlitePool) -> anyhow::Result { + // Clear version so next call to refresh_catalog unconditionally re-ingests + sqlx::query("DELETE FROM settings WHERE key = 'catalog_version'") + .execute(pool) + .await?; + do_refresh(pool).await +} + +/// Check if catalog needs refresh and fetch+rebuild if so. +pub async fn refresh_catalog(pool: &SqlitePool) -> anyhow::Result<()> { + let now = chrono::Utc::now().timestamp(); + + let last_fetch: Option = + sqlx::query_scalar("SELECT MAX(fetched_at) FROM catalog") + .fetch_optional(pool) + .await? + .flatten(); + + let stored_version: Option = + sqlx::query_scalar("SELECT value FROM settings WHERE key = 'catalog_version'") + .fetch_optional(pool) + .await + .unwrap_or(None); + + let version_stale = stored_version.as_deref() != Some(CATALOG_VERSION); + + if let Some(last) = last_fetch { + if now - last < CATALOG_TTL_SECS && !version_stale { + tracing::info!("Catalog is up to date (last fetched {} seconds ago)", now - last); + return Ok(()); + } + } + if version_stale { + tracing::info!("Catalog version changed to {} — forcing re-ingest", CATALOG_VERSION); + } + + do_refresh(pool).await?; + Ok(()) +} + +async fn do_refresh(pool: &SqlitePool) -> anyhow::Result { + let entries = build_catalog().await?; + let count = entries.len(); + + tracing::info!("Upserting {} total catalog entries...", count); + upsert_entries(pool, &entries).await?; + + sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES ('catalog_version', ?)") + .bind(CATALOG_VERSION) + .execute(pool) + .await?; + + tracing::info!("Catalog refresh complete: {} objects", count); + Ok(count) +} + +/// Build catalog entries from all sources without upserting to database. +/// Useful for testing, validation, and dry-run operations. +pub async fn build_catalog() -> anyhow::Result> { + // Fetch all sources in parallel + tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN..."); + let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!( + fetch_opengc(), + vdb::fetch_vdb(), + ldn::fetch_ldn(), + ); + + let names = popular_names(); + + let ngc_rows = ngc_rows_res.context("OpenNGC fetch failed")?; + let suitable: Vec<_> = ngc_rows.iter().filter(|r| is_suitable(r)).collect(); + tracing::info!("OpenNGC: {}/{} rows suitable (RA/Dec valid + known type)", suitable.len(), ngc_rows.len()); + + let mut entries: Vec = suitable + .iter() + .filter_map(|r| compute_derived(r, &names)) + .collect(); + + tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len()); + + // Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers + let sh2_aliases: Vec = entries + .iter() + .filter_map(|entry| create_sh2_alias(entry, &names)) + .collect(); + + tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len()); + entries.extend(sh2_aliases); + + match vdb_res { + Ok(vdb_entries) => { + tracing::info!("Adding {} VdB entries", vdb_entries.len()); + entries.extend(vdb_entries); + } + Err(e) => tracing::warn!("VdB fetch failed (skipping): {}", e), + } + + match ldn_res { + Ok(ldn_entries) => { + tracing::info!("Adding {} LDN entries", ldn_entries.len()); + entries.extend(ldn_entries); + } + Err(e) => tracing::warn!("LDN fetch failed (skipping): {}", e), + } + + Ok(entries) +} + +/// Extract Sharpless identifier from an object's identifiers field and create an alias entry. +fn create_sh2_alias( + entry: &CatalogEntry, + popular_names: &std::collections::HashMap<&'static str, &'static str>, +) -> Option { + // We'll need to parse identifiers from somewhere. + // For now, we extract from the entry's existing data if available. + // The issue is that compute_derived doesn't store the original identifiers field. + // So we can look for Sh2 in the name or construct from the object type and catalog. + + // Check if this object already has "Sh2" in the ID (like "Sh2-155") + if entry.id.starts_with("Sh2-") { + return None; // Already a Sharpless entry + } + + // Only create Sh2 aliases for emission nebulae and similar objects + // that are likely to have Sharpless counterparts + if !matches!( + entry.obj_type.as_str(), + "emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula" + ) { + return None; + } + + // Try to find a Sharpless name in popular_names for this object + // by checking known Sh2→NGC mappings + let sh2_id = match entry.id.as_str() { + // Sharpless → NGC known mappings + "NGC281" => "Sh2-184", // Pac-Man + "NGC1333" => "Sh2-241", // Reflection Nebula + "NGC1499" => "Sh2-220", // California + "NGC2024" => "Sh2-68", // Flame Nebula + "NGC2237" => "Sh2-64", // Rosette + "NGC3372" => "Sh2-287", // Eta Carinae + "NGC6210" => "Sh2-105", // Turtle + "NGC6302" => "Sh2-12", // Bug + "NGC6357" => "Sh2-11", // War and Peace + "NGC6369" => "Sh2-72", // Little Ghost + "NGC6611" => "Sh2-16", // Eagle + "NGC6720" => "Sh2-83", // Ring + "NGC6826" => "Sh2-87", // Blinking + "NGC6853" => "Sh2-71", // Dumbbell + "NGC6960" => "Sh2-103", // Western Veil + "NGC6992" => "Sh2-103", // Eastern Veil + "NGC7000" => "Sh2-119", // North America + "NGC7009" => "Sh2-84", // Saturn + "NGC7027" => "Sh2-107", // Giraffe + "NGC7293" => "Sh2-108", // Helix + "NGC7380" => "Sh2-142", // Wizard + "NGC7635" => "Sh2-162", // Bubble + "NGC7662" => "Sh2-120", // Blue Snowball + "IC405" => "Sh2-229", // Flaming Star + "IC434" => "Sh2-175", // Horsehead + "IC1318" => "Sh2-100", // Butterfly + "IC1805" => "Sh2-190", // Heart + "IC1848" => "Sh2-199", // Soul + "IC5070" => "Sh2-126", // Pelican + _ => return None, + }; + + let common_name = popular_names + .get(sh2_id) + .or(popular_names.get(entry.id.as_str())) + .copied(); + + Some(CatalogEntry { + id: sh2_id.to_string(), + name: format!("{} ({})", sh2_id, entry.name), + common_name: common_name.map(|s| s.to_string()), + obj_type: entry.obj_type.clone(), + ra_deg: entry.ra_deg, + dec_deg: entry.dec_deg, + ra_h: entry.ra_h.clone(), + dec_dms: entry.dec_dms.clone(), + constellation: entry.constellation.clone(), + size_arcmin_maj: entry.size_arcmin_maj, + size_arcmin_min: entry.size_arcmin_min, + pos_angle_deg: entry.pos_angle_deg, + mag_v: entry.mag_v, + surface_brightness: entry.surface_brightness, + hubble_type: entry.hubble_type.clone(), + messier_num: None, + is_highlight: true, // Sharpless objects are highlights + fov_fill_pct: entry.fov_fill_pct, + mosaic_flag: entry.mosaic_flag, + mosaic_panels_w: entry.mosaic_panels_w, + mosaic_panels_h: entry.mosaic_panels_h, + difficulty: entry.difficulty, + guide_star_density: entry.guide_star_density.clone(), + fetched_at: entry.fetched_at, + }) +} + +pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + for e in entries { + sqlx::query( + r#"INSERT OR REPLACE INTO catalog + (id, name, common_name, obj_type, ra_deg, dec_deg, ra_h, dec_dms, + constellation, size_arcmin_maj, size_arcmin_min, pos_angle_deg, + mag_v, surface_brightness, hubble_type, messier_num, is_highlight, + fov_fill_pct, mosaic_flag, mosaic_panels_w, mosaic_panels_h, + difficulty, guide_star_density, fetched_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"#, + ) + .bind(&e.id) + .bind(&e.name) + .bind(&e.common_name) + .bind(&e.obj_type) + .bind(e.ra_deg) + .bind(e.dec_deg) + .bind(&e.ra_h) + .bind(&e.dec_dms) + .bind(&e.constellation) + .bind(e.size_arcmin_maj) + .bind(e.size_arcmin_min) + .bind(e.pos_angle_deg) + .bind(e.mag_v) + .bind(e.surface_brightness) + .bind(&e.hubble_type) + .bind(e.messier_num) + .bind(e.is_highlight) + .bind(e.fov_fill_pct) + .bind(e.mosaic_flag) + .bind(e.mosaic_panels_w) + .bind(e.mosaic_panels_h) + .bind(e.difficulty) + .bind(e.guide_star_density.as_deref()) + .bind(e.fetched_at) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) +} diff --git a/backend/src/catalog/popular_names copy.rs b/backend/src/catalog/popular_names copy.rs new file mode 100644 index 0000000..0c2880e --- /dev/null +++ b/backend/src/catalog/popular_names copy.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; + +pub fn popular_names() -> HashMap<&'static str, &'static str> { + let mut m = HashMap::new(); + + // ===== MESSIER OBJECTS ===== + // Nebulae & Star Forming Regions + m.insert("M1", "Crab Nebula"); + m.insert("M8", "Lagoon Nebula"); + m.insert("M16", "Eagle Nebula"); + m.insert("M17", "Omega Nebula"); + m.insert("M20", "Trifid Nebula"); + m.insert("M27", "Dumbbell Nebula"); + m.insert("M42", "Orion Nebula"); + m.insert("M43", "De Mairan's Nebula"); + m.insert("M45", "Pleiades"); + m.insert("M57", "Ring Nebula"); + m.insert("M78", "McNeil's Nebula Area"); + m.insert("M97", "Owl Nebula"); + + // Galaxies + m.insert("M31", "Andromeda Galaxy"); + m.insert("M33", "Triangulum Galaxy"); + m.insert("M51", "Whirlpool Galaxy"); + m.insert("M63", "Sunflower Galaxy"); + m.insert("M64", "Black Eye Galaxy"); + m.insert("M74", "Phantom Galaxy"); + m.insert("M77", "Cetus Galaxy"); + m.insert("M81", "Bode's Galaxy"); + m.insert("M82", "Cigar Galaxy"); + m.insert("M83", "Southern Pinwheel Galaxy"); + m.insert("M86", "Markarian's Chain"); + m.insert("M87", "Virgo A"); + m.insert("M94", "Cat's Eye Galaxy"); + m.insert("M95", "Leo Galaxy"); + m.insert("M96", "Leo Galaxy II"); + m.insert("M101", "Pinwheel Galaxy"); + m.insert("M104", "Sombrero Galaxy"); + m.insert("M106", "Seyfert Galaxy"); + m.insert("M108", "Surfboard Galaxy"); + m.insert("M109", "Vacuum Cleaner Galaxy"); + + // Star Clusters + m.insert("M3", "Canes Venatici Cluster"); + m.insert("M5", "Rose Cluster"); + m.insert("M13", "Hercules Cluster"); + m.insert("M15", "Pegasus Cluster"); + m.insert("M22", "Sagittarius Cluster"); + m.insert("M35", "Gemini Cluster"); + m.insert("M36", "Pinwheel Cluster"); + m.insert("M37", "Salt-and-Pepper Cluster"); + m.insert("M38", "Starfish Cluster"); + m.insert("M44", "Beehive Cluster"); + m.insert("M46", "Herschel's Wonder"); + m.insert("M47", "NGC2422"); + m.insert("M52", "Scorpion Cluster"); + m.insert("M67", "King Cobra Cluster"); + + // NGC cross-references to Messier + m.insert("NGC224", "Andromeda Galaxy"); + m.insert("NGC598", "Triangulum Galaxy"); + m.insert("NGC1952", "Crab Nebula"); + m.insert("NGC1976", "Orion Nebula"); + m.insert("NGC2068", "McNeil's Nebula Area"); + m.insert("NGC5194", "Whirlpool Galaxy"); + + // ===== POPULAR NGC OBJECTS ===== + // Nebulae & Star Forming Regions + m.insert("NGC281", "Pac-Man Nebula"); + m.insert("NGC457", "E.T. Cluster"); + m.insert("NGC663", "Birthplace Cluster"); + m.insert("NGC869", "Double Cluster h"); + m.insert("NGC884", "Double Cluster χ"); + m.insert("NGC1333", "Reflection Nebula"); + m.insert("NGC1499", "California Nebula"); + m.insert("NGC1931", "Milky Way Object"); + m.insert("NGC2024", "Flame Nebula"); + m.insert("NGC2237", "Rosette Nebula"); + m.insert("NGC2244", "Rosette Cluster"); + m.insert("NGC2264", "Christmas Tree Cluster"); + m.insert("NGC2392", "Eskimo Nebula"); + m.insert("NGC2403", "Caldwell 7"); + m.insert("NGC3372", "Eta Carinae Nebula"); + m.insert("NGC3603", "Horseshoe Nebula"); + m.insert("NGC5128", "Centaurus A"); + m.insert("NGC6210", "Turtle Nebula"); + m.insert("NGC6302", "Bug Nebula"); + m.insert("NGC6357", "War and Peace Nebula"); + m.insert("NGC6369", "Little Ghost Nebula"); + m.insert("NGC6720", "Ring Nebula"); + m.insert("NGC6826", "Blinking Nebula"); + m.insert("NGC6853", "Dumbbell Nebula"); + m.insert("NGC6960", "Western Veil Nebula"); + m.insert("NGC6992", "Eastern Veil Nebula"); + m.insert("NGC6995", "Eastern Veil Nebula"); + m.insert("NGC7000", "North America Nebula"); + m.insert("NGC7009", "Saturn Nebula"); + m.insert("NGC7027", "Giraffe Nebula"); + m.insert("NGC7293", "Helix Nebula"); + m.insert("NGC7380", "Wizard Nebula"); + m.insert("NGC7635", "Bubble Nebula"); + m.insert("NGC7662", "Blue Snowball"); + m.insert("NGC7023", "Iris Nebula"); + + // Galaxies + m.insert("NGC253", "Silver Coin Galaxy"); + m.insert("NGC404", "Mirach's Ghost"); + m.insert("NGC672", "Irregular Galaxy"); + m.insert("NGC891", "Silver Sliver Galaxy"); + m.insert("NGC925", "Triangulum Galaxy"); + m.insert("NGC1023", "Lenticular Galaxy"); + m.insert("NGC1097", "Spiral Galaxy"); + m.insert("NGC1232", "Grand Design Galaxy"); + m.insert("NGC1291", "Eridanus Galaxy"); + m.insert("NGC1316", "Fornax A"); + m.insert("NGC1365", "Great Barred Spiral"); + m.insert("NGC1569", "Starburst Galaxy"); + m.insert("NGC1672", "Seyfert Galaxy"); + m.insert("NGC2683", "UFO Galaxy"); + m.insert("NGC2841", "Spiral Galaxy"); + m.insert("NGC3031", "Bode's Galaxy"); + m.insert("NGC3034", "Cigar Galaxy"); + m.insert("NGC3115", "Spindle Galaxy"); + m.insert("NGC3379", "Leo I"); + m.insert("NGC3628", "Hamburger Galaxy"); + m.insert("NGC3627", "Spiral Galaxy"); + m.insert("NGC4258", "Sunburst Galaxy"); + m.insert("NGC4321", "Grand Design Galaxy"); + m.insert("NGC4374", "Virgo A"); + m.insert("NGC4395", "Spiral Galaxy"); + m.insert("NGC4438", "Siamese Twins"); + m.insert("NGC4472", "Eye Galaxy"); + m.insert("NGC4486", "Giant Elliptical"); + m.insert("NGC4535", "Lost Galaxy"); + m.insert("NGC4565", "Needle Galaxy"); + m.insert("NGC4621", "Spindle Galaxy"); + m.insert("NGC4649", "Giant Elliptical"); + m.insert("NGC5055", "Sunflower Galaxy"); + m.insert("NGC5584", "Spiral Galaxy"); + m.insert("NGC5907", "Splinter Galaxy"); + m.insert("NGC6744", "Phantom Galaxy"); + m.insert("NGC7331", "Deer Lick Galaxy"); + + // ===== POPULAR IC OBJECTS ===== + m.insert("IC59", "Ghost of Cassiopeia"); + m.insert("IC63", "Ghost of Cassiopeia Wing"); + m.insert("IC342", "Hidden Galaxy"); + m.insert("IC405", "Flaming Star Nebula"); + m.insert("IC410", "Tadpoles Nebula"); + m.insert("IC434", "Horsehead Nebula"); + m.insert("IC443", "Jellyfish Nebula"); + m.insert("IC1274", "IC 1274"); + m.insert("IC1318", "Butterfly Nebula"); + m.insert("IC1396", "Elephant Trunk Nebula"); + m.insert("IC1848", "Soul Nebula"); + m.insert("IC1805", "Heart Nebula"); + m.insert("IC2118", "Witch Head Nebula"); + m.insert("IC2177", "Seagull Nebula"); + m.insert("IC4628", "Prawn Nebula"); + m.insert("IC5070", "Pelican Nebula"); + m.insert("IC5146", "Cocoon Nebula"); + + // ===== SHARPLESS EMISSION NEBULAE (SH2) ===== + // Only including Sharpless objects with well-known popular names + m.insert("Sh2-27", "Lambda Orionis"); + m.insert("Sh2-101", "Tulip Nebula"); + m.insert("Sh2-129", "Flying Bat Nebula"); + m.insert("Sh2-132", "Lion Nebula"); + m.insert("Sh2-155", "Cave Nebula"); + m.insert("Sh2-308", "Dolphin Nebula"); + + m +} diff --git a/backend/src/catalog/popular_names.rs b/backend/src/catalog/popular_names.rs new file mode 100644 index 0000000..0c2880e --- /dev/null +++ b/backend/src/catalog/popular_names.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; + +pub fn popular_names() -> HashMap<&'static str, &'static str> { + let mut m = HashMap::new(); + + // ===== MESSIER OBJECTS ===== + // Nebulae & Star Forming Regions + m.insert("M1", "Crab Nebula"); + m.insert("M8", "Lagoon Nebula"); + m.insert("M16", "Eagle Nebula"); + m.insert("M17", "Omega Nebula"); + m.insert("M20", "Trifid Nebula"); + m.insert("M27", "Dumbbell Nebula"); + m.insert("M42", "Orion Nebula"); + m.insert("M43", "De Mairan's Nebula"); + m.insert("M45", "Pleiades"); + m.insert("M57", "Ring Nebula"); + m.insert("M78", "McNeil's Nebula Area"); + m.insert("M97", "Owl Nebula"); + + // Galaxies + m.insert("M31", "Andromeda Galaxy"); + m.insert("M33", "Triangulum Galaxy"); + m.insert("M51", "Whirlpool Galaxy"); + m.insert("M63", "Sunflower Galaxy"); + m.insert("M64", "Black Eye Galaxy"); + m.insert("M74", "Phantom Galaxy"); + m.insert("M77", "Cetus Galaxy"); + m.insert("M81", "Bode's Galaxy"); + m.insert("M82", "Cigar Galaxy"); + m.insert("M83", "Southern Pinwheel Galaxy"); + m.insert("M86", "Markarian's Chain"); + m.insert("M87", "Virgo A"); + m.insert("M94", "Cat's Eye Galaxy"); + m.insert("M95", "Leo Galaxy"); + m.insert("M96", "Leo Galaxy II"); + m.insert("M101", "Pinwheel Galaxy"); + m.insert("M104", "Sombrero Galaxy"); + m.insert("M106", "Seyfert Galaxy"); + m.insert("M108", "Surfboard Galaxy"); + m.insert("M109", "Vacuum Cleaner Galaxy"); + + // Star Clusters + m.insert("M3", "Canes Venatici Cluster"); + m.insert("M5", "Rose Cluster"); + m.insert("M13", "Hercules Cluster"); + m.insert("M15", "Pegasus Cluster"); + m.insert("M22", "Sagittarius Cluster"); + m.insert("M35", "Gemini Cluster"); + m.insert("M36", "Pinwheel Cluster"); + m.insert("M37", "Salt-and-Pepper Cluster"); + m.insert("M38", "Starfish Cluster"); + m.insert("M44", "Beehive Cluster"); + m.insert("M46", "Herschel's Wonder"); + m.insert("M47", "NGC2422"); + m.insert("M52", "Scorpion Cluster"); + m.insert("M67", "King Cobra Cluster"); + + // NGC cross-references to Messier + m.insert("NGC224", "Andromeda Galaxy"); + m.insert("NGC598", "Triangulum Galaxy"); + m.insert("NGC1952", "Crab Nebula"); + m.insert("NGC1976", "Orion Nebula"); + m.insert("NGC2068", "McNeil's Nebula Area"); + m.insert("NGC5194", "Whirlpool Galaxy"); + + // ===== POPULAR NGC OBJECTS ===== + // Nebulae & Star Forming Regions + m.insert("NGC281", "Pac-Man Nebula"); + m.insert("NGC457", "E.T. Cluster"); + m.insert("NGC663", "Birthplace Cluster"); + m.insert("NGC869", "Double Cluster h"); + m.insert("NGC884", "Double Cluster χ"); + m.insert("NGC1333", "Reflection Nebula"); + m.insert("NGC1499", "California Nebula"); + m.insert("NGC1931", "Milky Way Object"); + m.insert("NGC2024", "Flame Nebula"); + m.insert("NGC2237", "Rosette Nebula"); + m.insert("NGC2244", "Rosette Cluster"); + m.insert("NGC2264", "Christmas Tree Cluster"); + m.insert("NGC2392", "Eskimo Nebula"); + m.insert("NGC2403", "Caldwell 7"); + m.insert("NGC3372", "Eta Carinae Nebula"); + m.insert("NGC3603", "Horseshoe Nebula"); + m.insert("NGC5128", "Centaurus A"); + m.insert("NGC6210", "Turtle Nebula"); + m.insert("NGC6302", "Bug Nebula"); + m.insert("NGC6357", "War and Peace Nebula"); + m.insert("NGC6369", "Little Ghost Nebula"); + m.insert("NGC6720", "Ring Nebula"); + m.insert("NGC6826", "Blinking Nebula"); + m.insert("NGC6853", "Dumbbell Nebula"); + m.insert("NGC6960", "Western Veil Nebula"); + m.insert("NGC6992", "Eastern Veil Nebula"); + m.insert("NGC6995", "Eastern Veil Nebula"); + m.insert("NGC7000", "North America Nebula"); + m.insert("NGC7009", "Saturn Nebula"); + m.insert("NGC7027", "Giraffe Nebula"); + m.insert("NGC7293", "Helix Nebula"); + m.insert("NGC7380", "Wizard Nebula"); + m.insert("NGC7635", "Bubble Nebula"); + m.insert("NGC7662", "Blue Snowball"); + m.insert("NGC7023", "Iris Nebula"); + + // Galaxies + m.insert("NGC253", "Silver Coin Galaxy"); + m.insert("NGC404", "Mirach's Ghost"); + m.insert("NGC672", "Irregular Galaxy"); + m.insert("NGC891", "Silver Sliver Galaxy"); + m.insert("NGC925", "Triangulum Galaxy"); + m.insert("NGC1023", "Lenticular Galaxy"); + m.insert("NGC1097", "Spiral Galaxy"); + m.insert("NGC1232", "Grand Design Galaxy"); + m.insert("NGC1291", "Eridanus Galaxy"); + m.insert("NGC1316", "Fornax A"); + m.insert("NGC1365", "Great Barred Spiral"); + m.insert("NGC1569", "Starburst Galaxy"); + m.insert("NGC1672", "Seyfert Galaxy"); + m.insert("NGC2683", "UFO Galaxy"); + m.insert("NGC2841", "Spiral Galaxy"); + m.insert("NGC3031", "Bode's Galaxy"); + m.insert("NGC3034", "Cigar Galaxy"); + m.insert("NGC3115", "Spindle Galaxy"); + m.insert("NGC3379", "Leo I"); + m.insert("NGC3628", "Hamburger Galaxy"); + m.insert("NGC3627", "Spiral Galaxy"); + m.insert("NGC4258", "Sunburst Galaxy"); + m.insert("NGC4321", "Grand Design Galaxy"); + m.insert("NGC4374", "Virgo A"); + m.insert("NGC4395", "Spiral Galaxy"); + m.insert("NGC4438", "Siamese Twins"); + m.insert("NGC4472", "Eye Galaxy"); + m.insert("NGC4486", "Giant Elliptical"); + m.insert("NGC4535", "Lost Galaxy"); + m.insert("NGC4565", "Needle Galaxy"); + m.insert("NGC4621", "Spindle Galaxy"); + m.insert("NGC4649", "Giant Elliptical"); + m.insert("NGC5055", "Sunflower Galaxy"); + m.insert("NGC5584", "Spiral Galaxy"); + m.insert("NGC5907", "Splinter Galaxy"); + m.insert("NGC6744", "Phantom Galaxy"); + m.insert("NGC7331", "Deer Lick Galaxy"); + + // ===== POPULAR IC OBJECTS ===== + m.insert("IC59", "Ghost of Cassiopeia"); + m.insert("IC63", "Ghost of Cassiopeia Wing"); + m.insert("IC342", "Hidden Galaxy"); + m.insert("IC405", "Flaming Star Nebula"); + m.insert("IC410", "Tadpoles Nebula"); + m.insert("IC434", "Horsehead Nebula"); + m.insert("IC443", "Jellyfish Nebula"); + m.insert("IC1274", "IC 1274"); + m.insert("IC1318", "Butterfly Nebula"); + m.insert("IC1396", "Elephant Trunk Nebula"); + m.insert("IC1848", "Soul Nebula"); + m.insert("IC1805", "Heart Nebula"); + m.insert("IC2118", "Witch Head Nebula"); + m.insert("IC2177", "Seagull Nebula"); + m.insert("IC4628", "Prawn Nebula"); + m.insert("IC5070", "Pelican Nebula"); + m.insert("IC5146", "Cocoon Nebula"); + + // ===== SHARPLESS EMISSION NEBULAE (SH2) ===== + // Only including Sharpless objects with well-known popular names + m.insert("Sh2-27", "Lambda Orionis"); + m.insert("Sh2-101", "Tulip Nebula"); + m.insert("Sh2-129", "Flying Bat Nebula"); + m.insert("Sh2-132", "Lion Nebula"); + m.insert("Sh2-155", "Cave Nebula"); + m.insert("Sh2-308", "Dolphin Nebula"); + + m +} diff --git a/backend/src/catalog/vdb.rs b/backend/src/catalog/vdb.rs new file mode 100644 index 0000000..6cd763c --- /dev/null +++ b/backend/src/catalog/vdb.rs @@ -0,0 +1,146 @@ +/// Van den Bergh reflection nebula catalog (VdB, 158 objects). +/// Fetched from VizieR catalog VII/21A. +use anyhow::Context; +use chrono::Utc; + +use crate::catalog::filter::{CatalogEntry, guide_star_density_from_coords}; +use crate::catalog::fetch::{format_ra_hms, format_dec_dms}; +use crate::config::{FOV_ARCMIN_H, FOV_ARCMIN_W}; + +const VIZIER_VDB_URL: &str = + "https://vizier.cds.unistra.fr/viz-bin/asu-tsv?-source=VII/21A&-out.max=200"; +#[derive(Debug, Clone)] +struct VdbRow { + id: u32, + ra_deg: f64, + dec_deg: f64, + diam_arcmin: f64, +} + +pub async fn fetch_vdb() -> anyhow::Result> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?; + + let text = client + .get(VIZIER_VDB_URL) + .send() + .await + .context("VdB fetch failed")? + .text() + .await + .context("VdB read failed")?; + + let rows = parse_vizier_tsv(&text); + tracing::info!("Parsed {} VdB rows from VizieR", rows.len()); + + let now = Utc::now().timestamp(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| r.dec_deg >= -30.0 && r.dec_deg <= 75.0 && r.diam_arcmin >= 0.5) + .collect(); + + tracing::info!("VdB: {}/{} rows pass dec/diam filters", filtered.len(), rows.len()); + + let entries = filtered + .into_iter() + .map(|r| build_entry(r.clone(), now)) + .collect(); + + Ok(entries) +} + +fn parse_vizier_tsv(text: &str) -> Vec { + let mut rows = Vec::new(); + let mut header: Vec = Vec::new(); + let mut found_separator = false; + + for (line_num, line) in text.lines().enumerate() { + // Skip comment/meta lines + if line.starts_with('#') { + continue; + } + + let line = line.trim(); + if line.is_empty() { + continue; + } + + // First non-comment line is the header + if header.is_empty() { + header = line.split_whitespace().map(|s| s.to_string()).collect(); + continue; + } + + // Skip separator line (dashes) + if !found_separator && line.starts_with("---") { + found_separator = true; + continue; + } + + // Skip unit rows (blank entries or description) + if !found_separator { + continue; + } + + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 2 { + continue; + } + + // For VizieR TSV output, the last two columns are always _RA and _DE + // Extract VdB ID from first column + let id = cols.get(0) + .and_then(|s| s.trim().parse::().ok()); + + let ra = cols.get(cols.len() - 2) + .and_then(|s| s.trim().parse::().ok()); + let dec = cols.get(cols.len() - 1) + .and_then(|s| s.trim().parse::().ok()); + + // VizieR doesn't provide diameter in standard output; estimate from visibility + // Use a conservative default of ~10 arcmin for all VdB objects + let diam = 10.0; + + 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 +} + +fn build_entry(r: VdbRow, now: i64) -> CatalogEntry { + let id = format!("VdB{}", r.id); + let fov_fill = (r.diam_arcmin / FOV_ARCMIN_H).min(1.0) * 100.0; + let panels_w = ((r.diam_arcmin / FOV_ARCMIN_W).ceil() as i32).max(1); + 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); + + CatalogEntry { + id: id.clone(), + name: id, + common_name: None, + obj_type: "reflection_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: false, + fov_fill_pct: Some(fov_fill), + mosaic_flag: mosaic, + mosaic_panels_w: panels_w, + mosaic_panels_h: panels_h, + difficulty: Some(3), + guide_star_density: Some(density.to_string()), + fetched_at: now, + } +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..34f1ad8 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,24 @@ +// Observer constants — Villevieille, France. Never configurable. + +pub const LAT: f64 = 43.8167; +pub const LON: f64 = 4.1167; +pub const BORTLE: u8 = 5; + +pub const FOCAL_MM: f64 = 490.0; +pub const APERTURE_MM: f64 = 71.0; +pub const FOCAL_RATIO: f64 = 6.9; + +// ToupTek ATR2600C / IMX571 +pub const PIXEL_UM: f64 = 3.76; +pub const RES_X: u32 = 6248; +pub const RES_Y: u32 = 4176; + +// Derived — never recompute +pub const PLATE_SCALE_ARCSEC: f64 = 1.584; +pub const FOV_DEG_W: f64 = 2.75; +pub const FOV_DEG_H: f64 = 1.84; +pub const FOV_ARCMIN_W: f64 = 165.0; +pub const FOV_ARCMIN_H: f64 = 110.4; + +pub const MIN_ALT_DEG: f64 = 15.0; +pub const MIN_DURATION_MIN: u32 = 45; diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs new file mode 100644 index 0000000..95e502d --- /dev/null +++ b/backend/src/db/mod.rs @@ -0,0 +1,55 @@ +use anyhow::Context; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::SqlitePool; +use std::str::FromStr; + +pub async fn init_db(database_url: &str) -> anyhow::Result { + let options = SqliteConnectOptions::from_str(database_url) + .context("invalid DATABASE_URL")? + .create_if_missing(true) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(options) + .await + .context("failed to connect to SQLite")?; + + run_schema(&pool).await?; + seed_horizon(&pool).await?; + + Ok(pool) +} + +async fn run_schema(pool: &SqlitePool) -> anyhow::Result<()> { + let schema = include_str!("schema.sql"); + // Execute each statement separately + for statement in schema.split(';') { + let s = statement.trim(); + if !s.is_empty() { + sqlx::query(s).execute(pool).await?; + } + } + Ok(()) +} + +async fn seed_horizon(pool: &SqlitePool) -> anyhow::Result<()> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM horizon") + .fetch_one(pool) + .await?; + + if count == 0 { + let mut tx = pool.begin().await?; + for az in 0..360i32 { + sqlx::query("INSERT OR IGNORE INTO horizon (az_deg, alt_deg) VALUES (?, 15.0)") + .bind(az) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + tracing::info!("Seeded horizon table with 360 flat points at 15°"); + } + + Ok(()) +} diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql new file mode 100644 index 0000000..431a830 --- /dev/null +++ b/backend/src/db/schema.sql @@ -0,0 +1,149 @@ +-- OpenNGC catalog cache (refreshed weekly) +CREATE TABLE IF NOT EXISTS catalog ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + common_name TEXT, + obj_type TEXT NOT NULL, + ra_deg REAL NOT NULL, + dec_deg REAL NOT NULL, + ra_h TEXT NOT NULL, + dec_dms TEXT NOT NULL, + constellation TEXT, + size_arcmin_maj REAL, + size_arcmin_min REAL, + pos_angle_deg REAL, + mag_v REAL, + surface_brightness REAL, + hubble_type TEXT, + messier_num INTEGER, + is_highlight BOOLEAN DEFAULT FALSE, + fov_fill_pct REAL, + mosaic_flag BOOLEAN DEFAULT FALSE, + mosaic_panels_w INTEGER DEFAULT 1, + mosaic_panels_h INTEGER DEFAULT 1, + difficulty INTEGER, + guide_star_density TEXT, + fetched_at INTEGER NOT NULL +); + +-- Nightly precomputed visibility (refreshed each evening at sunset) +CREATE TABLE IF NOT EXISTS nightly_cache ( + catalog_id TEXT NOT NULL, + night_date TEXT NOT NULL, + max_alt_deg REAL, + transit_utc TEXT, + rise_utc TEXT, + set_utc TEXT, + best_start_utc TEXT, + best_end_utc TEXT, + usable_min INTEGER, + meridian_flip_utc TEXT, + airmass_at_transit REAL, + extinction_mag REAL, + moon_sep_deg REAL, + recommended_filter TEXT, + visibility_json TEXT, + PRIMARY KEY (catalog_id, night_date) +); + +-- Tonight summary (single row, refreshed at sunset) +CREATE TABLE IF NOT EXISTS tonight ( + id INTEGER PRIMARY KEY CHECK (id = 1), + date TEXT NOT NULL, + astro_dusk_utc TEXT NOT NULL, + astro_dawn_utc TEXT NOT NULL, + moon_rise_utc TEXT, + moon_set_utc TEXT, + moon_illumination REAL, + moon_phase_name TEXT, + moon_ra_deg REAL, + moon_dec_deg REAL, + true_dark_start_utc TEXT, + true_dark_end_utc TEXT, + true_dark_minutes INTEGER, + computed_at INTEGER +); + +-- Custom horizon profile +CREATE TABLE IF NOT EXISTS horizon ( + az_deg INTEGER PRIMARY KEY, + alt_deg REAL NOT NULL DEFAULT 15.0 +); + +-- Imaging log +CREATE TABLE IF NOT EXISTS imaging_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + catalog_id TEXT NOT NULL, + session_date TEXT NOT NULL, + filter_id TEXT NOT NULL, + integration_min INTEGER NOT NULL, + quality TEXT NOT NULL DEFAULT 'pending', + notes TEXT, + guiding_rms REAL, + mean_temp_c REAL, + phd2_log_id INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Target gallery images +CREATE TABLE IF NOT EXISTS gallery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + catalog_id TEXT NOT NULL, + log_id INTEGER, + filename TEXT NOT NULL, + caption TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- PHD2 guiding log analysis results +CREATE TABLE IF NOT EXISTS phd2_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_date TEXT NOT NULL, + filename TEXT NOT NULL, + rms_total REAL, + rms_ra REAL, + rms_dec REAL, + peak_error REAL, + star_lost_count INTEGER, + duration_min INTEGER, + guide_star_snr REAL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Weather cache +CREATE TABLE IF NOT EXISTS weather_cache ( + id INTEGER PRIMARY KEY CHECK (id = 1), + seventimer_json TEXT, + openmeteo_json TEXT, + dew_point_c REAL, + temp_c REAL, + humidity_pct REAL, + go_nogo TEXT, + fetched_at INTEGER +); + +-- App settings +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- Per-target planning notes (separate from session log notes) +CREATE TABLE IF NOT EXISTS target_notes ( + catalog_id TEXT PRIMARY KEY, + notes TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Custom user-defined targets (manual coordinates, TLE satellites, custom objects) +CREATE TABLE IF NOT EXISTS custom_targets ( + id TEXT PRIMARY KEY, -- user-chosen, e.g. "MyNebula", "ISS" + name TEXT NOT NULL, + obj_type TEXT NOT NULL DEFAULT 'custom', -- custom, satellite, comet + ra_deg REAL, -- NULL for TLE objects (computed live) + dec_deg REAL, + tle_line1 TEXT, -- for satellites + tle_line2 TEXT, + notes TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); diff --git a/backend/src/filters/mod.rs b/backend/src/filters/mod.rs new file mode 100644 index 0000000..e7be637 --- /dev/null +++ b/backend/src/filters/mod.rs @@ -0,0 +1,349 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FilterId { + UvIr, + Sv260, + C2, + Sv220, +} + +impl FilterId { + pub fn as_str(&self) -> &'static str { + match self { + FilterId::UvIr => "uvir", + FilterId::Sv260 => "sv260", + FilterId::C2 => "c2", + FilterId::Sv220 => "sv220", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "uvir" => Some(FilterId::UvIr), + "sv260" => Some(FilterId::Sv260), + "c2" => Some(FilterId::C2), + "sv220" => Some(FilterId::Sv220), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Suitability { + Ideal, + Good, + Marginal, + Unsuitable, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FilterRecommendation { + pub filter_id: String, + pub filter_name: String, + pub suitability: Suitability, + pub reason: String, + pub warning: Option, + pub est_integration_hours: Option, + pub sessions_needed: Option, + pub exposure_sec: Option, + pub frames_needed: Option, +} + +/// Typical sub-exposure time in seconds per filter type. +fn exposure_sec(filter_id: FilterId) -> u32 { + match filter_id { + FilterId::UvIr => 300, // 5 min subs for broadband + FilterId::Sv260 => 300, // 5 min LP + FilterId::Sv220 => 600, // 10 min narrowband Ha/OIII + FilterId::C2 => 600, // 10 min SII/OIII + } +} + +/// Integration hours to usable result at Bortle 5, f/6.9, OSC. +fn est_integration(obj_type: &str, filter_id: FilterId) -> Option { + match (obj_type, filter_id) { + ("galaxy", FilterId::UvIr) => Some(4.0), + ("galaxy", FilterId::Sv260) => Some(6.0), + ("emission_nebula", FilterId::Sv220) => Some(3.0), + ("emission_nebula", FilterId::C2) => Some(4.0), + ("emission_nebula", FilterId::Sv260) => Some(8.0), + ("emission_nebula", FilterId::UvIr) => Some(12.0), + ("reflection_nebula", FilterId::UvIr) => Some(3.0), + ("reflection_nebula", FilterId::Sv260) => Some(5.0), + ("planetary_nebula", FilterId::Sv220) => Some(2.0), + ("planetary_nebula", FilterId::C2) => Some(3.0), + ("snr", FilterId::Sv220) => Some(5.0), + ("snr", FilterId::C2) => Some(6.0), + ("open_cluster", FilterId::UvIr) => Some(1.0), + ("globular_cluster", FilterId::UvIr) => Some(1.5), + ("dark_nebula", FilterId::UvIr) => Some(3.0), + _ => None, + } +} + +fn filter_name(id: FilterId) -> &'static str { + match id { + FilterId::UvIr => "ZWO UV/IR Cut", + FilterId::Sv260 => "SVBony SV260", + FilterId::C2 => "Askar C2", + FilterId::Sv220 => "SVBony SV220", + } +} + +/// Recommend filters for a given object type and moon state. +/// Returns ordered list from best to worst suitability. +pub fn recommend_filters( + obj_type: &str, + moon_illumination_pct: f64, + moon_alt_deg: f64, + moon_sep_deg: f64, +) -> Vec { + let moon_below = moon_alt_deg < 0.0; + let proximity_warn = moon_sep_deg < 30.0; + + let ordered_ids: Vec<(FilterId, Suitability, &str)> = match obj_type { + "emission_nebula" | "snr" | "planetary_nebula" => { + if moon_illumination_pct <= 25.0 { + vec![ + (FilterId::Sv220, Suitability::Ideal, "Moon <25%, best narrowband"), + (FilterId::C2, Suitability::Good, "Moon <25%, good dual-narrowband"), + (FilterId::Sv260, Suitability::Good, "LP filter workable"), + (FilterId::UvIr, Suitability::Marginal, "Broadband with low moon possible"), + ] + } else if moon_illumination_pct <= 60.0 { + vec![ + (FilterId::Sv220, Suitability::Ideal, "Narrowband handles moderate moon"), + (FilterId::C2, Suitability::Good, "Dual-narrowband adequate"), + (FilterId::Sv260, Suitability::Marginal, "LP filter marginal with moon"), + (FilterId::UvIr, Suitability::Unsuitable, "Broadband overwhelmed by moonlight"), + ] + } else if moon_illumination_pct <= 95.0 { + vec![ + (FilterId::Sv220, Suitability::Ideal, "Narrowband required for bright moon"), + (FilterId::C2, Suitability::Good, "Dual-narrowband adequate"), + (FilterId::Sv260, Suitability::Unsuitable, "Moon too bright for LP filter"), + (FilterId::UvIr, Suitability::Unsuitable, "Moon too bright for broadband"), + ] + } else { + vec![ + (FilterId::Sv220, Suitability::Ideal, "Only viable filter at full moon"), + (FilterId::C2, Suitability::Marginal, "OIII extraction still possible"), + (FilterId::Sv260, Suitability::Unsuitable, "Moon too bright"), + (FilterId::UvIr, Suitability::Unsuitable, "Moon too bright"), + ] + } + } + "galaxy" | "reflection_nebula" => { + if moon_illumination_pct <= 40.0 { + vec![ + (FilterId::UvIr, Suitability::Ideal, "Low moon, broadband optimal"), + (FilterId::Sv260, Suitability::Good, "LP filter adds contrast"), + (FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable for galaxies"), + (FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable for galaxies"), + ] + } else if moon_illumination_pct <= 55.0 { + vec![ + (FilterId::Sv260, Suitability::Ideal, "LP filter best with moderate moon"), + (FilterId::UvIr, Suitability::Good, "UV/IR still usable"), + (FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable"), + (FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable"), + ] + } else { + vec![ + (FilterId::Sv260, Suitability::Marginal, "Moon very bright, LP filter only option"), + (FilterId::UvIr, Suitability::Unsuitable, "Moon too bright for broadband"), + (FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable"), + (FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable"), + ] + } + } + "open_cluster" | "globular_cluster" => { + vec![ + (FilterId::UvIr, Suitability::Ideal, "Broadband optimal for clusters"), + (FilterId::Sv260, Suitability::Good, "LP filter works for clusters"), + (FilterId::C2, Suitability::Unsuitable, "Narrowband not suitable for clusters"), + (FilterId::Sv220, Suitability::Unsuitable, "Narrowband not suitable for clusters"), + ] + } + "dark_nebula" => { + vec![ + (FilterId::UvIr, Suitability::Ideal, "Broadband shows star field contrast"), + (FilterId::Sv260, Suitability::Marginal, "LP filter reduces background detail"), + (FilterId::C2, Suitability::Unsuitable, "Narrowband not useful for dark nebulae"), + (FilterId::Sv220, Suitability::Unsuitable, "Narrowband not useful for dark nebulae"), + ] + } + _ => { + vec![ + (FilterId::Sv260, Suitability::Good, "General purpose LP filter"), + (FilterId::UvIr, Suitability::Good, "Broadband general use"), + (FilterId::C2, Suitability::Marginal, "Dual-narrowband may help"), + (FilterId::Sv220, Suitability::Marginal, "Narrowband may help"), + ] + } + }; + + ordered_ids + .into_iter() + .map(|(id, mut suit, reason)| { + // Moon below horizon bonus: upgrade Marginal → Good + if moon_below { + if let Suitability::Marginal = suit { + suit = Suitability::Good; + } + } + + let warning = if proximity_warn { + Some(format!( + "Moon only {:.0}° away — may cause gradients", + moon_sep_deg + )) + } else if moon_illumination_pct > 55.0 && matches!(id, FilterId::Sv260) && matches!(obj_type, "galaxy" | "reflection_nebula") { + Some("Moon very bright — expect strong gradients even with LP filter".to_string()) + } else { + None + }; + + let est = est_integration(obj_type, id); + let sessions = est.map(|h| (h / 2.0).ceil() as u32); + let exp_sec = exposure_sec(id); + let frames = est.map(|h| ((h * 3600.0) / exp_sec as f64).ceil() as u32); + + FilterRecommendation { + filter_id: id.as_str().to_string(), + filter_name: filter_name(id).to_string(), + suitability: suit, + reason: reason.to_string(), + warning, + est_integration_hours: est, + sessions_needed: sessions, + exposure_sec: Some(exp_sec), + frames_needed: frames, + } + }) + .collect() +} + +/// Return the top recommended filter id for a given object/moon state. +pub fn top_filter( + obj_type: &str, + moon_illumination_pct: f64, + moon_alt_deg: f64, + moon_sep_deg: f64, +) -> String { + recommend_filters(obj_type, moon_illumination_pct, moon_alt_deg, moon_sep_deg) + .into_iter() + .find(|r| !matches!(r.suitability, Suitability::Unsuitable)) + .map(|r| r.filter_id) + .unwrap_or_else(|| "sv260".to_string()) +} + +/// Processing workflow definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workflow { + pub name: String, + pub steps: Vec, + pub plugins: Vec<(String, String)>, + pub notes: String, +} + +pub fn get_workflow(obj_type: &str, filter_id: &str) -> Workflow { + match (obj_type, filter_id) { + (_, "sv220") | (_, "c2") if matches!(obj_type, "emission_nebula" | "snr" | "planetary_nebula") => { + if filter_id == "sv220" { + Workflow { + name: "HA+OIII Dual Narrowband (SV220)".to_string(), + steps: vec![ + "WBPP — weighted batch pre-processing".to_string(), + "DBXtract — extract Hα and OIII channels".to_string(), + "SPCC on each channel (GAIA DR3)".to_string(), + "NarrowBandNormalization — balance channel brightness".to_string(), + "BlurXTerminator per channel — deconvolution".to_string(), + "NoiseXTerminator v3 — AI noise reduction".to_string(), + "StarXTerminator — remove stars".to_string(), + "HOO composition: Hα→R, OIII→G+B".to_string(), + "GHS — generalized hyperbolic stretch".to_string(), + ], + plugins: vec![ + ("WBPP".to_string(), "Weighted batch calibration and integration".to_string()), + ("DBXtract".to_string(), "Dual-narrowband channel extraction from OSC data".to_string()), + ("SPCC".to_string(), "Spectrophotometric color calibration using GAIA DR3".to_string()), + ("BlurXTerminator".to_string(), "AI-powered deconvolution and sharpening".to_string()), + ("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()), + ("StarXTerminator".to_string(), "AI star separation for starless processing".to_string()), + ("GHS".to_string(), "Generalized hyperbolic stretch for non-linear processing".to_string()), + ], + notes: "Combine Hα and OIII from separate sessions for best result. 7nm filters require longer integrations.".to_string(), + } + } else { + Workflow { + name: "SII+OIII Dual Narrowband (Askar C2)".to_string(), + steps: vec![ + "WBPP — weighted batch pre-processing".to_string(), + "DBXtract — extract SII and OIII channels".to_string(), + "NarrowBandNormalization — balance channel brightness".to_string(), + "BlurXTerminator per channel".to_string(), + "NoiseXTerminator v3".to_string(), + "StarXTerminator".to_string(), + "SHO-like composition: SII→R, Hα→G (from SV220 if available), OIII→B".to_string(), + "GHS".to_string(), + ], + plugins: vec![ + ("WBPP".to_string(), "Weighted batch calibration and integration".to_string()), + ("DBXtract".to_string(), "Dual-narrowband channel extraction from OSC data".to_string()), + ("BlurXTerminator".to_string(), "AI-powered deconvolution".to_string()), + ("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()), + ("StarXTerminator".to_string(), "AI star separation".to_string()), + ("GHS".to_string(), "Non-linear stretch".to_string()), + ], + notes: "Combine OIII from C2 with OIII from SV220 if both sessions available. SII at 15nm is faint — prioritize long integrations.".to_string(), + } + } + } + ("open_cluster", _) | ("globular_cluster", _) => Workflow { + name: "Cluster Broadband".to_string(), + steps: vec![ + "WBPP — weighted batch pre-processing".to_string(), + "SPCC — spectrophotometric color calibration".to_string(), + "BlurXTerminator (star-optimised profile)".to_string(), + "NoiseXTerminator v3".to_string(), + "GHS — gentle S-curve only".to_string(), + ], + plugins: vec![ + ("WBPP".to_string(), "Weighted batch calibration and integration".to_string()), + ("SPCC".to_string(), "Color calibration for accurate star colors".to_string()), + ("BlurXTerminator".to_string(), "Star sharpening with star-optimised settings".to_string()), + ("NoiseXTerminator".to_string(), "Noise reduction preserving star detail".to_string()), + ("GHS".to_string(), "Gentle stretch preserving color".to_string()), + ], + notes: "No star removal for clusters. Preserve natural star field appearance.".to_string(), + }, + _ => Workflow { + name: "Broadband OSC".to_string(), + steps: vec![ + "WBPP — weighted batch pre-processing".to_string(), + "SPCC — spectrophotometric color calibration (GAIA DR3)".to_string(), + "BlurXTerminator — deconvolution".to_string(), + "NoiseXTerminator v3 — noise reduction".to_string(), + "GHS — generalized hyperbolic stretch".to_string(), + "DarkStructureEnhance — bring out dust lanes".to_string(), + "StarXTerminator (optional) — separate stars".to_string(), + "SetiAstro Statistical Stretch".to_string(), + ], + plugins: vec![ + ("WBPP".to_string(), "Weighted batch calibration and integration".to_string()), + ("SPCC".to_string(), "Spectrophotometric color calibration using GAIA DR3".to_string()), + ("BlurXTerminator".to_string(), "AI-powered deconvolution and sharpening".to_string()), + ("NoiseXTerminator".to_string(), "Deep-learning noise reduction".to_string()), + ("GHS".to_string(), "Generalized hyperbolic stretch".to_string()), + ("DarkStructureEnhance".to_string(), "Enhance dark dust lanes in galaxies".to_string()), + ("StarXTerminator".to_string(), "Optional star separation for background processing".to_string()), + ("SetiAstro Statistical Stretch".to_string(), "Statistical background stretching".to_string()), + ], + notes: "Suitable for galaxies and reflection nebulae with UV/IR or SV260 filter.".to_string(), + }, + } +} diff --git a/backend/src/jobs/catalog_refresh.rs b/backend/src/jobs/catalog_refresh.rs new file mode 100644 index 0000000..740d765 --- /dev/null +++ b/backend/src/jobs/catalog_refresh.rs @@ -0,0 +1,9 @@ +use sqlx::SqlitePool; + +use crate::catalog::refresh_catalog; + +pub async fn run_catalog_refresh(pool: SqlitePool) { + if let Err(e) = refresh_catalog(&pool).await { + tracing::error!("Catalog refresh failed: {}", e); + } +} diff --git a/backend/src/jobs/mod.rs b/backend/src/jobs/mod.rs new file mode 100644 index 0000000..a2da9dd --- /dev/null +++ b/backend/src/jobs/mod.rs @@ -0,0 +1,66 @@ +pub mod catalog_refresh; +pub mod nightly; +pub mod weather_poll; + +use sqlx::SqlitePool; +use tokio::time::sleep; + +use self::catalog_refresh::run_catalog_refresh; +pub use self::nightly::precompute_tonight; +use self::weather_poll::start_weather_scheduler; +use crate::astronomy::astro_twilight; +use crate::config::{LAT, LON}; + +pub fn start_all_jobs(pool: SqlitePool) { + // Catalog refresh on startup (respects TTL) + let pool_cat = pool.clone(); + tokio::spawn(async move { + run_catalog_refresh(pool_cat).await; + }); + + // Initial weather poll + let pool_wx = pool.clone(); + tokio::spawn(async move { + if let Err(e) = crate::weather::poll_weather(&pool_wx).await { + tracing::error!("Initial weather poll failed: {}", e); + } + }); + + // Weather scheduler + start_weather_scheduler(pool.clone()); + + // Nightly precompute: run at dusk each day + let pool_night = pool.clone(); + tokio::spawn(async move { + loop { + // Run once immediately on startup + if let Err(e) = precompute_tonight(&pool_night).await { + tracing::error!("Nightly precompute failed: {}", e); + } + + // Sleep until next dusk + sleep_until_next_dusk().await; + } + }); +} + +async fn sleep_until_next_dusk() { + // Compute tonight's dusk and sleep until then + let today = chrono::Utc::now().naive_utc().date(); + let tomorrow = today + chrono::Duration::days(1); + + let dusk = astro_twilight(tomorrow, LAT, LON) + .map(|(d, _)| d) + .unwrap_or_else(|_| chrono::Utc::now() + chrono::Duration::hours(24)); + + let now = chrono::Utc::now(); + let wait = if dusk > now { + (dusk - now).to_std().unwrap_or(std::time::Duration::from_secs(3600)) + } else { + std::time::Duration::from_secs(3600) + }; + + tracing::info!("Next nightly precompute scheduled in {:.0}h", wait.as_secs_f32() / 3600.0); + let tokio_dur = tokio::time::Duration::from_secs(wait.as_secs()); + sleep(tokio_dur).await; +} diff --git a/backend/src/jobs/nightly.rs b/backend/src/jobs/nightly.rs new file mode 100644 index 0000000..d956c04 --- /dev/null +++ b/backend/src/jobs/nightly.rs @@ -0,0 +1,229 @@ +use chrono::{DateTime, Duration, NaiveDate, Utc}; +use sqlx::SqlitePool; + +use crate::astronomy::{ + astro_twilight, compute_visibility, julian_date, moon_age_days, moon_altitude, + moon_illumination, moon_phase_name, moon_position, moon_rise_set, true_dark_window, + HorizonPoint, MoonState, TonightWindow, +}; +use crate::config::{LAT, LON}; +use crate::filters::top_filter; + +struct CatalogObj { + id: String, + ra_deg: f64, + dec_deg: f64, + obj_type: String, +} + +pub async fn precompute_tonight(pool: &SqlitePool) -> anyhow::Result<()> { + let today = Utc::now().naive_utc().date(); + precompute_for_date(pool, today).await?; + + // Also precompute next 90 nights (lightweight) + for i in 1..=90i64 { + let date = today + Duration::days(i); + if let Err(e) = precompute_lightweight(pool, date).await { + tracing::warn!("Lightweight precompute for {} failed: {}", date, e); + } + } + + Ok(()) +} + +pub async fn precompute_for_date(pool: &SqlitePool, date: NaiveDate) -> anyhow::Result<()> { + let start = std::time::Instant::now(); + tracing::info!("Nightly precompute for {}", date); + + // 1. Compute twilight + let (dusk, dawn) = astro_twilight(date, LAT, LON)?; + + // 2. Moon state + let midnight = dusk + (dawn - dusk) / 2; + let jd = julian_date(midnight); + let (moon_ra, moon_dec) = moon_position(jd); + let moon_illum = moon_illumination(jd); + let moon_age = moon_age_days(jd); + let moon_phase = moon_phase_name(moon_illum, moon_age); + let moon_alt = moon_altitude(jd, LAT, LON); + let (moon_rise, moon_set) = moon_rise_set(dusk, dawn, LAT, LON); + let true_dark = true_dark_window(dusk, dawn, LAT, LON); + let (true_dark_start, true_dark_end, true_dark_min) = match true_dark { + Some((s, e)) => (Some(s), Some(e), Some((e - s).num_minutes() as i32)), + None => (None, None, Some(0)), + }; + + // 3. Upsert tonight table + let now_ts = Utc::now().timestamp(); + sqlx::query( + r#"INSERT OR REPLACE INTO tonight + (id, date, astro_dusk_utc, astro_dawn_utc, + moon_rise_utc, moon_set_utc, moon_illumination, moon_phase_name, + moon_ra_deg, moon_dec_deg, + true_dark_start_utc, true_dark_end_utc, true_dark_minutes, computed_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(date.to_string()) + .bind(dusk.to_rfc3339()) + .bind(dawn.to_rfc3339()) + .bind(moon_rise.map(|t| t.to_rfc3339())) + .bind(moon_set.map(|t| t.to_rfc3339())) + .bind(moon_illum) + .bind(&moon_phase) + .bind(moon_ra) + .bind(moon_dec) + .bind(true_dark_start.map(|t| t.to_rfc3339())) + .bind(true_dark_end.map(|t| t.to_rfc3339())) + .bind(true_dark_min) + .bind(now_ts) + .execute(pool) + .await?; + + // 4. Load horizon + let horizon: Vec = sqlx::query_as( + "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", + ) + .fetch_all(pool) + .await?; + + let moon_state = MoonState { + ra_deg: moon_ra, + dec_deg: moon_dec, + illumination: moon_illum, + alt_at_midnight: moon_alt, + }; + + let window = TonightWindow { dusk, dawn }; + + // 5. Load all catalog objects + let objects: Vec = sqlx::query_as::<_, (String, f64, f64, String)>( + "SELECT id, ra_deg, dec_deg, obj_type FROM catalog", + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|(id, ra, dec, obj_type)| CatalogObj { id, ra_deg: ra, dec_deg: dec, obj_type }) + .collect(); + + let n_objects = objects.len(); + + // 6. Compute visibility for each object and upsert nightly_cache + let date_str = date.to_string(); + let mut tx = pool.begin().await?; + + for obj in &objects { + let vis = compute_visibility(obj.ra_deg, obj.dec_deg, &window, &horizon, &moon_state); + let rec_filter = top_filter( + &obj.obj_type, + moon_illum * 100.0, + moon_alt, + vis.moon_sep_deg, + ); + let vis_json = serde_json::to_string(&vis.curve).unwrap_or_default(); + + sqlx::query( + r#"INSERT OR REPLACE INTO nightly_cache + (catalog_id, night_date, max_alt_deg, transit_utc, rise_utc, set_utc, + best_start_utc, best_end_utc, usable_min, meridian_flip_utc, + airmass_at_transit, extinction_mag, moon_sep_deg, recommended_filter, visibility_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(&obj.id) + .bind(&date_str) + .bind(vis.max_alt_deg) + .bind(vis.transit_utc.map(|t| t.to_rfc3339())) + .bind(vis.rise_utc.map(|t| t.to_rfc3339())) + .bind(vis.set_utc.map(|t| t.to_rfc3339())) + .bind(vis.best_start_utc.map(|t| t.to_rfc3339())) + .bind(vis.best_end_utc.map(|t| t.to_rfc3339())) + .bind(vis.usable_min as i32) + .bind(vis.meridian_flip_utc.map(|t| t.to_rfc3339())) + .bind(vis.airmass_at_transit) + .bind(vis.extinction_at_transit) + .bind(vis.moon_sep_deg) + .bind(&rec_filter) + .bind(&vis_json) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + tracing::info!( + "Nightly precompute complete: {} objects processed in {:.1}s", + n_objects, + start.elapsed().as_secs_f32() + ); + + Ok(()) +} + +/// Lightweight precompute: only max_alt, transit, usable_min, recommended_filter. +/// Skips full visibility curve for performance. +async fn precompute_lightweight(pool: &SqlitePool, date: NaiveDate) -> anyhow::Result<()> { + // Check if already computed + let existing: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM nightly_cache WHERE night_date = ?", + ) + .bind(date.to_string()) + .fetch_one(pool) + .await?; + + if existing > 0 { + return Ok(()); + } + + let (dusk, dawn) = astro_twilight(date, LAT, LON)?; + let midnight = dusk + (dawn - dusk) / 2; + let jd = julian_date(midnight); + let (moon_ra, moon_dec) = moon_position(jd); + let moon_illum = moon_illumination(jd); + let moon_alt = moon_altitude(jd, LAT, LON); + + let horizon: Vec = sqlx::query_as( + "SELECT az_deg, alt_deg FROM horizon ORDER BY az_deg", + ) + .fetch_all(pool) + .await?; + + let moon_state = MoonState { + ra_deg: moon_ra, + dec_deg: moon_dec, + illumination: moon_illum, + alt_at_midnight: moon_alt, + }; + let window = TonightWindow { dusk, dawn }; + + let objects: Vec = sqlx::query_as::<_, (String, f64, f64, String)>( + "SELECT id, ra_deg, dec_deg, obj_type FROM catalog", + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|(id, ra, dec, ot)| CatalogObj { id, ra_deg: ra, dec_deg: dec, obj_type: ot }) + .collect(); + + let date_str = date.to_string(); + let mut tx = pool.begin().await?; + + for obj in &objects { + let vis = compute_visibility(obj.ra_deg, obj.dec_deg, &window, &horizon, &moon_state); + let rec_filter = top_filter(&obj.obj_type, moon_illum * 100.0, moon_alt, vis.moon_sep_deg); + + sqlx::query( + r#"INSERT OR IGNORE INTO nightly_cache + (catalog_id, night_date, max_alt_deg, transit_utc, usable_min, recommended_filter) + VALUES (?, ?, ?, ?, ?, ?)"#, + ) + .bind(&obj.id) + .bind(&date_str) + .bind(vis.max_alt_deg) + .bind(vis.transit_utc.map(|t: DateTime| t.to_rfc3339())) + .bind(vis.usable_min as i32) + .bind(&rec_filter) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + + Ok(()) +} diff --git a/backend/src/jobs/weather_poll.rs b/backend/src/jobs/weather_poll.rs new file mode 100644 index 0000000..6e90d3d --- /dev/null +++ b/backend/src/jobs/weather_poll.rs @@ -0,0 +1,29 @@ +use sqlx::SqlitePool; +use tokio::time::{sleep, Duration}; + +use crate::weather::poll_weather; + +pub fn start_weather_scheduler(pool: SqlitePool) { + // 3-hour weather poll + let pool_3h = pool.clone(); + tokio::spawn(async move { + loop { + if let Err(e) = poll_weather(&pool_3h).await { + tracing::error!("Weather poll (3h) failed: {}", e); + } + sleep(Duration::from_secs(3 * 3600)).await; + } + }); + + // 15-minute dew point poll (open-meteo only) + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(15 * 60)).await; + if let Err(e) = crate::weather::openmeteo::fetch_openmeteo().await { + tracing::warn!("Dew point poll failed: {}", e); + } else { + tracing::debug!("Dew point poll OK"); + } + } + }); +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..77771a9 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,47 @@ +mod api; +mod astronomy; +mod catalog; +mod config; +mod db; +mod filters; +mod jobs; +mod phd2; +mod weather; + +use tower_http::cors::{Any, CorsLayer}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "astronome=info,tower_http=info".into()), + ) + .init(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite:///data/astronome.db".to_string()); + + tracing::info!("Connecting to database: {}", database_url); + let pool = db::init_db(&database_url).await?; + + // Start background jobs + jobs::start_all_jobs(pool.clone()); + + // Build router + let cors = CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any); + + let app = api::build_router(pool).layer(cors); + + let bind_addr = "0.0.0.0:3301"; + tracing::info!("Starting server on {}", bind_addr); + + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/backend/src/phd2/mod.rs b/backend/src/phd2/mod.rs new file mode 100644 index 0000000..f5bdfe5 --- /dev/null +++ b/backend/src/phd2/mod.rs @@ -0,0 +1,306 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +use crate::config::PLATE_SCALE_ARCSEC; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Phd2Analysis { + pub session_date: String, // Extracted from log + pub duration_min: u32, + pub total_frames: u32, + pub rms_ra_arcsec: f64, + pub rms_dec_arcsec: f64, + pub rms_total_arcsec: f64, + pub peak_error_arcsec: f64, + pub star_lost_count: u32, + pub mean_snr: f64, + pub drift_ra_arcsec_per_min: f64, + pub drift_dec_arcsec_per_min: f64, + // Equipment details extracted from header + #[serde(skip_serializing_if = "Option::is_none")] + pub equipment_profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub camera_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exposure_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pixel_scale_arcsec: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hfd_px: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub guide_star_snr_at_start: Option, +} + +pub fn parse_phd2_log(content: &str) -> anyhow::Result { + // Extract session date from log header + // Look for patterns like "Log enabled at 2026-03-17 19:33:09" or "Guiding Begins at 2026-03-17 20:04:53" + let session_date = extract_session_date(content) + .unwrap_or_else(|| chrono::Utc::now().naive_utc().date().to_string()); + + // Extract header information before data section + let equipment_profile = extract_header_value(content, "Equipment Profile = "); + let camera_name = extract_camera_name(content); + let exposure_ms = extract_header_value(content, "Exposure = ") + .and_then(|s| s.trim_end_matches(" ms").parse::().ok()); + let mount_name = extract_header_value(content, "Mount = ") + .map(|s| s.split(',').next().unwrap_or(&s).to_string()); + let pixel_scale_arcsec = extract_header_value(content, "Pixel scale = ") + .and_then(|s| s.trim_end_matches(" arc-sec/px").parse::().ok()); + let hfd_px = extract_header_value(content, "HFD = ") + .and_then(|s| s.trim_end_matches(" px").parse::().ok()); + + // PHD2 logs have a header block followed by CSV data + // Find the line starting with "Frame,Time,..." + let header_line = content + .lines() + .enumerate() + .find(|(_, line)| line.starts_with("Frame,Time,")) + .map(|(i, _)| i) + .context("PHD2 log: could not find data header line")?; + + let data_lines: Vec<&str> = content + .lines() + .skip(header_line) + .collect(); + + if data_lines.is_empty() { + anyhow::bail!("PHD2 log: no data lines found"); + } + + // Parse header to find column indices + let headers: Vec<&str> = data_lines[0].split(',').collect(); + let col = |name: &str| -> anyhow::Result { + headers.iter().position(|h| h.trim() == name) + .with_context(|| format!("PHD2 log: missing column '{}'", name)) + }; + + let col_frame = col("Frame")?; + let col_time = col("Time")?; + let col_ra_raw = headers.iter().position(|h| h.trim() == "RARawDistance") + .or_else(|| headers.iter().position(|h| h.trim() == "RAGuideDistance")) + .context("PHD2 log: missing RA distance column")?; + let col_dec_raw = headers.iter().position(|h| h.trim() == "DECRawDistance") + .or_else(|| headers.iter().position(|h| h.trim() == "DECGuideDistance")) + .context("PHD2 log: missing Dec distance column")?; + let col_snr = headers.iter().position(|h| h.trim() == "SNR"); + let col_err = headers.iter().position(|h| h.trim() == "ErrorCode"); + + let mut ra_vals: Vec = Vec::new(); + let mut dec_vals: Vec = Vec::new(); + let mut snr_vals: Vec = Vec::new(); + let mut star_lost = 0u32; + let mut first_time: Option = None; + let mut last_time: Option = None; + let mut guide_star_snr_at_start: Option = None; + + for line in data_lines.iter().skip(1) { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split(',').collect(); + + // Check error code + if let Some(ec) = col_err { + if let Some(err_str) = fields.get(ec) { + if let Ok(err_code) = err_str.trim().parse::() { + if err_code != 0 { + star_lost += 1; + continue; + } + } + } + } + + let _frame: u32 = fields.get(col_frame) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + + let time: f64 = fields.get(col_time) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0.0); + + if first_time.is_none() { + first_time = Some(time); + // Capture SNR from first frame for reference + if let Some(sc) = col_snr { + if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::().ok()) { + guide_star_snr_at_start = Some(snr); + } + } + } + last_time = Some(time); + + let ra: f64 = fields.get(col_ra_raw) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0.0); + let dec: f64 = fields.get(col_dec_raw) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0.0); + + // Convert pixels to arcsec if values look like pixels (> 10.0) + let ra_arcsec = if ra.abs() < 30.0 && ra.abs() > 0.001 { + ra * PLATE_SCALE_ARCSEC + } else { + ra + }; + let dec_arcsec = if dec.abs() < 30.0 && dec.abs() > 0.001 { + dec * PLATE_SCALE_ARCSEC + } else { + dec + }; + + ra_vals.push(ra_arcsec); + dec_vals.push(dec_arcsec); + + if let Some(sc) = col_snr { + if let Some(snr) = fields.get(sc).and_then(|s| s.trim().parse::().ok()) { + snr_vals.push(snr); + } + } + } + + let n = ra_vals.len() as f64; + if n == 0.0 { + anyhow::bail!("PHD2 log: no valid data frames found"); + } + + let rms_ra = (ra_vals.iter().map(|v| v * v).sum::() / n).sqrt(); + let rms_dec = (dec_vals.iter().map(|v| v * v).sum::() / n).sqrt(); + let rms_total = (rms_ra * rms_ra + rms_dec * rms_dec).sqrt(); + + let peak_ra = ra_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max); + let peak_dec = dec_vals.iter().map(|v| v.abs()).fold(0.0_f64, f64::max); + let peak_error = peak_ra.max(peak_dec); + + let mean_snr = if snr_vals.is_empty() { + 0.0 + } else { + snr_vals.iter().sum::() / snr_vals.len() as f64 + }; + + let duration_sec = match (first_time, last_time) { + (Some(f), Some(l)) => (l - f).max(0.0), + _ => 0.0, + }; + let duration_min = (duration_sec / 60.0) as u32; + + // Simple linear drift: last half minus first half average + let drift_ra = if n > 4.0 { + let half = (n as usize) / 2; + let first_half_mean = ra_vals[..half].iter().sum::() / half as f64; + let second_half_mean = ra_vals[half..].iter().sum::() / (n as usize - half) as f64; + if duration_min > 0 { + (second_half_mean - first_half_mean) / (duration_min as f64 / 2.0) + } else { + 0.0 + } + } else { + 0.0 + }; + + let drift_dec = if n > 4.0 { + let half = (n as usize) / 2; + let first_half_mean = dec_vals[..half].iter().sum::() / half as f64; + let second_half_mean = dec_vals[half..].iter().sum::() / (n as usize - half) as f64; + if duration_min > 0 { + (second_half_mean - first_half_mean) / (duration_min as f64 / 2.0) + } else { + 0.0 + } + } else { + 0.0 + }; + + Ok(Phd2Analysis { + session_date, + duration_min, + total_frames: n as u32, + rms_ra_arcsec: rms_ra, + rms_dec_arcsec: rms_dec, + rms_total_arcsec: rms_total, + peak_error_arcsec: peak_error, + star_lost_count: star_lost, + mean_snr, + drift_ra_arcsec_per_min: drift_ra, + drift_dec_arcsec_per_min: drift_dec, + equipment_profile, + camera_name, + exposure_ms, + mount_name, + pixel_scale_arcsec, + hfd_px, + guide_star_snr_at_start, + }) +} + +fn extract_header_value(content: &str, key: &str) -> Option { + content + .lines() + .find(|line| line.contains(key)) + .and_then(|line| { + let parts: Vec<&str> = line.split(key).collect(); + if parts.len() > 1 { + let value = parts[1].trim(); + // Handle comma-separated values by taking up to first comma + let end_pos = value.find(',').unwrap_or(value.len()); + Some(value[..end_pos].trim().to_string()) + } else { + None + } + }) +} + +fn extract_camera_name(content: &str) -> Option { + // Look for "Camera = XXX, ..." line + content + .lines() + .find(|line| line.trim().starts_with("Camera = ")) + .and_then(|line| { + extract_header_value(&format!("{}\n", line), "Camera = ") + }) +} + +fn extract_session_date(content: &str) -> Option { + // Look for patterns like: + // "Log enabled at 2026-03-17 19:33:09" + // "Guiding Begins at 2026-03-17 20:04:53" + for line in content.lines() { + // Try "Log enabled at" pattern first + if let Some(idx) = line.find("Log enabled at ") { + let date_time = &line[idx + 15..]; + return extract_date_from_timestamp(date_time); + } + // Try "Guiding Begins at" pattern + if let Some(idx) = line.find("Guiding Begins at ") { + let date_time = &line[idx + 18..]; + return extract_date_from_timestamp(date_time); + } + // Try "Calibration Begins at" pattern as fallback + if let Some(idx) = line.find("Calibration Begins at ") { + let date_time = &line[idx + 21..]; + return extract_date_from_timestamp(date_time); + } + } + None +} + +fn extract_date_from_timestamp(timestamp: &str) -> Option { + // Extract date part from timestamp like "2026-03-17 19:33:09" + // Just take the first 10 characters which should be YYYY-MM-DD + if timestamp.len() >= 10 { + let date_part = ×tamp[..10]; + // Validate it's in YYYY-MM-DD format + if date_part.chars().nth(4) == Some('-') + && date_part.chars().nth(7) == Some('-') + && date_part[..4].chars().all(|c| c.is_numeric()) + && date_part[5..7].chars().all(|c| c.is_numeric()) + && date_part[8..10].chars().all(|c| c.is_numeric()) + { + return Some(date_part.to_string()); + } + } + None +} diff --git a/backend/src/weather/mod.rs b/backend/src/weather/mod.rs new file mode 100644 index 0000000..fd26991 --- /dev/null +++ b/backend/src/weather/mod.rs @@ -0,0 +1,94 @@ +pub mod openmeteo; +pub mod seventimer; + +use anyhow::Context; +use sqlx::SqlitePool; + +use self::openmeteo::fetch_openmeteo; +use self::seventimer::{fetch_seventimer, go_nogo}; + +pub async fn poll_weather(pool: &SqlitePool) -> anyhow::Result<()> { + tracing::info!("Polling weather..."); + + let (seventimer_result, openmeteo_result) = tokio::join!( + fetch_seventimer(), + fetch_openmeteo() + ); + + let seventimer_json = seventimer_result + .map(|j| serde_json::to_string(&j).unwrap_or_default()) + .unwrap_or_default(); + + let (dew_point, temp, humidity, go_nogo_str) = match openmeteo_result { + Ok(conditions) => { + let cc = 3u8; // default cloudcover if 7timer unavailable + let seeing = 3u8; + let transp = 3u8; + let gn = go_nogo(cc, seeing, transp).as_str().to_string(); + ( + Some(conditions.dew_point_c), + Some(conditions.temp_c), + Some(conditions.humidity_pct), + Some(gn), + ) + } + Err(e) => { + tracing::warn!("Open-Meteo poll failed: {}", e); + (None, None, None, None) + } + }; + + let openmeteo_json = if temp.is_some() { + Some(serde_json::json!({ + "temp_c": temp, + "humidity_pct": humidity, + "dew_point_c": dew_point + }) + .to_string()) + } else { + None + }; + + // Compute go/nogo from 7timer if available + let go_nogo_final = if !seventimer_json.is_empty() { + if let Ok(json) = serde_json::from_str::(&seventimer_json) { + if let Some(dataseries) = json["dataseries"].as_array() { + if let Some(first) = dataseries.first() { + let cc = first["cloudcover"].as_u64().unwrap_or(5) as u8; + let seeing = first["seeing"].as_u64().unwrap_or(4) as u8; + let transp = first["transparency"].as_u64().unwrap_or(4) as u8; + Some(go_nogo(cc, seeing, transp).as_str().to_string()) + } else { + go_nogo_str + } + } else { + go_nogo_str + } + } else { + go_nogo_str + } + } else { + go_nogo_str + }; + + let now = chrono::Utc::now().timestamp(); + + sqlx::query( + r#"INSERT OR REPLACE INTO weather_cache + (id, seventimer_json, openmeteo_json, dew_point_c, temp_c, humidity_pct, go_nogo, fetched_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(&seventimer_json) + .bind(&openmeteo_json) + .bind(dew_point) + .bind(temp) + .bind(humidity) + .bind(&go_nogo_final) + .bind(now) + .execute(pool) + .await + .context("failed to upsert weather_cache")?; + + tracing::info!("Weather poll complete. Go/Nogo: {:?}", go_nogo_final); + Ok(()) +} diff --git a/backend/src/weather/openmeteo.rs b/backend/src/weather/openmeteo.rs new file mode 100644 index 0000000..17e340f --- /dev/null +++ b/backend/src/weather/openmeteo.rs @@ -0,0 +1,56 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +const OPENMETEO_URL: &str = + "https://api.open-meteo.com/v1/forecast?latitude=43.8167&longitude=4.1167\ + ¤t=temperature_2m,relative_humidity_2m,dew_point_2m&wind_speed_unit=ms"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentConditions { + pub temp_c: f64, + pub humidity_pct: f64, + pub dew_point_c: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DewAlert { + Warning, + Critical, +} + +pub fn dew_alert(temp_c: f64, dew_point_c: f64) -> Option { + let margin = temp_c - dew_point_c; + if margin < 2.0 { + Some(DewAlert::Critical) + } else if margin < 4.0 { + Some(DewAlert::Warning) + } else { + None + } +} + +pub async fn fetch_openmeteo() -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let resp = client + .get(OPENMETEO_URL) + .send() + .await + .context("Open-Meteo request failed")?; + + let json: serde_json::Value = resp.json().await.context("Open-Meteo JSON parse failed")?; + + let current = &json["current"]; + let temp = current["temperature_2m"].as_f64().unwrap_or(0.0); + let humidity = current["relative_humidity_2m"].as_f64().unwrap_or(0.0); + let dew = current["dew_point_2m"].as_f64().unwrap_or(temp - 10.0); + + Ok(CurrentConditions { + temp_c: temp, + humidity_pct: humidity, + dew_point_c: dew, + }) +} diff --git a/backend/src/weather/seventimer.rs b/backend/src/weather/seventimer.rs new file mode 100644 index 0000000..bd31da7 --- /dev/null +++ b/backend/src/weather/seventimer.rs @@ -0,0 +1,48 @@ +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +const SEVENTIMER_URL: &str = + "http://www.7timer.info/bin/api.pl?lon=4.1167&lat=43.8167&product=astro&output=json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GoNogo { + Go, + Marginal, + Nogo, +} + +impl GoNogo { + pub fn as_str(&self) -> &'static str { + match self { + GoNogo::Go => "go", + GoNogo::Marginal => "marginal", + GoNogo::Nogo => "nogo", + } + } +} + +pub fn go_nogo(cloudcover: u8, seeing: u8, transparency: u8) -> GoNogo { + if cloudcover <= 2 && seeing <= 3 && transparency <= 3 { + GoNogo::Go + } else if cloudcover <= 4 && seeing <= 5 { + GoNogo::Marginal + } else { + GoNogo::Nogo + } +} + +pub async fn fetch_seventimer() -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build()?; + + let resp = client + .get(SEVENTIMER_URL) + .send() + .await + .context("7timer request failed")?; + + let json = resp.json::().await.context("7timer JSON parse failed")?; + Ok(json) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c2e473 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + backend: + build: ./backend + restart: unless-stopped + volumes: + - ./data:/data + environment: + - DATABASE_URL=sqlite:///data/astronome.db + - RUST_LOG=info + ports: + - "3301:3301" + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "3300:80" + depends_on: + - backend diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..07dbb02 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx-frontend.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9f71309 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + Astronome + + + + + + +
+ + + diff --git a/frontend/nginx-frontend.conf b/frontend/nginx-frontend.conf new file mode 100644 index 0000000..1202162 --- /dev/null +++ b/frontend/nginx-frontend.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cec0ed5 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2193 @@ +{ + "name": "astronome-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "astronome-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.56.2", + "aladin-lite": "^3.8.2", + "date-fns": "^3.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.13.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", + "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.96.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", + "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.96.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/aladin-lite": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/aladin-lite/-/aladin-lite-3.8.2.tgz", + "integrity": "sha512-/VGtxSWZ4StdKgwjmzqNkUY/3iTaBZJ5F6bN+nAfcK1NEATcAb2CYJX4U8FElpwYMgKrypYQV5KniRzhVbH+nA==", + "license": "GPL-3" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..713610d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "astronome-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.56.2", + "aladin-lite": "^3.8.2", + "date-fns": "^3.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "recharts": "^2.13.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c2c0c23 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import PageShell from './components/layout/PageShell'; +import Dashboard from './pages/Dashboard'; +import Targets from './pages/Targets'; +import Calendar from './pages/Calendar'; +import Stats from './pages/Stats'; +import Settings from './pages/Settings'; +import Gallery from './pages/Gallery'; +import SolarSystem from './pages/SolarSystem'; + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/frontend/src/aladin-lite.d.ts b/frontend/src/aladin-lite.d.ts new file mode 100644 index 0000000..9593c13 --- /dev/null +++ b/frontend/src/aladin-lite.d.ts @@ -0,0 +1,21 @@ +declare module 'aladin-lite' { + interface AladinInstance { + setFov: (fov: number) => void; + gotoRaDec: (ra: number, dec: number) => void; + addOverlay: (overlay: AladinOverlay) => void; + } + + interface AladinOverlay { + add: (shape: unknown) => void; + } + + interface AladinStatic { + init: Promise; + aladin: (selector: string, options: object) => AladinInstance; + graphicOverlay: (options: object) => AladinOverlay; + polyline: (points: [number, number][], options?: object) => unknown; + } + + const A: AladinStatic; + export default A; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..b362fc6 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,172 @@ +import type { + CalendarDay, + CalendarDateDetail, + CurvePoint, + FilterBreakdownItem, + FilterRecommendation, + GalleryImage, + HorizonPoint, + LogEntry, + Phd2Log, + Stats, + Target, + TargetNotes, + TargetsResponse, + Tonight, + VisibilitySummary, + WeatherData, + Workflow, +} from './types'; + +const base = '/api'; + +async function get(path: string): Promise { + const resp = await fetch(`${base}${path}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`); + return resp.json() as Promise; +} + +async function post(path: string, body: unknown): Promise { + const resp = await fetch(`${base}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`); + return resp.json() as Promise; +} + +async function put(path: string, body: unknown): Promise { + const resp = await fetch(`${base}${path}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`); + return resp.json() as Promise; +} + +async function del(path: string): Promise { + const resp = await fetch(`${base}${path}`, { method: 'DELETE' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`); + return resp.json() as Promise; +} + +// Targets +export interface TargetsParams { + type?: string; + constellation?: string; + filter?: string; + tonight?: boolean; + search?: string; + sort?: string; + page?: number; + limit?: number; + min_alt_deg?: number; + min_usable_min?: number; + mosaic_only?: boolean; + not_imaged?: boolean; +} + +export const api = { + targets: { + list: (params: TargetsParams = {}): Promise => { + const q = new URLSearchParams(); + if (params.type) q.set('type', params.type); + if (params.constellation) q.set('constellation', params.constellation); + if (params.filter) q.set('filter', params.filter); + if (params.tonight !== undefined) q.set('tonight', String(params.tonight)); + if (params.search) q.set('search', params.search); + if (params.sort) q.set('sort', params.sort); + if (params.page) q.set('page', String(params.page)); + if (params.limit) q.set('limit', String(params.limit)); + if (params.min_alt_deg !== undefined) q.set('min_alt_deg', String(params.min_alt_deg)); + if (params.min_usable_min !== undefined) q.set('min_usable_min', String(params.min_usable_min)); + if (params.mosaic_only) q.set('mosaic_only', 'true'); + if (params.not_imaged) q.set('not_imaged', 'true'); + return get(`/targets?${q}`); + }, + get: (id: string): Promise => get(`/targets/${id}`), + visibility: (id: string): Promise => get(`/targets/${id}/visibility`), + curve: (id: string): Promise<{ catalog_id: string; curve: CurvePoint[] }> => get(`/targets/${id}/curve`), + filters: (id: string): Promise<{ recommendations: FilterRecommendation[] }> => get(`/targets/${id}/filters`), + workflow: (id: string, filterId: string): Promise => get(`/targets/${id}/workflow/${filterId}`), + yearly: (id: string): Promise<{ catalog_id: string; points: { date: string; alt_at_midnight: number; transit_alt: number; usable_min: number; moon_illumination: number }[] }> => get(`/targets/${id}/yearly`), + getNotes: (id: string): Promise => get(`/targets/${id}/notes`), + putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { notes }), + }, + + tonight: { + get: (): Promise => get('/tonight'), + }, + + calendar: { + get: (months?: number): Promise<{ days: CalendarDay[] }> => + get(`/calendar${months ? `?months=${months}` : ''}`), + getDate: (date: string): Promise => + get(`/calendar/${date}`), + getNewMoonWindows: (): Promise<{ windows: { date: string; illumination: number; top_targets: { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[] }[] }> => + get('/calendar/new-moon-windows'), + }, + + weather: { + get: (): Promise => get('/weather'), + forecast: (): Promise => get('/weather/forecast'), + }, + + log: { + list: (page?: number): Promise<{ items: LogEntry[]; total: number }> => + get(`/log${page ? `?page=${page}` : ''}`), + forTarget: (catalogId: string): Promise<{ items: LogEntry[]; total_integration_min: number; filter_breakdown: FilterBreakdownItem[] }> => + get(`/log/${catalogId}`), + create: (entry: Omit): Promise<{ id: number }> => + post('/log', entry), + update: (id: number, data: Partial): Promise<{ id: number }> => + put(`/log/entry/${id}`, data), + delete: (id: number): Promise<{ id: number }> => + del(`/log/entry/${id}`), + exportCsv: (): void => { window.open('/api/log/export', '_blank'); }, + }, + + phd2: { + list: (): Promise<{ items: Phd2Log[] }> => get('/phd2'), + get: (id: number): Promise => get(`/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 }> => { + 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 }>); + }, + }, + + gallery: { + listAll: (): Promise<{ items: (GalleryImage & { target_name?: string; target_common_name?: string })[] }> => + get('/gallery'), + list: (catalogId: string): Promise<{ items: GalleryImage[] }> => + get(`/gallery/${catalogId}`), + delete: (id: number): Promise<{ id: number }> => del(`/gallery/item/${id}`), + upload: (catalogId: string, formData: FormData): Promise<{ id: number; url: string }> => { + return fetch(`${base}/gallery/${catalogId}`, { method: 'POST', body: formData }) + .then(r => r.json() as Promise<{ id: number; url: string }>); + }, + }, + + horizon: { + get: (): Promise<{ points: HorizonPoint[] }> => get('/horizon'), + set: (points: HorizonPoint[]): Promise<{ status: string }> => put('/horizon', points), + }, + + stats: { + get: (): Promise => get('/stats'), + }, + + health: { + get: (): Promise<{ status: string; catalog_size: number; catalog_last_refreshed?: number; db_size_bytes?: number; version: string }> => get('/health'), + }, + + admin: { + catalogRefresh: (): Promise<{ status: string }> => + fetch('/api/catalog/refresh', { method: 'POST' }).then(r => r.json() as Promise<{ status: string }>), + nightlyRecompute: (): Promise<{ status: string }> => + fetch('/api/nightly/recompute', { method: 'POST' }).then(r => r.json() as Promise<{ status: string }>), + }, +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..aeee0d7 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,237 @@ +export interface Target { + id: string; + name: string; + common_name?: string; + obj_type: string; + ra_deg: number; + dec_deg: number; + ra_h: string; + dec_dms: string; + constellation?: string; + size_arcmin_maj?: number; + size_arcmin_min?: number; + pos_angle_deg?: number; + mag_v?: number; + surface_brightness?: number; + hubble_type?: string; + messier_num?: number; + is_highlight: boolean; + fov_fill_pct?: number; + mosaic_flag: boolean; + mosaic_panels_w: number; + mosaic_panels_h: number; + difficulty?: number; + guide_star_density?: string; + // From nightly_cache + max_alt_deg?: number; + usable_min?: number; + transit_utc?: string; + recommended_filter?: string; + best_start_utc?: string; + best_end_utc?: string; + moon_sep_deg?: number; + is_visible_tonight?: boolean; + total_integration_min?: number; +} + +export interface TargetsResponse { + items: Target[]; + total: number; + page: number; + limit: number; +} + +export interface VisibilitySummary { + catalog_id: string; + night_date?: string; + max_alt_deg?: number; + transit_utc?: string; + rise_utc?: string; + set_utc?: string; + best_start_utc?: string; + best_end_utc?: string; + usable_min?: number; + meridian_flip_utc?: string; + airmass_at_transit?: number; + extinction_mag?: number; + moon_sep_deg?: number; + recommended_filter?: string; + is_visible_tonight?: boolean; +} + +export interface CurvePoint { + utc: string; + alt_deg: number; + az_deg: number; + airmass: number; + above_custom_horizon: boolean; + moon_alt_deg: number; +} + +export interface Tonight { + date: string; + astro_dusk_utc: string; + astro_dawn_utc: string; + moon_rise_utc?: string; + moon_set_utc?: string; + moon_illumination?: number; + moon_phase_name?: string; + moon_ra_deg?: number; + moon_dec_deg?: number; + true_dark_start_utc?: string; + true_dark_end_utc?: string; + true_dark_minutes?: number; + computed_at?: number; +} + +export interface WeatherData { + dew_point_c?: number; + temp_c?: number; + humidity_pct?: number; + go_nogo?: 'go' | 'marginal' | 'nogo'; + go_nogo_reasons?: string[]; + fetched_at?: number; + dew_alert?: 'warning' | 'critical'; + cloudcover?: number; + seeing?: number; + transparency?: number; + lifted_index?: number; + wind10m?: { speed: number; direction: string }; + rh2m?: number; +} + +export interface FilterRecommendation { + filter_id: string; + filter_name: string; + suitability: 'ideal' | 'good' | 'marginal' | 'unsuitable'; + reason: string; + warning?: string; + est_integration_hours?: number; + sessions_needed?: number; + exposure_sec?: number; + frames_needed?: number; +} + +export interface LogEntry { + id: number; + catalog_id: string; + session_date: string; + filter_id: string; + integration_min: number; + quality: 'keeper' | 'needs_more' | 'rejected' | 'pending'; + notes?: string; + guiding_rms?: number; + mean_temp_c?: number; + created_at: number; + target_name?: string; + target_common_name?: string; + target_obj_type?: string; +} + +export interface Phd2Log { + id: number; + session_date: string; + filename: string; + rms_total?: number; + rms_ra?: number; + rms_dec?: number; + peak_error?: number; + star_lost_count?: number; + duration_min?: number; + guide_star_snr?: number; + created_at: number; + // Equipment details + equipment_profile?: string; + camera_name?: string; + exposure_ms?: number; + mount_name?: string; + pixel_scale_arcsec?: number; + hfd_px?: number; + guide_star_snr_at_start?: number; +} + +export interface GalleryImage { + id: number; + catalog_id: string; + filename: string; + url: string; + caption?: string; + created_at: number; +} + +export interface HorizonPoint { + az_deg: number; + alt_deg: number; +} + +export interface Stats { + total_sessions: number; + total_integration_min: number; + objects_with_keeper: number; + filter_usage: { filter_id: string; count: number; total_min: number }[]; + monthly: { month: string; sessions: number; total_min: number }[]; + by_type: { obj_type: string; sessions: number; total_min: number }[]; + quality: { quality: string; count: number }[]; + top_targets: { id: string; name: string; common_name?: string; obj_type: string; sessions: number; total_min: number }[]; + guiding: { date: string; rms_total?: number; rms_ra?: number; rms_dec?: number }[]; +} + +export interface Workflow { + name: string; + steps: string[]; + plugins: [string, string][]; + notes: string; +} + +export interface CalendarDay { + date: string; + visible_count?: number; + max_usable_min?: number; + avg_max_alt?: number; + moon_illumination?: number; +} + +export interface CalendarDateDetail { + date: string; + moon_illumination: number; + top_targets: { + id: string; + name: string; + common_name?: string; + obj_type: string; + max_alt_deg?: number; + usable_min?: number; + transit_utc?: string; + recommended_filter?: string; + }[]; + tonight?: { + astro_dusk_utc?: string; + astro_dawn_utc?: string; + moon_rise_utc?: string; + moon_set_utc?: string; + moon_illumination?: number; + moon_phase_name?: string; + true_dark_start_utc?: string; + true_dark_end_utc?: string; + true_dark_minutes?: number; + }; + weather?: { + go_nogo?: string; + temp_c?: number; + dew_point_c?: number; + cloudcover?: number; + seeing?: number; + transparency?: number; + }; +} + +export interface TargetNotes { + catalog_id: string; + notes: string; +} + +export interface FilterBreakdownItem { + filter_id: string; + total_min: number; + sessions: number; +} diff --git a/frontend/src/components/charts/AltitudeCurve.tsx b/frontend/src/components/charts/AltitudeCurve.tsx new file mode 100644 index 0000000..394da1c --- /dev/null +++ b/frontend/src/components/charts/AltitudeCurve.tsx @@ -0,0 +1,241 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, + ReferenceArea, + ResponsiveContainer, + Legend, +} from 'recharts'; +import type { CurvePoint } from '../../api/types'; + +interface Props { + curve: CurvePoint[]; + dusk: string; + dawn: string; + trueDarkStart?: string; + trueDarkEnd?: string; + meridianFlip?: string; + transitUtc?: string; + horizonPoints?: { az_deg: number; alt_deg: number }[]; + moonSepDeg?: number; +} + +function fmtHour(utc: string): string { + return new Date(utc).toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Paris', + }); +} + +/** Interpolate horizon alt at a given azimuth — mirrors backend horizon_alt() exactly. */ +function horizonAlt(az: number, pts: { az_deg: number; alt_deg: number }[]): number { + if (!pts.length) return 15; + const norm = ((az % 360) + 360) % 360; + const loIdx = Math.floor(norm) % 360; + const hiIdx = (loIdx + 1) % 360; + const frac = norm - Math.floor(norm); + const loAlt = pts.find(p => p.az_deg === loIdx)?.alt_deg ?? 15; + const hiAlt = pts.find(p => p.az_deg === hiIdx)?.alt_deg ?? 15; + return loAlt + frac * (hiAlt - loAlt); +} + +export default function AltitudeCurve({ + curve, + dusk, + dawn, + trueDarkStart, + trueDarkEnd, + meridianFlip, + horizonPoints, + moonSepDeg, +}: Props) { + if (!curve || curve.length === 0) { + return ( +
+ No visibility curve available. +
+ ); + } + + // Subsample to ~120 points max for rendering performance (1-min data = 480+ points) + const stride = Math.max(1, Math.floor(curve.length / 120)); + const sampled = curve.filter((_, i) => i % stride === 0); + + const data = sampled + .filter(p => p.alt_deg > 0) // Only show above 0° altitude + .map(p => { + const horizonAltitude = horizonPoints?.length + ? horizonAlt(p.az_deg, horizonPoints) + : 15; + const belowHorizon = p.alt_deg < horizonAltitude; + return { + time: p.utc, + alt: belowHorizon ? null : Math.round(p.alt_deg * 10) / 10, + altBelowHorizon: belowHorizon ? Math.round(p.alt_deg * 10) / 10 : null, + // Only draw moon curve when above horizon + moon: p.moon_alt_deg > 0 ? Math.round(p.moon_alt_deg * 10) / 10 : null, + az: p.az_deg, + label: fmtHour(p.utc), + horizon: Math.round(horizonAltitude * 10) / 10, + belowHorizon, // Flag for styling + }; + }); + + // Find contiguous windows where moon is above horizon — shade those periods in blue-warn + // Also shade with a stronger tint if moonSepDeg < 30° (close approach) + type MoonWindow = { x1: string; x2: string; close: boolean }; + const moonWindows: MoonWindow[] = []; + let winStart: { label: string; close: boolean } | null = null; + for (let i = 0; i < data.length; i++) { + const pt = data[i]; + const moonUp = (pt.moon ?? 0) > 0; + const close = moonSepDeg != null && moonSepDeg < 30 && moonUp; + if (moonUp && !winStart) { + winStart = { label: pt.label, close }; + } else if (!moonUp && winStart) { + moonWindows.push({ x1: winStart.label, x2: data[i - 1].label, close: winStart.close }); + winStart = null; + } + } + if (winStart && data.length > 0) { + moonWindows.push({ x1: winStart.label, x2: data[data.length - 1].label, close: winStart.close }); + } + + const nowUtc = new Date().toISOString(); + + return ( +
+ + + + + `${v}°`} + /> + { + if (name === 'horizon') return [`${value}°`, 'Horizon']; + if (name === 'moon') return [`${value}°`, 'Moon']; + if (name === 'altBelowHorizon') return [`${value}°`, 'Altitude (below horizon)']; + return [`${value}°`, 'Altitude']; + }} + labelStyle={{ color: 'var(--text-mid)' }} + /> + + {/* True dark window shading */} + {trueDarkStart && trueDarkEnd && ( + + )} + + {/* Moon-above-horizon shading — subtle blue tint; stronger orange if within 30° */} + {moonWindows.map((w, i) => ( + + ))} + + {/* 15° line */} + + {/* 30° line */} + + + {/* Meridian flip */} + {meridianFlip && ( + + )} + + {/* Now marker */} + {nowUtc >= dusk && nowUtc <= dawn && ( + + )} + + {/* Moon altitude curve — dimmed blue */} + + + {/* Altitude below custom horizon — greyed out */} + + + {/* Custom horizon step-line — red dashed */} + {horizonPoints && horizonPoints.length > 0 && ( + + )} + + {/* Object altitude curve */} + + + +
+ ); +} diff --git a/frontend/src/components/charts/YearlyVisibility.tsx b/frontend/src/components/charts/YearlyVisibility.tsx new file mode 100644 index 0000000..f8b2add --- /dev/null +++ b/frontend/src/components/charts/YearlyVisibility.tsx @@ -0,0 +1,134 @@ +import { + ComposedChart, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; + +interface YearPoint { + date: string; + alt_at_midnight: number; + transit_alt: number; + usable_min: number; + moon_illumination: number; +} + +interface Props { + points: YearPoint[]; +} + +const MONTH_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function altColor(alt: number): string { + if (alt >= 50) return 'var(--good)'; + if (alt >= 30) return '#2ab8a0'; + if (alt >= 15) return 'var(--warn)'; + return 'var(--muted)'; +} + +export default function YearlyVisibility({ points }: Props) { + if (!points.length) return null; + + // Sample to ~52 weekly points for readability + const stride = Math.max(1, Math.floor(points.length / 52)); + const sampled = points.filter((_, i) => i % stride === 0); + + const data = sampled.map(p => { + const d = new Date(p.date + 'T00:00:00Z'); + return { + label: `${MONTH_ABBR[d.getUTCMonth()]} ${d.getUTCDate()}`, + month: d.getUTCMonth(), + alt: Math.round(p.alt_at_midnight * 10) / 10, + transit_alt: Math.round(p.transit_alt), + usable: Math.round(p.usable_min / 60 * 10) / 10, + moon: Math.round(p.moon_illumination * 100), + }; + }); + + return ( +
+
+ ALTITUDE AT MIDNIGHT — next 12 months (varies as transit shifts through seasons) +
+
+ + + + + `${v}°`} + width={32} + /> + `${v}%`} + width={28} + /> + { + if (name === 'alt') return [`${value}°`, 'Alt at midnight']; + if (name === 'moon') return [`${value}%`, 'Moon']; + return [value, name]; + }} + /> + + {data.map((entry, i) => ( + + ))} + + + + +
+
+ {[ + { color: 'var(--good)', label: '≥50° excellent' }, + { color: '#2ab8a0', label: '30–50° good' }, + { color: 'var(--warn)', label: '15–30° marginal' }, + { color: 'var(--muted)', label: '<15° poor' }, + { color: '#4d9de0', label: 'Moon %' }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/gallery/ImageUploadZone.tsx b/frontend/src/components/gallery/ImageUploadZone.tsx new file mode 100644 index 0000000..0b37e44 --- /dev/null +++ b/frontend/src/components/gallery/ImageUploadZone.tsx @@ -0,0 +1,67 @@ +import { useRef, useState } from 'react'; +import { api } from '../../api'; +import { useQueryClient } from '@tanstack/react-query'; + +interface Props { + catalogId: string; +} + +export default function ImageUploadZone({ catalogId }: Props) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const qc = useQueryClient(); + + const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + setUploading(true); + setError(null); + + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append('file', file); + try { + await api.gallery.upload(catalogId, fd); + qc.invalidateQueries({ queryKey: ['gallery', catalogId] }); + } catch (e) { + setError(`Upload failed: ${e instanceof Error ? e.message : 'Unknown error'}`); + } + } + setUploading(false); + }; + + return ( +
+
inputRef.current?.click()} + onDragOver={e => e.preventDefault()} + onDrop={e => { e.preventDefault(); handleFiles(e.dataTransfer.files); }} + style={{ + border: '1px dashed var(--border-hi)', + borderRadius: 4, + padding: '20px', + textAlign: 'center', + cursor: 'pointer', + color: 'var(--text-lo)', + fontFamily: 'var(--font-mono)', + fontSize: 12, + background: 'var(--bg-deep)', + transition: 'border-color 0.15s', + }} + > + {uploading ? 'Uploading...' : 'Drop images here or click to upload (JPEG, PNG, TIFF — max 50MB)'} +
+ handleFiles(e.target.files)} + /> + {error && ( +
{error}
+ )} +
+ ); +} diff --git a/frontend/src/components/gallery/LightboxView.tsx b/frontend/src/components/gallery/LightboxView.tsx new file mode 100644 index 0000000..c6e3cb8 --- /dev/null +++ b/frontend/src/components/gallery/LightboxView.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import type { GalleryImage } from '../../api/types'; +import { api } from '../../api'; +import { useQueryClient } from '@tanstack/react-query'; + +interface Props { + images: GalleryImage[]; + catalogId: string; +} + +export default function LightboxView({ images, catalogId }: Props) { + const [lightbox, setLightbox] = useState(null); + const qc = useQueryClient(); + + if (images.length === 0) { + return ( +
+ No images yet. +
+ ); + } + + return ( + <> +
+ {images.map(img => ( +
setLightbox(img)} + style={{ + cursor: 'pointer', + borderRadius: 3, + overflow: 'hidden', + background: 'var(--bg-deep)', + aspectRatio: '1', + position: 'relative', + }} + > + {img.caption +
+ ))} +
+ + {lightbox && ( +
setLightbox(null)} + style={{ + position: 'fixed', + inset: 0, + background: 'rgba(0,0,0,0.92)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + }} + > +
e.stopPropagation()} style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}> + {lightbox.caption +
+ {lightbox.caption && ( + + {lightbox.caption} + + )} + +
+ +
+
+ )} + + ); +} diff --git a/frontend/src/components/layout/PageShell.tsx b/frontend/src/components/layout/PageShell.tsx new file mode 100644 index 0000000..9c9b4a7 --- /dev/null +++ b/frontend/src/components/layout/PageShell.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react'; +import Sidebar from './Sidebar'; + +interface Props { + children: ReactNode; +} + +export default function PageShell({ children }: Props) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..888f353 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,203 @@ +import { NavLink } from 'react-router-dom'; +import { useTonight } from '../../hooks/useTonight'; +import { useWeather, useForecast } from '../../hooks/useWeather'; +import MoonPhaseIcon from '../sky/MoonPhaseIcon'; +import GoNogo from '../weather/GoNogo'; + +const SEEING_LABELS: Record = { + 1: '0.5″', 2: '0.75″', 3: '1.0″', 4: '1.25″', + 5: '1.5″', 6: '2.0″', 7: '2.5″', 8: '>3″', +}; +const TRANSP_LABELS: Record = { + 1: 'Excellent', 2: 'Good', 3: 'Good', 4: 'Average', + 5: 'Average', 6: 'Poor', 7: 'Poor', 8: 'Bad', +}; + +const navItems = [ + { path: '/dashboard', label: 'Dashboard', icon: '⬡' }, + { path: '/targets', label: 'Targets', icon: '✦' }, + { path: '/calendar', label: 'Calendar', icon: '◫' }, + { path: '/stats', label: 'Statistics', icon: '▤' }, + { path: '/gallery', label: 'Gallery', icon: '⬚' }, + { path: '/solar-system', label: 'Solar System', icon: '◉' }, + { path: '/settings', label: 'Settings', icon: '⚙' }, +]; + +function fmtTime(utc?: string): string { + if (!utc) return '—'; + return new Date(utc).toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Paris', + }); +} + +export default function Sidebar() { + const { data: tonight } = useTonight(); + const { data: weather } = useWeather(); + const { data: forecast } = useForecast(); + + // First forecast slot = current/nearest 3-hour window + const slot = (forecast as { dataseries?: { seeing?: number; transparency?: number; cloudcover?: number }[] })?.dataseries?.[0]; + + const darkStart = tonight?.true_dark_start_utc; + const darkEnd = tonight?.true_dark_end_utc; + const darkStr = darkStart && darkEnd + ? `${fmtTime(darkStart)}–${fmtTime(darkEnd)}` + : '—'; + + const dewMargin = weather?.temp_c != null && weather?.dew_point_c != null + ? (weather.temp_c - weather.dew_point_c).toFixed(1) + : null; + + const seeingMap: Record = { + 1: '0.5″', 2: '0.75″', 3: '1.0″', 4: '1.25″', + 5: '1.5″', 6: '2.0″', 7: '2.5″', 8: '>3″', + }; + + return ( + + ); +} diff --git a/frontend/src/components/log/LogForm.tsx b/frontend/src/components/log/LogForm.tsx new file mode 100644 index 0000000..61a44ee --- /dev/null +++ b/frontend/src/components/log/LogForm.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { useCreateLog } from '../../hooks/useLog'; +import { api } from '../../api'; + +interface Props { + catalogId: string; + onSuccess?: () => void; +} + +export default function LogForm({ catalogId, onSuccess }: Props) { + const [expanded, setExpanded] = useState(false); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [filterId, setFilterId] = useState('sv220'); + const [duration, setDuration] = useState(''); + const [quality, setQuality] = useState('pending'); + const [notes, setNotes] = useState(''); + const [phd2File, setPhd2File] = useState(null); + const [phd2Uploading, setPhd2Uploading] = useState(false); + const [phd2Result, setPhd2Result] = useState<{ rms_total?: number; rms_ra?: number; rms_dec?: number } | null>(null); + const createLog = useCreateLog(); + + if (!expanded) { + return ( + + ); + } + + const handleSubmit = async () => { + if (!duration) return; + let phd2LogId: number | undefined; + + // Upload PHD2 log first if provided + if (phd2File) { + setPhd2Uploading(true); + try { + const form = new FormData(); + form.append('file', phd2File); + const result = await api.phd2.upload(form); + phd2LogId = result.id; + const analysis = result.analysis as { rms_total?: number; rms_ra?: number; rms_dec?: number }; + setPhd2Result(analysis); + } catch { + // PHD2 upload failed — proceed without it + } + setPhd2Uploading(false); + } + + createLog.mutate({ + catalog_id: catalogId, + session_date: date, + filter_id: filterId, + integration_min: parseInt(duration), + quality: quality as 'keeper' | 'needs_more' | 'rejected' | 'pending', + notes: notes || undefined, + guiding_rms: phd2Result?.rms_total, + }, { + onSuccess: () => { + setExpanded(false); + setDuration(''); + setNotes(''); + setPhd2File(null); + setPhd2Result(null); + onSuccess?.(); + }, + }); + }; + + return ( +
+
+
+ + setDate(e.target.value)} style={{ fontSize: 12 }} /> +
+
+ + +
+
+ + setDuration(e.target.value)} + min={1} + placeholder="120" + style={{ fontSize: 12, width: 80 }} + /> +
+
+ + +
+
+