diff --git a/README.md b/README.md
index f0915c8..ed677d9 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ Support the developer of this open-source project
- **GPS** - Real-time GPS position tracking with live map, speed, altitude, and satellite info
- **TSCM** - Counter-surveillance with RF baseline comparison and threat detection
- **Meshtastic** - LoRa mesh network integration
+- **Space Weather** - Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL (no SDR required)
- **Spy Stations** - Number stations and diplomatic HF network database
- **Remote Agents** - Distributed SIGINT with remote sensor nodes
- **Offline Mode** - Bundled assets for air-gapped/field deployments
diff --git a/config.py b/config.py
index 5edd8ee..cbb5014 100644
--- a/config.py
+++ b/config.py
@@ -7,10 +7,21 @@ import os
import sys
# Application version
-VERSION = "2.19.0"
+VERSION = "2.20.0"
# Changelog - latest release notes (shown on welcome screen)
CHANGELOG = [
+ {
+ "version": "2.20.0",
+ "date": "February 2026",
+ "highlights": [
+ "Space Weather mode: real-time solar and geomagnetic monitoring from NOAA SWPC, NASA SDO, and HamQSL",
+ "Kp index, solar wind, X-ray flux charts with Chart.js visualization",
+ "HF band conditions, D-RAP absorption maps, aurora forecast, and solar imagery",
+ "NOAA Space Weather Scales (G/S/R), flare probability, and active solar regions",
+ "No SDR hardware required — all data from public APIs with server-side caching",
+ ]
+ },
{
"version": "2.19.0",
"date": "February 2026",
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 6f1f8ae..8d57ef9 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -165,6 +165,22 @@ Digital Selective Calling (DSC) monitoring on the international maritime distres
- **Real-time JSON output** with meter ID, consumption, and signal data
- **Multiple meter protocol support** via rtl_tcp integration
+## Space Weather
+
+- **Real-time solar indices** - Solar Flux Index (SFI), Kp index, A-index, sunspot number
+- **NOAA Space Weather Scales** - Geomagnetic storms (G), solar radiation (S), radio blackouts (R)
+- **HF band conditions** - Day/night propagation from HamQSL for 80m through 10m bands
+- **Solar wind monitoring** - Speed, density, and IMF Bz from DSCOVR satellite
+- **X-ray flux chart** - GOES X-ray data with flare class scale (A/B/C/M/X)
+- **Flare probability** - 1-day and 3-day C/M/X-class flare forecasts
+- **Solar imagery** - NASA SDO 193A, 304A, and magnetogram images
+- **D-RAP absorption maps** - HF radio absorption at 5-30 MHz frequency bands
+- **Aurora forecast** - OVATION aurora oval visualization
+- **SWPC alerts** - Real-time space weather alerts and warnings
+- **Active solar regions** - Current sunspot region data with location and area
+- **Auto-refresh** - 5-minute polling with manual refresh option
+- **No SDR required** - Data fetched from NOAA SWPC, NASA SDO, and HamQSL public APIs
+
## Satellite Tracking
- **Full-screen dashboard** - dedicated popout with polar plot and ground track
diff --git a/docs/UI_GUIDE.md b/docs/UI_GUIDE.md
index cbd2ff2..60e2e73 100644
--- a/docs/UI_GUIDE.md
+++ b/docs/UI_GUIDE.md
@@ -206,14 +206,23 @@ Extended base for full-screen dashboards (maps, visualizations).
| `listening` | Listening post |
| `spystations` | Spy stations |
| `meshtastic` | Mesh networking |
+| `weathersat` | Weather satellites |
+| `sstv_general` | HF SSTV |
+| `gps` | GPS tracking |
+| `websdr` | WebSDR |
+| `subghz` | Sub-GHz analyzer |
+| `bt_locate` | BT Locate |
+| `analytics` | Analytics dashboard |
+| `spaceweather` | Space weather |
+| `dmr` | DMR/P25 digital voice |
### Navigation Groups
The navigation is organized into groups:
-- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic
-- **Wireless**: WiFi, Bluetooth
-- **Security**: TSCM
-- **Space**: Satellite, ISS SSTV
+- **SDR / RF**: Pager, 433MHz, Meters, Aircraft, Vessels, APRS, Listening Post, Spy Stations, Meshtastic, WebSDR, SubGHz
+- **Wireless**: WiFi, Bluetooth, BT Locate
+- **Security**: TSCM, Analytics
+- **Space**: Satellite, ISS SSTV, Weather Sat, HF SSTV, GPS, Space Weather
---
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 4cead0e..8f15fd7 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -239,6 +239,25 @@ Enable the auto-scheduler to automatically capture passes:
- Starts SatDump at the correct time and frequency
- Decoded images are saved with timestamps
+## Space Weather
+
+1. **Switch to Space Weather mode** - Select "Space Weather" from the Space navigation group
+2. **View Dashboard** - Solar indices, NOAA scales, band conditions, and charts load automatically
+3. **Solar Imagery** - Toggle between SDO 193A, 304A, and Magnetogram views
+4. **D-RAP Maps** - Select frequency (5-30 MHz) to view HF radio absorption maps
+5. **Aurora Forecast** - View the OVATION aurora oval for the northern hemisphere
+6. **Alerts** - Review current SWPC space weather alerts and warnings
+7. **Active Regions** - View solar active region data (number, location, area)
+8. **Refresh** - Data auto-refreshes every 5 minutes, or click "Refresh Now"
+
+### Tips
+
+- No SDR hardware required — all data comes from public APIs (NOAA SWPC, NASA SDO, HamQSL)
+- Check HF band conditions before operating on shortwave frequencies
+- Kp >= 5 indicates geomagnetic storm conditions that may affect HF propagation
+- D-RAP maps show where HF absorption is highest — useful for path planning
+- Solar imagery updates approximately every 15 minutes from NASA SDO
+
## AIS Vessel Tracking
1. **Select Hardware** - Choose your SDR type
diff --git a/docs/index.html b/docs/index.html
index 1a0a4d6..6ddde2e 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -156,6 +156,11 @@
GPS Tracking
Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.
+
+
+
Space Weather
+
Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.
+
WiFi Scanning
diff --git a/pyproject.toml b/pyproject.toml
index 43a55cf..677a23c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "intercept"
-version = "2.19.0"
+version = "2.20.0"
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
readme = "README.md"
requires-python = ">=3.9"
diff --git a/routes/__init__.py b/routes/__init__.py
index 92c269d..acbf3f2 100644
--- a/routes/__init__.py
+++ b/routes/__init__.py
@@ -36,6 +36,7 @@ def register_blueprints(app):
from .subghz import subghz_bp
from .bt_locate import bt_locate_bp
from .analytics import analytics_bp
+ from .space_weather import space_weather_bp
app.register_blueprint(pager_bp)
app.register_blueprint(sensor_bp)
@@ -71,6 +72,7 @@ def register_blueprints(app):
app.register_blueprint(subghz_bp) # SubGHz transceiver (HackRF)
app.register_blueprint(bt_locate_bp) # BT Locate SAR device tracking
app.register_blueprint(analytics_bp) # Cross-mode analytics dashboard
+ app.register_blueprint(space_weather_bp) # Space weather monitoring
# Initialize TSCM state with queue and lock from app
import app as app_module
diff --git a/routes/space_weather.py b/routes/space_weather.py
new file mode 100644
index 0000000..8a59aa4
--- /dev/null
+++ b/routes/space_weather.py
@@ -0,0 +1,300 @@
+"""Space Weather routes - proxies NOAA SWPC, NASA SDO, and HamQSL data."""
+
+from __future__ import annotations
+
+import json
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from typing import Any
+
+from flask import Blueprint, Response, jsonify
+
+from utils.logging import get_logger
+
+logger = get_logger('intercept.space_weather')
+
+space_weather_bp = Blueprint('space_weather', __name__, url_prefix='/space-weather')
+
+# ---------------------------------------------------------------------------
+# TTL Cache
+# ---------------------------------------------------------------------------
+
+_cache: dict[str, dict[str, Any]] = {}
+
+# Cache TTLs in seconds
+TTL_REALTIME = 300 # 5 min for real-time data
+TTL_FORECAST = 1800 # 30 min for forecasts
+TTL_DAILY = 3600 # 1 hr for daily summaries
+TTL_IMAGE = 600 # 10 min for images
+
+
+def _cache_get(key: str) -> Any | None:
+ entry = _cache.get(key)
+ if entry and time.time() < entry['expires']:
+ return entry['data']
+ return None
+
+
+def _cache_set(key: str, data: Any, ttl: int) -> None:
+ _cache[key] = {'data': data, 'expires': time.time() + ttl}
+
+
+# ---------------------------------------------------------------------------
+# HTTP helpers
+# ---------------------------------------------------------------------------
+
+_TIMEOUT = 15 # seconds
+
+SWPC_BASE = 'https://services.swpc.noaa.gov'
+SWPC_JSON = f'{SWPC_BASE}/products'
+
+
+def _fetch_json(url: str, timeout: int = _TIMEOUT) -> Any | None:
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.loads(resp.read().decode())
+ except Exception as exc:
+ logger.warning('Failed to fetch %s: %s', url, exc)
+ return None
+
+
+def _fetch_text(url: str, timeout: int = _TIMEOUT) -> str | None:
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.read().decode()
+ except Exception as exc:
+ logger.warning('Failed to fetch %s: %s', url, exc)
+ return None
+
+
+def _fetch_bytes(url: str, timeout: int = _TIMEOUT) -> bytes | None:
+ try:
+ req = urllib.request.Request(url, headers={'User-Agent': 'INTERCEPT/1.0'})
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return resp.read()
+ except Exception as exc:
+ logger.warning('Failed to fetch %s: %s', url, exc)
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Data source fetchers
+# ---------------------------------------------------------------------------
+
+def _fetch_cached_json(cache_key: str, url: str, ttl: int) -> Any | None:
+ cached = _cache_get(cache_key)
+ if cached is not None:
+ return cached
+ data = _fetch_json(url)
+ if data is not None:
+ _cache_set(cache_key, data, ttl)
+ return data
+
+
+def _fetch_kp_index() -> Any | None:
+ return _fetch_cached_json('kp_index', f'{SWPC_JSON}/noaa-planetary-k-index.json', TTL_REALTIME)
+
+
+def _fetch_kp_forecast() -> Any | None:
+ return _fetch_cached_json('kp_forecast', f'{SWPC_JSON}/noaa-planetary-k-index-forecast.json', TTL_FORECAST)
+
+
+def _fetch_scales() -> Any | None:
+ return _fetch_cached_json('scales', f'{SWPC_JSON}/noaa-scales.json', TTL_REALTIME)
+
+
+def _fetch_flux() -> Any | None:
+ return _fetch_cached_json('flux', f'{SWPC_JSON}/10cm-flux-30-day.json', TTL_DAILY)
+
+
+def _fetch_alerts() -> Any | None:
+ return _fetch_cached_json('alerts', f'{SWPC_JSON}/alerts.json', TTL_REALTIME)
+
+
+def _fetch_solar_wind_plasma() -> Any | None:
+ return _fetch_cached_json('sw_plasma', f'{SWPC_JSON}/solar-wind/plasma-6-hour.json', TTL_REALTIME)
+
+
+def _fetch_solar_wind_mag() -> Any | None:
+ return _fetch_cached_json('sw_mag', f'{SWPC_JSON}/solar-wind/mag-6-hour.json', TTL_REALTIME)
+
+
+def _fetch_xrays() -> Any | None:
+ return _fetch_cached_json('xrays', f'{SWPC_BASE}/json/goes/primary/xrays-1-day.json', TTL_REALTIME)
+
+
+def _fetch_xray_flares() -> Any | None:
+ return _fetch_cached_json('xray_flares', f'{SWPC_BASE}/json/goes/primary/xray-flares-7-day.json', TTL_REALTIME)
+
+
+def _fetch_flare_probability() -> Any | None:
+ return _fetch_cached_json('flare_prob', f'{SWPC_BASE}/json/solar_probabilities.json', TTL_FORECAST)
+
+
+def _fetch_solar_regions() -> Any | None:
+ return _fetch_cached_json('solar_regions', f'{SWPC_BASE}/json/solar_regions.json', TTL_DAILY)
+
+
+def _fetch_sunspot_report() -> Any | None:
+ return _fetch_cached_json('sunspot_report', f'{SWPC_BASE}/json/sunspot_report.json', TTL_DAILY)
+
+
+def _parse_hamqsl_xml(xml_text: str) -> dict[str, Any] | None:
+ """Parse HamQSL solar XML into a dict of band conditions."""
+ try:
+ root = ET.fromstring(xml_text)
+ solar = root.find('.//solardata')
+ if solar is None:
+ return None
+ result: dict[str, Any] = {}
+ # Scalar fields
+ for tag in ('sfi', 'aindex', 'kindex', 'kindexnt', 'xray', 'sunspots',
+ 'heliumline', 'protonflux', 'electonflux', 'aurora',
+ 'normalization', 'latdegree', 'solarwind', 'magneticfield',
+ 'calculatedconditions', 'calculatedvhfconditions',
+ 'geomagfield', 'signalnoise', 'fof2', 'muffactor', 'muf'):
+ el = solar.find(tag)
+ if el is not None and el.text:
+ result[tag] = el.text.strip()
+ # Band conditions
+ bands: list[dict[str, str]] = []
+ for band_el in solar.findall('.//calculatedconditions/band'):
+ bands.append({
+ 'name': band_el.get('name', ''),
+ 'time': band_el.get('time', ''),
+ 'condition': band_el.text.strip() if band_el.text else ''
+ })
+ result['bands'] = bands
+ # VHF conditions
+ vhf: list[dict[str, str]] = []
+ for phen_el in solar.findall('.//calculatedvhfconditions/phenomenon'):
+ vhf.append({
+ 'name': phen_el.get('name', ''),
+ 'location': phen_el.get('location', ''),
+ 'condition': phen_el.text.strip() if phen_el.text else ''
+ })
+ result['vhf'] = vhf
+ return result
+ except ET.ParseError as exc:
+ logger.warning('Failed to parse HamQSL XML: %s', exc)
+ return None
+
+
+def _fetch_band_conditions() -> dict[str, Any] | None:
+ cached = _cache_get('band_conditions')
+ if cached is not None:
+ return cached
+ xml_text = _fetch_text('https://www.hamqsl.com/solarxml.php')
+ if xml_text is None:
+ return None
+ data = _parse_hamqsl_xml(xml_text)
+ if data is not None:
+ _cache_set('band_conditions', data, TTL_FORECAST)
+ return data
+
+
+# ---------------------------------------------------------------------------
+# Image proxy whitelist
+# ---------------------------------------------------------------------------
+
+IMAGE_WHITELIST: dict[str, dict[str, str]] = {
+ # D-RAP absorption maps
+ 'drap_global': {
+ 'url': f'{SWPC_BASE}/images/animations/d-rap/global/latest.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_5': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f05.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_10': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f10.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_15': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f15.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_20': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f20.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_25': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f25.png',
+ 'content_type': 'image/png',
+ },
+ 'drap_30': {
+ 'url': f'{SWPC_BASE}/images/d-rap/global_f30.png',
+ 'content_type': 'image/png',
+ },
+ # Aurora forecast
+ 'aurora_north': {
+ 'url': f'{SWPC_BASE}/images/animations/ovation/north/latest.jpg',
+ 'content_type': 'image/jpeg',
+ },
+ # SDO solar imagery
+ 'sdo_193': {
+ 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0193.jpg',
+ 'content_type': 'image/jpeg',
+ },
+ 'sdo_304': {
+ 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_0304.jpg',
+ 'content_type': 'image/jpeg',
+ },
+ 'sdo_magnetogram': {
+ 'url': 'https://sdo.gsfc.nasa.gov/assets/img/latest/latest_512_HMIBC.jpg',
+ 'content_type': 'image/jpeg',
+ },
+}
+
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+@space_weather_bp.route('/data')
+def get_data():
+ """Return aggregated space weather data from all sources."""
+ data = {
+ 'kp_index': _fetch_kp_index(),
+ 'kp_forecast': _fetch_kp_forecast(),
+ 'scales': _fetch_scales(),
+ 'flux': _fetch_flux(),
+ 'alerts': _fetch_alerts(),
+ 'solar_wind_plasma': _fetch_solar_wind_plasma(),
+ 'solar_wind_mag': _fetch_solar_wind_mag(),
+ 'xrays': _fetch_xrays(),
+ 'xray_flares': _fetch_xray_flares(),
+ 'flare_probability': _fetch_flare_probability(),
+ 'solar_regions': _fetch_solar_regions(),
+ 'sunspot_report': _fetch_sunspot_report(),
+ 'band_conditions': _fetch_band_conditions(),
+ 'timestamp': time.time(),
+ }
+ return jsonify(data)
+
+
+@space_weather_bp.route('/image/
')
+def get_image(key: str):
+ """Proxy and cache whitelisted space weather images."""
+ entry = IMAGE_WHITELIST.get(key)
+ if not entry:
+ return jsonify({'error': 'Unknown image key'}), 404
+
+ cache_key = f'img_{key}'
+ cached = _cache_get(cache_key)
+ if cached is not None:
+ return Response(cached, content_type=entry['content_type'],
+ headers={'Cache-Control': 'public, max-age=300'})
+
+ img_data = _fetch_bytes(entry['url'])
+ if img_data is None:
+ return jsonify({'error': 'Failed to fetch image'}), 502
+
+ _cache_set(cache_key, img_data, TTL_IMAGE)
+ return Response(img_data, content_type=entry['content_type'],
+ headers={'Cache-Control': 'public, max-age=300'})
diff --git a/static/css/modes/space-weather.css b/static/css/modes/space-weather.css
new file mode 100644
index 0000000..86a1000
--- /dev/null
+++ b/static/css/modes/space-weather.css
@@ -0,0 +1,467 @@
+/* Space Weather Mode Styles */
+
+/* Main container */
+.sw-visuals-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px;
+ height: 100%;
+ overflow-y: auto;
+}
+
+/* Header metrics strip */
+.sw-header-strip {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 8px 12px;
+ flex-wrap: wrap;
+}
+
+.sw-header-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 4px 12px;
+ min-width: 60px;
+}
+
+.sw-header-stat + .sw-header-stat {
+ border-left: 1px solid var(--border-color);
+}
+
+.sw-header-value {
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ line-height: 1.2;
+}
+
+.sw-header-label {
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+}
+
+.sw-header-value.accent-cyan { color: var(--accent-cyan); }
+.sw-header-value.accent-green { color: #00ff88; }
+.sw-header-value.accent-yellow { color: #ffcc00; }
+.sw-header-value.accent-orange { color: #ff8800; }
+.sw-header-value.accent-red { color: #ff3366; }
+
+/* Refresh controls in strip */
+.sw-strip-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+ padding-left: 12px;
+}
+
+.sw-refresh-btn {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+ transition: border-color 0.2s, color 0.2s;
+}
+
+.sw-refresh-btn:hover {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+.sw-last-update {
+ font-size: 10px;
+ color: var(--text-dim);
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+/* NOAA G/S/R Scale cards */
+.sw-scales-row {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+
+.sw-scale-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 10px;
+ text-align: center;
+}
+
+.sw-scale-label {
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ margin-bottom: 4px;
+}
+
+.sw-scale-value {
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.sw-scale-desc {
+ font-size: 10px;
+ color: var(--text-dim);
+ margin-top: 2px;
+}
+
+/* Scale severity colors */
+.sw-scale-0 { color: #00ff88; border-color: #00ff8833; }
+.sw-scale-1 { color: #88ff00; border-color: #88ff0033; }
+.sw-scale-2 { color: #ffcc00; border-color: #ffcc0033; }
+.sw-scale-3 { color: #ff8800; border-color: #ff880033; }
+.sw-scale-4 { color: #ff4400; border-color: #ff440033; }
+.sw-scale-5 { color: #ff0044; border-color: #ff004433; }
+
+/* HF Band conditions grid */
+.sw-band-panel {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.sw-band-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.sw-band-grid {
+ display: grid;
+ grid-template-columns: auto repeat(2, 1fr);
+ gap: 4px 8px;
+ font-size: 11px;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+.sw-band-header {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sw-band-name {
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.sw-band-cond {
+ text-align: center;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 600;
+}
+
+.sw-band-good { color: #00ff88; background: #00ff8815; }
+.sw-band-fair { color: #ffcc00; background: #ffcc0015; }
+.sw-band-poor { color: #ff3366; background: #ff336615; }
+
+/* 2-column dashboard grid */
+.sw-dashboard-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+/* Chart containers */
+.sw-chart-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.sw-chart-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.sw-chart-wrap {
+ position: relative;
+ height: 180px;
+}
+
+.sw-chart-wrap canvas {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+/* Flare probability table */
+.sw-prob-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 11px;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+.sw-prob-table th {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ text-align: left;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.sw-prob-table td {
+ padding: 4px 6px;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-color);
+}
+
+/* Solar image gallery */
+.sw-image-panel {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+}
+
+.sw-image-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.sw-image-tab {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-dim);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ cursor: pointer;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+ transition: all 0.2s;
+}
+
+.sw-image-tab:hover {
+ border-color: var(--text-secondary);
+ color: var(--text-secondary);
+}
+
+.sw-image-tab.active {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+ background: var(--accent-cyan)10;
+}
+
+.sw-image-frame {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 200px;
+ background: var(--bg-primary);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.sw-image-frame img {
+ max-width: 100%;
+ max-height: 400px;
+ object-fit: contain;
+ border-radius: 4px;
+}
+
+/* D-RAP frequency selector */
+.sw-drap-freqs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+}
+
+.sw-drap-freq-btn {
+ background: transparent;
+ border: 1px solid var(--border-color);
+ color: var(--text-dim);
+ padding: 3px 8px;
+ border-radius: 3px;
+ font-size: 10px;
+ cursor: pointer;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+ transition: all 0.2s;
+}
+
+.sw-drap-freq-btn:hover {
+ border-color: var(--text-secondary);
+ color: var(--text-secondary);
+}
+
+.sw-drap-freq-btn.active {
+ border-color: var(--accent-cyan);
+ color: var(--accent-cyan);
+}
+
+/* Alerts list */
+.sw-alerts-panel {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.sw-alert-item {
+ padding: 8px;
+ border-bottom: 1px solid var(--border-color);
+ font-size: 11px;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+.sw-alert-item:last-child {
+ border-bottom: none;
+}
+
+.sw-alert-type {
+ font-weight: 600;
+ color: var(--accent-cyan);
+ font-size: 10px;
+ text-transform: uppercase;
+ margin-bottom: 2px;
+}
+
+.sw-alert-time {
+ font-size: 10px;
+ color: var(--text-dim);
+ margin-bottom: 4px;
+}
+
+.sw-alert-msg {
+ color: var(--text-secondary);
+ line-height: 1.4;
+ white-space: pre-wrap;
+ font-size: 10px;
+}
+
+/* Active regions table */
+.sw-regions-panel {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ padding: 12px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.sw-regions-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 10px;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+.sw-regions-table th {
+ font-weight: 600;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ text-align: left;
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ background: var(--bg-card);
+}
+
+.sw-regions-table td {
+ padding: 4px 6px;
+ color: var(--text-secondary);
+ border-bottom: 1px solid var(--border-color);
+}
+
+/* Empty / loading states */
+.sw-empty {
+ text-align: center;
+ padding: 20px;
+ color: var(--text-dim);
+ font-size: 11px;
+ font-family: var(--font-mono, 'Roboto Condensed', monospace);
+}
+
+.sw-loading {
+ text-align: center;
+ padding: 20px;
+ color: var(--text-dim);
+ font-size: 11px;
+}
+
+.sw-loading::after {
+ content: '';
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--border-color);
+ border-top-color: var(--accent-cyan);
+ border-radius: 50%;
+ animation: sw-spin 0.8s linear infinite;
+ margin-left: 8px;
+ vertical-align: middle;
+}
+
+@keyframes sw-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Full-width card */
+.sw-full-width {
+ grid-column: 1 / -1;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .sw-dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .sw-scales-row {
+ grid-template-columns: 1fr;
+ }
+
+ .sw-header-strip {
+ gap: 0;
+ }
+
+ .sw-header-stat {
+ padding: 4px 8px;
+ min-width: 50px;
+ }
+
+ .sw-header-value {
+ font-size: 14px;
+ }
+}
diff --git a/static/js/modes/space-weather.js b/static/js/modes/space-weather.js
new file mode 100644
index 0000000..24e0ccb
--- /dev/null
+++ b/static/js/modes/space-weather.js
@@ -0,0 +1,677 @@
+/**
+ * Space Weather Mode — IIFE module
+ * Polls /space-weather/data every 5 min, renders dashboard with Chart.js
+ */
+const SpaceWeather = (function () {
+ 'use strict';
+
+ let _initialized = false;
+ let _pollTimer = null;
+ let _autoRefresh = true;
+ const POLL_INTERVAL = 5 * 60 * 1000; // 5 min
+
+ // Chart.js instances
+ let _kpChart = null;
+ let _windChart = null;
+ let _xrayChart = null;
+
+ // Current image selections
+ let _solarImageKey = 'sdo_193';
+ let _drapFreq = 'drap_global';
+
+ // -------------------------------------------------------------------
+ // Public API
+ // -------------------------------------------------------------------
+
+ function init() {
+ if (!_initialized) {
+ _initialized = true;
+ }
+ refresh();
+ _startAutoRefresh();
+ }
+
+ function destroy() {
+ _stopAutoRefresh();
+ _destroyCharts();
+ _initialized = false;
+ }
+
+ function refresh() {
+ _fetchData();
+ }
+
+ function selectSolarImage(key) {
+ _solarImageKey = key;
+ _updateSolarImageTabs();
+ const frame = document.getElementById('swSolarImageFrame');
+ if (frame) {
+ frame.innerHTML = 'Loading
';
+ const img = new Image();
+ img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
+ img.onerror = function () { frame.innerHTML = 'Failed to load image
'; };
+ img.src = '/space-weather/image/' + key + '?t=' + Date.now();
+ img.alt = key;
+ }
+ }
+
+ function selectDrapFreq(key) {
+ _drapFreq = key;
+ _updateDrapTabs();
+ const frame = document.getElementById('swDrapImageFrame');
+ if (frame) {
+ frame.innerHTML = 'Loading
';
+ const img = new Image();
+ img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
+ img.onerror = function () { frame.innerHTML = 'Failed to load image
'; };
+ img.src = '/space-weather/image/' + key + '?t=' + Date.now();
+ img.alt = key;
+ }
+ }
+
+ function toggleAutoRefresh() {
+ const cb = document.getElementById('swAutoRefresh');
+ _autoRefresh = cb ? cb.checked : !_autoRefresh;
+ if (_autoRefresh) _startAutoRefresh();
+ else _stopAutoRefresh();
+ }
+
+ // -------------------------------------------------------------------
+ // Polling
+ // -------------------------------------------------------------------
+
+ function _startAutoRefresh() {
+ _stopAutoRefresh();
+ if (_autoRefresh) {
+ _pollTimer = setInterval(_fetchData, POLL_INTERVAL);
+ }
+ }
+
+ function _stopAutoRefresh() {
+ if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
+ }
+
+ function _fetchData() {
+ fetch('/space-weather/data')
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ _renderAll(data);
+ _updateTimestamp();
+ })
+ .catch(function (err) {
+ console.warn('SpaceWeather fetch error:', err);
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // Master render
+ // -------------------------------------------------------------------
+
+ function _renderAll(data) {
+ _renderHeaderStrip(data);
+ _renderScales(data);
+ _renderBandConditions(data);
+ _renderKpChart(data);
+ _renderWindChart(data);
+ _renderXrayChart(data);
+ _renderFlareProb(data);
+ _renderSolarImage();
+ _renderDrapImage();
+ _renderAuroraImage();
+ _renderAlerts(data);
+ _renderRegions(data);
+ _updateSidebar(data);
+ }
+
+ // -------------------------------------------------------------------
+ // Header strip
+ // -------------------------------------------------------------------
+
+ function _renderHeaderStrip(data) {
+ var sfi = '--', kp = '--', aIndex = '--', ssn = '--', wind = '--', bz = '--';
+
+ // SFI from band_conditions (HamQSL) or flux
+ if (data.band_conditions && data.band_conditions.sfi) {
+ sfi = data.band_conditions.sfi;
+ } else if (data.flux && data.flux.length > 1) {
+ var last = data.flux[data.flux.length - 1];
+ sfi = last[1] || '--';
+ }
+
+ // Kp from kp_index
+ if (data.kp_index && data.kp_index.length > 1) {
+ var lastKp = data.kp_index[data.kp_index.length - 1];
+ kp = lastKp[1] || '--';
+ }
+
+ // A-index from band_conditions
+ if (data.band_conditions && data.band_conditions.aindex) {
+ aIndex = data.band_conditions.aindex;
+ }
+
+ // Sunspot number
+ if (data.band_conditions && data.band_conditions.sunspots) {
+ ssn = data.band_conditions.sunspots;
+ }
+
+ // Solar wind speed — last non-null entry
+ if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
+ for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
+ if (data.solar_wind_plasma[i][2]) {
+ wind = Math.round(parseFloat(data.solar_wind_plasma[i][2]));
+ break;
+ }
+ }
+ }
+
+ // IMF Bz — last non-null entry
+ if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
+ for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
+ if (data.solar_wind_mag[j][3]) {
+ bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1);
+ break;
+ }
+ }
+ }
+
+ _setText('swStripSfi', sfi);
+ _setText('swStripKp', kp);
+ _setText('swStripA', aIndex);
+ _setText('swStripSsn', ssn);
+ _setText('swStripWind', wind !== '--' ? wind + ' km/s' : '--');
+ _setText('swStripBz', bz !== '--' ? bz + ' nT' : '--');
+
+ // Color Kp by severity
+ var kpEl = document.getElementById('swStripKp');
+ if (kpEl) {
+ var kpNum = parseFloat(kp);
+ kpEl.className = 'sw-header-value';
+ if (kpNum >= 7) kpEl.classList.add('accent-red');
+ else if (kpNum >= 5) kpEl.classList.add('accent-orange');
+ else if (kpNum >= 4) kpEl.classList.add('accent-yellow');
+ else kpEl.classList.add('accent-green');
+ }
+
+ // Color Bz — negative is bad
+ var bzEl = document.getElementById('swStripBz');
+ if (bzEl) {
+ var bzNum = parseFloat(bz);
+ bzEl.className = 'sw-header-value';
+ if (bzNum < -10) bzEl.classList.add('accent-red');
+ else if (bzNum < -5) bzEl.classList.add('accent-orange');
+ else if (bzNum < 0) bzEl.classList.add('accent-yellow');
+ else bzEl.classList.add('accent-green');
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // NOAA Scales
+ // -------------------------------------------------------------------
+
+ function _renderScales(data) {
+ if (!data.scales) return;
+ var s = data.scales;
+ // Structure: { "0": { R: {Scale, Text}, S: {Scale, Text}, G: {Scale, Text} }, ... }
+ // Key "0" = current conditions
+ var current = s['0'];
+ if (!current) return;
+
+ var scaleMap = {
+ 'G': { el: 'swScaleG', label: 'Geomagnetic Storms' },
+ 'S': { el: 'swScaleS', label: 'Solar Radiation' },
+ 'R': { el: 'swScaleR', label: 'Radio Blackouts' }
+ };
+ ['G', 'S', 'R'].forEach(function (k) {
+ var info = scaleMap[k];
+ var scaleData = current[k];
+ var val = '0', text = info.label;
+ if (scaleData) {
+ val = String(scaleData.Scale || '0').replace(/[^0-9]/g, '') || '0';
+ if (scaleData.Text && scaleData.Text !== 'none') {
+ text = scaleData.Text;
+ }
+ }
+ var el = document.getElementById(info.el);
+ if (el) {
+ el.querySelector('.sw-scale-value').textContent = k + val;
+ el.querySelector('.sw-scale-value').className = 'sw-scale-value sw-scale-' + val;
+ var descEl = el.querySelector('.sw-scale-desc');
+ if (descEl) descEl.textContent = text;
+ }
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // Band conditions
+ // -------------------------------------------------------------------
+
+ function _renderBandConditions(data) {
+ var grid = document.getElementById('swBandGrid');
+ if (!grid) return;
+ if (!data.band_conditions || !data.band_conditions.bands || data.band_conditions.bands.length === 0) {
+ grid.innerHTML = 'No band data available
';
+ return;
+ }
+ // Group by band name, collect day/night
+ var bands = {};
+ data.band_conditions.bands.forEach(function (b) {
+ if (!bands[b.name]) bands[b.name] = {};
+ bands[b.name][b.time.toLowerCase()] = b.condition;
+ });
+
+ var html = 'Band
Day
Night
';
+ Object.keys(bands).forEach(function (name) {
+ html += '' + name + '
';
+ ['day', 'night'].forEach(function (t) {
+ var cond = bands[name][t] || '--';
+ var cls = 'sw-band-cond';
+ var cl = cond.toLowerCase();
+ if (cl === 'good') cls += ' sw-band-good';
+ else if (cl === 'fair') cls += ' sw-band-fair';
+ else if (cl === 'poor') cls += ' sw-band-poor';
+ html += '' + cond + '
';
+ });
+ });
+ grid.innerHTML = html;
+ }
+
+ // -------------------------------------------------------------------
+ // Kp bar chart
+ // -------------------------------------------------------------------
+
+ function _renderKpChart(data) {
+ var canvas = document.getElementById('swKpChart');
+ if (!canvas) return;
+ if (!data.kp_index || data.kp_index.length < 2) return;
+
+ var rows = data.kp_index.slice(1); // skip header
+ var labels = [];
+ var values = [];
+ var colors = [];
+
+ // Take last 24 entries
+ var subset = rows.slice(-24);
+ subset.forEach(function (r) {
+ var dt = r[0] || '';
+ labels.push(dt.slice(5, 16)); // MM-DD HH:MM
+ var v = parseFloat(r[1]) || 0;
+ values.push(v);
+ if (v >= 7) colors.push('#ff3366');
+ else if (v >= 5) colors.push('#ff8800');
+ else if (v >= 4) colors.push('#ffcc00');
+ else colors.push('#00ff88');
+ });
+
+ if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
+ _kpChart = new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: labels,
+ datasets: [{
+ data: values,
+ backgroundColor: colors,
+ borderWidth: 0,
+ barPercentage: 0.8
+ }]
+ },
+ options: _chartOpts('Kp', 0, 9, false)
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // Solar wind chart
+ // -------------------------------------------------------------------
+
+ function _renderWindChart(data) {
+ var canvas = document.getElementById('swWindChart');
+ if (!canvas) return;
+ if (!data.solar_wind_plasma || data.solar_wind_plasma.length < 2) return;
+
+ var rows = data.solar_wind_plasma.slice(1);
+ var labels = [];
+ var speedData = [];
+ var densityData = [];
+
+ // Sample every 3rd point to avoid overcrowding
+ for (var i = 0; i < rows.length; i += 3) {
+ var r = rows[i];
+ labels.push(r[0] ? r[0].slice(11, 16) : '');
+ speedData.push(r[2] ? parseFloat(r[2]) : null);
+ densityData.push(r[1] ? parseFloat(r[1]) : null);
+ }
+
+ if (_windChart) { _windChart.destroy(); _windChart = null; }
+ _windChart = new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [
+ {
+ label: 'Speed (km/s)',
+ data: speedData,
+ borderColor: '#00ccff',
+ backgroundColor: '#00ccff22',
+ borderWidth: 1.5,
+ pointRadius: 0,
+ fill: true,
+ tension: 0.3,
+ yAxisID: 'y'
+ },
+ {
+ label: 'Density (p/cm³)',
+ data: densityData,
+ borderColor: '#ff8800',
+ borderWidth: 1,
+ pointRadius: 0,
+ borderDash: [4, 2],
+ tension: 0.3,
+ yAxisID: 'y1'
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: true, position: 'top', labels: { color: '#888', font: { size: 10 }, boxWidth: 12, padding: 8 } }
+ },
+ scales: {
+ x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
+ y: { display: true, position: 'left', ticks: { color: '#00ccff', font: { size: 9 } }, grid: { color: '#ffffff08' }, title: { display: false } },
+ y1: { display: true, position: 'right', ticks: { color: '#ff8800', font: { size: 9 } }, grid: { drawOnChartArea: false } }
+ },
+ interaction: { mode: 'index', intersect: false }
+ }
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // X-ray flux chart
+ // -------------------------------------------------------------------
+
+ function _renderXrayChart(data) {
+ var canvas = document.getElementById('swXrayChart');
+ if (!canvas) return;
+ if (!data.xrays || data.xrays.length < 2) return;
+
+ // New format: array of objects with time_tag, flux, energy
+ // Filter to short-wavelength (0.1-0.8nm) only
+ var filtered = data.xrays.filter(function (r) {
+ return r.energy && r.energy === '0.1-0.8nm';
+ });
+ if (filtered.length === 0) filtered = data.xrays;
+
+ var labels = [];
+ var values = [];
+
+ // Sample every 3rd point
+ for (var i = 0; i < filtered.length; i += 3) {
+ var r = filtered[i];
+ var tag = r.time_tag || '';
+ labels.push(tag.slice(11, 16));
+ values.push(r.flux ? parseFloat(r.flux) : null);
+ }
+
+ if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
+ _xrayChart = new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: [{
+ label: 'X-Ray Flux (W/m²)',
+ data: values,
+ borderColor: '#ff3366',
+ backgroundColor: '#ff336622',
+ borderWidth: 1.5,
+ pointRadius: 0,
+ fill: true,
+ tension: 0.3
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false }
+ },
+ scales: {
+ x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
+ y: {
+ display: true,
+ type: 'logarithmic',
+ ticks: {
+ color: '#888',
+ font: { size: 9 },
+ callback: function (v) {
+ if (v >= 1e-4) return 'X';
+ if (v >= 1e-5) return 'M';
+ if (v >= 1e-6) return 'C';
+ if (v >= 1e-7) return 'B';
+ if (v >= 1e-8) return 'A';
+ return '';
+ }
+ },
+ grid: { color: '#ffffff08' }
+ }
+ }
+ }
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // Flare probability
+ // -------------------------------------------------------------------
+
+ function _renderFlareProb(data) {
+ var el = document.getElementById('swFlareProb');
+ if (!el) return;
+ if (!data.flare_probability || data.flare_probability.length === 0) {
+ el.innerHTML = 'No flare data
';
+ return;
+ }
+ // New format: array of objects with date, c_class_1_day, m_class_1_day, x_class_1_day, etc.
+ var latest = data.flare_probability.slice(-3);
+ var html = '';
+ html += 'Date C 1-day M 1-day X 1-day Proton ';
+ html += ' ';
+ latest.forEach(function (row) {
+ html += '';
+ html += '' + _escHtml(row.date || '--') + ' ';
+ html += '' + _escHtml(row.c_class_1_day || '--') + '% ';
+ html += '' + _escHtml(row.m_class_1_day || '--') + '% ';
+ html += '' + _escHtml(row.x_class_1_day || '--') + '% ';
+ html += '' + _escHtml(row['10mev_protons_1_day'] || '--') + '% ';
+ html += ' ';
+ });
+ html += '
';
+ el.innerHTML = html;
+ }
+
+ // -------------------------------------------------------------------
+ // Images
+ // -------------------------------------------------------------------
+
+ function _renderSolarImage() {
+ selectSolarImage(_solarImageKey);
+ }
+
+ function _renderDrapImage() {
+ selectDrapFreq(_drapFreq);
+ }
+
+ function _renderAuroraImage() {
+ var frame = document.getElementById('swAuroraFrame');
+ if (!frame) return;
+ var img = new Image();
+ img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
+ img.onerror = function () { frame.innerHTML = 'Failed to load aurora image
'; };
+ img.src = '/space-weather/image/aurora_north?t=' + Date.now();
+ img.alt = 'Aurora Forecast';
+ }
+
+ function _updateSolarImageTabs() {
+ document.querySelectorAll('.sw-solar-tab').forEach(function (btn) {
+ btn.classList.toggle('active', btn.dataset.key === _solarImageKey);
+ });
+ }
+
+ function _updateDrapTabs() {
+ document.querySelectorAll('.sw-drap-freq-btn').forEach(function (btn) {
+ btn.classList.toggle('active', btn.dataset.key === _drapFreq);
+ });
+ }
+
+ // -------------------------------------------------------------------
+ // Alerts
+ // -------------------------------------------------------------------
+
+ function _renderAlerts(data) {
+ var el = document.getElementById('swAlertsList');
+ if (!el) return;
+ if (!data.alerts || data.alerts.length === 0) {
+ el.innerHTML = 'No active alerts
';
+ return;
+ }
+ var html = '';
+ // Show latest 10
+ var items = data.alerts.slice(0, 10);
+ items.forEach(function (a) {
+ var msg = a.message || a.product_text || '';
+ // Truncate long messages
+ if (msg.length > 300) msg = msg.substring(0, 300) + '...';
+ html += '';
+ html += '
' + _escHtml(a.product_id || 'Alert') + '
';
+ html += '
' + _escHtml(a.issue_datetime || '') + '
';
+ html += '
' + _escHtml(msg) + '
';
+ html += '
';
+ });
+ el.innerHTML = html;
+ }
+
+ // -------------------------------------------------------------------
+ // Active regions
+ // -------------------------------------------------------------------
+
+ function _renderRegions(data) {
+ var el = document.getElementById('swRegionsBody');
+ if (!el) return;
+ if (!data.solar_regions || data.solar_regions.length === 0) {
+ el.innerHTML = 'No active regions ';
+ return;
+ }
+ // New format: array of objects with region, observed_date, location, longitude, area, etc.
+ // De-duplicate by region number (keep latest observed_date per region)
+ var byRegion = {};
+ data.solar_regions.forEach(function (r) {
+ var key = r.region || '';
+ if (!byRegion[key] || (r.observed_date > byRegion[key].observed_date)) {
+ byRegion[key] = r;
+ }
+ });
+ var regions = Object.values(byRegion);
+ var html = '';
+ regions.forEach(function (r) {
+ html += '';
+ html += '' + _escHtml(String(r.region || '')) + ' ';
+ html += '' + _escHtml(r.observed_date || '') + ' ';
+ html += '' + _escHtml(r.location || '') + ' ';
+ html += '' + _escHtml(String(r.longitude || '')) + ' ';
+ html += '' + _escHtml(String(r.area || '')) + ' ';
+ html += ' ';
+ });
+ el.innerHTML = html;
+ }
+
+ // -------------------------------------------------------------------
+ // Sidebar quick status
+ // -------------------------------------------------------------------
+
+ function _updateSidebar(data) {
+ var sfi = '--', kp = '--', aIdx = '--', ssn = '--', wind = '--', bz = '--';
+
+ if (data.band_conditions) {
+ if (data.band_conditions.sfi) sfi = data.band_conditions.sfi;
+ if (data.band_conditions.aindex) aIdx = data.band_conditions.aindex;
+ if (data.band_conditions.sunspots) ssn = data.band_conditions.sunspots;
+ }
+ if (data.kp_index && data.kp_index.length > 1) {
+ kp = data.kp_index[data.kp_index.length - 1][1] || '--';
+ }
+ if (data.solar_wind_plasma && data.solar_wind_plasma.length > 1) {
+ for (var i = data.solar_wind_plasma.length - 1; i >= 1; i--) {
+ if (data.solar_wind_plasma[i][2]) {
+ wind = Math.round(parseFloat(data.solar_wind_plasma[i][2])) + ' km/s';
+ break;
+ }
+ }
+ }
+ if (data.solar_wind_mag && data.solar_wind_mag.length > 1) {
+ for (var j = data.solar_wind_mag.length - 1; j >= 1; j--) {
+ if (data.solar_wind_mag[j][3]) {
+ bz = parseFloat(data.solar_wind_mag[j][3]).toFixed(1) + ' nT';
+ break;
+ }
+ }
+ }
+
+ _setText('swSidebarSfi', sfi);
+ _setText('swSidebarKp', kp);
+ _setText('swSidebarA', aIdx);
+ _setText('swSidebarSsn', ssn);
+ _setText('swSidebarWind', wind);
+ _setText('swSidebarBz', bz);
+ }
+
+ // -------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------
+
+ function _setText(id, text) {
+ var el = document.getElementById(id);
+ if (el) el.textContent = text;
+ }
+
+ function _escHtml(s) {
+ var d = document.createElement('div');
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ function _updateTimestamp() {
+ var el = document.getElementById('swLastUpdate');
+ if (el) el.textContent = 'Updated: ' + new Date().toLocaleTimeString();
+ }
+
+ function _chartOpts(yLabel, yMin, yMax, showLegend) {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: !!showLegend, labels: { color: '#888', font: { size: 10 } } }
+ },
+ scales: {
+ x: { display: true, ticks: { color: '#555', font: { size: 9 }, maxRotation: 45, maxTicksLimit: 8 }, grid: { color: '#ffffff08' } },
+ y: { display: true, min: yMin, max: yMax, ticks: { color: '#888', font: { size: 9 }, stepSize: 1 }, grid: { color: '#ffffff08' } }
+ }
+ };
+ }
+
+ function _destroyCharts() {
+ if (_kpChart) { _kpChart.destroy(); _kpChart = null; }
+ if (_windChart) { _windChart.destroy(); _windChart = null; }
+ if (_xrayChart) { _xrayChart.destroy(); _xrayChart = null; }
+ }
+
+ // -------------------------------------------------------------------
+ // Expose public API
+ // -------------------------------------------------------------------
+
+ return {
+ init: init,
+ destroy: destroy,
+ refresh: refresh,
+ selectSolarImage: selectSolarImage,
+ selectDrapFreq: selectDrapFreq,
+ toggleAutoRefresh: toggleAutoRefresh
+ };
+})();
diff --git a/templates/index.html b/templates/index.html
index a8b977c..897ca0f 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -66,6 +66,7 @@
+
@@ -258,6 +259,10 @@
GPS
+
+
+ Space Wx
+
@@ -551,6 +556,8 @@
{% include 'partials/modes/gps.html' %}
+ {% include 'partials/modes/space-weather.html' %}
+
{% include 'partials/modes/listening-post.html' %}
{% include 'partials/modes/tscm.html' %}
@@ -2903,6 +2910,141 @@
+
+
+
+
+
+
+
+
+
Geomagnetic
+
G0
+
Quiet
+
+
+
Solar Radiation
+
S0
+
None
+
+
+
Radio Blackouts
+
R0
+
None
+
+
+
+
+
+
+
+
+
+
Kp Index (3-hourly)
+
+
+
+
+
+
+
+
+
+
+
Solar Imagery (SDO)
+
+ 193Å
+ 304Å
+ Magnetogram
+
+
+
+
+
Aurora Forecast (North)
+
+
+
+
+
+
+
D-Region Absorption (D-RAP)
+
+ Global
+ 5 MHz
+ 10 MHz
+ 15 MHz
+ 20 MHz
+ 25 MHz
+ 30 MHz
+
+
+
+
+
+
+
+
+
Active Sunspot Regions
+
+
+ Region Date Loc Lo Area
+
+
+ Loading
+
+
+
+
+
+