weather_app/src/components/StormWatch.svelte
hermes-explorigin 4616c297a9
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 24s
Test, Build & Deploy / deploy (push) Successful in 18s
feat(storms): NWS alerts, SPC outlook and IEM storm reports
Adds a Storms tab driven by three keyless, CORS-enabled feeds:
- api.weather.gov/alerts/active?point=… for watches/warnings/advisories,
  with a banner above the content for tornado/severe/Extreme alerts
- SPC Day 1-3 categorical outlooks plus Day 1 tornado/hail/wind
  probabilities, resolved by point-in-polygon (nolyr GeoJSON)
- NWS Local Storm Reports via IEM (bounding box + radius cut), nearest first

New: src/lib/api/storm.js (pure helpers + cached fetchers),
src/lib/stores/storm.svelte.js (5-min polling, stale-response guard),
src/components/StormWatch.svelte. Router/nav gain a 'storms' view.
Tests: geometry/parsing (16), store (7), component render (3).
Docs: AGENTS.md storm section + API notes.
2026-10-17 18:10:46 +00:00

623 lines
16 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { app } from '../lib/stores/app.svelte.js'
import { storm } from '../lib/stores/storm.svelte.js'
const tz = $derived(app.forecastData?.timezone || undefined)
const alerts = $derived(storm.alerts)
const outlook = $derived(storm.outlook)
const reports = $derived(storm.reports)
const maxReports = 8
const SEVERITY_CLASS = {
Extreme: 'sev-extreme',
Severe: 'sev-severe',
Moderate: 'sev-moderate',
Minor: 'sev-minor',
Unknown: 'sev-minor',
}
/** Local calendar day key (YYYY-MM-DD) for an ISO timestamp. */
function localDay(iso) {
if (!iso) return null
try {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(new Date(iso))
const P = Object.fromEntries(parts.map((p) => [p.type, p.value]))
return `${P.year}-${P.month}-${P.day}`
} catch {
return null
}
}
/** "5:30 PM" in the location's timezone. */
function clock(iso) {
if (!iso) return ''
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour: 'numeric', minute: '2-digit',
}).format(new Date(iso))
} catch {
return ''
}
}
/** "Today 5:30 PM" / "Sat 2:00 AM" for an alert's end time. */
function untilLabel(iso) {
if (!iso) return ''
const day = localDay(iso)
const today = localDay(new Date().toISOString())
const d = new Date(iso)
const tomorrow = localDay(new Date(Date.now() + 86400000).toISOString())
const time = clock(iso)
if (day === today) return `until ${time}`
if (day === tomorrow) return `until tomorrow ${time}`
const weekday = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'short' }).format(d)
return `until ${weekday} ${time}`
}
/** Coarse "2h ago" label for storm reports. */
function ago(iso) {
if (!iso) return ''
const ms = Date.now() - Date.parse(iso)
if (!Number.isFinite(ms) || ms < 0) return ''
const mins = Math.round(ms / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.round(mins / 60)
if (hours < 24) return `${hours}h ago`
return `${Math.round(hours / 24)}d ago`
}
function distance(km) {
if (km == null) return ''
if (app.settings.units === 'imperial') {
const mi = km * 0.621371
return mi < 10 ? `${mi.toFixed(1)} mi` : `${Math.round(mi)} mi`
}
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`
}
function magnitudeLabel(r) {
if (r.magnitude == null) return ''
const type = String(r.type || '').toUpperCase()
if (/HAIL/.test(type)) {
const v = Number(r.magnitude)
return Number.isFinite(v) ? `${v}" hail` : ''
}
if (/WND|WIND/.test(type)) {
const v = Number(r.magnitude)
const mph = v
return Number.isFinite(v)
? app.settings.units === 'imperial'
? `${mph} mph`
: `${Math.round(mph * 1.609)} km/h`
: ''
}
return String(r.magnitude)
}
function outlookDay(dayKey) {
return outlook?.[dayKey] || null
}
function refresh() {
storm.watch({ force: true })
}
const issuedLabel = $derived(outlook?.issued ? clock(outlook.issued) : '')
</script>
<div class="storm-page">
<div class="storm-head">
<div class="storm-head-text">
<h3 class="storm-title">⛈️ Storm Watch</h3>
<p class="storm-sub">
{#if storm.updatedAt}
Updated {clock(new Date(storm.updatedAt).toISOString())}
{:else if storm.loading}
Checking watches, outlook and reports…
{:else}
Watches, severe-weather outlook and recent reports
{/if}
</p>
</div>
<button
class="btn-icon refresh-btn"
onclick={refresh}
disabled={storm.loading}
aria-label="Refresh storm data"
title="Refresh storm data"
>{storm.loading ? '⏳' : '↻'}</button>
</div>
{#if storm.error}
<div class="storm-error">{storm.error}</div>
{/if}
<!-- 1. Active NWS alerts ------------------------------------------------>
<section class="storm-card card" aria-label="Active weather alerts">
<div class="section-head">
<span class="section-icon">⚠️</span>
<span class="section-title">Active alerts</span>
{#if alerts.length}<span class="count-badge">{alerts.length}</span>{/if}
</div>
{#if alerts.length}
<ul class="alert-list">
{#each alerts as a (a.id)}
<li class="alert-item {SEVERITY_CLASS[a.severity] || 'sev-minor'}">
<div class="alert-row">
<span class="alert-event">{a.event}</span>
<span class="alert-until">{untilLabel(a.ends)}</span>
</div>
{#if a.headline}
<div class="alert-headline">{a.headline}</div>
{/if}
<details class="alert-details">
<summary>Details</summary>
{#if a.areaDesc}<div class="alert-area">{a.areaDesc}</div>{/if}
{#if a.description}<p class="alert-desc">{a.description}</p>{/if}
{#if a.instruction}<p class="alert-instruction">{a.instruction}</p>{/if}
<div class="alert-source">
{a.senderName || 'National Weather Service'} · expires {clock(a.expires)}
</div>
</details>
</li>
{/each}
</ul>
{:else if storm.loading && !storm.updatedAt}
<div class="empty-line">Checking for active alerts…</div>
{:else}
<div class="empty-line ok">✅ No active watches, warnings or advisories</div>
{/if}
</section>
<!-- 2. SPC severe-weather outlook --------------------------------------->
<section class="storm-card card" aria-label="SPC severe weather outlook">
<div class="section-head">
<span class="section-icon">🌪️</span>
<span class="section-title">Severe-weather outlook</span>
</div>
{#if outlook}
<div class="outlook-days">
{#each [['day1', 'Today'], ['day2', 'Tomorrow'], ['day3', 'Day 3']] as [key, label] (key)}
{@const r = outlookDay(key)}
{#if r}
<div
class="outlook-day"
class:risky={r.key !== 'NONE' && r.key !== 'TSTM'}
style={`--risk-color: ${r.color}`}
>
<span class="outlook-label">{label}</span>
<span class="outlook-chip">{r.short}</span>
<span class="outlook-desc">{r.label}</span>
</div>
{/if}
{/each}
</div>
{#if (outlook.probabilities?.tornado || outlook.probabilities?.wind || outlook.probabilities?.hail)}
<div class="prob-row">
<div class="prob">
<span class="prob-icon">🌪️</span>
<span class="prob-value">{outlook.probabilities.tornado}%</span>
<span class="prob-label">tornado</span>
</div>
<div class="prob">
<span class="prob-icon">💨</span>
<span class="prob-value">{outlook.probabilities.wind}%</span>
<span class="prob-label">wind</span>
</div>
<div class="prob">
<span class="prob-icon">🧊</span>
<span class="prob-value">{outlook.probabilities.hail}%</span>
<span class="prob-label">hail</span>
</div>
</div>
<div class="outlook-note">
Day 1 probability of severe weather within 25 miles of this point.
</div>
{:else if outlook.day1.key === 'NONE'}
<div class="empty-line">No severe-weather area covers this location right now.</div>
{/if}
{#if issuedLabel}
<div class="outlook-foot">SPC Day 1 outlook issued {issuedLabel} · updates ~4× daily</div>
{/if}
{:else if !storm.hasUsCoverage}
<div class="empty-line">SPC outlooks cover the United States only.</div>
{:else if storm.loading}
<div class="empty-line">Loading outlook…</div>
{:else}
<div class="empty-line">Outlook unavailable right now.</div>
{/if}
</section>
<!-- 3. Recent Local Storm Reports --------------------------------------->
<section class="storm-card card" aria-label="Recent storm reports">
<div class="section-head">
<span class="section-icon">📍</span>
<span class="section-title">Recent reports</span>
{#if reports.length}<span class="count-badge">{reports.length}</span>{/if}
</div>
{#if reports.length}
<div class="reports-sub">
Past 24 hours within {distance(160)} · nearest first
</div>
<ul class="report-list">
{#each reports.slice(0, maxReports) as r (r.id)}
<li class="report-item">
<span class="report-icon">{r.icon}</span>
<div class="report-body">
<div class="report-main">
<span class="report-type">{r.label}</span>
{#if magnitudeLabel(r)}<span class="report-mag">{magnitudeLabel(r)}</span>{/if}
</div>
<div class="report-place">
{[r.city, r.county && `${r.county} Co.`, r.state].filter(Boolean).join(', ')}
</div>
{#if r.remark}
<div class="report-remark">{r.remark}</div>
{/if}
</div>
<div class="report-meta">
<span>{distance(r.distanceKm)}</span>
<span class="report-ago">{ago(r.valid)}</span>
</div>
</li>
{/each}
</ul>
{#if reports.length > maxReports}
<div class="outlook-foot">+{reports.length - maxReports} more reports nearby</div>
{/if}
{:else if !storm.hasUsCoverage}
<div class="empty-line">Local Storm Reports cover the United States only.</div>
{:else if storm.loading && !storm.updatedAt}
<div class="empty-line">Loading recent reports…</div>
{:else}
<div class="empty-line ok">✅ No storm reports within {distance(160)} in the last 24 hours</div>
{/if}
<div class="outlook-foot">
Reports: NWS Local Storm Reports via Iowa Environmental Mesonet · Outlook: NOAA Storm Prediction Center
</div>
</section>
</div>
<style>
.storm-page {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 900px;
}
.storm-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 2px 2px 0;
}
.storm-title {
font-size: 1.05rem;
font-weight: 700;
}
.storm-sub {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-top: 2px;
}
.refresh-btn {
font-size: 1.15rem;
}
.storm-error {
font-size: 0.8rem;
color: #fca5a5;
background: rgba(239, 68, 68, 0.08);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: var(--radius-md);
padding: 8px 12px;
}
.storm-card {
padding: 14px 16px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 10px;
margin-bottom: 10px;
border-bottom: 1px solid var(--color-border);
}
.section-icon { font-size: 1rem; }
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
flex: 1;
}
.count-badge {
font-size: 0.7rem;
font-weight: 700;
min-width: 20px;
text-align: center;
padding: 2px 7px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.15);
color: var(--color-primary);
}
.empty-line {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 4px 0;
}
.empty-line.ok { color: var(--color-success); }
/* --- alerts --- */
.alert-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-item {
border-left: 3px solid var(--sev-color, var(--color-warning));
background: var(--sev-bg, rgba(245, 158, 11, 0.08));
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.sev-extreme { --sev-color: #ff2d55; --sev-bg: rgba(255, 45, 85, 0.12); }
.sev-severe { --sev-color: #ef4444; --sev-bg: rgba(239, 68, 68, 0.1); }
.sev-moderate { --sev-color: #f59e0b; --sev-bg: rgba(245, 158, 11, 0.1); }
.sev-minor { --sev-color: #38bdf8; --sev-bg: rgba(56, 189, 248, 0.07); }
.alert-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.alert-event {
font-size: 0.88rem;
font-weight: 700;
color: var(--color-text);
}
.alert-until {
font-size: 0.72rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.alert-headline {
font-size: 0.78rem;
color: var(--color-text-secondary);
margin-top: 3px;
}
.alert-details {
margin-top: 6px;
}
.alert-details summary {
font-size: 0.72rem;
color: var(--color-primary);
cursor: pointer;
width: fit-content;
list-style: none;
}
.alert-details summary::-webkit-details-marker { display: none; }
.alert-details summary::before { content: '▸ '; }
.alert-details[open] summary::before { content: '▾ '; }
.alert-area,
.alert-desc,
.alert-instruction {
font-size: 0.76rem;
color: var(--color-text-secondary);
margin-top: 6px;
white-space: pre-wrap;
line-height: 1.45;
}
.alert-instruction {
color: var(--color-text);
border-left: 2px solid var(--sev-color, var(--color-border));
padding-left: 8px;
}
.alert-source {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 6px;
}
/* --- outlook --- */
.outlook-days {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.outlook-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 6px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--color-border);
}
.outlook-day.risky {
border-color: var(--risk-color);
background: color-mix(in srgb, var(--risk-color) 14%, transparent);
}
.outlook-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.outlook-chip {
font-size: 0.95rem;
font-weight: 800;
letter-spacing: 0.02em;
color: var(--risk-color, var(--color-text));
}
.outlook-desc {
font-size: 0.68rem;
color: var(--color-text-secondary);
text-align: center;
line-height: 1.25;
}
.prob-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 12px;
}
.prob {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 8px 4px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.03);
}
.prob-icon { font-size: 0.95rem; }
.prob-value {
font-size: 0.95rem;
font-weight: 700;
}
.prob-label {
font-size: 0.68rem;
color: var(--color-text-muted);
}
.outlook-note,
.reports-sub,
.outlook-foot {
font-size: 0.7rem;
color: var(--color-text-muted);
margin-top: 8px;
}
.outlook-foot {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--color-border);
}
/* --- reports --- */
.report-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.report-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(30, 58, 102, 0.6);
}
.report-item:last-child { border-bottom: none; }
.report-icon {
font-size: 1rem;
line-height: 1.3;
}
.report-body {
flex: 1;
min-width: 0;
}
.report-main {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.report-type {
font-size: 0.85rem;
font-weight: 600;
}
.report-mag {
font-size: 0.75rem;
color: var(--color-warning);
font-weight: 600;
}
.report-place {
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-top: 1px;
}
.report-remark {
font-size: 0.72rem;
color: var(--color-text-muted);
margin-top: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.report-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
font-size: 0.7rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.report-ago {
color: var(--color-text-muted);
}
@media (max-width: 480px) {
.outlook-desc { display: none; }
.storm-card { padding: 12px; }
}
</style>