Files
skyview.astronomiemuseum.de/public/geocron/aurora.js
T
2026-04-02 15:40:06 +02:00

386 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
}());