386 lines
16 KiB
JavaScript
386 lines
16 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
|
||
}());
|