Dynamic "Live" Earth View Web Card

Hi all.

I created a dynamic live Earth view script for the Home Assistant web card (iFrame).

I always wanted to have a desktop background of a ‘live’ view of the Earth (the files are delayed by a couple of hours) to see the weather across the globe, so I hacked this together.

What’s in the box?

  • An HTML file containing the CSS and JS required to display the card
  • HA Card YAML
  • Update to configuration.yaml

How does it work?

The code fetches two northern hemisphere tiles from the Meteosat-12 satellite in geostationary orbit centered on northern Europe and Africa via the colostate.edu website.

The code also fetches the country and continent outline images and overlays them onto the satellite imagery tiles.

The most recent image is found and the previous X number of images (this value can be changed in the code) are preloaded, presented, and animated on a loop.

There is a hidden opacity slider in the bottom-right to allow the country and continent outline layer to be hidden (I don’t want to see this all the time, sometimes I just want to enjoy the blue marble…). Just hover your pointer over that area.

There are also transport controls hidden in the code by CSS (back, forwards, play/pause, animation speed), you can enable them by removing the display:none in the CSS.

It is all presented in a Web Page iFrame card on the dashboard and sized to 100% width and whatever height (I chose 7 or 8).

You need to enable the time sensor in HA, add the following to your configuration.yaml. This is to allow time codes to be used as unique query paramaters for cache busting purposes in HA:

# Enabling Time Sensor for cache-busting in the Meteosat Earth animation tile.
sensor:
  - platform: time_date
    display_options:
      - time

Here is how I add the card in HA, you can do whatevers:

type: iframe
url: >-
  /local/meteosat12_card.html?v={% raw %}{{ now().timestamp() | int }}{% endraw
  %}
aspect_ratio: 100%
hide_background: false
grid_options:
  columns: full
  rows: 7

I think that’s it. The cache-busting can be a bit sketchy, so if anyone has any improvements on that, let me know. Seems to work okay for me generally. I tested on FF, Chrome, Opera, Edge.

All thoughts, comments, criticisms warmly received!

