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:
@@ -0,0 +1,702 @@
|
||||
/* ==========================================================================
|
||||
Meteogramm: Wetter über die nächsten Tage, im Stil des Dashboards.
|
||||
|
||||
const m = new Meteogramm(behaelter, { kopf: element });
|
||||
m.zeichnen();
|
||||
m.aktualisieren(mqttData); // nur die Live-Leiste, nicht das Bild
|
||||
|
||||
Die Daten kommen aus ajax/meteogramm.php, was gezeigt wird steht in
|
||||
js/solar/meteogrammKatalog.js. Diese Datei kennt kein Feld und keine Spur
|
||||
beim Namen - sie liest nur, was der Katalog deklariert. Wer eine Spur
|
||||
hinzufügen will, ändert hier nichts.
|
||||
|
||||
Ein einziges SVG über alle Felder. Dadurch gibt es genau eine Zeitachse,
|
||||
und Nachtstreifen, Tagesgrenzen, Jetzt-Linie und Zeiger laufen ohne
|
||||
Kunstgriff durch alle Felder hindurch. Jedes Feld ist ein <g> mit eigener
|
||||
Y-Abbildung; X(t) ist gemeinsam.
|
||||
|
||||
Das viewBox ist so breit wie der Behälter (nicht fest), damit eine
|
||||
Nutzereinheit ein CSS-Pixel bleibt und die Beschriftung am Handy nicht
|
||||
mitschrumpft - dieselbe Wahl wie in speicher.js.
|
||||
========================================================================== */
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const TAGE = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
const NACHLADEN_MS = 10 * 60000;
|
||||
const ALT_S = 2 * 3600; // ab hier gilt der Stand als alt
|
||||
|
||||
const komma = (z, n = 1) => Number(z).toFixed(n).replace(".", ",");
|
||||
const uhrzeit = d => String(d.getHours()).padStart(2, "0") + ":"
|
||||
+ String(d.getMinutes()).padStart(2, "0");
|
||||
const schutz = s => String(s).replace(/[&<>"]/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
/** Wie in den übrigen Modulen: mqtt.js liefert Buffer, keine Zahlen. */
|
||||
function leser(daten) {
|
||||
return {
|
||||
da(t) { return daten[t] !== undefined; },
|
||||
zahl(t) { const v = Number(String(daten[t])); return isFinite(v) ? v : 0; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Achse auf runde Werte bringen.
|
||||
*
|
||||
* Ohne das stuenden an der Temperaturachse Werte wie 0,8 und 13,4 - die
|
||||
* Zahl ist dann zwar richtig, aber niemand liest eine Kurve gegen 13,4 ab.
|
||||
* Gesucht wird die groesste "huebsche" Schrittweite (1, 2, 2,5, 5, 10 mal
|
||||
* einer Zehnerpotenz), die noch hoechstens n Stufen braucht.
|
||||
*/
|
||||
function schoeneAchse(min, max, n) {
|
||||
const spanne = Math.max(max - min, 1e-6);
|
||||
const roh = spanne / n;
|
||||
const potenz = Math.pow(10, Math.floor(Math.log10(roh)));
|
||||
const schritt = [1, 2, 2.5, 5, 10].map(f => f * potenz).find(s => s >= roh) || 10 * potenz;
|
||||
return {
|
||||
min: Math.floor(min / schritt) * schritt,
|
||||
max: Math.ceil(max / schritt) * schritt,
|
||||
schritt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mitternacht des Tages, in dem t liegt - als Unix-Sekunden. */
|
||||
function tagesBeginn(t) {
|
||||
const d = new Date(t * 1000);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return Math.round(d.getTime() / 1000);
|
||||
}
|
||||
|
||||
class Meteogramm {
|
||||
|
||||
constructor(behaelter, opt = {}) {
|
||||
this.behaelter = behaelter;
|
||||
this.kopf = opt.kopf || null;
|
||||
this.katalog = opt.katalog || METEOGRAMM_KATALOG;
|
||||
const vorgabe = this.katalog.zeitraeume.find(z => z.vorgabe);
|
||||
// Am Handy starten zwei Tage statt fünf: auf 358 nutzbaren Pixeln
|
||||
// blieben für fünf Tage drei Pixel je Stunde, und ein Wolkenband aus
|
||||
// Dreipixelstreifen sagt nichts mehr.
|
||||
const eng = window.innerWidth < this.katalog.umbruch;
|
||||
this.tage = opt.tage
|
||||
|| (eng ? this.katalog.zeitraumSchmal : (vorgabe ? vorgabe.tage : 5));
|
||||
this.stand = null; // Antwort des Endpunkts
|
||||
this.live = null; // Werte der eigenen Station aus MQTT
|
||||
this.zuletzt = {};
|
||||
this.sichtbar = false;
|
||||
|
||||
new ResizeObserver(() => this.tafelZeichnen()).observe(behaelter);
|
||||
new IntersectionObserver(e => {
|
||||
const vorher = this.sichtbar;
|
||||
this.sichtbar = e[0].isIntersecting;
|
||||
this.behaelter.classList.toggle("mtg-pause", !this.sichtbar);
|
||||
// Erst beim Hinsehen laden: vorher kostet der Abruf nur.
|
||||
if (this.sichtbar && !vorher && !this.stand) this.holen();
|
||||
}).observe(behaelter);
|
||||
setInterval(() => {
|
||||
if (this.sichtbar && !document.hidden) this.holen();
|
||||
}, NACHLADEN_MS);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Zeichnen */
|
||||
|
||||
zeichnen() {
|
||||
this.behaelter.innerHTML = `
|
||||
<div class="mtg">
|
||||
<div class="mtg-meldung" hidden></div>
|
||||
<div class="mtg-jetzt"></div>
|
||||
<div class="mtg-bild"><div class="mtg-tipp" hidden></div></div>
|
||||
<div class="mtg-unten">
|
||||
<div class="mtg-legende"></div>
|
||||
<div class="mtg-fuss"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const $ = s => this.behaelter.querySelector(s);
|
||||
this.dom = {
|
||||
meldung: $(".mtg-meldung"), jetzt: $(".mtg-jetzt"), bild: $(".mtg-bild"),
|
||||
tipp: $(".mtg-tipp"), legende: $(".mtg-legende"), fuss: $(".mtg-fuss"),
|
||||
};
|
||||
|
||||
// Die Zeitraumknöpfe gehören in die Kartenkopfzeile, nicht ins Bild -
|
||||
// deshalb hängt sie das Modul dort ein, wenn die Seite einen Platz
|
||||
// dafür anbietet.
|
||||
if (this.kopf) {
|
||||
this.kopf.innerHTML =
|
||||
`<div class="mtg-zeitraum" role="group" aria-label="Zeitraum">`
|
||||
+ this.katalog.zeitraeume.map(z =>
|
||||
`<button type="button" data-mtg-tage="${z.tage}"`
|
||||
+ `${z.tage === this.tage ? ' class="aktiv"' : ""}>${z.name}</button>`).join("")
|
||||
+ `</div>`;
|
||||
this.kopf.addEventListener("click", ev => {
|
||||
const b = ev.target.closest("[data-mtg-tage]");
|
||||
if (!b) return;
|
||||
this.tage = Number(b.dataset.mtgTage);
|
||||
this.kopf.querySelectorAll("[data-mtg-tage]").forEach(x =>
|
||||
x.classList.toggle("aktiv", x === b));
|
||||
this.holen();
|
||||
});
|
||||
}
|
||||
|
||||
if (this.stand) this.anzeigen();
|
||||
else if (this.sichtbar) this.holen();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Daten */
|
||||
|
||||
async holen() {
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
"ajax/meteogramm.php?tage=" + this.tage + "&rueck=" + this.katalog.rueckblick,
|
||||
{ cache: "no-store" });
|
||||
const d = await antwort.json();
|
||||
if (!antwort.ok || d.error) throw new Error(d.error || "HTTP " + antwort.status);
|
||||
this.stand = d;
|
||||
this.zuletzt = {};
|
||||
if (this.dom) this.anzeigen();
|
||||
} catch (e) {
|
||||
if (this.dom) {
|
||||
this.dom.bild.innerHTML =
|
||||
'<p class="mtg-leer">Vorhersage nicht abrufbar: ' + schutz(e.message) + "</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur die Live-Leiste. Das Bild bleibt stehen - es zeigt Stunden. */
|
||||
aktualisieren(daten) {
|
||||
const m = leser(daten);
|
||||
if (!m.da("weatherStation/tempAmb")) return;
|
||||
this.live = {
|
||||
temp: m.zahl("weatherStation/tempAmb"),
|
||||
feuchte: m.zahl("weatherStation/hum"),
|
||||
druck: m.zahl("weatherStation/qff"),
|
||||
wind: m.zahl("weatherStation/avgWindspeed"),
|
||||
boe: m.zahl("weatherStation/maxgust"),
|
||||
richtung: m.zahl("weatherStation/windDeg"),
|
||||
};
|
||||
if (this.dom) this.liveZeigen();
|
||||
}
|
||||
|
||||
anzeigen() {
|
||||
this.liveZeigen();
|
||||
this.tafelZeichnen();
|
||||
this.legendeZeigen();
|
||||
this.fussZeigen();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Live-Leiste */
|
||||
|
||||
liveZeigen() {
|
||||
const s = this.live;
|
||||
if (!s) return;
|
||||
const k = [
|
||||
{ z: "", w: komma(s.temp, 1) + " °C", n: "Temperatur" }, // bi-thermometer-half
|
||||
{ z: "", w: Math.round(s.feuchte) + " %", n: "Feuchte" }, // bi-droplet-fill
|
||||
{ z: "", w: komma(s.wind, 1) + " km/h", n: "Wind" }, // bi-wind
|
||||
{ z: "", w: komma(s.boe, 1) + " km/h", n: "Böen" }, // bi-wind
|
||||
{ z: "", w: komma(s.druck, 1) + " hPa", n: "Druck" }, // bi-speedometer2
|
||||
];
|
||||
const html = '<span class="mtg-jetzt-titel">gerade</span>' + k.map(x =>
|
||||
`<span class="mtg-wert" title="${x.n}"><i>${x.z}</i>${schutz(x.w)}</span>`).join("");
|
||||
if (this.zuletzt.live === html) return;
|
||||
this.zuletzt.live = html;
|
||||
this.dom.jetzt.innerHTML = html;
|
||||
}
|
||||
|
||||
fussZeigen() {
|
||||
const d = this.stand;
|
||||
if (!d) return;
|
||||
const alt = d.jetzt - d.stand > ALT_S;
|
||||
const text = d.stand
|
||||
? "Stand " + uhrzeit(new Date(d.stand * 1000)) + " · Open-Meteo"
|
||||
: "kein Stand";
|
||||
if (this.zuletzt.fuss !== text + alt) {
|
||||
this.zuletzt.fuss = text + alt;
|
||||
this.dom.fuss.textContent = text;
|
||||
this.dom.fuss.classList.toggle("mtg-alt", alt);
|
||||
}
|
||||
const meldung = alt
|
||||
? "Die Vorhersage ist seit über zwei Stunden nicht aufgefrischt worden."
|
||||
: "";
|
||||
this.dom.meldung.textContent = meldung;
|
||||
this.dom.meldung.hidden = !meldung;
|
||||
}
|
||||
|
||||
legendeZeigen() {
|
||||
const teile = [];
|
||||
this.katalog.felder.forEach(f => {
|
||||
(f.baender || []).forEach(b =>
|
||||
teile.push(`<span><i class="mtg-band-probe" style="background:${b.farbe}"></i>${b.name}</span>`));
|
||||
f.spuren.filter(s => s.legende).forEach(s =>
|
||||
teile.push(`<span><i style="background:${s.farbe}"></i>${schutz(s.name)}</span>`));
|
||||
});
|
||||
const html = teile.join("");
|
||||
if (this.zuletzt.legende === html) return;
|
||||
this.zuletzt.legende = html;
|
||||
this.dom.legende.innerHTML = html;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Tafel */
|
||||
|
||||
tafelZeichnen() {
|
||||
const d = this.stand;
|
||||
if (!d || !this.dom) return;
|
||||
const kat = this.katalog;
|
||||
const B = Math.max(320, Math.round(this.dom.bild.clientWidth) || 320);
|
||||
const schmal = B < kat.umbruch;
|
||||
const sig = [B, schmal, this.tage, d.stand, d.jetzt].join("|");
|
||||
if (this.zuletzt.tafel === sig) return;
|
||||
this.zuletzt.tafel = sig;
|
||||
|
||||
if (!d.stunden.length) {
|
||||
this.dom.bild.innerHTML = '<p class="mtg-leer">Noch keine Vorhersage vorhanden.</p>'
|
||||
+ '<div class="mtg-tipp" hidden></div>';
|
||||
this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
|
||||
return;
|
||||
}
|
||||
|
||||
const hoehen = kat.hoehen[schmal ? "schmal" : "breit"];
|
||||
const L = schmal ? 30 : 36, R = schmal ? 30 : 38;
|
||||
// Name und Einheit stehen in der Luecke ueber dem Feld, nicht darin -
|
||||
// sonst liegen sie unter den Wettersymbolen und den Windpfeilen, die
|
||||
// beide am oberen Rand ihres Feldes sitzen.
|
||||
const O = 15, U = schmal ? 26 : 24, LUECKE = 16;
|
||||
const felder = kat.felder.map(f => ({ f, h: hoehen[f.hoehe] }));
|
||||
let y = O;
|
||||
felder.forEach(e => { e.oben = y; y += e.h + LUECKE; });
|
||||
const H = y - LUECKE + U;
|
||||
|
||||
const von = d.von, bis = d.bis;
|
||||
const X = t => L + (t - von) / (bis - von) * (B - L - R);
|
||||
const breiteStunde = (B - L - R) / ((bis - von) / 3600);
|
||||
|
||||
const S = kat.spalten;
|
||||
const wert = (zeile, name) => zeile[S[name]];
|
||||
|
||||
// --- Achsen je Feld und Seite ---
|
||||
felder.forEach(e => {
|
||||
e.achsen = {};
|
||||
["links", "rechts"].forEach(seite => {
|
||||
const cfg = e.f[seite];
|
||||
if (!cfg) return;
|
||||
let min = cfg.min, max = cfg.max;
|
||||
if (min === undefined || max === undefined) {
|
||||
const werte = [];
|
||||
e.f.spuren.filter(s => s.achse === seite).forEach(s =>
|
||||
d.stunden.forEach(z => {
|
||||
const v = wert(z, s.spalte);
|
||||
if (v !== null && v !== undefined) werte.push(v);
|
||||
}));
|
||||
if (!werte.length) werte.push(0, 1);
|
||||
if (min === undefined) min = Math.min(...werte);
|
||||
if (max === undefined) max = Math.max(...werte);
|
||||
// Etwas Luft, sonst klebt die Kurve am Rand.
|
||||
const luft = (max - min) * 0.12 || 1;
|
||||
if (cfg.min === undefined) min -= luft;
|
||||
if (cfg.max === undefined) max += luft;
|
||||
}
|
||||
if (cfg.minSpanne && max - min < cfg.minSpanne) {
|
||||
const mitte = (max + min) / 2;
|
||||
min = cfg.min !== undefined ? cfg.min : mitte - cfg.minSpanne / 2;
|
||||
max = min + cfg.minSpanne;
|
||||
}
|
||||
// Feste Grenzen bleiben fest - eine Achse 0..1000 W/m2 soll nicht
|
||||
// auf 0..1000 "gerundet" werden und dabei wandern.
|
||||
let schritt = null;
|
||||
if (cfg.min === undefined || cfg.max === undefined) {
|
||||
const rund = schoeneAchse(min, max, cfg.gitter || 4);
|
||||
if (cfg.min === undefined) min = rund.min;
|
||||
if (cfg.max === undefined) max = rund.max;
|
||||
schritt = rund.schritt;
|
||||
}
|
||||
e.achsen[seite] = { min, max, schritt, einheit: cfg.einheit, cfg };
|
||||
});
|
||||
e.Y = (seite, v) => {
|
||||
const a = e.achsen[seite];
|
||||
return e.oben + (a.max - v) / (a.max - a.min) * e.h;
|
||||
};
|
||||
});
|
||||
|
||||
const id = "mtg" + Math.random().toString(36).slice(2, 8);
|
||||
let svg = `<svg class="mtg-tafel" viewBox="0 0 ${B} ${H}" width="${B}" height="${H}" `
|
||||
+ `role="img" aria-label="Wetter der nächsten ${this.tage} Tage">`;
|
||||
|
||||
// --- Nacht und Vergangenheit ---
|
||||
const nachtStuecke = this.naechte(d);
|
||||
felder.filter(e => e.f.nacht).forEach(e => {
|
||||
nachtStuecke.forEach(([a, b]) => {
|
||||
const x1 = Math.max(L, X(a)), x2 = Math.min(B - R, X(b));
|
||||
if (x2 > x1) {
|
||||
svg += `<rect class="mtg-nacht" x="${x1.toFixed(1)}" y="${e.oben}" `
|
||||
+ `width="${(x2 - x1).toFixed(1)}" height="${e.h}"/>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (d.jetzt > von) {
|
||||
const x2 = Math.min(B - R, X(d.jetzt));
|
||||
svg += `<rect class="mtg-vergangen" x="${L}" y="${O}" `
|
||||
+ `width="${(x2 - L).toFixed(1)}" height="${H - O - U}"/>`;
|
||||
}
|
||||
|
||||
// --- Tagesgrenzen durch alle Felder ---
|
||||
for (let t = tagesBeginn(von) + 86400; t < bis; t += 86400) {
|
||||
const x = X(t).toFixed(1);
|
||||
svg += `<line class="mtg-tag" x1="${x}" x2="${x}" y1="${O}" y2="${H - U}"/>`;
|
||||
}
|
||||
|
||||
// --- Felder ---
|
||||
felder.forEach(e => {
|
||||
svg += this.feldZeichnen(e, { d, X, L, R, B, id, breiteStunde, wert, schmal });
|
||||
});
|
||||
|
||||
// --- Jetzt-Linie, Zeitachse, Zeiger, Fangfläche ---
|
||||
const xJetzt = X(d.jetzt);
|
||||
if (xJetzt > L && xJetzt < B - R) {
|
||||
svg += `<line class="mtg-jetztlinie" x1="${xJetzt.toFixed(1)}" x2="${xJetzt.toFixed(1)}" `
|
||||
+ `y1="${O}" y2="${H - U}"/>`;
|
||||
}
|
||||
svg += this.zeitachse({ d, X, L, R, B, H, U, schmal });
|
||||
svg += `<line class="mtg-zeiger" x1="-10" x2="-10" y1="${O}" y2="${H - U}"/>`;
|
||||
svg += `<rect class="mtg-fang" x="${L}" y="0" width="${B - L - R}" height="${H}"/>`;
|
||||
svg += `</svg><div class="mtg-tipp" hidden></div>`;
|
||||
|
||||
this.dom.bild.innerHTML = svg;
|
||||
this.dom.tipp = this.dom.bild.querySelector(".mtg-tipp");
|
||||
this.zeigerVerdrahten({ d, X, L, R, B, felder, wert });
|
||||
}
|
||||
|
||||
/** Nachtabschnitte als [von, bis] aus den Sonnenzeiten der Tage. */
|
||||
naechte(d) {
|
||||
const T = this.katalog.tagSpalten;
|
||||
const stuecke = [];
|
||||
const tage = d.tage;
|
||||
if (!tage.length) return stuecke;
|
||||
// Vor dem ersten Sonnenaufgang ist Nacht.
|
||||
stuecke.push([d.von, tage[0][T.auf]]);
|
||||
tage.forEach((tag, i) => {
|
||||
const naechster = tage[i + 1];
|
||||
stuecke.push([tag[T.unter], naechster ? naechster[T.auf] : d.bis]);
|
||||
});
|
||||
return stuecke;
|
||||
}
|
||||
|
||||
/** Ein Feld: Gitter, Achsen, Bänder, Spuren, Symbolleisten. */
|
||||
feldZeichnen(e, o) {
|
||||
const { d, X, L, R, B, id, breiteStunde, wert, schmal } = o;
|
||||
const f = e.f;
|
||||
const S = this.katalog.spalten;
|
||||
let svg = `<g class="mtg-feld" data-feld="${f.id}">`;
|
||||
|
||||
// Gitter und linke Beschriftung
|
||||
const links = e.achsen.links;
|
||||
if (links) {
|
||||
const schritt = links.schritt || (links.max - links.min) / (links.cfg.gitter || 4);
|
||||
for (let v = links.min; v <= links.max + 1e-6; v += schritt) {
|
||||
const y = e.Y("links", v).toFixed(1);
|
||||
svg += `<line class="mtg-gitter" x1="${L}" x2="${B - R}" y1="${y}" y2="${y}"/>`;
|
||||
svg += `<text class="mtg-achse" x="${L - 4}" y="${+y + 3}" text-anchor="end">`
|
||||
+ `${this.achsenZahl(v)}</text>`;
|
||||
}
|
||||
}
|
||||
// Rechte Beschriftung nur, wo sie etwas bedeutet.
|
||||
const rechts = e.achsen.rechts;
|
||||
if (rechts && rechts.cfg.ticks) {
|
||||
rechts.cfg.ticks.forEach(v => {
|
||||
svg += `<text class="mtg-achse" x="${B - R + 4}" y="${e.Y("rechts", v) + 3}">`
|
||||
+ `${this.achsenZahl(v)}</text>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Name samt Einheit in die Luecke darueber, als EIN Text. Getrennt
|
||||
// standen "mm/h" und "NIEDERSCHLAG UND BEWOELKUNG" dicht nebeneinander
|
||||
// und lasen sich wie ein Wort.
|
||||
const titel = f.name + (links ? " · " + links.einheit : "");
|
||||
svg += `<text class="mtg-name" x="${L}" y="${e.oben - 5}">${schutz(titel)}</text>`;
|
||||
if (rechts && rechts.cfg.ticks) {
|
||||
svg += `<text class="mtg-einheit" x="${B - R + 4}" y="${e.oben - 5}">`
|
||||
+ `${schutz(rechts.einheit)}</text>`;
|
||||
}
|
||||
|
||||
// Bänder (Bewölkung): je Schicht EIN Rechteck mit einem waagerechten
|
||||
// Farbverlauf, dessen Haltepunkte die Stundenwerte sind.
|
||||
//
|
||||
// Ein Rechteck je Stunde wäre naheliegender und sah aus wie
|
||||
// Fernsehrauschen: zweihundert harte Kanten nebeneinander. Der Verlauf
|
||||
// zeichnet dieselben Zahlen, blendet aber zwischen ihnen über - und
|
||||
// Bewölkung ist ohnehin nichts, was zur vollen Stunde umspringt.
|
||||
(f.baender || []).forEach(band => {
|
||||
band.schichten.forEach((sch, si) => {
|
||||
const y1 = e.Y(band.achse, sch.bis), y2 = e.Y(band.achse, sch.von);
|
||||
const stops = d.stunden.map(z => {
|
||||
const p = wert(z, sch.spalte);
|
||||
const o = p === null || p === undefined ? 0 : p / 100 * band.maxDeckung;
|
||||
const an = ((z[S.t] - d.von) / (d.bis - d.von) * 100).toFixed(2);
|
||||
return `<stop offset="${an}%" stop-color="${band.farbe}" `
|
||||
+ `stop-opacity="${o.toFixed(3)}"/>`;
|
||||
}).join("");
|
||||
if (!stops) return;
|
||||
const gid = `${id}-${f.id}-${si}`;
|
||||
svg += `<defs><linearGradient id="${gid}" x1="0" x2="1" y1="0" y2="0">`
|
||||
+ stops + `</linearGradient></defs>`;
|
||||
svg += `<rect class="mtg-wolke" x="${L}" y="${y1.toFixed(1)}" `
|
||||
+ `width="${(B - R - L).toFixed(1)}" height="${(y2 - y1).toFixed(1)}" `
|
||||
+ `fill="url(#${gid})"/>`;
|
||||
});
|
||||
});
|
||||
|
||||
// Spuren
|
||||
f.spuren.forEach((spur, i) => {
|
||||
const punkte = this.punkte(d, spur, wert);
|
||||
if (!punkte.length) return;
|
||||
if (spur.art === "balken") {
|
||||
const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
|
||||
punkte.forEach(([t, v]) => {
|
||||
if (!v) return;
|
||||
const y = e.Y(spur.achse, v);
|
||||
svg += `<rect class="mtg-balken" x="${X(t).toFixed(1)}" y="${y.toFixed(1)}" `
|
||||
+ `width="${Math.max(0.8, breiteStunde * 0.86).toFixed(2)}" `
|
||||
+ `height="${Math.max(0.6, grund - y).toFixed(1)}" fill="${spur.farbe}"/>`;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pfad = this.pfad(punkte, X, v => e.Y(spur.achse, v));
|
||||
if (spur.art === "flaeche") {
|
||||
const grund = e.Y(spur.achse, Math.max(e.achsen[spur.achse].min, 0));
|
||||
// Senkrechter Verlauf statt einer gleichmäßigen Füllung: oben, wo
|
||||
// die Spitzen stehen, kräftig, nach unten auslaufend. Flach
|
||||
// gefüllt sah die Strahlung über der dunklen Fläche olivgrün aus.
|
||||
const gid = `${id}-${f.id}-${spur.id}`;
|
||||
const deck = spur.deckkraft || 0.2;
|
||||
svg += `<defs><linearGradient id="${gid}" x1="0" x2="0" y1="0" y2="1">`
|
||||
+ `<stop offset="0%" stop-color="${spur.farbe}" stop-opacity="${(deck * 1.9).toFixed(3)}"/>`
|
||||
+ `<stop offset="100%" stop-color="${spur.farbe}" stop-opacity="${(deck * 0.25).toFixed(3)}"/>`
|
||||
+ `</linearGradient></defs>`;
|
||||
svg += `<path class="mtg-flaeche" d="${pfad} L${X(punkte[punkte.length - 1][0]).toFixed(1)} `
|
||||
+ `${grund.toFixed(1)} L${X(punkte[0][0]).toFixed(1)} ${grund.toFixed(1)} Z" `
|
||||
+ `fill="url(#${gid})"/>`;
|
||||
return;
|
||||
}
|
||||
svg += `<path class="mtg-linie" d="${pfad}" stroke="${spur.farbe}" `
|
||||
+ `stroke-width="${spur.breit || 2}"`
|
||||
+ (spur.strich ? ` stroke-dasharray="${spur.strich}"` : "")
|
||||
+ ` style="--i:${i}"/>`;
|
||||
if (spur.beschriftung === "minmax") {
|
||||
svg += this.minMax(d, spur, punkte, X, v => e.Y(spur.achse, v), { links: L, rechts: B - R });
|
||||
}
|
||||
});
|
||||
|
||||
// Wettersymbole
|
||||
if (f.symbole) {
|
||||
const schritt = this.schritt(f.symbole, breiteStunde, schmal);
|
||||
d.stunden.forEach((z, i) => {
|
||||
if (i % schritt) return;
|
||||
const eintrag = this.katalog.wetter[wert(z, "code")];
|
||||
if (!eintrag) return;
|
||||
const glyph = (!z[S.t] || wert(z, "code") === null) ? null
|
||||
: (this.istNacht(d, z[S.t]) && eintrag.nacht ? eintrag.nacht : eintrag.glyph);
|
||||
if (!glyph) return;
|
||||
svg += `<text class="mtg-wetter" x="${(X(z[S.t]) + breiteStunde / 2).toFixed(1)}" `
|
||||
+ `y="${e.oben + 15}" text-anchor="middle">${glyph}</text>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Windpfeile
|
||||
if (f.pfeile) {
|
||||
const schritt = this.schritt(f.pfeile, breiteStunde, schmal);
|
||||
const laenge = this.katalog.windPfeil.laenge;
|
||||
d.stunden.forEach((z, i) => {
|
||||
if (i % schritt) return;
|
||||
const grad = wert(z, f.pfeile.spalte);
|
||||
if (grad === null || grad === undefined) return;
|
||||
const v = wert(z, f.pfeile.staerke) || 0;
|
||||
const x = X(z[S.t]) + breiteStunde / 2, y = e.oben + 12;
|
||||
// +180: die Richtung sagt, woher der Wind kommt - der Pfeil fliegt
|
||||
// mit ihm. Dieselbe Rechnung wie updateWeatherCards() in common.js.
|
||||
svg += `<g class="mtg-pfeil${v < this.katalog.windPfeil.schwach ? " mtg-schwach" : ""}" `
|
||||
+ `transform="translate(${x.toFixed(1)} ${y}) rotate(${(grad + 180).toFixed(0)})">`
|
||||
+ `<path d="M0 ${-laenge} L0 ${laenge} M0 ${laenge} L${-3} ${laenge - 4} `
|
||||
+ `M0 ${laenge} L3 ${laenge - 4}"/></g>`;
|
||||
});
|
||||
}
|
||||
|
||||
return svg + "</g>";
|
||||
}
|
||||
|
||||
/** Wie viele Stunden übersprungen werden, damit nichts überlappt. */
|
||||
schritt(cfg, breiteStunde, schmal) {
|
||||
let s = cfg.alle || 3;
|
||||
while (s * breiteStunde < cfg.mindestAbstand && s < 24) s *= 2;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Die Werte einer Spur, gefiltert nach gemessen/vorhergesagt. */
|
||||
punkte(d, spur, wert) {
|
||||
const S = this.katalog.spalten;
|
||||
const aus = [];
|
||||
d.stunden.forEach((z, i) => {
|
||||
const v = wert(z, spur.spalte);
|
||||
if (v === null || v === undefined) return;
|
||||
const gemessen = !!z[S.gemessen];
|
||||
if (spur.nurGemessen && !gemessen) return;
|
||||
// Die Vorhersagespur nimmt die letzte gemessene Stunde mit, sonst
|
||||
// klafft am Jetzt-Strich eine Lücke zwischen beiden Linien.
|
||||
if (spur.nurVorhersage && gemessen) {
|
||||
const naechste = d.stunden[i + 1];
|
||||
if (!naechste || naechste[S.gemessen]) return;
|
||||
}
|
||||
aus.push([z[S.t], spur.skala ? spur.skala(v) : v]);
|
||||
});
|
||||
return aus;
|
||||
}
|
||||
|
||||
/** Pfad mit Lückenerkennung: über zwei Stunden Abstand wird nicht verbunden. */
|
||||
pfad(punkte, X, Y) {
|
||||
let d = "", zug = [];
|
||||
const zu = () => {
|
||||
if (zug.length) d += "M" + zug.map(p => p[0].toFixed(1) + " " + p[1].toFixed(1)).join("L");
|
||||
zug = [];
|
||||
};
|
||||
punkte.forEach((p, i) => {
|
||||
if (i && p[0] - punkte[i - 1][0] > 2 * 3600) zu();
|
||||
zug.push([X(p[0]), Y(p[1])]);
|
||||
});
|
||||
zu();
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Tagesminimum und -maximum an die Kurve schreiben. */
|
||||
minMax(d, spur, punkte, X, Y, grenze) {
|
||||
let svg = "";
|
||||
const nachTag = new Map();
|
||||
punkte.forEach(([t, v]) => {
|
||||
const tag = tagesBeginn(t);
|
||||
const e = nachTag.get(tag);
|
||||
if (!e) nachTag.set(tag, { min: [t, v], max: [t, v] });
|
||||
else {
|
||||
if (v < e.min[1]) e.min = [t, v];
|
||||
if (v > e.max[1]) e.max = [t, v];
|
||||
}
|
||||
});
|
||||
nachTag.forEach(e => {
|
||||
[[e.max, -7], [e.min, 13]].forEach(([p, dy]) => {
|
||||
// Am Rand nach innen ruecken: ein Tagesmaximum in der letzten
|
||||
// Stunde stand sonst halb ausserhalb des Bildes.
|
||||
const x = Math.min(grenze.rechts - 8, Math.max(grenze.links + 8, X(p[0])));
|
||||
svg += `<text class="mtg-marke" x="${x.toFixed(1)}" `
|
||||
+ `y="${(Y(p[1]) + dy).toFixed(1)}" text-anchor="middle" `
|
||||
+ `fill="${spur.farbe}">${Math.round(p[1])}</text>`;
|
||||
});
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
istNacht(d, t) {
|
||||
const T = this.katalog.tagSpalten;
|
||||
const tag = d.tage.find(x => t >= x[T.t] && t < x[T.t] + 86400);
|
||||
if (!tag) return false;
|
||||
return t < tag[T.auf] || t >= tag[T.unter];
|
||||
}
|
||||
|
||||
achsenZahl(v) {
|
||||
return Math.abs(v) >= 10 || Number.isInteger(v) ? String(Math.round(v)) : komma(v, 1);
|
||||
}
|
||||
|
||||
/** Der Streifen unten: Wochentag je Tag, bei zwei Tagen auch Stunden. */
|
||||
zeitachse(o) {
|
||||
const { d, X, L, R, B, H, U, schmal } = o;
|
||||
let svg = "";
|
||||
const stundenTicks = this.tage <= 2;
|
||||
for (let t = tagesBeginn(d.von); t < d.bis; t += 86400) {
|
||||
const a = Math.max(d.von, t), b = Math.min(d.bis, t + 86400);
|
||||
if (b <= a) continue;
|
||||
const mitte = (X(a) + X(b)) / 2;
|
||||
const tag = new Date(t * 1000);
|
||||
const text = TAGE[tag.getDay()] + (schmal ? "" : " " + tag.getDate() + ".");
|
||||
svg += `<text class="mtg-tagname" x="${mitte.toFixed(1)}" y="${H - U + 12}" `
|
||||
+ `text-anchor="middle">${text}</text>`;
|
||||
if (stundenTicks) {
|
||||
[6, 12, 18].forEach(h => {
|
||||
const x = X(t + h * 3600);
|
||||
if (x < L || x > B - R) return;
|
||||
svg += `<text class="mtg-stunde" x="${x.toFixed(1)}" y="${H - U + 22}" `
|
||||
+ `text-anchor="middle">${String(h).padStart(2, "0")}</text>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ Zeiger */
|
||||
|
||||
zeigerVerdrahten(o) {
|
||||
const { d, X, L, R, B, felder, wert } = o;
|
||||
const S = this.katalog.spalten;
|
||||
const svg = this.dom.bild.querySelector("svg");
|
||||
const zeiger = svg && svg.querySelector(".mtg-zeiger");
|
||||
const tipp = this.dom.tipp;
|
||||
if (!svg || !zeiger || !tipp) return;
|
||||
|
||||
const verstecken = () => {
|
||||
tipp.hidden = true;
|
||||
zeiger.setAttribute("x1", -10);
|
||||
zeiger.setAttribute("x2", -10);
|
||||
};
|
||||
|
||||
const zeigen = ev => {
|
||||
const r = svg.getBoundingClientRect();
|
||||
const x = (ev.clientX - r.left) / r.width * B;
|
||||
const t = d.von + (x - L) / (B - L - R) * (d.bis - d.von);
|
||||
let best = null;
|
||||
d.stunden.forEach(z => {
|
||||
if (!best || Math.abs(z[S.t] - t) < Math.abs(best[S.t] - t)) best = z;
|
||||
});
|
||||
if (!best || Math.abs(best[S.t] - t) > 2 * 3600) return verstecken();
|
||||
|
||||
const tx = X(best[S.t]);
|
||||
zeiger.setAttribute("x1", tx);
|
||||
zeiger.setAttribute("x2", tx);
|
||||
|
||||
const zeit = new Date(best[S.t] * 1000);
|
||||
const eintrag = this.katalog.wetter[wert(best, "code")];
|
||||
let html = `<b>${TAGE[zeit.getDay()]} ${zeit.getDate()}.${zeit.getMonth() + 1}., `
|
||||
+ `${uhrzeit(zeit)}</b>`
|
||||
+ `<em class="mtg-tipp-art">${best[S.gemessen] ? "gemessen" : "Vorhersage"}</em>`;
|
||||
if (eintrag) {
|
||||
html += `<span class="mtg-tipp-wetter"><i>${eintrag.glyph}</i>${schutz(eintrag.name)}</span>`;
|
||||
}
|
||||
this.katalog.felder.forEach(f => {
|
||||
f.spuren.forEach(spur => {
|
||||
if (spur.nurGemessen && !best[S.gemessen]) return;
|
||||
if (spur.nurVorhersage && best[S.gemessen]) return;
|
||||
const v = wert(best, spur.spalte);
|
||||
if (v === null || v === undefined) return;
|
||||
html += `<span><i style="background:${spur.farbe}"></i>${schutz(spur.name)}`
|
||||
+ `<em>${schutz(spur.format(v))}</em></span>`;
|
||||
});
|
||||
(f.baender || []).forEach(band => {
|
||||
const teile = band.schichten
|
||||
.map(s => [s.name, wert(best, s.spalte)])
|
||||
.filter(([, v]) => v !== null && v !== undefined);
|
||||
if (!teile.length) return;
|
||||
html += `<span><i class="mtg-band-probe" style="background:${band.farbe}"></i>`
|
||||
+ `${schutz(band.name)}<em>`
|
||||
+ teile.map(([n, v]) => n + " " + Math.round(v) + " %").join(" · ")
|
||||
+ `</em></span>`;
|
||||
});
|
||||
});
|
||||
tipp.innerHTML = html;
|
||||
tipp.hidden = false;
|
||||
const links = tx / B * r.width;
|
||||
tipp.style.left = Math.min(r.width - tipp.offsetWidth - 4,
|
||||
Math.max(4, links + 12)) + "px";
|
||||
};
|
||||
|
||||
svg.addEventListener("pointermove", zeigen);
|
||||
svg.addEventListener("pointerdown", zeigen);
|
||||
svg.addEventListener("pointerleave", verstecken);
|
||||
}
|
||||
}
|
||||
|
||||
window.Meteogramm = Meteogramm;
|
||||
})();
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user