🌌 Aurora Cards - North and South Hemispheres (Three Cards)

:milky_way: Aurora Map Card — NOAA OVATION 24h Animation

This custom card shows a smooth 24-hour playback of the NOAA SWPC OVATION aurora forecast, directly in Home Assistant.
You can switch between South and North hemispheres and optionally remember your default.


:open_file_folder: Installation

  1. Create the file
/config/www/aurora-map.html
  1. Copy the entire below code snippet into the file you just created
AURORA MAP CARD CODE aurora-map.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Aurora South – 24h (filename-only)</title>
  <style>
    :root { color-scheme: dark; }
    html,body { height:100%; }
    html,body { margin:0; background:#000; color:#e6e6e6; font:14px/1.4 system-ui,Segoe UI,Roboto; }
    body.centerY { min-height:100vh; display:flex; align-items:center; }
    body.pinTop  { min-height:100vh; display:flex; align-items:flex-start; }
    *, *::before, *::after { box-sizing: border-box; }

    /* Container width like KP card */
    .wrap { width: min(960px, 100%); margin-inline:auto; padding:12px; }
    /* FLUSH mode: remove the gap entirely */
    .wrap.flush { padding: 0 !important; }

    /* Viewer: fixed height (360px), full width */
    .box { width:100%; height:360px; }
    .box > img { width:100%; height:100%; object-fit: contain; display:block; image-rendering:auto; }

    .tag {
      position:fixed; left:.5rem; bottom:.5rem; color:#ccc;
      font:12px/1.2 sans-serif; background:rgba(0,0,0,.45);
      padding:.25rem .4rem; border-radius:.3rem;
    }

    /* Controls */
    .controls {
      position: fixed; top:.5rem; right:.5rem; display:flex; gap:.4rem; align-items:center;
      font:12px/1.2 system-ui,Segoe UI,Roboto; color:#ddd; user-select:none;
      background:rgba(0,0,0,.45); padding:.3rem; border-radius:.35rem;
      backdrop-filter: blur(2px);
    }
    .controls button {
      border:1px solid rgba(255,255,255,.25);
      background:rgba(255,255,255,.08);
      color:#eee; padding:.25rem .5rem; border-radius:.3rem; cursor:pointer;
    }
    .controls button.active { border-color:#9ad; background:rgba(160,200,255,.18); }
    .controls label { display:flex; align-items:center; gap:.25rem; opacity:.9; }
    .sp { width:.4rem; }
  </style>
</head>
<body class="centerY">
  <div class="wrap flush">
    <div class="box"><img id="frame" alt="NOAA Aurora (24h animated)" /></div>
  </div>
  <div class="tag" id="stamp">loading…</div>

  <div class="controls">
    <button id="btn-south" type="button">SOUTH</button>
    <button id="btn-north" type="button">NORTH</button>
  </div>

  <script>
    // ---------- Tweakables (static) ----------
    const DEFAULT_HEMI = 'south'; // ← set 'south' or 'north' as the default on refresh
    let HOURS = 24;
    let STEP_MIN = 5;
    let JITTER_MIN = 4;
    let FPS = 5;
    let PRELOAD_AHEAD = 10;
    // ----------------------------------------

    // Hemisphere state
    let HEMI = DEFAULT_HEMI;
    let PREFIX = HEMI === 'south' ? 'aurora_S' : 'aurora_N';
    let BASE   = `https://services.swpc.noaa.gov/images/animations/ovation/${HEMI}/`;

    const pad2 = (n) => String(n).padStart(2,'0');
    const stampName = (d) =>
      `${PREFIX}_${d.getUTCFullYear()}-${pad2(d.getUTCMonth()+1)}-${pad2(d.getUTCDate())}_${pad2(d.getUTCHours())}${pad2(d.getUTCMinutes())}.jpg`;

    function buildGrid() {
      const now = new Date();
      const end = new Date(Date.UTC(
        now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(),
        now.getUTCHours(), now.getUTCMinutes() - (now.getUTCMinutes() % STEP_MIN), 0, 0
      ));
      const start = new Date(end.getTime() - HOURS * 60 * 60 * 1000);
      const grid = [];
      for (let t = new Date(start); t <= end; t = new Date(t.getTime() + STEP_MIN*60*1000)) {
        grid.push(new Date(t));
      }
      return { grid, end };
    }

    const sweepOrder = [0,1,-1,2,-2,3,-3,4,-4];
    function candidateUrls(tBaseUtc) {
      const order = sweepOrder.filter(x => Math.abs(x) <= JITTER_MIN);
      return order.map(j => {
        const tj = new Date(tBaseUtc.getTime() + j*60*1000);
        return BASE + stampName(tj);
      });
    }

    let resolvedCache = new Map(); // key: ISO -> url|null
    function resolveFirstWorking(tUtc) {
      const key = tUtc.toISOString();
      if (resolvedCache.has(key)) return Promise.resolve(resolvedCache.get(key));

      const list = candidateUrls(tUtc);
      return new Promise((resolve) => {
        let i = 0;
        const tryNext = () => {
          if (i >= list.length) { resolvedCache.set(key, null); resolve(null); return; }
          const url = list[i++];
          const im = new Image();
          im.onload = () => { resolvedCache.set(key, url); resolve(url); };
          im.onerror = tryNext;
          im.decoding = 'async';
          im.src = url;
        };
        tryNext();
      });
    }

    const img = document.getElementById('frame');
    const tag = document.getElementById('stamp');
    const btnSouth = document.getElementById('btn-south');
    const btnNorth = document.getElementById('btn-north');

    let idx = 0, playing = false;
    const FRAME_MS = 1000 / FPS;
    let last = 0, acc = 0;

    let grid = [];
    let playlist = [];

    function setButtons() {
      btnSouth.classList.toggle('active', HEMI === 'south');
      btnNorth.classList.toggle('active', HEMI === 'north');
      document.title = `Aurora ${HEMI === 'south' ? 'South' : 'North'} – 24h (filename-only)`;
      img.alt = `NOAA Aurora ${HEMI === 'south' ? 'South' : 'North'} (24h animated)`;
    }

    function resetForHemi(newHemi) {
      HEMI = newHemi;
      PREFIX = HEMI === 'south' ? 'aurora_S' : 'aurora_N';
      BASE   = `https://services.swpc.noaa.gov/images/animations/ovation/${HEMI}/`;

      resolvedCache = new Map();
      const built = buildGrid();
      grid = built.grid;
      playlist = new Array(grid.length).fill(null);
      idx = 0;
      playing = false;
      tag.textContent = 'loading…';
      setButtons();
      warmFrom(0);
      firstPassResolve();
    }

    async function firstPassResolve() {
      let resolvedCount = 0;
      for (let i = 0; i < grid.length; i++) {
        const url = await resolveFirstWorking(grid[i]);
        if (url) { playlist[i] = { url, t: grid[i] }; resolvedCount++; }
        tag.textContent = `resolved ${resolvedCount}/${grid.length}`;
      }
      playing = true;
    }

    async function warmFrom(startIdx) {
      let warmed = 0, i = startIdx;
      while (warmed < PRELOAD_AHEAD && i < startIdx + (grid.length || 0)) {
        const p = playlist[i % (playlist.length || 1)];
        if (p && p.url) {
          const im = new Image(); im.decoding = 'async'; im.src = p.url;
          warmed++;
        }
        i++;
      }
    }

    function loop(ts) {
      if (!last) last = ts;
      const dt = ts - last; last = ts; acc += dt;

      if (acc >= FRAME_MS) {
        acc -= FRAME_MS;

        if (playlist.length) {
          let tries = 0;
          while (tries < playlist.length && (!playlist[idx] || !playlist[idx].url)) {
            idx = (idx + 1) % playlist.length; tries++;
          }
          if (playlist[idx] && playlist[idx].url) {
            img.src = playlist[idx].url;
            if (playing) {
              tag.textContent = playlist[idx].t.toISOString()
                .replace('T',' ').replace('.000Z',' UTC');
            }
            idx = (idx + 1) % playlist.length;
            if ((idx % 6) === 0) warmFrom(idx);
          }
        }
      }
      requestAnimationFrame(loop);
    }

    img.addEventListener('error', () => { idx = (idx + 1) % (playlist.length || 1); });

    btnSouth.addEventListener('click', () => resetForHemi('south'));
    btnNorth.addEventListener('click', () => resetForHemi('north'));

    resetForHemi(HEMI);
    requestAnimationFrame(loop);
  </script>
</body>
</html>
  1. Restart Home Assistant
    Required so HA can serve new static files from /local/.

:framed_picture: Add the Card to Lovelace

Add an iframe card in your dashboard:

type: iframe
url: /local/aurora-map.html?v=1
aspect_ratio: 100%

If in Sections View

type: iframe
url: /local/aurora-map.html?v=1
aspect_ratio: 100%
grid_options:
  columns: 12
  rows: 7

:arrows_counterclockwise: Cache Busting

  • The ?v=1 at the end forces browsers to reload when you update the file.
  • Each time you edit aurora-map.html, bump the number (?v=2, ?v=3, …) so you don’t get a stale cached version.
  • Once the card is loaded you do not have to restart home assistant to refresh, just bump the number.

:control_knobs: How It Works

  • Buttons: switch between SOUTH and NORTH.
    * Remember as default: saves your choice in the browser (localStorage).
    • Buttons still override the active hemisphere for the current session (no persistence).
    • Added a tweakable DEFAULT_HEMI ('south' | 'north').
    • On load/refresh, the viewer uses DEFAULT_HEMI and lights the matching button.
  • Sizing:
    • Width: responsive up to 960 px.
    • Height: fixed at 360 px for consistency.
    • No outer gaps (flush mode).
  • Data source: pulls still images from NOAA every 5 minutes, resolving missing frames with a ±4-minute sweep.
  • Playback: runs at 5 fps by default, preloading frames for smooth animation.
  • Timestamp: bottom-left overlay shows UTC for each frame.

:gear: Optional Tuning

Inside the <script> you can adjust:

// ---------- Tweakables (static) ----------
const DEFAULT_HEMI = 'south'; // ← set 'south' or 'north' as the default on refresh
let HOURS = 24;        // how many hours back
let STEP_MIN = 5;      // data cadence (minutes)
let JITTER_MIN = 4;    // search window for missing frames
let FPS = 5;           // playback speed
let PRELOAD_AHEAD = 10;// preload frames ahead

:pray: Credits

  • Aurora images courtesy of NOAA SWPC OVATION model.
    Data and service availability depend on NOAA’s infrastructure.
2 Likes

:milky_way: KP Forecast Graph (NOAA 3-Day, 27-Day & History)

A self-contained HTML widget that shows geomagnetic Kp forecasts and history in a responsive bar chart.
It pulls live data directly from NOAA SWPC feeds (3-Day forecast, 27-Day outlook, and forecast JSON) and displays the current Kp Index in the header with color-coded severity levels.


:open_file_folder: 1) Installation

  1. In Home Assistant, open File Editor (or use Samba/SSH).
  2. Create a file:
  • Path: /config/www/kp-forecast-graph.html
  • Paste the full HTML code into this file and save.
    3.(If you don’t have a www folder, create it first.)*

    4.Paste the below code snippet into your kp-forecast-graph.html file
KP FORECAST GRAPH CARD CODE kp-forecast-graph.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>KP Forecast (NOAA 3-Day & 27-Day + History)</title>
<style>
  :root { color-scheme: dark; }

  /* --- Page + centering -------------------------------------------------- */
  html,body { height:100%; }
  html,body { margin:0; background:#111; color:#e6e6e6; font:14px/1.4 system-ui,Segoe UI,Roboto; }

  /* Center by default; pin to top when panel is open */
  body.centerY { min-height:100vh; display:flex; align-items:center; }
  body.pinTop  { min-height:100vh; display:flex; align-items:flex-start; }

  /* Predictable sizing (avoid horizontal scrollbars) */
  *, *::before, *::after { box-sizing: border-box; }

  /* Widget container */
  .wrap { width: min(960px, 100%); margin-inline:auto; padding:12px; }

  /* ===== Current KP header ===== */
  .kp-header { margin: 0 0 10px; }
  .kp-titlebar { display:flex; align-items:center; gap:12px; }
  .kp-titlebar .spacer { flex:1 1 auto; }

  .kp-title { margin: 0; font-size: 20px; font-weight: 700; color: #eaeaea; display:flex; gap:.5rem; align-items: baseline; flex-wrap: wrap; }
  .kp-val, .kp-label { font-weight: 800; }
  .kp-subtitle { color:#9aa0a6; font-size:14px; margin-top:2px; }

  /* NOAA color buckets */
  .kp-lvl0 .kp-val, .kp-lvl0 .kp-label { color:#63C25E; }
  .kp-lvl1 .kp-val, .kp-lvl1 .kp-label { color:#FFE34A; }
  .kp-lvl2 .kp-val, .kp-lvl2 .kp-label { color:#FF8A22; }
  .kp-lvl3 .kp-val, .kp-lvl3 .kp-label { color:#E8453C; }
  .kp-lvl4 .kp-val, .kp-lvl4 .kp-label { color:#8C1B13; }
  .kp-lvl5 .kp-val, .kp-lvl5 .kp-label { color:#68138c; }

  /* ===== Controls ===== */
  .row { display:flex; align-items:center; margin:0 0 10px; flex-wrap:wrap; }
  .row-left { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
  .row-right { margin-left:auto; }
  .btn { background:#2a2a2a; border:0; padding:6px 10px; border-radius:8px; color:#e6e6e6; cursor:pointer; }
  .btn.active { background:#5b34ff; }
  .iconbtn { width:32px; height:32px; padding:0; display:grid; place-items:center; border-radius:8px; }
  .iconbtn span { font-size:18px; line-height:1; }

  /* Info panel (above header) */
  .panelWrap { overflow:hidden; max-height:0; transition:max-height .35s ease, opacity .25s ease, transform .25s ease; opacity:0; transform:translateY(-8px); margin-bottom:10px; }
  .panelWrap.open { opacity:1; transform:translateY(0); }
  .panel { background:#141414; border:1px solid #222; border-radius:10px; padding:16px 18px; }
  .panel h2 { margin:0 0 12px; font-size:20px; }
  .grid3 { display:grid; grid-template-columns: 1.2fr 1.2fr 1fr; gap:18px; }
  .panel h3 { margin:0 0 8px; font-size:16px; }
  .panel p { margin:.45rem 0; }
  .panel ul { margin:.25rem 0 .75rem 1.1rem; padding:0; }
  .panel li { margin:.25rem 0; line-height:1.35; }

  /* Colored subheadings for G-scale */
  .g1{color:#FFE34A}.g2{color:#FF8A22}.g3{color:#E8453C}.g4{color:#8C1B13}.g5{color:#68138c}

  canvas { display:block; width:100%; height:320px; }

  /* Legend */
  .legend { margin-top:14px; display:grid; grid-template-columns: repeat(5, 1fr); gap:14px; }
  .lg-item { text-align:center; }
  .lg-bar { height:12px; border-radius:999px; margin-bottom:6px; }
  .lg-cap { font-size:13px; color:#cfcfcf; }
</style>
</head>
<body class="centerY">
  <div class="wrap">

    <!-- Info panel ABOVE the header -->
    <div id="infoWrap" class="panelWrap" hidden>
      <div class="panel">
        <h2>Understanding the KP Index & NOAA Scales</h2>
        <div class="grid3">
          <section>
            <h3>What is the KP Index?</h3>
            <p>The KP index is a global geomagnetic storm index that measures the disturbance of Earth's magnetic field caused by solar wind. It ranges from 0 (very little activity) to 9 (extreme geomagnetic storm)</p>
          </section>

          <section>
            <h3>NOAA Scales</h3>
            <h3 class="g1">G1 (Kp 5)</h3>
            <ul><li>Minor storm — Aurora visible closer to the poles</li></ul>

            <h3 class="g2">G2 (Kp 6)</h3>
            <ul><li>Moderate storm — Aurora extends to mid-latitudes</li></ul>

            <h3 class="g3">G3 (Kp 7)</h3>
            <ul><li>Strong storm — Aurora visible in lower latitudes</li></ul>

            <h3 class="g4">G4–G5 (Kp 8–9)</h3>
            <ul><li>Severe to extreme storms — Aurora visible halfway between pole and equator</li></ul>
          </section>

          <section>
            <h3>Aurora Tips</h3>
            <ul>
              <li>Check KP forecasts</li>
              <li>Find dark locations away from city lights</li>
              <li>Best viewing 10 PM – 2 AM local time</li>
              <li>Eyes adapt to darkness in 20–30 min</li>
            </ul>
          </section>
        </div>
      </div>
    </div>

    <!-- ===== Current KP header ===== -->
    <div class="kp-header">
      <div class="kp-titlebar">
        <h2 class="kp-title">
          <span>Current KP Index</span>
          <span id="kpTitleVal" class="kp-val">--</span>
          <span id="kpTitleLabel" class="kp-label">—</span>
        </h2>
        <span class="spacer"></span>
        <button id="infoBtn" class="btn iconbtn" aria-expanded="false" aria-controls="infoWrap" aria-label="Show information about the KP Index">
          <span aria-hidden="true">ℹ️</span>
        </button>
      </div>
      <div id="kpSubtitle" class="kp-subtitle">Loading…</div>
    </div>

    <!-- Controls -->
    <div class="row">
      <div class="row-left">
        <button class="btn active" data-range="24h">24 Hours</button>
        <button class="btn" data-range="3d">3 Days</button>
        <button class="btn" data-range="27d">27 Days</button>
      </div>
      <div class="row-right">
        <button class="btn" data-range="hist" title="All rows from NOAA JSON feed">History</button>
      </div>
    </div>

    <!-- Chart -->
    <canvas id="kpChart" aria-label="KP Index bar chart" role="img"></canvas>

    <!-- Legend -->
    <div class="legend" aria-hidden="true">
      <div class="lg-item"><div class="lg-bar" style="background:#63C25E"></div><div class="lg-cap">Kp 0–4 (Quiet)</div></div>
      <div class="lg-item"><div class="lg-bar" style="background:#FFE34A"></div><div class="lg-cap">Kp 5 (G1)</div></div>
      <div class="lg-item"><div class="lg-bar" style="background:#FF8A22"></div><div class="lg-cap">Kp 6 (G2)</div></div>
      <div class="lg-item"><div class="lg-bar" style="background:#E8453C"></div><div class="lg-cap">Kp 7 (G3)</div></div>
      <div class="lg-item"><div class="lg-bar" style="background:#68138c; box-shadow: inset 0 0 0 2px #E8453C66;"></div><div class="lg-cap">Kp 8–9 (G4–G5)</div></div>
    </div>

  </div>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script>
(async function () {
  /* Data sources */
  const THREE_DAY_TXT = 'https://services.swpc.noaa.gov/text/3-day-forecast.txt';
  const OUTLOOK27_TXT = 'https://services.swpc.noaa.gov/text/27-day-outlook.txt';
  const KP_JSON       = 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json';
  const KP_NOW_URL    = 'https://services.swpc.noaa.gov/json/planetary_k_index_1m.json';

  const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
  const fmtDay  = new Intl.DateTimeFormat(undefined, { timeZone: TZ, month:'short', day:'2-digit' });
  const fmtTime = new Intl.DateTimeFormat(undefined, { timeZone: TZ, hour12:false, hour:'2-digit', minute:'2-digit' });
  const fmtFull = new Intl.DateTimeFormat(undefined, { timeZone: TZ, month:'short', day:'2-digit', hour12:false, hour:'2-digit', minute:'2-digit' });

  function kpColor(kp){ if(kp<5)return'#63C25E'; if(kp<6)return'#FFE34A'; if(kp<7)return'#FFD046'; if(kp<8)return'#FF8A22'; if(kp<9)return'#E8453C'; return'#8C1B13'; }
  function classifyKp(kp) {
    if (kp < 5) return {cls:'kp-lvl0', label:'Quiet',         note:'No geomagnetic storm'};
    if (kp < 6) return {cls:'kp-lvl1', label:'G1 • Minor',    note:'Geomagnetic storm (G1)'};
    if (kp < 7) return {cls:'kp-lvl2', label:'G2 • Moderate', note:'Geomagnetic storm (G2)'};
    if (kp < 8) return {cls:'kp-lvl3', label:'G3 • Strong',   note:'Geomagnetic storm (G3)'};
    if (kp < 9) return {cls:'kp-lvl4', label:'G4 • Severe',   note:'Geomagnetic storm (G4)'};
    return               {cls:'kp-lvl5', label:'G5 • Extreme', note:'Geomagnetic storm (G5)'};
  }
  // Month helpers (fix)
  const MONS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
  const monIndex = (monStr) => {
    const s = String(monStr).slice(0,3).toLowerCase();
    return MONS.findIndex(m => m.toLowerCase() === s); // returns 0..11
  };

  /* Header: current Kp */
  async function updateKpHeader() {
    const title = document.querySelector('.kp-title'); if (!title) return;
    const valEl = document.getElementById('kpTitleVal');
    const lblEl = document.getElementById('kpTitleLabel');
    const subEl = document.getElementById('kpSubtitle');

    try {
      const rows = await fetch(KP_NOW_URL, {cache:'no-store'}).then(r=>r.json());
      const last = rows[rows.length-1] || {};
      const raw  = Number(last.estimated_kp ?? last.kp_index ?? last.kp ?? NaN);
      if (!Number.isFinite(raw)) throw new Error('No Kp in feed');

      const kp = Math.round(raw * 100) / 100;
      const {cls, label, note} = classifyKp(kp);

      valEl.textContent = kp.toFixed(2);
      lblEl.textContent = label;
      subEl.textContent = note;

      title.classList.remove('kp-lvl0','kp-lvl1','kp-lvl2','kp-lvl3','kp-lvl4','kp-lvl5');
      title.classList.add(cls);
    } catch (e) {
      console.error(e);
      valEl.textContent = '--';
      lblEl.textContent = 'Unavailable';
      subEl.textContent = 'Failed to load from NOAA';
    }
  }

  /* 3-Day forecast parsing */
  async function fetch3Day(){
    const txt=await fetch(THREE_DAY_TXT,{cache:'no-store'}).then(r=>r.text());
    const blockMatch=txt.match(/NOAA Kp index breakdown([\s\S]*?)^\s*B\./mi)||txt.match(/NOAA Kp index breakdown([\s\S]*)$/i);
    if(!blockMatch) throw new Error('3-day Kp block not found');
    const block=blockMatch[1];
    const dayTokens=[...block.matchAll(/\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})\b/g)].map(m=>[m[1],m[2]]);
    if(dayTokens.length<3) throw new Error('3-day dates not found');
    const lastThree=dayTokens.slice(-3);
    const yearMatch=txt.match(/\b(20\d{2})\b/);
    const yearUTC=yearMatch?parseInt(yearMatch[1],10):new Date().getUTCFullYear();  /* <-- FIXED */
    const dayBaseUTC=lastThree.map(([mon,dd])=>new Date(Date.UTC(yearUTC,monIndex(mon),parseInt(dd,10),0,0,0)));
    const rows=[...block.matchAll(/(\d{2})-(\d{2})UT\s+([0-9.]+)(?:\s+\(G\d\))?\s+([0-9.]+)(?:\s+\(G\d\))?\s+([0-9.]+)(?:\s+\(G\d\))?/g)];
    const out=[];
    for(const r of rows){
      const startH=parseInt(r[1],10);
      const vals=[parseFloat(r[3]),parseFloat(r[4]),parseFloat(r[5])];
      for(let d=0; d<3; d++){
        const t=new Date(dayBaseUTC[d].getTime()+startH*3600e3);
        out.push({t,kp:vals[d]});
      }
    }
    out.sort((a,b)=>a.t-b.t);
    return out;
  }

  /* 27-Day outlook parsing */
  async function fetch27d(){
    const txt=await fetch(OUTLOOK27_TXT,{cache:'no-store'}).then(r=>r.text());
    const lines=txt.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
    const re=/^(\d{4})\s+([A-Za-z]{3})\s+(\d{1,2})\s+\d+\s+\d+\s+(\d+)/;
    const out=[];
    for(const line of lines){
      const m=line.match(re);
      if(m){
        const y=parseInt(m[1],10), mon=m[2], dd=parseInt(m[3],10), kp=parseFloat(m[4]);
        out.push({t:new Date(Date.UTC(y,monIndex(mon),dd,0,0,0)), kp});
      }
    }
    out.sort((a,b)=>a.t-b.t);
    return out;
  }

  /* Kp JSON history */
  async function fetchKPJson(){
    const json=await fetch(KP_JSON,{cache:'no-store'}).then(r=>r.json());
    const rows=json.slice(1);
    return rows.map(r=>({
      t:new Date(r[0].replace(' ','T')+'Z'),
      kp:parseFloat(r[1]),
      tag:String(r[2]||'').toLowerCase()
    })).sort((a,b)=>a.t-b.t);
  }

  // Chart
  const ctx=document.getElementById('kpChart');
  const chart=new Chart(ctx,{
    type:'bar',
    data:{ labels:[], datasets:[{ data:[], backgroundColor:[], borderWidth:0 }] },
    options:{
      animation:false,
      plugins:{
        legend:{ display:false },
        tooltip:{
          intersect:false,
          callbacks:{
            title:items=>{
              const d=labelsDates[items[0].dataIndex];
              return viewMode==='27d'?fmtDay.format(d):fmtFull.format(d);
            },
            label:(item)=>{
              const rowTag=labelsTags?.[item.dataIndex];
              return rowTag?`Kp ${item.formattedValue} • ${rowTag}`:`Kp ${item.formattedValue}`;
            }
          }
        }
      },
      scales:{
        y:{ min:0,max:9,ticks:{ stepSize:1,color:'#9da3a8',font:{ size:12 } }, grid:{ color:'#2a2f35' }, border:{ color:'#2a2f35' } },
        x:{
          type:'category',
          grid:{ color:'#1d2024' }, border:{ color:'#2a2f35' },
          ticks:{
            color:'#b7bcc2', font:{ size:12, weight:500 }, padding:6, minRotation:35, maxRotation:35,
            callback:(value,index)=>{
              const d=labelsDates[index];
              if(!d) return '';
              if(viewMode==='27d') return fmtDay.format(d);
              const key=fmtDay.format(d);
              if(!seenDay.has(key)){ seenDay.add(key); return key; }
              return fmtTime.format(d);
            }
          }
        }
      }
    }
  });

  // State
  let labelsDates=[]; let labelsTags=null; let viewMode='24h'; let seenDay=new Set();

  // Fetch once
  let threeDayData=[], outlook27Data=[], jsonHistory=[];
  try {
    [threeDayData, outlook27Data, jsonHistory] = await Promise.all([
      fetch3Day(), fetch27d(), fetchKPJson()
    ]);
  } catch (e) {
    console.error('Fetch/parse error:', e);
  }

  // Render modes
  function setRange(mode){
    viewMode=mode; seenDay=new Set();

    if(mode==='27d'){
      labelsTags=null;
      labelsDates=outlook27Data.map(x=>x.t);
      chart.data.labels=labelsDates.map(()=> '');
      chart.data.datasets[0].data=outlook27Data.map(x=>x.kp);
      chart.data.datasets[0].backgroundColor=outlook27Data.map(x=>kpColor(x.kp));
      chart.options.scales.x.ticks.maxTicksLimit=27;
      chart.update(); return;
    }

    if(mode==='hist'){
      labelsDates=jsonHistory.map(x=>x.t);
      labelsTags=jsonHistory.map(x=>x.tag);
      chart.data.labels=labelsDates.map(()=> '');
      chart.data.datasets[0].data=jsonHistory.map(x=>x.kp);
      chart.data.datasets[0].backgroundColor=jsonHistory.map(x=>kpColor(x.kp));
      chart.options.scales.x.ticks.maxTicksLimit=50;
      chart.update(); return;
    }

    labelsTags=null;
    if(mode==='24h'){
      const first8=threeDayData.slice(0,8);
      labelsDates=first8.map(x=>x.t);
      chart.data.labels=labelsDates.map(()=> '');
      chart.data.datasets[0].data=first8.map(x=>x.kp);
      chart.data.datasets[0].backgroundColor=first8.map(x=>kpColor(x.kp));
      chart.options.scales.x.ticks.maxTicksLimit=12;
    }else{
      labelsDates=threeDayData.map(x=>x.t);
      chart.data.labels=labelsDates.map(()=> '');
      chart.data.datasets[0].data=threeDayData.map(x=>x.kp);
      chart.data.datasets[0].backgroundColor=threeDayData.map(x=>kpColor(x.kp));
      chart.options.scales.x.ticks.maxTicksLimit=28;
    }
    chart.update();
  }

  // Buttons
  document.querySelectorAll('.btn[data-range]').forEach(btn=>{
    btn.onclick=()=>{ document.querySelectorAll('.btn[data-range]').forEach(b=>b.classList.remove('active')); btn.classList.add('active'); setRange(btn.dataset.range); };
  });

  // Info toggle — also toggle centering vs top-pin and keep panel in view
  const infoBtn=document.getElementById('infoBtn');
  const infoWrap=document.getElementById('infoWrap');

  function openPanel(){
    document.body.classList.remove('centerY');
    document.body.classList.add('pinTop');

    infoWrap.hidden=false;
    const h=infoWrap.scrollHeight;
    infoWrap.style.maxHeight=h+'px';
    infoWrap.classList.add('open');
    infoBtn.setAttribute('aria-expanded','true');
    infoBtn.setAttribute('aria-label','Hide information about the KP Index');

    infoWrap.scrollIntoView({ block:'start', behavior:'smooth' });
  }
  function closePanel(){
    infoWrap.style.maxHeight='0px';
    infoWrap.classList.remove('open');
    infoBtn.setAttribute('aria-expanded','false');
    infoBtn.setAttribute('aria-label','Show information about the KP Index');
    infoWrap.addEventListener('transitionend',()=>{
      if(infoWrap.style.maxHeight==='0px'){
        infoWrap.hidden=true;
        document.body.classList.remove('pinTop');
        document.body.classList.add('centerY');
      }
    },{once:true});
  }
  infoBtn.addEventListener('click',()=> (infoBtn.getAttribute('aria-expanded')==='true') ? closePanel() : openPanel());

  // Init
  setRange('24h');
  updateKpHeader();
  setInterval(updateKpHeader, 5 * 60 * 1000);
})();
</script>
</body>
</html>

After saving, the file will be served by Home Assistant at:

/local/kp-forecast-graph.html

:framed_picture: 2) Add to a Dashboard (Lovelace)

Use an iframe card:

type: iframe
url: /local/kp-forecast-graph.html?v=2025-09-17
aspect_ratio: 100%
  • aspect_ratio: 100% ensures a square layout in most dashboards.
  • You can also give it a fixed height if preferred.

:arrows_counterclockwise: 3) Cache Busting (important)

Browsers aggressively cache /local files.
Whenever you update the HTML file, bump the ?v= number (any unique string works) to force a reload:

  • Old:
    /local/kp-forecast-graph.html?v=1
  • New:
    /local/kp-forecast-graph.html?v=2

:wrench: Always change the ?v= parameter when you edit the file so you’ll see your latest version.


:sparkles: 4) Features

  • Current Kp header with live NOAA 1-minute JSON feed.
  • Range controls:
    • 24h: First 8 rows of 3-Day forecast (≈ 24 hours).
    • 3d: All rows from 3-Day forecast (≈ 72 hours).
    • 27d: Daily bars from 27-Day outlook.
    • History: All rows from NOAA forecast JSON.
  • Info panel: Toggle with :information_source: button (explains KP index, G-scales, and aurora viewing tips).
  • Chart: Bar chart with color-coded severity (Quiet → G1–G5).
  • Legend: Static color keys for Kp 0–9.

:hammer_and_wrench: 5) Troubleshooting

  • Nothing changed after editing the file:
    → Bump the ?v= number in the iframe URL.
  • 404 / not found:
    → Check that the file path is exactly /config/www/kp-forecast-graph.html.
  • Looks squashed:
    → Try aspect_ratio: 100%, or remove it and set a fixed height depending on your layout.

:raised_hands: 6) Credits

  • Data: NOAA SWPC
  • Visualization: Chart.js v4
  • Packaged as a drop-in HTML widget for Home Assistant Lovelace dashboards.

:earth_africa: AuroraScore Iframe Card

This card embeds the AuroraScore website directly into your Home Assistant dashboard.
AuroraScore uses live space-weather data and automatically centers the map on your current browser location for a personalized aurora forecast.


:open_file_folder: 1) Installation

No file placement required — this one loads directly from the AuroraScore website.
Just add an iframe card to your Lovelace dashboard:

type: iframe
url: https://aurorascore.no/
aspect_ratio: 100%
grid_options:
  columns: 12
  rows: 7

:sparkles: 2) Features

  • :world_map: Auto-centering: The map locks onto your browser’s location so forecasts are always relevant.
  • :new_moon: Mapbox Dark Style: Perfect background for aurora visualisation — auroral ovals really stand out.
  • :artificial_satellite: Satellite View: A fast, crisp satellite basemap is available for an immersive real-world look.
  • :earth_americas: Zoom Controls:
    • Zoom out: See auroral conditions across the entire world.
    • Zoom in: Precision equivalent to Google Maps street-level zoom.
  • :zap: Performance: AuroraScore loads quickly and is optimized for interactive use, even inside HA dashboards.

:hammer_and_wrench: 3) Tips

  • Use aspect_ratio: 100% for a clean square display, or adjust for widescreen layouts.
  • Pair it with your KP Forecast Graph card for context: forecast values + live map.
  • AuroraScore uses Mapbox, so you get modern vector map rendering and smooth pan/zoom.

:raised_hands: 4) Credits

  • AuroraScore for the web tool and data integration
  • Mapbox for cartography and satellite tiles
  • Drop-in usage via Home Assistant iframe card

:robot: 5) Conditionally Display the Cards

Use the NOAA Aurora Visibility Sensor Integration to conditionally display your cards, for your location.

1 Like

Nice, thanks!!

Now, just a couple of thoughts, off the cuff…

  1. Would it be possible to hard-code N or S hemisphere? It’s highly unlikely that I’ll get to Antarctica anytime soon. And if I did, I wouldn’t mind changing a config file or whatever.

  2. Likewise, would it be possible to hard-code a default for current location? I have location turned off in my browser and don’t want to have to turn it back on every time I go into HA.

Sorry I haven’t got time to actually test these today, so maybe these options already exist.

Thank you for posting these, they’re a great addition to HA!

You definitely can hard code the north or south. I put the option in to change north or south with a default.

There is a setting in the code for default.

</s> <s>// Hemisphere state</s> <s>let HEMI = (localStorage.getItem('aurora.hemi') || 'south');</s> <s>
Change south to north and that is your default

Mine is defaulted to South but if there is activity in the south I wanted an easy way to see what was going on in the north.

As for the location unfortunately that is baked into aurora score. Seems it is driven by internet server location. I am currently 3000km away from home. When connected through VPN to my home server it gives my home location, when I turn it off it connects to my nearest capital city.

Spin them up and test them, I look forward to your feedback.

UPDATED CODE IN ORIGINAL POST

  • Buttons: switch between SOUTH and NORTH.
    * Remember as default: saves your choice in the browser (localStorage).
    • Buttons still override the active hemisphere for the current session (no persistence).
    • Added a tweakable DEFAULT_HEMI ('south' | 'north').
    • On load/refresh, the viewer uses DEFAULT_HEMI and lights the matching button.

Cards in Grid Card - with NOAA Aurora Visibility Sensor

            type: conditional
            conditions:
              - condition: state
                entity: binary_sensor.aurora_visibility_visibility_alert
                state: 'on'
            card:
              square: true
              type: grid
              cards:
                - type: iframe
                  url: /local/kp-forecast-graph.html
                  aspect_ratio: 100%
                - type: iframe
                  url: /local/aurora-map.html?v40
                  aspect_ratio: 100%
                - type: iframe
                  url: https://aurorascore.no/
                  aspect_ratio: 100%
              columns: 3

Cards in Sections View - with NOAA Aurora Visibility Sensor

type: conditional
conditions:
  - condition: state
    entity: binary_sensor.aurora_visibility_visibility_alert
    state: "on"
card:
  type: iframe
  url: /local/kp-forecast-graph.html
  aspect_ratio: 100%
type: conditional
conditions:
  - condition: state
    entity: binary_sensor.aurora_visibility_visibility_alert
    state: "on"
card:
  type: iframe
  url: /local/aurora-map.html?v40
  aspect_ratio: 100%
type: conditional
conditions:
  - condition: state
    entity: binary_sensor.aurora_visibility_visibility_alert
    state: "on"
card:
  type: iframe
  url: https://aurorascore.no/
  aspect_ratio: 100%
1 Like

Please make this an HACS addon for the lazy like me :smiley: :smiley: :smiley:

1 Like

I did start work on a HACS version but I need to create a *.js to wrap the *.html so they can be downloaded. I will make a GitHub repository for each in time but currently that would still involve following the steps above.
It is quite simple, using VS Studio Code editor addon

  • Create and name the *.html file in your /config/www folder
  • Paste the html code into the file
  • Restart HA
  • Create a webpage card aka iframe paste the URL in