Geocron ist drin
@@ -5423,3 +5423,58 @@ body.sternenhimmel-page .star-loading-note {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
letter-spacing: 0.04em;
|
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' => 'jupitersystem.php', 'label' => 'Jupitersystem'],
|
||||||
['href' => 'tagbogen.php', 'label' => 'Tagbogen'],
|
['href' => 'tagbogen.php', 'label' => 'Tagbogen'],
|
||||||
['href' => 'sternenhimmel.php', 'label' => 'Sternenhimmel'],
|
['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 |