diff --git a/ajax/meteogramm.php b/ajax/meteogramm.php
new file mode 100644
index 0000000..e4c957b
--- /dev/null
+++ b/ajax/meteogramm.php
@@ -0,0 +1,186 @@
+ "Nicht angemeldet."]);
+ exit;
+}
+
+/*
+ * Die Spaltenlage einer Stundenzeile. Neue Größen hängen HINTEN an - dann
+ * bleiben die Stellen bestehender Spuren gültig und der Katalog muss nur
+ * einen Schlüssel dazubekommen.
+ *
+ * 0 t Unix-Sekunden, volle Stunde
+ * 1 gemessen 1 = die eigene Station hat diese Stunde gemessen
+ * 2 temp °C 3 regen mm 4 schnee cm
+ * 5 wind km/h 6 boe km/h 7 richtung Grad
+ * 8 strahlung W/m² 9 wtief % 10 wmittel %
+ * 11 whoch % 12 code WMO 13 feuchte %
+ * 14 druck hPa 15 regenWkt %
+ */
+
+try {
+ $db = solarDb();
+
+ $tage = max(2, min(7, intval($_GET["tage"] ?? 5)));
+ $rueck = max(0, min(72, intval($_GET["rueck"] ?? 24)));
+
+ // Auf volle Stunden rasten: die Vorhersage kennt nur Stunden, und eine
+ // Achse, die bei 13:47 anfängt, hätte keine runden Tagesgrenzen.
+ $jetzt = time();
+ $von = (intdiv($jetzt, 3600) - $rueck) * 3600;
+ $bis = (intdiv($jetzt, 3600) + $tage * 24) * 3600;
+
+ // --- Vorhersage und nachgerechnete Stunden ---------------------------
+ $reihe = [];
+ $stmt = $db->prepare(
+ "SELECT UNIX_TIMESTAMP(datetime) AS t, temp, regen, schnee, wind, boe,
+ richtung, strahlung, wolken_tief, wolken_mittel, wolken_hoch,
+ wettercode, feuchte, druck, regen_wkt
+ FROM weatherHours
+ WHERE datetime BETWEEN FROM_UNIXTIME(?) AND FROM_UNIXTIME(?)
+ ORDER BY datetime");
+ $stmt->bind_param("ii", $von, $bis);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ while ($z = $res->fetch_assoc()) {
+ $t = (int)$z["t"];
+ $reihe[$t] = [
+ $t, 0,
+ zahl($z["temp"], 1), zahl($z["regen"], 1), zahl($z["schnee"], 1),
+ zahl($z["wind"], 1), zahl($z["boe"], 1), ganz($z["richtung"]),
+ ganz($z["strahlung"]),
+ ganz($z["wolken_tief"]), ganz($z["wolken_mittel"]), ganz($z["wolken_hoch"]),
+ ganz($z["wettercode"]), ganz($z["feuchte"]), ganz($z["druck"]),
+ ganz($z["regen_wkt"]),
+ ];
+ }
+ $stmt->close();
+
+ // --- Gemessene Stunden der eigenen Station ---------------------------
+ //
+ // Derselbe Ausdruck dient als Spalte und als GROUP BY - ein Schalter
+ // statt zweier Abfragen, wie in ajax/speicher.php.
+ //
+ // MAX(gustSpeed), aber AVG(windSpeed): Open-Meteos Böe ist die Spitze der
+ // Stunde. Mit einem Mittelwert auf der Messseite spränge die Böenlinie
+ // genau am Jetzt-Strich.
+ //
+ // Die Windrichtung ist ein Winkel und verträgt kein arithmetisches
+ // Mittel: aus 350° und 10° würden 180°, also die Gegenrichtung. Deshalb
+ // die Vektorsumme über Sinus und Kosinus.
+ if ($rueck > 0) {
+ $stmt = $db->prepare(
+ "SELECT FLOOR(UNIX_TIMESTAMP(datetime) / 3600) * 3600 AS t,
+ AVG(tempAmb) AS temp, AVG(windSpeed) AS wind,
+ MAX(gustSpeed) AS boe, AVG(hum) AS feuchte, AVG(qff) AS druck,
+ DEGREES(ATAN2(AVG(SIN(RADIANS(windDir))),
+ AVG(COS(RADIANS(windDir))))) AS richtung
+ FROM weatherStation
+ WHERE datetime >= FROM_UNIXTIME(?) AND datetime < FROM_UNIXTIME(?)
+ GROUP BY t ORDER BY t");
+ $stmt->bind_param("ii", $von, $jetzt);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ while ($z = $res->fetch_assoc()) {
+ $t = (int)$z["t"];
+ if (!isset($reihe[$t])) {
+ continue; // gemessen, aber ohne Vorhersagezeile - nichts zu zeigen
+ }
+ $reihe[$t][1] = 1;
+ $reihe[$t][2] = zahl($z["temp"], 1);
+ $reihe[$t][5] = zahl($z["wind"], 1);
+ $reihe[$t][6] = zahl($z["boe"], 1);
+ $reihe[$t][7] = ((int)round($z["richtung"]) + 360) % 360;
+ $reihe[$t][13] = ganz($z["feuchte"]);
+ $reihe[$t][14] = ganz($z["druck"]);
+ }
+ $stmt->close();
+ }
+
+ ksort($reihe);
+
+ // --- Tage: Sonnenzeiten und Tagesspanne ------------------------------
+ //
+ // Sonnenauf- und -untergang kommen aus weatherDays und nicht aus
+ // daylight: daylight reicht nur bis morgen, das Meteogramm bis zu sieben
+ // Tage. Tage ohne Sonnenzeit (nachgetragenes Archiv) fallen hier heraus -
+ // sie liegen ohnehin weit vor dem angezeigten Fenster.
+ $tageListe = [];
+ $stmt = $db->prepare(
+ "SELECT UNIX_TIMESTAMP(date) AS t, temp_min, temp_max, wettercode,
+ UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenauf)) AS auf,
+ UNIX_TIMESTAMP(CONCAT(date, ' ', sonnenunter)) AS unter
+ FROM weatherDays
+ WHERE date BETWEEN DATE(FROM_UNIXTIME(?)) AND DATE(FROM_UNIXTIME(?))
+ AND sonnenauf IS NOT NULL
+ ORDER BY date");
+ $stmt->bind_param("ii", $von, $bis);
+ $stmt->execute();
+ $res = $stmt->get_result();
+ while ($z = $res->fetch_assoc()) {
+ $tageListe[] = [
+ (int)$z["t"], zahl($z["temp_min"], 1), zahl($z["temp_max"], 1),
+ ganz($z["wettercode"]), (int)$z["auf"], (int)$z["unter"],
+ ];
+ }
+ $stmt->close();
+
+ // Wie frisch die Vorhersage ist - das Modul schreibt daraus "Stand 09:40"
+ // und färbt es warnend, wenn der Sammler stehengeblieben ist.
+ $stand = 0;
+ if ($res = $db->query("SELECT UNIX_TIMESTAMP(MAX(abgerufen)) AS s FROM weatherHours")) {
+ $stand = (int)($res->fetch_assoc()["s"] ?? 0);
+ }
+
+ echo json_encode([
+ "von" => $von, "bis" => $bis, "jetzt" => $jetzt, "stand" => $stand,
+ "stunden" => array_values($reihe),
+ "tage" => $tageListe,
+ ]);
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode(["error" => "Meteogramm nicht abrufbar: " . $e->getMessage()]);
+}
+
+/** Zahl mit fester Nachkommastelle, NULL bleibt NULL. */
+function zahl($wert, $stellen)
+{
+ return $wert === null ? null : round((float)$wert, $stellen);
+}
+
+/** Ganze Zahl, NULL bleibt NULL - eine fehlende Bewölkung ist nicht 0 %. */
+function ganz($wert)
+{
+ return $wert === null ? null : (int)round((float)$wert);
+}
diff --git a/css/solar.css b/css/solar.css
index 2d93969..d0f11c8 100644
--- a/css/solar.css
+++ b/css/solar.css
@@ -3958,6 +3958,83 @@ svg.anl-plan.anl-hoch { max-height: none; }
.spk .spk-verlauf.neu .spk-linie { stroke-dashoffset: 0; }
}
+/* ==========================================================================
+ Meteogramm (js/solar/meteogramm.js, Inhalt aus meteogrammKatalog.js).
+ Töne wie Übersicht, Anlage und Speicher. Datenfarben stehen im Katalog -
+ hier nur, was die Struktur trägt.
+
+ Alle Regeln tragen .mtg davor: solar.css lädt vor adminlte.min.css, und
+ bei gleicher Spezifität gewänne die spätere Regel.
+ ========================================================================== */
+.mtg { display: grid; gap: .6rem; }
+.mtg .mtg-meldung { border-radius: .7rem; padding: .5rem .9rem; font-size: .85rem; background: rgba(232, 163, 58, .12); color: #e8a33a; }
+.mtg .mtg-leer { color: var(--ton-schrift-3, #6f7a81); font-size: .85rem; margin: .8rem 0; }
+
+/* Live-Leiste: was die eigene Station gerade meldet. */
+.mtg .mtg-jetzt { display: flex; flex-wrap: wrap; align-items: baseline; gap: .3rem 1rem; }
+.mtg .mtg-jetzt-titel { font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--ton-schrift-3, #6f7a81); }
+.mtg .mtg-wert { font-size: .92rem; font-weight: 600; color: var(--ton-schrift, #e8ede9); font-variant-numeric: tabular-nums; display: inline-flex; align-items: center; gap: .3rem; }
+.mtg .mtg-wert i { font-family: bootstrap-icons; font-style: normal; font-size: .9rem; font-weight: 400; color: var(--ton-schrift-2, #9aa6ad); }
+
+/* Die Tafel. position:relative traegt den Tooltip. */
+.mtg .mtg-bild { position: relative; min-height: 180px; }
+.mtg .mtg-tafel { display: block; width: 100%; height: auto; touch-action: pan-y; user-select: none; -webkit-tap-highlight-color: transparent; font-family: "Poppins", system-ui, sans-serif; }
+
+.mtg .mtg-nacht { fill: #0c0f11; fill-opacity: .35; }
+.mtg .mtg-vergangen { fill: #ffffff; fill-opacity: .025; pointer-events: none; }
+.mtg .mtg-gitter { stroke: var(--ton-linie, rgba(138, 148, 158, .2)); stroke-width: 1; }
+.mtg .mtg-tag { stroke: var(--ton-linie-satt, rgba(138, 148, 158, .38)); stroke-width: 1; }
+.mtg .mtg-jetztlinie { stroke: var(--ton-sonne, #f0b429); stroke-width: 1.5; stroke-dasharray: 3 3; }
+.mtg .mtg-zeiger { stroke: var(--ton-schrift-2, #9aa6ad); stroke-width: 1; pointer-events: none; }
+.mtg .mtg-fang { fill: transparent; }
+
+.mtg .mtg-achse { fill: var(--ton-schrift-3, #6f7a81); font-size: 10px; }
+.mtg .mtg-einheit { fill: var(--ton-schrift-3, #6f7a81); font-size: 9px; font-weight: 600; }
+.mtg .mtg-name { fill: var(--ton-schrift-2, #9aa6ad); font-size: 9.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; }
+.mtg .mtg-tagname { fill: var(--ton-schrift-2, #9aa6ad); font-size: 11px; font-weight: 600; }
+.mtg .mtg-stunde { fill: var(--ton-schrift-3, #6f7a81); font-size: 9px; }
+.mtg .mtg-marke { font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; }
+.mtg .mtg-wetter { font-family: bootstrap-icons; font-size: 15px; fill: var(--ton-schrift-2, #9aa6ad); }
+
+.mtg .mtg-linie { fill: none; stroke-linejoin: round; stroke-linecap: round; }
+.mtg .mtg-flaeche, .mtg .mtg-balken, .mtg .mtg-wolke { stroke: none; }
+.mtg .mtg-pfeil path { stroke: var(--ton-schrift-2, #9aa6ad); stroke-width: 1.3; fill: none; stroke-linecap: round; }
+.mtg .mtg-pfeil.mtg-schwach path { stroke-opacity: .35; }
+
+/* Zeitraumknoepfe in der Kartenkopfzeile, wie .spk-zeitraum. */
+.mtg-zeitraum { display: inline-flex; gap: 2px; padding: 3px; border-radius: var(--ton-radius-klein, .5rem); background: var(--ton-grund, #191d20); }
+.mtg-zeitraum button { all: unset; box-sizing: border-box; cursor: pointer; padding: .2rem .7rem; border-radius: .4rem; font-size: .8rem; font-weight: 600; line-height: 1.5; color: var(--ton-schrift-2, #9aa6ad); }
+.mtg-zeitraum button.aktiv { background: var(--ton-flaeche-2, #2b3237); color: var(--ton-schrift, #e8ede9); }
+
+/* Legende und Fuss. */
+.mtg .mtg-unten { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: .3rem .9rem; }
+.mtg .mtg-legende { display: flex; flex-wrap: wrap; gap: .25rem .9rem; font-size: .78rem; color: var(--ton-schrift-2, #9aa6ad); }
+.mtg .mtg-legende span { display: inline-flex; align-items: center; gap: .35rem; }
+.mtg .mtg-legende i, .mtg .mtg-tipp i { width: .6rem; height: .6rem; border-radius: 2px; display: inline-block; flex: none; }
+.mtg .mtg-band-probe { opacity: .5; }
+.mtg .mtg-fuss { font-size: .74rem; color: var(--ton-schrift-3, #6f7a81); }
+.mtg .mtg-fuss.mtg-alt { color: #e8a33a; }
+
+/* Tooltip als div ueber dem SVG - formatierbar, anders als
. */
+.mtg .mtg-tipp { position: absolute; top: 4px; pointer-events: none; z-index: 3; display: grid; gap: .1rem; min-width: 10rem; padding: .5rem .7rem; border-radius: .6rem; background: var(--ton-flaeche-2, #2b3237); border: 1px solid var(--ton-linie-satt, rgba(138, 148, 158, .38)); box-shadow: 0 6px 18px rgba(0, 0, 0, .35); font-size: .78rem; color: var(--ton-schrift, #e8ede9); }
+.mtg .mtg-tipp b { font-size: .8rem; }
+.mtg .mtg-tipp .mtg-tipp-art { font-style: normal; font-size: .7rem; color: var(--ton-schrift-3, #6f7a81); margin-top: -.15rem; }
+.mtg .mtg-tipp .mtg-tipp-wetter { display: flex; align-items: center; gap: .35rem; color: var(--ton-schrift-2, #9aa6ad); margin-bottom: .15rem; }
+.mtg .mtg-tipp .mtg-tipp-wetter i { font-family: bootstrap-icons; font-style: normal; width: auto; height: auto; font-size: .95rem; background: none !important; }
+.mtg .mtg-tipp span { display: flex; align-items: center; gap: .35rem; }
+.mtg .mtg-tipp em { font-style: normal; font-weight: 600; margin-left: auto; font-variant-numeric: tabular-nums; }
+
+@media (max-width: 575.98px) {
+ .mtg .mtg-jetzt { gap: .2rem .7rem; }
+ .mtg .mtg-wert { font-size: .85rem; }
+ .mtg .mtg-unten { flex-direction: column; align-items: flex-start; }
+}
+
+.mtg.mtg-pause *, .mtg-pause .mtg * { animation-play-state: paused !important; }
+@media (prefers-reduced-motion: reduce) {
+ .mtg *, .mtg *::after { animation: none !important; transition: none !important; }
+}
+
/* ==========================================================================
Anzeige-Modus (index.php?action=anzeige, js/solar/anzeige.js): die
Solar-Seite auf einem festen Display, z.B. Raspberry Pi mit 800 x 480.
diff --git a/index.php b/index.php
index bb9941b..df046f1 100644
--- a/index.php
+++ b/index.php
@@ -101,6 +101,7 @@ if (isset($_GET["addUser"])) {
$pages = [
"solar" => ["template" => "solar.php", "mqtt" => true, "charts" => true, "meteogram" => true, "leaflet" => false,
"scripts" => ["js/solar/jahresstatistik.js", "js/solar/anlageKatalog.js", "js/solar/anlage.js", "js/solar/speicher.js",
+ "js/solar/meteogrammKatalog.js", "js/solar/meteogramm.js",
"js/solar/energieflussKatalog.js", "js/solar/energiefluss.js", "js/solar/bewaesserungAnzeige.js", "js/solar/ladefenster.js", "js/solar/bedienfenster.js", "js/solar/solarMQTT.js"]],
"home" => ["template" => "home.php", "mqtt" => true, "charts" => false, "meteogram" => false, "leaflet" => false,
"scripts" => ["js/solar/autoActionFuncs.js", "js/solar/zeitleiste.js", "js/solar/raumfenster.js", "js/solar/homeMQTT.js"]],
diff --git a/js/solar/meteogramm.js b/js/solar/meteogramm.js
new file mode 100644
index 0000000..a68b13e
--- /dev/null
+++ b/js/solar/meteogramm.js
@@ -0,0 +1,702 @@
+/* ==========================================================================
+ Meteogramm: Wetter über die nächsten Tage, im Stil des Dashboards.
+
+ const m = new Meteogramm(behaelter, { kopf: element });
+ m.zeichnen();
+ m.aktualisieren(mqttData); // nur die Live-Leiste, nicht das Bild
+
+ Die Daten kommen aus ajax/meteogramm.php, was gezeigt wird steht in
+ js/solar/meteogrammKatalog.js. Diese Datei kennt kein Feld und keine Spur
+ beim Namen - sie liest nur, was der Katalog deklariert. Wer eine Spur
+ hinzufügen will, ändert hier nichts.
+
+ Ein einziges SVG über alle Felder. Dadurch gibt es genau eine Zeitachse,
+ und Nachtstreifen, Tagesgrenzen, Jetzt-Linie und Zeiger laufen ohne
+ Kunstgriff durch alle Felder hindurch. Jedes Feld ist ein mit eigener
+ Y-Abbildung; X(t) ist gemeinsam.
+
+ Das viewBox ist so breit wie der Behälter (nicht fest), damit eine
+ Nutzereinheit ein CSS-Pixel bleibt und die Beschriftung am Handy nicht
+ mitschrumpft - dieselbe Wahl wie in speicher.js.
+ ========================================================================== */
+
+(function () {
+ "use strict";
+
+ const NS = "http://www.w3.org/2000/svg";
+ const TAGE = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
+ const NACHLADEN_MS = 10 * 60000;
+ const ALT_S = 2 * 3600; // ab hier gilt der Stand als alt
+
+ const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
+ const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":"
+ + String(d.getMinutes()).padStart(2, "0");
+ const schutz = s => String(s).replace(/[&<>"]/g, c =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
+
+ /** Wie in den übrigen Modulen: mqtt.js liefert Buffer, keine Zahlen. */
+ function leser(daten) {
+ return {
+ da(t) { return daten[t] !== undefined; },
+ zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; },
+ };
+ }
+
+ /**
+ * Eine Achse auf runde Werte bringen.
+ *
+ * Ohne das stuenden an der Temperaturachse Werte wie 0,8 und 13,4 - die
+ * Zahl ist dann zwar richtig, aber niemand liest eine Kurve gegen 13,4 ab.
+ * Gesucht wird die groesste "huebsche" Schrittweite (1, 2, 2,5, 5, 10 mal
+ * einer Zehnerpotenz), die noch hoechstens n Stufen braucht.
+ */
+ function schoeneAchse(min, max, n) {
+ const spanne = Math.max(max - min, 1e-6);
+ const roh = spanne / n;
+ const potenz = Math.pow(10, Math.floor(Math.log10(roh)));
+ const schritt = [1, 2, 2.5, 5, 10].map(f => f * potenz).find(s => s >= roh) || 10 * potenz;
+ return {
+ min: Math.floor(min / schritt) * schritt,
+ max: Math.ceil(max / schritt) * schritt,
+ schritt,
+ };
+ }
+
+ /** Mitternacht des Tages, in dem t liegt - als Unix-Sekunden. */
+ function tagesBeginn(t) {
+ const d = new Date(t * 1000);
+ d.setHours(0, 0, 0, 0);
+ return Math.round(d.getTime() / 1000);
+ }
+
+ class Meteogramm {
+
+ constructor(behaelter, opt = {}) {
+ this.behaelter = behaelter;
+ this.kopf = opt.kopf || null;
+ this.katalog = opt.katalog || METEOGRAMM_KATALOG;
+ const vorgabe = this.katalog.zeitraeume.find(z => z.vorgabe);
+ // Am Handy starten zwei Tage statt fünf: auf 358 nutzbaren Pixeln
+ // blieben für fünf Tage drei Pixel je Stunde, und ein Wolkenband aus
+ // Dreipixelstreifen sagt nichts mehr.
+ const eng = window.innerWidth < this.katalog.umbruch;
+ this.tage = opt.tage
+ || (eng ? this.katalog.zeitraumSchmal : (vorgabe ? vorgabe.tage : 5));
+ this.stand = null; // Antwort des Endpunkts
+ this.live = null; // Werte der eigenen Station aus MQTT
+ this.zuletzt = {};
+ this.sichtbar = false;
+
+ new ResizeObserver(() => this.tafelZeichnen()).observe(behaelter);
+ new IntersectionObserver(e => {
+ const vorher = this.sichtbar;
+ this.sichtbar = e[0].isIntersecting;
+ this.behaelter.classList.toggle("mtg-pause", !this.sichtbar);
+ // Erst beim Hinsehen laden: vorher kostet der Abruf nur.
+ if (this.sichtbar && !vorher && !this.stand) this.holen();
+ }).observe(behaelter);
+ setInterval(() => {
+ if (this.sichtbar && !document.hidden) this.holen();
+ }, NACHLADEN_MS);
+ }
+
+ /* ---------------------------------------------------------- Zeichnen */
+
+ zeichnen() {
+ this.behaelter.innerHTML = `
+
+
+
+
+
+
+
+
+
`;
+ const $ = s => this.behaelter.querySelector(s);
+ this.dom = {
+ meldung: $(".mtg-meldung"), jetzt: $(".mtg-jetzt"), bild: $(".mtg-bild"),
+ tipp: $(".mtg-tipp"), legende: $(".mtg-legende"), fuss: $(".mtg-fuss"),
+ };
+
+ // Die Zeitraumknöpfe gehören in die Kartenkopfzeile, nicht ins Bild -
+ // deshalb hängt sie das Modul dort ein, wenn die Seite einen Platz
+ // dafür anbietet.
+ if (this.kopf) {
+ this.kopf.innerHTML =
+ `
'
+ + '';
+ this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
+ return;
+ }
+
+ const hoehen = kat.hoehen[schmal ? "schmal" : "breit"];
+ const L = schmal ? 30 : 36, R = schmal ? 30 : 38;
+ // Name und Einheit stehen in der Luecke ueber dem Feld, nicht darin -
+ // sonst liegen sie unter den Wettersymbolen und den Windpfeilen, die
+ // beide am oberen Rand ihres Feldes sitzen.
+ const O = 15, U = schmal ? 26 : 24, LUECKE = 16;
+ const felder = kat.felder.map(f => ({ f, h: hoehen[f.hoehe] }));
+ let y = O;
+ felder.forEach(e => { e.oben = y; y += e.h + LUECKE; });
+ const H = y - LUECKE + U;
+
+ const von = d.von, bis = d.bis;
+ const X = t => L + (t - von) / (bis - von) * (B - L - R);
+ const breiteStunde = (B - L - R) / ((bis - von) / 3600);
+
+ const S = kat.spalten;
+ const wert = (zeile, name) => zeile[S[name]];
+
+ // --- Achsen je Feld und Seite ---
+ felder.forEach(e => {
+ e.achsen = {};
+ ["links", "rechts"].forEach(seite => {
+ const cfg = e.f[seite];
+ if (!cfg) return;
+ let min = cfg.min, max = cfg.max;
+ if (min === undefined || max === undefined) {
+ const werte = [];
+ e.f.spuren.filter(s => s.achse === seite).forEach(s =>
+ d.stunden.forEach(z => {
+ const v = wert(z, s.spalte);
+ if (v !== null && v !== undefined) werte.push(v);
+ }));
+ if (!werte.length) werte.push(0, 1);
+ if (min === undefined) min = Math.min(...werte);
+ if (max === undefined) max = Math.max(...werte);
+ // Etwas Luft, sonst klebt die Kurve am Rand.
+ const luft = (max - min) * 0.12 || 1;
+ if (cfg.min === undefined) min -= luft;
+ if (cfg.max === undefined) max += luft;
+ }
+ if (cfg.minSpanne && max - min < cfg.minSpanne) {
+ const mitte = (max + min) / 2;
+ min = cfg.min !== undefined ? cfg.min : mitte - cfg.minSpanne / 2;
+ max = min + cfg.minSpanne;
+ }
+ // Feste Grenzen bleiben fest - eine Achse 0..1000 W/m2 soll nicht
+ // auf 0..1000 "gerundet" werden und dabei wandern.
+ let schritt = null;
+ if (cfg.min === undefined || cfg.max === undefined) {
+ const rund = schoeneAchse(min, max, cfg.gitter || 4);
+ if (cfg.min === undefined) min = rund.min;
+ if (cfg.max === undefined) max = rund.max;
+ schritt = rund.schritt;
+ }
+ e.achsen[seite] = { min, max, schritt, einheit: cfg.einheit, cfg };
+ });
+ e.Y = (seite, v) => {
+ const a = e.achsen[seite];
+ return e.oben + (a.max - v) / (a.max - a.min) * e.h;
+ };
+ });
+
+ const id = "mtg" + Math.random().toString(36).slice(2, 8);
+ let svg = ``;
+
+ this.dom.bild.innerHTML = svg;
+ this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
+ this.zeigerVerdrahten({ d, X, L, R, B, felder, wert });
+ }
+
+ /** Nachtabschnitte als [von, bis] aus den Sonnenzeiten der Tage. */
+ naechte(d) {
+ const T = this.katalog.tagSpalten;
+ const stuecke = [];
+ const tage = d.tage;
+ if (!tage.length) return stuecke;
+ // Vor dem ersten Sonnenaufgang ist Nacht.
+ stuecke.push([d.von, tage[0][T.auf]]);
+ tage.forEach((tag, i) => {
+ const naechster = tage[i + 1];
+ stuecke.push([tag[T.unter], naechster ? naechster[T.auf] : d.bis]);
+ });
+ return stuecke;
+ }
+
+ /** Ein Feld: Gitter, Achsen, Bänder, Spuren, Symbolleisten. */
+ feldZeichnen(e, o) {
+ const { d, X, L, R, B, id, breiteStunde, wert, schmal } = o;
+ const f = e.f;
+ const S = this.katalog.spalten;
+ let svg = ``;
+
+ // Gitter und linke Beschriftung
+ const links = e.achsen.links;
+ if (links) {
+ const schritt = links.schritt || (links.max - links.min) / (links.cfg.gitter || 4);
+ for (let v = links.min; v <= links.max + 1e-6; v += schritt) {
+ const y = e.Y("links", v).toFixed(1);
+ svg += ``;
+ svg += ``
+ + `${this.achsenZahl(v)}`;
+ }
+ }
+ // Rechte Beschriftung nur, wo sie etwas bedeutet.
+ const rechts = e.achsen.rechts;
+ if (rechts && rechts.cfg.ticks) {
+ rechts.cfg.ticks.forEach(v => {
+ svg += ``
+ + `${this.achsenZahl(v)}`;
+ });
+ }
+
+ // Name samt Einheit in die Luecke darueber, als EIN Text. Getrennt
+ // standen "mm/h" und "NIEDERSCHLAG UND BEWOELKUNG" dicht nebeneinander
+ // und lasen sich wie ein Wort.
+ const titel = f.name + (links ? " · " + links.einheit : "");
+ svg += `${schutz(titel)}`;
+ if (rechts && rechts.cfg.ticks) {
+ svg += ``
+ + `${schutz(rechts.einheit)}`;
+ }
+
+ // Bänder (Bewölkung): je Schicht EIN Rechteck mit einem waagerechten
+ // Farbverlauf, dessen Haltepunkte die Stundenwerte sind.
+ //
+ // Ein Rechteck je Stunde wäre naheliegender und sah aus wie
+ // Fernsehrauschen: zweihundert harte Kanten nebeneinander. Der Verlauf
+ // zeichnet dieselben Zahlen, blendet aber zwischen ihnen über - und
+ // Bewölkung ist ohnehin nichts, was zur vollen Stunde umspringt.
+ (f.baender || []).forEach(band => {
+ band.schichten.forEach((sch, si) => {
+ const y1 = e.Y(band.achse, sch.bis), y2 = e.Y(band.achse, sch.von);
+ const stops = d.stunden.map(z => {
+ const p = wert(z, sch.spalte);
+ const o = p === null || p === undefined ? 0 : p / 100 * band.maxDeckung;
+ const an = ((z[S.t] - d.von) / (d.bis - d.von) * 100).toFixed(2);
+ return ``;
+ }).join("");
+ if (!stops) return;
+ const gid = `${id}-${f.id}-${si}`;
+ svg += ``
+ + stops + ``;
+ svg += ``;
+ });
+ });
+
+ // Spuren
+ f.spuren.forEach((spur, i) => {
+ const punkte = this.punkte(d, spur, wert);
+ if (!punkte.length) return;
+ if (spur.art === "balken") {
+ const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
+ punkte.forEach(([t, v]) => {
+ if (!v) return;
+ const y = e.Y(spur.achse, v);
+ svg += ``;
+ });
+ return;
+ }
+ const pfad = this.pfad(punkte, X, v => e.Y(spur.achse, v));
+ if (spur.art === "flaeche") {
+ const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
+ // Senkrechter Verlauf statt einer gleichmäßigen Füllung: oben, wo
+ // die Spitzen stehen, kräftig, nach unten auslaufend. Flach
+ // gefüllt sah die Strahlung über der dunklen Fläche olivgrün aus.
+ const gid = `${id}-${f.id}-${spur.id}`;
+ const deck = spur.deckkraft || 0.2;
+ svg += ``
+ + ``
+ + ``
+ + ``;
+ svg += ``;
+ return;
+ }
+ svg += ``;
+ if (spur.beschriftung === "minmax") {
+ svg += this.minMax(d, spur, punkte, X, v => e.Y(spur.achse, v), { links: L, rechts: B - R });
+ }
+ });
+
+ // Wettersymbole
+ if (f.symbole) {
+ const schritt = this.schritt(f.symbole, breiteStunde, schmal);
+ d.stunden.forEach((z, i) => {
+ if (i % schritt) return;
+ const eintrag = this.katalog.wetter[wert(z, "code")];
+ if (!eintrag) return;
+ const glyph = (!z[S.t] || wert(z, "code") === null) ? null
+ : (this.istNacht(d, z[S.t]) && eintrag.nacht ? eintrag.nacht : eintrag.glyph);
+ if (!glyph) return;
+ svg += `${glyph}`;
+ });
+ }
+
+ // Windpfeile
+ if (f.pfeile) {
+ const schritt = this.schritt(f.pfeile, breiteStunde, schmal);
+ const laenge = this.katalog.windPfeil.laenge;
+ d.stunden.forEach((z, i) => {
+ if (i % schritt) return;
+ const grad = wert(z, f.pfeile.spalte);
+ if (grad === null || grad === undefined) return;
+ const v = wert(z, f.pfeile.staerke) || 0;
+ const x = X(z[S.t]) + breiteStunde / 2, y = e.oben + 12;
+ // +180: die Richtung sagt, woher der Wind kommt - der Pfeil fliegt
+ // mit ihm. Dieselbe Rechnung wie updateWeatherCards() in common.js.
+ svg += ``
+ + ``;
+ });
+ }
+
+ return svg + "";
+ }
+
+ /** Wie viele Stunden übersprungen werden, damit nichts überlappt. */
+ schritt(cfg, breiteStunde, schmal) {
+ let s = cfg.alle || 3;
+ while (s * breiteStunde < cfg.mindestAbstand && s < 24) s *= 2;
+ return s;
+ }
+
+ /** Die Werte einer Spur, gefiltert nach gemessen/vorhergesagt. */
+ punkte(d, spur, wert) {
+ const S = this.katalog.spalten;
+ const aus = [];
+ d.stunden.forEach((z, i) => {
+ const v = wert(z, spur.spalte);
+ if (v === null || v === undefined) return;
+ const gemessen = !!z[S.gemessen];
+ if (spur.nurGemessen && !gemessen) return;
+ // Die Vorhersagespur nimmt die letzte gemessene Stunde mit, sonst
+ // klafft am Jetzt-Strich eine Lücke zwischen beiden Linien.
+ if (spur.nurVorhersage && gemessen) {
+ const naechste = d.stunden[i + 1];
+ if (!naechste || naechste[S.gemessen]) return;
+ }
+ aus.push([z[S.t], spur.skala ? spur.skala(v) : v]);
+ });
+ return aus;
+ }
+
+ /** Pfad mit Lückenerkennung: über zwei Stunden Abstand wird nicht verbunden. */
+ pfad(punkte, X, Y) {
+ let d = "", zug = [];
+ const zu = () => {
+ if (zug.length) d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L");
+ zug = [];
+ };
+ punkte.forEach((p, i) => {
+ if (i && p[0] - punkte[i - 1][0] > 2 * 3600) zu();
+ zug.push([X(p[0]), Y(p[1])]);
+ });
+ zu();
+ return d;
+ }
+
+ /** Tagesminimum und -maximum an die Kurve schreiben. */
+ minMax(d, spur, punkte, X, Y, grenze) {
+ let svg = "";
+ const nachTag = new Map();
+ punkte.forEach(([t, v]) => {
+ const tag = tagesBeginn(t);
+ const e = nachTag.get(tag);
+ if (!e) nachTag.set(tag, { min: [t, v], max: [t, v] });
+ else {
+ if (v < e.min[1]) e.min = [t, v];
+ if (v > e.max[1]) e.max = [t, v];
+ }
+ });
+ nachTag.forEach(e => {
+ [[e.max, -7], [e.min, 13]].forEach(([p, dy]) => {
+ // Am Rand nach innen ruecken: ein Tagesmaximum in der letzten
+ // Stunde stand sonst halb ausserhalb des Bildes.
+ const x = Math.min(grenze.rechts - 8, Math.max(grenze.links + 8, X(p[0])));
+ svg += `${Math.round(p[1])}`;
+ });
+ });
+ return svg;
+ }
+
+ istNacht(d, t) {
+ const T = this.katalog.tagSpalten;
+ const tag = d.tage.find(x => t >= x[T.t] && t < x[T.t] + 86400);
+ if (!tag) return false;
+ return t < tag[T.auf] || t >= tag[T.unter];
+ }
+
+ achsenZahl(v) {
+ return Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : komma(v, 1);
+ }
+
+ /** Der Streifen unten: Wochentag je Tag, bei zwei Tagen auch Stunden. */
+ zeitachse(o) {
+ const { d, X, L, R, B, H, U, schmal } = o;
+ let svg = "";
+ const stundenTicks = this.tage <= 2;
+ for (let t = tagesBeginn(d.von); t < d.bis; t += 86400) {
+ const a = Math.max(d.von, t), b = Math.min(d.bis, t + 86400);
+ if (b <= a) continue;
+ const mitte = (X(a) + X(b)) / 2;
+ const tag = new Date(t * 1000);
+ const text = TAGE[tag.getDay()] + (schmal ? "" : " " + tag.getDate() + ".");
+ svg += `${text}`;
+ if (stundenTicks) {
+ [6, 12, 18].forEach(h => {
+ const x = X(t + h * 3600);
+ if (x < L || x > B - R) return;
+ svg += `${String(h).padStart(2, "0")}`;
+ });
+ }
+ }
+ return svg;
+ }
+
+ /* ------------------------------------------------------------ Zeiger */
+
+ zeigerVerdrahten(o) {
+ const { d, X, L, R, B, felder, wert } = o;
+ const S = this.katalog.spalten;
+ const svg = this.dom.bild.querySelector("svg");
+ const zeiger = svg && svg.querySelector(".mtg-zeiger");
+ const tipp = this.dom.tipp;
+ if (!svg || !zeiger || !tipp) return;
+
+ const verstecken = () => {
+ tipp.hidden = true;
+ zeiger.setAttribute("x1", -10);
+ zeiger.setAttribute("x2", -10);
+ };
+
+ const zeigen = ev => {
+ const r = svg.getBoundingClientRect();
+ const x = (ev.clientX - r.left) / r.width * B;
+ const t = d.von + (x - L) / (B - L - R) * (d.bis - d.von);
+ let best = null;
+ d.stunden.forEach(z => {
+ if (!best || Math.abs(z[S.t] - t) < Math.abs(best[S.t] - t)) best = z;
+ });
+ if (!best || Math.abs(best[S.t] - t) > 2 * 3600) return verstecken();
+
+ const tx = X(best[S.t]);
+ zeiger.setAttribute("x1", tx);
+ zeiger.setAttribute("x2", tx);
+
+ const zeit = new Date(best[S.t] * 1000);
+ const eintrag = this.katalog.wetter[wert(best, "code")];
+ let html = `${TAGE[zeit.getDay()]} ${zeit.getDate()}.${zeit.getMonth() + 1}., `
+ + `${uhrzeit(zeit)}`
+ + `${best[S.gemessen] ? "gemessen" : "Vorhersage"}`;
+ if (eintrag) {
+ html += `${eintrag.glyph}${schutz(eintrag.name)}`;
+ }
+ this.katalog.felder.forEach(f => {
+ f.spuren.forEach(spur => {
+ if (spur.nurGemessen && !best[S.gemessen]) return;
+ if (spur.nurVorhersage && best[S.gemessen]) return;
+ const v = wert(best, spur.spalte);
+ if (v === null || v === undefined) return;
+ html += `${schutz(spur.name)}`
+ + `${schutz(spur.format(v))}`;
+ });
+ (f.baender || []).forEach(band => {
+ const teile = band.schichten
+ .map(s => [s.name, wert(best, s.spalte)])
+ .filter(([, v]) => v !== null && v !== undefined);
+ if (!teile.length) return;
+ html += ``
+ + `${schutz(band.name)}`
+ + teile.map(([n, v]) => n + " " + Math.round(v) + " %").join(" · ")
+ + ``;
+ });
+ });
+ tipp.innerHTML = html;
+ tipp.hidden = false;
+ const links = tx / B * r.width;
+ tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4,
+ Math.max(4, links + 12)) + "px";
+ };
+
+ svg.addEventListener("pointermove", zeigen);
+ svg.addEventListener("pointerdown", zeigen);
+ svg.addEventListener("pointerleave", verstecken);
+ }
+ }
+
+ window.Meteogramm = Meteogramm;
+})();
diff --git a/js/solar/meteogrammKatalog.js b/js/solar/meteogrammKatalog.js
new file mode 100644
index 0000000..15d3f11
--- /dev/null
+++ b/js/solar/meteogrammKatalog.js
@@ -0,0 +1,271 @@
+/* ==========================================================================
+ Was das Meteogramm zeigt.
+
+ Hier steht alles, was man ändern will, und nichts, was man zum Zeichnen
+ wissen muss. js/solar/meteogramm.js kennt kein Feld, keine Spur und keine
+ Farbe beim Namen - es liest nur, was hier deklariert ist. Dasselbe Muster
+ wie anlageKatalog.js und energieflussKatalog.js.
+
+ ---------------------------------------------------------------------------
+ So kommt eine Spur dazu
+
+ 1. Spalte in ajax/meteogramm.php hinten anhängen
+ 2. Schlüssel in SPALTEN hinten anhängen
+ 3. Eintrag in "spuren" des passenden Feldes
+
+ Sonst nichts. Hinten anhängen ist wichtig: die Stellen der bestehenden
+ Spuren bleiben dadurch gültig.
+
+ ---------------------------------------------------------------------------
+ Das Vokabular
+
+ Ein FELD ist ein waagerechter Streifen mit eigener senkrechter Achse. Alle
+ Felder teilen sich die Zeitachse.
+
+ id Kennung
+ name Überschrift links oben im Feld
+ hoehe Schlüssel in HOEHEN (breit/schmal)
+ links { einheit, min?, max?, minSpanne?, gitter? } - linke Achse
+ rechts dasselbe für die rechte Achse, oder fehlt
+ nacht true = Tag/Nacht-Schattierung in diesem Feld zeichnen
+ symbole { alle, mindestAbstand } für die Wettersymbolleiste, oder fehlt
+ pfeile { spalte, alle, mindestAbstand } für die Windpfeile, oder fehlt
+ baender Liste von BÄNDERN, vor den Spuren gezeichnet
+ spuren Liste von SPUREN, in Zeichenreihenfolge (erste zuunterst)
+
+ Eine SPUR ist eine Reihe von Werten über die Zeit.
+
+ id, name Kennung; der Name steht in Legende und Tooltip
+ spalte Schlüssel in SPALTEN
+ achse "links" oder "rechts"
+ art "linie" | "flaeche" | "balken"
+ farbe Datenfarben gehören hierher, nicht ins Stylesheet
+ breit Strichstärke bei "linie"
+ strich z.B. "4 3" für gestrichelt
+ nurGemessen true = nur Stunden mit gemessen = 1
+ nurVorhersage true = nur Stunden ohne
+ beschriftung "minmax" = Tagesminimum und -maximum an die Kurve schreiben
+ format Wert -> Text für den Tooltip
+ legende true = in der Legendenzeile zeigen
+
+ Ein BAND füllt eine Fläche mit veränderlicher Deckkraft - so wird das
+ Höhenprofil der Bewölkung nachgebaut, das Open-Meteo nicht liefert.
+
+ schichten je { spalte, von, bis, name } in Einheiten der rechten Achse
+ maxDeckung Deckkraft bei 100 %
+ ========================================================================== */
+
+const METEOGRAMM_KATALOG = (function () {
+ "use strict";
+
+ const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
+ const ganz = z => String(Math.round(z));
+
+ /* Die Spaltenlage einer Stundenzeile aus ajax/meteogramm.php. Steht dort
+ im Kopfkommentar noch einmal - beide Listen müssen zueinander passen. */
+ const SPALTEN = {
+ t: 0, gemessen: 1, temp: 2, regen: 3, schnee: 4, wind: 5, boe: 6,
+ richtung: 7, strahlung: 8, wtief: 9, wmittel: 10, whoch: 11,
+ code: 12, feuchte: 13, druck: 14, regenWkt: 15,
+ };
+
+ /* Eine Tageszeile. */
+ const TAGSPALTEN = { t: 0, tmin: 1, tmax: 2, code: 3, auf: 4, unter: 5 };
+
+ /* Feldhöhen in Nutzereinheiten (= CSS-Pixel). Unter UMBRUCH gilt "schmal".
+ Zusammen mit Achsenstreifen und Rändern ergibt "schmal" rund 345 px -
+ das eingebettete meteoblue-Bild brauchte 500. */
+ const HOEHEN = {
+ breit: { temp: 150, regen: 130, wind: 115 },
+ schmal: { temp: 120, regen: 100, wind: 92 },
+ };
+ const UMBRUCH = 520;
+
+ /* --------------------------------------------------------- Wettersymbole
+
+ WMO-Code -> Glyphe der Bootstrap-Icons, wie das ZEICHEN-Register in
+ energiefluss.js. Die achtundzwanzig Codes fallen auf zwölf Symbole
+ zusammen; wo es eine Nachtvariante gibt, steht sie daneben.
+
+ Die Codepunkte stammen aus css/bootstrap-icons.min.css. Der Kommentar
+ dahinter nennt das Icon - ohne ihn wäre die Zeile nicht zu lesen. */
+ const WETTER = {
+ 0: { glyph: "", nacht: "", name: "klar" }, // bi-sun-fill / bi-moon-stars-fill
+ 1: { glyph: "", nacht: "", name: "überwiegend klar" }, // dito
+ 2: { glyph: "", nacht: "", name: "teils bewölkt" }, // bi-cloud-sun-fill / bi-cloud-moon-fill
+ 3: { glyph: "", name: "bedeckt" }, // bi-clouds-fill
+ 45: { glyph: "", name: "Nebel" }, // bi-cloud-fog2-fill
+ 48: { glyph: "", name: "Reifnebel" }, // bi-cloud-fog2-fill
+ 51: { glyph: "", name: "leichter Nieselregen" }, // bi-cloud-drizzle-fill
+ 53: { glyph: "", name: "Nieselregen" }, // bi-cloud-drizzle-fill
+ 55: { glyph: "", name: "starker Nieselregen" }, // bi-cloud-drizzle-fill
+ 56: { glyph: "", name: "gefrierender Nieselregen" }, // bi-cloud-sleet-fill
+ 57: { glyph: "", name: "starker gefrierender Nieselregen" }, // bi-cloud-sleet-fill
+ 61: { glyph: "", name: "leichter Regen" }, // bi-cloud-rain-fill
+ 63: { glyph: "", name: "Regen" }, // bi-cloud-rain-fill
+ 65: { glyph: "", name: "starker Regen" }, // bi-cloud-rain-heavy-fill
+ 66: { glyph: "", name: "gefrierender Regen" }, // bi-cloud-sleet-fill
+ 67: { glyph: "", name: "starker gefrierender Regen" }, // bi-cloud-sleet-fill
+ 71: { glyph: "", name: "leichter Schneefall" }, // bi-cloud-snow-fill
+ 73: { glyph: "", name: "Schneefall" }, // bi-cloud-snow-fill
+ 75: { glyph: "", name: "starker Schneefall" }, // bi-cloud-snow-fill
+ 77: { glyph: "", name: "Schneegriesel" }, // bi-snow3
+ 80: { glyph: "", name: "leichte Regenschauer" }, // bi-cloud-rain-fill
+ 81: { glyph: "", name: "Regenschauer" }, // bi-cloud-rain-fill
+ 82: { glyph: "", name: "heftige Regenschauer" }, // bi-cloud-rain-heavy-fill
+ 85: { glyph: "", name: "leichte Schneeschauer" }, // bi-cloud-snow-fill
+ 86: { glyph: "", name: "Schneeschauer" }, // bi-cloud-snow-fill
+ 95: { glyph: "", name: "Gewitter" }, // bi-cloud-lightning-fill
+ 96: { glyph: "", name: "Gewitter mit Hagel" }, // bi-cloud-lightning-rain-fill
+ 99: { glyph: "", name: "schweres Gewitter mit Hagel" }, // bi-cloud-lightning-rain-fill
+ };
+
+ /* Die Farben der Anzeige. Strukturfarben stehen im Stylesheet, Datenfarben
+ hier - so wie bei energieflussKatalog.js. */
+ const FARBE = {
+ temp: "#e5624f",
+ tempMess: "#f0907f",
+ strahlung: "#f0b429",
+ regen: "#4f8fb8",
+ schnee: "#a8cfe8",
+ wind: "#7fb3d5",
+ boe: "#e8a33a",
+ wolken: "#c9d0d6",
+ };
+
+ return {
+ spalten: SPALTEN,
+ tagSpalten: TAGSPALTEN,
+ hoehen: HOEHEN,
+ umbruch: UMBRUCH,
+ wetter: WETTER,
+ farbe: FARBE,
+
+ /* Voreinstellung ist fünf Tage wie beim Vorbild; am Handy zwei, weil dort
+ fünf Tage drei Pixel je Stunde bedeuten. */
+ zeitraeume: [
+ { tage: 2, name: "2 Tage" },
+ { tage: 5, name: "5 Tage", vorgabe: true },
+ { tage: 7, name: "7 Tage" },
+ ],
+ zeitraumSchmal: 2,
+
+ /* Wie weit der Rückblick reicht, in Stunden. Die eigene Station misst
+ alle fünf Minuten; der Endpunkt mittelt auf Stunden. */
+ rueckblick: 24,
+
+ /* Der Windpfeil zeigt, wohin der Wind weht. Die meteorologische Richtung
+ sagt, woher er kommt - deshalb dreht der Renderer um 180 Grad, genau
+ wie updateWeatherCards() in common.js. */
+ windPfeil: {
+ laenge: 9,
+ // Unter dieser Stärke verblasst der Pfeil: bei Windstille zeigt eine
+ // Richtung ohnehin nichts an.
+ schwach: 5,
+ },
+
+ felder: [
+ {
+ id: "temp",
+ name: "Temperatur",
+ hoehe: "temp",
+ nacht: true,
+ links: { einheit: "°C", minSpanne: 8, gitter: 4 },
+ // Die Strahlung teilt sich das Feld mit der Temperatur statt ein
+ // eigenes zu bekommen: beide folgen demselben Tagesrhythmus und
+ // derselben Tag/Nacht-Schattierung, und ein viertes Feld machte das
+ // Meteogramm am Handy höher als das externe - also genau das
+ // Gegenteil dessen, was es soll. Die Achse ist fest 0..1000, damit
+ // ein trüber Tag flach bleibt und nicht auf volle Höhe gestreckt wird.
+ rechts: { einheit: "W/m²", min: 0, max: 1000, gitter: 0 },
+ symbole: { alle: 3, mindestAbstand: 26 },
+ spuren: [
+ {
+ id: "strahlung", name: "Strahlung", spalte: "strahlung",
+ achse: "rechts", art: "flaeche", farbe: FARBE.strahlung,
+ deckkraft: 0.16, legende: true,
+ format: w => ganz(w) + " W/m²",
+ },
+ {
+ id: "temp", name: "Temperatur", spalte: "temp",
+ achse: "links", art: "linie", farbe: FARBE.temp, breit: 2.4,
+ beschriftung: "minmax", legende: true, nurVorhersage: true,
+ format: w => komma(w, 1) + " °C",
+ },
+ {
+ // Dieselbe Spalte, aber nur die gemessenen Stunden: links vom
+ // Jetzt-Strich steht, was die eigene Station wirklich gesehen
+ // hat. Zwei Spuren statt einer, damit die Farbe wechseln kann,
+ // ohne dass der Renderer von "gemessen" wissen muss.
+ id: "tempMess", name: "gemessen", spalte: "temp",
+ achse: "links", art: "linie", farbe: FARBE.tempMess, breit: 2.4,
+ nurGemessen: true, legende: true,
+ format: w => komma(w, 1) + " °C",
+ },
+ ],
+ },
+ {
+ id: "regen",
+ name: "Niederschlag und Bewölkung",
+ hoehe: "regen",
+ nacht: true,
+ // minSpanne 2 mm: ohne sie füllte ein Nieselregen von 0,2 mm das
+ // ganze Feld und sähe aus wie ein Wolkenbruch.
+ links: { einheit: "mm/h", min: 0, minSpanne: 2, gitter: 3 },
+ // Die rechte Achse ist die Höhe in Kilometern - sie gehört zum
+ // Wolkenband, nicht zu einer Spur.
+ rechts: { einheit: "km", min: 0, max: 14, ticks: [0, 2, 7, 14] },
+ baender: [
+ {
+ id: "wolken", name: "Bewölkung", achse: "rechts",
+ farbe: FARBE.wolken, maxDeckung: 0.42,
+ // Open-Meteo kennt drei Schichten statt eines Profils. Die
+ // Grenzen sind die üblichen meteorologischen: tiefe Wolken bis
+ // 2 km, mittelhohe bis 7 km, hohe darüber.
+ schichten: [
+ { spalte: "wtief", von: 0, bis: 2, name: "tief" },
+ { spalte: "wmittel", von: 2, bis: 7, name: "mittel" },
+ { spalte: "whoch", von: 7, bis: 14, name: "hoch" },
+ ],
+ },
+ ],
+ spuren: [
+ {
+ id: "regen", name: "Niederschlag", spalte: "regen",
+ achse: "links", art: "balken", farbe: FARBE.regen, legende: true,
+ format: w => komma(w, 1) + " mm/h",
+ },
+ {
+ id: "schnee", name: "Schnee", spalte: "schnee",
+ achse: "links", art: "balken", farbe: FARBE.schnee, legende: true,
+ // Schnee kommt in cm, die Achse rechnet in mm Wasser. Die
+ // Faustregel ist ein Zentimeter Neuschnee je Millimeter Wasser.
+ skala: w => w * 1,
+ format: w => komma(w, 1) + " cm",
+ },
+ ],
+ },
+ {
+ id: "wind",
+ name: "Wind",
+ hoehe: "wind",
+ links: { einheit: "km/h", min: 0, minSpanne: 20, gitter: 3 },
+ pfeile: { spalte: "richtung", staerke: "wind", alle: 3, mindestAbstand: 16 },
+ spuren: [
+ {
+ id: "boe", name: "Böen", spalte: "boe",
+ achse: "links", art: "linie", farbe: FARBE.boe, breit: 1.6,
+ strich: "4 3", legende: true,
+ format: w => komma(w, 1) + " km/h",
+ },
+ {
+ id: "wind", name: "Wind", spalte: "wind",
+ achse: "links", art: "linie", farbe: FARBE.wind, breit: 2.2,
+ legende: true,
+ format: w => komma(w, 1) + " km/h",
+ },
+ ],
+ },
+ ],
+ };
+})();
diff --git a/js/solar/solarMQTT.js b/js/solar/solarMQTT.js
index e4cfde4..ac0cf9c 100644
--- a/js/solar/solarMQTT.js
+++ b/js/solar/solarMQTT.js
@@ -149,6 +149,7 @@ function setFlowWidth(id, width) {
let energiefluss = null;
let anlage = null;
let speicher = null;
+let meteogramm = null;
const solarSVG = {
updateCnt: 99,
@@ -168,6 +169,9 @@ const solarSVG = {
// Auch verborgen: so schreibt die Anlage ihren Verlauf je Platte mit.
if (anlage) anlage.aktualisieren(mqttData);
if (speicher) speicher.aktualisieren(mqttData);
+ // Nur die Live-Leiste des Meteogramms; die Tafel zeigt Stunden und wird
+ // davon nicht neu gezeichnet.
+ if (meteogramm) meteogramm.aktualisieren(mqttData);
}
};
@@ -224,6 +228,54 @@ function energieflussStarten() {
}
}
+/**
+ * Das Meteogramm aufsetzen und den Umschalter eigenes/meteoblue verdrahten.
+ *
+ * mountMeteogram() wird ausdruecklich NICHT beim Laden gerufen, sondern erst
+ * beim ersten Umschalten auf meteoblue. Das Widget ist ein vollstaendiges
+ * Fremd-Dokument in einem iframe - wer es nie ansieht, soll es auch nicht
+ * laden. Das ist nebenbei der groesste Ladezeitgewinn dieser Karte.
+ */
+function meteogrammStarten() {
+ const behaelter = document.getElementById("meteogramm");
+ if (!behaelter || typeof Meteogramm === "undefined") {
+ // Seite ohne eigenes Meteogramm: dann wie frueher das Fremd-Widget.
+ if (typeof mountMeteogram === "function") mountMeteogram();
+ return;
+ }
+ // Anzeige-Modus (festes Display, 800 x 480): dort ist fuer fuenf Tage
+ // kein Platz, zwei Tage bleiben lesbar.
+ const anzeigeModus = document.body.classList.contains("anzeige");
+ meteogramm = new Meteogramm(behaelter, {
+ kopf: document.querySelector(".mtg-kopf"),
+ tage: anzeigeModus ? 2 : undefined,
+ });
+ meteogramm.zeichnen();
+
+ let blueGeladen = false;
+ const zeigeQuelle = function (name) {
+ document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
+ const an = knopf.dataset.mtgQuelle === name;
+ knopf.classList.toggle("active", an);
+ knopf.setAttribute("aria-selected", an ? "true" : "false");
+ });
+ document.querySelectorAll("[data-mtg-flaeche]").forEach(function (flaeche) {
+ flaeche.hidden = flaeche.dataset.mtgFlaeche !== name;
+ });
+ if (name === "blue" && !blueGeladen && typeof mountMeteogram === "function") {
+ blueGeladen = true;
+ mountMeteogram();
+ }
+ try { localStorage.setItem("mtgQuelle", name); } catch (e) { /* privates Fenster */ }
+ };
+ document.querySelectorAll("[data-mtg-quelle]").forEach(function (knopf) {
+ knopf.addEventListener("click", function () { zeigeQuelle(knopf.dataset.mtgQuelle); });
+ });
+ let gemerkt = null;
+ try { gemerkt = localStorage.getItem("mtgQuelle"); } catch (e) { /* egal */ }
+ if (gemerkt === "blue") zeigeQuelle("blue");
+}
+
var chartSettings = {
type: 'line',
options: {
@@ -436,7 +488,7 @@ var chartSettings = {
baueJahresstatistik(document.getElementById("Stats-Year"),
{ spalten: 1, werkzeuge: false, fussnoten: false });
energieflussStarten();
- mountMeteogram();
+ meteogrammStarten();
}
});
diff --git a/restricted/solar.php b/restricted/solar.php
index c2a3ca2..5fe4d57 100644
--- a/restricted/solar.php
+++ b/restricted/solar.php
@@ -84,16 +84,40 @@
-
-
Forecast
+
+
+
Wetter
+
+
+
+
+
+
+
-
-
+
+
+
+
+
diff --git a/solarLog_weather.sql b/solarLog_weather.sql
new file mode 100644
index 0000000..4870e7b
--- /dev/null
+++ b/solarLog_weather.sql
@@ -0,0 +1,178 @@
+-- ===========================================================================
+-- Wetter: Vorhersage und Archiv (Open-Meteo)
+--
+-- Einspielen:
+-- mysql -h 127.0.0.1 -P 3310 -u -p solarLog < solarLog_weather.sql
+--
+-- Geschrieben von SolarManager/gatherForecastData.py (laufend) und einmalig
+-- von SolarManager/wetterarchiv_nachtragen.py. Gelesen von ajax/meteogramm.php.
+--
+-- ---------------------------------------------------------------------------
+-- Warum die alten Tabellen nicht erweitert, sondern neu aufgebaut werden
+--
+-- weatherHours und weatherDays hatten die Form der OpenWeatherMap-One-Call-
+-- Antwort: weatherStr, icon ("04n"), pop, uvi, feelsLike, moonrise, mooncycle,
+-- Wind in m/s, und Bewoelkung als ein einziger Prozentwert. Geschrieben hat
+-- sie /volume1/web/gatherWeather.php - ausserhalb beider Repositorien, von
+-- keiner Aufgabe mehr gestartet, seit dem 27.10.2024 tot, weil die kostenlose
+-- One-Call-2.5-API abgeschaltet wurde. Gelesen hat sie zuletzt niemand: im
+-- ganzen Web-Repository gibt es keinen einzigen SELECT darauf.
+--
+-- Von siebzehn beziehungsweise neunundzwanzig Spalten waeren keine fuenf
+-- uebriggeblieben. Ein ALTER TABLE haette die Ruine stehengelassen.
+--
+-- Die Namen bleiben trotzdem: "stuendliches Wetter" und "Wetter je Tag"
+-- beschreiben den neuen Inhalt genauso gut, die Dokumentation bleibt gueltig,
+-- und zwei aehnlich heissende Tabellen nebeneinander verwirren mehr, als ein
+-- neuer Name nuetzt. Die alten werden vorher umbenannt, nicht geloescht - die
+-- 25 MB eilen nicht, ein DROP ist nicht rueckholbar. Nach ein paar Wochen
+-- Ruhe duerfen weatherHours_alt und weatherDays_alt weg.
+--
+-- ---------------------------------------------------------------------------
+-- Warum nichts geloescht wird
+--
+-- Diese Tabellen sind nicht nur Anzeigevorrat, sondern ein Archiv. Aus den
+-- Strahlungswerten der Vergangenheit soll spaeter eine eigene Ertragsprognose
+-- gerechnet werden: EnergyFlow_hourly.pv_kwh liefert den gemessenen Ertrag je
+-- Stunde seit dem 02.04.2022, hier steht die zugehoerige Einstrahlung. Wer
+-- alte Zeilen wegraeumt, wirft genau die Haelfte des Datenpaares weg.
+--
+-- Der Platz spricht nicht dagegen: 8.760 Zeilen im Jahr, rund 1,5 MB. Ein
+-- einziger Tag EnergyFlow ist groesser. Die Tabellen kommen deshalb auch
+-- nicht in THIN_TABLES des Rollup-Jobs - sie sind schon stuendlich, da gibt
+-- es nichts auszuduennen.
+--
+-- ---------------------------------------------------------------------------
+-- Wie eine Stunde erwachsen wird
+--
+-- Der Schluessel ist der Zeitpunkt, nicht eine laufende Nummer, und
+-- geschrieben wird mit REPLACE INTO. Dieselbe Stunde kommt alle zwanzig
+-- Minuten erneut herein und wird dabei genauer: erst als Vorhersage von
+-- uebermorgen, zuletzt als Analyse des vergangenen Tages. Angehaengt wird
+-- nichts, ueberschrieben alles.
+--
+-- Damit man einer Zeile trotzdem ansieht, woher sie stammt, tragen beide
+-- Tabellen zwei Spalten mit: "abgerufen" sagt, wann sie zuletzt geschrieben
+-- wurde, "ist_vorhersage" sagt, ob die Stunde zu diesem Zeitpunkt noch in der
+-- Zukunft lag. Eine Zeile mit ist_vorhersage = 0 ist nachgerechnet und damit
+-- die belastbare Haelfte fuer eine Korrelation.
+--
+-- Was dabei verlorenginge, faengt weatherForecastLog auf: dort landet je
+-- Stunde die *erste* Vorhersage und wird nie wieder angefasst (INSERT
+-- IGNORE). Damit laesst sich spaeter beantworten, wie gut eine Vorhersage von
+-- vor drei Tagen war, ohne dafuer erst jahrelang zu sammeln.
+--
+-- ---------------------------------------------------------------------------
+-- Bewoelkung und Strahlung
+--
+-- Open-Meteo kennt kein stufenloses Hoehenprofil der Bewoelkung, sondern drei
+-- Schichten. Daraus zeichnet das Meteogramm drei Baender - 0-2, 2-7 und
+-- 7-14 km -, deren Graustufe der Bedeckungsgrad ist. Mehr Hoehenaufloesung
+-- gibt die Quelle nicht her; das ist eine Vereinfachung gegenueber dem
+-- Vorbild und keine Nachlaessigkeit.
+--
+-- Die Strahlung steht hier ausrichtungsfrei: global auf die Waagerechte
+-- (strahlung), sowie ihre Zerlegung in direkt und diffus. Was ein geneigtes
+-- Dach davon abbekommt, haengt an Neigung und Azimut und steht deshalb in
+-- weatherTilted - eine Zeile je Stunde und Flaeche. Eine zusaetzliche Flaeche
+-- ist damit eine Zeile in der config.ini und keine Spalte hier.
+-- ===========================================================================
+
+-- Erst beiseite, dann neu. IF EXISTS (MariaDB ab 10.5) fuer den Fall, dass es
+-- die Altlast nie gab.
+--
+-- Diese beiden Zeilen gelten genau einmal: laeuft das Skript ein zweites Mal,
+-- bricht es hier mit "weatherHours_alt already exists" ab - dann ist die
+-- Umbenennung laengst passiert, und man laesst die beiden Zeilen weg. Das ist
+-- ein verstaendlicher Abbruch und lieber so als eine Automatik, die im
+-- Zweifel die frisch gefuellte Tabelle beiseiteschiebt.
+RENAME TABLE IF EXISTS `weatherHours` TO `weatherHours_alt`;
+RENAME TABLE IF EXISTS `weatherDays` TO `weatherDays_alt`;
+
+
+CREATE TABLE IF NOT EXISTS `weatherHours` (
+ `datetime` datetime NOT NULL COMMENT 'Ortszeit, volle Stunde',
+ `temp` decimal(4,1) DEFAULT NULL COMMENT 'degC in 2 m (temperature_2m)',
+ `gefuehlt` decimal(4,1) DEFAULT NULL COMMENT 'degC gefuehlt (apparent_temperature)',
+ `feuchte` tinyint(3) unsigned DEFAULT NULL COMMENT '% rel. (relativehumidity_2m)',
+ `taupunkt` decimal(4,1) DEFAULT NULL COMMENT 'degC (dewpoint_2m)',
+ `druck` smallint(5) unsigned DEFAULT NULL COMMENT 'hPa auf Meereshoehe (pressure_msl) - so wie weatherStation.qff, damit beide vergleichbar sind',
+ `regen` decimal(4,1) DEFAULT NULL COMMENT 'mm in dieser Stunde (precipitation)',
+ `regen_wkt` tinyint(3) unsigned DEFAULT NULL COMMENT '% Wahrscheinlichkeit (precipitation_probability)',
+ `schnee` decimal(4,1) DEFAULT NULL COMMENT 'cm in dieser Stunde (snowfall)',
+ `wettercode` tinyint(3) unsigned DEFAULT NULL COMMENT 'WMO 0..99 (weathercode) - bestimmt das Symbol',
+ `wolken_tief` tinyint(3) unsigned DEFAULT NULL COMMENT '% Bedeckung 0-2 km (cloudcover_low)',
+ `wolken_mittel` tinyint(3) unsigned DEFAULT NULL COMMENT '% Bedeckung 2-7 km (cloudcover_mid)',
+ `wolken_hoch` tinyint(3) unsigned DEFAULT NULL COMMENT '% Bedeckung 7-14 km (cloudcover_high)',
+ `wind` decimal(4,1) DEFAULT NULL COMMENT 'km/h in 10 m, Mittel der Stunde (windspeed_10m)',
+ `boe` decimal(4,1) DEFAULT NULL COMMENT 'km/h, Spitze der Stunde (windgusts_10m)',
+ `richtung` smallint(5) unsigned DEFAULT NULL COMMENT 'Grad, woher der Wind kommt (winddirection_10m)',
+ `strahlung` smallint(5) unsigned DEFAULT NULL COMMENT 'W/m2 global auf die Waagerechte (shortwave_radiation)',
+ `direkt` smallint(5) unsigned DEFAULT NULL COMMENT 'W/m2 direkter Anteil (direct_radiation)',
+ `diffus` smallint(5) unsigned DEFAULT NULL COMMENT 'W/m2 diffuser Anteil (diffuse_radiation)',
+ `tag` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1 = Tag, 0 = Nacht (is_day) - entscheidet Sonne oder Mond im Symbol',
+ `ist_vorhersage` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1 = beim Schreiben lag die Stunde noch in der Zukunft, 0 = nachgerechnet',
+ `abgerufen` datetime NOT NULL COMMENT 'wann diese Zeile zuletzt geschrieben wurde',
+ PRIMARY KEY (`datetime`),
+ KEY `ist_vorhersage` (`ist_vorhersage`, `datetime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+
+CREATE TABLE IF NOT EXISTS `weatherDays` (
+ `date` date NOT NULL COMMENT 'Ortszeit',
+ `wettercode` tinyint(3) unsigned DEFAULT NULL COMMENT 'WMO, praegendes Wetter des Tages',
+ `temp_min` decimal(4,1) DEFAULT NULL COMMENT 'degC (temperature_2m_min)',
+ `temp_max` decimal(4,1) DEFAULT NULL COMMENT 'degC (temperature_2m_max)',
+ `sonnenauf` time DEFAULT NULL COMMENT 'Ortszeit - Quelle der Tag/Nacht-Schattierung, denn daylight reicht nur bis morgen',
+ `sonnenunter` time DEFAULT NULL COMMENT 'Ortszeit',
+ `regen_summe` decimal(5,1) DEFAULT NULL COMMENT 'mm (precipitation_sum)',
+ `regen_wkt` tinyint(3) unsigned DEFAULT NULL COMMENT '% (precipitation_probability_max)',
+ `wind_max` decimal(4,1) DEFAULT NULL COMMENT 'km/h (windspeed_10m_max)',
+ `boe_max` decimal(4,1) DEFAULT NULL COMMENT 'km/h (windgusts_10m_max)',
+ `strahlung_summe` decimal(6,2) DEFAULT NULL COMMENT 'MJ/m2 am Tag (shortwave_radiation_sum) - Vergleichsgroesse zur Tagesernte',
+ `ist_vorhersage` tinyint(1) NOT NULL DEFAULT 1 COMMENT 'wie bei weatherHours',
+ `abgerufen` datetime NOT NULL,
+ PRIMARY KEY (`date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+
+-- Einstrahlung in Modulebene, je Flaeche eine Zeile.
+--
+-- Eine eigene Tabelle und keine Spalten in weatherHours: die Anlage hat drei
+-- Flaechen mit verschiedener Neigung und Ausrichtung (Dach, Veranda,
+-- Carport), und eine vierte soll keine Schemaaenderung kosten. Welche
+-- Flaechen es gibt, sagt allein die config.ini des Sammlers.
+--
+-- Gefuellt wird erst, wenn dort Neigung und Azimut eingetragen sind. Ohne
+-- Eintrag bleibt die Tabelle leer, und das Meteogramm merkt nichts davon -
+-- es zeichnet die waagerechte Strahlung aus weatherHours. Nachtragen laesst
+-- sich das jederzeit: die Archiv-API liefert auch die geneigte Einstrahlung
+-- rueckwirkend.
+CREATE TABLE IF NOT EXISTS `weatherTilted` (
+ `datetime` datetime NOT NULL COMMENT 'Ortszeit, volle Stunde - zeigt auf weatherHours',
+ `flaeche` varchar(20) NOT NULL COMMENT 'Kuerzel wie im Anlagenkatalog: dach, veranda, carport',
+ `strahlung` smallint(5) unsigned DEFAULT NULL COMMENT 'W/m2 in Modulebene (global_tilted_irradiance)',
+ PRIMARY KEY (`datetime`, `flaeche`),
+ KEY `flaeche` (`flaeche`, `datetime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
+
+
+-- Die erste Vorhersage je Stunde, unveraenderlich.
+--
+-- weatherHours haelt immer den neuesten Stand - das ist fuer die Anzeige
+-- richtig und fuer die Frage "wie gut war die Vorhersage?" unbrauchbar, weil
+-- die Vorhersage dort laengst von der Analyse ueberschrieben wurde. Hier
+-- landet deshalb, was zuerst ueber eine Stunde gesagt wurde, mit dem
+-- Zeitpunkt der Aussage. INSERT IGNORE, nie REPLACE.
+--
+-- Nur die drei Groessen, um die es dabei geht. Eine vollstaendige Kopie waere
+-- der doppelte Platz fuer eine Frage, die sich an Temperatur, Strahlung und
+-- Regen entscheidet.
+CREATE TABLE IF NOT EXISTS `weatherForecastLog` (
+ `datetime` datetime NOT NULL COMMENT 'die vorhergesagte Stunde, Ortszeit',
+ `abgerufen` datetime NOT NULL COMMENT 'wann diese Aussage gemacht wurde - die Differenz ist der Vorlauf',
+ `temp` decimal(4,1) DEFAULT NULL COMMENT 'degC',
+ `strahlung` smallint(5) unsigned DEFAULT NULL COMMENT 'W/m2 waagerecht',
+ `regen` decimal(4,1) DEFAULT NULL COMMENT 'mm',
+ PRIMARY KEY (`datetime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;