diff --git a/IDEAS.md b/IDEAS.md
index 7637bef..598d5b9 100644
--- a/IDEAS.md
+++ b/IDEAS.md
@@ -187,7 +187,23 @@ Eines bauen, benutzen, dann entscheiden.
- **Speicherplatz** (Aether) — eine Zahl, ein Balken, billig zu bauen
- **Letzte Notizen** (InkNote) — braucht 1.3, kommt also später
-### Wetter in der Leiste, neben der Uhr — Marcs Idee (25.08.2026)
+### Wetter in der Leiste, neben der Uhr — ✅ gebaut (25.08.2026)
+
+**Steht.** Zwei Schalter in den Einstellungen, unabhängig voneinander: auf dem
+Schreibtisch, in der Leiste, beides oder keins. Die Stadt wird einmal gesucht
+und mit ihren Koordinaten gespeichert; der Server holt das Wetter und legt es
+zehn Minuten beiseite, die Oberflächen zeigen nur, was er hergibt.
+
+Offen geblieben ist eines mit Absicht: **Die Stadt lässt sich nur auf dem
+Schreibtisch ändern, nicht aus der Leiste heraus.** Ein Klick auf das Symbol
+führt dorthin. Der Grund ist derselbe wie bei allem anderen in der Leiste: Sie
+liest über den Schlüssel und darf nichts schreiben — und über eine Site-Grenze
+wüsste sie nicht, für wen sie es täte. Liegt alles unter einer Domain, wäre es
+nachrüstbar.
+
+Die ursprüngliche Notiz, zur Nachvollziehbarkeit:
+
+#### Wie es geplant war
Ein Pixelsymbol neben der Uhrzeit, dazu die Temperatur. Ein Klick öffnet ein
kleines Feld, in dem die Stadt eingestellt wird. Alles im Röhren-Look: Sonne,
diff --git a/README.md b/README.md
index 3ddb9ee..4972396 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,32 @@ in beiden: Menüleiste, Palette, Einstellungen, App-Liste.
**Tasten:** `1` `2` App starten · `M` Betriebsart · `S` Einstellungen · `?` Hilfe ·
`Esc` schließt Startmenü, oberstes Fenster oder Dialog — in dieser Reihenfolge.
+## Wetter
+
+Ein Pixelsymbol mit der Temperatur — wahlweise auf dem Schreibtisch, in der
+Leiste, in beidem oder nirgends. Zwei getrennte Schalter unter
+*Einstellungen → Wetter*, weil das zwei verschiedene Orte sind: Wer es beim
+Arbeiten in einer App sehen will, will es nicht zwangsläufig auch auf einer
+Seite, die er selten aufruft.
+
+Die Stadt wird dort einmal gesucht und **mit ihren Koordinaten** gespeichert;
+sonst müsste jede Abfrage den Ort erneut suchen — zwei Anfragen nach draußen
+statt einer. Ohne Stadt bleibt das Wetter aus.
+
+**Der Server holt es, nicht der Browser.** Die Leiste steht auf jeder Seite
+jeder App: Fragte sie selbst beim Wetterdienst an, ginge bei jedem Seitenaufruf
+aus jeder App eine Anfrage samt IP dorthin, und die CSP jeder App müsste den
+fremden Ursprung erlauben. So sieht der Dienst einen Server. Die Antwort gilt
+zehn Minuten — das Wetter ändert sich langsamer, als Seiten neu geladen werden.
+
+Verwendet wird [Open-Meteo](https://open-meteo.com): kein Zugangsschlüssel, kein
+Konto, nichts einzurichten. Ist der Dienst nicht erreichbar oder das Gerät
+offline, verschwindet das Symbol — lieber keins als eine Zahl von gestern.
+
+Ändern lässt sich die Stadt nur auf dem Schreibtisch; ein Klick auf das Symbol
+in der Leiste führt dorthin. Die Leiste liest über den Schlüssel und darf nichts
+schreiben.
+
## Die Leiste in Aether / InkNote
Eine Zeile ins `frontend/index.html` der App:
diff --git a/honeyos b/honeyos
new file mode 100755
index 0000000..64d3cf1
Binary files /dev/null and b/honeyos differ
diff --git a/main.go b/main.go
index f1dd464..5a693fd 100644
--- a/main.go
+++ b/main.go
@@ -14,6 +14,7 @@ import (
"log"
"net/http"
"os"
+ "strconv"
"strings"
"time"
)
@@ -27,7 +28,7 @@ const localSub = "local"
// Version ist die einzige Stelle, an der sie steht. build.sh liest sie hier aus
// und vergibt denselben Docker-Tag; die Oberfläche holt sie über /api/public.
// Vorher stand sie zusätzlich in os.js und lief mit den Image-Tags auseinander.
-const Version = "0.7"
+const Version = "0.8"
type Config struct {
Addr string
@@ -68,9 +69,10 @@ func (c Config) RedirectURL() string {
}
type Server struct {
- cfg Config
- auth *Auth
- store *Store
+ cfg Config
+ auth *Auth
+ store *Store
+ wetter *wetterDienst
}
func main() {
@@ -128,7 +130,7 @@ func main() {
log.Fatal(err)
}
- s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
+ s := &Server{cfg: cfg, auth: NewAuth(cfg), store: store, wetter: neuerWetterdienst()}
srv := &http.Server{
Addr: cfg.Addr,
@@ -161,6 +163,13 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
+ // Dieselbe Tür wie die App-Liste: Die Leiste steht in einer fremden App und
+ // kommt dort nur mit dem Schlüssel an Daten.
+ mux.HandleFunc("GET /api/weather", s.appsReadable(s.handleWeather))
+ // Die Ortssuche dagegen nur für Angemeldete. Sie reicht einen Suchbegriff an
+ // einen fremden Dienst weiter — offen stünde damit ein Weg, über HoneyOS
+ // beliebig dort anzufragen.
+ mux.HandleFunc("GET /api/weather/search", s.requireAuth(s.handleWeatherSearch))
web, err := fs.Sub(webFS, "web")
if err != nil {
@@ -339,6 +348,39 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request, sub string)
})
}
+// handleWeather liefert das Wetter für den eingestellten Ort — oder nichts.
+//
+// „Nichts" ist hier ein 204 und kein Fehler: Kein Ort eingestellt ist der
+// Normalfall, solange niemand einen gewählt hat, und die Leiste soll dafür
+// nicht rot blinken, sondern das Symbol einfach weglassen. Dasselbe gilt, wenn
+// der Dienst nicht erreichbar ist: lieber kein Wetter als eine Zahl von gestern.
+func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request, sub string) {
+ set := s.store.SettingsFor(sub)
+ lat, err1 := strconv.ParseFloat(set["weatherLat"], 64)
+ lon, err2 := strconv.ParseFloat(set["weatherLon"], 64)
+ if set["weatherCity"] == "" || err1 != nil || err2 != nil {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ wetter, err := s.wetter.Jetzt(r.Context(), set["weatherCity"], lat, lon)
+ if err != nil {
+ log.Printf("wetter: %v", err)
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ writeJSON(w, http.StatusOK, wetter)
+}
+
+func (s *Server) handleWeatherSearch(w http.ResponseWriter, r *http.Request, sub string) {
+ orte, err := s.wetter.Suche(r.Context(), r.URL.Query().Get("q"))
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "Ortssuche nicht erreichbar"})
+ return
+ }
+ writeJSON(w, http.StatusOK, orte)
+}
+
func (s *Server) handleSetApps(w http.ResponseWriter, r *http.Request, sub string) {
var in []App
if err := decode(r, &in); err != nil {
diff --git a/weather.go b/weather.go
new file mode 100644
index 0000000..fc19d1b
--- /dev/null
+++ b/weather.go
@@ -0,0 +1,210 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Wetter kommt vom Server, nicht aus dem Browser.
+//
+// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim
+// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt
+// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt
+// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt.
+//
+// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für
+// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand
+// einrichten muss, bevor irgendetwas zu sehen ist.
+const (
+ wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort
+ wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten
+)
+
+type Wetter struct {
+ Ort string `json:"ort"`
+ Grad float64 `json:"grad"`
+ Code int `json:"code"`
+ Text string `json:"text"`
+ Glyph string `json:"glyph"`
+ Stand string `json:"stand"`
+}
+
+type Ort struct {
+ Name string `json:"name"`
+ Land string `json:"land"`
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+}
+
+type wetterDienst struct {
+ mu sync.Mutex
+ cache map[string]wetterEintrag
+ client *http.Client
+
+ // Adressen als Feld, damit der Test einen eigenen Server unterschieben kann.
+ wetterURL string
+ sucheURL string
+}
+
+type wetterEintrag struct {
+ w Wetter
+ bis time.Time
+}
+
+func neuerWetterdienst() *wetterDienst {
+ return &wetterDienst{
+ cache: map[string]wetterEintrag{},
+ client: &http.Client{Timeout: wetterTimeout},
+ wetterURL: "https://api.open-meteo.com/v1/forecast",
+ sucheURL: "https://geocoding-api.open-meteo.com/v1/search",
+ }
+}
+
+// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text.
+// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert
+// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal
+// zwölf Pixeln ohnehin nichts unterscheiden.
+func wmo(code int) (glyph, text string) {
+ switch {
+ case code == 0:
+ return "sonne", "klar"
+ case code <= 2:
+ return "heiter", "heiter"
+ case code == 3:
+ return "wolke", "bedeckt"
+ case code == 45 || code == 48:
+ return "nebel", "nebel"
+ case code >= 51 && code <= 67, code >= 80 && code <= 82:
+ return "regen", "regen"
+ case code >= 71 && code <= 77, code == 85 || code == 86:
+ return "schnee", "schnee"
+ case code >= 95:
+ return "gewitter", "gewitter"
+ }
+ return "wolke", "wolken"
+}
+
+// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er
+// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
+// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
+// nach draußen.
+func (d *wetterDienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
+ schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
+
+ d.mu.Lock()
+ if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) {
+ d.mu.Unlock()
+ e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst
+ return e.w, nil
+ }
+ d.mu.Unlock()
+
+ u, _ := url.Parse(d.wetterURL)
+ q := u.Query()
+ q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64))
+ q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64))
+ q.Set("current", "temperature_2m,weather_code")
+ q.Set("timezone", "auto")
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return Wetter{}, err
+ }
+ res, err := d.client.Do(req)
+ if err != nil {
+ return Wetter{}, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode)
+ }
+
+ var antwort struct {
+ Current struct {
+ Temp float64 `json:"temperature_2m"`
+ Code int `json:"weather_code"`
+ } `json:"current"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
+ return Wetter{}, err
+ }
+
+ glyph, text := wmo(antwort.Current.Code)
+ w := Wetter{
+ Ort: ort,
+ Grad: antwort.Current.Temp,
+ Code: antwort.Current.Code,
+ Text: text,
+ Glyph: glyph,
+ Stand: time.Now().Format(time.RFC3339),
+ }
+
+ d.mu.Lock()
+ d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)}
+ d.mu.Unlock()
+ return w, nil
+}
+
+// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
+// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
+// jedes Mal erneut sucht.
+func (d *wetterDienst) Suche(ctx context.Context, name string) ([]Ort, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil, fmt.Errorf("kein Suchbegriff")
+ }
+
+ u, _ := url.Parse(d.sucheURL)
+ q := u.Query()
+ q.Set("name", name)
+ q.Set("count", "6")
+ q.Set("language", "de")
+ q.Set("format", "json")
+ u.RawQuery = q.Encode()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ res, err := d.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode)
+ }
+
+ var antwort struct {
+ Results []struct {
+ Name string `json:"name"`
+ Country string `json:"country"`
+ Admin1 string `json:"admin1"`
+ Lat float64 `json:"latitude"`
+ Lon float64 `json:"longitude"`
+ } `json:"results"`
+ }
+ if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
+ return nil, err
+ }
+
+ orte := make([]Ort, 0, len(antwort.Results))
+ for _, r := range antwort.Results {
+ land := r.Country
+ // „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als
+ // „Köln, Deutschland" — davon gibt es mehrere.
+ if r.Admin1 != "" && r.Admin1 != r.Name {
+ land = r.Admin1 + ", " + r.Country
+ }
+ orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon})
+ }
+ return orte, nil
+}
diff --git a/weather_test.go b/weather_test.go
new file mode 100644
index 0000000..b2f58ca
--- /dev/null
+++ b/weather_test.go
@@ -0,0 +1,150 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+// Ein erfundener Wetterdienst. Kein Test in diesem Projekt fragt je wirklich
+// bei Open-Meteo an: Das wäre langsam, unzuverlässig und würde einen fremden
+// Dienst für unsere Testläufe benutzen.
+func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *wetterDienst {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if aufrufe != nil {
+ atomic.AddInt32(aufrufe, 1)
+ }
+ if strings.Contains(r.URL.Path, "search") {
+ json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
+ {"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
+ "latitude": 50.938, "longitude": 6.96},
+ }})
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"current": map[string]any{
+ "temperature_2m": grad, "weather_code": code,
+ }})
+ }))
+ t.Cleanup(srv.Close)
+
+ d := neuerWetterdienst()
+ d.wetterURL = srv.URL + "/forecast"
+ d.sucheURL = srv.URL + "/search"
+ return d
+}
+
+func TestWetterKommtAn(t *testing.T) {
+ d := fakeWetter(t, 61, 12.4, nil)
+ w, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
+ if err != nil {
+ t.Fatalf("unerwartet: %v", err)
+ }
+ if w.Ort != "Köln" || w.Grad != 12.4 {
+ t.Fatalf("falsche Werte: %+v", w)
+ }
+ if w.Glyph != "regen" || w.Text != "regen" {
+ t.Fatalf("Code 61 ist Regen, nicht %q/%q", w.Glyph, w.Text)
+ }
+}
+
+// Der Zwischenspeicher ist der Grund, warum die Leiste auf jeder Seite jeder App
+// stehen darf: Ohne ihn ginge bei jedem Seitenaufruf eine Anfrage nach draußen.
+func TestZweiteFrageGehtNichtNachDraussen(t *testing.T) {
+ var aufrufe int32
+ d := fakeWetter(t, 0, 21, &aufrufe)
+ for i := 0; i < 5; i++ {
+ if _, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9); err != nil {
+ t.Fatalf("unerwartet: %v", err)
+ }
+ }
+ if aufrufe != 1 {
+ t.Fatalf("%d Anfragen nach draußen, erwartet 1", aufrufe)
+ }
+}
+
+func TestAndererOrtWirdGetrenntGehalten(t *testing.T) {
+ var aufrufe int32
+ d := fakeWetter(t, 0, 21, &aufrufe)
+ d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
+ d.Jetzt(context.Background(), "Wien", 48.2, 16.4)
+ if aufrufe != 2 {
+ t.Fatalf("zwei Orte, %d Anfragen", aufrufe)
+ }
+}
+
+func TestWettercodesWerdenZuSymbolen(t *testing.T) {
+ fälle := map[int]string{
+ 0: "sonne", 2: "heiter", 3: "wolke", 45: "nebel",
+ 61: "regen", 80: "regen", 73: "schnee", 86: "schnee", 95: "gewitter",
+ }
+ for code, will := range fälle {
+ if g, _ := wmo(code); g != will {
+ t.Errorf("Code %d → %q, erwartet %q", code, g, will)
+ }
+ }
+}
+
+func TestOrtssucheGibtKoordinatenZurueck(t *testing.T) {
+ d := fakeWetter(t, 0, 20, nil)
+ orte, err := d.Suche(context.Background(), "Köln")
+ if err != nil {
+ t.Fatalf("unerwartet: %v", err)
+ }
+ if len(orte) != 1 || orte[0].Lat == 0 || orte[0].Lon == 0 {
+ t.Fatalf("ohne Koordinaten müsste jede Abfrage neu suchen: %+v", orte)
+ }
+ // Bundesland statt Land: „Köln, Deutschland" gibt es mehrfach.
+ if !strings.Contains(orte[0].Land, "Nordrhein-Westfalen") {
+ t.Fatalf("Land zu grob: %q", orte[0].Land)
+ }
+}
+
+func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
+ var aufrufe int32
+ d := fakeWetter(t, 0, 20, &aufrufe)
+ if _, err := d.Suche(context.Background(), " "); err == nil {
+ t.Fatal("leerer Begriff müsste abgelehnt werden")
+ }
+ if aufrufe != 0 {
+ t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
+ }
+}
+
+// Ohne eingestellten Ort ist 204 die richtige Antwort: kein Fehler, nur nichts
+// zu zeigen. Die Leiste lässt das Symbol dann weg, statt etwas zu melden.
+func TestOhneOrtGibtEsKeinWetter(t *testing.T) {
+ s := newTestServer(t, "") // ohne Anbieter: offener Betrieb, kein Anmelden nötig
+ s.wetter = fakeWetter(t, 0, 20, nil)
+
+ rec := httptest.NewRecorder()
+ s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("ohne Ort erwartet 204, bekam %d", rec.Code)
+ }
+}
+
+func TestMitOrtKommtDasWetter(t *testing.T) {
+ s := newTestServer(t, "")
+ s.wetter = fakeWetter(t, 3, 8.6, nil)
+ if err := s.store.SetSettings(localSub, map[string]string{
+ "weatherCity": "Köln", "weatherLat": "50.938", "weatherLon": "6.96",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ rec := httptest.NewRecorder()
+ s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/api/weather", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("erwartet 200, bekam %d: %s", rec.Code, rec.Body)
+ }
+ var w Wetter
+ json.Unmarshal(rec.Body.Bytes(), &w)
+ if w.Ort != "Köln" || w.Glyph != "wolke" {
+ t.Fatalf("falsches Wetter: %+v", w)
+ }
+}
diff --git a/web/honeybar/honeybar.js b/web/honeybar/honeybar.js
index 4b6d087..ae084a2 100644
--- a/web/honeybar/honeybar.js
+++ b/web/honeybar/honeybar.js
@@ -51,6 +51,8 @@
// nur an Ursprünge, die selbst als App eingetragen sind.
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
const appsKey = d("key", "");
+ // Dieselbe Herkunft wie die App-Liste: Wo /api/apps liegt, liegt /api/weather.
+ const wetterUrl = appsUrl ? appsUrl.replace(/\/apps(\?|$)/, "/weather$1") : "";
let apps = DEFAULT_APPS;
let settings = null;
@@ -67,6 +69,33 @@
const SAFE = "env(safe-area-inset-top, 0px)";
const HOEHE = "calc(" + BAR_H + "px + " + SAFE + ")";
+ /* Wettersymbole als Pixelraster, dieselben Namen wie in weather.go.
+ Sie stehen auch in os.js — bis die geteilte Symbolquelle (1.3) gebaut ist,
+ ist das die kleinere Sünde: Die Leiste soll eine einzelne Datei bleiben,
+ die eine fremde App mit einer Zeile einbindet. */
+ const WX = {
+ sonne: "............;.....##.....;..#..##..#..;...#....#...;....####....;..##....##..;..##....##..;....####....;...#....#...;..#..##..#..;.....##.....;............",
+ heiter: "............;...##.......;..####..#...;...##..###..;......#####.;...####...#.;..#....##.#.;.##........#;#..........#;.##########.;............;............",
+ wolke: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;#..........#;#..........#;.##########.;............;............;............",
+ nebel: "............;....####....;..##....##..;.##.......#.;#..........#;.##########.;............;.#########..;............;..#########.;............;.#########..",
+ regen: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;............;..#..#..#...;.#..#..#....;..#..#..#...;.#..#..#....",
+ schnee: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;............;..#...#...#.;.#.#.#.#.#..;..#...#...#.;............",
+ gewitter: "............;....####....;...#....#...;..#......##.;.##........#;##.........#;.##########.;......##....;.....##.....;....####....;......##....;.....##.....",
+ };
+
+ function wxSvg(name) {
+ const grid = (WX[name] || WX.wolke).split(";");
+ let d = "";
+ grid.forEach((row, y) => {
+ let run = 0;
+ for (let x = 0; x <= row.length; x++) {
+ if (row[x] === "#") run++;
+ else if (run) { d += `