Initial Commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo add:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -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
|
||||||
@@ -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`.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM rust:1.77-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY Cargo.toml ./
|
||||||
|
# Create dummy main to cache dependencies
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||||
|
RUN cargo build --release
|
||||||
|
RUN rm -rf src
|
||||||
|
COPY src ./src
|
||||||
|
# Force rebuild of main crate
|
||||||
|
RUN touch src/main.rs && cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /data/gallery
|
||||||
|
COPY --from=builder /app/target/release/astronome /usr/local/bin/
|
||||||
|
CMD ["astronome"]
|
||||||
@@ -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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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<serde_json::Value> = targets.iter().map(|r| serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<AppState>,
|
||||||
|
Query(params): Query<CalendarQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<String, (i64, i64, f64)> = rows.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
let date = r.try_get::<Option<String>, _>("night_date").unwrap_or_default().unwrap_or_default();
|
||||||
|
let visible = r.try_get::<Option<i64>, _>("visible_count").unwrap_or_default().unwrap_or(0);
|
||||||
|
let usable = r.try_get::<Option<i64>, _>("max_usable_min").unwrap_or_default().unwrap_or(0);
|
||||||
|
let avg_alt = r.try_get::<Option<f64>, _>("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<AppState>,
|
||||||
|
Path(date): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = targets.iter().map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"max_alt_deg": r.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||||
|
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
// 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::<Option<String>, _>("astro_dusk_utc").unwrap_or_default(),
|
||||||
|
"astro_dawn_utc": r.try_get::<Option<String>, _>("astro_dawn_utc").unwrap_or_default(),
|
||||||
|
"moon_rise_utc": r.try_get::<Option<String>, _>("moon_rise_utc").unwrap_or_default(),
|
||||||
|
"moon_set_utc": r.try_get::<Option<String>, _>("moon_set_utc").unwrap_or_default(),
|
||||||
|
"moon_illumination": r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default(),
|
||||||
|
"moon_phase_name": r.try_get::<Option<String>, _>("moon_phase_name").unwrap_or_default(),
|
||||||
|
"true_dark_start_utc": r.try_get::<Option<String>, _>("true_dark_start_utc").unwrap_or_default(),
|
||||||
|
"true_dark_end_utc": r.try_get::<Option<String>, _>("true_dark_end_utc").unwrap_or_default(),
|
||||||
|
"true_dark_minutes": r.try_get::<Option<i32>, _>("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::<Option<String>, _>("go_nogo").unwrap_or_default();
|
||||||
|
let temp_c = r.try_get::<Option<f64>, _>("temp_c").unwrap_or_default();
|
||||||
|
let dew_c = r.try_get::<Option<f64>, _>("dew_point_c").unwrap_or_default();
|
||||||
|
let seventimer: Option<serde_json::Value> = r.try_get::<Option<String>, _>("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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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::<Option<String>, _>("caption").unwrap_or_default(),
|
||||||
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
|
"target_name": r.try_get::<Option<String>, _>("target_name").unwrap_or_default(),
|
||||||
|
"target_common_name": r.try_get::<Option<String>, _>("target_common_name").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_gallery(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(catalog_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = 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::<Option<String>, _>("caption").unwrap_or_default(),
|
||||||
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upload_image(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(catalog_id): Path<String>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let mut image_bytes: Option<Vec<u8>> = None;
|
||||||
|
let mut orig_filename = String::from("image.jpg");
|
||||||
|
let mut caption: Option<String> = None;
|
||||||
|
let mut log_id: Option<i32> = 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<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<Vec<u8>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let points: Vec<HorizonPoint> = 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<AppState>,
|
||||||
|
Json(body): Json<Vec<HorizonEntry>>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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() })))
|
||||||
|
}
|
||||||
@@ -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<u32>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub guiding_rms: Option<f64>,
|
||||||
|
pub mean_temp_c: Option<f64>,
|
||||||
|
pub phd2_log_id: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UpdateLogEntry {
|
||||||
|
pub quality: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
pub guiding_rms: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_log(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<LogQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>();
|
||||||
|
|
||||||
|
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<AppState>,
|
||||||
|
Path(catalog_id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>();
|
||||||
|
|
||||||
|
let total_min: Option<i64> = 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<serde_json::Value> = breakdown_rows.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||||
|
"total_min": r.try_get::<i64, _>("total_min").unwrap_or_default(),
|
||||||
|
"sessions": r.try_get::<i64, _>("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<AppState>,
|
||||||
|
) -> 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::<String, _>("session_date").unwrap_or_default();
|
||||||
|
let name = r.try_get::<String, _>("name").unwrap_or_default();
|
||||||
|
let common = r.try_get::<Option<String>, _>("common_name").unwrap_or_default().unwrap_or_default();
|
||||||
|
let obj_type = r.try_get::<String, _>("obj_type").unwrap_or_default();
|
||||||
|
let filter = r.try_get::<String, _>("filter_id").unwrap_or_default();
|
||||||
|
let mins = r.try_get::<i32, _>("integration_min").unwrap_or_default();
|
||||||
|
let quality = r.try_get::<String, _>("quality").unwrap_or_default();
|
||||||
|
let rms = r.try_get::<Option<f64>, _>("guiding_rms").unwrap_or_default()
|
||||||
|
.map(|v| format!("{:.2}", v)).unwrap_or_default();
|
||||||
|
let temp = r.try_get::<Option<f64>, _>("mean_temp_c").unwrap_or_default()
|
||||||
|
.map(|v| format!("{:.1}", v)).unwrap_or_default();
|
||||||
|
let notes = r.try_get::<Option<String>, _>("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<AppState>,
|
||||||
|
Json(body): Json<CreateLogEntry>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Json(body): Json<UpdateLogEntry>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<i32, _>("id").unwrap_or_default(),
|
||||||
|
"catalog_id": r.try_get::<String, _>("catalog_id").unwrap_or_default(),
|
||||||
|
"session_date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||||
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||||
|
"integration_min": r.try_get::<i32, _>("integration_min").unwrap_or_default(),
|
||||||
|
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||||
|
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||||
|
"guiding_rms": r.try_get::<Option<f64>, _>("guiding_rms").unwrap_or_default(),
|
||||||
|
"mean_temp_c": r.try_get::<Option<f64>, _>("mean_temp_c").unwrap_or_default(),
|
||||||
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
|
"target_name": r.try_get::<Option<String>, _>("name").unwrap_or_default(),
|
||||||
|
"target_common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"target_obj_type": r.try_get::<Option<String>, _>("obj_type").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Vec<_>>()
|
||||||
|
.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<String, usize>,
|
||||||
|
messier_count: usize,
|
||||||
|
has_sizes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn catalog_rebuild_task(pool: &SqlitePool) -> Result<RebuildStats, Box<dyn std::error::Error>> {
|
||||||
|
// 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<String, usize> = 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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
let catalog_size: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM catalog")
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let catalog_last_refreshed: Option<i64> =
|
||||||
|
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"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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<AppState>,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = rows.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.try_get::<i32, _>("id").unwrap_or_default(),
|
||||||
|
"session_date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||||
|
"filename": r.try_get::<String, _>("filename").unwrap_or_default(),
|
||||||
|
"rms_total": r.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||||
|
"rms_ra": r.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||||
|
"rms_dec": r.try_get::<Option<f64>, _>("rms_dec").unwrap_or_default(),
|
||||||
|
"peak_error": r.try_get::<Option<f64>, _>("peak_error").unwrap_or_default(),
|
||||||
|
"star_lost_count": r.try_get::<Option<i32>, _>("star_lost_count").unwrap_or_default(),
|
||||||
|
"duration_min": r.try_get::<Option<i32>, _>("duration_min").unwrap_or_default(),
|
||||||
|
"guide_star_snr": r.try_get::<Option<f64>, _>("guide_star_snr").unwrap_or_default(),
|
||||||
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "items": items })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_phd2(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<i32, _>("id").unwrap_or_default(),
|
||||||
|
"session_date": row.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||||
|
"filename": row.try_get::<String, _>("filename").unwrap_or_default(),
|
||||||
|
"rms_total": row.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||||
|
"rms_ra": row.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||||
|
"rms_dec": row.try_get::<Option<f64>, _>("rms_dec").unwrap_or_default(),
|
||||||
|
"peak_error": row.try_get::<Option<f64>, _>("peak_error").unwrap_or_default(),
|
||||||
|
"star_lost_count": row.try_get::<Option<i32>, _>("star_lost_count").unwrap_or_default(),
|
||||||
|
"duration_min": row.try_get::<Option<i32>, _>("duration_min").unwrap_or_default(),
|
||||||
|
"guide_star_snr": row.try_get::<Option<f64>, _>("guide_star_snr").unwrap_or_default(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_phd2(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -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::<Utc>::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<f64>,
|
||||||
|
pub angular_size_arcsec: Option<f64>,
|
||||||
|
pub phase_pct: Option<f64>, // 0–100
|
||||||
|
pub distance_au: Option<f64>,
|
||||||
|
pub elongation_deg: Option<f64>, // 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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<SolarSystemObject> = 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<String>,
|
||||||
|
pub ra_deg: Option<f64>,
|
||||||
|
pub dec_deg: Option<f64>,
|
||||||
|
pub tle_line1: Option<String>,
|
||||||
|
pub tle_line2: Option<String>,
|
||||||
|
pub notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_custom_targets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<serde_json::Value> = rows.iter().map(|r| {
|
||||||
|
let ra: Option<f64> = r.try_get("ra_deg").unwrap_or_default();
|
||||||
|
let dec: Option<f64> = r.try_get("dec_deg").unwrap_or_default();
|
||||||
|
let has_tle = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default().is_some();
|
||||||
|
|
||||||
|
let mut obj = serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"ra_deg": ra,
|
||||||
|
"dec_deg": dec,
|
||||||
|
"notes": r.try_get::<Option<String>, _>("notes").unwrap_or_default(),
|
||||||
|
"has_tle": has_tle,
|
||||||
|
"created_at": r.try_get::<i64, _>("created_at").unwrap_or_default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute live position: prefer TLE propagation if available, else fixed RA/Dec
|
||||||
|
let tle1 = r.try_get::<Option<String>, _>("tle_line1").unwrap_or_default();
|
||||||
|
let tle2 = r.try_get::<Option<String>, _>("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<AppState>,
|
||||||
|
Json(input): Json<CustomTargetInput>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
sqlx::query("DELETE FROM custom_targets WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(serde_json::json!({ "id": id, "status": "deleted" })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
|
||||||
|
use super::{AppError, AppState};
|
||||||
|
|
||||||
|
pub async fn get_stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<i64> =
|
||||||
|
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<serde_json::Value> = filter_usage.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"filter_id": r.try_get::<String, _>("filter_id").unwrap_or_default(),
|
||||||
|
"count": r.try_get::<i64, _>("count").unwrap_or_default(),
|
||||||
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = monthly.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"month": r.try_get::<String, _>("month").unwrap_or_default(),
|
||||||
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||||
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = type_breakdown.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||||
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = quality.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"quality": r.try_get::<String, _>("quality").unwrap_or_default(),
|
||||||
|
"count": r.try_get::<i64, _>("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<serde_json::Value> = top_targets.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": r.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": r.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": r.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"sessions": r.try_get::<i64, _>("sessions").unwrap_or_default(),
|
||||||
|
"total_min": r.try_get::<Option<i64>, _>("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<serde_json::Value> = guiding.iter().map(|r| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"date": r.try_get::<String, _>("session_date").unwrap_or_default(),
|
||||||
|
"rms_total": r.try_get::<Option<f64>, _>("rms_total").unwrap_or_default(),
|
||||||
|
"rms_ra": r.try_get::<Option<f64>, _>("rms_ra").unwrap_or_default(),
|
||||||
|
"rms_dec": r.try_get::<Option<f64>, _>("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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub constellation: Option<String>,
|
||||||
|
pub filter: Option<String>,
|
||||||
|
pub tonight: Option<bool>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub page: Option<u32>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub min_alt_deg: Option<f64>,
|
||||||
|
pub min_usable_min: Option<i32>,
|
||||||
|
pub mosaic_only: Option<bool>,
|
||||||
|
pub not_imaged: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct TargetRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub common_name: Option<String>,
|
||||||
|
pub obj_type: String,
|
||||||
|
pub ra_deg: f64,
|
||||||
|
pub dec_deg: f64,
|
||||||
|
pub ra_h: String,
|
||||||
|
pub dec_dms: String,
|
||||||
|
pub constellation: Option<String>,
|
||||||
|
pub size_arcmin_maj: Option<f64>,
|
||||||
|
pub size_arcmin_min: Option<f64>,
|
||||||
|
pub mag_v: Option<f64>,
|
||||||
|
pub surface_brightness: Option<f64>,
|
||||||
|
pub hubble_type: Option<String>,
|
||||||
|
pub messier_num: Option<i32>,
|
||||||
|
pub is_highlight: bool,
|
||||||
|
pub fov_fill_pct: Option<f64>,
|
||||||
|
pub mosaic_flag: bool,
|
||||||
|
pub mosaic_panels_w: i32,
|
||||||
|
pub mosaic_panels_h: i32,
|
||||||
|
pub difficulty: Option<i32>,
|
||||||
|
pub guide_star_density: Option<String>,
|
||||||
|
// From nightly_cache
|
||||||
|
pub max_alt_deg: Option<f64>,
|
||||||
|
pub usable_min: Option<i32>,
|
||||||
|
pub transit_utc: Option<String>,
|
||||||
|
pub recommended_filter: Option<String>,
|
||||||
|
pub best_start_utc: Option<String>,
|
||||||
|
pub best_end_utc: Option<String>,
|
||||||
|
pub moon_sep_deg: Option<f64>,
|
||||||
|
pub is_visible_tonight: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_targets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(params): Query<TargetsQuery>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<String> = 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<i32> = 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<serde_json::Value> = rows.iter().map(|row| {
|
||||||
|
use sqlx::Row;
|
||||||
|
serde_json::json!({
|
||||||
|
"id": row.try_get::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||||
|
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||||
|
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
|
||||||
|
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
|
||||||
|
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||||
|
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||||
|
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
|
||||||
|
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
|
||||||
|
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||||
|
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||||
|
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||||
|
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||||
|
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||||
|
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||||
|
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
|
||||||
|
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||||
|
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||||
|
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||||
|
"max_alt_deg": row.try_get::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"usable_min": row.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||||
|
"transit_utc": row.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||||
|
"recommended_filter": row.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
"best_start_utc": row.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
|
||||||
|
"best_end_utc": row.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
|
||||||
|
"moon_sep_deg": row.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
||||||
|
"is_visible_tonight": row.try_get::<Option<bool>, _>("is_visible_tonight").unwrap_or_default(),
|
||||||
|
"total_integration_min": row.try_get::<i64, _>("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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
// Support both NGC/IC IDs and M-number IDs (e.g. "M42")
|
||||||
|
let m_num: Option<i32> = id.trim()
|
||||||
|
.strip_prefix(['M', 'm'])
|
||||||
|
.and_then(|n| n.parse().ok());
|
||||||
|
|
||||||
|
let row = if let Some(n) = m_num {
|
||||||
|
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::<String, _>("id").unwrap_or_default(),
|
||||||
|
"name": row.try_get::<String, _>("name").unwrap_or_default(),
|
||||||
|
"common_name": row.try_get::<Option<String>, _>("common_name").unwrap_or_default(),
|
||||||
|
"obj_type": row.try_get::<String, _>("obj_type").unwrap_or_default(),
|
||||||
|
"ra_deg": row.try_get::<f64, _>("ra_deg").unwrap_or_default(),
|
||||||
|
"dec_deg": row.try_get::<f64, _>("dec_deg").unwrap_or_default(),
|
||||||
|
"ra_h": row.try_get::<String, _>("ra_h").unwrap_or_default(),
|
||||||
|
"dec_dms": row.try_get::<String, _>("dec_dms").unwrap_or_default(),
|
||||||
|
"constellation": row.try_get::<Option<String>, _>("constellation").unwrap_or_default(),
|
||||||
|
"size_arcmin_maj": row.try_get::<Option<f64>, _>("size_arcmin_maj").unwrap_or_default(),
|
||||||
|
"size_arcmin_min": row.try_get::<Option<f64>, _>("size_arcmin_min").unwrap_or_default(),
|
||||||
|
"pos_angle_deg": row.try_get::<Option<f64>, _>("pos_angle_deg").unwrap_or_default(),
|
||||||
|
"mag_v": row.try_get::<Option<f64>, _>("mag_v").unwrap_or_default(),
|
||||||
|
"surface_brightness": row.try_get::<Option<f64>, _>("surface_brightness").unwrap_or_default(),
|
||||||
|
"hubble_type": row.try_get::<Option<String>, _>("hubble_type").unwrap_or_default(),
|
||||||
|
"messier_num": row.try_get::<Option<i32>, _>("messier_num").unwrap_or_default(),
|
||||||
|
"is_highlight": row.try_get::<bool, _>("is_highlight").unwrap_or_default(),
|
||||||
|
"fov_fill_pct": row.try_get::<Option<f64>, _>("fov_fill_pct").unwrap_or_default(),
|
||||||
|
"mosaic_flag": row.try_get::<bool, _>("mosaic_flag").unwrap_or_default(),
|
||||||
|
"mosaic_panels_w": row.try_get::<i32, _>("mosaic_panels_w").unwrap_or(1),
|
||||||
|
"mosaic_panels_h": row.try_get::<i32, _>("mosaic_panels_h").unwrap_or(1),
|
||||||
|
"difficulty": row.try_get::<Option<i32>, _>("difficulty").unwrap_or_default(),
|
||||||
|
"guide_star_density": row.try_get::<Option<String>, _>("guide_star_density").unwrap_or_default(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_visibility(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Option<f64>, _>("max_alt_deg").unwrap_or_default(),
|
||||||
|
"transit_utc": r.try_get::<Option<String>, _>("transit_utc").unwrap_or_default(),
|
||||||
|
"rise_utc": r.try_get::<Option<String>, _>("rise_utc").unwrap_or_default(),
|
||||||
|
"set_utc": r.try_get::<Option<String>, _>("set_utc").unwrap_or_default(),
|
||||||
|
"best_start_utc": r.try_get::<Option<String>, _>("best_start_utc").unwrap_or_default(),
|
||||||
|
"best_end_utc": r.try_get::<Option<String>, _>("best_end_utc").unwrap_or_default(),
|
||||||
|
"usable_min": r.try_get::<Option<i32>, _>("usable_min").unwrap_or_default(),
|
||||||
|
"meridian_flip_utc": r.try_get::<Option<String>, _>("meridian_flip_utc").unwrap_or_default(),
|
||||||
|
"airmass_at_transit": r.try_get::<Option<f64>, _>("airmass_at_transit").unwrap_or_default(),
|
||||||
|
"extinction_mag": r.try_get::<Option<f64>, _>("extinction_mag").unwrap_or_default(),
|
||||||
|
"moon_sep_deg": r.try_get::<Option<f64>, _>("moon_sep_deg").unwrap_or_default(),
|
||||||
|
"recommended_filter": r.try_get::<Option<String>, _>("recommended_filter").unwrap_or_default(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
None => compute_visibility_live(&state, &id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn compute_visibility_live(state: &AppState, id: &str) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let cat_row = sqlx::query("SELECT ra_deg, dec_deg, obj_type FROM catalog WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.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<HorizonPoint> = 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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
// Always compute live at 1-minute resolution for the interactive chart.
|
||||||
|
// The cached visibility_json uses 10-minute steps and lacks moon_alt_deg.
|
||||||
|
let cat_row = sqlx::query("SELECT ra_deg, dec_deg FROM catalog WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.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<HorizonPoint> = 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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.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::<Option<f64>, _>("moon_illumination").unwrap_or_default().unwrap_or(0.5),
|
||||||
|
r.try_get::<Option<f64>, _>("moon_ra_deg").unwrap_or_default().unwrap_or(0.0),
|
||||||
|
r.try_get::<Option<f64>, _>("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<AppState>,
|
||||||
|
Path((id, filter_id)): Path<(String, String)>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let cat_row = sqlx::query("SELECT obj_type FROM catalog WHERE id = ?")
|
||||||
|
.bind(&id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let notes: Option<String> = 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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<NotesBody>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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" })))
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use axum::{extract::State, Json};
|
||||||
|
|
||||||
|
use super::{AppError, AppState};
|
||||||
|
|
||||||
|
pub async fn get_tonight(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Option<String>, _>("date").unwrap_or_default(),
|
||||||
|
"astro_dusk_utc": r.try_get::<Option<String>, _>("astro_dusk_utc").unwrap_or_default(),
|
||||||
|
"astro_dawn_utc": r.try_get::<Option<String>, _>("astro_dawn_utc").unwrap_or_default(),
|
||||||
|
"moon_rise_utc": r.try_get::<Option<String>, _>("moon_rise_utc").unwrap_or_default(),
|
||||||
|
"moon_set_utc": r.try_get::<Option<String>, _>("moon_set_utc").unwrap_or_default(),
|
||||||
|
"moon_illumination": r.try_get::<Option<f64>, _>("moon_illumination").unwrap_or_default(),
|
||||||
|
"moon_phase_name": r.try_get::<Option<String>, _>("moon_phase_name").unwrap_or_default(),
|
||||||
|
"moon_ra_deg": r.try_get::<Option<f64>, _>("moon_ra_deg").unwrap_or_default(),
|
||||||
|
"moon_dec_deg": r.try_get::<Option<f64>, _>("moon_dec_deg").unwrap_or_default(),
|
||||||
|
"true_dark_start_utc": r.try_get::<Option<String>, _>("true_dark_start_utc").unwrap_or_default(),
|
||||||
|
"true_dark_end_utc": r.try_get::<Option<String>, _>("true_dark_end_utc").unwrap_or_default(),
|
||||||
|
"true_dark_minutes": r.try_get::<Option<i32>, _>("true_dark_minutes").unwrap_or_default(),
|
||||||
|
"computed_at": r.try_get::<Option<i64>, _>("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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<serde_json::Value> {
|
||||||
|
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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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<String> = r.try_get("seventimer_json").unwrap_or_default();
|
||||||
|
|
||||||
|
let parsed_7t = seventimer_json.as_deref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(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<String> = 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::<Option<f64>, _>("temp_c").unwrap_or_default().unwrap_or(20.0);
|
||||||
|
let dew = r.try_get::<Option<f64>, _>("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::<Option<String>, _>("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::<String>::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::<Option<f64>, _>("dew_point_c").unwrap_or_default(),
|
||||||
|
"temp_c": r.try_get::<Option<f64>, _>("temp_c").unwrap_or_default(),
|
||||||
|
"humidity_pct": r.try_get::<Option<f64>, _>("humidity_pct").unwrap_or_default(),
|
||||||
|
"go_nogo": tonight_go_nogo,
|
||||||
|
"go_nogo_reasons": go_nogo_reasons,
|
||||||
|
"fetched_at": r.try_get::<Option<i64>, _>("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<AppState>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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::<Option<String>, _>("seventimer_json").ok().flatten()
|
||||||
|
})
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||||
|
.unwrap_or(serde_json::json!({}));
|
||||||
|
|
||||||
|
Ok(Json(forecast))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Utc>,
|
||||||
|
dawn: DateTime<Utc>,
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
|
||||||
|
let step = Duration::minutes(5);
|
||||||
|
let mut rise: Option<DateTime<Utc>> = None;
|
||||||
|
let mut set: Option<DateTime<Utc>> = 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()
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Utc>, DateTime<Utc>)> {
|
||||||
|
// 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<Utc>,
|
||||||
|
t1: DateTime<Utc>,
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
target_alt: f64,
|
||||||
|
descending: bool,
|
||||||
|
) -> anyhow::Result<DateTime<Utc>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
/// Compute Julian Date from a UTC datetime.
|
||||||
|
pub fn julian_date(dt: DateTime<Utc>) -> 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
|
||||||
|
}
|
||||||
@@ -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<Utc>,
|
||||||
|
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<DateTime<Utc>>,
|
||||||
|
pub rise_utc: Option<DateTime<Utc>>,
|
||||||
|
pub set_utc: Option<DateTime<Utc>>,
|
||||||
|
pub best_start_utc: Option<DateTime<Utc>>,
|
||||||
|
pub best_end_utc: Option<DateTime<Utc>>,
|
||||||
|
pub usable_min: u32,
|
||||||
|
pub is_visible_tonight: bool,
|
||||||
|
pub meridian_flip_utc: Option<DateTime<Utc>>,
|
||||||
|
pub airmass_at_transit: f64,
|
||||||
|
pub extinction_at_transit: f64,
|
||||||
|
pub moon_sep_deg: f64,
|
||||||
|
pub curve: Vec<CurvePoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TonightWindow {
|
||||||
|
pub dusk: DateTime<Utc>,
|
||||||
|
pub dawn: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<DateTime<Utc>> = None;
|
||||||
|
let mut rise_utc: Option<DateTime<Utc>> = None;
|
||||||
|
let mut set_utc: Option<DateTime<Utc>> = None;
|
||||||
|
let mut best_start: Option<DateTime<Utc>> = None;
|
||||||
|
let mut best_end: Option<DateTime<Utc>> = 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<Utc>,
|
||||||
|
dawn: DateTime<Utc>,
|
||||||
|
lat: f64,
|
||||||
|
lon: f64,
|
||||||
|
) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
|
||||||
|
let step = Duration::minutes(5);
|
||||||
|
let mut t = dusk;
|
||||||
|
let mut best: Option<(DateTime<Utc>, DateTime<Utc>)> = None;
|
||||||
|
let mut current_start: Option<DateTime<Utc>> = 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
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
#[serde(rename = "MajAx")]
|
||||||
|
pub maj_ax: Option<String>,
|
||||||
|
#[serde(rename = "MinAx")]
|
||||||
|
pub min_ax: Option<String>,
|
||||||
|
#[serde(rename = "PosAng")]
|
||||||
|
pub pos_angle: Option<String>,
|
||||||
|
#[serde(rename = "B-Mag")]
|
||||||
|
pub mag_b: Option<String>,
|
||||||
|
#[serde(rename = "V-Mag")]
|
||||||
|
pub mag_v: Option<String>,
|
||||||
|
#[serde(rename = "SurfBr")]
|
||||||
|
pub surface_brightness: Option<String>,
|
||||||
|
#[serde(rename = "Hubble")]
|
||||||
|
pub hubble_type: Option<String>,
|
||||||
|
#[serde(rename = "Pax")]
|
||||||
|
pub pax: Option<String>,
|
||||||
|
#[serde(rename = "Pm-RA")]
|
||||||
|
pub pm_ra: Option<String>,
|
||||||
|
#[serde(rename = "Pm-Dec")]
|
||||||
|
pub pm_dec: Option<String>,
|
||||||
|
#[serde(rename = "RadVel")]
|
||||||
|
pub rad_vel: Option<String>,
|
||||||
|
#[serde(rename = "Redshift")]
|
||||||
|
pub redshift: Option<String>,
|
||||||
|
#[serde(rename = "Cstar-U-Mag")]
|
||||||
|
pub cstar_u: Option<String>,
|
||||||
|
#[serde(rename = "Cstar-B-Mag")]
|
||||||
|
pub cstar_b: Option<String>,
|
||||||
|
#[serde(rename = "Cstar-V-Mag")]
|
||||||
|
pub cstar_v: Option<String>,
|
||||||
|
#[serde(rename = "M")]
|
||||||
|
pub messier: Option<String>,
|
||||||
|
#[serde(rename = "NGC")]
|
||||||
|
pub ngc_cross: Option<String>,
|
||||||
|
#[serde(rename = "IC")]
|
||||||
|
pub ic_cross: Option<String>,
|
||||||
|
#[serde(rename = "Cstar-Names")]
|
||||||
|
pub cstar_names: Option<String>,
|
||||||
|
#[serde(rename = "Identifiers")]
|
||||||
|
pub identifiers: Option<String>,
|
||||||
|
#[serde(rename = "Common names")]
|
||||||
|
pub common_names: Option<String>,
|
||||||
|
#[serde(rename = "NED notes")]
|
||||||
|
pub ned_notes: Option<String>,
|
||||||
|
#[serde(rename = "OpenNGC notes")]
|
||||||
|
pub opengc_notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawCatalogRow {
|
||||||
|
pub fn ra_deg(&self) -> Option<f64> {
|
||||||
|
parse_ra_deg(&self.ra)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dec_deg(&self) -> Option<f64> {
|
||||||
|
parse_dec_deg(&self.dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn maj_ax_arcmin(&self) -> Option<f64> {
|
||||||
|
self.maj_ax.as_deref().and_then(|s| s.parse::<f64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn min_ax_arcmin(&self) -> Option<f64> {
|
||||||
|
self.min_ax.as_deref().and_then(|s| s.parse::<f64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mag_v_f64(&self) -> Option<f64> {
|
||||||
|
self.mag_v.as_deref().and_then(|s| s.parse::<f64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn surface_brightness_f64(&self) -> Option<f64> {
|
||||||
|
self.surface_brightness.as_deref().and_then(|s| s.parse::<f64>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn messier_num(&self) -> Option<i32> {
|
||||||
|
self.messier.as_deref().and_then(|s| s.parse::<i32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pos_angle_f64(&self) -> Option<f64> {
|
||||||
|
self.pos_angle.as_deref().and_then(|s| s.parse::<f64>().ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse RA string "HH:MM:SS.ss" to decimal degrees.
|
||||||
|
fn parse_ra_deg(s: &str) -> Option<f64> {
|
||||||
|
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<f64> {
|
||||||
|
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<Vec<RawCatalogRow>> {
|
||||||
|
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<Vec<RawCatalogRow>> {
|
||||||
|
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::<RawCatalogRow>() {
|
||||||
|
match result {
|
||||||
|
Ok(row) => rows.push(row),
|
||||||
|
Err(e) => tracing::debug!("Skipping CSV row: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub obj_type: String,
|
||||||
|
pub ra_deg: f64,
|
||||||
|
pub dec_deg: f64,
|
||||||
|
pub ra_h: String,
|
||||||
|
pub dec_dms: String,
|
||||||
|
pub constellation: Option<String>,
|
||||||
|
pub size_arcmin_maj: Option<f64>,
|
||||||
|
pub size_arcmin_min: Option<f64>,
|
||||||
|
pub pos_angle_deg: Option<f64>,
|
||||||
|
pub mag_v: Option<f64>,
|
||||||
|
pub surface_brightness: Option<f64>,
|
||||||
|
pub hubble_type: Option<String>,
|
||||||
|
pub messier_num: Option<i32>,
|
||||||
|
pub is_highlight: bool,
|
||||||
|
pub fov_fill_pct: Option<f64>,
|
||||||
|
pub mosaic_flag: bool,
|
||||||
|
pub mosaic_panels_w: i32,
|
||||||
|
pub mosaic_panels_h: i32,
|
||||||
|
pub difficulty: Option<i32>,
|
||||||
|
pub guide_star_density: Option<String>,
|
||||||
|
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<CatalogEntry> {
|
||||||
|
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<f64>,
|
||||||
|
surface_brightness: Option<f64>,
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<LdnRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = 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::<u32>().ok());
|
||||||
|
let ra = idx("_RA")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().ok());
|
||||||
|
let dec = idx("_DE")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().ok());
|
||||||
|
let dmax = idx("Size")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||||
|
.unwrap_or(5.0);
|
||||||
|
let dmin = dmax * 0.6;
|
||||||
|
let opacity = idx("Opac")
|
||||||
|
.and_then(|i| cols.get(i))
|
||||||
|
.and_then(|s| s.trim().parse::<u8>().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<LdnRow> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<usize> {
|
||||||
|
// 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<i64> =
|
||||||
|
sqlx::query_scalar("SELECT MAX(fetched_at) FROM catalog")
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let stored_version: Option<String> =
|
||||||
|
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<usize> {
|
||||||
|
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<Vec<CatalogEntry>> {
|
||||||
|
// Fetch all sources in parallel
|
||||||
|
tracing::info!("Refreshing catalog from OpenNGC + VdB + LDN...");
|
||||||
|
let (ngc_rows_res, vdb_res, ldn_res) = tokio::join!(
|
||||||
|
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<CatalogEntry> = suitable
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| compute_derived(r, &names))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!("OpenNGC: {}/{} rows successfully derived to entries", entries.len(), suitable.len());
|
||||||
|
|
||||||
|
// Generate additional Sharpless (Sh2) entries from objects that have Sh2 identifiers
|
||||||
|
let sh2_aliases: Vec<CatalogEntry> = entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| create_sh2_alias(entry, &names))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!("Generated {} Sharpless alias entries", sh2_aliases.len());
|
||||||
|
entries.extend(sh2_aliases);
|
||||||
|
|
||||||
|
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<CatalogEntry> {
|
||||||
|
// We'll need to parse identifiers from somewhere.
|
||||||
|
// For now, we extract from the entry's existing data if available.
|
||||||
|
// The issue is that compute_derived doesn't store the original identifiers field.
|
||||||
|
// So we can look for Sh2 in the name or construct from the object type and catalog.
|
||||||
|
|
||||||
|
// Check if this object already has "Sh2" in the ID (like "Sh2-155")
|
||||||
|
if entry.id.starts_with("Sh2-") {
|
||||||
|
return None; // Already a Sharpless entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only create Sh2 aliases for emission nebulae and similar objects
|
||||||
|
// that are likely to have Sharpless counterparts
|
||||||
|
if !matches!(
|
||||||
|
entry.obj_type.as_str(),
|
||||||
|
"emission_nebula" | "reflection_nebula" | "nebula" | "dark_nebula" | "planetary_nebula"
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a Sharpless name in popular_names for this object
|
||||||
|
// by checking known Sh2→NGC mappings
|
||||||
|
let sh2_id = match entry.id.as_str() {
|
||||||
|
// Sharpless → NGC known mappings
|
||||||
|
"NGC281" => "Sh2-184", // Pac-Man
|
||||||
|
"NGC1333" => "Sh2-241", // Reflection Nebula
|
||||||
|
"NGC1499" => "Sh2-220", // California
|
||||||
|
"NGC2024" => "Sh2-68", // Flame Nebula
|
||||||
|
"NGC2237" => "Sh2-64", // Rosette
|
||||||
|
"NGC3372" => "Sh2-287", // Eta Carinae
|
||||||
|
"NGC6210" => "Sh2-105", // Turtle
|
||||||
|
"NGC6302" => "Sh2-12", // Bug
|
||||||
|
"NGC6357" => "Sh2-11", // War and Peace
|
||||||
|
"NGC6369" => "Sh2-72", // Little Ghost
|
||||||
|
"NGC6611" => "Sh2-16", // Eagle
|
||||||
|
"NGC6720" => "Sh2-83", // Ring
|
||||||
|
"NGC6826" => "Sh2-87", // Blinking
|
||||||
|
"NGC6853" => "Sh2-71", // Dumbbell
|
||||||
|
"NGC6960" => "Sh2-103", // Western Veil
|
||||||
|
"NGC6992" => "Sh2-103", // Eastern Veil
|
||||||
|
"NGC7000" => "Sh2-119", // North America
|
||||||
|
"NGC7009" => "Sh2-84", // Saturn
|
||||||
|
"NGC7027" => "Sh2-107", // Giraffe
|
||||||
|
"NGC7293" => "Sh2-108", // Helix
|
||||||
|
"NGC7380" => "Sh2-142", // Wizard
|
||||||
|
"NGC7635" => "Sh2-162", // Bubble
|
||||||
|
"NGC7662" => "Sh2-120", // Blue Snowball
|
||||||
|
"IC405" => "Sh2-229", // Flaming Star
|
||||||
|
"IC434" => "Sh2-175", // Horsehead
|
||||||
|
"IC1318" => "Sh2-100", // Butterfly
|
||||||
|
"IC1805" => "Sh2-190", // Heart
|
||||||
|
"IC1848" => "Sh2-199", // Soul
|
||||||
|
"IC5070" => "Sh2-126", // Pelican
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let common_name = popular_names
|
||||||
|
.get(sh2_id)
|
||||||
|
.or(popular_names.get(entry.id.as_str()))
|
||||||
|
.copied();
|
||||||
|
|
||||||
|
Some(CatalogEntry {
|
||||||
|
id: sh2_id.to_string(),
|
||||||
|
name: format!("{} ({})", sh2_id, entry.name),
|
||||||
|
common_name: common_name.map(|s| s.to_string()),
|
||||||
|
obj_type: entry.obj_type.clone(),
|
||||||
|
ra_deg: entry.ra_deg,
|
||||||
|
dec_deg: entry.dec_deg,
|
||||||
|
ra_h: entry.ra_h.clone(),
|
||||||
|
dec_dms: entry.dec_dms.clone(),
|
||||||
|
constellation: entry.constellation.clone(),
|
||||||
|
size_arcmin_maj: entry.size_arcmin_maj,
|
||||||
|
size_arcmin_min: entry.size_arcmin_min,
|
||||||
|
pos_angle_deg: entry.pos_angle_deg,
|
||||||
|
mag_v: entry.mag_v,
|
||||||
|
surface_brightness: entry.surface_brightness,
|
||||||
|
hubble_type: entry.hubble_type.clone(),
|
||||||
|
messier_num: None,
|
||||||
|
is_highlight: true, // Sharpless objects are highlights
|
||||||
|
fov_fill_pct: entry.fov_fill_pct,
|
||||||
|
mosaic_flag: entry.mosaic_flag,
|
||||||
|
mosaic_panels_w: entry.mosaic_panels_w,
|
||||||
|
mosaic_panels_h: entry.mosaic_panels_h,
|
||||||
|
difficulty: entry.difficulty,
|
||||||
|
guide_star_density: entry.guide_star_density.clone(),
|
||||||
|
fetched_at: entry.fetched_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_entries(pool: &SqlitePool, entries: &[CatalogEntry]) -> anyhow::Result<()> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<Vec<CatalogEntry>> {
|
||||||
|
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<VdbRow> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut header: Vec<String> = 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::<u32>().ok());
|
||||||
|
|
||||||
|
let ra = cols.get(cols.len() - 2)
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().ok());
|
||||||
|
let dec = cols.get(cols.len() - 1)
|
||||||
|
.and_then(|s| s.trim().parse::<f64>().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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<SqlitePool> {
|
||||||
|
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(())
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
);
|
||||||
@@ -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<Self> {
|
||||||
|
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<String>,
|
||||||
|
pub est_integration_hours: Option<f64>,
|
||||||
|
pub sessions_needed: Option<u32>,
|
||||||
|
pub exposure_sec: Option<u32>,
|
||||||
|
pub frames_needed: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<f64> {
|
||||||
|
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<FilterRecommendation> {
|
||||||
|
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<String>,
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<HorizonPoint> = 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<CatalogObj> = 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<HorizonPoint> = 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<CatalogObj> = 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<Utc>| t.to_rfc3339()))
|
||||||
|
.bind(vis.usable_min as i32)
|
||||||
|
.bind(&rec_filter)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:3001";
|
||||||
|
tracing::info!("Starting server on {}", bind_addr);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(bind_addr).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub camera_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exposure_ms: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mount_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pixel_scale_arcsec: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub hfd_px: Option<f64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub guide_star_snr_at_start: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_phd2_log(content: &str) -> anyhow::Result<Phd2Analysis> {
|
||||||
|
// 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::<u32>().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::<f64>().ok());
|
||||||
|
let hfd_px = extract_header_value(content, "HFD = ")
|
||||||
|
.and_then(|s| s.trim_end_matches(" px").parse::<f64>().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<usize> {
|
||||||
|
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<f64> = Vec::new();
|
||||||
|
let mut dec_vals: Vec<f64> = Vec::new();
|
||||||
|
let mut snr_vals: Vec<f64> = Vec::new();
|
||||||
|
let mut star_lost = 0u32;
|
||||||
|
let mut first_time: Option<f64> = None;
|
||||||
|
let mut last_time: Option<f64> = None;
|
||||||
|
let mut guide_star_snr_at_start: Option<f64> = 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::<i32>() {
|
||||||
|
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::<f64>().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::<f64>().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::<f64>() / n).sqrt();
|
||||||
|
let rms_dec = (dec_vals.iter().map(|v| v * v).sum::<f64>() / 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::<f64>() / 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::<f64>() / half as f64;
|
||||||
|
let second_half_mean = ra_vals[half..].iter().sum::<f64>() / (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::<f64>() / half as f64;
|
||||||
|
let second_half_mean = dec_vals[half..].iter().sum::<f64>() / (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<String> {
|
||||||
|
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<String> {
|
||||||
|
// 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<String> {
|
||||||
|
// 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<String> {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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::<serde_json::Value>(&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(())
|
||||||
|
}
|
||||||
@@ -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<DewAlert> {
|
||||||
|
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<CurrentConditions> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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<serde_json::Value> {
|
||||||
|
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::<serde_json::Value>().await.context("7timer JSON parse failed")?;
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:///data/astronome.db
|
||||||
|
- RUST_LOG=info
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000: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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Astronome</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<!-- Aladin Lite loaded via npm (aladin-lite package), not CDN -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2193
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<BrowserRouter>
|
||||||
|
<PageShell>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/targets" element={<Targets />} />
|
||||||
|
<Route path="/calendar" element={<Calendar />} />
|
||||||
|
<Route path="/stats" element={<Stats />} />
|
||||||
|
<Route path="/gallery" element={<Gallery />} />
|
||||||
|
<Route path="/solar-system" element={<SolarSystem />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</PageShell>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+21
@@ -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<void>;
|
||||||
|
aladin: (selector: string, options: object) => AladinInstance;
|
||||||
|
graphicOverlay: (options: object) => AladinOverlay;
|
||||||
|
polyline: (points: [number, number][], options?: object) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const A: AladinStatic;
|
||||||
|
export default A;
|
||||||
|
}
|
||||||
@@ -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<T>(path: string): Promise<T> {
|
||||||
|
const resp = await fetch(`${base}${path}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||||
|
return resp.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put<T>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del<T>(path: string): Promise<T> {
|
||||||
|
const resp = await fetch(`${base}${path}`, { method: 'DELETE' });
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${path}`);
|
||||||
|
return resp.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<TargetsResponse> => {
|
||||||
|
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<TargetsResponse>(`/targets?${q}`);
|
||||||
|
},
|
||||||
|
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
||||||
|
visibility: (id: string): Promise<VisibilitySummary> => 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<Workflow> => 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<TargetNotes> => get(`/targets/${id}/notes`),
|
||||||
|
putNotes: (id: string, notes: string): Promise<{ status: string }> => put(`/targets/${id}/notes`, { notes }),
|
||||||
|
},
|
||||||
|
|
||||||
|
tonight: {
|
||||||
|
get: (): Promise<Tonight> => get('/tonight'),
|
||||||
|
},
|
||||||
|
|
||||||
|
calendar: {
|
||||||
|
get: (months?: number): Promise<{ days: CalendarDay[] }> =>
|
||||||
|
get(`/calendar${months ? `?months=${months}` : ''}`),
|
||||||
|
getDate: (date: string): Promise<CalendarDateDetail> =>
|
||||||
|
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<WeatherData> => get('/weather'),
|
||||||
|
forecast: (): Promise<unknown> => 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<LogEntry, 'id' | 'created_at' | 'target_name' | 'target_common_name' | 'target_obj_type'>): Promise<{ id: number }> =>
|
||||||
|
post('/log', entry),
|
||||||
|
update: (id: number, data: Partial<LogEntry>): 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<Phd2Log> => 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<Stats> => 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 }>),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: 16 }}>
|
||||||
|
No visibility curve available.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div style={{ width: '100%', height: 240 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -10 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 90]}
|
||||||
|
tick={{ fill: 'var(--text-lo)', fontSize: 10, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={v => `${v}°`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'IBM Plex Mono',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === '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 && (
|
||||||
|
<ReferenceArea
|
||||||
|
x1={fmtHour(trueDarkStart)}
|
||||||
|
x2={fmtHour(trueDarkEnd)}
|
||||||
|
fill="var(--amber-glow)"
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moon-above-horizon shading — subtle blue tint; stronger orange if within 30° */}
|
||||||
|
{moonWindows.map((w, i) => (
|
||||||
|
<ReferenceArea
|
||||||
|
key={i}
|
||||||
|
x1={w.x1}
|
||||||
|
x2={w.x2}
|
||||||
|
fill={w.close ? 'rgba(232,131,42,0.10)' : 'rgba(77,157,224,0.08)'}
|
||||||
|
strokeOpacity={0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 15° line */}
|
||||||
|
<ReferenceLine y={15} stroke="var(--muted)" strokeDasharray="4 4" />
|
||||||
|
{/* 30° line */}
|
||||||
|
<ReferenceLine y={30} stroke="var(--good)" strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||||
|
|
||||||
|
{/* Meridian flip */}
|
||||||
|
{meridianFlip && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={fmtHour(meridianFlip)}
|
||||||
|
stroke="var(--amber)"
|
||||||
|
strokeDasharray="6 3"
|
||||||
|
label={{ value: 'Flip', fill: 'var(--amber)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Now marker */}
|
||||||
|
{nowUtc >= dusk && nowUtc <= dawn && (
|
||||||
|
<ReferenceLine
|
||||||
|
x={fmtHour(nowUtc)}
|
||||||
|
stroke="var(--amber)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moon altitude curve — dimmed blue */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="moon"
|
||||||
|
stroke="#4d9de0"
|
||||||
|
strokeWidth={1}
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeDasharray="4 2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Altitude below custom horizon — greyed out */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="altBelowHorizon"
|
||||||
|
stroke="var(--good)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom horizon step-line — red dashed */}
|
||||||
|
{horizonPoints && horizonPoints.length > 0 && (
|
||||||
|
<Line
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="horizon"
|
||||||
|
stroke="var(--danger)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
activeDot={false}
|
||||||
|
strokeOpacity={0.7}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Object altitude curve */}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="alt"
|
||||||
|
stroke="var(--good)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: 'var(--amber)' }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>
|
||||||
|
ALTITUDE AT MIDNIGHT — next 12 months (varies as transit shifts through seasons)
|
||||||
|
</div>
|
||||||
|
<div style={{ width: '100%', height: 160 }}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={data} margin={{ top: 4, right: 8, bottom: 4, left: -18 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
interval={Math.floor(data.length / 12)}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="alt"
|
||||||
|
domain={[0, 90]}
|
||||||
|
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={v => `${v}°`}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="moon"
|
||||||
|
orientation="right"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={v => `${v}%`}
|
||||||
|
width={28}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'IBM Plex Mono',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
}}
|
||||||
|
formatter={(value: number, name: string) => {
|
||||||
|
if (name === 'alt') return [`${value}°`, 'Alt at midnight'];
|
||||||
|
if (name === 'moon') return [`${value}%`, 'Moon'];
|
||||||
|
return [value, name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar yAxisId="alt" dataKey="alt" radius={[1, 1, 0, 0]} maxBarSize={12}>
|
||||||
|
{data.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={altColor(entry.alt)} fillOpacity={0.7} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
<Line
|
||||||
|
yAxisId="moon"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="moon"
|
||||||
|
stroke="#4d9de0"
|
||||||
|
strokeWidth={1}
|
||||||
|
dot={false}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeDasharray="3 2"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginTop: 6, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ color: 'var(--good)', label: '≥50° excellent' },
|
||||||
|
{ color: '#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 }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<div style={{ width: 10, height: 10, background: color, borderRadius: 2, opacity: 0.8 }} />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => 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)'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.tiff,.tif"
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 6 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<GalleryImage | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '8px 0' }}>
|
||||||
|
No images yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
||||||
|
{images.map(img => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
onClick={() => setLightbox(img)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
aspectRatio: '1',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={img.caption ?? img.filename}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.92)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
|
<img
|
||||||
|
src={lightbox.url}
|
||||||
|
alt={lightbox.caption ?? lightbox.filename}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '85vh', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
{lightbox.caption && (
|
||||||
|
<span style={{ color: 'var(--text-mid)', fontSize: 12, fontFamily: 'var(--font-sans)' }}>
|
||||||
|
{lightbox.caption}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
api.gallery.delete(lightbox.id).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['gallery', catalogId] });
|
||||||
|
setLightbox(null);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--danger)', fontSize: 12, marginLeft: 'auto' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -12,
|
||||||
|
right: -12,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
fontSize: 20,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageShell({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<main style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
background: 'var(--bg-void)',
|
||||||
|
padding: '24px 32px',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number, string> = {
|
||||||
|
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<number, string> = {
|
||||||
|
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<number, string> = {
|
||||||
|
1: '0.5″', 2: '0.75″', 3: '1.0″', 4: '1.25″',
|
||||||
|
5: '1.5″', 6: '2.0″', 7: '2.5″', 8: '>3″',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={{
|
||||||
|
width: 220,
|
||||||
|
minWidth: 220,
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100vh',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Logo */}
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 20px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--amber)',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
Astronome
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav style={{ padding: '8px 0', flex: 1, overflow: 'auto' }}>
|
||||||
|
{navItems.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
style={({ isActive }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '9px 20px',
|
||||||
|
color: isActive ? 'var(--text-hi)' : 'var(--text-mid)',
|
||||||
|
background: isActive ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
borderLeft: `2px solid ${isActive ? 'var(--amber)' : 'transparent'}`,
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? 700 : 400,
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
textDecoration: 'none',
|
||||||
|
transition: 'color 0.1s',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 14, opacity: 0.7 }}>{item.icon}</span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Tonight widget */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
marginBottom: 8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
Tonight
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Dusk', fmtTime(tonight?.astro_dusk_utc)],
|
||||||
|
['Dawn', fmtTime(tonight?.astro_dawn_utc)],
|
||||||
|
['Dark', darkStr],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)', paddingBottom: 3 }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-mid)', fontSize: 11, fontFamily: 'var(--font-mono)', textAlign: 'right' }}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>Moon</td>
|
||||||
|
<td style={{ textAlign: 'right', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4 }}>
|
||||||
|
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{tonight?.moon_illumination != null
|
||||||
|
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
{tonight?.moon_illumination != null && (
|
||||||
|
<MoonPhaseIcon illumination={tonight.moon_illumination} size={14} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditions widget */}
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
padding: '12px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
marginBottom: 8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
Conditions
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 6 }}>
|
||||||
|
<GoNogo status={weather?.go_nogo} compact />
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Temp', weather?.temp_c != null ? `${weather.temp_c.toFixed(1)}°C` : '—'],
|
||||||
|
['Dew Δ', dewMargin ? `${dewMargin}°C ${parseFloat(dewMargin) < 4 ? '⚠' : '✓'}` : '—'],
|
||||||
|
['Seeing', slot?.seeing ? SEEING_LABELS[slot.seeing] ?? '—' : '—'],
|
||||||
|
['Transp', slot?.transparency ? TRANSP_LABELS[slot.transparency] ?? '—' : '—'],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)', paddingBottom: 3 }}>{label}</td>
|
||||||
|
<td style={{
|
||||||
|
color: label === 'Dew Δ' && dewMargin && parseFloat(dewMargin) < 4
|
||||||
|
? 'var(--danger)'
|
||||||
|
: 'var(--text-mid)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>{value as string}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string>('pending');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [phd2File, setPhd2File] = useState<File | null>(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 (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
style={{
|
||||||
|
background: 'var(--amber)',
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add Session
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 12,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Date</label>
|
||||||
|
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ fontSize: 12 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Filter</label>
|
||||||
|
<select value={filterId} onChange={e => setFilterId(e.target.value)} style={{ fontSize: 12 }}>
|
||||||
|
<option value="sv220">HaOIII (SV220)</option>
|
||||||
|
<option value="c2">SIIOIII (C2)</option>
|
||||||
|
<option value="sv260">LP (SV260)</option>
|
||||||
|
<option value="uvir">UV/IR Cut</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Duration (min)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={duration}
|
||||||
|
onChange={e => setDuration(e.target.value)}
|
||||||
|
min={1}
|
||||||
|
placeholder="120"
|
||||||
|
style={{ fontSize: 12, width: 80 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<label style={{ fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>Quality</label>
|
||||||
|
<select value={quality} onChange={e => setQuality(e.target.value)} style={{ fontSize: 12 }}>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="keeper">Keeper</option>
|
||||||
|
<option value="needs_more">Needs More</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
placeholder="Notes (optional)..."
|
||||||
|
rows={2}
|
||||||
|
style={{ fontSize: 12, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
{/* PHD2 log upload */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '4px 10px', background: 'var(--bg-panel)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 3, cursor: 'pointer', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: phd2File ? 'var(--good)' : 'var(--text-lo)',
|
||||||
|
}}>
|
||||||
|
⟳ {phd2File ? phd2File.name : 'Attach PHD2 log (optional)'}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".log,.txt,.csv"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => setPhd2File(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{phd2File && (
|
||||||
|
<button onClick={() => setPhd2File(null)} style={{ color: 'var(--text-lo)', fontSize: 11 }}>✕</button>
|
||||||
|
)}
|
||||||
|
{phd2Result && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
|
||||||
|
RMS: {phd2Result.rms_total?.toFixed(2)}″
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!duration || createLog.isPending || phd2Uploading}
|
||||||
|
style={{
|
||||||
|
background: 'var(--amber)',
|
||||||
|
color: '#000',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '5px 14px',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontWeight: 700,
|
||||||
|
opacity: !duration ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{phd2Uploading ? 'Uploading PHD2…' : createLog.isPending ? 'Saving...' : 'Save Session'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setExpanded(false)} style={{ color: 'var(--text-mid)', fontSize: 12 }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
interface Props {
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Record<string, { icon: string; label: string }> = {
|
||||||
|
keeper: { icon: '✓', label: 'Keeper' },
|
||||||
|
needs_more: { icon: '→', label: 'Needs More' },
|
||||||
|
rejected: { icon: '✗', label: 'Rejected' },
|
||||||
|
pending: { icon: '·', label: 'Pending' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function QualityFlag({ quality }: Props) {
|
||||||
|
const cfg = config[quality] ?? config.pending;
|
||||||
|
return (
|
||||||
|
<span className={`quality-chip ${quality}`}>
|
||||||
|
{cfg.icon} {cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { LogEntry } from '../../api/types';
|
||||||
|
import QualityFlag from './QualityFlag';
|
||||||
|
import { useDeleteLog, useUpdateLog } from '../../hooks/useLog';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entries: LogEntry[];
|
||||||
|
totalMin?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionList({ entries, totalMin }: Props) {
|
||||||
|
const deleteLog = useDeleteLog();
|
||||||
|
const updateLog = useUpdateLog();
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editQuality, setEditQuality] = useState('');
|
||||||
|
const [editNotes, setEditNotes] = useState('');
|
||||||
|
|
||||||
|
const hours = totalMin ? Math.floor(totalMin / 60) : 0;
|
||||||
|
const mins = totalMin ? totalMin % 60 : 0;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '12px 0' }}>
|
||||||
|
No sessions logged yet.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-mid)', fontSize: 12, fontFamily: 'var(--font-mono)', marginBottom: 10 }}>
|
||||||
|
{entries.length} session{entries.length !== 1 ? 's' : ''} · {hours}h {mins}m total
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{entries.map(entry => (
|
||||||
|
<div key={entry.id} style={{
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '8px 12px',
|
||||||
|
}}>
|
||||||
|
{editingId === entry.id ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<select
|
||||||
|
value={editQuality}
|
||||||
|
onChange={e => setEditQuality(e.target.value)}
|
||||||
|
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="keeper">Keeper</option>
|
||||||
|
<option value="needs_more">Needs More</option>
|
||||||
|
<option value="rejected">Rejected</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
value={editNotes}
|
||||||
|
onChange={e => setEditNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Notes..."
|
||||||
|
style={{ fontSize: 12, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
updateLog.mutate({ id: entry.id, data: { quality: editQuality as LogEntry['quality'], notes: editNotes } });
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'var(--amber)', color: '#000', borderRadius: 3,
|
||||||
|
padding: '3px 10px', fontSize: 11, fontFamily: 'var(--font-mono)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditingId(null)} style={{ color: 'var(--text-mid)', fontSize: 11 }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', minWidth: 80 }}>
|
||||||
|
{entry.session_date}
|
||||||
|
</span>
|
||||||
|
<span className={`filter-pill ${entry.filter_id}`}>{entry.filter_id.toUpperCase()}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
|
||||||
|
{entry.integration_min}min
|
||||||
|
</span>
|
||||||
|
<QualityFlag quality={entry.quality} />
|
||||||
|
{entry.guiding_rms != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
|
RMS {entry.guiding_rms.toFixed(2)}″
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry.notes && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-mid)', flex: 1 }}>
|
||||||
|
{entry.notes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(entry.id);
|
||||||
|
setEditQuality(entry.quality);
|
||||||
|
setEditNotes(entry.notes ?? '');
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--text-lo)', fontSize: 11, padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteLog.mutate(entry.id)}
|
||||||
|
style={{ color: 'var(--danger)', fontSize: 11, padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onUploaded?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PHD2UploadZone({ onUploaded }: Props) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [duplicate, setDuplicate] = useState<{ id: number; message: string } | null>(null);
|
||||||
|
const [result, setResult] = useState<{
|
||||||
|
rms_total: number;
|
||||||
|
rms_ra: number;
|
||||||
|
rms_dec: number;
|
||||||
|
duration_min?: number;
|
||||||
|
camera_name?: string;
|
||||||
|
exposure_ms?: number;
|
||||||
|
mount_name?: string;
|
||||||
|
session_date?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
setDuplicate(null);
|
||||||
|
setResult(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.phd2.upload(fd);
|
||||||
|
|
||||||
|
if (res.duplicate) {
|
||||||
|
setDuplicate({
|
||||||
|
id: res.duplicate_id || 0,
|
||||||
|
message: res.message || `Duplicate session detected (ID: ${res.duplicate_id})`
|
||||||
|
});
|
||||||
|
setResult(null);
|
||||||
|
} else {
|
||||||
|
const analysis = res.analysis as any;
|
||||||
|
setResult({
|
||||||
|
rms_total: analysis.rms_total_arcsec,
|
||||||
|
rms_ra: analysis.rms_ra_arcsec,
|
||||||
|
rms_dec: analysis.rms_dec_arcsec,
|
||||||
|
duration_min: analysis.duration_min,
|
||||||
|
camera_name: analysis.camera_name,
|
||||||
|
exposure_ms: analysis.exposure_ms,
|
||||||
|
mount_name: analysis.mount_name,
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||||
|
onUploaded?.(res.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(`Parse failed: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '10px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Parsing PHD2 log...' : '↑ Upload PHD2 log (.log)'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".log,.csv"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
{error && <div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 4 }}>{error}</div>}
|
||||||
|
{duplicate && (
|
||||||
|
<div style={{ color: 'var(--warn)', fontSize: 11, marginTop: 4, fontFamily: 'var(--font-mono)' }}>
|
||||||
|
⚠ {duplicate.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)', marginTop: 4, lineHeight: '1.5' }}>
|
||||||
|
<div>✓ RMS Total: {result.rms_total.toFixed(2)}″ (RA: {result.rms_ra.toFixed(2)}″ Dec: {result.rms_dec.toFixed(2)}″)</div>
|
||||||
|
{result.session_date && (
|
||||||
|
<div style={{ color: 'var(--text-mid)', marginTop: 4 }}>Date: {result.session_date}</div>
|
||||||
|
)}
|
||||||
|
{result.duration_min !== undefined && (
|
||||||
|
<div style={{ color: 'var(--text-mid)', marginTop: result.session_date ? 2 : 6 }}>Duration: {result.duration_min}m</div>
|
||||||
|
)}
|
||||||
|
{(result.camera_name || result.mount_name) && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', marginTop: 4 }}>
|
||||||
|
{result.camera_name && <div>Camera: {result.camera_name}</div>}
|
||||||
|
{result.mount_name && <div>Mount: {result.mount_name}</div>}
|
||||||
|
{result.exposure_ms && <div>Exposure: {result.exposure_ms}ms</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef, useId } from 'react';
|
||||||
|
import A from 'aladin-lite';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
ra: number;
|
||||||
|
dec: number;
|
||||||
|
sizeArcmin?: number;
|
||||||
|
fovW?: number;
|
||||||
|
fovH?: number;
|
||||||
|
mosaic?: { panels_w: number; panels_h: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AladinEmbed({ ra, dec, fovW = 2.75, fovH = 1.84, mosaic }: Props) {
|
||||||
|
const uid = useId().replace(/:/g, '');
|
||||||
|
const containerId = `aladin-${uid}`;
|
||||||
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await A.init;
|
||||||
|
initializedRef.current = true;
|
||||||
|
|
||||||
|
const aladin = A.aladin(`#${containerId}`, {
|
||||||
|
survey: 'CDS/P/DSS2/color',
|
||||||
|
fov: Math.max(fovW, fovH) * 3.5,
|
||||||
|
target: `${ra} ${dec}`,
|
||||||
|
showReticle: false,
|
||||||
|
showZoomControl: false,
|
||||||
|
showFullscreenControl: false,
|
||||||
|
showLayersControl: false,
|
||||||
|
showGotoControl: false,
|
||||||
|
showShareControl: false,
|
||||||
|
showStatusBar: false,
|
||||||
|
cooFrame: 'J2000',
|
||||||
|
showCooGrid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const overlay = A.graphicOverlay({ color: '#e8832a', lineWidth: 2 });
|
||||||
|
aladin.addOverlay(overlay);
|
||||||
|
|
||||||
|
const halfW = fovW / 2;
|
||||||
|
const halfH = fovH / 2;
|
||||||
|
const decRad = (dec * Math.PI) / 180;
|
||||||
|
const cosD = Math.max(Math.cos(decRad), 0.01);
|
||||||
|
const panels_w = mosaic?.panels_w ?? 1;
|
||||||
|
const panels_h = mosaic?.panels_h ?? 1;
|
||||||
|
const isMultiPanel = panels_w > 1 || panels_h > 1;
|
||||||
|
|
||||||
|
for (let pw = 0; pw < panels_w; pw++) {
|
||||||
|
for (let ph = 0; ph < panels_h; ph++) {
|
||||||
|
const panelRa = ra + ((pw - (panels_w - 1) / 2) * fovW) / cosD;
|
||||||
|
const panelDec = dec + (ph - (panels_h - 1) / 2) * fovH;
|
||||||
|
const corners: [number, number][] = [
|
||||||
|
[panelRa - halfW / cosD, panelDec - halfH],
|
||||||
|
[panelRa + halfW / cosD, panelDec - halfH],
|
||||||
|
[panelRa + halfW / cosD, panelDec + halfH],
|
||||||
|
[panelRa - halfW / cosD, panelDec + halfH],
|
||||||
|
[panelRa - halfW / cosD, panelDec - halfH],
|
||||||
|
];
|
||||||
|
const lineColor = isMultiPanel ? '#e8c030' : '#e8832a';
|
||||||
|
overlay.add(A.polyline(corners, { color: lineColor, lineWidth: 1.5 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Aladin init error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
init();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={containerId}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 280,
|
||||||
|
background: '#000',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
interface Props {
|
||||||
|
illumination: number; // 0.0–1.0
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MoonPhaseIcon({ illumination, size = 24 }: Props) {
|
||||||
|
// Draw a crescent / disk based on illumination
|
||||||
|
const r = size / 2 - 1;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const pct = illumination;
|
||||||
|
|
||||||
|
// Waxing: 0-0.5 → crescent, 0.5-1 → gibbous
|
||||||
|
const d = (() => {
|
||||||
|
if (pct < 0.01) {
|
||||||
|
// New moon — just a circle outline
|
||||||
|
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx} ${cy + r} A ${r} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||||
|
}
|
||||||
|
if (pct > 0.99) {
|
||||||
|
// Full moon — filled circle
|
||||||
|
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx} ${cy + r} A ${r} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||||
|
}
|
||||||
|
// Lit fraction → x offset of inner terminator ellipse
|
||||||
|
const x_offset = r * Math.abs(2 * pct - 1);
|
||||||
|
const waxing = pct <= 0.5;
|
||||||
|
|
||||||
|
if (waxing) {
|
||||||
|
// Crescent: right side lit
|
||||||
|
return `M ${cx} ${cy - r}
|
||||||
|
A ${r} ${r} 0 1 1 ${cx} ${cy + r}
|
||||||
|
A ${x_offset} ${r} 0 1 0 ${cx} ${cy - r}`;
|
||||||
|
} else {
|
||||||
|
// Gibbous: mostly lit, small dark crescent on left
|
||||||
|
return `M ${cx} ${cy - r}
|
||||||
|
A ${r} ${r} 0 1 1 ${cx} ${cy + r}
|
||||||
|
A ${x_offset} ${r} 0 1 1 ${cx} ${cy - r}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const isFull = pct > 0.99;
|
||||||
|
const isNew = pct < 0.01;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||||
|
{isNew ? (
|
||||||
|
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--text-mid)" strokeWidth={1} />
|
||||||
|
) : isFull ? (
|
||||||
|
<circle cx={cx} cy={cy} r={r} fill="var(--warn)" />
|
||||||
|
) : (
|
||||||
|
<path d={d} fill="var(--warn)" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Target, Workflow } from '../../api/types';
|
||||||
|
import { useTargetCurve, useTargetFilters, useTargetVisibility, useTargetWorkflow, useTargetYearly } from '../../hooks/useTargets';
|
||||||
|
import { useTargetLog } from '../../hooks/useLog';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
import AltitudeCurve from '../charts/AltitudeCurve';
|
||||||
|
import YearlyVisibility from '../charts/YearlyVisibility';
|
||||||
|
import AladinEmbed from '../sky/AladinEmbed';
|
||||||
|
import LogForm from '../log/LogForm';
|
||||||
|
import SessionList from '../log/SessionList';
|
||||||
|
import ImageUploadZone from '../gallery/ImageUploadZone';
|
||||||
|
import LightboxView from '../gallery/LightboxView';
|
||||||
|
import { useTonight } from '../../hooks/useTonight';
|
||||||
|
import { useHorizon } from '../../hooks/useHorizon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: Target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = ['Tonight', 'Target', 'Filters & Workflow', 'Log & Gallery', 'Yearly'];
|
||||||
|
|
||||||
|
const WORKFLOW_SHORT: Record<string, string> = {
|
||||||
|
'HA+OIII Dual Narrowband (SV220)': 'HaOIII',
|
||||||
|
'SII+OIII Dual Narrowband (Askar C2)': 'SHO',
|
||||||
|
'Cluster Broadband': 'Broadband Cluster',
|
||||||
|
'Broadband OSC': 'Broadband OSC',
|
||||||
|
};
|
||||||
|
|
||||||
|
function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const shortName = WORKFLOW_SHORT[workflow.name] ?? workflow.name;
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
|
||||||
|
cursor: 'pointer', borderBottom: expanded ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)',
|
||||||
|
color: 'var(--amber)', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
fontWeight: 700, padding: '2px 8px', borderRadius: 3, letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{shortName}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-sans)', fontSize: 12, color: 'var(--text-mid)', flex: 1 }}>
|
||||||
|
{workflow.name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-lo)', fontSize: 11 }}>{expanded ? '▲' : '▼'} details</span>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div style={{ padding: '10px 14px' }}>
|
||||||
|
<ol style={{ paddingLeft: 18, marginBottom: 10 }}>
|
||||||
|
{workflow.steps.map((step, i) => (
|
||||||
|
<li key={i} style={{ color: 'var(--text-hi)', fontSize: 12, fontFamily: 'var(--font-sans)', marginBottom: 3 }}>{step}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{workflow.notes && (
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-mid)', fontStyle: 'italic', marginTop: 6 }}>{workflow.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 DetailDrawer({ target }: Props) {
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
const [selectedFilter, setSelectedFilter] = useState('sv220');
|
||||||
|
const [notes, setNotes] = useState<string | null>(null);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data: tonight } = useTonight();
|
||||||
|
const { data: visData } = useTargetVisibility(target.id);
|
||||||
|
const { data: curveData } = useTargetCurve(target.id);
|
||||||
|
const { data: filtersData } = useTargetFilters(target.id);
|
||||||
|
const { data: workflowData } = useTargetWorkflow(target.id, selectedFilter);
|
||||||
|
const { data: logData } = useTargetLog(target.id);
|
||||||
|
const { data: horizonData } = useHorizon();
|
||||||
|
const { data: yearlyData } = useTargetYearly(target.id, tab === 4);
|
||||||
|
const { data: galleryData } = useQuery({
|
||||||
|
queryKey: ['gallery', target.id],
|
||||||
|
queryFn: () => api.gallery.list(target.id),
|
||||||
|
enabled: tab === 3,
|
||||||
|
});
|
||||||
|
const { data: notesData } = useQuery({
|
||||||
|
queryKey: ['target-notes', target.id],
|
||||||
|
queryFn: () => api.targets.getNotes(target.id),
|
||||||
|
enabled: tab === 3,
|
||||||
|
});
|
||||||
|
const saveNotesMutation = useMutation({
|
||||||
|
mutationFn: (text: string) => api.targets.putNotes(target.id, text),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['target-notes', target.id] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dssUrl = `https://archive.stsci.edu/cgi-bin/dss_search?v=poss2ukstu_red&r=${target.ra_deg}&d=${target.dec_deg}&e=J2000&h=${target.size_arcmin_maj ?? 15}&w=${target.size_arcmin_maj ?? 15}&f=gif`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border-hi)', borderRadius: 4, marginTop: 2 }}>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{TABS.map((t, i) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(i)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: tab === i ? 'var(--amber)' : 'var(--text-mid)',
|
||||||
|
borderBottom: tab === i ? '2px solid var(--amber)' : '2px solid transparent',
|
||||||
|
background: 'none',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
transition: 'color 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px 20px' }}>
|
||||||
|
{/* Tab 1: Tonight */}
|
||||||
|
{tab === 0 && (
|
||||||
|
<div>
|
||||||
|
{curveData?.curve && curveData.curve.length > 0 ? (
|
||||||
|
<AltitudeCurve
|
||||||
|
curve={curveData.curve}
|
||||||
|
dusk={tonight?.astro_dusk_utc ?? ''}
|
||||||
|
dawn={tonight?.astro_dawn_utc ?? ''}
|
||||||
|
trueDarkStart={tonight?.true_dark_start_utc}
|
||||||
|
trueDarkEnd={tonight?.true_dark_end_utc}
|
||||||
|
meridianFlip={visData?.meridian_flip_utc}
|
||||||
|
horizonPoints={horizonData?.points}
|
||||||
|
moonSepDeg={visData?.moon_sep_deg}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontSize: 12, fontFamily: 'var(--font-mono)', marginBottom: 12 }}>
|
||||||
|
Curve data loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', marginTop: 12 }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Rise', fmtTime(visData?.rise_utc)],
|
||||||
|
['Transit', fmtTime(visData?.transit_utc)],
|
||||||
|
['Set', fmtTime(visData?.set_utc)],
|
||||||
|
['Best window', visData?.best_start_utc && visData?.best_end_utc
|
||||||
|
? `${fmtTime(visData.best_start_utc)} – ${fmtTime(visData.best_end_utc)}`
|
||||||
|
: '—'],
|
||||||
|
['Usable time', visData?.usable_min ? `${visData.usable_min} min` : '—'],
|
||||||
|
['Meridian flip', fmtTime(visData?.meridian_flip_utc)],
|
||||||
|
['Moon sep', visData?.moon_sep_deg != null ? `${visData.moon_sep_deg.toFixed(1)}°` : '—'],
|
||||||
|
['Airmass @transit', visData?.airmass_at_transit?.toFixed(2) ?? '—'],
|
||||||
|
['Extinction', visData?.extinction_mag != null ? `${visData.extinction_mag.toFixed(2)} mag` : '—'],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 2: Target */}
|
||||||
|
{tab === 1 && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={dssUrl}
|
||||||
|
alt={`DSS ${target.name}`}
|
||||||
|
style={{ width: '100%', borderRadius: 3, background: '#000' }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
DSS Digitized Sky Survey
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%', marginBottom: 16 }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Type', target.obj_type],
|
||||||
|
['Constellation', target.constellation ?? '—'],
|
||||||
|
['RA', target.ra_h],
|
||||||
|
['Dec', target.dec_dms],
|
||||||
|
['Size', target.size_arcmin_maj ? `${target.size_arcmin_maj.toFixed(1)}′ × ${(target.size_arcmin_min ?? target.size_arcmin_maj).toFixed(1)}′` : '—'],
|
||||||
|
['Magnitude', target.mag_v?.toFixed(1) ?? '—'],
|
||||||
|
['Surface brightness', target.surface_brightness ? `${target.surface_brightness.toFixed(1)} mag/arcsec²` : '—'],
|
||||||
|
['Hubble type', target.hubble_type ?? '—'],
|
||||||
|
['FOV fill', target.fov_fill_pct != null ? `${target.fov_fill_pct.toFixed(0)}%` : '—'],
|
||||||
|
['Guide stars', target.guide_star_density ?? '—'],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, width: 140 }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{value as string}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* Guiding context badge */}
|
||||||
|
{target.guide_star_density && (() => {
|
||||||
|
const density = target.guide_star_density;
|
||||||
|
const msgs: Record<string, { color: string; text: string; note: string }> = {
|
||||||
|
sparse: { color: 'var(--warn)', text: 'Sparse guide field', note: 'OAG may struggle — consider a bright guide star or off-axis offset' },
|
||||||
|
moderate: { color: 'var(--blue)', text: 'Moderate guide field', note: 'OAG should work with careful star selection' },
|
||||||
|
rich: { color: 'var(--good)', text: 'Rich guide field', note: 'Plenty of guide stars for OAG or guidescope' },
|
||||||
|
};
|
||||||
|
const m = msgs[density];
|
||||||
|
if (!m) return null;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: `1px solid ${m.color}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '6px 10px',
|
||||||
|
marginBottom: 12,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: m.color, fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
◉ {m.text}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-mid)', fontSize: 11, fontStyle: 'italic' }}>{m.note}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<AladinEmbed
|
||||||
|
ra={target.ra_deg}
|
||||||
|
dec={target.dec_deg}
|
||||||
|
mosaic={target.mosaic_flag ? { panels_w: target.mosaic_panels_w, panels_h: target.mosaic_panels_h } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 3: Filters & Workflow */}
|
||||||
|
{tab === 2 && (
|
||||||
|
<div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 20 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['Filter', 'Suitability', 'Reason', 'Sub exp', 'Frames', 'Total time', 'Sessions'].map(h => (
|
||||||
|
<th key={h} style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, textAlign: 'left', paddingBottom: 6, fontWeight: 500, letterSpacing: '0.06em', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtersData?.recommendations?.map(rec => (
|
||||||
|
<tr key={rec.filter_id} style={{ borderBottom: '1px solid var(--border)', opacity: rec.suitability === 'unsuitable' ? 0.4 : 1 }}>
|
||||||
|
<td style={{ padding: '6px 8px 6px 0' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFilter(rec.filter_id)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<span className={`filter-pill ${rec.filter_id}`}>{rec.filter_id.toUpperCase()}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', padding: '6px 8px',
|
||||||
|
color: rec.suitability === 'ideal' ? 'var(--good)' : rec.suitability === 'good' ? 'var(--teal)' : rec.suitability === 'marginal' ? 'var(--warn)' : 'var(--muted)'
|
||||||
|
}}>
|
||||||
|
{rec.suitability}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||||
|
{rec.reason}
|
||||||
|
{rec.warning && <div style={{ color: 'var(--warn)', fontSize: 10 }}>⚠ {rec.warning}</div>}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||||
|
{rec.exposure_sec ? `${rec.exposure_sec / 60}min` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-hi)', padding: '6px 8px' }}>
|
||||||
|
{rec.frames_needed ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--amber)', padding: '6px 8px' }}>
|
||||||
|
{rec.est_integration_hours ? `${rec.est_integration_hours}h` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text-mid)', padding: '6px 8px' }}>
|
||||||
|
{rec.sessions_needed ? `×${rec.sessions_needed}` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{workflowData && <WorkflowCard workflow={workflowData} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 5: Yearly */}
|
||||||
|
{tab === 4 && (
|
||||||
|
<div>
|
||||||
|
{yearlyData?.points ? (
|
||||||
|
<YearlyVisibility points={yearlyData.points} />
|
||||||
|
) : (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
Loading yearly data…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab 4: Log & Gallery */}
|
||||||
|
{tab === 3 && (
|
||||||
|
<div>
|
||||||
|
{/* Filter breakdown + planning notes row */}
|
||||||
|
{((logData?.filter_breakdown && logData.filter_breakdown.length > 0) || true) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 16 }}>
|
||||||
|
{/* Filter accumulation */}
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 12px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Integration by Filter (keepers only)
|
||||||
|
</div>
|
||||||
|
{(logData?.filter_breakdown ?? []).length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>No keeper sessions yet</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{(logData?.filter_breakdown ?? []).map(fb => (
|
||||||
|
<div key={fb.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className={`filter-pill ${fb.filter_id}`} style={{ minWidth: 60 }}>
|
||||||
|
{fb.filter_id.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', fontWeight: 700 }}>
|
||||||
|
{(fb.total_min / 60).toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
× {fb.sessions} session{fb.sessions !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Planning notes */}
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '10px 12px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 8 }}>
|
||||||
|
Planning Notes
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={notes ?? notesData?.notes ?? ''}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
onBlur={e => { if (notes !== null) saveNotesMutation.mutate(e.target.value); }}
|
||||||
|
placeholder="Field notes, guide star position, framing tips…"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 72,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 3,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '6px 8px',
|
||||||
|
resize: 'vertical',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{saveNotesMutation.isSuccess && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--good)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>✓ saved</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main log + gallery */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||||
|
<div>
|
||||||
|
<LogForm catalogId={target.id} />
|
||||||
|
<SessionList
|
||||||
|
entries={logData?.items ?? []}
|
||||||
|
totalMin={logData?.total_integration_min}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
<ImageUploadZone catalogId={target.id} />
|
||||||
|
</div>
|
||||||
|
<LightboxView images={galleryData?.items ?? []} catalogId={target.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import type { Target } from '../../api/types';
|
||||||
|
import TypeBadge from './TypeBadge';
|
||||||
|
import VisBar from './VisBar';
|
||||||
|
import { useTonight } from '../../hooks/useTonight';
|
||||||
|
import { useHorizon } from '../../hooks/useHorizon';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../../api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
target: Target;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display labels for filter IDs
|
||||||
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
|
sv220: 'HaOIII',
|
||||||
|
c2: 'SII/OIII',
|
||||||
|
sv260: 'LP',
|
||||||
|
uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recommended integration hours per object type and filter (from CLAUDE.md §16.3)
|
||||||
|
const GOAL_HOURS: Record<string, Record<string, number>> = {
|
||||||
|
galaxy: { uvir: 4.0, sv260: 6.0 },
|
||||||
|
emission_nebula: { sv220: 3.0, c2: 4.0, sv260: 8.0, uvir: 12.0 },
|
||||||
|
reflection_nebula: { uvir: 3.0, sv260: 5.0 },
|
||||||
|
planetary_nebula: { sv220: 2.0, c2: 3.0 },
|
||||||
|
snr: { sv220: 5.0, c2: 6.0 },
|
||||||
|
open_cluster: { uvir: 1.0 },
|
||||||
|
globular_cluster: { uvir: 1.5 },
|
||||||
|
dark_nebula: { uvir: 3.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getGoalMin(obj_type: string, recommended_filter?: string): number | null {
|
||||||
|
const byType = GOAL_HOURS[obj_type];
|
||||||
|
if (!byType) return null;
|
||||||
|
const filter = recommended_filter ?? Object.keys(byType)[0];
|
||||||
|
const h = byType[filter] ?? Object.values(byType)[0];
|
||||||
|
return h ? h * 60 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationProgress({ obj_type, recommended_filter, total_min }: {
|
||||||
|
obj_type: string; recommended_filter?: string; total_min?: number;
|
||||||
|
}) {
|
||||||
|
const goalMin = getGoalMin(obj_type, recommended_filter);
|
||||||
|
if (!goalMin || total_min == null) return null;
|
||||||
|
const pct = Math.min((total_min / goalMin) * 100, 100);
|
||||||
|
const color = pct >= 100 ? 'var(--good)' : pct >= 60 ? 'var(--warn)' : 'var(--danger)';
|
||||||
|
return (
|
||||||
|
<div style={{ width: 60 }}>
|
||||||
|
<div style={{ height: 4, background: 'var(--bg-hover)', borderRadius: 2, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${pct}%`, height: '100%', background: color, borderRadius: 2, transition: 'width 0.3s' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: pct >= 100 ? color : 'var(--text-lo)', marginTop: 2, textAlign: 'right' }}>
|
||||||
|
{pct >= 100 ? '✓' : `${Math.round(pct)}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAlt(alt?: number): { text: string; color: string } {
|
||||||
|
if (alt == null) return { text: '—', color: 'var(--text-lo)' };
|
||||||
|
const color = alt >= 30 ? 'var(--good)' : alt >= 15 ? 'var(--warn)' : 'var(--danger)';
|
||||||
|
return { text: `${alt.toFixed(0)}°`, color };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtIntegration(min?: number): string {
|
||||||
|
if (!min) return '—';
|
||||||
|
if (min < 60) return `${min}m`;
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return m > 0 ? `${h}h${m}m` : `${h}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function difficultyDots(d?: number) {
|
||||||
|
if (!d) return null;
|
||||||
|
return (
|
||||||
|
<span style={{ letterSpacing: 2 }}>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<span key={i} style={{ color: i < d ? 'var(--amber)' : 'var(--muted)', fontSize: 8 }}>●</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TargetRow({ target, expanded, onToggle }: Props) {
|
||||||
|
const { data: tonight } = useTonight();
|
||||||
|
const { data: horizonData } = useHorizon();
|
||||||
|
const alt = fmtAlt(target.max_alt_deg);
|
||||||
|
|
||||||
|
// Fetch visibility curve to check if target is ever above custom horizon
|
||||||
|
const { data: curveData } = useQuery({
|
||||||
|
queryKey: ['curve', target.id],
|
||||||
|
queryFn: () => api.targets.curve(target.id),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
enabled: !target.is_visible_tonight ? false : true, // Only fetch if potentially visible
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if visible above custom horizon by examining the curve
|
||||||
|
let invisible = !target.is_visible_tonight;
|
||||||
|
if (!invisible && horizonData?.points?.length) {
|
||||||
|
// If we have horizon data and a curve, check if any point is above custom horizon
|
||||||
|
const hasPointAboveHorizon = curveData?.curve?.some(pt => pt.above_custom_horizon);
|
||||||
|
if (curveData && !hasPointAboveHorizon) {
|
||||||
|
invisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterLabel = target.recommended_filter ? (FILTER_LABELS[target.recommended_filter] ?? target.recommended_filter.toUpperCase()) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: expanded ? 'var(--bg-hover)' : 'var(--bg-row)',
|
||||||
|
opacity: invisible ? 0.35 : 1,
|
||||||
|
fontStyle: invisible ? 'italic' : 'normal',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: '7px 8px 7px 12px', width: 44 }}>
|
||||||
|
<TypeBadge type={target.obj_type} />
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', minWidth: 160 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||||
|
{target.messier_num != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)', fontWeight: 700 }}>
|
||||||
|
M{target.messier_num}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: target.messier_num != null ? 'var(--text-mid)' : 'var(--text-hi)' }}>
|
||||||
|
{target.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{target.common_name && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{target.common_name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 80, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{target.size_arcmin_maj
|
||||||
|
? `${target.size_arcmin_maj.toFixed(1)}′`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 50 }}>
|
||||||
|
{target.fov_fill_pct != null && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: target.fov_fill_pct > 80 ? 'var(--good)' : target.fov_fill_pct > 40 ? 'var(--amber)' : 'var(--muted)',
|
||||||
|
}}>
|
||||||
|
{target.fov_fill_pct.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 50 }}>
|
||||||
|
{target.mosaic_flag && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--warn)' }}>
|
||||||
|
{target.mosaic_panels_w}×{target.mosaic_panels_h}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 42, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{target.mag_v?.toFixed(1) ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 32 }}>
|
||||||
|
{difficultyDots(target.difficulty)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 70 }}>
|
||||||
|
{filterLabel && (
|
||||||
|
<span className={`filter-pill ${target.recommended_filter}`}>
|
||||||
|
{filterLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 60 }}>
|
||||||
|
{target.max_alt_deg == null ? (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>not tonight</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: alt.color, fontWeight: 600 }}>
|
||||||
|
{alt.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 88 }}>
|
||||||
|
{tonight?.astro_dusk_utc && tonight?.astro_dawn_utc && (
|
||||||
|
<VisBar
|
||||||
|
dusk={tonight.astro_dusk_utc}
|
||||||
|
dawn={tonight.astro_dawn_utc}
|
||||||
|
rise={target.best_start_utc}
|
||||||
|
set={target.best_end_utc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 8px', width: 60 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: (target.total_integration_min ?? 0) > 0 ? 'var(--teal)' : 'var(--text-lo)',
|
||||||
|
}}>
|
||||||
|
{fmtIntegration(target.total_integration_min)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 12px 7px 4px', width: 68 }}>
|
||||||
|
<IntegrationProgress
|
||||||
|
obj_type={target.obj_type}
|
||||||
|
recommended_filter={target.recommended_filter}
|
||||||
|
total_min={target.total_integration_min}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const LABELS: Record<string, string> = {
|
||||||
|
galaxy: 'GX',
|
||||||
|
emission_nebula: 'EN',
|
||||||
|
planetary_nebula: 'PN',
|
||||||
|
snr: 'SNR',
|
||||||
|
globular_cluster: 'GC',
|
||||||
|
open_cluster: 'OC',
|
||||||
|
reflection_nebula: 'RN',
|
||||||
|
dark_nebula: 'DN',
|
||||||
|
nebula: 'NB',
|
||||||
|
galaxy_group: 'GG',
|
||||||
|
interacting_galaxy: 'IG',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TypeBadge({ type }: Props) {
|
||||||
|
const label = LABELS[type] ?? type.slice(0, 3).toUpperCase();
|
||||||
|
return (
|
||||||
|
<span className={`type-badge ${type.replace(/ /g, '_')}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
interface Props {
|
||||||
|
dusk: string;
|
||||||
|
dawn: string;
|
||||||
|
rise?: string;
|
||||||
|
set?: string;
|
||||||
|
bestStart?: string;
|
||||||
|
bestEnd?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutes(utc: string, refUtc: string): number {
|
||||||
|
return (new Date(utc).getTime() - new Date(refUtc).getTime()) / 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VisBar({
|
||||||
|
dusk, dawn, rise, set, bestStart, bestEnd, width = 80, height = 14,
|
||||||
|
}: Props) {
|
||||||
|
const totalMin = toMinutes(dawn, dusk);
|
||||||
|
if (totalMin <= 0) return <svg width={width} height={height} />;
|
||||||
|
|
||||||
|
const pct = (utc: string) => (toMinutes(utc, dusk) / totalMin) * width;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||||
|
{/* Background */}
|
||||||
|
<rect x={0} y={2} width={width} height={height - 4} rx={2} fill="var(--bg-deep)" />
|
||||||
|
|
||||||
|
{/* Rise → Set arc */}
|
||||||
|
{rise && set && (
|
||||||
|
<rect
|
||||||
|
x={Math.max(0, pct(rise))}
|
||||||
|
y={2}
|
||||||
|
width={Math.min(width, pct(set)) - Math.max(0, pct(rise))}
|
||||||
|
height={height - 4}
|
||||||
|
rx={2}
|
||||||
|
fill="var(--warn)"
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Best window */}
|
||||||
|
{bestStart && bestEnd && (
|
||||||
|
<rect
|
||||||
|
x={Math.max(0, pct(bestStart))}
|
||||||
|
y={2}
|
||||||
|
width={Math.min(width, pct(bestEnd)) - Math.max(0, pct(bestStart))}
|
||||||
|
height={height - 4}
|
||||||
|
rx={2}
|
||||||
|
fill="var(--good)"
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Now marker */}
|
||||||
|
<line
|
||||||
|
x1={pct(new Date().toISOString())}
|
||||||
|
y1={0}
|
||||||
|
x2={pct(new Date().toISOString())}
|
||||||
|
y2={height}
|
||||||
|
stroke="var(--amber)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
interface Props {
|
||||||
|
level?: 'warning' | 'critical';
|
||||||
|
temp?: number;
|
||||||
|
dewPoint?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DewAlert({ level, temp, dewPoint }: Props) {
|
||||||
|
if (!level) return null;
|
||||||
|
|
||||||
|
const margin = temp != null && dewPoint != null ? (temp - dewPoint).toFixed(1) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: level === 'critical' ? 'rgba(224,82,82,0.2)' : 'rgba(232,192,48,0.15)',
|
||||||
|
border: `1px solid ${level === 'critical' ? 'var(--danger)' : 'var(--warn)'}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '10px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: level === 'critical' ? 'var(--danger)' : 'var(--warn)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>⚠</span>
|
||||||
|
<span>
|
||||||
|
DEW POINT ALERT — {level === 'critical' ? 'CRITICAL' : 'WARNING'}
|
||||||
|
{margin && ` — Margin: ${margin}°C`}
|
||||||
|
{level === 'critical'
|
||||||
|
? ' — Condensation imminent. Protect optics immediately.'
|
||||||
|
: ' — Risk of dew forming. Enable dew heaters.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
interface Props {
|
||||||
|
status?: 'go' | 'marginal' | 'nogo' | null;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
go: { color: 'var(--good)', label: 'GO', bg: 'rgba(61,186,114,0.15)' },
|
||||||
|
marginal: { color: 'var(--warn)', label: 'MARGINAL', bg: 'rgba(232,192,48,0.15)' },
|
||||||
|
nogo: { color: 'var(--danger)', label: 'NO-GO', bg: 'rgba(224,82,82,0.15)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GoNogo({ status, compact }: Props) {
|
||||||
|
const cfg = status ? config[status] : null;
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: compact ? '2px 8px' : '6px 14px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: compact ? 10 : 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.1em',
|
||||||
|
}}>
|
||||||
|
UNKNOWN
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: compact ? '2px 8px' : '8px 18px',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: cfg.bg,
|
||||||
|
color: cfg.color,
|
||||||
|
border: `1px solid ${cfg.color}`,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: compact ? 10 : 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '0.15em',
|
||||||
|
}}>
|
||||||
|
{cfg.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { WeatherData } from '../../api/types';
|
||||||
|
import GoNogo from './GoNogo';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weather: WeatherData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEING_LABELS: Record<number, string> = {
|
||||||
|
1: '0.5″ (Excellent)', 2: '0.75″ (Good)', 3: '1.0″ (Good)',
|
||||||
|
4: '1.25″ (Average)', 5: '1.5″ (Average)', 6: '2.0″ (Poor)',
|
||||||
|
7: '2.5″ (Poor)', 8: '>3″ (Bad)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSP_LABELS: Record<number, string> = {
|
||||||
|
1: 'Excellent', 2: 'Good', 3: 'Good', 4: 'Average',
|
||||||
|
5: 'Average', 6: 'Poor', 7: 'Poor', 8: 'Bad',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CC_LABELS: Record<number, string> = {
|
||||||
|
1: '0–6% (Clear)', 2: '6–19%', 3: '19–31%', 4: '31–44%',
|
||||||
|
5: '44–56%', 6: '56–69%', 7: '69–81%', 8: '81–94%', 9: '94–100% (Overcast)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIND_DIRS: Record<string, string> = {
|
||||||
|
N: '↑N', NE: '↗NE', E: '→E', SE: '↘SE',
|
||||||
|
S: '↓S', SW: '↙SW', W: '←W', NW: '↖NW',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WeatherCard({ weather }: Props) {
|
||||||
|
const [showReasons, setShowReasons] = useState(false);
|
||||||
|
|
||||||
|
const margin = weather.temp_c != null && weather.dew_point_c != null
|
||||||
|
? weather.temp_c - weather.dew_point_c
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const windStr = weather.wind10m
|
||||||
|
? `${WIND_DIRS[weather.wind10m.direction] ?? weather.wind10m.direction} ${weather.wind10m.speed} m/s`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<GoNogo status={weather.go_nogo} />
|
||||||
|
{weather.go_nogo === 'marginal' && weather.go_nogo_reasons && weather.go_nogo_reasons.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReasons(v => !v)}
|
||||||
|
title="Why marginal?"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--warn)',
|
||||||
|
border: '1px solid var(--warn)', borderRadius: 3, padding: '1px 6px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
why?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showReasons && weather.go_nogo_reasons && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(232,192,48,0.08)', border: '1px solid var(--warn)',
|
||||||
|
borderRadius: 3, padding: '6px 10px', marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{weather.go_nogo_reasons.map((r, i) => (
|
||||||
|
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--warn)', marginBottom: 2 }}>
|
||||||
|
⚠ {r}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{([
|
||||||
|
['Temperature', weather.temp_c != null ? `${weather.temp_c.toFixed(1)}°C` : null],
|
||||||
|
['Humidity', weather.humidity_pct != null ? `${weather.humidity_pct.toFixed(0)}%` : null],
|
||||||
|
['Dew Point', weather.dew_point_c != null ? `${weather.dew_point_c.toFixed(1)}°C` : null],
|
||||||
|
['Dew Margin', margin != null ? `${margin.toFixed(1)}°C${margin < 4 ? ' ⚠' : ' ✓'}` : null],
|
||||||
|
['Cloud cover', weather.cloudcover ? CC_LABELS[weather.cloudcover] ?? `${weather.cloudcover}/9` : null],
|
||||||
|
['Seeing', weather.seeing ? SEEING_LABELS[weather.seeing] ?? `${weather.seeing}/8` : null],
|
||||||
|
['Transparency', weather.transparency ? TRANSP_LABELS[weather.transparency] ?? `${weather.transparency}/8` : null],
|
||||||
|
['Wind', windStr],
|
||||||
|
['Atm. stability', weather.lifted_index != null ? `LI ${weather.lifted_index}${weather.lifted_index < -2 ? ' (unstable)' : weather.lifted_index >= 2 ? ' (stable)' : ''}` : null],
|
||||||
|
] as [string, string | null][]).filter(([, v]) => v != null).map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
paddingBottom: 5,
|
||||||
|
width: '45%',
|
||||||
|
}}>{label}</td>
|
||||||
|
<td style={{
|
||||||
|
color: (label === 'Dew Margin' && margin != null && margin < 4) ? 'var(--danger)'
|
||||||
|
: (label === 'Atm. stability' && (weather.lifted_index ?? 0) < -2) ? 'var(--warn)'
|
||||||
|
: 'var(--text-hi)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'right',
|
||||||
|
}}>{value as string}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function useCalendar(months?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendar', months],
|
||||||
|
queryFn: () => api.calendar.get(months),
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCalendarDate(date: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['calendar-date', date],
|
||||||
|
queryFn: () => api.calendar.getDate(date),
|
||||||
|
enabled: !!date,
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function useHorizon() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['horizon'],
|
||||||
|
queryFn: () => api.horizon.get(),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { LogEntry } from '../api/types';
|
||||||
|
|
||||||
|
export function useLog(page?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['log', page],
|
||||||
|
queryFn: () => api.log.list(page),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetLog(catalogId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-log', catalogId],
|
||||||
|
queryFn: () => api.log.forTarget(catalogId),
|
||||||
|
enabled: !!catalogId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLog() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (entry: Omit<LogEntry, 'id' | 'created_at' | 'target_name' | 'target_common_name' | 'target_obj_type'>) =>
|
||||||
|
api.log.create(entry),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['log'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateLog() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<LogEntry> }) =>
|
||||||
|
api.log.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['log'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteLog() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => api.log.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['log'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['target-log'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function useStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['stats'],
|
||||||
|
queryFn: () => api.stats.get(),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api, type TargetsParams } from '../api';
|
||||||
|
|
||||||
|
export function useTargets(params: TargetsParams = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['targets', params],
|
||||||
|
queryFn: () => api.targets.list(params),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTarget(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target', id],
|
||||||
|
queryFn: () => api.targets.get(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetVisibility(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-visibility', id],
|
||||||
|
queryFn: () => api.targets.visibility(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetCurve(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-curve', id],
|
||||||
|
queryFn: () => api.targets.curve(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetFilters(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-filters', id],
|
||||||
|
queryFn: () => api.targets.filters(id),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetWorkflow(id: string, filterId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-workflow', id, filterId],
|
||||||
|
queryFn: () => api.targets.workflow(id, filterId),
|
||||||
|
enabled: !!id && !!filterId,
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTargetYearly(id: string, enabled = false) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['target-yearly', id],
|
||||||
|
queryFn: () => api.targets.yearly(id),
|
||||||
|
enabled: !!id && enabled,
|
||||||
|
staleTime: 60 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function useTonight() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tonight'],
|
||||||
|
queryFn: () => api.tonight.get(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export function useWeather() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['weather'],
|
||||||
|
queryFn: () => api.weather.get(),
|
||||||
|
staleTime: 15 * 60_000,
|
||||||
|
refetchInterval: 15 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useForecast() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['forecast'],
|
||||||
|
queryFn: () => api.weather.forecast(),
|
||||||
|
staleTime: 3 * 60 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useCalendar, useCalendarDate } from '../hooks/useCalendar';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||||
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval } from 'date-fns';
|
||||||
|
|
||||||
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
|
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtTime(utc?: string): string {
|
||||||
|
if (!utc) return '—';
|
||||||
|
return new Date(utc).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalDay = {
|
||||||
|
date: string;
|
||||||
|
moon_illumination?: number;
|
||||||
|
max_usable_min?: number;
|
||||||
|
avg_max_alt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 12-month horizontal lunar cycle timeline */
|
||||||
|
function NewMoonTimeline({ days }: { days: CalDay[] }) {
|
||||||
|
const { data: nmData } = useQuery({
|
||||||
|
queryKey: ['new-moon-windows'],
|
||||||
|
queryFn: () => api.calendar.getNewMoonWindows(),
|
||||||
|
staleTime: 24 * 60 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a map of new moon date → top targets
|
||||||
|
const nmTargetMap = new Map<string, { id: string; name: string; common_name?: string; max_alt_deg?: number; recommended_filter?: string }[]>();
|
||||||
|
for (const w of nmData?.windows ?? []) {
|
||||||
|
nmTargetMap.set(w.date, w.top_targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days.length === 0) {
|
||||||
|
return <div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk into lunar cycles: whenever illumination crosses a local minimum (new moon ≈ < 5%)
|
||||||
|
// For display, just show month-by-month rows with a continuous illumination bar.
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
// Group days into months
|
||||||
|
const monthMap = new Map<string, CalDay[]>();
|
||||||
|
for (const d of days) {
|
||||||
|
const month = d.date.slice(0, 7); // "2026-04"
|
||||||
|
if (!monthMap.has(month)) monthMap.set(month, []);
|
||||||
|
monthMap.get(month)!.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12, fontSize: 10, fontFamily: 'var(--font-mono)', color: 'var(--text-lo)' }}>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(61,186,114,0.35)', borderRadius: 2, marginRight: 4 }} />{'< 20% moon — prime narrowband'}</span>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(232,131,42,0.3)', borderRadius: 2, marginRight: 4 }} />{'20–50% — broadband OK'}</span>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 12, height: 8, background: 'rgba(224,82,82,0.25)', borderRadius: 2, marginRight: 4 }} />{'> 50% — challenging'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Array.from(monthMap.entries()).map(([month, mDays]) => {
|
||||||
|
// Find new moon dates in this month (local illumination minimum, < 5%)
|
||||||
|
const newMoonDates = mDays.filter((d, i) => {
|
||||||
|
const illum = d.moon_illumination ?? 0.5;
|
||||||
|
const prev = mDays[i - 1]?.moon_illumination ?? illum;
|
||||||
|
const next = mDays[i + 1]?.moon_illumination ?? illum;
|
||||||
|
return illum < 0.05 && illum <= prev && illum <= next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Days in this month ordered
|
||||||
|
const sorted = [...mDays].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
const daysInMonth = sorted.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={month} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
width: 80,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{format(new Date(month + '-01'), 'MMM yyyy')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Moon illumination bar */}
|
||||||
|
<div style={{ flex: 1, position: 'relative', height: 28, display: 'flex' }}>
|
||||||
|
{sorted.map(d => {
|
||||||
|
const illum = d.moon_illumination ?? 0;
|
||||||
|
const isToday = d.date === today;
|
||||||
|
const isNewMoon = newMoonDates.some(nm => nm.date === d.date);
|
||||||
|
let bg: string;
|
||||||
|
if (illum < 0.2) bg = 'rgba(61,186,114,0.35)';
|
||||||
|
else if (illum < 0.5) bg = 'rgba(232,131,42,0.30)';
|
||||||
|
else bg = 'rgba(224,82,82,0.25)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={d.date} title={`${d.date} — ${Math.round(illum * 100)}%`}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: bg,
|
||||||
|
borderLeft: isToday ? '2px solid var(--amber)' : '1px solid transparent',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{/* New moon marker */}
|
||||||
|
{isNewMoon && (
|
||||||
|
<div style={{
|
||||||
|
width: 4, height: 4,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--text-hi)',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{/* Full moon marker */}
|
||||||
|
{illum > 0.95 && (
|
||||||
|
<div style={{
|
||||||
|
width: 4, height: 4,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.5)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.7)',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 2,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{/* Illumination curve as height */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0, left: 0, right: 0,
|
||||||
|
height: `${illum * 100}%`,
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Day count axis: show 1, 8, 15, 22, 28 */}
|
||||||
|
{sorted.filter((_, i) => [0, 7, 14, 21, 27].includes(i)).map(d => {
|
||||||
|
const idx = sorted.indexOf(d);
|
||||||
|
const pct = (idx / daysInMonth) * 100;
|
||||||
|
return (
|
||||||
|
<div key={`lbl-${d.date}`} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${pct}%`,
|
||||||
|
bottom: -13,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 8,
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}>
|
||||||
|
{parseInt(d.date.slice(8))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New moon windows: date + top 3 emission targets */}
|
||||||
|
<div style={{ width: 200, flexShrink: 0 }}>
|
||||||
|
{newMoonDates.map(d => {
|
||||||
|
const targets = nmTargetMap.get(d.date) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={d.date} style={{ marginBottom: 4 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)', marginBottom: 2 }}>
|
||||||
|
● {d.date.slice(5)}
|
||||||
|
</div>
|
||||||
|
{targets.map(t => (
|
||||||
|
<div key={t.id} style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingLeft: 8 }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
{t.max_alt_deg != null && (
|
||||||
|
<span style={{ color: 'var(--text-lo)', marginLeft: 4 }}>{t.max_alt_deg.toFixed(0)}°</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
● = new moon · vertical line = today · hover a day for date + illumination %
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Calendar() {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const [newMoonView, setNewMoonView] = useState(false);
|
||||||
|
const { data: calData3 } = useCalendar(3);
|
||||||
|
const { data: calData12 } = useCalendar(12);
|
||||||
|
const { data: dateData } = useCalendarDate(selectedDate ?? '');
|
||||||
|
|
||||||
|
const calData = newMoonView ? calData12 : calData3;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const dayMap = new Map<string, CalDay>(
|
||||||
|
(calData?.days ?? []).map(d => [d.date, d])
|
||||||
|
);
|
||||||
|
|
||||||
|
const months = [today, new Date(today.getFullYear(), today.getMonth() + 1), new Date(today.getFullYear(), today.getMonth() + 2)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Calendar</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => { setNewMoonView(v => !v); setSelectedDate(null); }}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${newMoonView ? 'var(--amber)' : 'var(--border)'}`,
|
||||||
|
background: newMoonView ? 'var(--amber-glow)' : 'var(--bg-panel)',
|
||||||
|
color: newMoonView ? 'var(--amber)' : 'var(--text-mid)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
● New Moon View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newMoonView ? (
|
||||||
|
<NewMoonTimeline days={calData12?.days ?? []} />
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: selectedDate ? '2fr 1fr' : '1fr', gap: 20 }}>
|
||||||
|
<div>
|
||||||
|
{months.map(month => {
|
||||||
|
const monthStart = startOfMonth(month);
|
||||||
|
const monthEnd = endOfMonth(month);
|
||||||
|
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||||
|
const firstDow = monthStart.getDay();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={month.toISOString()} style={{ marginBottom: 28 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 10, color: 'var(--text-hi)' }}>
|
||||||
|
{format(month, 'MMMM yyyy')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
|
||||||
|
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||||
|
<div key={d} style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--text-lo)', paddingBottom: 4 }}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: (firstDow + 6) % 7 }).map((_, i) => (
|
||||||
|
<div key={`e${i}`} />
|
||||||
|
))}
|
||||||
|
{days.map(day => {
|
||||||
|
const dateStr = format(day, 'yyyy-MM-dd');
|
||||||
|
const info = dayMap.get(dateStr);
|
||||||
|
const usable = info?.max_usable_min ?? 0;
|
||||||
|
const moonIllum = info?.moon_illumination;
|
||||||
|
const isSelected = selectedDate === dateStr;
|
||||||
|
const isToday = dateStr === format(today, 'yyyy-MM-dd');
|
||||||
|
const isNarrowbandNight = moonIllum != null && moonIllum < 0.2;
|
||||||
|
|
||||||
|
let bg = 'var(--bg-panel)';
|
||||||
|
if (usable > 240) bg = 'rgba(42,184,160,0.2)';
|
||||||
|
else if (usable > 60) bg = 'rgba(232,131,42,0.15)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={dateStr}
|
||||||
|
onClick={() => setSelectedDate(isSelected ? null : dateStr)}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? 'var(--amber-glow)' : bg,
|
||||||
|
border: `1px solid ${isSelected ? 'var(--amber)' : isNarrowbandNight ? 'var(--amber)' : isToday ? 'var(--text-lo)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minHeight: 56,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: isToday ? 'var(--amber)' : 'var(--text-mid)',
|
||||||
|
marginBottom: 2,
|
||||||
|
fontWeight: isToday ? 700 : 400,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span>{format(day, 'd')}</span>
|
||||||
|
{moonIllum != null && (
|
||||||
|
<MoonPhaseIcon illumination={moonIllum} size={12} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{usable > 0 && (
|
||||||
|
<div style={{
|
||||||
|
height: 3,
|
||||||
|
background: usable > 240 ? 'var(--teal)' : 'var(--amber)',
|
||||||
|
borderRadius: 2,
|
||||||
|
width: `${Math.min(100, (usable / 480) * 100)}%`,
|
||||||
|
marginBottom: 2,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{moonIllum != null && (
|
||||||
|
<div style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 9,
|
||||||
|
color: isNarrowbandNight ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
}}>
|
||||||
|
{Math.round(moonIllum * 100)}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Side panel for selected date */}
|
||||||
|
{selectedDate && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: 16, height: 'fit-content', position: 'sticky', top: 8 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 16, fontWeight: 700, marginBottom: 12 }}>
|
||||||
|
{selectedDate}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moon + dark window */}
|
||||||
|
{(() => {
|
||||||
|
const illum = dateData?.moon_illumination ?? dayMap.get(selectedDate)?.moon_illumination;
|
||||||
|
const info = dayMap.get(selectedDate);
|
||||||
|
return illum != null ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<MoonPhaseIcon illumination={illum} size={28} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text-hi)', fontWeight: 700 }}>
|
||||||
|
{Math.round(illum * 100)}%
|
||||||
|
</div>
|
||||||
|
{illum < 0.2 && (
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--amber)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
★ Prime narrowband night
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{info?.max_usable_min ? (
|
||||||
|
<div style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--teal)' }}>
|
||||||
|
{Math.floor(info.max_usable_min / 60)}h {info.max_usable_min % 60}m dark
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Tonight summary */}
|
||||||
|
{dateData?.tonight && (
|
||||||
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 10px', marginBottom: 10 }}>
|
||||||
|
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Dusk', dateData.tonight.astro_dusk_utc ? fmtTime(dateData.tonight.astro_dusk_utc) : '—'],
|
||||||
|
['Dawn', dateData.tonight.astro_dawn_utc ? fmtTime(dateData.tonight.astro_dawn_utc) : '—'],
|
||||||
|
['True dark', dateData.tonight.true_dark_start_utc && dateData.tonight.true_dark_end_utc
|
||||||
|
? `${fmtTime(dateData.tonight.true_dark_start_utc)} – ${fmtTime(dateData.tonight.true_dark_end_utc)}`
|
||||||
|
: '—'],
|
||||||
|
['Moon', dateData.tonight.moon_phase_name ?? '—'],
|
||||||
|
].map(([label, val]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', paddingBottom: 3, width: 70 }}>{label}</td>
|
||||||
|
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>{val}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weather */}
|
||||||
|
{dateData?.weather && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10, padding: '6px 10px', background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4 }}>
|
||||||
|
{dateData.weather.go_nogo && (
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: dateData.weather.go_nogo === 'go' ? 'var(--good)' : dateData.weather.go_nogo === 'marginal' ? 'var(--warn)' : 'var(--danger)',
|
||||||
|
}}>
|
||||||
|
{dateData.weather.go_nogo.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateData.weather.temp_c != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-mid)' }}>
|
||||||
|
{dateData.weather.temp_c.toFixed(0)}°C
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateData.weather.cloudcover != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
Cloud {dateData.weather.cloudcover}/9
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dateData.weather.seeing != null && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
Seeing {dateData.weather.seeing}/8
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginBottom: 8, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
|
||||||
|
Top Targets
|
||||||
|
</div>
|
||||||
|
{!dateData && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No precomputed data for this date.</div>
|
||||||
|
)}
|
||||||
|
{dateData?.top_targets?.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontSize: 12 }}>No visible targets.</div>
|
||||||
|
)}
|
||||||
|
{dateData?.top_targets?.map((t, i) => (
|
||||||
|
<div key={t.id} style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '5px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10, width: 16 }}>{i + 1}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)' }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-lo)' }}>{t.name}</div>
|
||||||
|
</div>
|
||||||
|
{t.recommended_filter && (
|
||||||
|
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||||
|
{FILTER_LABELS[t.recommended_filter] ?? ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--good)' }}>
|
||||||
|
{t.max_alt_deg?.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTonight } from '../hooks/useTonight';
|
||||||
|
import { useWeather, useForecast } from '../hooks/useWeather';
|
||||||
|
import { useTargets } from '../hooks/useTargets';
|
||||||
|
import { useStats } from '../hooks/useStats';
|
||||||
|
import GoNogo from '../components/weather/GoNogo';
|
||||||
|
import DewAlert from '../components/weather/DewAlert';
|
||||||
|
import MoonPhaseIcon from '../components/sky/MoonPhaseIcon';
|
||||||
|
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||||
|
import type { Target } from '../api/types';
|
||||||
|
|
||||||
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
|
sv220: 'HaOIII', c2: 'SII/OIII', sv260: 'LP', uvir: 'UV/IR',
|
||||||
|
};
|
||||||
|
const CC_LABELS: Record<number, string> = {
|
||||||
|
1: 'Clear', 2: 'Clear', 3: 'Mostly clear', 4: 'Partly cloudy',
|
||||||
|
5: 'Partly cloudy', 6: 'Cloudy', 7: 'Mostly cloudy', 8: 'Overcast', 9: 'Overcast',
|
||||||
|
};
|
||||||
|
const CC_COLOR = (n: number) => n <= 2 ? 'var(--good)' : n <= 4 ? 'var(--teal)' : n <= 6 ? 'var(--warn)' : 'var(--danger)';
|
||||||
|
|
||||||
|
function fmtTime(utc?: string): string {
|
||||||
|
if (!utc) return '—';
|
||||||
|
return new Date(utc).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||||
|
}
|
||||||
|
function fmtDuration(min?: number): string {
|
||||||
|
if (!min) return '—';
|
||||||
|
const h = Math.floor(min / 60);
|
||||||
|
const m = min % 60;
|
||||||
|
return `${h}h ${m < 10 ? '0' : ''}${m}m`;
|
||||||
|
}
|
||||||
|
function fmtIntTotal(min: number): string {
|
||||||
|
if (min < 60) return `${min} min`;
|
||||||
|
const h = (min / 60).toFixed(1);
|
||||||
|
return `${h} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: tonight } = useTonight();
|
||||||
|
const { data: weather } = useWeather();
|
||||||
|
const { data: forecast } = useForecast();
|
||||||
|
const { data: targets } = useTargets({ tonight: true, limit: 5 });
|
||||||
|
const { data: stats } = useStats();
|
||||||
|
const [expandedTarget, setExpandedTarget] = useState<Target | null>(null);
|
||||||
|
|
||||||
|
const moonPct = tonight?.moon_illumination != null
|
||||||
|
? `${Math.round(tonight.moon_illumination * 100)}%`
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
// Next 4 forecast slots for mini weather bar (3h each = 12h ahead)
|
||||||
|
const slots = (forecast as { dataseries?: { cloudcover?: number; seeing?: number; timepoint?: number }[] })?.dataseries?.slice(0, 8) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, color: 'var(--text-hi)', letterSpacing: '0.04em' }}>
|
||||||
|
Dashboard
|
||||||
|
</h1>
|
||||||
|
{tonight?.date && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-lo)' }}>
|
||||||
|
{tonight.date}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dew alert banner */}
|
||||||
|
{weather?.dew_alert && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<DewAlert level={weather.dew_alert} temp={weather.temp_c} dewPoint={weather.dew_point_c} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stat cards row */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 }}>
|
||||||
|
{/* Go/No-go */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Tonight</div>
|
||||||
|
<GoNogo status={weather?.go_nogo} />
|
||||||
|
{weather?.temp_c != null && (
|
||||||
|
<div style={{ marginTop: 8, fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{weather.temp_c.toFixed(1)}°C · {weather.humidity_pct?.toFixed(0)}% RH
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moon */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Moon</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{tonight?.moon_illumination != null && <MoonPhaseIcon illumination={tonight.moon_illumination} size={32} />}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 22, fontWeight: 600, color: 'var(--text-hi)', lineHeight: 1 }}>{moonPct}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 3 }}>{tonight?.moon_phase_name ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* True dark */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>True Dark</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--teal)', lineHeight: 1 }}>
|
||||||
|
{fmtDuration(tonight?.true_dark_minutes)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
|
{tonight?.true_dark_start_utc
|
||||||
|
? `${fmtTime(tonight.true_dark_start_utc)} – ${fmtTime(tonight.true_dark_end_utc)}`
|
||||||
|
: 'No full dark tonight'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats summary */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 8 }}>Logbook</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 600, color: 'var(--amber)', lineHeight: 1 }}>
|
||||||
|
{stats ? fmtIntTotal(stats.total_integration_min) : '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
|
{stats ? `${stats.total_sessions} sessions · ${stats.objects_with_keeper} keepers` : 'No data yet'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tonight timing + top targets + forecast */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr 1fr', gap: 16, marginBottom: 20 }}>
|
||||||
|
|
||||||
|
{/* Tonight timing */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Tonight's Window</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Dusk', fmtTime(tonight?.astro_dusk_utc)],
|
||||||
|
['Dawn', fmtTime(tonight?.astro_dawn_utc)],
|
||||||
|
['Moon rise', fmtTime(tonight?.moon_rise_utc)],
|
||||||
|
['Moon set', fmtTime(tonight?.moon_set_utc)],
|
||||||
|
['Dark start', fmtTime(tonight?.true_dark_start_utc)],
|
||||||
|
['Dark end', fmtTime(tonight?.true_dark_end_utc)],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, paddingBottom: 4, paddingRight: 8 }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 11, textAlign: 'right' }}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top targets */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>Top Targets Tonight</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
{!targets?.items?.length && (
|
||||||
|
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
Catalog loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{targets?.items?.map((t, i) => (
|
||||||
|
<div key={t.id}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedTarget(expandedTarget?.id === t.id ? null : t)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 14px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: expandedTarget?.id === t.id ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 14 }}>{i + 1}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--amber)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
|
{t.name} · {t.usable_min ? `${t.usable_min}min` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: (t.max_alt_deg ?? 0) >= 30 ? 'var(--good)' : 'var(--warn)' }}>
|
||||||
|
{t.max_alt_deg?.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
{t.recommended_filter && (
|
||||||
|
<span className={`filter-pill ${t.recommended_filter}`} style={{ fontSize: 9 }}>
|
||||||
|
{FILTER_LABELS[t.recommended_filter] ?? t.recommended_filter.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expandedTarget?.id === t.id && <DetailDrawer target={t} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forecast mini bars */}
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 10 }}>24h Forecast</div>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '12px 14px' }}>
|
||||||
|
{slots.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>No forecast data</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{slots.map((slot, i) => {
|
||||||
|
const cc = slot.cloudcover ?? 5;
|
||||||
|
const seeing = slot.seeing ?? 5;
|
||||||
|
const hoursAhead = (i + 1) * 3;
|
||||||
|
const label = `+${hoursAhead}h`;
|
||||||
|
return (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 28 }}>{label}</span>
|
||||||
|
<div style={{ flex: 1, background: 'var(--bg-deep)', borderRadius: 2, height: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${((9 - cc) / 8) * 100}%`,
|
||||||
|
background: CC_COLOR(cc),
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'width 0.3s',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: CC_COLOR(cc), width: 80 }}>
|
||||||
|
{CC_LABELS[cc] ?? '—'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: seeing <= 3 ? 'var(--good)' : seeing <= 5 ? 'var(--warn)' : 'var(--danger)', width: 24, textAlign: 'right' }}>
|
||||||
|
{['', '0.5″', '0.75″', '1.0″', '1.25″', '1.5″', '2.0″', '2.5″', '>3″'][seeing] ?? '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { GalleryImage } from '../api/types';
|
||||||
|
|
||||||
|
type GalleryImageWithTarget = GalleryImage & {
|
||||||
|
target_name?: string;
|
||||||
|
target_common_name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString('fr-FR', {
|
||||||
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Gallery() {
|
||||||
|
const [lightbox, setLightbox] = useState<GalleryImageWithTarget | null>(null);
|
||||||
|
const [filterTarget, setFilterTarget] = useState('');
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['gallery-all'],
|
||||||
|
queryFn: () => api.gallery.listAll(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const images = data?.items ?? [];
|
||||||
|
|
||||||
|
// Unique targets for filter
|
||||||
|
const targets = Array.from(
|
||||||
|
new Map(images.map(img => [img.catalog_id, img.target_common_name ?? img.target_name ?? img.catalog_id])).entries()
|
||||||
|
).sort((a, b) => a[1].localeCompare(b[1]));
|
||||||
|
|
||||||
|
const filtered = filterTarget
|
||||||
|
? images.filter(img => img.catalog_id === filterTarget)
|
||||||
|
: images;
|
||||||
|
|
||||||
|
// Group by target
|
||||||
|
const grouped: Record<string, GalleryImageWithTarget[]> = {};
|
||||||
|
for (const img of filtered) {
|
||||||
|
const key = img.catalog_id;
|
||||||
|
if (!grouped[key]) grouped[key] = [];
|
||||||
|
grouped[key].push(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 24 }}>
|
||||||
|
<h1 style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}>
|
||||||
|
Gallery
|
||||||
|
</h1>
|
||||||
|
<span style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
{images.length} image{images.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{targets.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={filterTarget}
|
||||||
|
onChange={e => setFilterTarget(e.target.value)}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All targets</option>
|
||||||
|
{targets.map(([id, label]) => (
|
||||||
|
<option key={id} value={id}>{label} ({id})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
Loading images…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && images.length === 0 && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '40px 0',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
No images yet. Upload images from the Targets page → Log & Gallery tab.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.entries(grouped).map(([catalogId, imgs]) => {
|
||||||
|
const first = imgs[0];
|
||||||
|
const targetLabel = first.target_common_name ?? first.target_name ?? catalogId;
|
||||||
|
return (
|
||||||
|
<div key={catalogId} style={{ marginBottom: 32 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'baseline',
|
||||||
|
gap: 10,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-display)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
}}>
|
||||||
|
{targetLabel}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--amber)',
|
||||||
|
}}>
|
||||||
|
{catalogId}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)', marginLeft: 'auto' }}>
|
||||||
|
{imgs.length} image{imgs.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
{imgs.map(img => (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
onClick={() => setLightbox(img)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
transition: 'border-color 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--border-hi)')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||||
|
>
|
||||||
|
<div style={{ aspectRatio: '4/3', overflow: 'hidden' }}>
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={img.caption ?? img.filename}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '6px 8px' }}>
|
||||||
|
{img.caption && (
|
||||||
|
<div style={{
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginBottom: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{img.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 10 }}>
|
||||||
|
{fmtDate(img.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.94)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={lightbox.url}
|
||||||
|
alt={lightbox.caption ?? lightbox.filename}
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '85vh', borderRadius: 4, display: 'block' }}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{lightbox.target_common_name ?? lightbox.target_name ?? lightbox.catalog_id}
|
||||||
|
<span style={{ color: 'var(--amber)', fontFamily: 'var(--font-mono)', fontSize: 11, marginLeft: 8 }}>
|
||||||
|
{lightbox.catalog_id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{lightbox.caption && (
|
||||||
|
<div style={{ color: 'var(--text-mid)', fontFamily: 'var(--font-sans)', fontSize: 12, marginTop: 2 }}>
|
||||||
|
{lightbox.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11, marginTop: 2 }}>
|
||||||
|
{fmtDate(lightbox.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
api.gallery.delete(lightbox.id).then(() => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['gallery-all'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['gallery', lightbox.catalog_id] });
|
||||||
|
setLightbox(null);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
color: 'var(--danger)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '4px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -12,
|
||||||
|
right: -12,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
fontSize: 18,
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { HorizonPoint } from '../api/types';
|
||||||
|
|
||||||
|
function HorizonPolarChart({ points }: { points: HorizonPoint[] }) {
|
||||||
|
const size = 280;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const r = 120;
|
||||||
|
|
||||||
|
// Draw horizon profile as a polar chart
|
||||||
|
const pathParts = points.map((p, i) => {
|
||||||
|
const azRad = (p.az_deg - 90) * (Math.PI / 180);
|
||||||
|
const altFrac = 1 - p.alt_deg / 90;
|
||||||
|
const pr = altFrac * r;
|
||||||
|
const x = cx + pr * Math.cos(azRad);
|
||||||
|
const y = cy + pr * Math.sin(azRad);
|
||||||
|
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
if (pathParts.length) pathParts.push('Z');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||||
|
{/* Grid circles */}
|
||||||
|
{[15, 30, 45, 60, 75, 90].map(alt => {
|
||||||
|
const pr = (1 - alt / 90) * r;
|
||||||
|
return (
|
||||||
|
<g key={alt}>
|
||||||
|
<circle cx={cx} cy={cy} r={pr}
|
||||||
|
fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray={alt === 15 ? '4 4' : '2 4'}
|
||||||
|
/>
|
||||||
|
<text x={cx + 3} y={cy - pr - 2} fill="var(--text-lo)" fontSize={8} fontFamily="IBM Plex Mono">{alt}°</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Cardinal lines */}
|
||||||
|
{[0, 90, 180, 270].map(az => {
|
||||||
|
const azRad = (az - 90) * (Math.PI / 180);
|
||||||
|
return (
|
||||||
|
<line key={az}
|
||||||
|
x1={cx} y1={cy}
|
||||||
|
x2={cx + r * Math.cos(azRad)} y2={cy + r * Math.sin(azRad)}
|
||||||
|
stroke="var(--border)" strokeWidth={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Labels */}
|
||||||
|
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([label, az]) => {
|
||||||
|
const azRad = ((az as number) - 90) * (Math.PI / 180);
|
||||||
|
return (
|
||||||
|
<text key={label as string}
|
||||||
|
x={cx + (r + 14) * Math.cos(azRad)}
|
||||||
|
y={cy + (r + 14) * Math.sin(azRad) + 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="var(--text-lo)"
|
||||||
|
fontSize={10}
|
||||||
|
fontFamily="IBM Plex Mono"
|
||||||
|
>
|
||||||
|
{label as string}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Horizon profile */}
|
||||||
|
{pathParts.length > 0 && (
|
||||||
|
<path d={pathParts.join(' ')} fill="rgba(232,131,42,0.15)" stroke="var(--amber)" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data: horizonData } = useQuery({
|
||||||
|
queryKey: ['horizon'],
|
||||||
|
queryFn: () => api.horizon.get(),
|
||||||
|
});
|
||||||
|
const { data: health } = useQuery({
|
||||||
|
queryKey: ['health'],
|
||||||
|
queryFn: () => api.health.get(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const setHorizon = useMutation({
|
||||||
|
mutationFn: (points: HorizonPoint[]) => api.horizon.set(points),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['horizon'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [recomputing, setRecomputing] = useState(false);
|
||||||
|
const [recomputeMsg, setRecomputeMsg] = useState('');
|
||||||
|
const [rebuilding, setRebuilding] = useState(false);
|
||||||
|
const [rebuildResult, setRebuildResult] = useState<any>(null);
|
||||||
|
|
||||||
|
const triggerRecompute = async () => {
|
||||||
|
setRecomputing(true);
|
||||||
|
setRecomputeMsg('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/nightly/recompute', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
setRecomputeMsg('Nightly recompute started — takes ~20s. Reload the Targets page when done.');
|
||||||
|
} else {
|
||||||
|
setRecomputeMsg('Backend returned an error. Check logs.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRecomputeMsg('Error reaching backend.');
|
||||||
|
}
|
||||||
|
setRecomputing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerRebuild = async () => {
|
||||||
|
setRebuilding(true);
|
||||||
|
setRebuildResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/catalog/rebuild');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'success') {
|
||||||
|
setRebuildResult({
|
||||||
|
status: 'success',
|
||||||
|
message: `Rebuild complete: ${data.total} objects. Starting automatic nightly recompute...`,
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
// Invalidate queries to refresh the catalog
|
||||||
|
qc.invalidateQueries({ queryKey: ['targets'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['health'] });
|
||||||
|
// Wait for nightly recompute to complete (~30s) then reload
|
||||||
|
setTimeout(() => window.location.reload(), 4000);
|
||||||
|
} else {
|
||||||
|
setRebuildResult({ error: 'Unexpected response from server.' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await res.json().catch(() => ({}));
|
||||||
|
setRebuildResult({ error: errorData.error || 'Backend returned an error. Check logs.' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setRebuildResult({ error: `Error reaching backend: ${String(err)}` });
|
||||||
|
}
|
||||||
|
setRebuilding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHorizonCSV = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
const lines = text.trim().split('\n').slice(1); // skip header
|
||||||
|
const points: HorizonPoint[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const [az, alt] = line.split(',').map(Number);
|
||||||
|
if (!isNaN(az) && !isNaN(alt)) {
|
||||||
|
points.push({ az_deg: Math.round(az) % 360, alt_deg: Math.max(0, Math.min(90, alt)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (points.length === 360) {
|
||||||
|
setHorizon.mutate(points);
|
||||||
|
} else {
|
||||||
|
alert(`CSV must have exactly 360 rows (got ${points.length}). Format: az_deg,alt_deg`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetHorizon = () => {
|
||||||
|
const flat: HorizonPoint[] = Array.from({ length: 360 }, (_, i) => ({ az_deg: i, alt_deg: 15.0 }));
|
||||||
|
setHorizon.mutate(flat);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 24 }}>Settings</h1>
|
||||||
|
|
||||||
|
{/* Custom Horizon */}
|
||||||
|
<section style={{ marginBottom: 32 }}>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||||
|
Custom Horizon Profile
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
|
||||||
|
{horizonData?.points && <HorizonPolarChart points={horizonData.points} />}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-mid)', maxWidth: 300 }}>
|
||||||
|
Upload a CSV file with columns <code>az_deg,alt_deg</code>, one row per degree (360 rows total).
|
||||||
|
</div>
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
}}>
|
||||||
|
↑ Upload CSV
|
||||||
|
<input type="file" accept=".csv" style={{ display: 'none' }} onChange={handleHorizonCSV} />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={resetHorizon}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset to Flat 15°
|
||||||
|
</button>
|
||||||
|
{setHorizon.isSuccess && (
|
||||||
|
<div style={{ color: 'var(--good)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||||
|
✓ Horizon updated
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<section>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 16, marginBottom: 14, color: 'var(--text-hi)' }}>
|
||||||
|
App Info
|
||||||
|
</h2>
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px', maxWidth: 440 }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: 12 }}>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
['Status', health?.status ?? '—'],
|
||||||
|
['Catalog size', health?.catalog_size != null ? `${health.catalog_size.toLocaleString()} objects` : '—'],
|
||||||
|
['Last refreshed', health?.catalog_last_refreshed
|
||||||
|
? new Date(health.catalog_last_refreshed * 1000).toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
: '—'],
|
||||||
|
['DB size', health?.db_size_bytes != null
|
||||||
|
? health.db_size_bytes < 1024 * 1024
|
||||||
|
? `${Math.round(health.db_size_bytes / 1024)} KB`
|
||||||
|
: `${(health.db_size_bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: '—'],
|
||||||
|
['Backend version', health?.version ?? '—'],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<tr key={label}>
|
||||||
|
<td style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, paddingBottom: 8, width: '45%' }}>{label}</td>
|
||||||
|
<td style={{ color: 'var(--text-hi)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>{value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={triggerRebuild}
|
||||||
|
disabled={rebuilding}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'var(--blue)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: rebuilding ? 0.6 : 1,
|
||||||
|
cursor: rebuilding ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rebuilding ? 'Rebuilding…' : 'Rebuild Catalog'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={triggerRecompute}
|
||||||
|
disabled={recomputing}
|
||||||
|
style={{
|
||||||
|
padding: '6px 16px',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
color: 'var(--text-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 12,
|
||||||
|
opacity: recomputing ? 0.6 : 1,
|
||||||
|
cursor: recomputing ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recomputing ? 'Starting…' : 'Recompute Tonight'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recomputeMsg && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
{recomputeMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rebuildResult && (
|
||||||
|
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-mid)', fontFamily: 'var(--font-mono)', background: 'var(--bg-row)', padding: '10px 12px', borderRadius: 4 }}>
|
||||||
|
{rebuildResult.error ? (
|
||||||
|
<div style={{ color: 'var(--danger)' }}>{rebuildResult.error}</div>
|
||||||
|
) : rebuildResult.message ? (
|
||||||
|
<div style={{ color: 'var(--good)' }}>{rebuildResult.message}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div><strong>Total:</strong> {rebuildResult.total?.toLocaleString() || '?'} entries</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<strong>By Type:</strong>
|
||||||
|
{rebuildResult.by_type && Object.entries(rebuildResult.by_type).length > 0 ? (
|
||||||
|
<div style={{ marginLeft: 12, marginTop: 4 }}>
|
||||||
|
{Object.entries(rebuildResult.by_type).map(([type, count]: [string, any]) => (
|
||||||
|
<div key={type} style={{ fontSize: 10 }}>{type}: {count}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginLeft: 12, marginTop: 4, fontSize: 10 }}>None</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<strong>Messier:</strong> {rebuildResult.messier_count || 0}
|
||||||
|
<span style={{ color: 'var(--text-lo)', marginLeft: 8 }}>({rebuildResult.has_sizes || 0} with size data)</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const base = '/api';
|
||||||
|
|
||||||
|
interface SSOObject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
obj_type: string;
|
||||||
|
ra_deg: number;
|
||||||
|
dec_deg: number;
|
||||||
|
ra_h: string;
|
||||||
|
dec_dms: string;
|
||||||
|
alt_deg: number;
|
||||||
|
az_deg: number;
|
||||||
|
airmass: number;
|
||||||
|
mag_v?: number;
|
||||||
|
angular_size_arcsec?: number;
|
||||||
|
phase_pct?: number;
|
||||||
|
distance_au?: number;
|
||||||
|
elongation_deg?: number;
|
||||||
|
is_visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
obj_type: string;
|
||||||
|
ra_deg?: number;
|
||||||
|
dec_deg?: number;
|
||||||
|
ra_h?: string;
|
||||||
|
dec_dms?: string;
|
||||||
|
alt_deg?: number;
|
||||||
|
az_deg?: number;
|
||||||
|
has_tle: boolean;
|
||||||
|
notes?: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function altColor(alt: number) {
|
||||||
|
if (alt >= 30) return 'var(--good)';
|
||||||
|
if (alt >= 15) return 'var(--warn)';
|
||||||
|
return 'var(--danger)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TypeBadge({ type }: { type: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
planet: 'var(--blue)',
|
||||||
|
moon: '#aaa',
|
||||||
|
star: '#ffe066',
|
||||||
|
custom: 'var(--teal)',
|
||||||
|
satellite: 'var(--amber)',
|
||||||
|
comet: '#c39dde',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
background: colors[type] ?? 'var(--muted)',
|
||||||
|
color: '#111',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
{type}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SolarSystem() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
id: '', name: '', ra_h: '', dec_dms: '', notes: '', obj_type: 'custom',
|
||||||
|
tle_line1: '', tle_line2: '',
|
||||||
|
});
|
||||||
|
const [coordMode, setCoordMode] = useState<'radec' | 'tle'>('radec');
|
||||||
|
|
||||||
|
const { data: ssoData, isLoading } = useQuery({
|
||||||
|
queryKey: ['solar-system'],
|
||||||
|
queryFn: () => fetch(`${base}/solar-system`).then(r => r.json()) as Promise<{ objects: SSOObject[]; computed_at: string }>,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: customData } = useQuery({
|
||||||
|
queryKey: ['custom-targets'],
|
||||||
|
queryFn: () => fetch(`${base}/custom-targets`).then(r => r.json()) as Promise<{ items: CustomTarget[] }>,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => fetch(`${base}/custom-targets/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['custom-targets'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async (body: typeof form) => {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: body.id, name: body.name, obj_type: body.obj_type, notes: body.notes || undefined,
|
||||||
|
};
|
||||||
|
if (coordMode === 'tle') {
|
||||||
|
if (body.tle_line1.trim()) payload.tle_line1 = body.tle_line1.trim();
|
||||||
|
if (body.tle_line2.trim()) payload.tle_line2 = body.tle_line2.trim();
|
||||||
|
} else {
|
||||||
|
const ra = parseCoord(body.ra_h, 'ra');
|
||||||
|
const dec = parseCoord(body.dec_dms, 'dec');
|
||||||
|
if (ra !== null) payload.ra_deg = ra;
|
||||||
|
if (dec !== null) payload.dec_deg = dec;
|
||||||
|
}
|
||||||
|
return fetch(`${base}/custom-targets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['custom-targets'] });
|
||||||
|
setShowAddForm(false);
|
||||||
|
setForm({ id: '', name: '', ra_h: '', dec_dms: '', notes: '', obj_type: 'custom', tle_line1: '', tle_line2: '' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseCoord(s: string, type: 'ra' | 'dec'): number | null {
|
||||||
|
const trimmed = s.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
// Try decimal
|
||||||
|
const dec = parseFloat(trimmed);
|
||||||
|
if (!isNaN(dec)) return dec;
|
||||||
|
// Try HH:MM:SS or DD:MM:SS
|
||||||
|
const parts = trimmed.replace(/[hdm°′"s]/g, ':').split(':').map(p => parseFloat(p.trim()));
|
||||||
|
if (parts.length >= 2 && !parts.some(isNaN)) {
|
||||||
|
const sign = trimmed.startsWith('-') ? -1 : 1;
|
||||||
|
const abs = Math.abs(parts[0]) + (parts[1] ?? 0) / 60 + (parts[2] ?? 0) / 3600;
|
||||||
|
return type === 'ra' ? abs * 15 : sign * abs;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssoObjects = ssoData?.objects ?? [];
|
||||||
|
const customObjects = customData?.items ?? [];
|
||||||
|
|
||||||
|
const colStyle = {
|
||||||
|
padding: '6px 10px',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
textAlign: 'left' as const,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
};
|
||||||
|
const headStyle = {
|
||||||
|
...colStyle,
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
textTransform: 'uppercase' as const,
|
||||||
|
borderBottom: '1px solid var(--border-hi)',
|
||||||
|
padding: '5px 10px',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px 28px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 20, fontWeight: 700, color: 'var(--text-hi)', letterSpacing: '0.04em' }}>
|
||||||
|
Solar System
|
||||||
|
</h1>
|
||||||
|
{ssoData?.computed_at && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
|
computed {new Date(ssoData.computed_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginLeft: 4 }}>
|
||||||
|
· updates every 60s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>Computing positions…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Planets & Moon table */}
|
||||||
|
{ssoObjects.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, marginBottom: 24, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['', 'Object', 'RA', 'Dec', 'Alt', 'Az', 'Airmass', 'Mag', 'Size″', 'Phase', 'Dist AU', 'Elong'].map(h => (
|
||||||
|
<th key={h} style={headStyle}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ssoObjects.filter(o => o.id !== 'sun').map(obj => (
|
||||||
|
<tr key={obj.id} style={{
|
||||||
|
background: 'var(--bg-row)',
|
||||||
|
opacity: obj.is_visible ? 1 : 0.4,
|
||||||
|
transition: 'background 0.1s',
|
||||||
|
}}>
|
||||||
|
<td style={{ ...colStyle, width: 60 }}><TypeBadge type={obj.obj_type} /></td>
|
||||||
|
<td style={{ ...colStyle, fontWeight: 600, color: 'var(--text-hi)', minWidth: 80 }}>{obj.name}</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.ra_h}</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.dec_dms}</td>
|
||||||
|
<td style={{ ...colStyle, fontWeight: 600, color: altColor(obj.alt_deg) }}>
|
||||||
|
{obj.alt_deg.toFixed(1)}°
|
||||||
|
{!obj.is_visible && obj.alt_deg > 0 && <span style={{ color: 'var(--text-lo)', fontSize: 10 }}> ↑</span>}
|
||||||
|
{obj.alt_deg < 0 && <span style={{ color: 'var(--text-lo)', fontSize: 10 }}> ↓</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.az_deg.toFixed(1)}°</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||||
|
{obj.is_visible ? obj.airmass.toFixed(2) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-hi)' }}>
|
||||||
|
{obj.mag_v != null ? obj.mag_v.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||||
|
{obj.angular_size_arcsec != null ? obj.angular_size_arcsec.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||||
|
{obj.phase_pct != null ? `${obj.phase_pct.toFixed(0)}%` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>
|
||||||
|
{obj.distance_au != null ? obj.distance_au.toFixed(2) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: (obj.elongation_deg ?? 0) < 30 ? 'var(--warn)' : 'var(--text-mid)' }}>
|
||||||
|
{obj.elongation_deg != null ? `${obj.elongation_deg.toFixed(1)}°` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom targets section */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: 14, fontWeight: 700, color: 'var(--text-mid)' }}>
|
||||||
|
Custom Targets
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(v => !v)}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--amber)',
|
||||||
|
border: '1px solid var(--amber-dim)',
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: '3px 10px',
|
||||||
|
background: 'var(--amber-glow)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add target
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
{showAddForm && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '16px 20px',
|
||||||
|
marginBottom: 16,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>ID (unique)</label>
|
||||||
|
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))} placeholder="ISS or MyComet" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Name</label>
|
||||||
|
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="My Object" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Type</label>
|
||||||
|
<select value={form.obj_type} onChange={e => setForm(f => ({ ...f, obj_type: e.target.value }))} style={{ width: '100%' }}>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
<option value="satellite">Satellite</option>
|
||||||
|
<option value="comet">Comet</option>
|
||||||
|
<option value="asteroid">Asteroid</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Notes</label>
|
||||||
|
<input value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} placeholder="optional notes" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coordinate mode toggle */}
|
||||||
|
<div style={{ gridColumn: '1/-1' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 0, marginBottom: 10 }}>
|
||||||
|
{(['radec', 'tle'] as const).map(mode => (
|
||||||
|
<button key={mode} onClick={() => setCoordMode(mode)} style={{
|
||||||
|
padding: '4px 14px',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
background: coordMode === mode ? 'var(--amber-glow)' : 'var(--bg-deep)',
|
||||||
|
color: coordMode === mode ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
border: `1px solid ${coordMode === mode ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: mode === 'radec' ? '3px 0 0 3px' : '0 3px 3px 0',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
{mode === 'radec' ? 'RA/Dec coordinates' : 'TLE (satellite / comet)'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coordMode === 'radec' ? (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>RA (decimal° or HH:MM:SS)</label>
|
||||||
|
<input value={form.ra_h} onChange={e => setForm(f => ({ ...f, ra_h: e.target.value }))} placeholder="10.684 or 00:42:44" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>Dec (decimal° or ±DD:MM:SS)</label>
|
||||||
|
<input value={form.dec_dms} onChange={e => setForm(f => ({ ...f, dec_dms: e.target.value }))} placeholder="41.269 or +41:16:09" style={{ width: '100%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginBottom: 2 }}>
|
||||||
|
Paste TLE from <a href="https://celestrak.org/SOCRATES/" target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>CelesTrak</a> or <a href="https://heavens-above.com" target="_blank" rel="noreferrer" style={{ color: 'var(--blue)' }}>Heavens-Above</a>. Position updates every 60s.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>TLE Line 1</label>
|
||||||
|
<input
|
||||||
|
value={form.tle_line1}
|
||||||
|
onChange={e => setForm(f => ({ ...f, tle_line1: e.target.value }))}
|
||||||
|
placeholder="1 25544U 98067A 24001.50000000 .00001234 00000-0 12345-4 0 9999"
|
||||||
|
style={{ width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', display: 'block', marginBottom: 4 }}>TLE Line 2</label>
|
||||||
|
<input
|
||||||
|
value={form.tle_line2}
|
||||||
|
onChange={e => setForm(f => ({ ...f, tle_line2: e.target.value }))}
|
||||||
|
placeholder="2 25544 51.6416 12.3456 0001234 12.3456 347.6543 15.49507895123456"
|
||||||
|
style={{ width: '100%', fontFamily: 'var(--font-mono)', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ gridColumn: '1/-1', display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button onClick={() => setShowAddForm(false)} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', padding: '4px 14px', border: '1px solid var(--border)', borderRadius: 3, cursor: 'pointer' }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate(form)}
|
||||||
|
disabled={!form.id || !form.name}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: '#111', background: 'var(--amber)', padding: '4px 14px', borderRadius: 3, cursor: 'pointer', opacity: (!form.id || !form.name) ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom targets table */}
|
||||||
|
{customObjects.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['Type', 'Name', 'RA', 'Dec', 'Alt now', 'Notes', ''].map(h => (
|
||||||
|
<th key={h} style={headStyle}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{customObjects.map(obj => (
|
||||||
|
<tr key={obj.id} style={{ background: 'var(--bg-row)', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={colStyle}><TypeBadge type={obj.obj_type} /></td>
|
||||||
|
<td style={{ ...colStyle, fontWeight: 600, color: 'var(--text-hi)' }}>
|
||||||
|
{obj.name}
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-lo)', display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
{obj.id}
|
||||||
|
{obj.has_tle && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 8, background: 'var(--amber-glow)', border: '1px solid var(--amber-dim)', color: 'var(--amber)', padding: '0 4px', borderRadius: 2 }}>
|
||||||
|
TLE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.ra_h ?? (obj.has_tle ? <span style={{ color: 'var(--danger)', fontSize: 10 }}>TLE error</span> : '—')}</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-mid)' }}>{obj.dec_dms ?? '—'}</td>
|
||||||
|
<td style={{ ...colStyle }}>
|
||||||
|
{obj.alt_deg != null ? (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600, color: altColor(obj.alt_deg) }}>
|
||||||
|
{obj.alt_deg.toFixed(1)}°
|
||||||
|
</span>
|
||||||
|
) : <span style={{ color: 'var(--text-lo)' }}>—</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ ...colStyle, color: 'var(--text-lo)', fontSize: 11 }}>{obj.notes ?? ''}</td>
|
||||||
|
<td style={{ ...colStyle, width: 60 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(obj.id)}
|
||||||
|
style={{ color: 'var(--danger)', fontFamily: 'var(--font-mono)', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customObjects.length === 0 && !showAddForm && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: '12px 0' }}>
|
||||||
|
No custom targets yet. Add any RA/Dec object or comet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||||
|
PieChart, Pie, Cell, ScatterChart, Scatter, CartesianGrid, LineChart, Line,
|
||||||
|
} from 'recharts';
|
||||||
|
import { useStats } from '../hooks/useStats';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api';
|
||||||
|
import type { Phd2Log } from '../api/types';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const FILTER_COLORS: Record<string, string> = {
|
||||||
|
sv220: '#9b59b6',
|
||||||
|
c2: '#4d9de0',
|
||||||
|
sv260: '#e8832a',
|
||||||
|
uvir: '#3dba72',
|
||||||
|
};
|
||||||
|
|
||||||
|
function PHD2Section() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<number | null>(null);
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['phd2'],
|
||||||
|
queryFn: () => api.phd2.list(),
|
||||||
|
});
|
||||||
|
const items = data?.items ?? [];
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setUploadResult(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await api.phd2.upload(fd) as any;
|
||||||
|
if (res.duplicate) {
|
||||||
|
setUploadResult('⚠ Duplicate session - not imported');
|
||||||
|
} else {
|
||||||
|
const analysis = res.analysis;
|
||||||
|
const details: string[] = [];
|
||||||
|
details.push(`RMS ${analysis.rms_total_arcsec.toFixed(2)}″`);
|
||||||
|
if (analysis.duration_min) details.push(`${analysis.duration_min}m`);
|
||||||
|
if (analysis.camera_name) details.push(analysis.camera_name);
|
||||||
|
setUploadResult(`✓ Uploaded: ${details.join(' · ')}`);
|
||||||
|
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUploadResult('✗ Upload failed');
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Delete this PHD2 session?')) return;
|
||||||
|
setDeleting(id);
|
||||||
|
try {
|
||||||
|
await api.phd2.delete(id);
|
||||||
|
qc.invalidateQueries({ queryKey: ['phd2'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['stats'] });
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Failed to delete: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
setDeleting(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, flex: 1 }}>PHD2 Guiding Sessions</div>
|
||||||
|
<div
|
||||||
|
onClick={() => !uploading && inputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
cursor: uploading ? 'default' : 'pointer',
|
||||||
|
background: 'var(--bg-deep)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Parsing…' : '↑ Upload .log'}
|
||||||
|
</div>
|
||||||
|
<input ref={inputRef} type="file" accept=".log,.csv" style={{ display: 'none' }}
|
||||||
|
onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
|
||||||
|
</div>
|
||||||
|
{uploadResult && (
|
||||||
|
<div style={{ padding: '6px 16px', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: uploadResult.startsWith('✓') ? 'var(--good)' : uploadResult.startsWith('⚠') ? 'var(--warn)' : 'var(--danger)',
|
||||||
|
borderBottom: '1px solid var(--border)', background: 'var(--bg-deep)' }}>
|
||||||
|
{uploadResult}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div style={{ padding: 16, color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
No PHD2 logs imported yet. Upload a .log file to analyze guiding performance.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{['Date', 'File', 'Duration', 'RMS Total', 'RMS RA', 'RMS Dec', 'Peak', 'Lost', 'SNR', ''].map(h => (
|
||||||
|
<th key={h} style={{ padding: '6px 10px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.05em', whiteSpace: 'nowrap' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((log: Phd2Log) => {
|
||||||
|
const rms = log.rms_total ?? 0;
|
||||||
|
const rmsColor = rms < 0.7 ? 'var(--good)' : rms < 1.2 ? 'var(--warn)' : 'var(--danger)';
|
||||||
|
return (
|
||||||
|
<tr key={log.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-hi)', whiteSpace: 'nowrap' }}>{log.session_date}</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||||
|
title={log.filename}>{log.filename}</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.duration_min ? `${log.duration_min}m` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 700, color: rmsColor, whiteSpace: 'nowrap' }}>
|
||||||
|
{log.rms_total ? `${log.rms_total.toFixed(2)}″` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.rms_ra ? `${log.rms_ra.toFixed(2)}″` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.rms_dec ? `${log.rms_dec.toFixed(2)}″` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.peak_error ?? 0) > 2 ? 'var(--warn)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.peak_error ? `${log.peak_error.toFixed(2)}″` : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: (log.star_lost_count ?? 0) > 5 ? 'var(--danger)' : 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.star_lost_count ?? 0}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '6px 10px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)', whiteSpace: 'nowrap' }}>
|
||||||
|
{log.guide_star_snr ? log.guide_star_snr.toFixed(1) : '—'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(log.id)}
|
||||||
|
disabled={deleting === log.id}
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 2,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10,
|
||||||
|
cursor: deleting === log.id ? 'default' : 'pointer',
|
||||||
|
opacity: deleting === log.id ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting === log.id ? '…' : '×'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stats() {
|
||||||
|
const { data: stats, isLoading } = useStats();
|
||||||
|
|
||||||
|
if (isLoading || !stats) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
|
||||||
|
Loading statistics…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalH = (stats.total_integration_min / 60).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22 }}>Statistics</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => api.log.exportCsv()}
|
||||||
|
style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
padding: '5px 14px',
|
||||||
|
background: 'var(--bg-panel)',
|
||||||
|
border: '1px solid var(--border-hi)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--text-mid)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
↓ Export Log CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header stat cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 14, marginBottom: 28 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Total Sessions', value: stats.total_sessions },
|
||||||
|
{ label: 'Integration Time', value: `${totalH}h` },
|
||||||
|
{ label: 'Objects Imaged', value: stats.objects_with_keeper },
|
||||||
|
{ label: 'Filter Types Used', value: stats.filter_usage.length },
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div key={label} style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 18px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', letterSpacing: '0.1em', textTransform: 'uppercase', marginBottom: 6 }}>{label}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 24, fontWeight: 700 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 24 }}>
|
||||||
|
{/* Integration per month */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Integration per Month</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={stats.monthly}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="month" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} />
|
||||||
|
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }}
|
||||||
|
formatter={(v: number) => [`${(v / 60).toFixed(1)}h`, 'Integration']}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total_min" fill="var(--amber)" radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter usage pie */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Filter Usage</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<ResponsiveContainer width={160} height={160}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={stats.filter_usage} dataKey="total_min" nameKey="filter_id" cx="50%" cy="50%" outerRadius={70} innerRadius={40}>
|
||||||
|
{stats.filter_usage.map((entry) => (
|
||||||
|
<Cell key={entry.filter_id} fill={FILTER_COLORS[entry.filter_id] ?? '#888'} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(v: number) => `${(v / 60).toFixed(1)}h`} contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div>
|
||||||
|
{stats.filter_usage.map(f => (
|
||||||
|
<div key={f.filter_id} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<div style={{ width: 10, height: 10, borderRadius: 2, background: FILTER_COLORS[f.filter_id] ?? '#888' }} />
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-mid)' }}>
|
||||||
|
{f.filter_id.toUpperCase()} — {(f.total_min / 60).toFixed(1)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Object type breakdown */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>By Object Type</div>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<BarChart data={stats.by_type} layout="vertical">
|
||||||
|
<XAxis type="number" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${Math.round(v/60)}h`} />
|
||||||
|
<YAxis type="category" dataKey="obj_type" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} width={90} />
|
||||||
|
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontSize: 11 }} formatter={(v: number) => `${(v / 60).toFixed(1)}h`} />
|
||||||
|
<Bar dataKey="total_min" fill="var(--teal)" radius={[0, 2, 2, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guiding RMS over time */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, padding: '14px 16px' }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Guiding RMS over Time</div>
|
||||||
|
{stats.guiding.length === 0 ? (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>No PHD2 logs imported yet.</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<LineChart data={stats.guiding.map(g => ({ ...g, rms_total: g.rms_total ?? null, rms_ra: g.rms_ra ?? null, rms_dec: g.rms_dec ?? null }))}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="date" tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} interval="preserveStartEnd" />
|
||||||
|
<YAxis tick={{ fill: 'var(--text-lo)', fontSize: 9, fontFamily: 'IBM Plex Mono' }} tickLine={false} tickFormatter={v => `${v}″`} />
|
||||||
|
<Tooltip contentStyle={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', fontFamily: 'IBM Plex Mono', fontSize: 11 }} formatter={(v: number) => `${v.toFixed(2)}″`} />
|
||||||
|
<Line type="monotone" dataKey="rms_total" stroke="var(--blue)" strokeWidth={2} dot={{ r: 3, fill: 'var(--blue)' }} name="Total RMS" connectNulls={false} />
|
||||||
|
<Line type="monotone" dataKey="rms_ra" stroke="var(--amber)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="RA RMS" connectNulls={false} />
|
||||||
|
<Line type="monotone" dataKey="rms_dec" stroke="var(--teal)" strokeWidth={1} dot={false} strokeDasharray="3 2" name="Dec RMS" connectNulls={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top targets */}
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||||
|
Most Integrated Targets
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
{['Name', 'Type', 'Sessions', 'Integration'].map(h => (
|
||||||
|
<th key={h} style={{ padding: '6px 12px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', fontWeight: 500, letterSpacing: '0.06em' }}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.top_targets.map(t => (
|
||||||
|
<tr key={t.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-hi)' }}>
|
||||||
|
{t.common_name ?? t.name}
|
||||||
|
{t.common_name && <span style={{ color: 'var(--text-lo)', marginLeft: 6 }}>{t.name}</span>}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '7px 12px', fontSize: 11, color: 'var(--text-mid)' }}>{t.obj_type}</td>
|
||||||
|
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11 }}>{t.sessions}</td>
|
||||||
|
<td style={{ padding: '7px 12px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--amber)' }}>
|
||||||
|
{(t.total_min / 60).toFixed(1)}h
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality breakdown */}
|
||||||
|
{stats.quality && stats.quality.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-panel)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden', marginTop: 20 }}>
|
||||||
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)', fontFamily: 'var(--font-display)', fontSize: 13, fontWeight: 700 }}>
|
||||||
|
Session Quality Breakdown
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 0 }}>
|
||||||
|
{stats.quality.map(q => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
keeper: 'var(--good)',
|
||||||
|
needs_more: 'var(--blue)',
|
||||||
|
rejected: 'var(--danger)',
|
||||||
|
pending: 'var(--muted)',
|
||||||
|
};
|
||||||
|
const color = colorMap[q.quality] ?? 'var(--text-mid)';
|
||||||
|
const total = stats.quality.reduce((s, x) => s + x.count, 0);
|
||||||
|
return (
|
||||||
|
<div key={q.quality} style={{
|
||||||
|
flex: q.count,
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRight: '1px solid var(--border)',
|
||||||
|
minWidth: 80,
|
||||||
|
}}>
|
||||||
|
<div className={`quality-chip ${q.quality}`} style={{ marginBottom: 4 }}>
|
||||||
|
{q.quality.replace('_', ' ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 700, color }}>
|
||||||
|
{q.count}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>
|
||||||
|
{total > 0 ? Math.round((q.count / total) * 100) : 0}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PHD2Section />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { useState, Fragment } from 'react';
|
||||||
|
import { useTargets } from '../hooks/useTargets';
|
||||||
|
import TargetRow from '../components/targets/TargetRow';
|
||||||
|
import DetailDrawer from '../components/targets/DetailDrawer';
|
||||||
|
import type { Target } from '../api/types';
|
||||||
|
|
||||||
|
const OBJ_TYPES = ['All', 'galaxy', 'emission_nebula', 'reflection_nebula', 'planetary_nebula', 'snr', 'open_cluster', 'globular_cluster', 'dark_nebula'];
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
galaxy: 'Galaxy', emission_nebula: 'Emission', reflection_nebula: 'Reflection',
|
||||||
|
planetary_nebula: 'Planetary', snr: 'SNR', open_cluster: 'Cluster',
|
||||||
|
globular_cluster: 'Globular', dark_nebula: 'Dark',
|
||||||
|
};
|
||||||
|
const FILTERS = [
|
||||||
|
{ id: '', label: 'Any filter' },
|
||||||
|
{ id: 'sv220', label: 'HaOIII (SV220)' },
|
||||||
|
{ id: 'c2', label: 'SIIOIII (C2)' },
|
||||||
|
{ id: 'sv260', label: 'LP (SV260)' },
|
||||||
|
{ id: 'uvir', label: 'UV/IR Cut' },
|
||||||
|
];
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: '', label: 'Best alt tonight' },
|
||||||
|
{ value: 'transit', label: 'Transit time' },
|
||||||
|
{ value: 'size', label: 'Size (largest)' },
|
||||||
|
{ value: 'magnitude', label: 'Magnitude' },
|
||||||
|
{ value: 'difficulty', label: 'Difficulty' },
|
||||||
|
{ value: 'integration', label: 'Total integration' },
|
||||||
|
];
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ id: 'tonight', label: 'Tonight only' },
|
||||||
|
{ id: 'not_imaged', label: 'Not yet imaged' },
|
||||||
|
{ id: 'mosaic_only', label: 'Mosaics only' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
||||||
|
|
||||||
|
function Chip({ active, color, onClick, children }: { active: boolean; color?: string; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
const c = color ?? 'var(--amber)';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '3px 10px',
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${active ? c : 'var(--border)'}`,
|
||||||
|
background: active ? `${c}22` : 'var(--bg-panel)',
|
||||||
|
color: active ? c : 'var(--text-mid)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 11,
|
||||||
|
cursor: 'pointer',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Targets() {
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [filterPill, setFilterPill] = useState('');
|
||||||
|
const [tonight, setTonight] = useState(true);
|
||||||
|
const [notImaged, setNotImaged] = useState(false);
|
||||||
|
const [mosaicOnly, setMosaicOnly] = useState(false);
|
||||||
|
const [minAlt, setMinAlt] = useState<number | undefined>(undefined);
|
||||||
|
const [minUsable, setMinUsable] = useState<number | undefined>(undefined);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sort, setSort] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const { data, isLoading } = useTargets({
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
filter: filterPill || undefined,
|
||||||
|
tonight,
|
||||||
|
not_imaged: notImaged || undefined,
|
||||||
|
mosaic_only: mosaicOnly || undefined,
|
||||||
|
min_alt_deg: minAlt,
|
||||||
|
min_usable_min: minUsable,
|
||||||
|
search: search || undefined,
|
||||||
|
sort: sort || undefined,
|
||||||
|
page,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleExpand = (id: string) => {
|
||||||
|
setExpandedId(prev => prev === id ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: 22, marginBottom: 12 }}>Targets</h1>
|
||||||
|
|
||||||
|
{/* Filter bar */}
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
background: 'var(--bg-void)',
|
||||||
|
padding: '8px 0 10px',
|
||||||
|
zIndex: 10,
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
marginBottom: 10,
|
||||||
|
}}>
|
||||||
|
{/* Row 1: object types */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
|
||||||
|
{OBJ_TYPES.map(t => (
|
||||||
|
<Chip
|
||||||
|
key={t}
|
||||||
|
active={(t === 'All' && !typeFilter) || t === typeFilter}
|
||||||
|
color="var(--amber)"
|
||||||
|
onClick={() => setTypeFilter(t === 'All' ? '' : t)}
|
||||||
|
>
|
||||||
|
{TYPE_LABELS[t] ?? t}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: filters + sort + status + search */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{/* Filter fit */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', marginRight: 2 }}>FILTER</span>
|
||||||
|
{FILTERS.map(f => (
|
||||||
|
<Chip
|
||||||
|
key={f.id}
|
||||||
|
active={f.id === filterPill}
|
||||||
|
color="var(--blue)"
|
||||||
|
onClick={() => setFilterPill(f.id === filterPill ? '' : f.id)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||||
|
|
||||||
|
{/* Status toggles */}
|
||||||
|
<Chip active={tonight} color="var(--good)" onClick={() => setTonight(v => !v)}>
|
||||||
|
Tonight
|
||||||
|
</Chip>
|
||||||
|
<Chip active={notImaged} color="var(--teal)" onClick={() => setNotImaged(v => !v)}>
|
||||||
|
Not imaged
|
||||||
|
</Chip>
|
||||||
|
<Chip active={mosaicOnly} color="var(--warn)" onClick={() => setMosaicOnly(v => !v)}>
|
||||||
|
Mosaics only
|
||||||
|
</Chip>
|
||||||
|
|
||||||
|
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||||
|
|
||||||
|
{/* Min alt filter */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN ALT</span>
|
||||||
|
<select
|
||||||
|
value={minAlt ?? ''}
|
||||||
|
onChange={e => setMinAlt(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 70 }}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="20">20°</option>
|
||||||
|
<option value="30">30°</option>
|
||||||
|
<option value="40">40°</option>
|
||||||
|
<option value="50">50°</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min usable */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>MIN TIME</span>
|
||||||
|
<select
|
||||||
|
value={minUsable ?? ''}
|
||||||
|
onChange={e => setMinUsable(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px', width: 80 }}
|
||||||
|
>
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option value="60">1h</option>
|
||||||
|
<option value="120">2h</option>
|
||||||
|
<option value="180">3h</option>
|
||||||
|
<option value="240">4h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ width: 1, background: 'var(--border)', height: 16, margin: '0 2px' }} />
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SORT</span>
|
||||||
|
<select
|
||||||
|
value={sort}
|
||||||
|
onChange={e => setSort(e.target.value)}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px' }}
|
||||||
|
>
|
||||||
|
{SORT_OPTIONS.map(o => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search name, NGC, M42, Andromeda…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '3px 10px', width: 220 }}
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', whiteSpace: 'nowrap' }}>
|
||||||
|
tonight filter bypassed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-lo)' }}>
|
||||||
|
{data?.total ?? 0} objects
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 12, padding: 16 }}>
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{data && (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border-hi)' }}>
|
||||||
|
{COL_HEADERS.map(h => (
|
||||||
|
<th key={h} style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text-lo)',
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((target: Target) => (
|
||||||
|
<Fragment key={target.id}>
|
||||||
|
<TargetRow
|
||||||
|
target={target}
|
||||||
|
expanded={expandedId === target.id}
|
||||||
|
onToggle={() => toggleExpand(target.id)}
|
||||||
|
/>
|
||||||
|
{expandedId === target.id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COL_HEADERS.length} style={{ padding: 0 }}>
|
||||||
|
<DetailDrawer target={target} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
@import './tokens.css';
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg-void);
|
||||||
|
color: var(--text-hi);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
min-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, kbd, pre, .mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--amber);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-hi);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: var(--amber-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-deep); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--muted); border-radius: 3px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-lo); }
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.text-hi { color: var(--text-hi); }
|
||||||
|
.text-mid { color: var(--text-mid); }
|
||||||
|
.text-lo { color: var(--text-lo); }
|
||||||
|
.text-amber { color: var(--amber); }
|
||||||
|
.text-good { color: var(--good); }
|
||||||
|
.text-warn { color: var(--warn); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); }
|
||||||
|
.display { font-family: var(--font-display); }
|
||||||
|
|
||||||
|
/* Type badge pills */
|
||||||
|
.type-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-badge.galaxy { background: var(--type-galaxy); color: #fff; }
|
||||||
|
.type-badge.emission_nebula { background: var(--type-emission); color: #fff; }
|
||||||
|
.type-badge.planetary_nebula { background: var(--type-planetary); color: #fff; }
|
||||||
|
.type-badge.snr { background: var(--type-snr); color: #fff; }
|
||||||
|
.type-badge.globular_cluster { background: var(--type-globular); color: #fff; }
|
||||||
|
.type-badge.open_cluster { background: var(--type-open); color: #111; }
|
||||||
|
.type-badge.reflection_nebula { background: var(--type-reflection); color: #fff; }
|
||||||
|
.type-badge.dark_nebula { background: var(--type-dark); color: var(--text-mid); }
|
||||||
|
.type-badge.nebula { background: var(--teal); color: #fff; }
|
||||||
|
|
||||||
|
/* Quality chips */
|
||||||
|
.quality-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.quality-chip.keeper { background: var(--good); color: #fff; }
|
||||||
|
.quality-chip.needs_more { background: var(--blue); color: #fff; }
|
||||||
|
.quality-chip.rejected { background: var(--danger); color: #fff; }
|
||||||
|
.quality-chip.pending { background: var(--muted); color: var(--text-mid); }
|
||||||
|
|
||||||
|
/* Filter pills */
|
||||||
|
.filter-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.filter-pill.sv220 { background: rgba(155, 89, 182, 0.3); color: #c39dde; border: 1px solid rgba(155,89,182,0.4); }
|
||||||
|
.filter-pill.c2 { background: rgba(77, 157, 224, 0.3); color: #8cbee8; border: 1px solid rgba(77,157,224,0.4); }
|
||||||
|
.filter-pill.sv260 { background: rgba(232, 131, 42, 0.3); color: #e8a870; border: 1px solid rgba(232,131,42,0.4); }
|
||||||
|
.filter-pill.uvir { background: rgba(61, 186, 114, 0.3); color: #7dd1a1; border: 1px solid rgba(61,186,114,0.4); }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
:root {
|
||||||
|
/* Backgrounds */
|
||||||
|
--bg-void: #080a0f;
|
||||||
|
--bg-deep: #0d1017;
|
||||||
|
--bg-panel: #111520;
|
||||||
|
--bg-row: #141825;
|
||||||
|
--bg-hover: #1a2035;
|
||||||
|
|
||||||
|
/* Accent palette */
|
||||||
|
--amber: #e8832a;
|
||||||
|
--amber-dim: #7a4415;
|
||||||
|
--amber-glow: rgba(232, 131, 42, 0.12);
|
||||||
|
--blue: #4d9de0;
|
||||||
|
--blue-dim: #1a3d5c;
|
||||||
|
--teal: #2ab8a0;
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
|
||||||
|
/* Object type colors */
|
||||||
|
--type-galaxy: #4d9de0;
|
||||||
|
--type-emission: #2ab8a0;
|
||||||
|
--type-planetary: #3dba72;
|
||||||
|
--type-snr: #e8832a;
|
||||||
|
--type-globular: #9b59b6;
|
||||||
|
--type-open: #f1c40f;
|
||||||
|
--type-reflection: #e67e22;
|
||||||
|
--type-dark: #3a4258;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream backend { server backend:3001; }
|
||||||
|
upstream frontend { server frontend:80; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
client_max_body_size 60M;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user