1048 lines
30 KiB
PHP
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'; ?>
|