From 16239c1d31be193ba9d4886f0188ec34cb5b13c8 Mon Sep 17 00:00:00 2001 From: Smittix Date: Tue, 17 Feb 2026 22:10:34 +0000 Subject: [PATCH] feat: Add Space Weather mode with real-time solar and geomagnetic monitoring New mode providing real-time space weather data from NOAA SWPC, NASA SDO, and HamQSL APIs. Includes Kp index, solar wind, X-ray flux charts, HF band conditions, D-RAP absorption maps, aurora forecast, solar imagery, flare probability, and active solar regions. No SDR hardware required. Bumps version to 2.20.0. Updates all documentation including README, FEATURES, USAGE, UI_GUIDE, help modal, and GitHub Pages site. Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + config.py | 13 +- docs/FEATURES.md | 16 + docs/UI_GUIDE.md | 17 +- docs/USAGE.md | 19 + docs/index.html | 5 + pyproject.toml | 2 +- routes/__init__.py | 2 + routes/space_weather.py | 300 +++++++++ static/css/modes/space-weather.css | 467 ++++++++++++++ static/js/modes/space-weather.js | 677 ++++++++++++++++++++ templates/index.html | 169 ++++- templates/partials/help-modal.html | 11 + templates/partials/modes/space-weather.html | 71 ++ templates/partials/nav.html | 2 + 15 files changed, 1759 insertions(+), 13 deletions(-) create mode 100644 routes/space_weather.py create mode 100644 static/css/modes/space-weather.css create mode 100644 static/js/modes/space-weather.js create mode 100644 templates/partials/modes/space-weather.html 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 += ''; + html += ''; + latest.forEach(function (row) { + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
DateC 1-dayM 1-dayX 1-dayProton
' + _escHtml(row.date || '--') + '' + _escHtml(row.c_class_1_day || '--') + '%' + _escHtml(row.m_class_1_day || '--') + '%' + _escHtml(row.x_class_1_day || '--') + '%' + _escHtml(row['10mev_protons_1_day'] || '--') + '%
'; + 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 +
@@ -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 @@ + + +