791 lines
21 KiB
PHP
791 lines
21 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
session_start();
|
|
|
|
$loggedIn = isset($_SESSION['user_id']);
|
|
$pageTitle = 'Geocron 3D - AstroTools';
|
|
$bodyClass = 'geocron-page geocron3d-page';
|
|
$publicBasePath = './';
|
|
|
|
require __DIR__ . '/header.php';
|
|
?>
|
|
|
|
<section class="card geocron-card">
|
|
<div class="geocron-stage geocron3d-stage">
|
|
<div class="geocron3d-shell">
|
|
<div class="geocron3d-copy">
|
|
<p class="geocron3d-kicker">Prototyp</p>
|
|
<h2>3D-Erde mit live berechneten Satellitenbahnen</h2>
|
|
<p>
|
|
Ziehen zum Drehen, Mausrad zum Zoomen. Die Satellitenpositionen werden aus den
|
|
aktuellen TLE-Daten deines Accounts berechnet und als Objekte oberhalb der Erde gezeigt.
|
|
</p>
|
|
<label class="geocron3d-toggle">
|
|
<input type="checkbox" id="geocron3dLabelsToggle">
|
|
<span>Satelliten-Beschriftungen</span>
|
|
</label>
|
|
<div class="geocron3d-status" id="geocron3dStatus">Lade 3D-Szene…</div>
|
|
</div>
|
|
<div class="geocron3d-view" id="geocron3dView">
|
|
<canvas id="geocron3dCanvas" aria-label="3D Erde mit Satelliten"></canvas>
|
|
<div class="geocron3d-labels" id="geocron3dLabels" aria-hidden="true"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.geocron3d-page main > .container {
|
|
max-width: min(1720px, calc(100% - 28px));
|
|
}
|
|
|
|
.geocron3d-stage {
|
|
padding: 18px;
|
|
}
|
|
|
|
.geocron3d-shell {
|
|
display: grid;
|
|
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
|
|
gap: 18px;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.geocron3d-copy,
|
|
.geocron3d-view {
|
|
min-height: 620px;
|
|
border: 1px solid rgba(201, 168, 76, 0.14);
|
|
border-radius: calc(var(--radius) + 4px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.geocron3d-copy {
|
|
padding: 22px 20px;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(74, 158, 255, 0.16), transparent 38%),
|
|
linear-gradient(180deg, rgba(10, 15, 30, 0.96) 0%, rgba(7, 11, 22, 0.98) 100%);
|
|
}
|
|
|
|
.geocron3d-kicker {
|
|
margin: 0 0 10px;
|
|
color: rgba(201, 168, 76, 0.82);
|
|
font-size: 0.74rem;
|
|
letter-spacing: 0.18em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.geocron3d-copy h2 {
|
|
margin: 0 0 14px;
|
|
font-size: clamp(1.5rem, 1.2rem + 1vw, 2.1rem);
|
|
}
|
|
|
|
.geocron3d-copy p {
|
|
margin: 0 0 16px;
|
|
color: rgba(222, 232, 255, 0.86);
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.geocron3d-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin: 0 0 16px;
|
|
color: rgba(222, 232, 255, 0.88);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
|
|
.geocron3d-toggle input {
|
|
width: 16px;
|
|
height: 16px;
|
|
accent-color: #6b9dff;
|
|
}
|
|
|
|
.geocron3d-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 40px;
|
|
padding: 8px 12px;
|
|
border: 1px solid rgba(108, 145, 220, 0.28);
|
|
border-radius: 999px;
|
|
background: rgba(10, 16, 32, 0.72);
|
|
color: rgba(192, 214, 255, 0.92);
|
|
font-size: 0.92rem;
|
|
}
|
|
|
|
.geocron3d-view {
|
|
position: relative;
|
|
background:
|
|
radial-gradient(circle at 50% 36%, rgba(70, 110, 190, 0.12), transparent 30%),
|
|
radial-gradient(circle at 50% 68%, rgba(8, 12, 20, 0.1), transparent 44%),
|
|
#02050b;
|
|
}
|
|
|
|
.geocron3d-view canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: grab;
|
|
}
|
|
|
|
.geocron3d-view canvas.is-dragging {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.geocron3d-labels {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.geocron3d-label {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
padding: 4px 8px;
|
|
border: 1px solid rgba(110, 150, 230, 0.35);
|
|
border-radius: 999px;
|
|
background: rgba(6, 10, 20, 0.78);
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
letter-spacing: 0.03em;
|
|
white-space: nowrap;
|
|
transform: translate(-50%, -50%);
|
|
backdrop-filter: blur(8px);
|
|
opacity: 0;
|
|
transition: opacity 0.16s ease;
|
|
}
|
|
|
|
.geocron3d-label.is-visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (max-width: 1080px) {
|
|
.geocron3d-shell {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.geocron3d-copy,
|
|
.geocron3d-view {
|
|
min-height: 280px;
|
|
}
|
|
|
|
.geocron3d-view {
|
|
min-height: 520px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "./js/three.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from './js/GLTFLoader.js';
|
|
|
|
const EARTH_RADIUS = 6;
|
|
const EARTH_REAL_RADIUS_KM = 6371;
|
|
const ORBIT_WINDOW_MINUTES = 90;
|
|
const ORBIT_STEP_SECONDS = 10;
|
|
const ORBIT_REFRESH_MS = 30_000;
|
|
const MONTH_INDEX = new Date().getMonth() + 1;
|
|
const EARTH_LONGITUDE_OFFSET_DEG = 0;
|
|
|
|
const canvas = document.getElementById('geocron3dCanvas');
|
|
const host = document.getElementById('geocron3dView');
|
|
const statusEl = document.getElementById('geocron3dStatus');
|
|
const labelsToggle = document.getElementById('geocron3dLabelsToggle');
|
|
const labelsLayer = document.getElementById('geocron3dLabels');
|
|
let labelsEnabled = false;
|
|
let issModelTemplatePromise = null;
|
|
|
|
function setStatus(text, isError = false) {
|
|
statusEl.textContent = text;
|
|
statusEl.style.color = isError ? 'rgba(255, 196, 196, 0.95)' : 'rgba(192, 214, 255, 0.92)';
|
|
statusEl.style.borderColor = isError ? 'rgba(190, 80, 80, 0.34)' : 'rgba(108, 145, 220, 0.28)';
|
|
}
|
|
|
|
function loadSatelliteJs() {
|
|
return new Promise((resolve, reject) => {
|
|
if (window.satellite) {
|
|
resolve(window.satellite);
|
|
return;
|
|
}
|
|
|
|
const existing = document.querySelector('script[data-satellite-js="true"]');
|
|
if (existing) {
|
|
existing.addEventListener('load', () => resolve(window.satellite), { once: true });
|
|
existing.addEventListener('error', () => reject(new Error('satellite.js konnte nicht geladen werden.')), { once: true });
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/satellite.js@6.0.2/dist/satellite.min.js';
|
|
script.async = true;
|
|
script.dataset.satelliteJs = 'true';
|
|
script.onload = () => resolve(window.satellite);
|
|
script.onerror = () => reject(new Error('satellite.js konnte nicht geladen werden.'));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function lonLatAltToVector3(latDeg, lonDeg, altKm = 0) {
|
|
const lat = THREE.MathUtils.degToRad(latDeg);
|
|
const lon = THREE.MathUtils.degToRad(-lonDeg + EARTH_LONGITUDE_OFFSET_DEG);
|
|
const radius = EARTH_RADIUS * (1 + altKm / EARTH_REAL_RADIUS_KM);
|
|
const cosLat = Math.cos(lat);
|
|
return new THREE.Vector3(
|
|
radius * cosLat * Math.cos(lon),
|
|
radius * Math.sin(lat),
|
|
radius * cosLat * Math.sin(lon)
|
|
);
|
|
}
|
|
|
|
function wrapLongitude(lon) {
|
|
return ((lon + 180) % 360 + 360) % 360 - 180;
|
|
}
|
|
|
|
function createOrbitGeometry(samples) {
|
|
const positions = [];
|
|
for (const sample of samples) {
|
|
const point = lonLatAltToVector3(sample.lat, sample.lon, sample.altKm);
|
|
positions.push(point.x, point.y, point.z);
|
|
}
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
|
return geometry;
|
|
}
|
|
|
|
const renderer = new THREE.WebGLRenderer({
|
|
canvas,
|
|
antialias: true,
|
|
alpha: true,
|
|
});
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x02050b);
|
|
scene.fog = new THREE.FogExp2(0x02050b, 0.04);
|
|
|
|
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 500);
|
|
camera.position.set(0, 4.8, 18);
|
|
|
|
const root = new THREE.Group();
|
|
scene.add(root);
|
|
|
|
const starsGeometry = new THREE.BufferGeometry();
|
|
const starPositions = [];
|
|
for (let i = 0; i < 1500; i++) {
|
|
const radius = 120 + Math.random() * 100;
|
|
const theta = Math.random() * Math.PI * 2;
|
|
const phi = Math.acos(THREE.MathUtils.randFloatSpread(2));
|
|
starPositions.push(
|
|
radius * Math.sin(phi) * Math.cos(theta),
|
|
radius * Math.cos(phi),
|
|
radius * Math.sin(phi) * Math.sin(theta)
|
|
);
|
|
}
|
|
starsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starPositions, 3));
|
|
scene.add(new THREE.Points(
|
|
starsGeometry,
|
|
new THREE.PointsMaterial({
|
|
color: 0xcddcff,
|
|
size: 0.7,
|
|
sizeAttenuation: true,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
})
|
|
));
|
|
|
|
scene.add(new THREE.AmbientLight(0x2a3550, 0.9));
|
|
const hemiLight = new THREE.HemisphereLight(0x5f7db5, 0x08101c, 0.8);
|
|
scene.add(hemiLight);
|
|
const sunLight = new THREE.DirectionalLight(0xfff2c2, 2.5);
|
|
sunLight.position.set(20, 8, 14);
|
|
scene.add(sunLight);
|
|
|
|
const textureLoader = new THREE.TextureLoader();
|
|
const earthDayTexture = textureLoader.load(`./images/planets/earth/day/${MONTH_INDEX}.jpg`);
|
|
earthDayTexture.colorSpace = THREE.SRGBColorSpace;
|
|
const earthNightTexture = textureLoader.load(`./images/planets/earth/night/${MONTH_INDEX}.jpg`);
|
|
earthNightTexture.colorSpace = THREE.SRGBColorSpace;
|
|
|
|
const earthMaterial = new THREE.MeshStandardMaterial({
|
|
map: earthDayTexture,
|
|
roughness: 0.92,
|
|
metalness: 0.02,
|
|
});
|
|
|
|
const shaderUniforms = {
|
|
nightMap: { value: earthNightTexture },
|
|
lightDirView: { value: new THREE.Vector3(0.8, 0.2, 0.4).normalize() },
|
|
};
|
|
|
|
earthMaterial.onBeforeCompile = (shader) => {
|
|
shader.uniforms.nightMap = shaderUniforms.nightMap;
|
|
shader.uniforms.lightDirView = shaderUniforms.lightDirView;
|
|
|
|
shader.fragmentShader = shader.fragmentShader
|
|
.replace(
|
|
'#include <common>',
|
|
`#include <common>
|
|
uniform sampler2D nightMap;
|
|
uniform vec3 lightDirView;`
|
|
)
|
|
.replace(
|
|
'#include <normal_fragment_maps>',
|
|
`#include <normal_fragment_maps>
|
|
vec4 nightSample = texture2D(nightMap, vMapUv);
|
|
float nightMask = 1.0 - smoothstep(-0.12, 0.18, dot(normalize(normal), normalize(lightDirView)));
|
|
diffuseColor.rgb = mix(diffuseColor.rgb, nightSample.rgb, nightMask * 0.92);`
|
|
);
|
|
};
|
|
|
|
const earthMesh = new THREE.Mesh(
|
|
new THREE.SphereGeometry(EARTH_RADIUS, 96, 96),
|
|
earthMaterial
|
|
);
|
|
root.add(earthMesh);
|
|
|
|
const atmosphereMesh = new THREE.Mesh(
|
|
new THREE.SphereGeometry(EARTH_RADIUS * 1.035, 64, 64),
|
|
new THREE.MeshBasicMaterial({
|
|
color: 0x5a8dff,
|
|
transparent: true,
|
|
opacity: 0.08,
|
|
side: THREE.BackSide,
|
|
})
|
|
);
|
|
root.add(atmosphereMesh);
|
|
|
|
const earthGlow = new THREE.Sprite(
|
|
new THREE.SpriteMaterial({
|
|
color: 0x7aa7ff,
|
|
transparent: true,
|
|
opacity: 0.16,
|
|
depthWrite: false,
|
|
})
|
|
);
|
|
earthGlow.scale.setScalar(17);
|
|
root.add(earthGlow);
|
|
|
|
const satelliteGroup = new THREE.Group();
|
|
const orbitGroup = new THREE.Group();
|
|
root.add(orbitGroup);
|
|
root.add(satelliteGroup);
|
|
|
|
const dragState = {
|
|
active: false,
|
|
lastX: 0,
|
|
lastY: 0,
|
|
azimuth: 0.7,
|
|
polar: 1.12,
|
|
distance: 18,
|
|
};
|
|
|
|
function updateCamera() {
|
|
dragState.polar = THREE.MathUtils.clamp(dragState.polar, 0.25, Math.PI - 0.25);
|
|
dragState.distance = THREE.MathUtils.clamp(dragState.distance, 10, 38);
|
|
camera.position.setFromSphericalCoords(dragState.distance, dragState.polar, dragState.azimuth);
|
|
camera.lookAt(0, 0, 0);
|
|
}
|
|
|
|
function resize() {
|
|
const width = host.clientWidth;
|
|
const height = host.clientHeight;
|
|
renderer.setSize(width, height, false);
|
|
camera.aspect = width / height;
|
|
camera.updateProjectionMatrix();
|
|
}
|
|
|
|
host.addEventListener('pointerdown', (event) => {
|
|
dragState.active = true;
|
|
dragState.lastX = event.clientX;
|
|
dragState.lastY = event.clientY;
|
|
canvas.classList.add('is-dragging');
|
|
host.setPointerCapture(event.pointerId);
|
|
});
|
|
|
|
host.addEventListener('pointermove', (event) => {
|
|
if (!dragState.active) {
|
|
return;
|
|
}
|
|
const dx = event.clientX - dragState.lastX;
|
|
const dy = event.clientY - dragState.lastY;
|
|
dragState.lastX = event.clientX;
|
|
dragState.lastY = event.clientY;
|
|
dragState.azimuth -= dx * 0.005;
|
|
dragState.polar -= dy * 0.005;
|
|
updateCamera();
|
|
});
|
|
|
|
function stopDragging(event) {
|
|
if (!dragState.active) {
|
|
return;
|
|
}
|
|
dragState.active = false;
|
|
canvas.classList.remove('is-dragging');
|
|
if (event) {
|
|
host.releasePointerCapture(event.pointerId);
|
|
}
|
|
}
|
|
|
|
host.addEventListener('pointerup', stopDragging);
|
|
host.addEventListener('pointercancel', stopDragging);
|
|
host.addEventListener('pointerleave', stopDragging);
|
|
host.addEventListener('wheel', (event) => {
|
|
event.preventDefault();
|
|
dragState.distance += event.deltaY * 0.012;
|
|
updateCamera();
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('resize', resize);
|
|
updateCamera();
|
|
resize();
|
|
|
|
function clearGroup(group) {
|
|
while (group.children.length) {
|
|
const child = group.children[group.children.length - 1];
|
|
group.remove(child);
|
|
if (child.geometry) {
|
|
child.geometry.dispose();
|
|
}
|
|
if (child.material) {
|
|
if (Array.isArray(child.material)) {
|
|
child.material.forEach((material) => material.dispose());
|
|
} else {
|
|
child.material.dispose();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearLabels() {
|
|
labelsLayer.replaceChildren();
|
|
}
|
|
|
|
function loadIssModelTemplate() {
|
|
if (issModelTemplatePromise) {
|
|
return issModelTemplatePromise;
|
|
}
|
|
|
|
const loader = new GLTFLoader();
|
|
issModelTemplatePromise = new Promise((resolve, reject) => {
|
|
loader.load(
|
|
'./images/3dmodels/iss-web/ISS-web.gltf',
|
|
(gltf) => {
|
|
const template = gltf.scene;
|
|
template.traverse((node) => {
|
|
if (!node.isMesh) {
|
|
return;
|
|
}
|
|
node.castShadow = false;
|
|
node.receiveShadow = false;
|
|
});
|
|
resolve(template);
|
|
},
|
|
undefined,
|
|
reject
|
|
);
|
|
}).catch((error) => {
|
|
console.warn('ISS-Modell konnte nicht geladen werden, Fallback wird verwendet.', error);
|
|
return null;
|
|
});
|
|
|
|
return issModelTemplatePromise;
|
|
}
|
|
|
|
function createSatelliteFallback(color, isIss = false) {
|
|
if (!isIss) {
|
|
return new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.11, 16, 16),
|
|
new THREE.MeshBasicMaterial({ color })
|
|
);
|
|
}
|
|
|
|
const group = new THREE.Group();
|
|
|
|
const bodyMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xd7dde8,
|
|
roughness: 0.65,
|
|
metalness: 0.35,
|
|
});
|
|
const panelMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0x587fc8,
|
|
roughness: 0.55,
|
|
metalness: 0.2,
|
|
emissive: 0x112244,
|
|
emissiveIntensity: 0.18,
|
|
});
|
|
const trussMaterial = new THREE.MeshStandardMaterial({
|
|
color: 0xb8c3d9,
|
|
roughness: 0.7,
|
|
metalness: 0.28,
|
|
});
|
|
|
|
const core = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.24, 0.14, 0.14),
|
|
bodyMaterial
|
|
);
|
|
group.add(core);
|
|
|
|
const moduleA = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.045, 0.045, 0.18, 12),
|
|
bodyMaterial
|
|
);
|
|
moduleA.rotation.z = Math.PI / 2;
|
|
moduleA.position.x = -0.18;
|
|
group.add(moduleA);
|
|
|
|
const moduleB = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.045, 0.045, 0.18, 12),
|
|
bodyMaterial
|
|
);
|
|
moduleB.rotation.z = Math.PI / 2;
|
|
moduleB.position.x = 0.18;
|
|
group.add(moduleB);
|
|
|
|
const truss = new THREE.Mesh(
|
|
new THREE.BoxGeometry(0.78, 0.035, 0.035),
|
|
trussMaterial
|
|
);
|
|
group.add(truss);
|
|
|
|
const panelGeometry = new THREE.BoxGeometry(0.34, 0.015, 0.18);
|
|
const leftPanel = new THREE.Mesh(panelGeometry, panelMaterial);
|
|
leftPanel.position.x = -0.58;
|
|
group.add(leftPanel);
|
|
|
|
const rightPanel = new THREE.Mesh(panelGeometry, panelMaterial);
|
|
rightPanel.position.x = 0.58;
|
|
group.add(rightPanel);
|
|
|
|
const radiatorGeometry = new THREE.BoxGeometry(0.12, 0.01, 0.09);
|
|
const radiatorTop = new THREE.Mesh(radiatorGeometry, trussMaterial);
|
|
radiatorTop.position.set(0, 0.09, 0);
|
|
group.add(radiatorTop);
|
|
|
|
const radiatorBottom = new THREE.Mesh(radiatorGeometry, trussMaterial);
|
|
radiatorBottom.position.set(0, -0.09, 0);
|
|
group.add(radiatorBottom);
|
|
|
|
return group;
|
|
}
|
|
|
|
function buildOrbitSamples(satrec, satelliteLib, startDate) {
|
|
const samples = [];
|
|
const steps = Math.floor((ORBIT_WINDOW_MINUTES * 60) / ORBIT_STEP_SECONDS);
|
|
for (let i = 0; i <= steps; i++) {
|
|
const date = new Date(startDate.getTime() + i * ORBIT_STEP_SECONDS * 1000);
|
|
const propagation = satelliteLib.propagate(satrec, date);
|
|
if (!propagation.position) {
|
|
continue;
|
|
}
|
|
const gmst = satelliteLib.gstime(date);
|
|
const geodetic = satelliteLib.eciToGeodetic(propagation.position, gmst);
|
|
samples.push({
|
|
lat: satelliteLib.degreesLat(geodetic.latitude),
|
|
lon: wrapLongitude(satelliteLib.degreesLong(geodetic.longitude)),
|
|
altKm: geodetic.height,
|
|
});
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
async function buildSatellites(rows, satelliteLib) {
|
|
clearGroup(satelliteGroup);
|
|
clearGroup(orbitGroup);
|
|
const issTemplate = await loadIssModelTemplate();
|
|
|
|
const satellites = rows.map((row, index) => {
|
|
const satrec = satelliteLib.twoline2satrec(row.tle_line1, row.tle_line2);
|
|
const hue = (index * 0.173) % 1;
|
|
const color = new THREE.Color().setHSL(hue, 0.8, 0.67);
|
|
const isIss = row.norad_cat_id === 25544;
|
|
const mesh = isIss && issTemplate
|
|
? issTemplate.clone(true)
|
|
: createSatelliteFallback(color, isIss);
|
|
mesh.scale.setScalar(isIss && issTemplate ? 0.0035 : (isIss ? 1.35 : 1));
|
|
satelliteGroup.add(mesh);
|
|
|
|
const labelEl = document.createElement('div');
|
|
labelEl.className = 'geocron3d-label';
|
|
labelEl.textContent = row.object_name ?? String(row.norad_cat_id);
|
|
labelsLayer.appendChild(labelEl);
|
|
|
|
const orbitSamples = buildOrbitSamples(satrec, satelliteLib, new Date());
|
|
let orbitLine = null;
|
|
if (orbitSamples.length > 1) {
|
|
orbitLine = new THREE.Line(
|
|
createOrbitGeometry(orbitSamples),
|
|
new THREE.LineBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.45,
|
|
})
|
|
);
|
|
orbitGroup.add(orbitLine);
|
|
}
|
|
|
|
return { row, satrec, mesh, labelEl, previousPosition: null, orbitLine };
|
|
});
|
|
|
|
return satellites;
|
|
}
|
|
|
|
function refreshOrbitLines(items, satelliteLib, now) {
|
|
for (const item of items) {
|
|
if (!item.orbitLine) {
|
|
continue;
|
|
}
|
|
|
|
const orbitSamples = buildOrbitSamples(item.satrec, satelliteLib, now);
|
|
const nextGeometry = createOrbitGeometry(orbitSamples);
|
|
item.orbitLine.geometry.dispose();
|
|
item.orbitLine.geometry = nextGeometry;
|
|
}
|
|
}
|
|
|
|
function updateSatellitePositions(items, satelliteLib, now) {
|
|
const hostWidth = host.clientWidth;
|
|
const hostHeight = host.clientHeight;
|
|
|
|
for (const item of items) {
|
|
const propagation = satelliteLib.propagate(item.satrec, now);
|
|
if (!propagation.position) {
|
|
item.mesh.visible = false;
|
|
item.labelEl.classList.remove('is-visible');
|
|
continue;
|
|
}
|
|
|
|
const gmst = satelliteLib.gstime(now);
|
|
const geodetic = satelliteLib.eciToGeodetic(propagation.position, gmst);
|
|
const pos = lonLatAltToVector3(
|
|
satelliteLib.degreesLat(geodetic.latitude),
|
|
wrapLongitude(satelliteLib.degreesLong(geodetic.longitude)),
|
|
geodetic.height
|
|
);
|
|
item.mesh.visible = true;
|
|
item.mesh.position.copy(pos);
|
|
|
|
if (item.row.norad_cat_id === 25544) {
|
|
const outward = pos.clone().normalize();
|
|
const tangent = item.previousPosition
|
|
? pos.clone().sub(item.previousPosition).normalize()
|
|
: new THREE.Vector3(1, 0, 0);
|
|
|
|
if (tangent.lengthSq() > 1e-8) {
|
|
const upHint = outward.clone();
|
|
const side = new THREE.Vector3().crossVectors(upHint, tangent).normalize();
|
|
const correctedUp = new THREE.Vector3().crossVectors(tangent, side).normalize();
|
|
const basis = new THREE.Matrix4().makeBasis(tangent, correctedUp, side);
|
|
item.mesh.quaternion.setFromRotationMatrix(basis);
|
|
}
|
|
}
|
|
|
|
item.previousPosition = pos.clone();
|
|
|
|
if (!labelsEnabled) {
|
|
item.labelEl.classList.remove('is-visible');
|
|
continue;
|
|
}
|
|
|
|
const labelPos = pos.clone().multiplyScalar(1.035);
|
|
const ndc = labelPos.project(camera);
|
|
const isOffscreen = ndc.z < -1 || ndc.z > 1 || ndc.x < -1 || ndc.x > 1 || ndc.y < -1 || ndc.y > 1;
|
|
|
|
if (isOffscreen) {
|
|
item.labelEl.classList.remove('is-visible');
|
|
continue;
|
|
}
|
|
|
|
const viewDir = camera.position.clone().normalize();
|
|
const satNormal = pos.clone().normalize();
|
|
if (satNormal.dot(viewDir) <= 0) {
|
|
item.labelEl.classList.remove('is-visible');
|
|
continue;
|
|
}
|
|
|
|
const x = (ndc.x * 0.5 + 0.5) * hostWidth;
|
|
const y = (-ndc.y * 0.5 + 0.5) * hostHeight - 10;
|
|
item.labelEl.style.left = `${x}px`;
|
|
item.labelEl.style.top = `${y}px`;
|
|
item.labelEl.classList.add('is-visible');
|
|
}
|
|
}
|
|
|
|
let satellites = [];
|
|
let satelliteLib = null;
|
|
let nextOrbitRefreshAt = 0;
|
|
|
|
labelsToggle.addEventListener('change', () => {
|
|
labelsEnabled = labelsToggle.checked;
|
|
labelsLayer.setAttribute('aria-hidden', labelsEnabled ? 'false' : 'true');
|
|
if (!labelsEnabled) {
|
|
for (const sat of satellites) {
|
|
sat.labelEl.classList.remove('is-visible');
|
|
}
|
|
}
|
|
});
|
|
|
|
async function init() {
|
|
try {
|
|
satelliteLib = await loadSatelliteJs();
|
|
setStatus('Lade TLE-Daten und ISS-Modell…');
|
|
|
|
const response = await fetch('./geocron/getTLE.php', { cache: 'no-store' });
|
|
const rows = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(rows.error || `TLE-Fehler (${response.status})`);
|
|
}
|
|
|
|
clearLabels();
|
|
satellites = await buildSatellites(rows, satelliteLib);
|
|
nextOrbitRefreshAt = performance.now() + ORBIT_REFRESH_MS;
|
|
|
|
if (satellites.length === 0) {
|
|
setStatus('Keine Satelliten im Konto gefunden. Bitte zuerst Favoriten im Satellitenbereich hinterlegen.');
|
|
} else {
|
|
setStatus(`${satellites.length} Satelliten im Orbit geladen.`);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
setStatus(error.message || '3D-Szene konnte nicht geladen werden.', true);
|
|
}
|
|
}
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
|
|
if (satelliteLib && satellites.length > 0) {
|
|
const now = new Date();
|
|
updateSatellitePositions(satellites, satelliteLib, now);
|
|
|
|
if (performance.now() >= nextOrbitRefreshAt) {
|
|
refreshOrbitLines(satellites, satelliteLib, now);
|
|
nextOrbitRefreshAt = performance.now() + ORBIT_REFRESH_MS;
|
|
}
|
|
}
|
|
|
|
shaderUniforms.lightDirView.value.copy(
|
|
sunLight.position.clone().transformDirection(camera.matrixWorldInverse)
|
|
);
|
|
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
init();
|
|
animate();
|
|
</script>
|
|
|
|
<?php require __DIR__ . '/footer.php'; ?>
|