Geocron ist drin

This commit is contained in:
Eskimue
2026-04-02 15:40:06 +02:00
parent 4adb5a6065
commit 1156cb66dc
39 changed files with 3195 additions and 0 deletions
+55
View File
@@ -5423,3 +5423,58 @@ body.sternenhimmel-page .star-loading-note {
font-size: 0.68rem;
letter-spacing: 0.04em;
}
/* Geocron */
body.geocron-page main > .container {
max-width: min(1600px, calc(100% - 32px));
}
.geocron-card {
padding: 0;
overflow: hidden;
}
.geocron-stage {
padding: 14px;
background:
radial-gradient(circle at top, rgba(74, 158, 255, 0.08), transparent 46%),
linear-gradient(180deg, rgba(5, 10, 20, 0.94) 0%, rgba(8, 14, 28, 0.98) 100%);
}
.geocron-earth-wrap {
position: relative;
width: 100%;
aspect-ratio: 2 / 1;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: calc(var(--radius) + 4px);
background: #000;
overflow: hidden;
}
.geocron-earth-viewport {
position: absolute;
top: 50%;
left: 50%;
max-width: 100%;
max-height: 100%;
transform: translate(-50%, -50%);
}
.geocron-earth-viewport > canvas,
.geocron-earth-viewport > img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.geocron-earth-viewport #earthDay {
z-index: 1;
}
+385
View File
@@ -0,0 +1,385 @@
/**
* aurora.js – Polarlicht-Plugin für die Erd-Visualisierung
* ===========================================================
* Einbinden: <script src="aurora.js"></script>
* (am Ende des <body>, nach dem bestehenden <script>-Block)
*
* Datenquelle: NOAA SWPC Ovation Prime Aurora Forecast
* Aktualisierung: alle ~5 Minuten (entspricht der NOAA-Auflösung)
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const AU_CFG = {
// ── Datenquelle ────────────────────────────────────────────
// NOAA SWPC Ovation Prime – globale Aurora-Wahrscheinlichkeit
DATA_URL: "https://services.swpc.noaa.gov/json/ovation_aurora_latest.json",
REFRESH_MS: 5 * 60 * 1000, // alle 5 Minuten (NOAA aktualisiert ~alle 5 min)
// ── Darstellung ────────────────────────────────────────────
// Mindest-Wahrscheinlichkeit zum Zeichnen (0–100)
// Werte darunter werden ignoriert (= unsichtbar)
MIN_PROBABILITY: 4,
// Farbverlauf nach Wahrscheinlichkeit:
// [ { prob: Schwellwert, r, g, b, a } ]
// prob=0 ist immer transparent, höhere Werte heller/farbiger
COLOR_STOPS: [
{ prob: 0, r: 0, g: 255, b: 120, a: 0.5 }, // transparent
{ prob: 5, r: 0, g: 230, b: 100, a: 1.0 }, // zartes Grün
{ prob: 20, r: 30, g: 255, b: 140, a: 1.0 }, // helles Grün
{ prob: 40, r: 100, g: 255, b: 200, a: 1.0 }, // Grün-Cyan
{ prob: 60, r: 140, g: 200, b: 255, a: 1.0 }, // Blau-Weiß
{ prob: 80, r: 200, g: 150, b: 255, a: 1.0 }, // Lila-Weiß
{ prob:100, r: 255, g: 255, b: 255, a: 1.0 }, // Weiß
],
// Globale Deckkraft des Aurora-Layers (0.0 = unsichtbar, 1.0 = voll sichtbar)
// Dieser Wert multipliziert die Deckkraft aller COLOR_STOPS – schnelle Einstellung
// ohne die einzelnen Farbwerte anfassen zu müssen
OPACITY: 1.0,
// Weichzeichner über die fertige Aurora-Maske (in Pixeln)
// Höher = weichere, diffusere Darstellung
BLUR_PX: 6,
// Auflösung der internen Render-Karte
// NOAA liefert 1°-Gitter (360×181 Punkte) – wir rendern auf dieser Größe
// und skalieren dann auf Canvas-Größe hoch (mit blur geglättet)
RENDER_W: 720,
RENDER_H: 361,
// ── Canvas z-index ─────────────────────────────────────────
// Zwischen Nachtkarte (z:2) und Wolken (z:3) oder darüber?
// 2.5 wäre ideal, aber CSS z-index ist ganzzahlig →
// wir setzen 3 und legen aurora UNTER die Wolken
Z_INDEX: 3, // über Nacht (2), unter Wolken (3) ← empfohlen
// ── Info-Anzeige (oben links) ──────────────────────────────
SHOW_INFO: true,
INFO_FONT: "12px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
// ── Kp-Index Anzeige ───────────────────────────────────────
// Zusätzlich den aktuellen Kp-Index laden und anzeigen
SHOW_KP: true,
KP_URL: "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json",
};
// ═══════════════════════════════════════════════════════════════
// INTERNE VARIABLEN
// ═══════════════════════════════════════════════════════════════
let auroraPoints = []; // [{ lon, lat, aurora }] aurora = 0–100
let forecastTime = null; // Zeitstempel der Vorhersage
let currentKp = null; // aktueller Kp-Index
let canvas, ctx;
let offscreen = null; // vorberechnete Aurora-Textur
let lastFetch = 0;
let animFrame = null;
// ═══════════════════════════════════════════════════════════════
// CANVAS EINRICHTEN
// ═══════════════════════════════════════════════════════════════
function initCanvas() {
const viewport = document.getElementById("earth-viewport");
if (!viewport) {
console.error("aurora.js: #earth-viewport nicht gefunden.");
return false;
}
canvas = document.createElement("canvas");
canvas.id = "auroraCanvas";
canvas.style.position = "absolute";
canvas.style.top = "0";
canvas.style.left = "0";
canvas.style.display = "block";
canvas.style.zIndex = AU_CFG.Z_INDEX;
canvas.style.pointerEvents = "none";
viewport.appendChild(canvas);
ctx = canvas.getContext("2d");
syncSize();
const ro = new ResizeObserver(() => { syncSize(); renderOffscreen(); draw(); });
ro.observe(viewport);
return true;
}
function syncSize() {
const ref = document.getElementById("nightCanvas")
|| document.getElementById("earthDay");
if (!ref) return;
const w = ref.offsetWidth || ref.width || 0;
const h = ref.offsetHeight || ref.height || 0;
if (w && h) {
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.width = w;
canvas.height = h;
}
}
// ═══════════════════════════════════════════════════════════════
// DATEN LADEN
// ═══════════════════════════════════════════════════════════════
async function fetchAurora() {
try {
const res = await fetch(AU_CFG.DATA_URL, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const json = await res.json();
// Ovation-Format: { "Forecast Time": "...", "coordinates": [[lon, lat, aurora], ...] }
forecastTime = json["Forecast Time"] ? new Date(json["Forecast Time"]) : null;
auroraPoints = (json.coordinates || []).map(c => ({
lon: c[0],
lat: c[1],
aurora: c[2],
}));
lastFetch = Date.now();
renderOffscreen();
} catch (e) {
console.warn("aurora.js: Aurora-Fetch-Fehler –", e.message);
}
}
async function fetchKp() {
if (!AU_CFG.SHOW_KP) return;
try {
const res = await fetch(AU_CFG.KP_URL, { cache: "no-store" });
if (!res.ok) return;
const json = await res.json();
// Format: [["time", "Kp", ...], ["2024-...", "3.33", ...], ...]
// Letzter Datenpunkt ist aktuellster Wert
if (json.length > 1) {
const last = json[json.length - 1];
currentKp = parseFloat(last[1]);
}
} catch (e) { /* ignorieren */ }
}
// ═══════════════════════════════════════════════════════════════
// AURORA-TEXTUR VORBERECHNEN (Offscreen-Canvas)
//
// Die NOAA-Daten liefern ~65160 Punkte (360 × ~181).
// Wir rendern sie einmalig auf ein kleines Offscreen-Canvas
// und skalieren es dann auf den Haupt-Canvas hoch.
// Das vermeidet teure per-Frame-Berechnungen.
// ═══════════════════════════════════════════════════════════════
function interpolateColor(prob) {
const stops = AU_CFG.COLOR_STOPS;
if (prob <= stops[0].prob) return stops[0];
if (prob >= stops[stops.length - 1].prob) return stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
const s0 = stops[i], s1 = stops[i + 1];
if (prob >= s0.prob && prob <= s1.prob) {
const t = (prob - s0.prob) / (s1.prob - s0.prob);
return {
r: Math.round(s0.r + t * (s1.r - s0.r)),
g: Math.round(s0.g + t * (s1.g - s0.g)),
b: Math.round(s0.b + t * (s1.b - s0.b)),
a: s0.a + t * (s1.a - s0.a),
};
}
}
return stops[stops.length - 1];
}
function renderOffscreen() {
if (!auroraPoints.length) return;
const RW = AU_CFG.RENDER_W;
const RH = AU_CFG.RENDER_H;
if (!offscreen) {
offscreen = document.createElement("canvas");
offscreen.width = RW;
offscreen.height = RH;
}
const octx = offscreen.getContext("2d");
const imgData = octx.createImageData(RW, RH);
const d = imgData.data;
// Gitter aufbauen: lon -180…180, lat 90…-90
// NOAA gibt Lon 0-360, wir müssen auf -180…180 normalisieren
const grid = new Float32Array(RW * RH); // initialisiert mit 0
for (const pt of auroraPoints) {
if (pt.aurora < AU_CFG.MIN_PROBABILITY) continue;
// NOAA: lon 0…360
const lon = pt.lon > 180 ? pt.lon - 360 : pt.lon;
const lat = pt.lat;
// Pixel-Koordinaten
const px = Math.round(((lon + 180) / 360) * (RW - 1));
const py = Math.round(((90 - lat) / 180) * (RH - 1));
if (px >= 0 && px < RW && py >= 0 && py < RH) {
grid[py * RW + px] = Math.max(grid[py * RW + px], pt.aurora);
}
}
// Einfacher 3×3-Mittelwert-Blur über das Gitter (Kantenglätte)
const blurred = new Float32Array(RW * RH);
for (let y = 1; y < RH - 1; y++) {
for (let x = 1; x < RW - 1; x++) {
let sum = 0;
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++)
sum += grid[(y + dy) * RW + (x + dx)];
blurred[y * RW + x] = sum / 9;
}
}
// Auf ImageData schreiben
for (let i = 0; i < RW * RH; i++) {
const v = blurred[i];
if (v < AU_CFG.MIN_PROBABILITY) continue;
const c = interpolateColor(v);
d[i * 4] = c.r;
d[i * 4 + 1] = c.g;
d[i * 4 + 2] = c.b;
d[i * 4 + 3] = Math.round(c.a * 255);
}
octx.putImageData(imgData, 0, 0);
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN (pro Frame)
// ═══════════════════════════════════════════════════════════════
function draw() {
const w = canvas.width, h = canvas.height;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
if (offscreen) {
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, AU_CFG.OPACITY));
ctx.filter = `blur(${AU_CFG.BLUR_PX}px)`;
ctx.drawImage(offscreen, 0, 0, w, h);
ctx.filter = "none";
ctx.globalAlpha = 1;
ctx.restore();
}
if (AU_CFG.SHOW_INFO) drawInfo(w, h);
}
// ─── Info-Box (Vorschauzeit + Kp-Index) ───────────────────────
function drawInfo(w, h) {
const lines = [];
if (forecastTime) {
lines.push("🌌 Aurora Forecast");
lines.push(forecastTime.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit", timeZoneName: "short"
}));
}
if (currentKp !== null) {
const kpLabel = kpDescription(currentKp);
lines.push(`Kp-Index: ${currentKp.toFixed(1)} ${kpLabel}`);
}
if (!lines.length) return;
ctx.save();
ctx.font = AU_CFG.INFO_FONT;
ctx.textBaseline = "top";
const lh = 17, padX = 10, padY = 7;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
const ix = 10, iy = 10;
// Hintergrund
ctx.fillStyle = "rgba(5, 10, 25, 0.75)";
ctx.strokeStyle = "rgba(80, 200, 160, 0.4)";
ctx.lineWidth = 1;
roundRect(ctx, ix, iy, bw, bh, 6);
ctx.fill(); ctx.stroke();
lines.forEach((line, i) => {
if (i === 0) {
ctx.font = "bold " + AU_CFG.INFO_FONT;
ctx.fillStyle = "rgba(100, 255, 180, 0.95)";
} else if (line.startsWith("Kp")) {
ctx.font = AU_CFG.INFO_FONT;
ctx.fillStyle = kpColor(currentKp);
} else {
ctx.font = AU_CFG.INFO_FONT;
ctx.fillStyle = "rgba(180, 230, 210, 0.85)";
}
ctx.fillText(line, ix + padX, iy + padY + i * lh);
});
ctx.restore();
}
function kpDescription(kp) {
if (kp < 1) return "(ruhig)";
if (kp < 2) return "(ruhig)";
if (kp < 3) return "(unsicher)";
if (kp < 4) return "(leicht aktiv)";
if (kp < 5) return "(aktiv)";
if (kp < 6) return "⚡ G1 – Kleiner Sturm";
if (kp < 7) return "⚡ G2 – Mäßiger Sturm";
if (kp < 8) return "⚡ G3 – Starker Sturm";
if (kp < 9) return "⚡ G4 – Sehr starker Sturm";
return "⚡ G5 – Extremer Sturm";
}
function kpColor(kp) {
if (kp < 4) return "rgba(100, 230, 150, 0.9)";
if (kp < 5) return "rgba(200, 220, 60, 0.9)";
if (kp < 6) return "rgba(255, 160, 30, 0.9)";
if (kp < 7) return "rgba(255, 80, 40, 0.9)";
return "rgba(255, 100, 255, 0.9)";
}
// ═══════════════════════════════════════════════════════════════
// CANVAS-HELFER
// ═══════════════════════════════════════════════════════════════
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ═══════════════════════════════════════════════════════════════
// ANIMATIONSSCHLEIFE
// ═══════════════════════════════════════════════════════════════
function loop() {
if (Date.now() - lastFetch >= AU_CFG.REFRESH_MS) {
fetchAurora();
fetchKp();
}
draw();
animFrame = requestAnimationFrame(loop);
}
// ═══════════════════════════════════════════════════════════════
// START
// ═══════════════════════════════════════════════════════════════
function init() {
if (!initCanvas()) return;
Promise.all([fetchAurora(), fetchKp()]).then(() => loop());
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}());
+215
View File
@@ -0,0 +1,215 @@
/**
* earth-core.js – Kern-Framework für die Erd-Visualisierung
* =============================================================
* Stellt Hilfsfunktionen, astronomische Berechnungen und ein
* Event-System bereit, auf das alle Plugins lauschen.
*
* Gefeuerte Events (auf window):
* earth:resize → { detail: { w, h } }
* earth:slow → { detail: { sun, w, h, now } } (~1×/min)
* earth:fast → { detail: { sun, w, h, now } } (~30 fps)
*
* Globales Objekt: window.EarthCore
*/
window.EarthCore = (function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
// ── Bildpfade ──────────────────────────────────────────────
IMG_DAY_DIR: "../images/planets/earth/day/",
IMG_EXT: ".jpg",
// ── Aktualisierungsintervalle ──────────────────────────────
UPDATE_FAST_MS: 1000, // ~30 fps
UPDATE_SLOW_MS: 60_000, // 1× pro Minute
};
// ═══════════════════════════════════════════════════════════════
// HILFSFUNKTIONEN (öffentlich über EarthCore)
// ═══════════════════════════════════════════════════════════════
const toRad = d => d * Math.PI / 180;
const toDeg = r => r * 180 / Math.PI;
const toX = (lon, w) => ((lon + 180) / 360) * w;
const toY = (lat, h) => ((90 - lat) / 180) * h;
/** Maximales 2:1-Rechteck im Earth-Container */
function calcSize() {
const wrapper = document.getElementById("earth-wrapper");
const W = wrapper?.clientWidth || window.innerWidth;
const H = wrapper?.clientHeight || window.innerHeight;
return W / H >= 2
? { w: H * 2, h: H }
: { w: W, h: W / 2 };
}
/**
* Erstellt einen Plugin-Canvas und hängt ihn in #earth-wrapper.
* @param {string} id – canvas.id
* @param {number} zIndex – CSS z-index
* @param {boolean} [noPointer] – pointerEvents: none (Standard: true)
*/
function createCanvas(id, zIndex, noPointer = true) {
const viewport = document.getElementById("earth-viewport");
if (!viewport) throw new Error("earth-core: #earth-viewport nicht gefunden");
const c = document.createElement("canvas");
c.id = id;
Object.assign(c.style, {
position: "absolute",
top: "0",
left: "0",
display: "block",
zIndex: String(zIndex),
pointerEvents: noPointer ? "none" : "auto",
});
viewport.appendChild(c);
return c;
}
/**
* Synchronisiert Größe eines Canvas auf aktuelle Erdgröße.
* Gibt true zurück wenn Größe gesetzt werden konnte.
*/
function syncCanvas(canvas, w, h) {
if (!w || !h) return false;
const rw = Math.round(w), rh = Math.round(h);
canvas.style.width = rw + "px";
canvas.style.height = rh + "px";
canvas.width = rw;
canvas.height = rh;
return true;
}
/**
* Zeichnet eine abgerundete Rechteck-Path (kein fill/stroke).
* Universell für alle Plugins nutzbar.
*/
function roundRectPath(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ═══════════════════════════════════════════════════════════════
// ASTRONOMISCHE BERECHNUNGEN
// ═══════════════════════════════════════════════════════════════
/** Subsolar-Punkt: wo steht die Sonne senkrecht? (Astronomy Engine) */
function subsolarPoint(date) {
try {
const vec = Astronomy.GeoVector(Astronomy.Body.Sun, date, true);
const dist = Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
const dec = toDeg(Math.asin(vec.z / dist));
const ra = toDeg(Math.atan2(vec.y, vec.x));
const gmst = Astronomy.SiderealTime(date) * 15; // Stunden → Grad
const lon = ((ra - gmst + 180) % 360 + 360) % 360 - 180;
return { lat: dec, lon: lon };
} catch (e) {
return { lat: 0, lon: 0 };
}
}
// ═══════════════════════════════════════════════════════════════
// ZUSTAND & EVENT-LOOP
// ═══════════════════════════════════════════════════════════════
let _sun = { lat: 0, lon: 0 };
let _size = { w: 0, h: 0 };
let _resizeRaf = 0;
let _resizeObserver = null;
function _buildDetail(now) {
return { sun: _sun, w: _size.w, h: _size.h, now };
}
function _fireSlow() {
const now = new Date();
_sun = subsolarPoint(now);
window.dispatchEvent(new CustomEvent("earth:slow", { detail: _buildDetail(now) }));
}
function _fireFast() {
window.dispatchEvent(new CustomEvent("earth:fast", { detail: _buildDetail(new Date()) }));
}
function _onResize() {
_size = calcSize();
const viewport = document.getElementById("earth-viewport");
if (viewport) {
viewport.style.width = Math.round(_size.w) + "px";
viewport.style.height = Math.round(_size.h) + "px";
}
window.dispatchEvent(new CustomEvent("earth:resize", { detail: { w: _size.w, h: _size.h } }));
_fireSlow();
_fireFast();
}
function _queueResize() {
if (_resizeRaf) cancelAnimationFrame(_resizeRaf);
_resizeRaf = requestAnimationFrame(() => {
_resizeRaf = 0;
_onResize();
});
}
window.addEventListener("resize", _queueResize);
// ── Start ──────────────────────────────────────────────────────
function _start() {
// Tagbild laden
const img = document.getElementById("earthDay");
if (img) {
const month = new Date().getMonth() + 1;
img.src = `${CFG.IMG_DAY_DIR}${month}${CFG.IMG_EXT}`;
}
_size = calcSize();
const viewport = document.getElementById("earth-viewport");
if (viewport) {
viewport.style.width = Math.round(_size.w) + "px";
viewport.style.height = Math.round(_size.h) + "px";
}
const wrapper = document.getElementById("earth-wrapper");
if (wrapper && typeof ResizeObserver !== "undefined") {
_resizeObserver = new ResizeObserver(() => _queueResize());
_resizeObserver.observe(wrapper);
}
_fireSlow();
_fireFast();
setInterval(_fireFast, CFG.UPDATE_FAST_MS);
setInterval(_fireSlow, CFG.UPDATE_SLOW_MS);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _start);
} else {
_start();
}
// ═══════════════════════════════════════════════════════════════
// ÖFFENTLICHE API
// ═══════════════════════════════════════════════════════════════
return {
// Koordinaten-Helfer
toRad, toDeg, toX, toY,
// Canvas-Helfer
createCanvas, syncCanvas, roundRectPath,
// Astronomie
subsolarPoint,
// Größe
calcSize,
// Live-Getter
get sun() { return _sun; },
get size() { return _size; },
};
}());
+411
View File
@@ -0,0 +1,411 @@
/**
* earthquake.js – Erdbeben-Plugin für die Erd-Visualisierung
* =============================================================
* Einbinden: <script src="earthquake.js"></script>
* (einfach am Ende des <body>, nach dem bestehenden <script>-Block)
*
* Voraussetzung: Das Element #earth-wrapper existiert im DOM.
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const EQ_CFG = {
// ── Datenquelle ────────────────────────────────────────────
// USGS GeoJSON-Feeds (wähle einen aus oder eigene URL):
// all_hour = alle letzten 60 Minuten
// all_day = alle letzten 24 Stunden ← Standard
// all_week = alle letzten 7 Tage
// 2.5_day = nur M ≥ 2.5 letzte 24 h
DATA_URL: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson",
REFRESH_MS: 5 * 60 * 1000, // Aktualisierung alle 5 Minuten
// ── Mindestmagnitude zum Anzeigen ──────────────────────────
MIN_MAG: 0, // 0 = alles anzeigen
// ── Marker-Darstellung ─────────────────────────────────────
// Radius = BASE_RADIUS_PX + MAG_SCALE_PX × magnitude
BASE_RADIUS_PX: 2,
MAG_SCALE_PX: 2.2,
MIN_RADIUS_PX: 2,
// Farbe nach Magnitude (Grenzen aufsteigend)
MAG_COLORS: [
{ min: 0, color: "rgba( 80, 200, 80, 0.85)" }, // grün < 2
{ min: 2, color: "rgba( 180, 220, 60, 0.85)" }, // gelb-grün
{ min: 3, color: "rgba( 255, 210, 0, 0.85)" }, // gelb
{ min: 4, color: "rgba( 255, 140, 0, 0.85)" }, // orange
{ min: 5, color: "rgba( 230, 50, 30, 0.85)" }, // rot
{ min: 6, color: "rgba( 180, 0, 200, 0.85)" }, // lila
{ min: 7, color: "rgba( 255, 255, 255, 0.95)" }, // weiß
],
BORDER_COLOR: "rgba(255, 255, 255, 0.35)",
BORDER_WIDTH_PX: 0.8,
GLOW_BLUR_PX: 6,
// ── Pulsierender Ring für starke Beben ─────────────────────
PULSE_MIN_MAG: 5.0, // ab dieser Magnitude pulsiert ein Ring
PULSE_DURATION_MS: 2000, // Dauer einer Pulsation in ms
// ── Canvas z-index ─────────────────────────────────────────
Z_INDEX: 9999,
// ── Popup (Klick auf Marker) ───────────────────────────────
POPUP_BG: "rgba(10, 15, 30, 0.92)",
POPUP_BORDER: "rgba(120, 180, 255, 0.6)",
POPUP_TEXT: "#e8f0ff",
POPUP_FONT: "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
// ── Legende ────────────────────────────────────────────────
SHOW_LEGEND: true,
LEGEND_FONT: "12px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
};
// ═══════════════════════════════════════════════════════════════
// INTERNE VARIABLEN
// ═══════════════════════════════════════════════════════════════
let earthquakes = []; // aktuelle Erdbebendaten
let canvas, ctx;
let popup = null; // { x, y, quake } oder null
let animFrame = null;
let lastFetch = 0;
// ── Koordinaten-Helpers (gleiche Logik wie Haupt-Skript) ──────
const toX = (lon, w) => ((lon + 180) / 360) * w;
const toY = (lat, h) => ((90 - lat) / 180) * h;
// ═══════════════════════════════════════════════════════════════
// CANVAS EINRICHTEN
// ═══════════════════════════════════════════════════════════════
function initCanvas() {
const viewport = document.getElementById("earth-viewport");
if (!viewport) {
console.error("earthquake.js: #earth-viewport nicht gefunden.");
return false;
}
canvas = document.createElement("canvas");
canvas.id = "earthquakeCanvas";
canvas.style.position = "absolute";
canvas.style.top = "0";
canvas.style.left = "0";
canvas.style.display = "block";
canvas.style.zIndex = EQ_CFG.Z_INDEX;
canvas.style.pointerEvents = "none"; // Klicks standard-mäßig durchlassen
viewport.appendChild(canvas);
ctx = canvas.getContext("2d");
// Größe an vorhandenes Erdbild anpassen
syncSize();
// ResizeObserver auf das earth-wrapper-Element
const ro = new ResizeObserver(() => { syncSize(); draw(); });
ro.observe(viewport);
// Klick-Handler für Popups
canvas.style.pointerEvents = "auto";
canvas.addEventListener("click", onCanvasClick);
canvas.addEventListener("mousemove", onCanvasMove);
return true;
}
function syncSize() {
// Referenz: erstes Canvas-Kind (oder dayImg) im wrapper
const ref = document.getElementById("nightCanvas")
|| document.getElementById("earthDay");
if (!ref) return;
const w = ref.offsetWidth || ref.width || 0;
const h = ref.offsetHeight || ref.height || 0;
if (w && h) {
canvas.style.width = w + "px";
canvas.style.height = h + "px";
canvas.width = w;
canvas.height = h;
}
}
// ═══════════════════════════════════════════════════════════════
// DATEN LADEN
// ═══════════════════════════════════════════════════════════════
async function fetchEarthquakes() {
try {
const res = await fetch(EQ_CFG.DATA_URL, { cache: "no-store" });
if (!res.ok) throw new Error("HTTP " + res.status);
const json = await res.json();
earthquakes = (json.features || [])
.filter(f => {
const mag = f.properties?.mag;
return mag !== null && mag >= EQ_CFG.MIN_MAG;
})
.map(f => ({
mag: f.properties.mag,
place: f.properties.place || "Unbekannt",
time: new Date(f.properties.time),
depth: f.geometry?.coordinates?.[2] ?? 0,
lon: f.geometry?.coordinates?.[0] ?? 0,
lat: f.geometry?.coordinates?.[1] ?? 0,
}));
lastFetch = Date.now();
} catch (e) {
console.warn("earthquake.js: Fetch-Fehler –", e.message);
}
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function magColor(mag) {
let color = EQ_CFG.MAG_COLORS[0].color;
for (const entry of EQ_CFG.MAG_COLORS) {
if (mag >= entry.min) color = entry.color;
}
return color;
}
function markerRadius(mag) {
return Math.max(
EQ_CFG.MIN_RADIUS_PX,
EQ_CFG.BASE_RADIUS_PX + EQ_CFG.MAG_SCALE_PX * mag
);
}
function draw() {
const w = canvas.width, h = canvas.height;
if (!w || !h) return;
ctx.clearRect(0, 0, w, h);
const now = Date.now();
// ── Erdbeben-Marker ─────────────────────────────────────────
for (const q of earthquakes) {
const x = toX(q.lon, w);
const y = toY(q.lat, h);
const r = markerRadius(q.mag/2);
const c = magColor(q.mag);
// Pulsierender Ring für starke Beben
if (q.mag >= EQ_CFG.PULSE_MIN_MAG) {
const age = (now % EQ_CFG.PULSE_DURATION_MS) / EQ_CFG.PULSE_DURATION_MS;
const pr = r + age * r * 2.5;
const pa = (1 - age) * 0.6;
ctx.save();
ctx.beginPath();
ctx.arc(x, y, pr, 0, Math.PI * 2);
ctx.strokeStyle = c.replace(/[\d.]+\)$/, pa.toFixed(2) + ")");
ctx.lineWidth = 1.2;
ctx.stroke();
ctx.restore();
}
// Hauptmarker
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = c;
ctx.shadowColor = c;
ctx.shadowBlur = EQ_CFG.GLOW_BLUR_PX;
ctx.fill();
ctx.strokeStyle = EQ_CFG.BORDER_COLOR;
ctx.lineWidth = EQ_CFG.BORDER_WIDTH_PX;
ctx.stroke();
ctx.restore();
}
// ── Legende ─────────────────────────────────────────────────
if (EQ_CFG.SHOW_LEGEND) drawLegend(w, h);
// ── Popup ────────────────────────────────────────────────────
if (popup) drawPopup(popup, w, h);
}
function drawLegend(w, h) {
const items = [
{ label: "M < 2", color: EQ_CFG.MAG_COLORS[0].color },
{ label: "M 2–3", color: EQ_CFG.MAG_COLORS[1].color },
{ label: "M 3–4", color: EQ_CFG.MAG_COLORS[2].color },
{ label: "M 4–5", color: EQ_CFG.MAG_COLORS[3].color },
{ label: "M 5–6", color: EQ_CFG.MAG_COLORS[4].color },
{ label: "M 6–7", color: EQ_CFG.MAG_COLORS[5].color },
{ label: "M ≥ 7", color: EQ_CFG.MAG_COLORS[6].color },
];
const padX = 10, padY = 6;
const lineH = 18;
const dotR = 5;
const legW = 90;
const legH = padY * 2 + items.length * lineH;
const lx = w - legW - 10;
const ly = h - legH - 10;
// Hintergrund
ctx.save();
ctx.fillStyle = "rgba(10, 15, 30, 0.75)";
ctx.strokeStyle = "rgba(100, 160, 255, 0.35)";
ctx.lineWidth = 1;
roundRect(ctx, lx, ly, legW, legH, 6);
ctx.fill(); ctx.stroke();
// Titel
ctx.font = "bold " + EQ_CFG.LEGEND_FONT;
ctx.fillStyle = "rgba(180, 210, 255, 0.95)";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
// Zeilen
ctx.font = EQ_CFG.LEGEND_FONT;
items.forEach((item, i) => {
const iy = ly + padY + dotR + i * lineH;
ctx.beginPath();
ctx.arc(lx + padX + dotR, iy, dotR, 0, Math.PI * 2);
ctx.fillStyle = item.color;
ctx.shadowColor = item.color;
ctx.shadowBlur = 4;
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(220, 230, 255, 0.9)";
ctx.fillText(item.label, lx + padX + dotR * 2 + 4, iy);
});
// Zähler unten
ctx.font = "11px " + EQ_CFG.LEGEND_FONT;
ctx.fillStyle = "rgba(140, 170, 220, 0.75)";
ctx.textAlign = "center";
const countText = earthquakes.length + " Beben (24 h)";
ctx.fillText(countText, lx + legW / 2, ly + legH - padY);
ctx.restore();
}
function drawPopup(pop, w, h) {
const q = pop.quake;
const magStr = q.mag !== null ? "M " + q.mag.toFixed(1) : "M ?";
const timeStr =
q.time.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
timeZone: "UTC"
}) + " UTC";
const depthStr = q.depth.toFixed(1) + " km Tiefe";
const lines = [
magStr + " – " + depthStr,
q.place,
timeStr,
];
ctx.save();
ctx.font = EQ_CFG.POPUP_FONT;
ctx.textBaseline = "top";
const lh = 18, padX = 10, padY = 8;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
let px = pop.x + 12, py = pop.y - bh / 2;
if (px + bw > w - 5) px = pop.x - bw - 12;
if (py < 5) py = 5;
if (py + bh > h - 5) py = h - bh - 5;
roundRect(ctx, px, py, bw, bh, 7);
ctx.fillStyle = EQ_CFG.POPUP_BG;
ctx.fill();
ctx.strokeStyle = EQ_CFG.POPUP_BORDER;
ctx.lineWidth = 1;
ctx.stroke();
lines.forEach((line, i) => {
ctx.fillStyle = i === 0 ? magColor(q.mag) : EQ_CFG.POPUP_TEXT;
if (i === 0) {
ctx.font = "bold " + EQ_CFG.POPUP_FONT;
} else {
ctx.font = EQ_CFG.POPUP_FONT;
}
ctx.fillText(line, px + padX, py + padY + i * lh);
});
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════
// INTERAKTION
// ═══════════════════════════════════════════════════════════════
function hitTest(cx, cy) {
const w = canvas.width, h = canvas.height;
let best = null, bestDist = Infinity;
for (const q of earthquakes) {
const x = toX(q.lon, w);
const y = toY(q.lat, h);
const r = markerRadius(q.mag) + 3; // etwas größere Klickfläche
const d = Math.hypot(cx - x, cy - y);
if (d <= r && d < bestDist) { best = q; bestDist = d; }
}
return best;
}
function onCanvasClick(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
const q = hitTest(mx, my);
popup = q ? { x: mx, y: my, quake: q } : null;
draw();
}
function onCanvasMove(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
canvas.style.cursor = hitTest(mx, my) ? "pointer" : "default";
}
// ═══════════════════════════════════════════════════════════════
// ANIMATIONSSCHLEIFE
// ═══════════════════════════════════════════════════════════════
function loop() {
// Daten bei Bedarf nachladen
if (Date.now() - lastFetch >= EQ_CFG.REFRESH_MS) fetchEarthquakes();
draw();
animFrame = requestAnimationFrame(loop);
}
// ═══════════════════════════════════════════════════════════════
// CANVAS-HELFER
// ═══════════════════════════════════════════════════════════════
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
// ═══════════════════════════════════════════════════════════════
// START
// ═══════════════════════════════════════════════════════════════
function init() {
if (!initCanvas()) return;
fetchEarthquakes().then(() => { loop(); });
}
// Warten bis DOM bereit ist
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
}());
+210
View File
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function tleChecksum(string $line): int {
$sum = 0;
for ($i = 0; $i < 68; $i++) {
$c = $line[$i];
if (ctype_digit($c)) {
$sum += (int)$c;
} elseif ($c === '-') {
$sum += 1;
}
}
return $sum % 10;
}
function tleExp(float $val): string {
if (abs($val) < 1e-99) {
return ' 00000-0';
}
$sign = $val >= 0 ? '+' : '-';
$abs = abs($val);
$exp = (int)floor(log10($abs)) + 1;
$mantissa = (int)round($abs / pow(10, $exp) * 1e5);
if ($mantissa >= 100000) {
$mantissa = 10000;
$exp++;
}
$expSign = $exp >= 0 ? '+' : '-';
return sprintf('%s%05d%s%d', $sign, $mantissa, $expSign, abs($exp));
}
function tleMmDot(float $val): string {
$sign = $val >= 0 ? ' ' : '-';
return sprintf('%s.%08d', $sign, (int)round(abs($val) * 1e8));
}
function tleEpoch(string $epochStr): string {
$dt = new DateTimeImmutable($epochStr, new DateTimeZone('UTC'));
$yy = (int)$dt->format('Y') % 100;
$jan1 = new DateTimeImmutable($dt->format('Y') . '-01-01 00:00:00', new DateTimeZone('UTC'));
$secs = (float)$dt->format('U') - (float)$jan1->format('U');
$day = $secs / 86400.0 + 1.0;
return sprintf('%02d%012.8f', $yy, $day);
}
function tleIntlDesig(?string $objectId): string {
if (!$objectId) {
return str_repeat(' ', 8);
}
if (preg_match('/^(\d{4})-(\d+)([A-Z]*)$/i', trim($objectId), $m)) {
$yy = substr($m[1], 2, 2);
$launch = str_pad($m[2], 3, '0', STR_PAD_LEFT);
$piece = str_pad(strtoupper($m[3]), 3, ' ', STR_PAD_RIGHT);
return substr(str_pad($yy . $launch . $piece, 8, ' '), 0, 8);
}
return str_pad(substr($objectId, 0, 8), 8);
}
function buildLine1(array $sat, array $tle): string {
$norad = (int)$sat['norad_cat_id'];
$class = in_array($sat['classification_type'] ?? '', ['U', 'C', 'S'], true)
? $sat['classification_type']
: 'U';
$line = sprintf(
'1 %05d%1s %-8s %14s %10s %8s %8s %1d %4d',
$norad,
$class,
tleIntlDesig($sat['object_id'] ?? null),
tleEpoch((string)$tle['epoch']),
tleMmDot((float)($tle['mean_motion_dot'] ?? 0)),
tleExp((float)($tle['mean_motion_ddot'] ?? 0)),
tleExp((float)($tle['bstar'] ?? 0)),
(int)($tle['ephemeris_type'] ?? 0),
(int)($tle['element_set_no'] ?? 0)
);
$line = str_pad($line, 68);
return $line . tleChecksum($line);
}
function buildLine2(array $sat, array $tle): string {
$norad = (int)$sat['norad_cat_id'];
$ecc = (float)($tle['eccentricity'] ?? 0);
$eccStr = substr(sprintf('%.7f', $ecc), 2);
$line = sprintf(
'2 %05d %8.4f %8.4f %7s %8.4f %8.4f %11.8f%5d',
$norad,
(float)($tle['inclination'] ?? 0),
(float)($tle['ra_of_asc_node'] ?? 0),
$eccStr,
(float)($tle['arg_of_pericenter'] ?? 0),
(float)($tle['mean_anomaly'] ?? 0),
(float)($tle['mean_motion'] ?? 0),
(int)($tle['rev_at_epoch'] ?? 0)
);
$line = str_pad($line, 68);
return $line . tleChecksum($line);
}
$userId = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : 0;
if ($userId <= 0) {
http_response_code(401);
echo json_encode(['error' => 'Nicht angemeldet.']);
exit;
}
$requestedIds = array_values(array_filter(
array_map('intval', explode(',', (string)($_GET['ids'] ?? ''))),
static fn(int $id): bool => $id > 0
));
$cfg = require __DIR__ . '/../../config/database.php';
try {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$cfg['host'],
$cfg['dbname'],
$cfg['charset'] ?? 'utf8mb4'
);
$pdo = new PDO($dsn, $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'DB-Verbindung fehlgeschlagen.']);
exit;
}
$sql = "
SELECT
s.id,
s.norad_cat_id,
s.object_name,
s.object_id,
s.classification_type,
us.is_favorite,
t.epoch,
t.mean_motion,
t.eccentricity,
t.inclination,
t.ra_of_asc_node,
t.arg_of_pericenter,
t.mean_anomaly,
t.ephemeris_type,
t.element_set_no,
t.rev_at_epoch,
t.bstar,
t.mean_motion_dot,
t.mean_motion_ddot
FROM app_user_satellites us
INNER JOIN sat_satellites s
ON s.id = us.satellite_id
INNER JOIN sat_tle_current t
ON t.satellite_id = s.id
WHERE us.user_id = :user_id
AND t.epoch IS NOT NULL
AND t.mean_motion IS NOT NULL
";
$params = [':user_id' => $userId];
if ($requestedIds !== []) {
$idPlaceholders = [];
foreach ($requestedIds as $index => $noradId) {
$placeholder = ':norad_' . $index;
$idPlaceholders[] = $placeholder;
$params[$placeholder] = $noradId;
}
$sql .= ' AND s.norad_cat_id IN (' . implode(', ', $idPlaceholders) . ')';
}
$sql .= ' ORDER BY us.is_favorite DESC, s.object_name ASC';
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Abfrage fehlgeschlagen.']);
exit;
}
$result = [];
foreach ($rows as $row) {
$result[] = [
'id' => (int)$row['id'],
'norad_cat_id' => (int)$row['norad_cat_id'],
'object_name' => (string)$row['object_name'],
'is_favorite' => (int)($row['is_favorite'] ?? 0) === 1,
'tle_line1' => buildLine1($row, $row),
'tle_line2' => buildLine2($row, $row),
];
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
session_start();
$loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Geocron - AstroTools';
$bodyClass = 'geocron-page';
$publicBasePath = '../';
require __DIR__ . '/../header.php';
?>
<section class="card geocron-card">
<div class="geocron-stage">
<div id="earth-wrapper" class="geocron-earth-wrap">
<div id="earth-viewport" class="geocron-earth-viewport">
<img id="earthDay" alt="Erde Tag">
<!-- Alle weiteren Canvas-Layer werden von den Plugins erstellt -->
</div>
</div>
</div>
</section>
<script src="../js/astronomy.browser.min.js"></script>
<script src="earth-core.js"></script>
<script src="plugin-terminator.js"></script>
<script src="plugin-clouds.js"></script>
<script src="plugin-satellites.js"></script>
<script src="plugin-sun.js"></script>
<script src="plugin-moon.js"></script>
<script src="plugin-markers.js"></script>
<script src="aurora.js"></script>
<script src="earthquake.js"></script>
<script src="plugin-ui.js"></script>
<?php require __DIR__ . '/../footer.php'; ?>
+97
View File
@@ -0,0 +1,97 @@
/**
* plugin-clouds.js – Wolken-Layer
* ==================================
* Abhängigkeit: earth-core.js, plugin-terminator.js
*
* Erstellt: #cloudCanvas (z-index 3)
*
* Lauscht auf earth:terminator und zeichnet die Wolkentextur
* mit getrennter Deckkraft für Tag- und Nachtseite.
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
// Externe Wolkentextur (Alpha-PNG)
CLOUD_URL: "https://clouds.matteason.co.uk/images/2048x1024/clouds-alpha.png",
// Deckkraft der Wolken (0.0 = unsichtbar | 1.0 = voll sichtbar)
CLOUD_ALPHA_DAY: 0.35, // Tagseite
CLOUD_ALPHA_NIGHT: 0.18, // Nachtseite
};
// ── Kurz-Referenz auf EarthCore-Helfer ────────────────────────
const { toX, toY, createCanvas, syncCanvas } = EarthCore;
// ── Canvas & Context ──────────────────────────────────────────
const cloudCanvas = createCanvas("cloudCanvas", 3);
const ctx = cloudCanvas.getContext("2d");
// ── Wolkenbild laden ──────────────────────────────────────────
const cloudImg = new Image();
cloudImg.crossOrigin = "anonymous";
cloudImg.src = CFG.CLOUD_URL;
// Letzter bekannter Terminator-State (für Re-Renders bei Resize)
let _lastTerminator = null;
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function clipToSide(pts, w, h, northIsNight, nightSide) {
ctx.beginPath();
if (!nightSide) ctx.rect(0, 0, w, h);
ctx.moveTo(toX(pts[0].lon, w), toY(pts[0].lat, h));
for (let i = 1; i < pts.length; i++) {
ctx.lineTo(toX(pts[i].lon, w), toY(pts[i].lat, h));
}
if (northIsNight) { ctx.lineTo(w, 0); ctx.lineTo(0, 0); }
else { ctx.lineTo(w, h); ctx.lineTo(0, h); }
ctx.closePath();
ctx.clip(nightSide ? "nonzero" : "evenodd");
}
function render({ pts, northIsNight, w, h }) {
if (!cloudImg.complete || !cloudImg.naturalWidth) return;
if (!w || !h) return;
syncCanvas(cloudCanvas, w, h);
ctx.clearRect(0, 0, w, h);
// Tagseite
ctx.save();
clipToSide(pts, w, h, northIsNight, false);
ctx.globalAlpha = CFG.CLOUD_ALPHA_DAY;
ctx.drawImage(cloudImg, 0, 0, w, h);
ctx.restore();
// Nachtseite
ctx.save();
clipToSide(pts, w, h, northIsNight, true);
ctx.globalAlpha = CFG.CLOUD_ALPHA_NIGHT;
ctx.drawImage(cloudImg, 0, 0, w, h);
ctx.restore();
}
// ── Events ────────────────────────────────────────────────────
window.addEventListener("earth:terminator", e => {
_lastTerminator = e.detail;
render(e.detail);
});
window.addEventListener("earth:resize", e => {
if (_lastTerminator) {
render({ ..._lastTerminator, w: e.detail.w, h: e.detail.h });
}
});
cloudImg.onload = () => {
if (_lastTerminator) render(_lastTerminator);
};
}());
+567
View File
@@ -0,0 +1,567 @@
/**
* plugin-markers.js – Ortsmarker auf der Weltkarte
* ====================================================
* Abhängigkeit: earth-core.js
*
* Erstellt: #markersCanvas (z-index 8)
*
* Jeden Marker in der MARKERS-Liste eintragen.
* Unterstützt: Icon-PNG, Fallback-Punkt, Label, Klick-Popup.
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// MARKER-LISTE – hier eintragen
// ═══════════════════════════════════════════════════════════════
const MARKERS = [
// ═══════════════════════════════════════════════════════════
// EIGENE STANDORTE
// ═══════════════════════════════════════════════════════════
{
name: "Astronomiemuseum",
lat: 50.377222,
lon: 11.189722,
icon: "../images/logoAM.png",
iconW: 67,
iconH: 45,
showLabel: false,
popup: "Astronomiemuseum\nSternwarte Rodewisch",
},
{
name: "Antipode Astronomiemuseum",
lat: -50.377222,
lon: -168.810278,
color: "rgba(255, 100, 180, 0.95)",
radius: 5,
showLabel: true,
popup: "Antipode des Astronomiemuseums\nGenau gegenüber auf der Erde\n50.38°S / 168.81°W\nSüdpazifik – ca. 800 km südöstlich von Neuseeland",
},
// ═══════════════════════════════════════════════════════════
// GEOGRAFISCHE BESONDERHEITEN
// ═══════════════════════════════════════════════════════════
{
name: "Null Island",
lat: 0,
lon: 0,
color: "rgba(255, 255, 100, 0.95)",
radius: 5,
showLabel: true,
popup: "Null Island (0°/0°)\nSchnittpunkt Äquator & Nullmeridian\nHier liegt eine echte GPS-Referenzboje",
},
{
name: "Nullmeridian – Greenwich",
lat: 51.4769,
lon: -0.0005,
color: "rgba(100, 200, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "Royal Observatory Greenwich\nDefiniert seit 1884 den Nullmeridian (0° Länge)\nLondon, England",
},
{
name: "Pol der Unzugänglichkeit",
lat: -48.873,
lon: -123.393,
color: "rgba(200, 100, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "Pol der Unzugänglichkeit (Pazifik)\nAm weitesten von jeder Küste entfernt\n2.688 km bis zur nächsten Landmasse",
},
{
name: "Erdmittelpunkt der Landmasse",
lat: 39.0,
lon: 34.0,
color: "rgba(255, 160, 60, 0.95)",
radius: 5,
showLabel: true,
popup: "Erdmittelpunkt der Landmasse\nGeografisches Zentrum aller Kontinente\nTürkei",
},
{
name: "Magnetischer Nordpol",
lat: 86.5,
lon: -150.0,
color: "rgba(100, 255, 200, 0.95)",
radius: 5,
showLabel: true,
popup: "Magnetischer Nordpol (ca. 2024)\nWandert jährlich ~50 km Richtung Sibirien\nPosition ändert sich kontinuierlich",
},
{
name: "Magnetischer Südpol",
lat: -64.1,
lon: 136.0,
color: "rgba(100, 255, 200, 0.95)",
radius: 5,
showLabel: true,
popup: "Magnetischer Südpol (ca. 2024)\nDerzeit nahe der Küste der Antarktis\nPosition ändert sich kontinuierlich",
},
// ═══════════════════════════════════════════════════════════
// HISTORISCHE OBSERVATORIEN
// ═══════════════════════════════════════════════════════════
{
name: "Sternwarte Paris",
lat: 48.8365,
lon: 2.3363,
color: "rgba(180, 220, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "Observatoire de Paris\nDefinierte bis 1884 den Nullmeridian\nGegründet 1667 – älteste Staatssternwarte",
},
{
name: "Uraniborg",
lat: 55.9,
lon: 12.7,
color: "rgba(180, 220, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "Uraniborg – Tycho Brahes Observatorium\nVen, Dänemark (heute Schweden)\nErbaut 1576 – präziseste vorteleskopische Messungen",
},
{
name: "Jantar Mantar Jaipur",
lat: 26.9245,
lon: 75.8236,
color: "rgba(255, 200, 100, 0.95)",
radius: 5,
showLabel: true,
popup: "Jantar Mantar – Jaipur, Indien\nGrößte steinerne Sonnenuhren der Welt\nErbaut 1724 – UNESCO Weltkulturerbe",
},
{
name: "Göbekli Tepe",
lat: 37.2232,
lon: 38.9224,
color: "rgba(255, 200, 100, 0.95)",
radius: 5,
showLabel: true,
popup: "Göbekli Tepe – Türkei\nÄlteste bekannte astronomische Anlage\nCa. 10.000 v. Chr. – vor Stonehenge & den Pyramiden",
},
// ═══════════════════════════════════════════════════════════
// MODERNE TELESKOPE & OBSERVATORIEN
// ═══════════════════════════════════════════════════════════
{
name: "VLT – Very Large Telescope",
lat: -24.6275,
lon: -70.4044,
color: "rgba(120, 200, 255, 0.95)",
radius: 6,
showLabel: true,
popup: "VLT – Very Large Telescope (ESO)\nCerro Paranal, Atacama-Wüste, Chile\n4 Teleskope à 8,2 m Spiegel\nEins der leistungsfähigsten optischen Observatorien der Welt",
},
{
name: "Mauna Kea",
lat: 19.8228,
lon: -155.4683,
color: "rgba(120, 200, 255, 0.95)",
radius: 6,
showLabel: true,
popup: "Mauna Kea Observatory – Hawaii, USA\nGrößte Teleskopkonzentration der Welt\n4.205 m Höhe – ideale Beobachtungsbedingungen\nHeimat von Keck, Subaru, UKIRT u.v.m.",
},
{
name: "Atacama (ALMA)",
lat: -23.0229,
lon: -67.7540,
color: "rgba(120, 200, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "ALMA – Atacama Large Millimeter Array\nChile, 5.058 m Höhe\n66 Radioteleskop-Antennen\nBeobachtet die kühlsten Objekte des Universums",
},
{
name: "Arecibo (Standort)",
lat: 18.3463,
lon: -66.7528,
color: "rgba(180, 180, 180, 0.85)",
radius: 5,
showLabel: true,
popup: "Arecibo Observatory – Puerto Rico\n305 m Radioteleskop (1963–2020)\nKollabiert im Dez. 2020\nWar 53 Jahre lang das größte Radioteleskop der Welt",
},
{
name: "FAST Radioteleskop",
lat: 25.6531,
lon: 106.8566,
color: "rgba(120, 200, 255, 0.95)",
radius: 5,
showLabel: true,
popup: "FAST – Five-hundred-meter Aperture\nSpherical Telescope, Guizhou, China\n500 m Durchmesser – größtes Radioteleskop der Welt\nIn Betrieb seit 2016",
},
// ═══════════════════════════════════════════════════════════
// METEORITEN & EINSCHLAGKRATER
// ═══════════════════════════════════════════════════════════
{
name: "Chicxulub-Krater",
lat: 21.4,
lon: -89.5,
color: "rgba(255, 80, 50, 0.95)",
radius: 8,
showLabel: true,
popup: "Chicxulub-Krater – Yucatán, Mexiko\n~180 km Durchmesser\nEinschlag vor 66 Mio. Jahren\nAuslöser des Massen­aussterbens der Dinosaurier\nGrößter bekannter Einschlag der Erdgeschichte",
},
{
name: "Vredefort-Krater",
lat: -27.0,
lon: 27.5,
color: "rgba(255, 80, 50, 0.95)",
radius: 7,
showLabel: true,
popup: "Vredefort-Krater – Südafrika\n~300 km Durchmesser (ursprünglich)\nÄltester bekannter Einschlagkrater: vor 2,02 Mrd. Jahren\nUNESCO Weltkulturerbe",
},
{
name: "Sudbury-Krater",
lat: 46.6,
lon: -81.2,
color: "rgba(255, 80, 50, 0.95)",
radius: 7,
showLabel: true,
popup: "Sudbury Basin – Ontario, Kanada\n~130 km Durchmesser\nEinschlag vor 1,85 Mrd. Jahren\nEiner der größten und ältesten Krater\nHeute bedeutende Nickel- und Kupfermine",
},
{
name: "Popigai-Krater",
lat: 71.65,
lon: 111.0,
color: "rgba(255, 80, 50, 0.95)",
radius: 6,
showLabel: true,
popup: "Popigai-Krater – Sibirien, Russland\n~100 km Durchmesser\nEinschlag vor 35,7 Mio. Jahren\nEnthält Billionen Karat Impact-Diamanten\nViertgrößter bekannter Krater",
},
{
name: "Manicouagan-Krater",
lat: 51.37,
lon: -68.7,
color: "rgba(255, 80, 50, 0.95)",
radius: 6,
showLabel: true,
popup: "Manicouagan-Krater – Québec, Kanada\n~100 km Durchmesser\nEinschlag vor 215 Mio. Jahren\nRing-See gut sichtbar auf Satellitenbildern\n'Auge Quebecs'",
},
{
name: "Acraman-Krater",
lat: -32.0,
lon: 135.45,
color: "rgba(255, 80, 50, 0.95)",
radius: 6,
showLabel: true,
popup: "Acraman-Krater – Südaustralien\n~90 km Durchmesser\nEinschlag vor 580 Mio. Jahren\nHeute ein ausgetrockneter Salzsee",
},
{
name: "Tunguska",
lat: 60.917,
lon: 101.95,
color: "rgba(255, 160, 50, 0.95)",
radius: 5,
showLabel: true,
popup: "Tunguska-Ereignis – Sibirien, Russland\n30. Juni 1908\nLuftexplosion eines ~50 m Asteroiden\nStärke ~1.000 Hiroshima-Bomben\n2.000 km² Wald niedergewalzt\nKein Krater – Objekt verglühte in der Atmosphäre",
},
{
name: "Meteor Crater (Barringer)",
lat: 35.0272,
lon: -111.0225,
color: "rgba(255, 80, 50, 0.95)",
radius: 5,
showLabel: true,
popup: "Barringer Meteor Crater – Arizona, USA\n1,2 km Durchmesser, 170 m tief\nEinschlag vor ~50.000 Jahren\nBester erhaltener Einschlagkrater der Erde\nEisenmeteorit, ca. 50 m groß",
},
{
name: "Hoba-Meteorit",
lat: -19.5936,
lon: 17.9347,
color: "rgba(200, 180, 140, 0.95)",
radius: 5,
showLabel: true,
popup: "Hoba-Meteorit – Namibia\nGrößter bekannter Meteorit der Erde\n60 Tonnen, 2,7 × 2,7 m\nEntdeckt 1920 – liegt noch am Fundort\nEisenmeteorit, vor ~80.000 Jahren gefallen",
},
{
name: "Tscheljabinsk",
lat: 55.1644,
lon: 61.4168,
color: "rgba(255, 160, 50, 0.95)",
radius: 5,
showLabel: true,
popup: "Tscheljabinsk-Meteorit – Russland\n15. Februar 2013\n~20 m großer Asteroid\nLuftexplosion in 30 km Höhe\n~1.500 Verletzte durch Druckwelle\nHellster Meteor seit Tunguska",
},
{
name: "Nördlingen (Ries-Krater)",
lat: 48.8566,
lon: 10.6167,
color: "rgba(255, 80, 50, 0.95)",
radius: 5,
showLabel: true,
popup: "Nördlinger Ries – Bayern, Deutschland\n~24 km Durchmesser\nEinschlag vor 14,6 Mio. Jahren\nStadtmauer Nördlingen besteht aus Suevit (Impaktgestein)\nKirche St. Georg enthält ~5.000 Karat Mikro-Diamanten",
},
{
name: "Sikhote-Alin",
lat: 46.1,
lon: 134.7,
color: "rgba(200, 180, 140, 0.95)",
radius: 5,
showLabel: true,
popup: "Sikhote-Alin-Meteorit – Russland\n12. Februar 1947\nEisenmeteorit, ~23 Tonnen (gefundene Fragmente)\nZerbrach in der Atmosphäre in tausende Stücke\nHinterließ ~100 kleine Krater im Primorsky Krai\nGrößter beobachteter Eisenmeteorit-Einschlag",
},
{
name: "Campo del Cielo",
lat: -27.5,
lon: -61.7,
color: "rgba(200, 180, 140, 0.95)",
radius: 5,
showLabel: true,
popup: "Campo del Cielo – Argentinien\nEinschlag vor ca. 4.000–5.000 Jahren\nGruppe von ~26 Kratern auf 60 km²\nBisher ~100 Tonnen Eisenmeteoriten gefunden\nGróncio: 30,8 Tonnen – zweitgrößter Meteorit der Erde",
},
{
name: "Siljan-Krater",
lat: 61.0,
lon: 14.9,
color: "rgba(255, 80, 50, 0.95)",
radius: 5,
showLabel: true,
popup: "Siljan-Krater – Schweden\n~52 km Durchmesser\nEinschlag vor 376 Mio. Jahren\nGrößter Einschlagkrater Europas\nHeute malerische Seenlandschaft",
},
// ═══════════════════════════════════════════════════════════
{
name: "Baikonur",
lat: 45.9200,
lon: 63.3420,
color: "rgba(255, 120, 120, 0.95)",
radius: 5,
showLabel: true,
popup: "Baikonur Kosmodrom – Kasachstan\nÄltester und größter Raumfahrtbahnhof\nErster Satellit (Sputnik 1957)\nErster Mensch im All (Gagarin 1961)",
},
{
name: "Cape Canaveral",
lat: 28.3922,
lon: -80.6077,
color: "rgba(255, 120, 120, 0.95)",
radius: 5,
showLabel: true,
popup: "Kennedy Space Center – Florida, USA\nNASA-Hauptstartplatz seit 1958\nMond-Missionen Apollo, Space Shuttle\nHeute auch SpaceX Falcon & Starship",
},
{
name: "Kourou (ESA)",
lat: 5.2390,
lon: -52.7684,
color: "rgba(255, 120, 120, 0.95)",
radius: 5,
showLabel: true,
popup: "Centre Spatial Guyanais – Kourou\nFranzösisch-Guyana, ESA-Startplatz\nIdeal: nur 5° vom Äquator → Erdrotation spart Treibstoff\nHeimstart von Ariane-Raketen",
},
{
name: "Jiuquan (CNSA)",
lat: 40.9605,
lon: 100.2913,
color: "rgba(255, 120, 120, 0.95)",
radius: 5,
showLabel: true,
popup: "Jiuquan Satellite Launch Center\nGobi-Wüste, China\nErster chinesischer Taikonaut ins All (2003)\nStart der Shenzhou-Missionen",
},
// ═══════════════════════════════════════════════════════════
// POLARLICHT-HOTSPOTS
// ═══════════════════════════════════════════════════════════
{
name: "Tromsø",
lat: 69.6496,
lon: 18.9560,
color: "rgba(80, 255, 160, 0.95)",
radius: 5,
showLabel: true,
popup: "Tromsø – Norwegen\n69°N – mitten im Aurora-Oval\nPolarlicht-Hauptstadt Europas\nBeste Sichtbarkeit: Oktober–März",
},
];
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION – globale Darstellungsoptionen
// ═══════════════════════════════════════════════════════════════
const CFG = {
// ── Fallback-Punkt (wenn kein Icon angegeben) ──────────────
DOT_RADIUS_PX: 5,
DOT_COLOR: "rgba(255, 200, 80, 0.95)",
DOT_BORDER: "rgba(255, 255, 255, 0.6)",
DOT_BORDER_W: 1.2,
DOT_GLOW: "rgba(255, 200, 80, 0.6)",
DOT_GLOW_BLUR: 6,
// ── Label ──────────────────────────────────────────────────
LABEL_FONT: "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
LABEL_OFFSET_X: 10,
LABEL_OFFSET_Y: -10,
LABEL_TEXT: "rgba(255, 255, 255, 0.95)",
LABEL_BG: "rgba(0, 0, 0, 0.55)",
LABEL_PAD_X: 6,
LABEL_PAD_Y: 3,
LABEL_RADIUS: 5,
// ── Popup ──────────────────────────────────────────────────
POPUP_BG: "rgba(10, 15, 30, 0.92)",
POPUP_BORDER: "rgba(180, 160, 255, 0.6)",
POPUP_TEXT: "#e8f0ff",
POPUP_FONT: "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
};
// ═══════════════════════════════════════════════════════════════
// INTERNER STATE
// ═══════════════════════════════════════════════════════════════
const { toX, toY, createCanvas, syncCanvas, roundRectPath } = EarthCore;
const markersCanvas = createCanvas("markersCanvas", 8, false); // pointerEvents: auto
const ctx = markersCanvas.getContext("2d");
let popup = null; // { x, y, text } oder null
// ── Icons vorladen ────────────────────────────────────────────
const iconCache = new Map(); // marker-index → HTMLImageElement | null
MARKERS.forEach((m, i) => {
if (!m.icon) { iconCache.set(i, null); return; }
const img = new Image();
img.onload = () => { iconCache.set(i, img); draw(EarthCore.size); };
img.onerror = () => { iconCache.set(i, null); };
img.src = m.icon;
});
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function drawMarker(m, i, w, h) {
const x = toX(m.lon, w);
const y = toY(m.lat, h);
const img = iconCache.get(i);
if (img && img.complete && img.naturalWidth > 0) {
// ── Icon ─────────────────────────────────────────────────
const hw = (m.iconW || 32) / 2;
const hh = (m.iconH || 32) / 2;
ctx.drawImage(img, x - hw, y - hh, m.iconW || 32, m.iconH || 32);
// Wrap an Datumsgrenze
if (x - hw < 0) ctx.drawImage(img, x - hw + w, y - hh, m.iconW || 32, m.iconH || 32);
if (x + hw > w) ctx.drawImage(img, x - hw - w, y - hh, m.iconW || 32, m.iconH || 32);
} else {
// ── Fallback: farbiger Leuchtpunkt ────────────────────────
const r = m.radius ?? CFG.DOT_RADIUS_PX;
const fc = m.color ?? CFG.DOT_COLOR;
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = fc;
ctx.shadowColor = m.glow ?? CFG.DOT_GLOW;
ctx.shadowBlur = CFG.DOT_GLOW_BLUR;
ctx.fill();
ctx.strokeStyle = CFG.DOT_BORDER;
ctx.lineWidth = CFG.DOT_BORDER_W;
ctx.stroke();
ctx.restore();
}
// ── Label ─────────────────────────────────────────────────
if (m.showLabel && m.name) drawLabel(x, y, m.name);
}
function drawLabel(x, y, text) {
ctx.save();
ctx.font = CFG.LABEL_FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const tx = x + CFG.LABEL_OFFSET_X;
const ty = y + CFG.LABEL_OFFSET_Y;
const boxW = ctx.measureText(text).width + CFG.LABEL_PAD_X * 2;
const boxH = 13 + CFG.LABEL_PAD_Y * 2;
ctx.fillStyle = CFG.LABEL_BG;
roundRectPath(ctx, tx, ty - boxH / 2, boxW, boxH, CFG.LABEL_RADIUS);
ctx.fill();
ctx.fillStyle = CFG.LABEL_TEXT;
ctx.fillText(text, tx + CFG.LABEL_PAD_X, ty);
ctx.restore();
}
function drawPopup({ x, y, text }) {
const lines = text.split("\n");
ctx.save();
ctx.font = CFG.POPUP_FONT;
ctx.textBaseline = "top";
const lh = 18, padX = 10, padY = 8;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
const w = markersCanvas.width, h = markersCanvas.height;
let px = x + 12, py = y - bh / 2;
if (px + bw > w - 5) px = x - bw - 12;
if (py < 5) py = 5;
if (py + bh > h - 5) py = h - bh - 5;
roundRectPath(ctx, px, py, bw, bh, 7);
ctx.fillStyle = CFG.POPUP_BG;
ctx.fill();
ctx.strokeStyle = CFG.POPUP_BORDER;
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = CFG.POPUP_TEXT;
lines.forEach((line, i) => {
if (i === 0) ctx.font = "bold " + CFG.POPUP_FONT;
else ctx.font = CFG.POPUP_FONT;
ctx.fillText(line, px + padX, py + padY + i * lh);
});
ctx.restore();
}
function draw({ w, h }) {
if (!w || !h) return;
syncCanvas(markersCanvas, w, h);
ctx.clearRect(0, 0, w, h);
MARKERS.forEach((m, i) => drawMarker(m, i, w, h));
if (popup) drawPopup(popup);
}
// ═══════════════════════════════════════════════════════════════
// INTERAKTION – Klick öffnet Popup
// ═══════════════════════════════════════════════════════════════
function hitTest(cx, cy) {
const w = markersCanvas.width, h = markersCanvas.height;
for (const m of MARKERS) {
if (!m.popup) continue;
const x = toX(m.lon, w);
const y = toY(m.lat, h);
const hw = (m.iconW ?? CFG.DOT_RADIUS_PX * 2) / 2 + 4;
const hh = (m.iconH ?? CFG.DOT_RADIUS_PX * 2) / 2 + 4;
if (Math.abs(cx - x) <= hw && Math.abs(cy - y) <= hh) return { m, x, y };
}
return null;
}
markersCanvas.addEventListener("click", e => {
const rect = markersCanvas.getBoundingClientRect();
const scaleX = markersCanvas.width / rect.width;
const scaleY = markersCanvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
const hit = hitTest(mx, my);
popup = hit ? { x: mx, y: my, text: hit.m.popup } : null;
draw(EarthCore.size);
});
markersCanvas.addEventListener("mousemove", e => {
const rect = markersCanvas.getBoundingClientRect();
const scaleX = markersCanvas.width / rect.width;
const scaleY = markersCanvas.height / rect.height;
const mx = (e.clientX - rect.left) * scaleX;
const my = (e.clientY - rect.top) * scaleY;
markersCanvas.style.cursor = hitTest(mx, my) ? "pointer" : "default";
});
// ═══════════════════════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════════════════════
window.addEventListener("earth:slow", e => draw(e.detail));
window.addEventListener("earth:resize", e => draw(e.detail));
}());
+230
View File
@@ -0,0 +1,230 @@
/**
* plugin-moon.js – Mond-Marker mit Popup (Alter & Phase)
* =========================================================
* Abhängigkeit: earth-core.js
* Erstellt: #moonCanvas (z-index 6)
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
RADIUS_PX: 16,
ICON: "../images/moons/moon4geocron.png",
UPDATE_MS: 60_000,
OBS_LAT: 50.3667, // Sternwarte Sonneberg
OBS_LON: 11.1833,
};
const { toX, toY, createCanvas, syncCanvas, roundRectPath, toDeg } = EarthCore;
const moonCanvas = createCanvas("moonCanvas", 6);
const ctx = moonCanvas.getContext("2d");
const moonImg = new Image();
moonImg.src = CFG.ICON;
let _moon = { lat: 0, lon: 0 };
let _phase = 0; // 0–360°
let _popup = false;
let _rise = null; // Date | null
let _set = null; // Date | null
// ═══════════════════════════════════════════════════════════════
// MONDBERECHNUNG (Astronomy Engine)
// ═══════════════════════════════════════════════════════════════
function moonPosition(date) {
try {
const vec = Astronomy.GeoMoon(date);
const dist = Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
const dec = toDeg(Math.asin(vec.z / dist));
const ra = toDeg(Math.atan2(vec.y, vec.x));
const gmst = Astronomy.SiderealTime(date) * 15; // Stunden → Grad
const lon = ((ra - gmst + 180) % 360 + 360) % 360 - 180;
return { lat: dec, lon: lon };
} catch (e) {
return { lat: 0, lon: 0 };
}
}
// ═══════════════════════════════════════════════════════════════
// AUFGANG / UNTERGANG (Astronomy Engine)
// ═══════════════════════════════════════════════════════════════
let _obs = null;
function findMoonRiseSet(date) {
try {
if (!_obs) _obs = new Astronomy.Observer(CFG.OBS_LAT, CFG.OBS_LON, 0);
const d0 = new Date(date); d0.setUTCHours(0, 0, 0, 0);
const r = Astronomy.SearchRiseSet(Astronomy.Body.Moon, _obs, +1, d0, 1);
const s = Astronomy.SearchRiseSet(Astronomy.Body.Moon, _obs, -1, d0, 1);
return { rise: r ? r.date : null, set: s ? s.date : null };
} catch (e) {
return { rise: null, set: null };
}
}
function fmtTime(d) {
if (!d) return "–";
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
/** Mondalter in Tagen (0–29.53) */
function moonAge(phaseDeg) {
return phaseDeg / 360 * 29.530589;
}
/** Deutsche Phasenbezeichnung */
function phaseName(phaseDeg) {
if (phaseDeg < 2 || phaseDeg >= 358) return "Neumond";
if (phaseDeg < 88) return "Zunehmende Sichel";
if (phaseDeg < 92) return "Erstes Viertel";
if (phaseDeg < 178) return "Zunehmender Mond";
if (phaseDeg < 182) return "Vollmond";
if (phaseDeg < 268) return "Abnehmender Mond";
if (phaseDeg < 272) return "Letztes Viertel";
return "Abnehmende Sichel";
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function drawMoon(cx, cy) {
const r = CFG.RADIUS_PX;
if (moonImg.complete && moonImg.naturalWidth > 0) {
ctx.drawImage(moonImg, cx - r, cy - r, r * 2, r * 2);
} else {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = "rgba(210, 210, 210, 0.95)";
ctx.shadowColor = "rgba(200, 200, 200, 0.6)";
ctx.shadowBlur = 8;
ctx.fill();
ctx.restore();
}
}
function drawPopup(cx, cy, w, h) {
const age = moonAge(_phase);
const illum = (1 - Math.cos(_phase * Math.PI / 180)) / 2 * 100;
const lines = [
phaseName(_phase),
"Mondalter: " + age.toFixed(1) + " Tage",
"Beleuchtung: " + illum.toFixed(0) + " %",
"Aufgang: " + fmtTime(_rise),
"Untergang: " + fmtTime(_set),
];
ctx.save();
ctx.font = "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
ctx.textBaseline = "top";
const lh = 18, padX = 10, padY = 8;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
let bx = cx + CFG.RADIUS_PX + 8;
let by = cy - bh / 2;
if (bx + bw > w - 5) bx = cx - bw - CFG.RADIUS_PX - 8;
if (by < 5) by = 5;
if (by + bh > h - 5) by = h - bh - 5;
roundRectPath(ctx, bx, by, bw, bh, 7);
ctx.fillStyle = "rgba(10, 15, 30, 0.92)";
ctx.fill();
ctx.strokeStyle = "rgba(180, 200, 255, 0.6)";
ctx.lineWidth = 1;
ctx.stroke();
lines.forEach((line, i) => {
ctx.fillStyle = i === 0
? "rgba(180, 210, 255, 0.95)"
: "rgba(220, 230, 255, 0.9)";
ctx.font = i === 0
? "bold 13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif"
: "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
ctx.fillText(line, bx + padX, by + padY + i * lh);
});
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════════
function render({ w, h }) {
if (!w || !h) return;
syncCanvas(moonCanvas, w, h);
ctx.clearRect(0, 0, w, h);
const mx = toX(_moon.lon, w);
const my = toY(_moon.lat, h);
const edge = CFG.RADIUS_PX + 4;
drawMoon(mx, my);
if (mx - edge < 0) drawMoon(mx + w, my);
if (mx + edge > w) drawMoon(mx - w, my);
if (_popup) drawPopup(mx, my, w, h);
}
// ═══════════════════════════════════════════════════════════════
// KLICK-ERKENNUNG (via window, Canvas hat pointerEvents:none)
// ═══════════════════════════════════════════════════════════════
function isHit(mx, my) {
const { w, h } = EarthCore.size;
const cx = toX(_moon.lon, w);
const cy = toY(_moon.lat, h);
const r = CFG.RADIUS_PX + 6;
// Auch Wrap-Kopie prüfen
return Math.hypot(mx - cx, my - cy) <= r
|| Math.hypot(mx - (cx + w), my - cy) <= r
|| Math.hypot(mx - (cx - w), my - cy) <= r;
}
window.addEventListener("click", e => {
const rect = moonCanvas.getBoundingClientRect();
if (!rect.width) return;
const mx = (e.clientX - rect.left) * (moonCanvas.width / rect.width);
const my = (e.clientY - rect.top) * (moonCanvas.height / rect.height);
if (isHit(mx, my)) {
_popup = !_popup;
render(EarthCore.size);
} else if (_popup) {
_popup = false;
render(EarthCore.size);
}
});
window.addEventListener("mousemove", e => {
const rect = moonCanvas.getBoundingClientRect();
if (!rect.width) return;
const mx = (e.clientX - rect.left) * (moonCanvas.width / rect.width);
const my = (e.clientY - rect.top) * (moonCanvas.height / rect.height);
if (isHit(mx, my)) document.body.style.cursor = "pointer";
});
// ── Updates ───────────────────────────────────────────────────
function update() {
const now = new Date();
_moon = moonPosition(now);
_phase = Astronomy.MoonPhase(now);
const rs = findMoonRiseSet(now);
_rise = rs.rise;
_set = rs.set;
render(EarthCore.size);
}
window.addEventListener("earth:slow", () => update());
window.addEventListener("earth:resize", () => render(EarthCore.size));
moonImg.onload = () => render(EarthCore.size);
update();
setInterval(update, CFG.UPDATE_MS);
}());
+355
View File
@@ -0,0 +1,355 @@
/**
* plugin-satellites.js – Satelliten & Orbits
* ==============================================
* Abhängigkeit: earth-core.js, satellite.js (unpkg CDN)
*
* Erstellt: #orbitCanvas (z-index 4)
*
* Ortsmarker (z.B. Museum) → plugin-markers.js
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
// ── TLE-Endpunkt ───────────────────────────────────────────
TLE_ENDPOINT: "getTLE.php", // ?ids=25544,20580,… → JSON aus DB
// ── Satelliten-Icons ───────────────────────────────────────
// Zuerst ../images/satpics/{id}.png versucht, dann Fallback
SAT_ICON_FALLBACK: "../images/satpics/satellit.png",
// ── Marker-Größe ───────────────────────────────────────────
SAT_MARKER_SIZE_PX: 36,
SAT_MARKER_MIN_PX: 18,
SAT_SCALE: 1.5,
ISS_SCALE: 3, // zusätzlicher Faktor nur für ISS (ID 25544)
// ── Labels ─────────────────────────────────────────────────
SHOW_SAT_LABELS: true,
LABEL_FONT_PX: 14,
LABEL_FONT_FAMILY: "system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif",
LABEL_OFFSET_X_PX: 10,
LABEL_OFFSET_Y_PX: -10,
LABEL_TEXT_COLOR: "rgba(255, 255, 255, 0.95)",
LABEL_BG_COLOR: "rgba(0, 0, 0, 0.55)",
LABEL_PADDING_X_PX: 6,
LABEL_PADDING_Y_PX: 3,
LABEL_RADIUS_PX: 6,
// ── Orbit-Vorschau ─────────────────────────────────────────
ORBIT_MINUTES: 90, // Vorschau-Zeitraum ab jetzt
ORBIT_STEP_SEC: 10, // Zeitschritt
ORBIT_COLORS: [
"rgba(120, 200, 255, 0.95)",
"rgba(255, 160, 120, 0.95)",
"rgba(160, 255, 160, 0.95)",
"rgba(220, 180, 255, 0.95)",
"rgba(255, 220, 120, 0.95)",
],
ORBIT_LINE_WIDTH_DIV: 1500,
ORBIT_SHADOW_BLUR: 0,
};
// ── Kurz-Referenz auf EarthCore-Helfer ────────────────────────
const { toX, toY, createCanvas, syncCanvas, roundRectPath } = EarthCore;
// ── Canvas & Context ──────────────────────────────────────────
const orbitCanvas = createCanvas("orbitCanvas", 10000); // pointerEvents: none → Klicks via window
const ctx = orbitCanvas.getContext("2d");
// ── Icons laden ───────────────────────────────────────────────
const satIconMap = new Map();
const satFallbackIcon = new Image();
satFallbackIcon.src = CFG.SAT_ICON_FALLBACK;
function loadSatIcon(id) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => { satIconMap.set(id, img); resolve(); };
img.onerror = () => { satIconMap.set(id, satFallbackIcon); resolve(); };
img.src = `../images/satpics/${id}.png`;
});
}
// ── TLE-State ─────────────────────────────────────────────────
let satrecs = [];
// ── Popup-State ───────────────────────────────────────────────
let popup = null; // { x, y, sat: { name, altKm, speedKms, lat, lon } }
let lastPositions = []; // [{ id, name, x, y, altKm, speedKms }] nach jedem Render
// ═══════════════════════════════════════════════════════════════
// TLE AUS DB LADEN & SATRECS AUFBAUEN
// ═══════════════════════════════════════════════════════════════
async function buildSatRecs() {
const url = CFG.TLE_ENDPOINT;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error("TLE-Endpunkt Fehler: " + res.status);
const rows = await res.json();
if (rows.error) throw new Error("TLE-DB-Fehler: " + rows.error);
satrecs = rows.map(r => ({
id: Number(r.norad_cat_id),
name: r.object_name ?? String(r.norad_cat_id),
isFavorite: Boolean(r.is_favorite),
satrec: satellite.twoline2satrec(r.tle_line1, r.tle_line2),
}));
await Promise.all(satrecs.map(s => loadSatIcon(s.id)));
}
// ═══════════════════════════════════════════════════════════════
// SATELLITEN-POSITION
// ═══════════════════════════════════════════════════════════════
const wrapLon = lon => ((lon + 180) % 360 + 360) % 360 - 180;
function currentLatLon(satrec, date) {
const pv = satellite.propagate(satrec, date);
if (!pv.position) return null;
const gmst = satellite.gstime(date);
const gd = satellite.eciToGeodetic(pv.position, gmst);
const v = pv.velocity;
const speedKms = v ? Math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z) : null;
return {
lat: satellite.degreesLat(gd.latitude),
lon: wrapLon(satellite.degreesLong(gd.longitude)),
altKm: gd.height, // km über Ellipsoid
speedKms, // km/s
};
}
function groundTrackFuture(satrec, startDate) {
const steps = Math.floor((CFG.ORBIT_MINUTES * 60) / CFG.ORBIT_STEP_SEC);
const pts = [];
for (let i = 0; i <= steps; i++) {
const d = new Date(startDate.getTime() + i * CFG.ORBIT_STEP_SEC * 1000);
const ll = currentLatLon(satrec, d);
if (ll) pts.push(ll);
}
return pts;
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function drawOrbitLine(points, baseColor) {
if (points.length < 2) return;
const total = points.length - 1;
const match = baseColor.match(/[\d.]+/g);
const r = match[0], g = match[1], b = match[2];
for (let i = 0; i < total; i++) {
const p0 = points[i], p1 = points[i + 1];
if (Math.abs(p1.lon - p0.lon) > 180) continue;
const t = i / total;
const alpha = (1 - t) * (1 - t);
ctx.beginPath();
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`;
ctx.moveTo(toX(p0.lon, orbitCanvas.width), toY(p0.lat, orbitCanvas.height));
ctx.lineTo(toX(p1.lon, orbitCanvas.width), toY(p1.lat, orbitCanvas.height));
ctx.stroke();
}
}
function getMarkerSize(id) {
const base = Math.max(CFG.SAT_MARKER_MIN_PX, CFG.SAT_MARKER_SIZE_PX) * CFG.SAT_SCALE;
return id === 25544 ? base * CFG.ISS_SCALE : base;
}
function drawMarker(id, lat, lon) {
const w = orbitCanvas.width, h = orbitCanvas.height;
const x = toX(lon, w), y = toY(lat, h);
const img = satIconMap.get(id) ?? satFallbackIcon;
const size = getMarkerSize(id);
const half = size / 2;
if (img.complete && img.naturalWidth > 0) {
ctx.drawImage(img, x - half, y - half, size, size);
if (x - half < 0) ctx.drawImage(img, x - half + w, y - half, size, size);
if (x + half > w) ctx.drawImage(img, x - half - w, y - half, size, size);
} else {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, Math.max(4, size / 6), 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
ctx.shadowColor = "rgba(255, 255, 255, 0.6)";
ctx.shadowBlur = 6;
ctx.fill();
ctx.restore();
}
}
function drawLabel(x, y, text) {
if (!CFG.SHOW_SAT_LABELS || !text) return;
ctx.save();
ctx.font = `${CFG.LABEL_FONT_PX}px ${CFG.LABEL_FONT_FAMILY}`;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const tx = x + CFG.LABEL_OFFSET_X_PX;
const ty = y + CFG.LABEL_OFFSET_Y_PX;
const padX = CFG.LABEL_PADDING_X_PX;
const padY = CFG.LABEL_PADDING_Y_PX;
const boxW = ctx.measureText(text).width + padX * 2;
const boxH = CFG.LABEL_FONT_PX + padY * 2;
ctx.fillStyle = CFG.LABEL_BG_COLOR;
roundRectPath(ctx, tx, ty - boxH / 2, boxW, boxH, CFG.LABEL_RADIUS_PX);
ctx.fill();
ctx.fillStyle = CFG.LABEL_TEXT_COLOR;
ctx.fillText(text, tx + padX, ty);
ctx.restore();
}
// ── Popup zeichnen ────────────────────────────────────────────
function drawPopup(px, py, sat, w, h) {
const lines = [
sat.name,
"Höhe: " + sat.altKm.toFixed(0) + " km",
"Geschw.: " + (sat.speedKms * 3600).toFixed(0) + " km/h",
];
ctx.save();
ctx.font = "13px " + CFG.LABEL_FONT_FAMILY;
ctx.textBaseline = "top";
const lh = 18, padX = 10, padY = 8;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
let bx = px + 14, by = py - bh / 2;
if (bx + bw > w - 5) bx = px - bw - 14;
if (by < 5) by = 5;
if (by + bh > h - 5) by = h - bh - 5;
roundRectPath(ctx, bx, by, bw, bh, 7);
ctx.fillStyle = "rgba(10, 15, 30, 0.92)";
ctx.fill();
ctx.strokeStyle = "rgba(120, 180, 255, 0.6)";
ctx.lineWidth = 1;
ctx.stroke();
lines.forEach((line, i) => {
ctx.fillStyle = i === 0 ? "rgba(120, 200, 255, 0.95)" : "rgba(220, 230, 255, 0.9)";
if (i === 0) ctx.font = "bold 13px " + CFG.LABEL_FONT_FAMILY;
else ctx.font = "13px " + CFG.LABEL_FONT_FAMILY;
ctx.fillText(line, bx + padX, by + padY + i * lh);
});
ctx.restore();
}
function render({ w, h }) {
if (!w || !h) return;
syncCanvas(orbitCanvas, w, h);
ctx.clearRect(0, 0, w, h);
const now = new Date();
lastPositions = [];
for (let i = 0; i < satrecs.length; i++) {
const s = satrecs[i];
const color = CFG.ORBIT_COLORS[i % CFG.ORBIT_COLORS.length];
const pts = groundTrackFuture(s.satrec, now);
ctx.save();
ctx.lineWidth = Math.max(1, w / CFG.ORBIT_LINE_WIDTH_DIV);
ctx.shadowBlur = CFG.ORBIT_SHADOW_BLUR;
drawOrbitLine(pts, color);
ctx.restore();
if (pts.length > 0) {
const p0 = pts[0];
drawMarker(s.id, p0.lat, p0.lon);
const sx = toX(p0.lon, w), sy = toY(p0.lat, h);
drawLabel(sx, sy, s.name || String(s.id));
lastPositions.push({
id: s.id, name: s.name || String(s.id),
x: sx, y: sy,
altKm: p0.altKm ?? 0,
speedKms: p0.speedKms ?? 0,
});
}
}
// Popup über allem zeichnen
if (popup) {
drawPopup(popup.x, popup.y, popup.sat, w, h);
}
}
// ── Klick & Hover via window (orbitCanvas hat pointerEvents:none) ──
function canvasCoords(e) {
const rect = orbitCanvas.getBoundingClientRect();
if (!rect.width || !rect.height) return null;
const scaleX = orbitCanvas.width / rect.width;
const scaleY = orbitCanvas.height / rect.height;
return {
mx: (e.clientX - rect.left) * scaleX,
my: (e.clientY - rect.top) * scaleY,
};
}
function hitSatellite(mx, my) {
let best = null, bestDist = Infinity;
for (const p of lastPositions) {
const d = Math.hypot(mx - p.x, my - p.y);
const r = getMarkerSize(p.id) / 2 + 8;
if (d <= r && d < bestDist) { best = p; bestDist = d; }
}
return best;
}
window.addEventListener("click", e => {
const c = canvasCoords(e);
if (!c) return;
const hit = hitSatellite(c.mx, c.my);
if (hit) {
popup = { x: c.mx, y: c.my, sat: hit };
render(EarthCore.size);
} else if (popup) {
popup = null;
render(EarthCore.size);
}
});
window.addEventListener("mousemove", e => {
const c = canvasCoords(e);
if (!c) return;
const hit = hitSatellite(c.mx, c.my);
document.body.style.cursor = hit ? "pointer" : "";
});
// ── Events ────────────────────────────────────────────────────
window.addEventListener("earth:fast", e => render(e.detail));
window.addEventListener("earth:resize", e => render(e.detail));
// ── Abhängigkeit laden & initialisieren ───────────────────────
function loadScript(url) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${url}"]`)) { resolve(); return; }
const s = document.createElement("script");
s.src = url;
s.onload = resolve;
s.onerror = () => reject(new Error("Konnte nicht laden: " + url));
document.head.appendChild(s);
});
}
(async function init() {
try {
await loadScript("https://unpkg.com/satellite.js@6.0.2/dist/satellite.min.js");
await buildSatRecs();
render(EarthCore.size);
} catch (e) {
console.warn("plugin-satellites:", e.message);
}
}());
}());
+216
View File
@@ -0,0 +1,216 @@
/**
* plugin-sun.js – Sonnen-Marker mit Popup (Aufgang & Untergang)
* ================================================================
* Abhängigkeit: earth-core.js, astronomy.browser.min.js
*
* Erstellt: #sunCanvas (z-index 6)
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
CORE_RADIUS_PX: 16,
RAY_INNER_PX: 19,
RAY_OUTER_PX: 29,
RAY_COUNT: 11,
OBS_LAT: 50.3667, // Sternwarte Sonneberg
OBS_LON: 11.1833,
UPDATE_MS: 60_000,
};
const { toX, toY, createCanvas, syncCanvas, roundRectPath, toDeg } = EarthCore;
const sunCanvas = createCanvas("sunCanvas", 6);
const ctx = sunCanvas.getContext("2d");
let _sun = { lat: 0, lon: 0 };
let _rise = null;
let _set = null;
let _popup = false;
let _obs = null;
// ═══════════════════════════════════════════════════════════════
// SONNENBERECHNUNG (Astronomy Engine)
// ═══════════════════════════════════════════════════════════════
function sunPosition(date) {
try {
const vec = Astronomy.GeoVector(Astronomy.Body.Sun, date, true);
const dist = Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
const dec = toDeg(Math.asin(vec.z / dist));
const ra = toDeg(Math.atan2(vec.y, vec.x));
const gmst = Astronomy.SiderealTime(date) * 15; // Stunden → Grad
const lon = ((ra - gmst + 180) % 360 + 360) % 360 - 180;
return { lat: dec, lon: lon };
} catch (e) {
return { lat: 0, lon: 0 };
}
}
// ═══════════════════════════════════════════════════════════════
// AUFGANG / UNTERGANG (Astronomy Engine)
// ═══════════════════════════════════════════════════════════════
function findSunRiseSet(date) {
try {
if (!_obs) _obs = new Astronomy.Observer(CFG.OBS_LAT, CFG.OBS_LON, 0);
const d0 = new Date(date); d0.setUTCHours(0, 0, 0, 0);
const r = Astronomy.SearchRiseSet(Astronomy.Body.Sun, _obs, +1, d0, 1);
const s = Astronomy.SearchRiseSet(Astronomy.Body.Sun, _obs, -1, d0, 1);
return { rise: r ? r.date : null, set: s ? s.date : null };
} catch (e) {
return { rise: null, set: null };
}
}
function fmtTime(d) {
if (!d) return "–";
return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
function drawSunAt(cx, cy) {
ctx.save();
ctx.strokeStyle = "rgba(255, 200, 0, 0.9)";
ctx.lineWidth = 1.5;
ctx.shadowColor = "rgba(255, 200, 0, 0.7)";
ctx.shadowBlur = 6;
for (let i = 0; i < CFG.RAY_COUNT; i++) {
const a = (i / CFG.RAY_COUNT) * 2 * Math.PI;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * CFG.RAY_INNER_PX, cy + Math.sin(a) * CFG.RAY_INNER_PX);
ctx.lineTo(cx + Math.cos(a) * CFG.RAY_OUTER_PX, cy + Math.sin(a) * CFG.RAY_OUTER_PX);
ctx.stroke();
}
ctx.beginPath();
ctx.arc(cx, cy, CFG.CORE_RADIUS_PX, 0, 2 * Math.PI);
ctx.fillStyle = "rgba(255, 230, 0, 1)";
ctx.shadowColor = "rgba(255, 200, 0, 0.9)";
ctx.shadowBlur = 12;
ctx.fill();
ctx.strokeStyle = "rgba(255, 140, 0, 1)";
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
function drawPopup(cx, cy, w, h) {
const lines = [
"Aufgang: " + fmtTime(_rise),
"Untergang: " + fmtTime(_set),
];
ctx.save();
ctx.font = "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
ctx.textBaseline = "top";
const lh = 18, padX = 10, padY = 8;
const bw = Math.max(...lines.map(l => ctx.measureText(l).width)) + padX * 2;
const bh = lines.length * lh + padY * 2;
let bx = cx + CFG.RAY_OUTER_PX + 8;
let by = cy - bh / 2;
if (bx + bw > w - 5) bx = cx - bw - CFG.RAY_OUTER_PX - 8;
if (by < 5) by = 5;
if (by + bh > h - 5) by = h - bh - 5;
roundRectPath(ctx, bx, by, bw, bh, 7);
ctx.fillStyle = "rgba(30, 20, 5, 0.92)";
ctx.fill();
ctx.strokeStyle = "rgba(255, 200, 80, 0.6)";
ctx.lineWidth = 1;
ctx.stroke();
lines.forEach((line, i) => {
ctx.fillStyle = i === 0
? "rgba(255, 220, 100, 0.95)"
: "rgba(255, 240, 200, 0.9)";
ctx.font = i === 0
? "bold 13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif"
: "13px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif";
ctx.fillText(line, bx + padX, by + padY + i * lh);
});
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════════
function render({ w, h }) {
if (!w || !h) return;
syncCanvas(sunCanvas, w, h);
ctx.clearRect(0, 0, w, h);
const sx = toX(_sun.lon, w);
const sy = toY(_sun.lat, h);
const edge = CFG.RAY_OUTER_PX + 2;
drawSunAt(sx, sy);
if (sx - edge < 0) drawSunAt(sx + w, sy);
if (sx + edge > w) drawSunAt(sx - w, sy);
if (_popup) drawPopup(sx, sy, w, h);
}
// ═══════════════════════════════════════════════════════════════
// KLICK-ERKENNUNG
// ═══════════════════════════════════════════════════════════════
function isHit(mx, my) {
const { w, h } = EarthCore.size;
const cx = toX(_sun.lon, w);
const cy = toY(_sun.lat, h);
const r = CFG.RAY_OUTER_PX + 4;
return Math.hypot(mx - cx, my - cy) <= r
|| Math.hypot(mx - (cx + w), my - cy) <= r
|| Math.hypot(mx - (cx - w), my - cy) <= r;
}
window.addEventListener("click", e => {
const rect = sunCanvas.getBoundingClientRect();
if (!rect.width) return;
const mx = (e.clientX - rect.left) * (sunCanvas.width / rect.width);
const my = (e.clientY - rect.top) * (sunCanvas.height / rect.height);
if (isHit(mx, my)) {
_popup = !_popup;
render(EarthCore.size);
} else if (_popup) {
_popup = false;
render(EarthCore.size);
}
});
window.addEventListener("mousemove", e => {
const rect = sunCanvas.getBoundingClientRect();
if (!rect.width) return;
const mx = (e.clientX - rect.left) * (sunCanvas.width / rect.width);
const my = (e.clientY - rect.top) * (sunCanvas.height / rect.height);
if (isHit(mx, my)) document.body.style.cursor = "pointer";
});
// ═══════════════════════════════════════════════════════════════
// UPDATE
// ═══════════════════════════════════════════════════════════════
function update() {
const now = new Date();
_sun = sunPosition(now);
const rs = findSunRiseSet(now);
_rise = rs.rise;
_set = rs.set;
render(EarthCore.size);
}
window.addEventListener("earth:slow", () => update());
window.addEventListener("earth:resize", () => render(EarthCore.size));
update();
setInterval(update, CFG.UPDATE_MS);
}());
+226
View File
@@ -0,0 +1,226 @@
/**
* plugin-terminator.js – Nachtmaske, Dämmerungszonen & Terminator-Linien
* =========================================================================
* Abhängigkeit: earth-core.js
*
* Erstellt 2 Canvas-Layer:
* #nightCanvas (z-index 2) – per-Pixel Dämmerungsmaske über Nachtkarte
* #terminatorCanvas (z-index 5) – Terminator-Linien
*
* Feuert zusätzlich das Event earth:terminator mit
* { pts, northIsNight }
* damit plugin-clouds.js den Terminator zum Clipping nutzen kann.
*/
(function () {
// ═══════════════════════════════════════════════════════════════
// KONFIGURATION
// ═══════════════════════════════════════════════════════════════
const CFG = {
// ── Nachtbild ─────────────────────────────────────────────
IMG_NIGHT_DIR: "../images/planets/earth/night/",
IMG_EXT: ".jpg",
// ── Dämmerungsmaske ───────────────────────────────────────
// Alpha-Werte pro Sonnenhöhenwinkel-Zone
NIGHT_ALPHA: {
0: 80, // Bürgerliche Dämmerung (Sonne < 0°)
"-6": 150, // Nautische Dämmerung (Sonne < 6°)
"-12": 200, // Astronomische Dämmerung (Sonne < 12°)
"-18": 255, // Vollständige Nacht (Sonne < 18°)
},
// Auflösung der Dämmerungsmaske (höher = schärfer, mehr CPU)
MASK_W: 2440,
MASK_H: 1720,
// ── Terminator-Linien ─────────────────────────────────────
TERMINATOR_LINES: [
{ elev: 0, color: "rgba(255, 210, 80, 0.3)", shadow: "rgba(255, 180, 30, 0.2)" },
{ elev: -6, color: "rgba(255, 140, 50, 0.1)", shadow: "rgba(255, 100, 10, 0.2)" },
{ elev: -12, color: "rgba(200, 80, 200, 0.1)", shadow: "rgba(160, 40, 160, 0.2)" },
{ elev: -18, color: "rgba( 80, 120, 255, 0.1)", shadow: "rgba( 40, 70, 200, 0.2)" },
],
// Linienstärke: Canvas-Breite / Wert (Minimum 1 px)
LINE_WIDTH_DIV: 1000,
// Leuchtschein-Stärke um Terminator-Linien
SHADOW_BLUR: 4,
// Berechnungspunkte pro Kurve (höher = glatter)
STEPS: 5000,
};
// ── Kurz-Referenz auf EarthCore-Helfer ────────────────────────
const { toRad, toDeg, toX, toY, createCanvas, syncCanvas } = EarthCore;
// ── Canvas & Context ──────────────────────────────────────────
const nightCanvas = createCanvas("nightCanvas", 2);
const termCanvas = createCanvas("terminatorCanvas", 5);
const ctxNight = nightCanvas.getContext("2d");
const ctxTerm = termCanvas.getContext("2d");
// ── Nachtbild laden ───────────────────────────────────────────
const nightImg = new Image();
nightImg.src = `${CFG.IMG_NIGHT_DIR}${new Date().getMonth() + 1}${CFG.IMG_EXT}`;
// ═══════════════════════════════════════════════════════════════
// ASTRONOMISCHE HILFSFUNKTIONEN
// ═══════════════════════════════════════════════════════════════
function isNight(lat, lon, sLat, sLon) {
return (
Math.sin(toRad(lat)) * Math.sin(toRad(sLat)) +
Math.cos(toRad(lat)) * Math.cos(toRad(sLat)) *
Math.cos(toRad(lon - sLon))
) < 0;
}
/** Terminator-Kurve als Parameterkurve (Kleinstkreis) */
function terminatorCurve(sunLat, sunLon, elev = 0) {
const steps = CFG.STEPS;
const sinE = Math.sin(toRad(elev));
const cosE = Math.sqrt(Math.max(0, 1 - sinE * sinE));
const sLat = toRad(sunLat), sLon = toRad(sunLon);
const Sx = Math.cos(sLat) * Math.cos(sLon);
const Sy = Math.cos(sLat) * Math.sin(sLon);
const Sz = Math.sin(sLat);
let ux, uy, uz;
if (Math.abs(Sz) < 0.9) {
const len = Math.sqrt(Sx * Sx + Sy * Sy);
ux = -Sy / len; uy = Sx / len; uz = 0;
} else {
const len = Math.sqrt(Sy * Sy + Sz * Sz);
ux = 0; uy = -Sz / len; uz = Sy / len;
}
const vx = Sy * uz - Sz * uy;
const vy = Sz * ux - Sx * uz;
const vz = Sx * uy - Sy * ux;
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = (2 * Math.PI * i) / steps;
const ct = Math.cos(t), st = Math.sin(t);
const px = sinE * Sx + cosE * (ct * ux + st * vx);
const py = sinE * Sy + cosE * (ct * uy + st * vy);
const pz = sinE * Sz + cosE * (ct * uz + st * vz);
pts.push({
lat: toDeg(Math.asin(Math.max(-1, Math.min(1, pz)))),
lon: toDeg(Math.atan2(py, px)),
});
}
return pts;
}
/** Einfacher lon-sortierter Terminator (für Clipping in Clouds-Plugin) */
function terminatorPtsSimple(sunLat, sunLon, steps = 720) {
const sR = toRad(sunLat);
const pts = [];
for (let i = 0; i <= steps; i++) {
const lon = -180 + (360 / steps) * i;
const dLon = toRad(lon - sunLon);
const lat = Math.abs(sunLat) < 0.001
? (Math.cos(dLon) >= 0 ? 90 : -90)
: toDeg(Math.atan(-Math.cos(dLon) / Math.tan(sR)));
pts.push({ lon, lat });
}
return pts;
}
// ═══════════════════════════════════════════════════════════════
// ZEICHNEN
// ═══════════════════════════════════════════════════════════════
/** Nachtmaske mit per-Pixel Dämmerungsübergängen */
function drawNightMask(sunLat, sunLon, w, h) {
if (!nightImg.complete || !nightImg.naturalWidth) return;
const MW = CFG.MASK_W, MH = CFG.MASK_H;
const offNight = document.createElement("canvas");
offNight.width = MW; offNight.height = MH;
const octx = offNight.getContext("2d");
octx.drawImage(nightImg, 0, 0, MW, MH);
const imgData = octx.getImageData(0, 0, MW, MH);
const d = imgData.data;
const sinSLat = Math.sin(toRad(sunLat));
const cosSLat = Math.cos(toRad(sunLat));
for (let py = 0; py < MH; py++) {
const lat = 90 - (py / MH) * 180;
const sinLat = Math.sin(toRad(lat));
const cosLat = Math.cos(toRad(lat));
for (let px = 0; px < MW; px++) {
const lon = (px / MW) * 360 - 180;
const sinAlt = sinLat * sinSLat + cosLat * cosSLat * Math.cos(toRad(lon - sunLon));
const alt = toDeg(Math.asin(Math.max(-1, Math.min(1, sinAlt))));
let a = 0;
if (alt < 0) a = CFG.NIGHT_ALPHA[0];
if (alt < -6) a = CFG.NIGHT_ALPHA["-6"];
if (alt < -12) a = CFG.NIGHT_ALPHA["-12"];
if (alt < -18) a = CFG.NIGHT_ALPHA["-18"];
d[(py * MW + px) * 4 + 3] = a;
}
}
octx.putImageData(imgData, 0, 0);
ctxNight.clearRect(0, 0, w, h);
ctxNight.drawImage(offNight, 0, 0, w, h);
}
/** Terminator-Kurve zeichnen (Stift bei Meridian-Wrap heben) */
function strokeCurve(ctx, pts, w, h) {
ctx.beginPath();
let first = true, prevLon = null;
for (const p of pts) {
if (prevLon !== null && Math.abs(p.lon - prevLon) > 180) first = true;
const x = toX(p.lon, w), y = toY(p.lat, h);
first ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
first = false; prevLon = p.lon;
}
ctx.stroke();
}
// ── Haupt-Render ──────────────────────────────────────────────
function render({ sun, w, h }) {
if (!w || !h) return;
// Größe synchronisieren
syncCanvas(nightCanvas, w, h);
syncCanvas(termCanvas, w, h);
// Nachtmaske
drawNightMask(sun.lat, sun.lon, w, h);
// Terminator-Linien
ctxTerm.clearRect(0, 0, w, h);
for (const line of CFG.TERMINATOR_LINES) {
const curve = terminatorCurve(sun.lat, sun.lon, line.elev);
ctxTerm.strokeStyle = line.color;
ctxTerm.lineWidth = Math.max(1, w / CFG.LINE_WIDTH_DIV);
ctxTerm.shadowColor = line.shadow;
ctxTerm.shadowBlur = CFG.SHADOW_BLUR;
strokeCurve(ctxTerm, curve, w, h);
}
// Event für Clouds-Plugin feuern
const pts = terminatorPtsSimple(sun.lat, sun.lon);
const northIsNight = isNight(89, 0, sun.lat, sun.lon);
window.dispatchEvent(new CustomEvent("earth:terminator", {
detail: { pts, northIsNight, w, h }
}));
}
// ── Events ────────────────────────────────────────────────────
window.addEventListener("earth:slow", e => render(e.detail));
window.addEventListener("earth:resize", e => render({ sun: EarthCore.sun, ...e.detail }));
nightImg.onload = () => render({ sun: EarthCore.sun, ...EarthCore.size });
}());
+190
View File
@@ -0,0 +1,190 @@
/**
* plugin-ui.js – Layer-Steuerung (ein-/ausblenden)
* ====================================================
* Abhängigkeit: earth-core.js
*/
(function () {
const LAYERS = [
{ name: "Nacht & Dämmerung", canvasIds: ["nightCanvas", "terminatorCanvas"], defaultOn: true },
{ name: "Wolken", canvasIds: ["cloudCanvas"], defaultOn: false },
{ name: "Aurora", canvasIds: ["auroraCanvas"], defaultOn: false },
{ name: "Satelliten", canvasIds: ["orbitCanvas"], defaultOn: true },
{ name: "Sonne", canvasIds: ["sunCanvas"], defaultOn: true },
{ name: "Mond", canvasIds: ["moonCanvas"], defaultOn: true },
{ name: "Erdbeben", canvasIds: ["earthquakeCanvas"], defaultOn: false },
{ name: "Ortsmarker", canvasIds: ["markersCanvas"], defaultOn: false },
];
const CFG = {
TITLE: "Layer",
ACCENT: "#4a9eff",
ACCENT_OFF: "rgba(50, 60, 90, 0.9)",
};
// ── pendingVisibility und setVisibility VOR dem UI-Aufbau ──────
const pendingVisibility = new Map();
function setVisibility(layer, on) {
layer.canvasIds.forEach(id => {
const el = document.getElementById(id);
if (el) {
el.style.display = on ? "block" : "none";
} else {
pendingVisibility.set(id, on);
}
});
}
const observer = new MutationObserver(mutations => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (node.id && pendingVisibility.has(node.id)) {
node.style.display = pendingVisibility.get(node.id) ? "block" : "none";
pendingVisibility.delete(node.id);
}
}
}
if (pendingVisibility.size === 0) observer.disconnect();
});
observer.observe(document.getElementById("earth-wrapper") || document.body, {
childList: true, subtree: true,
});
// ── CSS ────────────────────────────────────────────────────────
const style = document.createElement("style");
style.textContent = `
#earth-ui {
position: absolute; bottom: 14px; left: 14px; z-index: 9999;
background: rgba(6, 10, 22, 0.78);
border: 1px solid rgba(80, 110, 190, 0.28);
border-radius: 8px; padding: 7px 10px;
font: 11px/1.4 system-ui, -apple-system, sans-serif;
color: rgba(190, 210, 255, 0.88);
user-select: none; min-width: 140px;
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
box-shadow: 0 2px 16px rgba(0,0,0,0.5);
opacity: 0.75; transition: opacity 0.2s;
}
#earth-ui:hover { opacity: 1; }
#earth-ui-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px; cursor: pointer; gap: 8px;
}
#earth-ui-title {
font-weight: 600; font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(140, 170, 255, 0.75);
}
#earth-ui-chevron {
font-size: 8px; color: rgba(100, 120, 180, 0.6);
transition: transform 0.2s;
}
#earth-ui-chevron.collapsed { transform: rotate(-90deg); }
#earth-ui-rows {
display: flex; flex-direction: column; gap: 4px;
overflow: hidden; max-height: 300px; opacity: 1;
transition: max-height 0.2s ease, opacity 0.15s;
}
#earth-ui-rows.collapsed { max-height: 0; opacity: 0; }
.earth-ui-row {
display: flex; align-items: center;
justify-content: space-between; gap: 8px;
}
.earth-ui-label {
font-size: 11px; color: rgba(190, 210, 255, 0.85);
transition: color 0.15s; flex: 1; cursor: pointer;
}
.earth-ui-label:hover { color: #fff; }
.earth-ui-label.off { color: rgba(100, 115, 155, 0.65); }
/* Schmaler Toggle: 28×15px */
.earth-ui-toggle { position: relative; width: 28px; height: 15px; flex-shrink: 0; }
.earth-ui-toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.earth-ui-slider {
position: absolute; inset: 0; border-radius: 15px;
background: ${CFG.ACCENT_OFF}; transition: background 0.18s; cursor: pointer;
}
.earth-ui-slider::before {
content: ""; position: absolute;
width: 10px; height: 10px; left: 2.5px; top: 50%;
transform: translateY(-50%); border-radius: 50%;
background: rgba(160, 175, 210, 0.8);
transition: left 0.18s, background 0.18s;
}
.earth-ui-toggle input:checked + .earth-ui-slider { background: ${CFG.ACCENT}; }
.earth-ui-toggle input:checked + .earth-ui-slider::before {
left: 15.5px; background: #fff;
}
`;
document.head.appendChild(style);
// ── Panel ──────────────────────────────────────────────────────
const panel = document.createElement("div");
panel.id = "earth-ui";
const header = document.createElement("div");
header.id = "earth-ui-header";
header.innerHTML = `
<span id="earth-ui-title">${CFG.TITLE}</span>
<span id="earth-ui-chevron">▼</span>
`;
panel.appendChild(header);
const rows = document.createElement("div");
rows.id = "earth-ui-rows";
panel.appendChild(rows);
let collapsed = false;
header.addEventListener("click", () => {
collapsed = !collapsed;
rows.classList.toggle("collapsed", collapsed);
document.getElementById("earth-ui-chevron").classList.toggle("collapsed", collapsed);
});
// ── Rows ───────────────────────────────────────────────────────
LAYERS.forEach(layer => {
const row = document.createElement("div");
row.className = "earth-ui-row";
const label = document.createElement("span");
label.className = "earth-ui-label";
label.textContent = layer.name;
const toggleWrap = document.createElement("label");
toggleWrap.className = "earth-ui-toggle";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = layer.defaultOn;
const slider = document.createElement("span");
slider.className = "earth-ui-slider";
toggleWrap.appendChild(checkbox);
toggleWrap.appendChild(slider);
row.appendChild(label);
row.appendChild(toggleWrap);
rows.appendChild(row);
setVisibility(layer, layer.defaultOn);
if (!layer.defaultOn) label.classList.add("off");
checkbox.addEventListener("change", () => {
setVisibility(layer, checkbox.checked);
label.classList.toggle("off", !checkbox.checked);
});
label.addEventListener("click", () => {
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event("change"));
});
});
(document.getElementById("earth-wrapper") || document.body).appendChild(panel);
}());
+1
View File
@@ -59,6 +59,7 @@ $menuGroups = [
['href' => 'jupitersystem.php', 'label' => 'Jupitersystem'],
['href' => 'tagbogen.php', 'label' => 'Tagbogen'],
['href' => 'sternenhimmel.php', 'label' => 'Sternenhimmel'],
['href' => 'geocron/index.php', 'label' => 'Geocron'],
],
],
[
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB