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 <g> 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 = ` + <div class="mtg"> + <div class="mtg-meldung" hidden></div> + <div class="mtg-jetzt"></div> + <div class="mtg-bild"><div class="mtg-tipp" hidden></div></div> + <div class="mtg-unten"> + <div class="mtg-legende"></div> + <div class="mtg-fuss"></div> + </div> + </div>`; + 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 = + `<div class="mtg-zeitraum" role="group" aria-label="Zeitraum">` + + this.katalog.zeitraeume.map(z => + `<button type="button" data-mtg-tage="${z.tage}"` + + `${z.tage === this.tage ? ' class="aktiv"' : ""}>${z.name}</button>`).join("") + + `</div>`; + this.kopf.addEventListener("click", ev => { + const b = ev.target.closest("[data-mtg-tage]"); + if (!b) return; + this.tage = Number(b.dataset.mtgTage); + this.kopf.querySelectorAll("[data-mtg-tage]").forEach(x => + x.classList.toggle("aktiv", x === b)); + this.holen(); + }); + } + + if (this.stand) this.anzeigen(); + else if (this.sichtbar) this.holen(); + } + + /* ------------------------------------------------------------- Daten */ + + async holen() { + try { + const antwort = await fetch( + "ajax/meteogramm.php?tage=" + this.tage + "&rueck=" + this.katalog.rueckblick, + { cache: "no-store" }); + const d = await antwort.json(); + if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status); + this.stand = d; + this.zuletzt = {}; + if (this.dom) this.anzeigen(); + } catch (e) { + if (this.dom) { + this.dom.bild.innerHTML = + '<p class="mtg-leer">Vorhersage nicht abrufbar: ' + schutz(e.message) + "</p>"; + } + } + } + + /** Nur die Live-Leiste. Das Bild bleibt stehen - es zeigt Stunden. */ + aktualisieren(daten) { + const m = leser(daten); + if (!m.da("weatherStation/tempAmb")) return; + this.live = { + temp: m.zahl("weatherStation/tempAmb"), + feuchte: m.zahl("weatherStation/hum"), + druck: m.zahl("weatherStation/qff"), + wind: m.zahl("weatherStation/avgWindspeed"), + boe: m.zahl("weatherStation/maxgust"), + richtung: m.zahl("weatherStation/windDeg"), + }; + if (this.dom) this.liveZeigen(); + } + + anzeigen() { + this.liveZeigen(); + this.tafelZeichnen(); + this.legendeZeigen(); + this.fussZeigen(); + } + + /* -------------------------------------------------------- Live-Leiste */ + + liveZeigen() { + const s = this.live; + if (!s) return; + const k = [ + { z: "", w: komma(s.temp, 1) + " °C", n: "Temperatur" }, // bi-thermometer-half + { z: "", w: Math.round(s.feuchte) + " %", n: "Feuchte" }, // bi-droplet-fill + { z: "", w: komma(s.wind, 1) + " km/h", n: "Wind" }, // bi-wind + { z: "", w: komma(s.boe, 1) + " km/h", n: "Böen" }, // bi-wind + { z: "", w: komma(s.druck, 1) + " hPa", n: "Druck" }, // bi-speedometer2 + ]; + const html = '<span class="mtg-jetzt-titel">gerade</span>' + k.map(x => + `<span class="mtg-wert" title="${x.n}"><i>${x.z}</i>${schutz(x.w)}</span>`).join(""); + if (this.zuletzt.live === html) return; + this.zuletzt.live = html; + this.dom.jetzt.innerHTML = html; + } + + fussZeigen() { + const d = this.stand; + if (!d) return; + const alt = d.jetzt - d.stand > ALT_S; + const text = d.stand + ? "Stand " + uhrzeit(new Date(d.stand * 1000)) + " · Open-Meteo" + : "kein Stand"; + if (this.zuletzt.fuss !== text + alt) { + this.zuletzt.fuss = text + alt; + this.dom.fuss.textContent = text; + this.dom.fuss.classList.toggle("mtg-alt", alt); + } + const meldung = alt + ? "Die Vorhersage ist seit über zwei Stunden nicht aufgefrischt worden." + : ""; + this.dom.meldung.textContent = meldung; + this.dom.meldung.hidden = !meldung; + } + + legendeZeigen() { + const teile = []; + this.katalog.felder.forEach(f => { + (f.baender || []).forEach(b => + teile.push(`<span><i class="mtg-band-probe" style="background:${b.farbe}"></i>${b.name}</span>`)); + f.spuren.filter(s => s.legende).forEach(s => + teile.push(`<span><i style="background:${s.farbe}"></i>${schutz(s.name)}</span>`)); + }); + const html = teile.join(""); + if (this.zuletzt.legende === html) return; + this.zuletzt.legende = html; + this.dom.legende.innerHTML = html; + } + + /* ------------------------------------------------------------- Tafel */ + + tafelZeichnen() { + const d = this.stand; + if (!d || !this.dom) return; + const kat = this.katalog; + const B = Math.max(320, Math.round(this.dom.bild.clientWidth) || 320); + const schmal = B < kat.umbruch; + const sig = [B, schmal, this.tage, d.stand, d.jetzt].join("|"); + if (this.zuletzt.tafel === sig) return; + this.zuletzt.tafel = sig; + + if (!d.stunden.length) { + this.dom.bild.innerHTML = '<p class="mtg-leer">Noch keine Vorhersage vorhanden.</p>' + + '<div class="mtg-tipp" hidden></div>'; + 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 = `<svg class="mtg-tafel" viewBox="0 0 ${B} ${H}" width="${B}" height="${H}" ` + + `role="img" aria-label="Wetter der nächsten ${this.tage} Tage">`; + + // --- Nacht und Vergangenheit --- + const nachtStuecke = this.naechte(d); + felder.filter(e => e.f.nacht).forEach(e => { + nachtStuecke.forEach(([a, b]) => { + const x1 = Math.max(L, X(a)), x2 = Math.min(B - R, X(b)); + if (x2 > x1) { + svg += `<rect class="mtg-nacht" x="${x1.toFixed(1)}" y="${e.oben}" ` + + `width="${(x2 - x1).toFixed(1)}" height="${e.h}"/>`; + } + }); + }); + if (d.jetzt > von) { + const x2 = Math.min(B - R, X(d.jetzt)); + svg += `<rect class="mtg-vergangen" x="${L}" y="${O}" ` + + `width="${(x2 - L).toFixed(1)}" height="${H - O - U}"/>`; + } + + // --- Tagesgrenzen durch alle Felder --- + for (let t = tagesBeginn(von) + 86400; t < bis; t += 86400) { + const x = X(t).toFixed(1); + svg += `<line class="mtg-tag" x1="${x}" x2="${x}" y1="${O}" y2="${H - U}"/>`; + } + + // --- Felder --- + felder.forEach(e => { + svg += this.feldZeichnen(e, { d, X, L, R, B, id, breiteStunde, wert, schmal }); + }); + + // --- Jetzt-Linie, Zeitachse, Zeiger, Fangfläche --- + const xJetzt = X(d.jetzt); + if (xJetzt > L && xJetzt < B - R) { + svg += `<line class="mtg-jetztlinie" x1="${xJetzt.toFixed(1)}" x2="${xJetzt.toFixed(1)}" ` + + `y1="${O}" y2="${H - U}"/>`; + } + svg += this.zeitachse({ d, X, L, R, B, H, U, schmal }); + svg += `<line class="mtg-zeiger" x1="-10" x2="-10" y1="${O}" y2="${H - U}"/>`; + svg += `<rect class="mtg-fang" x="${L}" y="0" width="${B - L - R}" height="${H}"/>`; + svg += `</svg><div class="mtg-tipp" hidden></div>`; + + 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 = `<g class="mtg-feld" data-feld="${f.id}">`; + + // 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 += `<line class="mtg-gitter" x1="${L}" x2="${B - R}" y1="${y}" y2="${y}"/>`; + svg += `<text class="mtg-achse" x="${L - 4}" y="${+y + 3}" text-anchor="end">` + + `${this.achsenZahl(v)}</text>`; + } + } + // Rechte Beschriftung nur, wo sie etwas bedeutet. + const rechts = e.achsen.rechts; + if (rechts && rechts.cfg.ticks) { + rechts.cfg.ticks.forEach(v => { + svg += `<text class="mtg-achse" x="${B - R + 4}" y="${e.Y("rechts", v) + 3}">` + + `${this.achsenZahl(v)}</text>`; + }); + } + + // 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 += `<text class="mtg-name" x="${L}" y="${e.oben - 5}">${schutz(titel)}</text>`; + if (rechts && rechts.cfg.ticks) { + svg += `<text class="mtg-einheit" x="${B - R + 4}" y="${e.oben - 5}">` + + `${schutz(rechts.einheit)}</text>`; + } + + // 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 `<stop offset="${an}%" stop-color="${band.farbe}" ` + + `stop-opacity="${o.toFixed(3)}"/>`; + }).join(""); + if (!stops) return; + const gid = `${id}-${f.id}-${si}`; + svg += `<defs><linearGradient id="${gid}" x1="0" x2="1" y1="0" y2="0">` + + stops + `</linearGradient></defs>`; + svg += `<rect class="mtg-wolke" x="${L}" y="${y1.toFixed(1)}" ` + + `width="${(B - R - L).toFixed(1)}" height="${(y2 - y1).toFixed(1)}" ` + + `fill="url(#${gid})"/>`; + }); + }); + + // 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 += `<rect class="mtg-balken" x="${X(t).toFixed(1)}" y="${y.toFixed(1)}" ` + + `width="${Math.max(0.8, breiteStunde * 0.86).toFixed(2)}" ` + + `height="${Math.max(0.6, grund - y).toFixed(1)}" fill="${spur.farbe}"/>`; + }); + 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 += `<defs><linearGradient id="${gid}" x1="0" x2="0" y1="0" y2="1">` + + `<stop offset="0%" stop-color="${spur.farbe}" stop-opacity="${(deck * 1.9).toFixed(3)}"/>` + + `<stop offset="100%" stop-color="${spur.farbe}" stop-opacity="${(deck * 0.25).toFixed(3)}"/>` + + `</linearGradient></defs>`; + svg += `<path class="mtg-flaeche" d="${pfad} L${X(punkte[punkte.length - 1][0]).toFixed(1)} ` + + `${grund.toFixed(1)} L${X(punkte[0][0]).toFixed(1)} ${grund.toFixed(1)} Z" ` + + `fill="url(#${gid})"/>`; + return; + } + svg += `<path class="mtg-linie" d="${pfad}" stroke="${spur.farbe}" ` + + `stroke-width="${spur.breit || 2}"` + + (spur.strich ? ` stroke-dasharray="${spur.strich}"` : "") + + ` style="--i:${i}"/>`; + 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 += `<text class="mtg-wetter" x="${(X(z[S.t]) + breiteStunde / 2).toFixed(1)}" ` + + `y="${e.oben + 15}" text-anchor="middle">${glyph}</text>`; + }); + } + + // 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 += `<g class="mtg-pfeil${v < this.katalog.windPfeil.schwach ? " mtg-schwach" : ""}" ` + + `transform="translate(${x.toFixed(1)} ${y}) rotate(${(grad + 180).toFixed(0)})">` + + `<path d="M0 ${-laenge} L0 ${laenge} M0 ${laenge} L${-3} ${laenge - 4} ` + + `M0 ${laenge} L3 ${laenge - 4}"/></g>`; + }); + } + + return svg + "</g>"; + } + + /** 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 += `<text class="mtg-marke" x="${x.toFixed(1)}" ` + + `y="${(Y(p[1]) + dy).toFixed(1)}" text-anchor="middle" ` + + `fill="${spur.farbe}">${Math.round(p[1])}</text>`; + }); + }); + 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 class="mtg-tagname" x="${mitte.toFixed(1)}" y="${H - U + 12}" ` + + `text-anchor="middle">${text}</text>`; + if (stundenTicks) { + [6, 12, 18].forEach(h => { + const x = X(t + h * 3600); + if (x < L || x > B - R) return; + svg += `<text class="mtg-stunde" x="${x.toFixed(1)}" y="${H - U + 22}" ` + + `text-anchor="middle">${String(h).padStart(2, "0")}</text>`; + }); + } + } + 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 = `<b>${TAGE[zeit.getDay()]} ${zeit.getDate()}.${zeit.getMonth() + 1}., ` + + `${uhrzeit(zeit)}</b>` + + `<em class="mtg-tipp-art">${best[S.gemessen] ? "gemessen" : "Vorhersage"}</em>`; + if (eintrag) { + html += `<span class="mtg-tipp-wetter"><i>${eintrag.glyph}</i>${schutz(eintrag.name)}</span>`; + } + 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 += `<span><i style="background:${spur.farbe}"></i>${schutz(spur.name)}` + + `<em>${schutz(spur.format(v))}</em></span>`; + }); + (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 += `<span><i class="mtg-band-probe" style="background:${band.farbe}"></i>` + + `${schutz(band.name)}<em>` + + teile.map(([n, v]) => n + " " + Math.round(v) + " %").join(" · ") + + `</em></span>`; + }); + }); + 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 @@ <div class="col-xxl-12 col-xl-12 col-lg-12 col-md-12 d-flex align-items-stretch"> <div class="card p-0 mb-4" style="width:100%"> - <div class="card-header pb-0 pt-1"> - <h3 class="card-title">Forecast</h3> + <!-- + Wetter. Der Titel hiess frueher "Forecast" wie die Karte + darunter, die aus simPower die PV-Ernte vorhersagt - zwei + gleichnamige Karten uebereinander. + --> + <div class="card-header pb-1 pt-1 d-flex align-items-center flex-wrap gap-2"> + <h3 class="card-title me-auto">Wetter</h3> + <!-- Hier haengt das Modul seine Zeitraumknoepfe ein (opt.kopf). --> + <div class="mtg-kopf"></div> + <!-- + Eigenes Meteogramm oder das eingebettete von meteoblue. + data-mtg-quelle und nicht data-enf-ansicht: zeigeAnsicht() in + solarMQTT.js greift seitenweit zu und wuerde sonst die + Realtime-Karte mitschalten. + --> + <div class="enf-umschalter" role="tablist" aria-label="Quelle"> + <button type="button" class="btn btn-sm active" role="tab" aria-selected="true" data-mtg-quelle="eigen">Eigenes</button> + <button type="button" class="btn btn-sm" role="tab" aria-selected="false" data-mtg-quelle="blue">meteoblue</button> + </div> <div class="card-tools"> <button type="button" class="btn btn-tool" data-lte-toggle="card-remove"> <i class="bi bi-x-lg"></i> </button> </div> </div> - <div class="card-body pb-1" id="meteogram"> - + <div class="card-body p-2 p-md-3"> + <!-- js/solar/meteogramm.js aus meteogrammKatalog.js, Daten aus ajax/meteogramm.php --> + <div data-mtg-flaeche="eigen" id="meteogramm"></div> + <!-- + Ziel des meteoblue-Rahmens. Die id heisst weiterhin + "meteogram" mit einem m, damit mountMeteogram() in common.js + unveraendert bleiben kann. + --> + <div data-mtg-flaeche="blue" id="meteogram" hidden></div> </div> </div> <!-- /.card --> 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 <user> -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;