Regenradar

Habe mal das coole OpenWeatherMap-Modul von @demel42 als Grundlage genommen und ein Regenradar gezimmert.
Es werden animierte Wetter-Icons von Basmilus verwendet ( GitHub - basmilius/weather-icons: Free to use animated weather icons. )
Die Karte kommt von Openstreetmap (https://www.openstreetmap.org/)
Die Anzeige des Standortes auf der Karte wir durch das CSS von Leaflet ermöglicht (https://leafletjs.com)
Die Übelagerung der Karte durch den Regenradar wird durch Rainviewer ermöglicht (https://www.rainviewer.com/)

Unterstützung zur Installation befindet sich im Script
Die Wert oben mittig sind die aktuellen Werte aus Openweather oder alternativ von der eigenen Station (Im Script konfigurierbar).
Die Vorhersage hat noch nette Tooltips beim drüber hoovern

30.06.2025 Version 1.0:

  • Initiale Version

07.09.2025 Version 1.1

  • Responsives Design für verschiedene Kachelgrössen oder Handy-App

11.09.2025 Version 1.2

  • Vorhersage hinzugefügt

11.09.2025 Version 1.3

  • Responsives Design verbessert

  • Infos zur zyklischen Erneuerung aktualisiert

10.01.2026 Version 1.4

  • Auto-Play Funktion hinzugefügt (im Script aktivierbar)

12.01.2026 Version 1.5

  • Deckkraft voreinstellbar (im Script aktivierbar)

  • Fehler im responsiven Design für Handy-Darstellung behoben

11.02.2026 Version 1.6

  • Standard Zoom-Stufe auf 7 angepasst, da Rainviewer bei stärkerem Zoom keine Daten mehr liefert

  • Zusätzlich Map-maxZoom und Radar-maxZoom auf 7 begrenzt, damit keine leeren Tiles angefragt werden

  • Wiederholrate in Autoplay auf 5 Sekunden erhöht

24.02.2026 Version 1.7

  • Radar-Tiles pro Frame gecacht: für jede Zeitstufe wird ein Tile-Layer angelegt und behalten,

  • Beim Umschalten der Frames wird nur noch die Opacity geändert (keine erneuten Tile-Loads für alte Frames).

  • Dank Caching kann der Autoplay-Intervall wieder auf 1 Sekunde gesetzt werden.

  • Autoplay wird automatisch beendet, sobald der Benutzer zoomt.

  • Zoom-Abbruch von Autoplay wird erst nach abgeschlossener Initialisierung aktiv.

  • Rainviewer-Frames werden clientseitig immer zur Minute 1, 11, 21, 31, 41, 51 neu geladen, ohne die Seite neu zu laden.

  • Autoplay bleibt dabei erhalten. Es braucht kein Event mehr auf das Script gesetzt zu werden.

27.02.2026 Version 1.8

  • Radar-Caching-Logik auf offiziellen Rainviewer-Beispielcode umgestellt.
  • Nowcast entsprechend Rainviewer-API entfernt (nur noch past-Frames).
  • Bei fehlerhaften Tiles wird nun ein Timer statt der Zeit eingeblendet. Ein Klick auf diesen Timer lädt den Frame neu und die Tiles werden erneut gecacht. Ansonsten wird nach 1 Minute der Frame automatisch neu geladen.
  • Beim ersten manuellen Ausführen legt das Script automatisch ein zyklisches Ereignis an, welches das Script jeweils zur Minute 1, 11, 21, 31, 41 und 51 (alle 10 Minuten, Offset 1) ausführt.

28.02.2026 Version 1.9

  • Fehler beim anlegen des Events behoben, dass nur ein gewisser Zeitbereich pro Tag aktiviert wurde.

image

image863×866 137 KB

image

image472×287 64.7 KB

<?php

// ==========================================
// INFO zum Script:
// Damit die Wettervorhersage funktioniert, ist eine Anbindung an das Openweathermap-Modul (OpenWeaterOneCall) von @demel42 nötig und die Location ist im Modul zu konfigurieren.
// Es muss eine String-Variable vom Typ html-Box erstellt werden für die Anzeige in der Visualisierung.
// Es werden animierte Wetter-Icons von Basmilus verwendet (https://github.com/basmilius/weather-icons)
// Die Karte kommt von Openstreetmap (https://www.openstreetmap.org/)
// Die Anzeige des Standortes auf der Karte wird durch das CSS von Leaflet ermöglicht (https://leafletjs.com)
// Die Überlagerung der Karte durch den Regenradar wird durch Rainviewer ermöglicht (https://www.rainviewer.com/)
//
// ==========================================
//
// 30.06.2025 Version 1.0: 
// Initiale Version
//
// 07.09.2025 Version 1.1
// Responsives Design für verschiedene Kachelgrössen oder Handy-App
//
// 11.09.2025 Version 1.2
// Vorhersage hinzugefügt
//
// 11.09.2025 Version 1.3
// Responsives Design verbessert
// Infos zur zyklischen Erneuerung aktualisiert
//
// 10.01.2026 Version 1.4
// Auto-Play Funktion hinzugefügt (im Script aktivierbar)
//
// 12.01.2026 Version 1.5
// Deckkraft voreinstellbar (im Script aktivierbar)
// Fehler im responsiven Design für Handy-Darstellung behoben
//
// 11.02.2026 Version 1.6
// Standard Zoom-Stufe auf 7 angepasst, da Rainviewer bei stärkerem Zoom keine Daten mehr liefert
// Zusätzlich Map-maxZoom und Radar-maxZoom auf 7 begrenzt, damit keine leeren Tiles angefragt werden
// Wiederholrate in Autoplay auf 5 Sekunden erhöht
//
// 24.02.2026 Version 1.7
// Radar-Tiles pro Frame gecacht: für jede Zeitstufe wird ein Tile-Layer angelegt und behalten,
// beim Umschalten der Frames wird nur noch die Opacity geändert (keine erneuten Tile-Loads für alte Frames).
// Dank Caching kann der Autoplay-Intervall wieder auf 1 Sekunde gesetzt werden.
// Autoplay wird automatisch beendet, sobald der Benutzer zoomt.
// Zoom-Abbruch von Autoplay wird erst nach abgeschlossener Initialisierung aktiv.
// Rainviewer-Frames wurden clientseitig zyklisch neu geladen.
//
// 27.02.2026 Version 1.8
// Radar-Caching-Logik auf offiziellen Rainviewer-Beispielcode umgestellt.
// Nowcast entsprechend Rainviewer-API entfernt (nur noch past-Frames).
// Bei fehlerhaften Tiles wird nun ein Timer statt der Zeit eingeblendet. 
// Ein Klick auf diesen Timer lädt den Frame neu und die Tiles werden erneut gecacht.
// Ansonsten wird nach 1 Minunte der Frame automatisch neu geladen.
// Beim ersten manuellen Ausführen legt das Script automatisch ein zyklisches Ereignis an,
// welches das Script jeweils zur Minute 1, 11, 21, 31, 41 und 51 (alle 10 Minuten, Offset 1) ausführt.
//
// 28.02.2026 Version 1.9
// Fehler beim anlegen des Events behoben, dass nur ein gewisser Zeitbereich pro Tag aktiviert wurde.
//
// ==========================================

declare(strict_types=1);

// ==========================================
// KONFIGURATION
// ==========================================

$htmlBoxID = 57536;         // ~HTML-Box ID
$owmInstID = 42366;         // OpenWeatherOneCall-Instanz-ID

$autoplay = 0;              // 0 = aus, 1 = ein
$zoom = 7;                  // Karten-Zoomstufe (Rainviewer max. 7)

$opacityDefault = 0.50;     // <<< Deckkraft Startwert (0.00 - 1.00) >>>

// Eigene aktuelle Werte (optional), wenn ID = 0, dann werden die Daten aus OpenWeatherMap verwendet
$temperatureID = 54867;     // Temperatur-Variable ID
$humidityID    = 29783;     // Luftfeuchte-Variable ID
$windSpeedID   = 45190;     // Windstärke-Variable ID
$rain1hID      = 13080;     // Niederschlag 1h-Variable ID

// HELL oder DUNKEL — 'light' | 'dark'
$theme = 'dark';

// Clamp für Opacity (Sicherheit)
$opacityDefault = max(0.0, min(1.0, (float)$opacityDefault));

// ==========================================
// BASISDATEN (Koordinaten)
// ==========================================

$location = json_decode(IPS_GetProperty($owmInstID, 'location'), true);
$lat = $location['latitude'];
$lon = $location['longitude'];
$daily_forecast_count = (int) IPS_GetProperty($owmInstID, 'daily_forecast_count');

// ==========================================
// AKTUELLE WERTE HOLEN (direkt oder OWM)
// ==========================================

$temperature = ($temperatureID > 0)
    ? GetValueFormatted($temperatureID)
    : GetValueFormatted(IPS_GetObjectIDByIdent('Temperature', $owmInstID));

$humidity = ($humidityID > 0)
    ? GetValueFormatted($humidityID)
    : GetValueFormatted(IPS_GetObjectIDByIdent('Humidity', $owmInstID));

$wind_speed = ($windSpeedID > 0)
    ? GetValueFormatted($windSpeedID)
    : GetValueFormatted(IPS_GetObjectIDByIdent('WindSpeed', $owmInstID));

$rain_1h = ($rain1hID > 0)
    ? GetValueFormatted($rain1hID)
    : GetValueFormatted(IPS_GetObjectIDByIdent('Rain_1h', $owmInstID));

$clouds = GetValueFormatted(IPS_GetObjectIDByIdent('Cloudiness', $owmInstID));

// ==========================================
// AUTOPLAY
// ==========================================

$autoplay = ((int)$autoplay === 1);
$autoplayJs = $autoplay ? 'true' : 'false';

// ==========================================
// VORHERSAGE LADEN (immer OWM)
// ==========================================

$forecast = [];
for ($i = 0; $i < $daily_forecast_count; $i++) {
    $pre = 'DailyForecast';
    $post = '_' . sprintf('%02d', $i);
    $idSnow = @IPS_GetObjectIDByIdent($pre . 'Snow' . $post, $owmInstID);

    $forecast[] = [
        'dt' => GetValueInteger(IPS_GetObjectIDByIdent($pre . 'Begin' . $post, $owmInstID)),
        'min' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'TemperatureMin' . $post, $owmInstID)),
        'max' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'TemperatureMax' . $post, $owmInstID)),
        'wind' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'WindSpeed' . $post, $owmInstID)),
        'rain' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'Rain' . $post, $owmInstID)),
        'snow' => $idSnow ? GetValueFloat($idSnow) : 0,
        'icon' => GetValueString(IPS_GetObjectIDByIdent($pre . 'ConditionIcon' . $post, $owmInstID)),
        'description' => GetValueString(IPS_GetObjectIDByIdent($pre . 'Conditions' . $post, $owmInstID)),
        'humidity' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'Humidity' . $post, $owmInstID)),
        'clouds' => GetValueFloat(IPS_GetObjectIDByIdent($pre . 'Cloudiness' . $post, $owmInstID)),
    ];
}

