/* ========================================================================== Meteogramm: Wetter über die nächsten Tage, im Stil des Dashboards. const m = new Meteogramm(behaelter, { kopf: element }); m.zeichnen(); m.aktualisieren(mqttData); // nur die Live-Leiste, nicht das Bild Die Daten kommen aus ajax/meteogramm.php, was gezeigt wird steht in js/solar/meteogrammKatalog.js. Diese Datei kennt kein Feld und keine Spur beim Namen - sie liest nur, was der Katalog deklariert. Wer eine Spur hinzufügen will, ändert hier nichts. Ein einziges SVG über alle Felder. Dadurch gibt es genau eine Zeitachse, und Nachtstreifen, Tagesgrenzen, Jetzt-Linie und Zeiger laufen ohne Kunstgriff durch alle Felder hindurch. Jedes Feld ist ein mit eigener Y-Abbildung; X(t) ist gemeinsam. Das viewBox ist so breit wie der Behälter (nicht fest), damit eine Nutzereinheit ein CSS-Pixel bleibt und die Beschriftung am Handy nicht mitschrumpft - dieselbe Wahl wie in speicher.js. ========================================================================== */ (function () { "use strict"; const NS = "http://www.w3.org/2000/svg"; const TAGE = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"]; const NACHLADEN_MS = 10 * 60000; const ALT_S = 2 * 3600; // ab hier gilt der Stand als alt const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ","); const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); const schutz = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); /** Wie in den übrigen Modulen: mqtt.js liefert Buffer, keine Zahlen. */ function leser(daten) { return { da(t) { return daten[t] !== undefined; }, zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; }, }; } /** * Eine Achse auf runde Werte bringen. * * Ohne das stuenden an der Temperaturachse Werte wie 0,8 und 13,4 - die * Zahl ist dann zwar richtig, aber niemand liest eine Kurve gegen 13,4 ab. * Gesucht wird die groesste "huebsche" Schrittweite (1, 2, 2,5, 5, 10 mal * einer Zehnerpotenz), die noch hoechstens n Stufen braucht. */ function schoeneAchse(min, max, n) { const spanne = Math.max(max - min, 1e-6); const roh = spanne / n; const potenz = Math.pow(10, Math.floor(Math.log10(roh))); const schritt = [1, 2, 2.5, 5, 10].map(f => f * potenz).find(s => s >= roh) || 10 * potenz; return { min: Math.floor(min / schritt) * schritt, max: Math.ceil(max / schritt) * schritt, schritt, }; } /** Mitternacht des Tages, in dem t liegt - als Unix-Sekunden. */ function tagesBeginn(t) { const d = new Date(t * 1000); d.setHours(0, 0, 0, 0); return Math.round(d.getTime() / 1000); } class Meteogramm { constructor(behaelter, opt = {}) { this.behaelter = behaelter; this.kopf = opt.kopf || null; this.katalog = opt.katalog || METEOGRAMM_KATALOG; const vorgabe = this.katalog.zeitraeume.find(z => z.vorgabe); // Am Handy starten zwei Tage statt fünf: auf 358 nutzbaren Pixeln // blieben für fünf Tage drei Pixel je Stunde, und ein Wolkenband aus // Dreipixelstreifen sagt nichts mehr. const eng = window.innerWidth < this.katalog.umbruch; this.tage = opt.tage || (eng ? this.katalog.zeitraumSchmal : (vorgabe ? vorgabe.tage : 5)); this.stand = null; // Antwort des Endpunkts this.live = null; // Werte der eigenen Station aus MQTT this.zuletzt = {}; this.sichtbar = false; new ResizeObserver(() => this.tafelZeichnen()).observe(behaelter); new IntersectionObserver(e => { const vorher = this.sichtbar; this.sichtbar = e[0].isIntersecting; this.behaelter.classList.toggle("mtg-pause", !this.sichtbar); // Erst beim Hinsehen laden: vorher kostet der Abruf nur. if (this.sichtbar && !vorher && !this.stand) this.holen(); }).observe(behaelter); setInterval(() => { if (this.sichtbar && !document.hidden) this.holen(); }, NACHLADEN_MS); } /* ---------------------------------------------------------- Zeichnen */ zeichnen() { this.behaelter.innerHTML = `
`; const $ = s => this.behaelter.querySelector(s); this.dom = { meldung: $(".mtg-meldung"), jetzt: $(".mtg-jetzt"), bild: $(".mtg-bild"), tipp: $(".mtg-tipp"), legende: $(".mtg-legende"), fuss: $(".mtg-fuss"), }; // Die Zeitraumknöpfe gehören in die Kartenkopfzeile, nicht ins Bild - // deshalb hängt sie das Modul dort ein, wenn die Seite einen Platz // dafür anbietet. if (this.kopf) { this.kopf.innerHTML = `
` + this.katalog.zeitraeume.map(z => ``).join("") + `
`; 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 = '

Vorhersage nicht abrufbar: ' + schutz(e.message) + "

"; } } } /** 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 = 'gerade' + k.map(x => `${x.z}${schutz(x.w)}`).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(`${b.name}`)); f.spuren.filter(s => s.legende).forEach(s => teile.push(`${schutz(s.name)}`)); }); 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 = '

Noch keine Vorhersage vorhanden.

' + ''; 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 = ``; // --- 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 += ``; } }); }); if (d.jetzt > von) { const x2 = Math.min(B - R, X(d.jetzt)); svg += ``; } // --- Tagesgrenzen durch alle Felder --- for (let t = tagesBeginn(von) + 86400; t < bis; t += 86400) { const x = X(t).toFixed(1); svg += ``; } // --- 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 += ``; } svg += this.zeitachse({ d, X, L, R, B, H, U, schmal }); svg += ``; svg += ``; svg += ``; this.dom.bild.innerHTML = svg; this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp"); this.zeigerVerdrahten({ d, X, L, R, B, felder, wert }); } /** Nachtabschnitte als [von, bis] aus den Sonnenzeiten der Tage. */ naechte(d) { const T = this.katalog.tagSpalten; const stuecke = []; const tage = d.tage; if (!tage.length) return stuecke; // Vor dem ersten Sonnenaufgang ist Nacht. stuecke.push([d.von, tage[0][T.auf]]); tage.forEach((tag, i) => { const naechster = tage[i + 1]; stuecke.push([tag[T.unter], naechster ? naechster[T.auf] : d.bis]); }); return stuecke; } /** Ein Feld: Gitter, Achsen, Bänder, Spuren, Symbolleisten. */ feldZeichnen(e, o) { const { d, X, L, R, B, id, breiteStunde, wert, schmal } = o; const f = e.f; const S = this.katalog.spalten; let svg = ``; // Gitter und linke Beschriftung const links = e.achsen.links; if (links) { const schritt = links.schritt || (links.max - links.min) / (links.cfg.gitter || 4); for (let v = links.min; v <= links.max + 1e-6; v += schritt) { const y = e.Y("links", v).toFixed(1); svg += ``; svg += `` + `${this.achsenZahl(v)}`; } } // Rechte Beschriftung nur, wo sie etwas bedeutet. const rechts = e.achsen.rechts; if (rechts && rechts.cfg.ticks) { rechts.cfg.ticks.forEach(v => { svg += `` + `${this.achsenZahl(v)}`; }); } // Name samt Einheit in die Luecke darueber, als EIN Text. Getrennt // standen "mm/h" und "NIEDERSCHLAG UND BEWOELKUNG" dicht nebeneinander // und lasen sich wie ein Wort. const titel = f.name + (links ? " · " + links.einheit : ""); svg += `${schutz(titel)}`; if (rechts && rechts.cfg.ticks) { svg += `` + `${schutz(rechts.einheit)}`; } // Bänder (Bewölkung): je Schicht EIN Rechteck mit einem waagerechten // Farbverlauf, dessen Haltepunkte die Stundenwerte sind. // // Ein Rechteck je Stunde wäre naheliegender und sah aus wie // Fernsehrauschen: zweihundert harte Kanten nebeneinander. Der Verlauf // zeichnet dieselben Zahlen, blendet aber zwischen ihnen über - und // Bewölkung ist ohnehin nichts, was zur vollen Stunde umspringt. (f.baender || []).forEach(band => { band.schichten.forEach((sch, si) => { const y1 = e.Y(band.achse, sch.bis), y2 = e.Y(band.achse, sch.von); const stops = d.stunden.map(z => { const p = wert(z, sch.spalte); const o = p === null || p === undefined ? 0 : p / 100 * band.maxDeckung; const an = ((z[S.t] - d.von) / (d.bis - d.von) * 100).toFixed(2); return ``; }).join(""); if (!stops) return; const gid = `${id}-${f.id}-${si}`; svg += `` + stops + ``; svg += ``; }); }); // Spuren f.spuren.forEach((spur, i) => { const punkte = this.punkte(d, spur, wert); if (!punkte.length) return; if (spur.art === "balken") { const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0)); punkte.forEach(([t, v]) => { if (!v) return; const y = e.Y(spur.achse, v); svg += ``; }); return; } const pfad = this.pfad(punkte, X, v => e.Y(spur.achse, v)); if (spur.art === "flaeche") { const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0)); // Senkrechter Verlauf statt einer gleichmäßigen Füllung: oben, wo // die Spitzen stehen, kräftig, nach unten auslaufend. Flach // gefüllt sah die Strahlung über der dunklen Fläche olivgrün aus. const gid = `${id}-${f.id}-${spur.id}`; const deck = spur.deckkraft || 0.2; svg += `` + `` + `` + ``; svg += ``; return; } svg += ``; if (spur.beschriftung === "minmax") { svg += this.minMax(d, spur, punkte, X, v => e.Y(spur.achse, v), { links: L, rechts: B - R }); } }); // Wettersymbole if (f.symbole) { const schritt = this.schritt(f.symbole, breiteStunde, schmal); d.stunden.forEach((z, i) => { if (i % schritt) return; const eintrag = this.katalog.wetter[wert(z, "code")]; if (!eintrag) return; const glyph = (!z[S.t] || wert(z, "code") === null) ? null : (this.istNacht(d, z[S.t]) && eintrag.nacht ? eintrag.nacht : eintrag.glyph); if (!glyph) return; svg += `${glyph}`; }); } // Windpfeile if (f.pfeile) { const schritt = this.schritt(f.pfeile, breiteStunde, schmal); const laenge = this.katalog.windPfeil.laenge; d.stunden.forEach((z, i) => { if (i % schritt) return; const grad = wert(z, f.pfeile.spalte); if (grad === null || grad === undefined) return; const v = wert(z, f.pfeile.staerke) || 0; const x = X(z[S.t]) + breiteStunde / 2, y = e.oben + 12; // +180: die Richtung sagt, woher der Wind kommt - der Pfeil fliegt // mit ihm. Dieselbe Rechnung wie updateWeatherCards() in common.js. svg += `` + ``; }); } return svg + ""; } /** Wie viele Stunden übersprungen werden, damit nichts überlappt. */ schritt(cfg, breiteStunde, schmal) { let s = cfg.alle || 3; while (s * breiteStunde < cfg.mindestAbstand && s < 24) s *= 2; return s; } /** Die Werte einer Spur, gefiltert nach gemessen/vorhergesagt. */ punkte(d, spur, wert) { const S = this.katalog.spalten; const aus = []; d.stunden.forEach((z, i) => { const v = wert(z, spur.spalte); if (v === null || v === undefined) return; const gemessen = !!z[S.gemessen]; if (spur.nurGemessen && !gemessen) return; // Die Vorhersagespur nimmt die letzte gemessene Stunde mit, sonst // klafft am Jetzt-Strich eine Lücke zwischen beiden Linien. if (spur.nurVorhersage && gemessen) { const naechste = d.stunden[i + 1]; if (!naechste || naechste[S.gemessen]) return; } aus.push([z[S.t], spur.skala ? spur.skala(v) : v]); }); return aus; } /** Pfad mit Lückenerkennung: über zwei Stunden Abstand wird nicht verbunden. */ pfad(punkte, X, Y) { let d = "", zug = []; const zu = () => { if (zug.length) d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L"); zug = []; }; punkte.forEach((p, i) => { if (i && p[0] - punkte[i - 1][0] > 2 * 3600) zu(); zug.push([X(p[0]), Y(p[1])]); }); zu(); return d; } /** Tagesminimum und -maximum an die Kurve schreiben. */ minMax(d, spur, punkte, X, Y, grenze) { let svg = ""; const nachTag = new Map(); punkte.forEach(([t, v]) => { const tag = tagesBeginn(t); const e = nachTag.get(tag); if (!e) nachTag.set(tag, { min: [t, v], max: [t, v] }); else { if (v < e.min[1]) e.min = [t, v]; if (v > e.max[1]) e.max = [t, v]; } }); nachTag.forEach(e => { [[e.max, -7], [e.min, 13]].forEach(([p, dy]) => { // Am Rand nach innen ruecken: ein Tagesmaximum in der letzten // Stunde stand sonst halb ausserhalb des Bildes. const x = Math.min(grenze.rechts - 8, Math.max(grenze.links + 8, X(p[0]))); svg += `${Math.round(p[1])}`; }); }); return svg; } istNacht(d, t) { const T = this.katalog.tagSpalten; const tag = d.tage.find(x => t >= x[T.t] && t < x[T.t] + 86400); if (!tag) return false; return t < tag[T.auf] || t >= tag[T.unter]; } achsenZahl(v) { return Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : komma(v, 1); } /** Der Streifen unten: Wochentag je Tag, bei zwei Tagen auch Stunden. */ zeitachse(o) { const { d, X, L, R, B, H, U, schmal } = o; let svg = ""; const stundenTicks = this.tage <= 2; for (let t = tagesBeginn(d.von); t < d.bis; t += 86400) { const a = Math.max(d.von, t), b = Math.min(d.bis, t + 86400); if (b <= a) continue; const mitte = (X(a) + X(b)) / 2; const tag = new Date(t * 1000); const text = TAGE[tag.getDay()] + (schmal ? "" : " " + tag.getDate() + "."); svg += `${text}`; if (stundenTicks) { [6, 12, 18].forEach(h => { const x = X(t + h * 3600); if (x < L || x > B - R) return; svg += `${String(h).padStart(2, "0")}`; }); } } return svg; } /* ------------------------------------------------------------ Zeiger */ zeigerVerdrahten(o) { const { d, X, L, R, B, felder, wert } = o; const S = this.katalog.spalten; const svg = this.dom.bild.querySelector("svg"); const zeiger = svg && svg.querySelector(".mtg-zeiger"); const tipp = this.dom.tipp; if (!svg || !zeiger || !tipp) return; const verstecken = () => { tipp.hidden = true; zeiger.setAttribute("x1", -10); zeiger.setAttribute("x2", -10); }; const zeigen = ev => { const r = svg.getBoundingClientRect(); const x = (ev.clientX - r.left) / r.width * B; const t = d.von + (x - L) / (B - L - R) * (d.bis - d.von); let best = null; d.stunden.forEach(z => { if (!best || Math.abs(z[S.t] - t) < Math.abs(best[S.t] - t)) best = z; }); if (!best || Math.abs(best[S.t] - t) > 2 * 3600) return verstecken(); const tx = X(best[S.t]); zeiger.setAttribute("x1", tx); zeiger.setAttribute("x2", tx); const zeit = new Date(best[S.t] * 1000); const eintrag = this.katalog.wetter[wert(best, "code")]; let html = `${TAGE[zeit.getDay()]} ${zeit.getDate()}.${zeit.getMonth() + 1}., ` + `${uhrzeit(zeit)}` + `${best[S.gemessen] ? "gemessen" : "Vorhersage"}`; if (eintrag) { html += `${eintrag.glyph}${schutz(eintrag.name)}`; } this.katalog.felder.forEach(f => { f.spuren.forEach(spur => { if (spur.nurGemessen && !best[S.gemessen]) return; if (spur.nurVorhersage && best[S.gemessen]) return; const v = wert(best, spur.spalte); if (v === null || v === undefined) return; html += `${schutz(spur.name)}` + `${schutz(spur.format(v))}`; }); (f.baender || []).forEach(band => { const teile = band.schichten .map(s => [s.name, wert(best, s.spalte)]) .filter(([, v]) => v !== null && v !== undefined); if (!teile.length) return; html += `` + `${schutz(band.name)}` + teile.map(([n, v]) => n + " " + Math.round(v) + " %").join(" · ") + ``; }); }); tipp.innerHTML = html; tipp.hidden = false; const links = tx / B * r.width; tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4, Math.max(4, links + 12)) + "px"; }; svg.addEventListener("pointermove", zeigen); svg.addEventListener("pointerdown", zeigen); svg.addEventListener("pointerleave", verstecken); } } window.Meteogramm = Meteogramm; })();