Files
2026-04-03 09:24:48 +02:00

1048 lines
30 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-time-controls" aria-label="Zeitsteuerung">
<button type="button" class="geocron3d-time-button is-active" data-time-scale="1">Live</button>
<button type="button" class="geocron3d-time-button" data-time-scale="25">25x</button>
<button type="button" class="geocron3d-time-button" data-time-scale="60">60x</button>
<button type="button" class="geocron3d-time-button" data-time-scale="120">120x</button>
<button type="button" class="geocron3d-time-button" data-time-scale="0">Pause</button>
</div>
<div class="geocron3d-time-readout" id="geocron3dTimeReadout">Simulationszeit: --</div>
<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-time-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
}
.geocron3d-time-button {
padding: 8px 12px;
border: 1px solid rgba(108, 145, 220, 0.28);
border-radius: 999px;
background: rgba(10, 16, 32, 0.72);
color: rgba(220, 232, 255, 0.92);
font: inherit;
cursor: pointer;
transition: background-color 0.16s ease, border-color 0.16s ease, transform 0.16s ease;
}
.geocron3d-time-button:hover {
border-color: rgba(125, 170, 255, 0.55);
background: rgba(18, 28, 52, 0.92);
}
.geocron3d-time-button.is-active {
border-color: rgba(150, 190, 255, 0.78);
background: rgba(38, 66, 122, 0.92);
}
.geocron3d-time-readout {
margin: 0 0 12px;
color: rgba(192, 214, 255, 0.88);
font-size: 0.9rem;
line-height: 1.5;
}
.geocron3d-view {
position: relative;
background: #000;
}
.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 src="./js/astronomy.browser.min.js"></script>
<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 AstronomyEngine = window.Astronomy;
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 EARTH_AXIAL_TILT_DEG = 23.439281;
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');
const timeReadoutEl = document.getElementById('geocron3dTimeReadout');
const timeButtons = Array.from(document.querySelectorAll('[data-time-scale]'));
let labelsEnabled = false;
let issModelTemplatePromise = null;
let simulatedTime = new Date();
let lastFrameTimeMs = performance.now();
let timeScale = 1;
if (!AstronomyEngine) {
throw new Error('Astronomy Engine konnte nicht geladen werden.');
}
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 getJulianDate(date) {
return date.getTime() / 86400000 + 2440587.5;
}
function getGreenwichMeanSiderealTime(date) {
const julianDate = getJulianDate(date);
const t = (julianDate - 2451545.0) / 36525;
let gmstDeg = 280.46061837
+ 360.98564736629 * (julianDate - 2451545.0)
+ 0.000387933 * t * t
- (t * t * t) / 38710000;
gmstDeg = ((gmstDeg % 360) + 360) % 360;
return THREE.MathUtils.degToRad(gmstDeg);
}
function getSunSubsolarPoint(date) {
const sunVector = AstronomyEngine.GeoVector(AstronomyEngine.Body.Sun, date, true);
const radius = Math.hypot(sunVector.x, sunVector.y, sunVector.z);
const declinationRad = Math.asin(sunVector.z / radius);
const rightAscensionRad = Math.atan2(sunVector.y, sunVector.x);
const gmstRad = getGreenwichMeanSiderealTime(date);
const subsolarLongitudeRad = THREE.MathUtils.euclideanModulo(rightAscensionRad - gmstRad + Math.PI, Math.PI * 2) - Math.PI;
return {
latDeg: THREE.MathUtils.radToDeg(declinationRad),
lonDeg: THREE.MathUtils.radToDeg(subsolarLongitudeRad),
};
}
function formatSimulationTime(date) {
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(date) + ' UTC';
}
function updateTimeReadout() {
const modeLabel = timeScale === 0 ? 'Pausiert' : (timeScale === 1 ? 'Live' : `${timeScale}x`);
timeReadoutEl.textContent = `Simulationszeit: ${formatSimulationTime(simulatedTime)} (${modeLabel})`;
}
function setTimeScale(nextScale) {
timeScale = nextScale;
if (nextScale === 1) {
simulatedTime = new Date();
lastFrameTimeMs = performance.now();
if (satelliteLib && satellites.length > 0) {
refreshOrbitLines(satellites, satelliteLib, simulatedTime);
nextOrbitRefreshAt = performance.now() + ORBIT_REFRESH_MS;
}
}
for (const button of timeButtons) {
button.classList.toggle('is-active', Number(button.dataset.timeScale) === nextScale);
}
updateTimeReadout();
}
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(0x000000);
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 earthTiltGroup = new THREE.Group();
earthTiltGroup.rotation.z = THREE.MathUtils.degToRad(-EARTH_AXIAL_TILT_DEG);
root.add(earthTiltGroup);
const earthRotationGroup = new THREE.Group();
earthTiltGroup.add(earthRotationGroup);
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
);
earthRotationGroup.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,
})
);
earthRotationGroup.add(atmosphereMesh);
const satelliteGroup = new THREE.Group();
const orbitGroup = new THREE.Group();
earthRotationGroup.add(orbitGroup);
earthRotationGroup.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, variantSeed = 0) {
const group = new THREE.Group();
const bodyMaterial = new THREE.MeshStandardMaterial({
color: isIss ? 0xd7dde8 : new THREE.Color(color).lerp(new THREE.Color(0xd9e1ef), 0.45),
roughness: 0.65,
metalness: 0.35,
});
const panelMaterial = new THREE.MeshStandardMaterial({
color: isIss ? 0x587fc8 : new THREE.Color(color).offsetHSL(0.02, -0.05, -0.12),
roughness: 0.55,
metalness: 0.2,
emissive: 0x112244,
emissiveIntensity: 0.18,
});
const trussMaterial = new THREE.MeshStandardMaterial({
color: isIss ? 0xb8c3d9 : 0xb9c4d8,
roughness: 0.7,
metalness: 0.28,
});
if (!isIss) {
const variant = Math.abs(variantSeed) % 4;
if (variant === 0) {
const bus = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.14, 0.14),
bodyMaterial
);
group.add(bus);
const mast = new THREE.Mesh(
new THREE.CylinderGeometry(0.012, 0.012, 0.62, 10),
trussMaterial
);
mast.rotation.z = Math.PI / 2;
group.add(mast);
const panelGeometry = new THREE.BoxGeometry(0.24, 0.012, 0.14);
const leftPanel = new THREE.Mesh(panelGeometry, panelMaterial);
leftPanel.position.x = -0.43;
group.add(leftPanel);
const rightPanel = new THREE.Mesh(panelGeometry, panelMaterial);
rightPanel.position.x = 0.43;
group.add(rightPanel);
const antenna = new THREE.Mesh(
new THREE.CylinderGeometry(0.008, 0.008, 0.16, 10),
trussMaterial
);
antenna.position.y = 0.14;
group.add(antenna);
} else if (variant === 1) {
const body = new THREE.Mesh(
new THREE.CylinderGeometry(0.09, 0.09, 0.22, 12),
bodyMaterial
);
body.rotation.z = Math.PI / 2;
group.add(body);
const dish = new THREE.Mesh(
new THREE.SphereGeometry(0.085, 16, 12, 0, Math.PI * 2, 0, Math.PI / 2),
trussMaterial
);
dish.rotation.z = -Math.PI / 2;
dish.position.x = 0.18;
group.add(dish);
const panelGeometry = new THREE.BoxGeometry(0.18, 0.012, 0.11);
const topPanel = new THREE.Mesh(panelGeometry, panelMaterial);
topPanel.position.set(-0.05, 0.2, 0);
topPanel.rotation.z = Math.PI / 2;
group.add(topPanel);
const bottomPanel = new THREE.Mesh(panelGeometry, panelMaterial);
bottomPanel.position.set(-0.05, -0.2, 0);
bottomPanel.rotation.z = Math.PI / 2;
group.add(bottomPanel);
} else if (variant === 2) {
const core = new THREE.Mesh(
new THREE.BoxGeometry(0.16, 0.16, 0.2),
bodyMaterial
);
group.add(core);
const armX = new THREE.Mesh(
new THREE.BoxGeometry(0.58, 0.018, 0.018),
trussMaterial
);
group.add(armX);
const armY = new THREE.Mesh(
new THREE.BoxGeometry(0.018, 0.58, 0.018),
trussMaterial
);
group.add(armY);
const panelGeometry = new THREE.BoxGeometry(0.14, 0.012, 0.12);
const panelOffsets = [
[0.34, 0, 0],
[-0.34, 0, 0],
[0, 0.34, 0],
[0, -0.34, 0],
];
for (const [x, y, z] of panelOffsets) {
const panel = new THREE.Mesh(panelGeometry, panelMaterial);
panel.position.set(x, y, z);
panel.rotation.z = Math.abs(y) > 0 ? Math.PI / 2 : 0;
group.add(panel);
}
} else {
const bus = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.11, 0.24),
bodyMaterial
);
group.add(bus);
const leftWing = new THREE.Mesh(
new THREE.BoxGeometry(0.32, 0.012, 0.12),
panelMaterial
);
leftWing.position.set(-0.28, 0.03, 0);
leftWing.rotation.z = 0.16;
group.add(leftWing);
const rightWing = new THREE.Mesh(
new THREE.BoxGeometry(0.32, 0.012, 0.12),
panelMaterial
);
rightWing.position.set(0.28, -0.03, 0);
rightWing.rotation.z = -0.16;
group.add(rightWing);
const boom = new THREE.Mesh(
new THREE.CylinderGeometry(0.01, 0.01, 0.28, 10),
trussMaterial
);
boom.position.y = -0.18;
group.add(boom);
const sensor = new THREE.Mesh(
new THREE.BoxGeometry(0.05, 0.05, 0.09),
trussMaterial
);
sensor.position.y = -0.33;
group.add(sensor);
}
return group;
}
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, row.norad_cat_id + index);
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, simulatedTime);
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 = item.mesh.getWorldPosition(new THREE.Vector3()).multiplyScalar(1.035);
const ndc = labelPos.clone().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 = labelPos.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');
}
}
function updateEarthOrientation(now) {
const gmst = getGreenwichMeanSiderealTime(now);
earthRotationGroup.rotation.y = gmst + THREE.MathUtils.degToRad(EARTH_LONGITUDE_OFFSET_DEG);
const subsolarPoint = getSunSubsolarPoint(now);
const sunDirectionLocal = lonLatAltToVector3(subsolarPoint.latDeg, subsolarPoint.lonDeg, 0).normalize().multiplyScalar(28);
sunLight.position.copy(earthRotationGroup.localToWorld(sunDirectionLocal));
}
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');
}
}
});
for (const button of timeButtons) {
button.addEventListener('click', () => {
setTimeScale(Number(button.dataset.timeScale));
});
}
async function init() {
try {
simulatedTime = new Date();
updateTimeReadout();
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);
const frameNowMs = performance.now();
const deltaMs = Math.min(frameNowMs - lastFrameTimeMs, 1000);
lastFrameTimeMs = frameNowMs;
if (timeScale !== 0) {
simulatedTime = new Date(simulatedTime.getTime() + deltaMs * timeScale);
}
updateTimeReadout();
updateEarthOrientation(simulatedTime);
if (satelliteLib && satellites.length > 0) {
updateSatellitePositions(satellites, satelliteLib, simulatedTime);
if (performance.now() >= nextOrbitRefreshAt) {
refreshOrbitLines(satellites, satelliteLib, simulatedTime);
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'; ?>