$json = json_encode($forecast, JSON_UNESCAPED_SLASHES | JSON_HEX_APOS | JSON_HEX_QUOT);


// ==========================================
// Automatisches zyklisches Ereignis anlegen
// ==========================================
//
// Beim ersten "Ausführen" (Konsole: Rechtsklick -> Ausführen) wird ein
// zyklisches Ereignis erstellt, das das Script alle 10 Minuten aufruft.
// Die erste Ausführung wird auf die nächste Minute aus {1,11,21,31,41,51}
// gelegt, sodass die Laufzeiten dann dauerhaft auf diesen Minuten liegen.
//

if ($_IPS['SENDER'] === 'Execute') {
    $eventName = 'Radar & Forecast Auto-Update';

    $eventID = @IPS_GetEventIDByName($eventName, $_IPS['SELF']);
    if ($eventID === false) {
        // Zyklisches Ereignis erstellen
        $eventID = IPS_CreateEvent(1); // 1 = cyclic
        IPS_SetName($eventID, $eventName);
        IPS_SetParent($eventID, $_IPS['SELF']);
        IPS_SetEventAction($eventID, "{7938A5A2-0981-5FE0-BE6C-8AA610D654EB}", []);
        IPS_SetEventCyclic($eventID, 2, 1, 0, 0, 2, 10);

        // Erste Ausführung auf nächste Minute 1,11,21,31,41,51 legen
        $now  = time();
        $min  = (int)date('i', $now);
        $hour = (int)date('H', $now);

        $targets = [1, 11, 21, 31, 41, 51];
        $nextMin  = 1;
        $nextHour = $hour;
        $found    = false;

        foreach ($targets as $t) {
            if ($t > $min) {
                $nextMin  = $t;
                $nextHour = $hour;
                $found    = true;
                break;
            }
        }

        if (!$found) {
            // auf nächste Stunde, Minute 1
            $nextHour++;
            if ($nextHour >= 24) {
                $nextHour = 0;
            }
            $nextMin = 1;
        }

        // Zeitfenster: 00:01 bis 23:59 (ganzer Tag)
        $startTs = mktime(0, 1, 0);
        $endTs   = mktime(23, 59, 0);

        IPS_SetEventCyclicTimeBounds($eventID, $startTs, $endTs);

        // Ereignis aktivieren
        IPS_SetEventActive($eventID, true);
    }
}

// ==========================================
// HTML + JS + CSS
// ==========================================

$html = <<<HTML
<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <title>Radar & Forecast</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
  <style>
    html, body, #map { height: 100%; margin: 0; }

    body.theme-dark{
      --bg: rgba(40,40,40,0.85); --text:#fff; --panel:#2b2b2b; --border:#444;
      --btn-bg:#333; --btn-fg:#fff; --btn-border:#999;
    }
    body.theme-light{
      --bg: rgba(255,255,255,0.9); --text:#222; --panel:#fff; --border:#ccc;
      --btn-bg:#fff; --btn-fg:#000; --btn-border:#ddd;
    }

    :root {
      --k: 1;
      --fs: calc(12px * var(--k));
      --fs-small: calc(11px * var(--k));
      --fs-tiny: calc(10px * var(--k));
      --pad: calc(8px * var(--k));
      --gap: calc(8px * var(--k));
      --radius: calc(10px * var(--k));
      --shadow: 0 calc(4px * var(--k)) calc(14px * var(--k)) rgba(0,0,0,.25);

      --icon: calc(20px * var(--k));
      --forecast-icon: calc(40px * var(--k));
      --legend-swatch-w: calc(18px * var(--k));
      --legend-swatch-h: calc(12px * var(--k));

      --controls-w: calc(220px * var(--k));
      --range-h: calc(22px * var(--k));
      --btn-pad-v: calc(8px * var(--k));
    }

    body { background: var(--bg); color: var(--text); font-family: system-ui, Segoe UI, Roboto, sans-serif; font-size: var(--fs); }

    .panel { background: var(--bg); color: var(--text); border-radius: var(--radius); padding: var(--pad); box-shadow: var(--shadow); backdrop-filter: blur(4px); }
    #current, #controls, #legend, #forecast { position: absolute; z-index: 1000; }
    #map { z-index: 0; }

    #current { top: 10px; left: 50%; transform: translateX(-50%); display: flex; gap: calc(var(--gap) + 6px); align-items: center; }
    #controls{ top: 10px; right: 10px; width: var(--controls-w); }
    #legend  { bottom: 10px; left: 10px; }
    #forecast{ bottom: 10px; right: 10px; display: flex; gap: var(--gap); padding: calc(var(--pad) - 2px) calc(var(--pad)); }

    #controls .row { display:flex; gap: calc(var(--gap) - 2px); justify-content: space-between; margin-bottom: calc(var(--gap) - 2px); }
    #controls button{
      flex:1; display:flex; align-items:center; justify-content:center;
      padding: var(--btn-pad-v) 0; font-size: var(--fs); line-height:1; gap:6px; min-width:0;
      border-radius: calc(var(--radius) - 2px);
      background: var(--btn-bg); color: var(--btn-fg); border: 1px solid var(--btn-border);
      -webkit-appearance:none; appearance:none; background-image:none; box-shadow:none;
      cursor: pointer;
    }
    #controls button:hover { filter: brightness(1.08); }
    #controls label { display:block; font-size: var(--fs-small); margin: 6px 0 2px; }
    #controls input[type=range]{ width:100%; height: var(--range-h); }
    #frameTime { display:block; margin-top:6px; font-weight:600; text-align:center; font-size: var(--fs-small); }

    .ico { width: 14px; height: 14px; display:inline-block; }
    .ico svg { width: 100%; height: 100%; display:block; fill: currentColor; }

    @media (min-width: 701px) and (max-width: 1300px) {
      #current {
        display: inline-flex;
        width: max-content;
        min-width: 340px;
        max-width: 560px;
      }
      #current-values { flex: 0 0 auto; }
    }

    @media (min-width: 540px) {
      #controls { width: 130px; }
      #controls .row {
        display: grid; grid-template-columns: repeat(3, 1fr);
        column-gap: 2px; margin-bottom: 6px;
      }
      #controls button { border-radius: 2px; padding: 4px 0; font-size: 12px; }
      #controls label  { font-size: 12px; margin: 4px 0 2px; }
      #controls input[type=range]{ height: 18px; }
      #frameTime { font-size: 12px; margin-top: 4px; }
      .ico { width: 12px; height: 12px; }
    }

    @media (max-width: 700px) and (min-width: 540px) {
      #current{
        top: 8px;
        left: 56px;
        right: auto;
        transform: none;
        display: inline-flex;
        width: max-content;
        min-width: 0;
        max-width: calc(100vw - 56px - 16px - (130px + 16px));
        justify-content: flex-end;
        text-align: right;
        flex-wrap: wrap;
        gap: 6px;
      }
    }

    @media (max-width: 539px) {
      #current {
        top: 8px;
        left: auto;
        right: 8px;
        transform: none;
        display: inline-flex;
        width: max-content;
        min-width: 0;
        max-width: calc(100vw - 56px - 16px);
        justify-content: flex-end;
        text-align: right;
        flex-wrap: wrap;
        gap: 6px;
      }
      #controls{ left: auto; right: 8px; width: 120px; }
      #controls .row { gap: 4px; margin-bottom: 6px; display:flex; }
      #controls button { padding: 6px 0; font-size: 11px; border-radius: 6px; }
      #controls label  { font-size: 10px; margin: 4px 0 2px; }
      #controls input[type=range]{ height: 16px; }
      #frameTime { font-size: 10px; margin-top: 4px; }
    }

    #forecast { border-radius: var(--radius); }
    .forecast-entry { text-align:center; }
    .forecast-entry img { width: var(--forecast-icon); height: var(--forecast-icon); }
    .forecast-entry .day { font-weight:600; margin-bottom: 2px; font-size: var(--fs); }
    .forecast-entry .temp{ font-size: var(--fs-small); opacity:.9; }

    .tooltip {
      position: fixed;
      background: var(--bg);
      color: var(--text);
      font-size: var(--fs-small);
      padding: calc(var(--pad) - 2px) calc(var(--pad));
      border-radius: var(--radius);
      box-shadow: 0 calc(8px * var(--k)) calc(18px * var(--k)) rgba(0,0,0,.28);
      white-space: normal;
      line-height: 1.25;
      max-width: calc(220px * var(--k));
      z-index: 2000;
    }

    #current div { display:flex; align-items:center; gap: calc(6px * var(--k)); font-size: var(--fs); white-space: nowrap; }
    #current img { width: var(--icon); height: var(--icon); }

    #legend { font-size: var(--fs-tiny); }
    #legend .legend-entry { display:flex; align-items:center; gap: calc(var(--gap) - 4px); margin-bottom: calc(4px * var(--k)); }
    #legend .legend-color { width: var(--legend-swatch-w); height: var(--legend-swatch-h); border: 1px solid var(--border); }
  </style>
</head>
<body class="theme-{$theme}">
  <div id="map"></div>

  <div id="current" class="panel">
    <div><img src="https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/thermometer.svg" alt=""> {$temperature}</div>
    <div><img src="https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/humidity.svg" alt=""> {$humidity}</div>
    <div><img src="https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/wind.svg" alt=""> {$wind_speed}</div>
    <div><img src="https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/rain.svg" alt=""> {$rain_1h}</div>
    <div><img src="https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/cloudy.svg" alt=""> {$clouds}</div>
  </div>

  <div id="controls" class="panel">
    <div class="row">
      <button id="btnPrev" title="Vorheriger Frame" aria-label="Vorheriger">
        <span class="ico" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M15.5 5.5 8.5 12l7 6.5-1.5 1.5L5.5 12l8.5-8.5 1.5 2z"/></svg>
        </span>
      </button>
      <button id="btnPlay" title="Abspielen" aria-label="Abspielen">
        <span class="ico" aria-hidden="true"></span>
      </button>
      <button id="btnNext" title="Nächster Frame" aria-label="Nächster">
        <span class="ico" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="m8.5 5.5 1.5-2L18.5 12l-8.5 8.5-1.5-1.5 7-6.5-7-6.5z"/></svg>
        </span>
      </button>
    </div>
    <label for="frameSlider">Zeit</label>
    <input id="frameSlider" type="range" min="0" max="0" step="1" value="0" />
    <label for="opacityRange">Deckkraft</label>
    <input id="opacityRange" type="range" min="0" max="1" step="0.01" value="{$opacityDefault}" />
    <span id="frameTime">⏳</span>
  </div>

  <div id="legend" class="panel">
    <div class="legend-entry"><div class="legend-color" style="background:#b3d9ff;"></div> Sehr leicht</div>
    <div class="legend-entry"><div class="legend-color" style="background:#3399ff;"></div> Leicht</div>
    <div class="legend-entry"><div class="legend-color" style="background:#0066ff;"></div> Mäßig</div>
    <div class="legend-entry"><div class="legend-color" style="background:#cc3300;"></div> Stark</div>
    <div class="legend-entry"><div class="legend-color" style="background:#990099;"></div> Extrem</div>
  </div>

  <div id="forecast" class="panel"></div>

  <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
  <script>
    // Skalierung (Baseline 780px, Cap 1.0)
    (function scaleByTile(){
      function computeK(){
        const w = document.documentElement.clientWidth || window.innerWidth || 800;
        const k = Math.max(0.75, Math.min(1.0, w / 780));
        document.documentElement.style.setProperty('--k', k.toFixed(3));
        if (window.map && window.map.invalidateSize) setTimeout(() => window.map.invalidateSize(), 0);
      }
      computeK();
      window.addEventListener('resize', computeK);
    })();

    const LAT = {$lat};
    const LON = {$lon};

    window.map = L.map('map', {
      zoomControl: true,
      maxZoom: 7
    }).setView([LAT, LON], {$zoom});

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{
      maxZoom: 7
    }).addTo(window.map);

    L.marker([LAT, LON]).addTo(window.map).bindPopup("Standort");

    // ================================
    // Rainviewer Caching / Animation
    // ================================

    const TILE_SIZE = (window.devicePixelRatio >= 2 ? 512 : 256);
    let RADAR_OPACITY = parseFloat(document.getElementById('opacityRange').value);
    const ANIMATION_DELAY_MS = 1000; // 1 Sekunde pro Frame

    let apiData = {};
    let mapFrames = [];
    let animationPosition = 0;
    let animationTimer = false;
    let currentLayer = null;
    let isLoading = false;
    let layerCache = {};

    let radarRetryTimeout = null;
    let radarRetryInterval = null;  
    let resumeAutoplayAfterError = false;

    const frameSlider = document.getElementById('frameSlider');
    const opacitySlider = document.getElementById('opacityRange');
    const timeDisplay  = document.getElementById('frameTime');
    const btnPlay      = document.getElementById('btnPlay');
    const btnPrev      = document.getElementById('btnPrev');
    const btnNext      = document.getElementById('btnNext');

    const AUTOPLAY = {$autoplayJs};

    timeDisplay.style.cursor = "pointer";
    timeDisplay.addEventListener('click', function(){
      const txt = (timeDisplay.textContent || "").toLowerCase();
      if (txt.indexOf("radar") !== -1) {
        location.reload();
      }
    });

    function showRadarError(message) {

    // Merken ob Autoplay lief
    resumeAutoplayAfterError = !!animationTimer;

    stop();

    // Falls bereits ein Retry läuft → abbrechen
    if (radarRetryTimeout) {
        clearTimeout(radarRetryTimeout);
        radarRetryTimeout = null;
    }
    if (radarRetryInterval) {
        clearInterval(radarRetryInterval);
        radarRetryInterval = null;
    }

    let secondsLeft = 60;

    // Sofort anzeigen
    timeDisplay.textContent = message + " – Reload in " + secondsLeft + "s";

    // Jede Sekunde Countdown aktualisieren
    radarRetryInterval = setInterval(function () {
        secondsLeft--;
        if (secondsLeft > 0) {
        timeDisplay.textContent = message + " – Reload in " + secondsLeft + "s";
        }
    }, 1000);

    // Nach 60 Sekunden neu laden
    radarRetryTimeout = setTimeout(function () {

        // Countdown stoppen
        clearInterval(radarRetryInterval);
        radarRetryInterval = null;
        radarRetryTimeout = null;

        timeDisplay.textContent = "⏳ Radar wird neu geladen...";

        // Layer + Cache sauber aufräumen
        try {
        if (currentLayer) {
            window.map.removeLayer(currentLayer);
            currentLayer = null;
        }
        } catch (e) {
        console.warn(e);
        }

        layerCache = {};
        isLoading = false;

        loadApiData();

    }, 60 * 1000);
    }

    const playSvg  = '<svg viewBox="0 0 24 24"><path d="M8 5l12 7-12 7V5z"/></svg>';
    const pauseSvg = '<svg viewBox="0 0 24 24"><path d="M8 5h3v14H8V5zm5 0h3v14h-3V5z"/></svg>';
    function setPlayState(isPlaying){
      btnPlay.title = isPlaying ? 'Pause' : 'Abspielen';
      btnPlay.setAttribute('aria-label', isPlaying ? 'Pause' : 'Abspielen');
      btnPlay.querySelector('.ico').innerHTML = isPlaying ? pauseSvg : playSvg;
    }
    setPlayState(false);

    function wrapPosition(position) {
      const len = mapFrames.length;
      if (!len) return 0;
      while (position >= len) position -= len;
      while (position < 0) position += len;
      return position;
    }

    function formatFrameTime(ts) {
      return new Date(ts * 1000).toLocaleTimeString();
    }

    function updateTimestamp(frame) {
      if (!frame) {
        timeDisplay.textContent = "Keine Radarframes";
      } else {
        timeDisplay.textContent = formatFrameTime(frame.time);
      }
    }

    function createRadarLayer(frame) {
      return L.tileLayer(
        apiData.host + frame.path + "/" + TILE_SIZE + "/{z}/{x}/{y}/2/1_1.png",
        {
          tileSize: 256,
          opacity: 0.001,
          maxNativeZoom: 7,
          maxZoom: 7
        }
      );
    }

    function clearLayerCache() {
      for (var pos in layerCache) {
        if (layerCache.hasOwnProperty(pos)) {
          if (parseInt(pos, 10) !== animationPosition) {
            try { window.map.removeLayer(layerCache[pos]); } catch(e){}
            delete layerCache[pos];
          }
        }
      }
    }

    function stop() {
      if (animationTimer) {
        clearTimeout(animationTimer);
        animationTimer = false;
        setPlayState(false);
        return true;
      }
      return false;
    }

    function play() {
      animationTimer = true;
      setPlayState(true);
      showFrame(animationPosition + 1);
    }

    function playStop() {
      if (!stop()) {
        play();
      }
    }

    function showFrame(position) {
      if (isLoading || !mapFrames.length) return;

      position = wrapPosition(position);
      const frame = mapFrames[position];

      updateTimestamp(frame);

      const oldLayer = currentLayer;

      if (layerCache[position]) {
        if (oldLayer) {
          oldLayer.setOpacity(0);
        }
        layerCache[position].setOpacity(RADAR_OPACITY);
        currentLayer = layerCache[position];
        animationPosition = position;
        frameSlider.value = position;

        if (animationTimer) {
          animationTimer = setTimeout(play, ANIMATION_DELAY_MS);
        }
        return;
      }

      isLoading = true;

      const newLayer = createRadarLayer(frame);

      let tileErrors = 0;
      let tileLoads  = 0;
      let errorReported = false;

      newLayer.on('load', function() {
        tileLoads++;

        if (!errorReported) {
          newLayer.setOpacity(RADAR_OPACITY);
          if (oldLayer) {
            oldLayer.setOpacity(0);
          }
          layerCache[position] = newLayer;
          currentLayer = newLayer;
          animationPosition = position;
          frameSlider.value = position;
        }

        isLoading = false;

        if (animationTimer && !errorReported) {
          animationTimer = setTimeout(play, ANIMATION_DELAY_MS);
        }
      });

      newLayer.on('tileerror', function(e) {
        tileErrors++;
        console.warn('Rainviewer tile error', e);

        if (!errorReported && tileErrors >= 4 && tileLoads === 0) {
          errorReported = true;
          isLoading = false;
          try { window.map.removeLayer(newLayer); } catch(e){}
          if (currentLayer === newLayer) currentLayer = null;
          showRadarError("Radar-Fehler");
        }
      });

      newLayer.addTo(window.map);
    }

    function initialize(api) {
      clearLayerCache();

      if (currentLayer) {
        try { window.map.removeLayer(currentLayer); } catch(e){}
        currentLayer = null;
      }
      mapFrames = [];
      animationPosition = 0;

      if (!api || !api.radar || !api.radar.past) {
        showRadarError("Keine Radarframes");
        frameSlider.max = 0;
        frameSlider.value = 0;
        return;
      }

      const past = api.radar.past || [];
      mapFrames = past;

      if (!mapFrames.length) {
        showRadarError("Keine Radarframes");
        frameSlider.max = 0;
        frameSlider.value = 0;
        return;
      }

      frameSlider.max = mapFrames.length - 1;

      // letzter Past-Frame = "live"
      animationPosition = mapFrames.length - 1;
      showFrame(animationPosition);
    }

    function loadApiData() {
    fetch("https://api.rainviewer.com/public/weather-maps.json")
        .then(r => r.json())
        .then(data => {
        apiData = data;
        initialize(apiData);

        // Autoplay starten:
        // - wenn es per Config aktiv ist (AUTOPLAY)
        // - oder wenn es vor dem Fehler lief (resumeAutoplayAfterError)
        if ((AUTOPLAY || resumeAutoplayAfterError) && mapFrames.length && !animationTimer) {
            resumeAutoplayAfterError = false;
            play();
        }
        })
        .catch(err => {
        console.error(err);
        showRadarError("Radar lädt nicht");
        });
    }

    // Buttons & Slider
    btnPrev.addEventListener('click', function(){
      stop();
      showFrame(animationPosition - 1);
    });
    btnNext.addEventListener('click', function(){
      stop();
      showFrame(animationPosition + 1);
    });
    btnPlay.addEventListener('click', function(){
      playStop();
    });

    frameSlider.addEventListener('input', function(e){
      const idx = parseInt(e.target.value, 10);
      stop();
      showFrame(idx);
    });

    opacitySlider.addEventListener('input', function(){
      RADAR_OPACITY = parseFloat(opacitySlider.value);
      if (currentLayer) currentLayer.setOpacity(RADAR_OPACITY);
    });

    // Zoom UND Verschieben brechen Autoplay ab
    window.map.on('zoomstart', function () {
      stop();
    });
    window.map.on('movestart', function () {
      stop();
      clearLayerCache();
    });

    // Radar einmalig beim Laden holen
    loadApiData();

    // Forecast unten
    const forecast = {$json};
    const ICON_URL = "https://raw.githubusercontent.com/basmilius/weather-icons/dev/production/fill/svg/";
    const iconMap = {
      "01d":"clear-day","01n":"clear-night",
      "02d":"partly-cloudy-day","02n":"partly-cloudy-night",
      "03d":"cloudy","04d":"overcast",
      "09d":"rain","09n":"rain","10d":"rain","10n":"rain",
      "11d":"thunderstorms-day","11n":"thunderstorms-night",
      "13d":"snow","13n":"snow",
      "50d":"fog","50n":"fog"
    };

    const box = document.getElementById("forecast");
    forecast.forEach(f => {
      const d = new Date(f.dt * 1000);
      const day = d.toLocaleDateString("de-CH", { weekday: "short" });
      const mappedIcon = iconMap[f.icon] || "not-available";
      const icon = ICON_URL + mappedIcon + ".svg";

      const entry = document.createElement("div");
      entry.className = "forecast-entry";
      entry.innerHTML =
        "<div class='day'>" + day + "</div>" +
        "<img alt='' src='" + icon + "'>" +
        "<div class='temp'>" + Math.round(f.max) + "°/" + Math.round(f.min) + "°</div>";

      entry.addEventListener("mouseenter", () => {
        const t = document.createElement("div");
        t.className = "tooltip";
        t.innerHTML =
          "<b style='display:block; margin-bottom:4px;'>" + f.description + "</b>" +
          "<div>💧 " + f.humidity + " %</div>" +
          "<div>🌡️ " + Math.round(f.max) + "° / " + Math.round(f.min) + "°</div>" +
          "<div>💨 " + Math.round(f.wind) + " km/h</div>" +
          (f.rain > 0 ? "<div>🌧️ " + f.rain.toFixed(1) + " mm</div>" : "") +
          (f.snow > 0 ? "<div>❄️ " + f.snow.toFixed(1) + " mm</div>" : "") +
          "<div>☁️ " + Math.round(f.clouds) + " %</div>";

        document.body.appendChild(t);

        const rect   = entry.getBoundingClientRect();
        const margin = 8;
        const tRect  = t.getBoundingClientRect();

        let left = rect.left + rect.width / 2 - tRect.width / 2;
        if (left < margin) left = margin;
        const maxLeft = window.innerWidth - margin - tRect.width;
        if (left > maxLeft) left = maxLeft;

        t.style.left = left + "px";
        t.style.top  = (rect.top - 8) + "px";
        t.style.transform = "translate(0, -100%)";
      });

      entry.addEventListener("mouseleave", () => {
        const t = document.querySelector(".tooltip");
        if (t) t.remove();
      });

      box.appendChild(entry);
    });

    (function placeControlsOnPhone(){
      const ctr = document.getElementById('controls');
      const cur = document.getElementById('current');
      if (!ctr || !cur) return;
      function update(){
        const isPhone = window.matchMedia("(max-width: 539px)").matches;
        if (isPhone) {
          const r = cur.getBoundingClientRect();
          const top = r.top + r.height + 8 + window.scrollY;
          ctr.style.top = top + "px";
        } else {
          ctr.style.top = "10px";
        }
        if (window.map && window.map.invalidateSize) setTimeout(() => window.map.invalidateSize(), 0);
      }
      update();
      window.addEventListener('resize', update);
      try { new ResizeObserver(update).observe(document.documentElement); } catch(e){}
    })();
  </script>
