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
+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;
})();