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.
623 lines
16 KiB
Svelte
623 lines
16 KiB
Svelte
<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>
|