Geocron ist drin
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}());
|
||||
@@ -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; },
|
||||
};
|
||||
|
||||
}());
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}());
|
||||
@@ -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);
|
||||
@@ -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'; ?>
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
}());
|
||||
@@ -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 Massenaussterbens 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));
|
||||
|
||||
}());
|
||||
@@ -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);
|
||||
|
||||
}());
|
||||
@@ -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);
|
||||
}
|
||||
}());
|
||||
|
||||
}());
|
||||
@@ -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);
|
||||
|
||||
}());
|
||||
@@ -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 });
|
||||
|
||||
}());
|
||||
@@ -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);
|
||||
|
||||
}());
|
||||
@@ -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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 315 KiB |
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 316 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 172 KiB |