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:
parent
b67729ae13
commit
f0bb0dcb8d
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue