mirror of
https://github.com/smittix/intercept.git
synced 2026-04-23 22:30:00 -07:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
13
config.py
13
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -156,6 +156,11 @@
|
||||
<h3>GPS Tracking</h3>
|
||||
<p>Real-time GPS position tracking with live map, speed, heading, altitude, satellite info, and track recording.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="space">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></div>
|
||||
<h3>Space Weather</h3>
|
||||
<p>Real-time solar and geomagnetic monitoring. Kp index, HF band conditions, solar imagery, D-RAP maps, and aurora forecasts from NOAA SWPC.</p>
|
||||
</div>
|
||||
<div class="feature-card" data-category="wireless">
|
||||
<div class="feature-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><circle cx="12" cy="20" r="1"/></svg></div>
|
||||
<h3>WiFi Scanning</h3>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
300
routes/space_weather.py
Normal file
300
routes/space_weather.py
Normal file
@@ -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/<key>')
|
||||
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'})
|
||||
467
static/css/modes/space-weather.css
Normal file
467
static/css/modes/space-weather.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
677
static/js/modes/space-weather.js
Normal file
677
static/js/modes/space-weather.js
Normal file
@@ -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 = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
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 = '<div class="sw-loading">Loading</div>';
|
||||
const img = new Image();
|
||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
||||
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 = '<div class="sw-empty" style="grid-column:1/-1">No band data available</div>';
|
||||
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 = '<div class="sw-band-header">Band</div><div class="sw-band-header" style="text-align:center">Day</div><div class="sw-band-header" style="text-align:center">Night</div>';
|
||||
Object.keys(bands).forEach(function (name) {
|
||||
html += '<div class="sw-band-name">' + name + '</div>';
|
||||
['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 += '<div class="' + cls + '">' + cond + '</div>';
|
||||
});
|
||||
});
|
||||
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 = '<div class="sw-empty">No flare data</div>';
|
||||
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 = '<table class="sw-prob-table"><thead><tr>';
|
||||
html += '<th>Date</th><th>C 1-day</th><th>M 1-day</th><th>X 1-day</th><th>Proton</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
latest.forEach(function (row) {
|
||||
html += '<tr>';
|
||||
html += '<td>' + _escHtml(row.date || '--') + '</td>';
|
||||
html += '<td>' + _escHtml(row.c_class_1_day || '--') + '%</td>';
|
||||
html += '<td>' + _escHtml(row.m_class_1_day || '--') + '%</td>';
|
||||
html += '<td>' + _escHtml(row.x_class_1_day || '--') + '%</td>';
|
||||
html += '<td>' + _escHtml(row['10mev_protons_1_day'] || '--') + '%</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
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 = '<div class="sw-empty">Failed to load aurora image</div>'; };
|
||||
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 = '<div class="sw-empty">No active alerts</div>';
|
||||
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 += '<div class="sw-alert-item">';
|
||||
html += '<div class="sw-alert-type">' + _escHtml(a.product_id || 'Alert') + '</div>';
|
||||
html += '<div class="sw-alert-time">' + _escHtml(a.issue_datetime || '') + '</div>';
|
||||
html += '<div class="sw-alert-msg">' + _escHtml(msg) + '</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
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 = '<tr><td colspan="5" class="sw-empty">No active regions</td></tr>';
|
||||
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 += '<tr>';
|
||||
html += '<td>' + _escHtml(String(r.region || '')) + '</td>';
|
||||
html += '<td>' + _escHtml(r.observed_date || '') + '</td>';
|
||||
html += '<td>' + _escHtml(r.location || '') + '</td>';
|
||||
html += '<td>' + _escHtml(String(r.longitude || '')) + '</td>';
|
||||
html += '<td>' + _escHtml(String(r.area || '')) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
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
|
||||
};
|
||||
})();
|
||||
@@ -66,6 +66,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||
@@ -258,6 +259,10 @@
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span>
|
||||
<span class="mode-name">GPS</span>
|
||||
</button>
|
||||
<button class="mode-card mode-card-sm" onclick="selectMode('spaceweather')">
|
||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span>
|
||||
<span class="mode-name">Space Wx</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Space Weather Dashboard -->
|
||||
<div id="spaceWeatherVisuals" class="sw-visuals-container" style="display: none;">
|
||||
<!-- Header metrics strip -->
|
||||
<div class="sw-header-strip">
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value accent-cyan" id="swStripSfi">--</span>
|
||||
<span class="sw-header-label">SFI</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripKp">--</span>
|
||||
<span class="sw-header-label">Kp</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripA">--</span>
|
||||
<span class="sw-header-label">A-Index</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripSsn">--</span>
|
||||
<span class="sw-header-label">SSN</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripWind">--</span>
|
||||
<span class="sw-header-label">Wind</span>
|
||||
</div>
|
||||
<div class="sw-header-stat">
|
||||
<span class="sw-header-value" id="swStripBz">--</span>
|
||||
<span class="sw-header-label">Bz</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOAA G/S/R Scales -->
|
||||
<div class="sw-scales-row">
|
||||
<div class="sw-scale-card" id="swScaleG">
|
||||
<div class="sw-scale-label">Geomagnetic</div>
|
||||
<div class="sw-scale-value sw-scale-0">G0</div>
|
||||
<div class="sw-scale-desc">Quiet</div>
|
||||
</div>
|
||||
<div class="sw-scale-card" id="swScaleS">
|
||||
<div class="sw-scale-label">Solar Radiation</div>
|
||||
<div class="sw-scale-value sw-scale-0">S0</div>
|
||||
<div class="sw-scale-desc">None</div>
|
||||
</div>
|
||||
<div class="sw-scale-card" id="swScaleR">
|
||||
<div class="sw-scale-label">Radio Blackouts</div>
|
||||
<div class="sw-scale-value sw-scale-0">R0</div>
|
||||
<div class="sw-scale-desc">None</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HF Band Conditions -->
|
||||
<div class="sw-band-panel">
|
||||
<div class="sw-band-title">HF Band Conditions</div>
|
||||
<div class="sw-band-grid" id="swBandGrid">
|
||||
<div class="sw-loading">Loading band data</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts row -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Kp Index (3-hourly)</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swKpChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Solar Wind</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swWindChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">X-Ray Flux (GOES)</div>
|
||||
<div class="sw-chart-wrap"><canvas id="swXrayChart"></canvas></div>
|
||||
</div>
|
||||
<div class="sw-chart-card">
|
||||
<div class="sw-chart-title">Flare Probability</div>
|
||||
<div id="swFlareProb"><div class="sw-loading">Loading</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solar imagery gallery -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">Solar Imagery (SDO)</div>
|
||||
<div class="sw-image-tabs">
|
||||
<button class="sw-image-tab sw-solar-tab active" data-key="sdo_193" onclick="SpaceWeather.selectSolarImage('sdo_193')">193Å</button>
|
||||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_304" onclick="SpaceWeather.selectSolarImage('sdo_304')">304Å</button>
|
||||
<button class="sw-image-tab sw-solar-tab" data-key="sdo_magnetogram" onclick="SpaceWeather.selectSolarImage('sdo_magnetogram')">Magnetogram</button>
|
||||
</div>
|
||||
<div class="sw-image-frame" id="swSolarImageFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">Aurora Forecast (North)</div>
|
||||
<div class="sw-image-frame" id="swAuroraFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D-RAP absorption map -->
|
||||
<div class="sw-image-panel">
|
||||
<div class="sw-chart-title">D-Region Absorption (D-RAP)</div>
|
||||
<div class="sw-drap-freqs">
|
||||
<button class="sw-drap-freq-btn active" data-key="drap_global" onclick="SpaceWeather.selectDrapFreq('drap_global')">Global</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_5" onclick="SpaceWeather.selectDrapFreq('drap_5')">5 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_10" onclick="SpaceWeather.selectDrapFreq('drap_10')">10 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_15" onclick="SpaceWeather.selectDrapFreq('drap_15')">15 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_20" onclick="SpaceWeather.selectDrapFreq('drap_20')">20 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_25" onclick="SpaceWeather.selectDrapFreq('drap_25')">25 MHz</button>
|
||||
<button class="sw-drap-freq-btn" data-key="drap_30" onclick="SpaceWeather.selectDrapFreq('drap_30')">30 MHz</button>
|
||||
</div>
|
||||
<div class="sw-image-frame" id="swDrapImageFrame">
|
||||
<div class="sw-loading">Loading</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts & Active Regions -->
|
||||
<div class="sw-dashboard-grid">
|
||||
<div class="sw-alerts-panel">
|
||||
<div class="sw-chart-title">Active Alerts</div>
|
||||
<div id="swAlertsList"><div class="sw-loading">Loading</div></div>
|
||||
</div>
|
||||
<div class="sw-regions-panel">
|
||||
<div class="sw-chart-title">Active Sunspot Regions</div>
|
||||
<table class="sw-regions-table">
|
||||
<thead>
|
||||
<tr><th>Region</th><th>Date</th><th>Loc</th><th>Lo</th><th>Area</th></tr>
|
||||
</thead>
|
||||
<tbody id="swRegionsBody">
|
||||
<tr><td colspan="5" class="sw-loading">Loading</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||
<div class="recon-panel collapsed" id="reconPanel">
|
||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||
@@ -3029,6 +3171,7 @@
|
||||
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>
|
||||
|
||||
<script>
|
||||
// ============================================
|
||||
@@ -3165,7 +3308,7 @@
|
||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||
'spystations', 'meshtastic', 'wifi', 'bluetooth', 'bt_locate',
|
||||
'tscm', 'satellite', 'sstv', 'weathersat', 'sstv_general', 'gps', 'websdr', 'subghz',
|
||||
'analytics'
|
||||
'analytics', 'spaceweather'
|
||||
]);
|
||||
|
||||
function getModeFromQuery() {
|
||||
@@ -3626,7 +3769,7 @@
|
||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||
'meshtastic': 'sdr',
|
||||
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space', 'sstv_general': 'space', 'gps': 'space',
|
||||
'subghz': 'sdr'
|
||||
'spaceweather': 'space', 'subghz': 'sdr'
|
||||
};
|
||||
|
||||
// Remove has-active from all dropdowns
|
||||
@@ -3731,6 +3874,7 @@
|
||||
document.getElementById('websdrMode')?.classList.toggle('active', mode === 'websdr');
|
||||
document.getElementById('subghzMode')?.classList.toggle('active', mode === 'subghz');
|
||||
document.getElementById('analyticsMode')?.classList.toggle('active', mode === 'analytics');
|
||||
document.getElementById('spaceWeatherMode')?.classList.toggle('active', mode === 'spaceweather');
|
||||
|
||||
|
||||
const pagerStats = document.getElementById('pagerStats');
|
||||
@@ -3774,7 +3918,8 @@
|
||||
'dmr': 'DIGITAL VOICE',
|
||||
'websdr': 'WEBSDR',
|
||||
'subghz': 'SUBGHZ',
|
||||
'analytics': 'ANALYTICS'
|
||||
'analytics': 'ANALYTICS',
|
||||
'spaceweather': 'SPACE WX'
|
||||
};
|
||||
const activeModeIndicator = document.getElementById('activeModeIndicator');
|
||||
if (activeModeIndicator) activeModeIndicator.innerHTML = '<span class="pulse-dot"></span>' + (modeNames[mode] || mode.toUpperCase());
|
||||
@@ -3794,6 +3939,7 @@
|
||||
const websdrVisuals = document.getElementById('websdrVisuals');
|
||||
const subghzVisuals = document.getElementById('subghzVisuals');
|
||||
const btLocateVisuals = document.getElementById('btLocateVisuals');
|
||||
const spaceWeatherVisuals = document.getElementById('spaceWeatherVisuals');
|
||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||
@@ -3810,6 +3956,7 @@
|
||||
if (websdrVisuals) websdrVisuals.style.display = mode === 'websdr' ? 'flex' : 'none';
|
||||
if (subghzVisuals) subghzVisuals.style.display = mode === 'subghz' ? 'flex' : 'none';
|
||||
if (btLocateVisuals) btLocateVisuals.style.display = mode === 'bt_locate' ? 'flex' : 'none';
|
||||
if (spaceWeatherVisuals) spaceWeatherVisuals.style.display = mode === 'spaceweather' ? 'flex' : 'none';
|
||||
|
||||
// Hide sidebar by default for Meshtastic mode, show for others
|
||||
const mainContent = document.querySelector('.main-content');
|
||||
@@ -3851,7 +3998,8 @@
|
||||
'dmr': 'Digital Voice Decoder',
|
||||
'websdr': 'HF/Shortwave WebSDR',
|
||||
'subghz': 'SubGHz Transceiver',
|
||||
'analytics': 'Cross-Mode Analytics'
|
||||
'analytics': 'Cross-Mode Analytics',
|
||||
'spaceweather': 'Space Weather Monitor'
|
||||
};
|
||||
const outputTitle = document.getElementById('outputTitle');
|
||||
if (outputTitle) outputTitle.textContent = titles[mode] || 'Signal Monitor';
|
||||
@@ -3874,11 +4022,16 @@
|
||||
if (typeof Analytics !== 'undefined' && Analytics.destroy) Analytics.destroy();
|
||||
}
|
||||
|
||||
// Initialize/destroy Space Weather mode
|
||||
if (mode !== 'spaceweather') {
|
||||
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy();
|
||||
}
|
||||
|
||||
// Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
|
||||
const reconBtn = document.getElementById('reconBtn');
|
||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||
const reconPanel = document.getElementById('reconPanel');
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics') {
|
||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'gps' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') {
|
||||
if (reconPanel) reconPanel.style.display = 'none';
|
||||
if (reconBtn) reconBtn.style.display = 'none';
|
||||
if (intelBtn) intelBtn.style.display = 'none';
|
||||
@@ -3916,8 +4069,8 @@
|
||||
// Hide output console for modes with their own visualizations
|
||||
const outputEl = document.getElementById('output');
|
||||
const statusBar = document.querySelector('.status-bar');
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz') ? 'none' : 'flex';
|
||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'dmr' || mode === 'websdr' || mode === 'subghz' || mode === 'analytics' || mode === 'spaceweather') ? 'none' : 'block';
|
||||
if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'dmr' || mode === 'subghz' || mode === 'spaceweather') ? 'none' : 'flex';
|
||||
|
||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||
if (mode !== 'meshtastic') {
|
||||
@@ -3983,6 +4136,8 @@
|
||||
SubGhz.init();
|
||||
} else if (mode === 'bt_locate') {
|
||||
BtLocate.init();
|
||||
} else if (mode === 'spaceweather') {
|
||||
SpaceWeather.init();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span><span class="desc">Weather Sat - NOAA & Meteor imagery</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg></span><span class="desc">HF SSTV - Shortwave image decoder</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg></span><span class="desc">GPS - GNSS signal analysis</span></div>
|
||||
<div class="icon-item"><span class="icon icon--sm"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></span><span class="desc">Space Weather - Solar & geomagnetic data</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,6 +198,15 @@
|
||||
<li>Useful for evaluating GNSS reception and interference</li>
|
||||
</ul>
|
||||
|
||||
<h3>Space Weather Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL</li>
|
||||
<li>Solar indices (SFI, Kp, A-index, sunspot number) and NOAA G/S/R scales</li>
|
||||
<li>HF band conditions, X-ray flux, solar wind speed, and flare probability</li>
|
||||
<li>Solar imagery (SDO 193/304/Magnetogram), D-RAP absorption maps, aurora forecast</li>
|
||||
<li>No SDR hardware required — all data from public APIs</li>
|
||||
</ul>
|
||||
|
||||
<h3>WiFi Mode</h3>
|
||||
<ul class="tip-list">
|
||||
<li>Requires a WiFi adapter capable of monitor mode</li>
|
||||
@@ -330,6 +340,7 @@
|
||||
<li><strong>Weather Sat:</strong> RTL-SDR, SatDump</li>
|
||||
<li><strong>HF SSTV:</strong> RTL-SDR or SoapySDR-compatible hardware, slowrx</li>
|
||||
<li><strong>GPS:</strong> RTL-SDR or GPS-capable SDR</li>
|
||||
<li><strong>Space Weather:</strong> Internet connection (public APIs)</li>
|
||||
<li><strong>WiFi:</strong> Monitor-mode adapter, aircrack-ng suite</li>
|
||||
<li><strong>Bluetooth:</strong> Bluetooth adapter, bluez (hcitool/bluetoothctl)</li>
|
||||
<li><strong>BT Locate:</strong> Bluetooth adapter, bluez</li>
|
||||
|
||||
71
templates/partials/modes/space-weather.html
Normal file
71
templates/partials/modes/space-weather.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!-- SPACE WEATHER MODE -->
|
||||
<div id="spaceWeatherMode" class="mode-content">
|
||||
<div class="section">
|
||||
<h3>Space Weather Monitor</h3>
|
||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||
Real-time solar and geomagnetic data from NOAA SWPC, NASA SDO, and HamQSL.
|
||||
No SDR hardware required — data is fetched from public APIs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Quick Status</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; font-size: 11px; font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;">
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">SFI</span>
|
||||
<span id="swSidebarSfi" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Kp</span>
|
||||
<span id="swSidebarKp" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">A-Index</span>
|
||||
<span id="swSidebarA" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">SSN</span>
|
||||
<span id="swSidebarSsn" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Wind</span>
|
||||
<span id="swSidebarWind" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; padding: 4px 6px; background: var(--bg-primary); border-radius: 3px;">
|
||||
<span style="color: var(--text-dim);">Bz</span>
|
||||
<span id="swSidebarBz" style="color: var(--accent-cyan); font-weight: 600;">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Refresh</h3>
|
||||
<button class="mode-btn" onclick="SpaceWeather.refresh()" style="width: 100%; margin-bottom: 8px;">
|
||||
Refresh Now
|
||||
</button>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: 6px;">
|
||||
<input type="checkbox" id="swAutoRefresh" checked onchange="SpaceWeather.toggleAutoRefresh()" style="width: auto;">
|
||||
Auto-refresh (5 min)
|
||||
</label>
|
||||
</div>
|
||||
<div id="swLastUpdate" style="font-size: 10px; color: var(--text-dim); font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; margin-top: 4px;">
|
||||
Not yet loaded
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Resources</h3>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<a href="https://www.swpc.noaa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NOAA Space Weather
|
||||
</a>
|
||||
<a href="https://sdo.gsfc.nasa.gov/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
NASA SDO
|
||||
</a>
|
||||
<a href="https://www.hamqsl.com/solar.html" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||
HamQSL Solar Data
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,6 +125,7 @@
|
||||
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mode_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49"/></svg>') }}
|
||||
{{ mode_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mode_item('spaceweather', 'Space Weather', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,6 +199,7 @@
|
||||
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||
{{ mobile_item('sstv_general', 'HF SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||
{{ mobile_item('gps', 'GPS', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/></svg>') }}
|
||||
{{ mobile_item('spaceweather', 'SpaceWx', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/></svg>') }}
|
||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||
|
||||
Reference in New Issue
Block a user