Eigenes Meteogramm statt des eingebetteten von meteoblue

Das Widget von meteoblue war ein fremdes Dokument im iframe: es passte
farblich nicht, liess sich nicht aendern, wurde auf schmalen Schirmen als
Ganzes heruntergerechnet und konnte die eigenen Messwerte nicht zeigen.
Genau das kann das neue Modul - links vom Jetzt-Strich steht, was die
Wetterstation gemessen hat, rechts die Vorhersage.

Aufgebaut wie die uebrigen Anzeige-Module: meteogrammKatalog.js sagt, WAS
gezeigt wird, meteogramm.js nur, WIE. Der Renderer kennt kein Feld und keine
Spur beim Namen. Eine weitere Spur ist damit ein Objekt im Katalog und keine
Codeaenderung.

Ein einziges SVG ueber alle drei Felder, damit Zeitachse, Nachtstreifen,
Jetzt-Linie und Zeiger nur einmal existieren und ueberall gleich sitzen.

Zwei Entscheidungen, die man sonst sucht:

Das Wolkenband zeichnet je Schicht ein Rechteck mit waagerechtem
Farbverlauf. Ein Rechteck je Stunde war der erste Versuch und sah aus wie
Fernsehrauschen - zweihundert harte Kanten nebeneinander. Open-Meteo kennt
ohnehin nur drei Schichten statt eines Hoehenprofils.

Die Strahlung teilt sich das Feld mit der Temperatur, statt ein viertes zu
bekommen: gleicher Tagesrhythmus, gleiche Nacht-Schattierung, und ein
viertes Feld machte das Ganze am Handy hoeher als das externe Widget.

Die alte Einbindung bleibt vorerst und ist umschaltbar. mountMeteogram()
laeuft jetzt erst beim Umschalten - wer meteoblue nie ansieht, laedt es auch
nicht mehr.

Die Karte heisst "Wetter" statt "Forecast": darunter steht bereits eine
zweite Karte dieses Namens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 13:54:55 +02:00
co-authored by Claude Opus 5
parent a4b25ba279
commit 2c48d78880
8 changed files with 1496 additions and 5 deletions
+186
View File
@@ -0,0 +1,186 @@
<?php
/*
* Daten für das Meteogramm der Solar-Seite (js/solar/meteogramm.js).
*
* ?tage=5&rueck=24 tage 2…7 nach vorn, rueck 0…72 Stunden zurück
*
* Die Vorhersage kommt aus solarLog.weatherHours und weatherDays (geschrieben
* von SolarManager/gatherForecastData.py), die gemessenen Stunden links vom
* Jetzt-Strich aus weatherStation.
*
* Verschmolzen wird nur teilweise, und das ist der Kern dieser Datei: Die
* eigene Station misst Temperatur, Wind, Böe, Richtung, Feuchte und Druck.
* Sie hat weder Regenmesser noch Pyranometer noch Wolkenkamera. Bewölkung,
* Strahlung, Niederschlag und Wettercode bleiben deshalb auch in der
* Vergangenheit aus weatherHours - dort stehen für vergangene Stunden keine
* Vorhersagen mehr, sondern nachgerechnete Werte (ist_vorhersage = 0).
* Genau dafür holt der Sammler zwei Tage Vergangenheit mit.
*
* Zeiten gehen als Unix-Sekunden hinaus, Messreihen als Tupel und nicht als
* Objekte: bei 200 Stunden mit 16 Feldern spart das ein Vielfaches. Welche
* Stelle was bedeutet, steht unten bei SPALTEN und noch einmal im Katalog
* (js/solar/meteogrammKatalog.js) - beide Listen müssen zueinander passen.
*
* Die Live-Werte kommen nicht von hier, sondern über MQTT (weatherStation/#).
*/
require_once("../helper.php");
require_once("../restricted/costs.php"); // solarDb()
header("Content-Type: application/json; charset=utf-8");
if (!checkLogin()) {
http_response_code(403);
echo json_encode(["error" => "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);
}
+77
View File
@@ -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 <title>. */
.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.
+1
View File
@@ -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"]],
+702
View File
@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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;
})();
+271
View File
@@ -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",
},
],
},
],
};
})();
+53 -1
View File
@@ -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();
}
});
+28 -4
View File
@@ -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 -->
+178
View File
@@ -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;