/* 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) { // don’t 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 didn’t 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 it’s 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();