Finalised moon phases and iss
This commit is contained in:
parent
7f9a745b16
commit
0c5c3cf2d0
77
README.md
77
README.md
|
|
@ -1,36 +1,63 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# 🛰️ Mission Control: ISS Tracker
|
||||||
|
|
||||||
## Getting Started
|
A production-grade, full-stack dashboard for tracking the International Space Station in real-time. This project combines orbital mechanics (SGP4 propagation), a background worker architecture, and a resilient Next.js frontend.
|
||||||
|
|
||||||
First, run the development server:
|
## 🚀 The Stack
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 15 (App Router) + Tailwind CSS + Lucide Icons
|
||||||
|
- **Backend/Worker**: Node.js + `satellite.js` (Orbital Physics)
|
||||||
|
- **Database**: SQLite (`better-sqlite3`)
|
||||||
|
- **Infrastructure**: Docker & Docker Compose
|
||||||
|
- **CI/CD**: Woodpecker CI
|
||||||
|
- **Deployment**: Hetzner VPS + Caddy (Reverse Proxy)
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
The system is split into two distinct services that share a persistent SQLite volume:
|
||||||
|
|
||||||
|
1. **The Worker (`iss-worker`)**:
|
||||||
|
* Fetches Two-Line Element (TLE) data from CelesTrak.
|
||||||
|
* Performs SGP4 propagation to calculate ISS coordinates.
|
||||||
|
* Determines upcoming "Pass Windows" (AOS to LOS) relative to a fixed ground station.
|
||||||
|
* Updates the shared database with the next two valid passes.
|
||||||
|
2. **The Web App (`mc-web`)**:
|
||||||
|
* Server-side queries the database for the next future pass.
|
||||||
|
* Calculates real-time "T-Minus" countdowns.
|
||||||
|
* Triggers a "Live" state when the ISS is above the horizon.
|
||||||
|
* Uses `router.refresh()` with a `useRef` lock to transition between passes without page reloads.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 🛠️ Key Technical Solutions
|
||||||
|
|
||||||
|
### Orbital Propagation
|
||||||
|
Instead of relying on 3rd-party APIs for pass predictions, this project runs its own physics engine. Using `satellite.js`, it calculates the elevation of the ISS relative to the observer's latitude and longitude.
|
||||||
|
|
||||||
|
### Self-Healing Database
|
||||||
|
The database includes an automatic migration layer in `lib/db.ts`. On startup, the application inspects the SQLite schema and applies necessary `ALTER TABLE` commands, ensuring seamless updates during CI/CD deployments without manual intervention.
|
||||||
|
|
||||||
|
### Resilient Synchronization
|
||||||
|
To prevent infinite refresh loops in the Next.js frontend, the `EventCard` component implements a "Refresh & Quiet" logic. It uses a `useRef` to ensure that only one server-side data sync is requested per orbital event.
|
||||||
|
|
||||||
|
## 📦 Deployment
|
||||||
|
|
||||||
|
### Docker Multi-Platform Build
|
||||||
|
The images are explicitly built for the `linux/amd64` architecture to ensure compatibility with Node.js native modules (like `better-sqlite3`) on the Hetzner VPS.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
docker buildx build --platform linux/amd64 -t mission-control-web .
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
### Woodpecker CI
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
The pipeline is automated via .woodpecker.yaml, handling the build, registry push, and remote deployment via Docker Compose.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
## 🚦 Getting Started
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
- Clone the repo
|
||||||
|
- Install dependencies: npm install
|
||||||
|
- Run the worker: npx tsx scripts/update-space.ts
|
||||||
|
- Start the dashboard: npm run dev
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
Ground Station Coordinates: 55.6761° N, 12.5683° E
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
32
app/page.tsx
32
app/page.tsx
|
|
@ -1,18 +1,36 @@
|
||||||
import MissionControl from '@/components/MissionControl';
|
import MissionControl from '@/components/MissionControl';
|
||||||
import db from '@/lib/db';
|
import db from '@/lib/db';
|
||||||
|
|
||||||
const FALLBACK_PASS_TIME = 1769610273000;
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const row = db.prepare(
|
// SQLite handles the 'now' comparison perfectly
|
||||||
"SELECT pass_time FROM iss_passes WHERE pass_time > datetime('now') LIMIT 1"
|
const issRow = db.prepare(`
|
||||||
).get() as { pass_time: string } | undefined;
|
SELECT pass_time, end_time
|
||||||
|
FROM iss_passes
|
||||||
|
WHERE datetime(end_time) > datetime('now')
|
||||||
|
ORDER BY datetime(pass_time) ASC LIMIT 1
|
||||||
|
`).get() as { pass_time: string, end_time: string } | undefined;
|
||||||
|
|
||||||
const nextPass = row ? new Date(row.pass_time) : new Date(FALLBACK_PASS_TIME);
|
// 2. Fetch Moon
|
||||||
|
const moonRow = db.prepare(`
|
||||||
|
SELECT title, event_time
|
||||||
|
FROM global_events
|
||||||
|
WHERE id = 'moon_phase'
|
||||||
|
`).get() as { title: string, event_time: string } | undefined;
|
||||||
|
|
||||||
|
// Prepare Dates
|
||||||
|
const issStart = issRow ? new Date(issRow.pass_time) : null;
|
||||||
|
const issEnd = issRow ? new Date(issRow.end_time) : null;
|
||||||
|
|
||||||
|
const moonStart = moonRow ? new Date(moonRow.event_time) : null;
|
||||||
|
// Moon "Event" lasts 24 hours
|
||||||
|
const moonEnd = moonStart ? new Date(moonStart.getTime() + 86400000) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<MissionControl initialIssPass={nextPass} />
|
<MissionControl
|
||||||
|
iss={{ start: issStart, end: issEnd }}
|
||||||
|
moon={{ title: moonRow?.title || "Moon Phase", start: moonStart, end: moonEnd }}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,47 +1,106 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { EventCardProps } from '@/types/space';
|
import { EventCardProps } from '@/types/space';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
export default function EventCard({ title, targetDate, icon }: EventCardProps) {
|
export default function EventCard({ id, title, targetDate, endDate, icon }: EventCardProps) {
|
||||||
// 1. Setup the state to hold our strings
|
const [isLive, setIsLive] = useState(false);
|
||||||
const [timeLeft, setTimeLeft] = useState({ h: "00", m: "00", s: "00" });
|
const router = useRouter();
|
||||||
|
const [timeLeft, setTimeLeft] = useState({ d: "00", h: "00", m: "00", s: "00" });
|
||||||
|
|
||||||
|
const lastRefreshedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateTimer = () => {
|
const updateTimer = () => {
|
||||||
const now = new Date().getTime();
|
if (!targetDate || !endDate) return;
|
||||||
const distance = targetDate.getTime() - now;
|
const now = new Date();
|
||||||
|
const timeToStart = targetDate.getTime() - now.getTime();
|
||||||
|
const timeToEnd = endDate.getTime() - now.getTime();
|
||||||
|
|
||||||
// If the time has passed, keep it at zero
|
if (timeToStart <= 0 && timeToEnd > 0) {
|
||||||
if (distance < 0) {
|
if (!isLive) setIsLive(true);
|
||||||
setTimeLeft({ h: "00", m: "00", s: "00" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Math to convert milliseconds to Hours, Minutes, and Seconds
|
if (timeToEnd <= 0) {
|
||||||
|
if (isLive) setIsLive(false);
|
||||||
|
|
||||||
|
const passId = endDate.toISOString();
|
||||||
|
|
||||||
|
if (lastRefreshedRef.current !== passId) {
|
||||||
|
|
||||||
|
const isStaleDataFromServer = endDate.getTime() < (new Date().getTime() - 5000);
|
||||||
|
|
||||||
|
if (!isStaleDataFromServer) {
|
||||||
|
console.log("🛰️ Pass complete. Syncing with Mission Control...");
|
||||||
|
lastRefreshedRef.current = passId;
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ Server returned stale ISS data. Standing by for worker update...");
|
||||||
|
lastRefreshedRef.current = passId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = timeToStart;
|
||||||
|
const d = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||||
const h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
const h = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||||
const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
const m = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
const s = Math.floor((distance % (1000 * 60)) / 1000);
|
const s = Math.floor((distance % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
// 2. Update the state (padStart ensures we always see '05' instead of '5')
|
|
||||||
setTimeLeft({
|
setTimeLeft({
|
||||||
|
d: d.toString().padStart(2, '0'),
|
||||||
h: h.toString().padStart(2, '0'),
|
h: h.toString().padStart(2, '0'),
|
||||||
m: m.toString().padStart(2, '0'),
|
m: m.toString().padStart(2, '0'),
|
||||||
s: s.toString().padStart(2, '0')
|
s: s.toString().padStart(2, '0')
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run the timer every 1000ms (1 second)
|
|
||||||
const interval = setInterval(updateTimer, 1000);
|
const interval = setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
// Call it once immediately so the user doesn't see 00:00:00 for the first second
|
|
||||||
updateTimer();
|
updateTimer();
|
||||||
|
|
||||||
// Important: Clean up the timer if the component disappears
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [targetDate]);
|
}, [targetDate, endDate, isLive, router]);
|
||||||
|
|
||||||
|
if (!targetDate) {
|
||||||
|
return (
|
||||||
|
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/20 backdrop-blur-md overflow-hidden opacity-60">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="p-2 bg-slate-700/30 rounded-lg text-slate-500">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-slate-500 font-mono tracking-widest uppercase text-xs">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="h-8 w-48 bg-slate-800 animate-pulse rounded" />
|
||||||
|
<p className="text-[10px] text-slate-600 mt-4 font-mono uppercase tracking-widest italic">
|
||||||
|
Waiting for orbital telemetry...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative p-6 rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-md overflow-hidden group">
|
<div className={`relative p-6 rounded-xl border transition-all duration-500 ${isLive ? 'border-blue-500 bg-blue-900/20 shadow-[0_0_20px_rgba(59,130,246,0.3)]' : 'border-slate-800 bg-slate-900/40'}`}>
|
||||||
|
{isLive ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 animate-pulse">
|
||||||
|
<Sparkles className="text-yellow-400 mb-2" size={32} />
|
||||||
|
<h3 className="text-xl font-bold text-white text-center">
|
||||||
|
{id === 'iss' ? 'Look Up!' : 'Event in Progress'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-300 text-sm text-center mt-1">
|
||||||
|
{id === 'iss'
|
||||||
|
? 'The ISS is directly above. Give them a wave! 👋'
|
||||||
|
: `The ${title} is occurring right now.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
|
<div className="p-2 bg-blue-500/10 rounded-lg text-blue-400">
|
||||||
{icon}
|
{icon}
|
||||||
|
|
@ -51,17 +110,37 @@ export default function EventCard({ title, targetDate, icon }: EventCardProps) {
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-4xl font-mono text-white flex gap-2">
|
<div className="text-4xl font-mono text-white flex items-baseline gap-2">
|
||||||
|
{parseInt(timeLeft.d) >= 2 ? (
|
||||||
|
/* Case 1: More than 2 days - Keep it super simple */
|
||||||
|
<>
|
||||||
|
<span>{timeLeft.d}</span>
|
||||||
|
<span className="text-slate-500 text-xl lowercase">Days</span>
|
||||||
|
</>
|
||||||
|
) : parseInt(timeLeft.d) === 1 ? (
|
||||||
|
/* Case 2: Between 24 and 48 hours - Show Day + Hours */
|
||||||
|
<>
|
||||||
|
<span>01</span><span className="text-slate-500 text-sm">d</span>
|
||||||
|
<span className="text-slate-500">:</span>
|
||||||
|
<span>{timeLeft.h}</span><span className="text-slate-500 text-sm">h</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Case 3: Under 24 hours - The full high-precision clock */
|
||||||
|
<>
|
||||||
<span>{timeLeft.h}</span>
|
<span>{timeLeft.h}</span>
|
||||||
<span className="text-slate-500">:</span>
|
<span className="text-slate-500">:</span>
|
||||||
<span>{timeLeft.m}</span>
|
<span>{timeLeft.m}</span>
|
||||||
<span className="text-slate-500">:</span>
|
<span className="text-slate-500">:</span>
|
||||||
<span>{timeLeft.s}</span>
|
<span>{timeLeft.s}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
|
<p className="text-[10px] text-slate-500 mt-2 font-mono uppercase tracking-widest leading-none">
|
||||||
T-Minus to Horizon
|
{parseInt(timeLeft.d) > 0 ? "Until event" : "T-Minus to Horizon"}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -4,47 +4,48 @@ import { useMemo } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import EventCard from '@/components/EventCard';
|
import EventCard from '@/components/EventCard';
|
||||||
import { Satellite, Rocket, Moon, Sparkles } from 'lucide-react';
|
import { Satellite, Rocket, Moon, Sparkles } from 'lucide-react';
|
||||||
|
import { MissionControlProps } from '@/types/space';
|
||||||
|
|
||||||
|
|
||||||
const Starfield = dynamic(() => import('@/components/Starfield'), {
|
const Starfield = dynamic(() => import('@/components/Starfield'), {
|
||||||
ssr: false
|
ssr: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Define the interface
|
|
||||||
interface MissionControlProps {
|
|
||||||
initialIssPass: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Accept the prop here
|
export default function MissionControl({ iss, moon }: MissionControlProps) {
|
||||||
export default function MissionControl({ initialIssPass }: MissionControlProps) {
|
|
||||||
|
|
||||||
const BASE_TIME = 1769610273000;
|
const BASE_TIME = 1769610273000;
|
||||||
|
|
||||||
const events = useMemo(() => [
|
const events = useMemo(() => [
|
||||||
{
|
{
|
||||||
id: 'iss', // Add an ID to distinguish the ISS card
|
id: 'iss',
|
||||||
title: "ISS Overhead: Home",
|
title: "ISS Overhead: Home",
|
||||||
date: initialIssPass, // Use the real data from SQLite here!
|
date: iss.start,
|
||||||
|
endDate: iss.end,
|
||||||
icon: <Satellite size={20} />,
|
icon: <Satellite size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moon',
|
id: 'moon',
|
||||||
title: "Next Lunar Phase: Full",
|
title: moon.title,
|
||||||
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 3),
|
date: moon.start,
|
||||||
|
endDate: moon.end,
|
||||||
icon: <Moon size={20} />,
|
icon: <Moon size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'starlink',
|
id: 'starlink',
|
||||||
title: "Starlink Train",
|
title: "Starlink Train",
|
||||||
date: new Date(BASE_TIME + 1000 * 60 * 45),
|
date: new Date(BASE_TIME + 1000 * 60 * 45),
|
||||||
|
endDate: new Date(BASE_TIME + 1000 * 60 * 55),
|
||||||
icon: <Rocket size={20} />,
|
icon: <Rocket size={20} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'meteor',
|
id: 'meteor',
|
||||||
title: "Meteor Shower",
|
title: "Meteor Shower",
|
||||||
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10),
|
date: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10),
|
||||||
|
endDate: new Date(BASE_TIME + 1000 * 60 * 60 * 24 * 10.1),
|
||||||
icon: <Sparkles size={20} />,
|
icon: <Sparkles size={20} />,
|
||||||
}
|
}
|
||||||
], [initialIssPass]); // Re-memoize if the pass updates
|
], [iss, moon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen flex flex-col items-center p-8 overflow-hidden">
|
<div className="relative min-h-screen flex flex-col items-center p-8 overflow-hidden">
|
||||||
|
|
@ -62,9 +63,11 @@ export default function MissionControl({ initialIssPass }: MissionControlProps)
|
||||||
<div className="z-10 grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-5xl">
|
<div className="z-10 grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-5xl">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<EventCard
|
<EventCard
|
||||||
key={event.id}
|
key={`${event.id}-${event.date?.getTime()}`}
|
||||||
|
id={event.id}
|
||||||
title={event.title}
|
title={event.title}
|
||||||
targetDate={event.date} // This now correctly uses the specific date for each card
|
targetDate={event.date}
|
||||||
|
endDate={event.endDate}
|
||||||
icon={event.icon}
|
icon={event.icon}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
13
lib/db.ts
13
lib/db.ts
|
|
@ -17,8 +17,19 @@ const db = new Database(dbPath);
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS iss_passes (
|
CREATE TABLE IF NOT EXISTS iss_passes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
pass_time TEXT NOT NULL
|
pass_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS global_events (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
event_time TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|
@ -3,7 +3,7 @@ import * as satellite from 'satellite.js';
|
||||||
|
|
||||||
const MY_LAT = 55.6683;
|
const MY_LAT = 55.6683;
|
||||||
const MY_LON = 12.5333;
|
const MY_LON = 12.5333;
|
||||||
const MY_ALT = 0; // Altitude in kilometers
|
const MY_ALT = 0;
|
||||||
|
|
||||||
async function updateISSData() {
|
async function updateISSData() {
|
||||||
console.log("🛰️ Fetching TLE data...");
|
console.log("🛰️ Fetching TLE data...");
|
||||||
|
|
@ -14,47 +14,105 @@ async function updateISSData() {
|
||||||
|
|
||||||
const line1 = lines[1];
|
const line1 = lines[1];
|
||||||
const line2 = lines[2];
|
const line2 = lines[2];
|
||||||
|
|
||||||
// 1. Initialize satellite record from TLE
|
|
||||||
const satrec = satellite.twoline2satrec(line1, line2);
|
const satrec = satellite.twoline2satrec(line1, line2);
|
||||||
|
|
||||||
// 2. Observer coordinates in radians
|
|
||||||
const observerGd = {
|
const observerGd = {
|
||||||
longitude: satellite.degreesToRadians(MY_LON),
|
longitude: satellite.degreesToRadians(MY_LON),
|
||||||
latitude: satellite.degreesToRadians(MY_LAT),
|
latitude: satellite.degreesToRadians(MY_LAT),
|
||||||
height: MY_ALT
|
height: MY_ALT
|
||||||
};
|
};
|
||||||
|
|
||||||
let nextPassDate: Date | null = null;
|
const passesFound: { start: Date, end: Date }[] = [];
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const searchTime = new Date(now.getTime());
|
||||||
|
|
||||||
// 3. Look ahead for the next 24 hours (1440 minutes)
|
const maxSearchMinutes = 2880;
|
||||||
for (let i = 0; i < 1440; i++) {
|
let minutesSearched = 0;
|
||||||
const checkTime = new Date(now.getTime() + i * 60000);
|
|
||||||
|
while (passesFound.length < 2 && minutesSearched < maxSearchMinutes) {
|
||||||
|
const checkTime = new Date(searchTime.getTime() + (minutesSearched * 60000));
|
||||||
const positionAndVelocity = satellite.propagate(satrec, checkTime);
|
const positionAndVelocity = satellite.propagate(satrec, checkTime);
|
||||||
const gmst = satellite.gstime(checkTime);
|
const gmst = satellite.gstime(checkTime);
|
||||||
|
|
||||||
// Calculate Look Angles (Azimuth, Elevation, Range)
|
|
||||||
if (typeof positionAndVelocity?.position !== 'boolean') {
|
if (typeof positionAndVelocity?.position !== 'boolean') {
|
||||||
const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst);
|
const positionEcf = satellite.eciToEcf(positionAndVelocity!.position, gmst);
|
||||||
const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf);
|
const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf);
|
||||||
const elevation = satellite.radiansToDegrees(lookAngles.elevation);
|
const elevation = satellite.radiansToDegrees(lookAngles.elevation);
|
||||||
|
|
||||||
// If elevation > 0, the ISS is above your horizon!
|
|
||||||
if (elevation > 0) {
|
if (elevation > 0) {
|
||||||
nextPassDate = checkTime;
|
const aos = checkTime;
|
||||||
|
let los = new Date(aos.getTime() + 5 * 60000);
|
||||||
|
|
||||||
|
for (let j = 1; j < 20; j++) {
|
||||||
|
const exitTime = new Date(aos.getTime() + j * 60000);
|
||||||
|
const pos = satellite.propagate(satrec, exitTime);
|
||||||
|
const gmstExit = satellite.gstime(exitTime);
|
||||||
|
if (typeof pos?.position !== 'boolean') {
|
||||||
|
const lookExit = satellite.ecfToLookAngles(observerGd, satellite.eciToEcf(pos!.position, gmstExit));
|
||||||
|
if (satellite.radiansToDegrees(lookExit.elevation) < 0) {
|
||||||
|
los = exitTime;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextPassDate) {
|
passesFound.push({ start: aos, end: los });
|
||||||
const upsert = db.prepare('INSERT INTO iss_passes (id, pass_time) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET pass_time=excluded.pass_time');
|
|
||||||
upsert.run(nextPassDate.toISOString());
|
minutesSearched += 90;
|
||||||
console.log(`✅ Real orbital pass found: ${nextPassDate.toISOString()}`);
|
continue;
|
||||||
} else {
|
|
||||||
console.log("❌ No pass found in the next 24 hours (check your TLE or coordinates).");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
minutesSearched++;
|
||||||
|
}
|
||||||
|
|
||||||
updateISSData().catch(console.error);
|
db.prepare('DELETE FROM iss_passes').run();
|
||||||
|
const insert = db.prepare('INSERT INTO iss_passes (pass_time, end_time) VALUES (?, ?)');
|
||||||
|
|
||||||
|
for (const pass of passesFound) {
|
||||||
|
insert.run(pass.start.toISOString(), pass.end.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Stored ${passesFound.length} future passes with start and end times.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this to your worker script
|
||||||
|
function getNextMoonPhase() {
|
||||||
|
const LUNAR_CYCLE = 29.53059; // Days
|
||||||
|
const KNOWN_NEW_MOON = new Date('2024-01-11T11:57:00Z'); // A reference New Moon
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const msSinceReference = now.getTime() - KNOWN_NEW_MOON.getTime();
|
||||||
|
const daysSinceReference = msSinceReference / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
const currentCycleProgress = daysSinceReference % LUNAR_CYCLE;
|
||||||
|
const daysToFullMoon = (LUNAR_CYCLE / 2) - currentCycleProgress;
|
||||||
|
const daysToNewMoon = LUNAR_CYCLE - currentCycleProgress;
|
||||||
|
|
||||||
|
// If we've passed the Full Moon in this cycle, count to New Moon
|
||||||
|
let targetDate, title;
|
||||||
|
if (daysToFullMoon > 0) {
|
||||||
|
targetDate = new Date(now.getTime() + daysToFullMoon * 86400000);
|
||||||
|
title = "Next Full Moon";
|
||||||
|
} else {
|
||||||
|
targetDate = new Date(now.getTime() + daysToNewMoon * 86400000);
|
||||||
|
title = "Next New Moon";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title, targetDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAllData() {
|
||||||
|
await updateISSData();
|
||||||
|
|
||||||
|
const moon = getNextMoonPhase();
|
||||||
|
const upsertMoon = db.prepare(`
|
||||||
|
INSERT INTO global_events (id, title, event_time)
|
||||||
|
VALUES ('moon_phase', ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET title=excluded.title, event_time=excluded.event_time
|
||||||
|
`);
|
||||||
|
upsertMoon.run(moon.title, moon.targetDate.toISOString());
|
||||||
|
|
||||||
|
console.log(`🌙 Updated Moon Phase: ${moon.title}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAllData().catch(console.error);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
interface EventCardProps {
|
|
||||||
title: string;
|
|
||||||
targetDate: Date;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
export interface ISSPass {
|
export interface ISSPass {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
@ -24,7 +22,14 @@ export interface Star {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventCardProps {
|
export interface EventCardProps {
|
||||||
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
targetDate: Date;
|
targetDate: Date | null;
|
||||||
icon?: ReactNode;
|
endDate: Date | null;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissionControlProps {
|
||||||
|
iss: { start: Date | null; end: Date | null };
|
||||||
|
moon: { title: string; start: Date | null; end: Date | null };
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue