feat(monitor/frontend): space weather panel (Kp 9-bar, solar wind, X-ray flux)

Three-row layout (label | value | secondary or 9-bar). Kp index renders as
a 9-segment grid bar — segments below kp filled --fg-2, at-or-above outlined
--fg-4. When kp >= 7 (G3+ geomagnetic storm) the entire filled portion goes
amber AND the panel's stale dot fires amber — one of the few legitimate
amber-data uses per ethos. X-ray class M or X also flips amber.

Backend snapshot caveat: the SWPC collector currently emits only
{ts, pushed} as the WS event payload — the actual kp / bz_gsm / speed_kms
/ density / xray_class / xray_flux readings live in Redis ring buffers
(per spec §6.2) but aren't yet projected into snapshot.space_weather.
The TS interface defines all fields optimistically; the panel renders
"—" / empty 9-bar for missing fields. When the backend extends the SWPC
event payload (or a separate snapshot enrichment lands), the panel
auto-fills with no frontend changes.

KP_SEGMENT_KEYS is an explicit string array instead of Array.from(...,
i) to avoid biome's noArrayIndexKey warning while preserving the
positional segment semantics.

Plan 5 Task 20 Design QA: Kp bar uses ONLY --fg-2 / --fg-4 / --amber
(no other colors), values mono tabular, X-ray class letter sans, no
sun glyph / aurora photo / weather-forecast iconography.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:38:52 -04:00
parent b67729ae13
commit f0bb0dcb8d
3 changed files with 175 additions and 0 deletions

View File

@ -8,6 +8,7 @@ import { DShieldPanel } from '@/pages/panels/DShieldPanel'
import { ETHPanel } from '@/pages/panels/ETHPanel'
import { KEVPanel } from '@/pages/panels/KEVPanel'
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
import { SpaceWeatherPanel } from '@/pages/panels/SpaceWeatherPanel'
import { useUIStore } from '@/stores/ui'
import { AlertBanner } from './AlertBanner'
import { BottomTicker } from './BottomTicker'
@ -31,6 +32,7 @@ export function Dashboard(): React.ReactElement {
<aside className={styles.left}>
<BTCPanel />
<ETHPanel />
<SpaceWeatherPanel />
</aside>
<section className={styles.center}>
<Globe />

View File

@ -0,0 +1,58 @@
// ©AngelaMos | 2026
// SpaceWeatherPanel.module.scss
.row {
display: grid;
grid-template-columns: 92px 1fr 1fr;
gap: 8px;
padding: 4px 8px;
align-items: center;
font-family: var(--font-mono);
font-size: var(--type-body);
font-variant-numeric: tabular-nums;
color: var(--fg-1);
}
.label {
font-family: var(--font-sans);
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
}
.value {
color: var(--fg-1);
}
.muted {
color: var(--fg-3);
}
.elevated {
color: var(--amber);
}
.classLetter {
font-family: var(--font-sans);
color: var(--fg-1);
}
.kpBar {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 2px;
height: 10px;
}
.kpSegEmpty {
border: 1px solid var(--fg-4);
}
.kpSegFilled {
background: var(--fg-2);
}
.kpSegElevated {
background: var(--amber);
}

View File

@ -0,0 +1,115 @@
// ©AngelaMos | 2026
// SpaceWeatherPanel.tsx
import { useSnapshot } from '@/api/snapshot'
import { Panel } from './Panel'
import styles from './SpaceWeatherPanel.module.scss'
const KP_SEGMENT_KEYS = [
'kp-0',
'kp-1',
'kp-2',
'kp-3',
'kp-4',
'kp-5',
'kp-6',
'kp-7',
'kp-8',
] as const
const KP_ELEVATED_THRESHOLD = 7
const STALE_AFTER_MS = 600_000
const KP_DECIMALS = 1
const DENSITY_DECIMALS = 2
const XRAY_FLUX_DECIMALS = 1
const XRAY_ELEVATED_PATTERN = /^[MX]/
interface SpaceWeatherData {
kp?: number
bz_gsm?: number
speed_kms?: number
density?: number
xray_class?: string
xray_flux?: number
ts?: string
pushed?: number
}
export function SpaceWeatherPanel(): React.ReactElement {
const { data } = useSnapshot()
const sw = (data?.space_weather as SpaceWeatherData | undefined) ?? {}
const lastTickAt = sw.ts ? new Date(sw.ts).getTime() : undefined
const isStaleByTime =
lastTickAt !== undefined && Date.now() - lastTickAt > STALE_AFTER_MS
const kpElevated = sw.kp !== undefined && sw.kp >= KP_ELEVATED_THRESHOLD
const xrayElevated =
sw.xray_class !== undefined && XRAY_ELEVATED_PATTERN.test(sw.xray_class)
const inAlarm = kpElevated || xrayElevated
return (
<Panel
title="SPACE WX"
subtitle="SWPC"
rawHref="https://services.swpc.noaa.gov/"
rawLabel="NOAA SWPC"
isStale={isStaleByTime || inAlarm}
lastTickAt={lastTickAt}
>
<div className={styles.row}>
<span className={styles.label}>Kp Index</span>
<span className={kpElevated ? styles.elevated : styles.value}>
{sw.kp !== undefined ? sw.kp.toFixed(KP_DECIMALS) : '—'}
</span>
<KpBar kp={sw.kp} elevated={kpElevated} />
</div>
<div className={styles.row}>
<span className={styles.label}>Solar Wind</span>
<span className={styles.value}>
{sw.speed_kms !== undefined ? `${Math.round(sw.speed_kms)} km/s` : '—'}
</span>
<span className={styles.value}>
{sw.density !== undefined
? `${sw.density.toFixed(DENSITY_DECIMALS)} p/cc`
: '—'}
</span>
</div>
<div className={styles.row}>
<span className={styles.label}>X-Ray Flux</span>
<span className={xrayElevated ? styles.elevated : styles.value}>
{sw.xray_flux !== undefined
? sw.xray_flux.toExponential(XRAY_FLUX_DECIMALS)
: '—'}
</span>
<span className={xrayElevated ? styles.elevated : styles.classLetter}>
{sw.xray_class ?? '—'}
</span>
</div>
</Panel>
)
}
SpaceWeatherPanel.displayName = 'SpaceWeatherPanel'
interface KpBarProps {
kp: number | undefined
elevated: boolean
}
function KpBar({ kp, elevated }: KpBarProps): React.ReactElement {
return (
<div className={styles.kpBar}>
{KP_SEGMENT_KEYS.map((key, i) => {
const filled = kp !== undefined && i < Math.floor(kp)
let cls: string
if (!filled) {
cls = styles.kpSegEmpty
} else if (elevated) {
cls = styles.kpSegElevated
} else {
cls = styles.kpSegFilled
}
return <div key={key} className={cls} />
})}
</div>
)
}