Files
SAT/assets/app.js
T
2026-06-25 21:36:46 +00:00

618 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* sat.thedarkelite.com - Cesium globe client
- Static crisp background image behind Cesium (/assets/bg/space.jpg) with subtle drift
- Cesium canvas made transparent (alpha) so CSS background shows through
- Radar overlay (RainViewer) as an imagery layer (tiles)
- Satellites via TLE -> SGP4 (satellite.js)
*/
const API = {
groups: "/api/groups.php",
tle: (group) => `/api/tle.php?group=${encodeURIComponent(group)}`
};
// RainViewer Weather Maps API endpoint (public)
const RADAR = {
api: "https://api.rainviewer.com/public/weather-maps.json", // provides host + frames[1](https://www.rainviewer.com/api/weather-maps-api.html)
size: 256,
color: 2, // universal blue (good default)
options: "1_0", // smooth=1, snow=0 (see docs)[1](https://www.rainviewer.com/api/weather-maps-api.html)
opacity: 0.55,
refreshMs: 5 * 60 * 1000
};
const state = {
groups: [],
group: "noaa",
sats: [],
satsFiltered: [],
satrecs: new Map(), // norad -> satrec
entities: new Map(), // norad -> Cesium.Entity
selected: null, // norad
trackEntity: null,
lastMeta: null,
timer: null,
// background drift
drift: { enabled: true, raf: null },
// radar
radar: {
layer: null, // Cesium.ImageryLayer
provider: null, // Cesium.UrlTemplateImageryProvider
framePath: null, // current /v2/radar/##########
lastUpdate: 0,
timer: null
}
};
const el = (id) => document.getElementById(id);
function fmt(n, d = 3) {
if (n === null || n === undefined || Number.isNaN(n)) return "—";
return Number(n).toFixed(d);
}
function setWarn(msg) {
const chip = el("chipWarn");
if (!chip) return;
if (!msg) {
chip.style.display = "none";
chip.textContent = "";
return;
}
chip.style.display = "inline-flex";
chip.textContent = msg;
}
function metaString(meta) {
if (!meta) return "—";
const parts = [];
if (meta.groupName) parts.push(meta.groupName);
if (meta.source) parts.push(meta.source);
if (meta.cached !== undefined) parts.push(meta.cached ? "cached" : "fresh");
if (meta.fetchedAtUtc) parts.push(`fetched ${meta.fetchedAtUtc} UTC`);
if (meta.cacheTtlSeconds) parts.push(`TTL ${Math.round(meta.cacheTtlSeconds / 60)}m`);
if (meta.count !== undefined) parts.push(`${meta.count} sats`);
return parts.join(" • ");
}
/* ---------- Cesium init ---------- */
const viewer = new Cesium.Viewer("cesiumContainer", {
animation: false,
timeline: false,
baseLayerPicker: false,
homeButton: false,
geocoder: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
infoBox: false,
selectionIndicator: false,
shouldAnimate: false,
// KEY: make the canvas support transparency so CSS background shows through
contextOptions: {
webgl: { alpha: true }
}
});
// --- BASEMAP (labeled, dark) ---
// Using CARTO dark_all with proper "@2x" support via customTags.
// CARTO documents optional @2x tiles as "{scale}" in URL pattern.[5](https://cdnjs.com/libraries/leaflet)
viewer.imageryLayers.removeAll();
const basemap = viewer.imageryLayers.addImageryProvider(
new Cesium.UrlTemplateImageryProvider({
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{scale}.png",
subdomains: ["a", "b", "c", "d"],
maximumLevel: 20,
credit: "© OpenStreetMap contributors © CARTO",
customTags: {
scale: () => (window.devicePixelRatio > 1 ? "@2x" : "")
}
})
); // UrlTemplateImageryProvider supports URL templates + customTags[4](https://usradioguy.com/wxtoimg-kepler-fix/)
// Minor “ops” tuning
basemap.brightness = 0.90;
basemap.contrast = 1.15;
basemap.saturation = 0.95;
basemap.gamma = 1.05;
// ---- CLEAN CRISP BACKGROUND IMAGE MODE ----
// Disable Cesium skybox entirely (prevents blurry/noisy stars). SkyBox can be assigned/removed.[6](https://github.com/CesiumGS/cesium/releases)
viewer.scene.skyBox = null;
viewer.scene.skyAtmosphere.show = false;
viewer.scene.fog.enabled = false;
// Make Cesium background transparent so the CSS image is visible
viewer.scene.backgroundColor = Cesium.Color.TRANSPARENT;
viewer.scene.globe.depthTestAgainstTerrain = false;
// Start camera over North America
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-98.5, 38.5, 12_500_000)
});
function applyLightingToggle() {
const t = el("toggleLighting");
viewer.scene.globe.enableLighting = !!(t && t.checked);
}
/* ---------- Background drift (subtle parallax) ---------- */
function startBackgroundDrift() {
if (!state.drift.enabled) return;
const container = document.getElementById("cesiumContainer");
if (!container) return;
const maxShiftPx = 28;
const smooth = 0.10;
let curX = 50;
let curY = 50;
function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
function tick() {
const heading = viewer.camera.heading || 0;
const pitch = viewer.camera.pitch || 0;
let hn = ((heading + Math.PI) % (Math.PI * 2)) - Math.PI;
const targetXpx = (hn / Math.PI) * maxShiftPx;
const targetYpx = (pitch / (Math.PI / 2)) * (maxShiftPx * 0.6);
const w = container.clientWidth || 1;
const h = container.clientHeight || 1;
const targetX = 50 + (targetXpx / w) * 100;
const targetY = 50 + (targetYpx / h) * 100;
curX = curX + (targetX - curX) * smooth;
curY = curY + (targetY - curY) * smooth;
curX = clamp(curX, 45, 55);
curY = clamp(curY, 46, 54);
container.style.backgroundPosition = `${curX.toFixed(2)}% ${curY.toFixed(2)}%`;
state.drift.raf = requestAnimationFrame(tick);
}
if (state.drift.raf) cancelAnimationFrame(state.drift.raf);
state.drift.raf = requestAnimationFrame(tick);
}
/* ---------- Radar overlay (RainViewer tiles) ---------- */
/*
RainViewer provides:
- JSON listing frames, plus "host" and "path" fields
- Tile URL format: {host}{path}/{size}/{z}/{x}/{y}/{color}/{options}.png[1](https://www.rainviewer.com/api/weather-maps-api.html)
- Public API limitations include max zoom 7 in examples/docs[3](https://github.com/rainviewer/rainviewer-api-example/blob/master/README.md)[1](https://www.rainviewer.com/api/weather-maps-api.html)
*/
async function updateRadarLayer(force = false) {
const now = Date.now();
if (!force && now - state.radar.lastUpdate < RADAR.refreshMs) return;
state.radar.lastUpdate = now;
let data;
try {
const res = await fetch(RADAR.api, { cache: "no-store" });
if (!res.ok) throw new Error(`RainViewer HTTP ${res.status}`);
data = await res.json();
} catch (e) {
// dont kill app if radar fails
console.warn("Radar fetch failed:", e);
return;
}
const host = data.host;
const past = data?.radar?.past;
if (!host || !Array.isArray(past) || past.length === 0) return;
// Use latest past frame (stable)
const latest = past[past.length - 1];
const path = latest.path; // like "/v2/radar/##########"[1](https://www.rainviewer.com/api/weather-maps-api.html)
if (!path) return;
// If frame didnt change, nothing to do
if (state.radar.framePath === path && state.radar.layer) return;
state.radar.framePath = path;
// Build tile template
const url =
`${host}${path}/${RADAR.size}/{z}/{x}/{y}/${RADAR.color}/${RADAR.options}.png`;
const provider = new Cesium.UrlTemplateImageryProvider({
url,
maximumLevel: 7, // public max zoom per RainViewer docs/examples[1](https://www.rainviewer.com/api/weather-maps-api.html)[3](https://github.com/rainviewer/rainviewer-api-example/blob/master/README.md)
credit: "Radar: RainViewer"
}); // UrlTemplateImageryProvider supports tile templates[4](https://usradioguy.com/wxtoimg-kepler-fix/)
// Remove old layer if exists
if (state.radar.layer) {
viewer.imageryLayers.remove(state.radar.layer, true);
state.radar.layer = null;
}
state.radar.provider = provider;
state.radar.layer = viewer.imageryLayers.addImageryProvider(provider);
// Put radar above basemap
// (it is added last so its already on top; keep explicit ordering safe)
state.radar.layer.alpha = RADAR.opacity;
}
function startRadarAutoRefresh() {
// Load immediately, then every 5 min
updateRadarLayer(true);
if (state.radar.timer) clearInterval(state.radar.timer);
state.radar.timer = setInterval(() => updateRadarLayer(false), RADAR.refreshMs);
}
/* ---------- Click picking ---------- */
const clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler.setInputAction((movement) => {
const picked = viewer.scene.pick(movement.position);
if (Cesium.defined(picked) && picked.id && picked.id.id) {
selectSatellite(String(picked.id.id));
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
/* ---------- Data + propagation ---------- */
async function loadGroups() {
const res = await fetch(API.groups, { cache: "no-store" });
if (!res.ok) throw new Error(`groups.php HTTP ${res.status}`);
const data = await res.json();
state.groups = data.groups || [];
return state.groups;
}
function fillGroupSelect() {
const sel = el("groupSelect");
sel.innerHTML = "";
for (const g of state.groups) {
const opt = document.createElement("option");
opt.value = g.id;
opt.textContent = g.name;
if (g.id === state.group) opt.selected = true;
sel.appendChild(opt);
}
}
async function loadTLEs(group) {
setWarn("");
el("statMeta").textContent = "Loading…";
const res = await fetch(API.tle(group), { cache: "no-store" });
const txt = await res.text();
if (!res.ok) throw new Error(`tle.php HTTP ${res.status} ${txt}`);
const data = JSON.parse(txt);
state.lastMeta = data.meta || null;
state.sats = (data.satellites || []).map((s) => ({
name: s.name,
norad: String(s.norad),
line1: s.line1,
line2: s.line2,
epoch: s.epoch || null
}));
state.satrecs.clear();
for (const s of state.sats) {
try {
const satrec = satellite.twoline2satrec(s.line1, s.line2);
state.satrecs.set(s.norad, satrec);
} catch (_) {}
}
el("statLoaded").textContent = String(state.sats.length);
el("statMeta").textContent = metaString(state.lastMeta);
clearSelection();
applyFilterAndRender();
}
function filterSats() {
const q = (el("searchBox").value || "").trim().toLowerCase();
const max = Math.max(50, Math.min(5000, parseInt(el("maxSats").value || "800", 10)));
let arr = state.sats;
if (q) {
arr = arr.filter(
(s) => s.name.toLowerCase().includes(q) || s.norad.toLowerCase() === q
);
}
arr = arr.slice(0, max);
state.satsFiltered = arr;
el("statShown").textContent = String(arr.length);
}
function computeLatLonAltKm(norad, date) {
const satrec = state.satrecs.get(norad);
if (!satrec) return null;
const pv = satellite.propagate(satrec, date);
if (!pv.position) return null;
const gmst = satellite.gstime(date);
const gd = satellite.eciToGeodetic(pv.position, gmst);
const lat = satellite.degreesLat(gd.latitude);
const lon = satellite.degreesLong(gd.longitude);
const altKm = gd.height;
let speed = null;
if (pv.velocity) {
const v = pv.velocity;
speed = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
}
return { lat, lon, altKm, speed };
}
/* ---------- Entities ---------- */
function clearEntities() {
for (const ent of state.entities.values()) viewer.entities.remove(ent);
state.entities.clear();
if (state.trackEntity) {
viewer.entities.remove(state.trackEntity);
state.trackEntity = null;
}
}
function makeSatEntity(sat) {
return viewer.entities.add({
id: sat.norad,
name: sat.name,
position: Cesium.Cartesian3.fromDegrees(0, 0, 500000),
point: {
pixelSize: 7,
color: Cesium.Color.fromCssColorString("#9b8cff"),
outlineColor: Cesium.Color.fromCssColorString("#000000"),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
});
}
function applyFilterAndRender() {
filterSats();
clearEntities();
for (const sat of state.satsFiltered) {
const ent = makeSatEntity(sat);
state.entities.set(sat.norad, ent);
}
tick();
}
function selectSatellite(norad) {
state.selected = norad;
const sat = state.sats.find((x) => x.norad === norad) || null;
el("statSelected").textContent = sat ? `${sat.name} (${sat.norad})` : "—";
for (const [id, ent] of state.entities.entries()) {
const active = id === norad;
if (ent.point) {
ent.point.pixelSize = active ? 11 : 7;
ent.point.color = active
? Cesium.Color.fromCssColorString("#22c55e")
: Cesium.Color.fromCssColorString("#9b8cff");
ent.point.outlineColor = active
? Cesium.Color.fromCssColorString("#ffffff")
: Cesium.Color.fromCssColorString("#000000");
ent.point.outlineWidth = 2;
}
if (el("toggleLabels").checked && active && sat) {
ent.label = new Cesium.LabelGraphics({
text: `${sat.name} (${sat.norad})`,
font: "14px ui-sans-serif",
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.BLACK,
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(12, -12),
disableDepthTestDistance: Number.POSITIVE_INFINITY
});
} else {
ent.label = undefined;
}
}
if (!sat) {
el("detail").style.display = "none";
if (state.trackEntity) {
viewer.entities.remove(state.trackEntity);
state.trackEntity = null;
}
return;
}
el("detailTitle").textContent = sat.name;
el("detailNorad").textContent = sat.norad;
el("detailTle").textContent = `${sat.line1}\n${sat.line2}`;
el("detailEpoch").textContent = sat.epoch || "—";
el("detail").style.display = "block";
const pos = computeLatLonAltKm(norad, new Date());
if (pos) {
const height = Math.max(1_500_000, pos.altKm * 1000 * 6);
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, height),
duration: 0.6
});
}
drawTrackIfEnabled();
}
function clearSelection() {
state.selected = null;
el("statSelected").textContent = "—";
el("detail").style.display = "none";
for (const ent of state.entities.values()) {
if (ent.point) {
ent.point.pixelSize = 7;
ent.point.color = Cesium.Color.fromCssColorString("#9b8cff");
ent.point.outlineColor = Cesium.Color.fromCssColorString("#000000");
ent.point.outlineWidth = 2;
}
ent.label = undefined;
}
if (state.trackEntity) {
viewer.entities.remove(state.trackEntity);
state.trackEntity = null;
}
}
function drawTrackIfEnabled() {
if (!el("toggleTracks").checked) {
if (state.trackEntity) {
viewer.entities.remove(state.trackEntity);
state.trackEntity = null;
}
return;
}
if (!state.selected) return;
const norad = state.selected;
const now = new Date();
const positions = [];
for (let t = -10 * 60; t <= 80 * 60; t += 30) {
const d = new Date(now.getTime() + t * 1000);
const p = computeLatLonAltKm(norad, d);
if (!p) continue;
positions.push(Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.altKm * 1000));
}
if (state.trackEntity) {
viewer.entities.remove(state.trackEntity);
state.trackEntity = null;
}
state.trackEntity = viewer.entities.add({
id: "__track__",
polyline: {
positions,
width: 2,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.12,
color: Cesium.Color.fromCssColorString("#22c55e")
})
}
});
}
/* ---------- Tick loop ---------- */
function tick() {
const now = new Date();
el("chipTime").textContent = `UTC ${now.toISOString().slice(11, 19)}`;
viewer.clock.currentTime = Cesium.JulianDate.fromDate(now);
let selectedPos = null;
for (const sat of state.satsFiltered) {
const p = computeLatLonAltKm(sat.norad, now);
if (!p) continue;
const ent = state.entities.get(sat.norad);
if (ent) ent.position = Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.altKm * 1000);
if (state.selected === sat.norad) selectedPos = p;
}
if (selectedPos) {
el("detailLat").textContent = fmt(selectedPos.lat, 4);
el("detailLon").textContent = fmt(selectedPos.lon, 4);
el("detailAlt").textContent = fmt(selectedPos.altKm, 2);
el("detailSpeed").textContent = fmt(selectedPos.speed, 3);
}
if (state.selected && el("toggleTracks").checked) {
const sec = now.getUTCSeconds();
if (sec % 5 === 0) drawTrackIfEnabled();
}
}
/* ---------- UI wiring ---------- */
function wireUI() {
el("groupSelect").addEventListener("change", async (e) => {
state.group = e.target.value;
clearSelection();
await loadTLEs(state.group);
});
el("btnReload").addEventListener("click", async () => {
clearSelection();
await loadTLEs(state.group);
});
el("btnClear").addEventListener("click", () => clearSelection());
el("detailClose").addEventListener("click", () => clearSelection());
el("searchBox").addEventListener("input", () => {
clearSelection();
applyFilterAndRender();
});
el("maxSats").addEventListener("change", () => {
clearSelection();
applyFilterAndRender();
});
el("toggleTracks").addEventListener("change", () => drawTrackIfEnabled());
el("toggleLabels").addEventListener("change", () => {
if (state.selected) selectSatellite(state.selected);
});
el("toggleLighting").addEventListener("change", () => applyLightingToggle());
}
async function boot() {
try {
wireUI();
applyLightingToggle();
await loadGroups();
fillGroupSelect();
await loadTLEs(state.group);
startBackgroundDrift();
// Start radar overlay updater
startRadarAutoRefresh();
if (state.timer) clearInterval(state.timer);
state.timer = setInterval(tick, 1000);
el("chipRate").textContent = "Update: 1s";
} catch (err) {
console.error(err);
setWarn("Load failed (see console)");
el("statMeta").textContent = String(err.message || err);
}
}
boot();