I can’t attach the HTML file, so you’ll have to add the following code yourself (apologies for the length!). Place the HTML file with the below code into your /config/www folder:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-control" content="no-store">
<title>Meteosat-12 GeoColor</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: #0a0e14;
    font-family: 'Courier New', monospace;
    overflow: hidden;
  }

  #card {
    width: 100%;
    height: 100vh;
    display: flex;
    flex-direction: column;
    background: #0a0e14;
  }

  /* ── Header ── */
  #header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 6px 12px;
    background: #0d1117;
    border-bottom: 0px solid #1e3a5f;
    flex-shrink: 0;
  }
  #header .title {
    color: #4fc3f7;
    font-size: 11px;
    font-weight: bold;
    letter-spacing: 2px;
    text-transform: uppercase;
  }
  #header .meta { color: #546e7a; font-size: 10px; letter-spacing: 1px; }
  #status        { color: #26a69a; font-size: 10px; letter-spacing: 1px; }

  /* ── Viewer ── */
  #viewer {
    flex: 1;
    display: flex;
    gap: 0px;
    padding: 2px;
    min-height: 0;
    position: relative;
  }

  .tile-wrap {
    flex: 1;
    position: relative;
    overflow: hidden;
    background: #050810;
    border: 0px;
  }

  /* ── Satellite imagery — double-buffered ── */
  .tile-wrap img.sat {
    position: absolute;
    left: 0;
    width: 100%;
    height: auto;
    object-fit: unset;
    top: 5%;              /* ← change this value to pan up/down - outline map needs to be changed separately */
    opacity: 0;
    display: block;
    z-index: 1;
  }
  .tile-wrap img.sat.show { opacity: 1; }

  /* ── Border overlay — sits above satellite, below UI chrome ── */
  .tile-wrap img.overlay {
    position: absolute;
    left: 0;
    width: 100%;
    height: auto;
    object-fit: unset;
    top: 5%;              /* must match .sat top value above */
    display: block;
    z-index: 2;
    pointer-events: none;
  }

  .tile-label {
    position: absolute;
    top: 0px;
    left: 0px;
    background: rgba(10, 14, 20, 0.75);
    color: #78909c;
    font-size: 9px;
    padding: 0px 0px;
    letter-spacing: 1px;
    border: 0px;
    pointer-events: none;
    z-index: 5;
  }

  /* ── Loading overlay (only shown during initial load) ── */
  #loading-overlay {
    position: absolute;
    inset: 0;
    background: rgba(5, 8, 16, 0.88);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    color: #4fc3f7;
    font-size: 10px;
    letter-spacing: 2px;
    z-index: 10;
    gap: 10px;
  }
  #loading-overlay.hidden { display: none; }
  #load-bar-wrap { width: 140px; height: 2px; background: #1a2a3a; border-radius: 2px; overflow: hidden; }
  #load-bar      { height: 100%; background: #4fc3f7; width: 0%; transition: width 0.2s ease; }

  /* ── Opacity slider — floats bottom-right, invisible until hovered ── */
  #slider-wrap {
    position: absolute;
    bottom: 8px;
    right: 8px;
    z-index: 20;
    display: flex;
    align-items: center;
    gap: 8px;
    background: rgba(10, 14, 20, 0.72);
    padding: 5px 10px;
    border-radius: 20px;
    border: 1px solid #1e3a5f;
    opacity: 0;
    transition: opacity 0.3s ease;
  }
  #slider-wrap:hover { opacity: 1; }

  #slider-wrap label {
    color: #546e7a;
    font-size: 9px;
    letter-spacing: 1px;
    text-transform: uppercase;
    white-space: nowrap;
  }

  #overlay-slider {
    -webkit-appearance: none;
    appearance: none;
    width: 100px;
    height: 3px;
    background: #1a2a3a;
    border-radius: 2px;
    outline: none;
    cursor: pointer;
  }
  #overlay-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: #4fc3f7;
    cursor: pointer;
    border: none;
  }
  #overlay-slider::-moz-range-thumb {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: #4fc3f7;
    cursor: pointer;
    border: none;
  }

  #overlay-pct {
    color: #4fc3f7;
    font-size: 9px;
    min-width: 28px;
    text-align: right;
  }

  /* ── Footer — hidden but kept in DOM so JS refs don't break ── */
  #footer { display: none; }

  .dot {
    display: inline-block; width: 6px; height: 6px; border-radius: 50%;
    background: #26a69a; margin-right: 4px;
    animation: pulse 2s infinite;
  }
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
</style>
</head>
<body>
<div id="card">

  <div id="viewer">
    
    <div id="loading-overlay">
      <span id="loading-text">FETCHING TIMESTAMPS</span>
      <div id="load-bar-wrap"><div id="load-bar"></div></div>
    </div>

    <!-- Left tile — double-buffered with two stacked img elements -->
    <div class="tile-wrap" id="wrap-left">
      <img id="left-a" class="sat" src="" alt="" />
      <img id="left-b" class="sat" src="" alt="" />
      <!-- Static border overlay — west tile -->
      <img id="left-ov" class="overlay" src="https://slider.cira.colostate.edu/data/maps/meteosat-12/full_disk/borders/white/20250714000504/02/000_001.png" alt="" />

    </div>

    <!-- Right tile — same double-buffer pattern -->
    <div class="tile-wrap" id="wrap-right">
      <img id="right-a" class="sat" src="" alt="" />
      <img id="right-b" class="sat" src="" alt="" />
      <!-- Static border overlay — east tile -->
      <img id="right-ov" class="overlay" src="https://slider.cira.colostate.edu/data/maps/meteosat-12/full_disk/borders/white/20250714000504/02/000_002.png" alt="" />
    </div>

    <!-- Opacity slider — anchored to viewer so it sits in the  bottom-right -->
    <div id="slider-wrap">
      <label>Borders</label>
      <input type="range" id="overlay-slider" min="0" max="100" value="40" />
      <span id="overlay-pct">40%</span>
    </div>

  </div>

  <!-- Footer hidden via CSS — kept here so JS getElementById calls don't throw -->
  <div id="footer">
    <div id="timestamp-display"></div>
    <div id="controls">
      <button id="btn-prev">◀</button>
      <button id="btn-play" class="active">⏸</button>
      <button id="btn-next">▶</button>
      <button id="speed-btn">1×</button>
    </div>
    <div id="frame-counter"></div>
    <div id="progress"><div id="progress-bar"></div></div>
  </div>

