initial commit
This commit is contained in:
+174
@@ -0,0 +1,174 @@
|
||||
:root{
|
||||
--bg:#0b0f19;
|
||||
--panel:#0f1626;
|
||||
--panel2:#0d1322;
|
||||
--text:#e7eaf0;
|
||||
--muted:#9aa6bd;
|
||||
--border:#1f2a44;
|
||||
--accent:#6d5efc;
|
||||
--good:#22c55e;
|
||||
--warn:#f59e0b;
|
||||
--shadow: 0 10px 30px rgba(0,0,0,.35);
|
||||
--radius:18px;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono","Courier New", monospace;
|
||||
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%;margin:0;background:var(--bg);color:var(--text);font-family:var(--sans)}
|
||||
a{color:inherit}
|
||||
|
||||
.app{display:flex;height:100vh;width:100vw;overflow:hidden}
|
||||
.sidebar{
|
||||
width:360px; min-width:320px; max-width:420px;
|
||||
background:linear-gradient(180deg, var(--panel) 0%, var(--panel2) 100%);
|
||||
border-right:1px solid var(--border);
|
||||
padding:16px;
|
||||
display:flex; flex-direction:column; gap:14px;
|
||||
}
|
||||
.brand{display:flex; align-items:center; gap:12px; padding:8px 8px 2px}
|
||||
.logo{
|
||||
width:44px;height:44px;border-radius:14px;
|
||||
background: radial-gradient(circle at 30% 20%, #8b7cff 0%, #6d5efc 40%, #3b33b8 100%);
|
||||
box-shadow: var(--shadow);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-weight:800; letter-spacing:.6px;
|
||||
}
|
||||
.brandText .title{font-weight:700}
|
||||
.brandText .subtitle{color:var(--muted);font-size:12px;margin-top:2px}
|
||||
|
||||
.panel{
|
||||
background: rgba(255,255,255,.02);
|
||||
border:1px solid var(--border);
|
||||
border-radius:var(--radius);
|
||||
padding:14px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow:auto;
|
||||
}
|
||||
|
||||
.label{display:block;margin:10px 2px 6px;color:var(--muted);font-size:12px}
|
||||
.select,.input{
|
||||
width:100%;
|
||||
background:#0a1020;
|
||||
border:1px solid var(--border);
|
||||
color:var(--text);
|
||||
border-radius:12px;
|
||||
padding:10px 12px;
|
||||
outline:none;
|
||||
}
|
||||
.select:focus,.input:focus{border-color:#2a3b68; box-shadow:0 0 0 3px rgba(109,94,252,.18)}
|
||||
.hint{color:var(--muted);font-size:12px;margin-top:6px;line-height:1.3}
|
||||
|
||||
.row{display:flex; gap:12px; margin-top:8px}
|
||||
.col{flex:1}
|
||||
|
||||
.toggles{margin-top:10px; display:flex; flex-direction:column; gap:8px}
|
||||
.toggle{
|
||||
display:flex; align-items:center; gap:10px;
|
||||
padding:10px 10px;
|
||||
border:1px solid rgba(31,42,68,.8);
|
||||
background: rgba(0,0,0,.12);
|
||||
border-radius:14px;
|
||||
color:var(--text);
|
||||
user-select:none;
|
||||
}
|
||||
.toggle input{accent-color:var(--accent); width:16px; height:16px}
|
||||
|
||||
.buttons{margin-top:12px}
|
||||
.btn{
|
||||
border:1px solid var(--border);
|
||||
background: rgba(255,255,255,.04);
|
||||
color:var(--text);
|
||||
border-radius:14px;
|
||||
padding:10px 12px;
|
||||
cursor:pointer;
|
||||
transition: transform .05s ease, background .15s ease, border-color .15s ease;
|
||||
}
|
||||
.btn:hover{background: rgba(255,255,255,.06); border-color:#2a3b68}
|
||||
.btn:active{transform: translateY(1px)}
|
||||
.btn.primary{
|
||||
background: rgba(109,94,252,.18);
|
||||
border-color: rgba(109,94,252,.45);
|
||||
}
|
||||
.btn.primary:hover{background: rgba(109,94,252,.26)}
|
||||
.btn.icon{padding:6px 10px;border-radius:12px}
|
||||
|
||||
.stats{
|
||||
margin-top:14px;
|
||||
border-top:1px solid rgba(31,42,68,.7);
|
||||
padding-top:12px;
|
||||
display:flex; flex-direction:column; gap:6px;
|
||||
}
|
||||
.k{color:var(--muted);font-size:12px}
|
||||
.v{font-weight:650}
|
||||
.small{font-size:12px;color:var(--muted);line-height:1.35}
|
||||
.mono{font-family:var(--mono)}
|
||||
|
||||
.main{flex:1; position:relative}
|
||||
.globe{height:100%; width:100%}
|
||||
|
||||
/* Cesium container must fill */
|
||||
#cesiumContainer, .cesium-viewer, .cesium-viewer-cesiumWidget {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hide default Cesium credits (we provide our own attribution elsewhere if you want) */
|
||||
.cesium-widget-credits { display:none !important; }
|
||||
|
||||
/* Top chips */
|
||||
.topbar{
|
||||
position:absolute; top:14px; left:14px; right:14px;
|
||||
display:flex; gap:10px; justify-content:flex-end;
|
||||
pointer-events:none;
|
||||
}
|
||||
.chip{
|
||||
pointer-events:none;
|
||||
padding:8px 10px;
|
||||
border-radius:999px;
|
||||
background: rgba(15,22,38,.75);
|
||||
border:1px solid rgba(31,42,68,.75);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: var(--shadow);
|
||||
font-size:12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chip.warn{color:#fff; background: rgba(245,158,11,.20); border-color: rgba(245,158,11,.45)}
|
||||
|
||||
/* Detail card */
|
||||
.detail{
|
||||
position:absolute; right:14px; bottom:14px;
|
||||
width:min(520px, calc(100% - 28px));
|
||||
background: rgba(15,22,38,.92);
|
||||
border:1px solid rgba(31,42,68,.9);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding:12px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.detailHeader{display:flex; align-items:center; justify-content:space-between; gap:12px; padding:4px 2px 10px}
|
||||
.detailTitle{font-weight:750}
|
||||
.detailGrid{
|
||||
display:grid;
|
||||
grid-template-columns: repeat(2, minmax(0,1fr));
|
||||
gap:8px 16px;
|
||||
padding:8px 2px 10px;
|
||||
border-top:1px solid rgba(31,42,68,.65);
|
||||
border-bottom:1px solid rgba(31,42,68,.65);
|
||||
}
|
||||
.detailTle{
|
||||
white-space:pre;
|
||||
margin-top:10px;
|
||||
background: rgba(0,0,0,.18);
|
||||
border:1px solid rgba(31,42,68,.65);
|
||||
border-radius:14px;
|
||||
padding:10px;
|
||||
overflow:auto;
|
||||
max-height:140px;
|
||||
}
|
||||
|
||||
/* Static crisp background image behind Cesium */
|
||||
#cesiumContainer{
|
||||
background: url("/assets/bg/space.jpg") center center / cover no-repeat;
|
||||
background-color: #05060a; /* fallback */
|
||||
}
|
||||
+618
@@ -0,0 +1,618 @@
|
||||
/* 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();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 988 KiB |
Reference in New Issue
Block a user