</body>
</html>
HTML;

SetValueString($htmlBoxID, $html);
1 „Gefällt mir“

Vielen Dank! Funktioniert!
Frage: Wie bekommst du eine mehrtätige Vorschau? Ich habe hier nur 1 Tag.

Update: Hat sich erledigt, war eine Einstellung bei OpenWeatherMap OnceCall.

Update: Doch nicht ganz: Schaut komisch aus bei mir. Bei 6 Tagen:

lg

Hast du OpenWeather so eingestellt?

Danke für die Anpassungen, ich habe am letzten Sonntag einen längeren Austausch mit copilot gehabt, leider nicht erfolgreich mit der Cache Umsetzung.

Deine Lösung scheint ja zu funktionieren.

Die fehlende Vorhersage unten rechts liegt an der “täglichen Vorhersage”, die ich auch gerade eingestellt habe.

Wie oft lässt du das Script laufen zur Aktualisierung?

Rainviewer-Frames werden clientseitig immer zur Minute 1, 11, 21, 31, 41, 51 neu geladen, ohne die Seite neu zu laden.

das passiert ja nicht von allein.

Exakt so:

und sieht so aus:

glg

Es gibt nun Version 1.8 des Scripts

Ich habe wieder auf ein Ereignis umgestellt, welches den Script zur Minute 1, 11, 21, 31, 41 und 51 neu lädt. Das Ereignis wird automatisch erstellt.