</div>
<script>
(function () {
  'use strict';

  // ── Config ─────────────────────────────────────────────────────────────────
  const SATELLITE        = 'meteosat-12---full_disk';
  const PRODUCT          = 'geocolor';
  const ZOOM             = '02';
  const TILE_LEFT        = '000_001';
  const TILE_RIGHT       = '000_002';
  const FRAME_COUNT      = 19;
  const BASE_URL         = 'https://slider.cira.colostate.edu/data/imagery';
  const SCAN_INTERVAL_MS = 10 * 60 * 1000;
  const TIMELIST_URL     = `https://slider.cira.colostate.edu/data/json/${SATELLITE}/${PRODUCT}/latest_times_v3.json`;
  const SPEEDS           = [400, 200, 100];
  const SPEED_LABELS     = ['1×', '2×', '4×'];

  // ── State ──────────────────────────────────────────────────────────────────
  let timestamps   = [];
  let cache        = [];
  let currentFrame = 0;
  let playing      = true;
  let speedIdx     = 0;
  let timer        = null;

  // ── Double-buffer slot descriptors ────────────────────────────────────────
  const slots = {
    left:  { a: document.getElementById('left-a'),  b: document.getElementById('left-b'),  active: 'a' },
    right: { a: document.getElementById('right-a'), b: document.getElementById('right-b'), active: 'a' }
  };
  slots.left.a.classList.add('show');
  slots.right.a.classList.add('show');

  // ── Overlay elements & slider ──────────────────────────────────────────────
  const leftOv   = document.getElementById('left-ov');
  const rightOv  = document.getElementById('right-ov');
  const slider   = document.getElementById('overlay-slider');
  const pctLabel = document.getElementById('overlay-pct');

  // Set initial overlay opacity from slider default value
  const applyOpacity = () => {
    const opacity = slider.value / 100;
    leftOv.style.opacity  = opacity;
    rightOv.style.opacity = opacity;
    pctLabel.textContent  = `${slider.value}%`;
  };
  applyOpacity();
  slider.addEventListener('input', applyOpacity);

  // ── DOM refs ───────────────────────────────────────────────────────────────
  const loadingOverlay = document.getElementById('loading-overlay');
  const loadingText    = document.getElementById('loading-text');
  const loadBar        = document.getElementById('load-bar');
  const tsDisplay      = document.getElementById('timestamp-display');
  const frameCtEl      = document.getElementById('frame-counter');
  const progressBar    = document.getElementById('progress-bar');
  const btnPlay        = document.getElementById('btn-play');
  const btnPrev        = document.getElementById('btn-prev');
  const btnNext        = document.getElementById('btn-next');
  const speedBtn       = document.getElementById('speed-btn');

  // ── URL / timestamp helpers ────────────────────────────────────────────────
  function buildUrl(ts, tile) {
    return `${BASE_URL}/${ts.slice(0,4)}/${ts.slice(4,6)}/${ts.slice(6,8)}` +
           `/${SATELLITE}/${PRODUCT}/${ts}/${ZOOM}/${tile}.png`;
  }

  function formatTimestamp(ts) {
    return `${ts.slice(0,4)}-${ts.slice(4,6)}-${ts.slice(6,8)}` +
           `  ${ts.slice(8,10)}:${ts.slice(10,12)} UTC`;
  }

  function dateToTimestamp(d) {
    const p = n => String(n).padStart(2, '0');
    // Round down to nearest 10 minutes to align with Meteosat scan slots
    const rounded = new Date(d);
    rounded.setUTCMinutes(Math.floor(d.getUTCMinutes() / 10) * 10, 0, 0);
    return `${rounded.getUTCFullYear()}${p(rounded.getUTCMonth()+1)}${p(rounded.getUTCDate())}` +
           `${p(rounded.getUTCHours())}${p(rounded.getUTCMinutes())}00`;
  }

  // ── Load a single Image ────────────────────────────────────────────────────
  function loadImage(url) {
    return new Promise(resolve => {
      const img = new Image();
      img.onload  = () => resolve(img);
      img.onerror = () => resolve(null);
      img.src = url;
    });
  }

  // ── Probe a single timestamp: both tiles must load ─────────────────────────
  async function probeTimestamp(ts) {
    const [left, right] = await Promise.all([
      loadImage(buildUrl(ts, TILE_LEFT)),
      loadImage(buildUrl(ts, TILE_RIGHT))
    ]);
    if (left && right) return { ts, left, right };
    console.warn(`Skipping ${ts} — missing tile(s):`,
      !left ? TILE_LEFT : '', !right ? TILE_RIGHT : '');
    return null;
  }

  // ── Fetch candidate timestamps from the SLIDER API ────────────────────────
  async function fetchCandidates() {
    try {
      const resp = await fetch(TIMELIST_URL + '?_=' + Date.now());
      if (!resp.ok) throw new Error('HTTP ' + resp.status);
      const data = await resp.json();
      let raw = [];
      if      (data.timestamps_int) raw = data.timestamps_int.map(n => String(n));
      else if (data.timestamps)     raw = data.timestamps.map(String);
      else if (Array.isArray(data)) raw = data.map(String);
      if (!raw.length) throw new Error('empty');
      raw.sort((a, b) => b.localeCompare(a));
      return raw;
    } catch (e) {
      console.warn('Timelist API failed, generating candidate timestamps from NOW:', e.message);
      return fallbackCandidates();
    }
  }

  // Generate ~60 candidate timestamps going back from RIGHT NOW.
  function fallbackCandidates() {
    const LOOKBACK = 60;
    const now = new Date();
    return Array.from({ length: LOOKBACK }, (_, i) =>
      dateToTimestamp(new Date(now.getTime() - i * SCAN_INTERVAL_MS))
    ); // newest-first
  }

  // ── Verified frame builder ─────────────────────────────────────────────────
  async function buildVerifiedFrames(candidates, onProgress) {
    const BATCH_SIZE = 6;
    const good = [];

    for (let i = 0; i < candidates.length && good.length < FRAME_COUNT; i += BATCH_SIZE) {
      const batch = candidates.slice(i, i + BATCH_SIZE);
      const results = await Promise.all(batch.map(ts => probeTimestamp(ts)));
      for (const r of results) {
        if (r && good.length < FRAME_COUNT) good.push(r);
      }
      if (onProgress) onProgress(good.length, FRAME_COUNT);
    }

    if (!good.length) throw new Error('No valid frames found');

    good.reverse(); // oldest→newest for forward playback

    return {
      verifiedTimestamps: good.map(f => f.ts),
      verifiedCache:      good.map(f => ({ left: f.left, right: f.right }))
    };
  }

  // ── Instant double-buffer frame swap ──────────────────────────────────────
  function showFrame(idx) {
    currentFrame = ((idx % cache.length) + cache.length) % cache.length;
    const f = cache[currentFrame];

    ['left', 'right'].forEach(side => {
      const slot    = slots[side];
      const visible = slot[slot.active];
      const hidden  = slot[slot.active === 'a' ? 'b' : 'a'];
      const img     = f[side];
      if (!img) return;
      hidden.src = img.src;
      hidden.classList.add('show');
      visible.classList.remove('show');
      slot.active = slot.active === 'a' ? 'b' : 'a';
    });

    if (tsDisplay)   tsDisplay.textContent   = formatTimestamp(timestamps[currentFrame]);
    if (frameCtEl)   frameCtEl.textContent   = `${currentFrame + 1} / ${cache.length}`;
    if (progressBar) progressBar.style.width = ((currentFrame + 1) / cache.length * 100) + '%';
  }

  // ── Playback controls ──────────────────────────────────────────────────────
  function startPlay() {
    if (timer) clearInterval(timer);
    timer = setInterval(() => showFrame(currentFrame + 1), SPEEDS[speedIdx]);
  }

  function stopPlay() {
    if (timer) { clearInterval(timer); timer = null; }
  }

  function togglePlay() {
    playing = !playing;
    if (playing) {
      if (btnPlay) { btnPlay.textContent = '⏸'; btnPlay.classList.add('active'); }
      startPlay();
    } else {
      if (btnPlay) { btnPlay.textContent = '▶'; btnPlay.classList.remove('active'); }
      stopPlay();
    }
  }

  function cycleSpeed() {
    speedIdx = (speedIdx + 1) % SPEEDS.length;
    if (speedBtn) speedBtn.textContent = SPEED_LABELS[speedIdx];
    if (playing) startPlay();
  }

  // ── Background refresh every 10 minutes ───────────────────────────────────
  async function refresh() {
    try {
      const candidates = await fetchCandidates();
      const { verifiedTimestamps, verifiedCache } = await buildVerifiedFrames(candidates, null);
      timestamps = verifiedTimestamps;
      cache      = verifiedCache;
    } catch (e) {
      console.error('Refresh failed:', e);
    }
  }

  // ── Init ──────────────────────────────────────────────────────────────────
  async function init() {
    loadingText.textContent = 'FETCHING TIMESTAMPS';
    loadBar.style.width = '0%';

    try {
      const candidates = await fetchCandidates();

      const { verifiedTimestamps, verifiedCache } = await buildVerifiedFrames(
        candidates,
        (good, needed) => {
          const pct = Math.round((good / needed) * 100);
          loadingText.textContent = `VERIFYING  ${good} / ${needed}`;
          loadBar.style.width = pct + '%';
        }
      );

      timestamps = verifiedTimestamps;
      cache      = verifiedCache;

      loadingOverlay.classList.add('hidden');
      showFrame(cache.length - 1);
      startPlay();
    } catch (e) {
      loadingText.textContent = 'ERROR — NO FRAMES FOUND';
      console.error('Init failed:', e);
      return;
    }

    setInterval(refresh, 10 * 60 * 1000);
  }

  // ── Event listeners (keyboard still works even with footer hidden) ─────────
  if (btnPlay) btnPlay.addEventListener('click', togglePlay);
  if (btnPrev) btnPrev.addEventListener('click', () => { if (playing) togglePlay(); showFrame(currentFrame - 1); });
  if (btnNext) btnNext.addEventListener('click', () => { if (playing) togglePlay(); showFrame(currentFrame + 1); });
  if (speedBtn) speedBtn.addEventListener('click', cycleSpeed);

  document.addEventListener('keydown', e => {
    if (e.key === ' ')          { e.preventDefault(); togglePlay(); }
    if (e.key === 'ArrowLeft')  { if (playing) togglePlay(); showFrame(currentFrame - 1); }
    if (e.key === 'ArrowRight') { if (playing) togglePlay(); showFrame(currentFrame + 1); }
    if (e.key === 's')          { cycleSpeed(); }
  });

  init();
}());
</script>
</body>
</html>

4 Likes