Dieselbe Ursache dürfte auch dieses Problem haben. Die Radar-Tiles wurden zwar geladen, aber die Vorhersage nicht mehr

Ich muss Version 1.9 nachschieben wegen eines Fehlers beim erstellen des Events, er wurde nur in einem gewissen Zeitbereich pro Tag ausgeführt.

1 „Gefällt mir“

Erst mal: Super arbeit von Dir, habs installiert, klasse!!!

Jetzt mal ne Frage zum Script: Hab noch ne ältere Version. Mit der 1.9 muss ich kein Ereignis / Automation erstellen um das Script periodisch auszuführen? Macht das Script selber?

Danke Dir

LG Harald

Ja, musst aber schauen dass du von der älteren Version nicht schon ein Event hast, eventuell vor dem Ausführen des neuen Scripts zuerst löschen, dann sollte der neue Event direkt unterhalb des Scriptes erstellt werden

Danke! läuft!

LG Harald

Vielen Dank fuer das Script. Nach dem Update auf OpenWeatherMap auf API V3 alles OK.
Aber im Kopf vom Script:

Es muss eine Integer-Variable vom Typ html-Box erstellt werden

Denke das ist falsch.

1 „Gefällt mir“

Danke für den Hinweis :joy:

vielen Dank für das Script. Hat alles ohne Fehlermeldung geklappt.

Aber ist es richtig das nur die letzten zwei Stunden in der Vergangenheit angezeigt werden ?

Ja mehr gibt Rainviewer nicht her…

Es gibt eine Möglichkeit "We**er Online” als Vollbild aufzurufen, das lässt sich sogar integrieren :innocent: .

Hast du mehr Infos dazu und etwas gemacht?

Und nicht nur DE :wink:

1 „Gefällt mir“

ja, aber besser –>PM

Es gibt auch die Vorschau von morgenwirdes.de für die nächsten zwei Stunden

basierend auf den DWD Daten und nur dür DE.

Schade das mit der OpenWeatherMap nicht vorausschauen kann.

Morgenwirdes habe ich bis jetzt auch im Einsatz. Sieht hat nur nicht so schick aus wie die OpenWeahterMap Variante.

Wobei diene die Ansicht von Ralf mit Morgenwirdes auch schon besser aus sieht als meine.

OpenWeather kann das schon, ist ja für die Vorhersage zuständig. Das Problem ist Rainviewer, von da kommt der Regenradar. Bis ende letztes Jahr waren noch 20min Vorhersage möglich, das wurde mit anderen Features aus der API entfernt, um mehr Ressourcen für ihre eigen App zu haben.

1 „Gefällt mir“