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.
This commit is contained in:
parent
ae71c36acc
commit
4616c297a9
39
AGENTS.md
39
AGENTS.md
@ -18,16 +18,21 @@ src/
|
||||
│ ├── DailyForecast.svelte # 7-day forecast cards with icons + detail
|
||||
│ ├── WeatherDetail.svelte # Expanded metrics: wind, atmosphere, sun/UV, temps
|
||||
│ ├── NotificationBell.svelte # Bell icon with badge; dropdown lists/dismisses alerts
|
||||
│ ├── StormWatch.svelte # Storms tab: active alerts, SPC outlook, recent reports
|
||||
│ └── SettingsDialog.svelte # Units (metric/imperial), alert thresholds, data reset
|
||||
├── lib/
|
||||
│ ├── autofocus.js # Svelte action for autofocus on mount
|
||||
│ ├── api/
|
||||
│ │ ├── weather.js # Open-Meteo fetch functions (forecast + geocoding)
|
||||
│ │ ├── nws.js # NWS forecast + official advisories normalization
|
||||
│ │ ├── storm.js # NWS alerts, SPC outlooks, IEM storm reports (+ geo helpers)
|
||||
│ │ ├── moon.js # Moon phase calculation
|
||||
│ │ └── weather-codes.js # WMO code → icon/description/severity mapping
|
||||
│ ├── storage/
|
||||
│ │ └── db.js # IndexedDB layer (idb): locations, settings
|
||||
│ └── stores/
|
||||
│ ├── app.svelte.js # AppStore: $state for locations, forecast, UI, settings
|
||||
│ ├── storm.svelte.js # StormStore: alerts/outlook/reports for the selected point
|
||||
│ └── notifications.svelte.js # Alert generation from forecast data
|
||||
```
|
||||
|
||||
@ -67,6 +72,33 @@ The notification store checks each forecast refresh for:
|
||||
|
||||
All thresholds are configurable in Settings.
|
||||
|
||||
## Storm Watch (SPC / NWS / IEM)
|
||||
|
||||
The **Storms** tab (and the red banner above the content when a tornado, severe
|
||||
thunderstorm or other Extreme alert is in force) is driven by three keyless,
|
||||
CORS-enabled feeds. All of them are fetched directly from the browser.
|
||||
|
||||
| Source | Endpoint | Refresh |
|
||||
|---|---|---|
|
||||
| NWS active alerts | `api.weather.gov/alerts/active?point={lat},{lon}` | 2 min HTTP cache |
|
||||
| SPC Day 1–3 outlook | `spc.noaa.gov/products/outlook/day{1,2,3}otlk_{cat,torn,wind,hail}.nolyr.geojson` | 20 min HTTP cache |
|
||||
| NWS Local Storm Reports | `mesonet.agron.iastate.edu/geojson/lsr.geojson?west=…&sts=…&ets=…` | 5 min HTTP cache |
|
||||
|
||||
- **SPC risk at a point** comes from point-in-polygon on the GeoJSON features
|
||||
(`LABEL` of the *highest* containing category wins: TSTM < MRGL < SLGT < ENH
|
||||
< MDT < HIGH). Day-1 tornado/hail/wind probabilities use the same test, with
|
||||
the "Less Than 2% All Areas" background polygon ignored.
|
||||
- **There is no Skywarn API** — Skywarn is a per-WFO volunteer program. The
|
||||
equivalent machine-readable ground truth is NWS Local Storm Reports (LSRs),
|
||||
which include trained-spotter and Spotter Network submissions.
|
||||
- The IEM LSR query **must include `sts`/`ets`**; a bbox alone (or `hours=`)
|
||||
returns national reports. `normalizeStormReports` then applies the exact
|
||||
radius cut (160 km by default) and sorts nearest-first.
|
||||
- Non-US points skip the SPC/LSR requests (`isUsPoint`) and the UI explains the
|
||||
US-only coverage.
|
||||
- The store de-dupes concurrent loads, ignores stale responses after a
|
||||
location switch, and polls every 5 min only while the document is visible.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
@ -86,6 +118,9 @@ All thresholds are configurable in Settings.
|
||||
- `tests/lib/storage/db.test.js` — Locations CRUD (add, update, delete, setCurrent, clear), settings CRUD with defaults
|
||||
- `tests/lib/stores/app.test.js` — AppStore initialization and derived selectedLocation
|
||||
- `tests/lib/stores/notifications.test.js` — Alert generation for all alert types, dismissal, reset, day filtering
|
||||
- `tests/lib/api/storm.test.js` — Point-in-polygon, risk ranking, SPC probabilities, LSR radius filter, alert sorting
|
||||
- `tests/lib/stores/storm.test.js` — StormStore loading, caching, location switches, partial failures, non-US points
|
||||
- `tests/components/storm-watch.test.js` — StormWatch renders populated and empty states (Svelte `mount` in jsdom)
|
||||
- Run with `npm run test` or `npm run test:run`
|
||||
|
||||
## API Notes
|
||||
@ -95,3 +130,7 @@ All thresholds are configurable in Settings.
|
||||
- Geocoding API returns up to 8 results per query with name, coordinates, country, admin1, timezone.
|
||||
- Rate limits are generous (~10,000 requests/day for the free tier).
|
||||
- Used only via `fetch()` in browser — no server-side API calls needed.
|
||||
- SPC outlook files are regenerated at each issuance (roughly 12Z/13Z/16:30Z/01Z);
|
||||
never hardcode a timestamp, just re-fetch the stable `.nolyr.geojson` names.
|
||||
- NWS/SPC/IEM all send `Access-Control-Allow-Origin: *`, so the static single-file
|
||||
build can call them without a proxy.
|
||||
|
||||
17
dist/favicon.svg
vendored
17
dist/favicon.svg
vendored
@ -1,17 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48">
|
||||
<!-- Sun circle -->
|
||||
<circle cx="22" cy="22" r="12" fill="#fbbf24" />
|
||||
<!-- Sun rays -->
|
||||
<g stroke="#fbbf24" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="22" y1="4" x2="22" y2="9" />
|
||||
<line x1="22" y1="35" x2="22" y2="40" />
|
||||
<line x1="4" y1="22" x2="9" y2="22" />
|
||||
<line x1="35" y1="22" x2="40" y2="22" />
|
||||
<line x1="9.5" y1="9.5" x2="13" y2="13" />
|
||||
<line x1="31" y1="31" x2="34.5" y2="34.5" />
|
||||
<line x1="9.5" y1="34.5" x2="13" y2="31" />
|
||||
<line x1="31" y1="13" x2="34.5" y2="9.5" />
|
||||
</g>
|
||||
<!-- Cloud overlay -->
|
||||
<path d="M22 34 Q16 34 14 31 Q12 28 15 27 Q13 23 17 21 Q21 19 25 21 Q29 17 34 19 Q38 21 37 26 Q41 27 39 31 Q37 35 22 34Z" fill="#60a5fa" opacity="0.9" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 815 B |
1
dist/icons.svg
vendored
1
dist/icons.svg
vendored
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" viewBox="0 0 0 0" />
|
||||
|
Before Width: | Height: | Size: 82 B |
1962
dist/index.html
vendored
1962
dist/index.html
vendored
File diff suppressed because it is too large
Load Diff
24
dist/pwa-icon.svg
vendored
24
dist/pwa-icon.svg
vendored
@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0ea5e9" />
|
||||
<stop offset="1" stop-color="#132744" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="512" height="512" rx="112" ry="112" fill="url(#bg)" />
|
||||
<circle cx="256" cy="256" r="196" fill="#fbbf24" opacity="0.92" />
|
||||
<g stroke="#fbbf24" stroke-width="12" stroke-linecap="round" opacity="0.9">
|
||||
<line x1="256" y1="92" x2="256" y2="140" />
|
||||
<line x1="256" y1="372" x2="256" y2="420" />
|
||||
<line x1="92" y1="256" x2="140" y2="256" />
|
||||
<line x1="372" y1="256" x2="420" y2="256" />
|
||||
<line x1="142" y1="142" x2="184" y2="184" />
|
||||
<line x1="328" y1="328" x2="370" y2="370" />
|
||||
<line x1="142" y1="328" x2="184" y2="370" />
|
||||
<line x1="328" y1="142" x2="370" y2="184" />
|
||||
</g>
|
||||
<path d="M256 332 Q214 332 196 306 Q184 276 192 256 Q188 232 214 218 Q206 204 238 196
|
||||
Q246 190 262 194 Q268 178 296 190 Q300 208 314 216 Q328 226 326 238
|
||||
Q342 246 344 268 Q348 288 336 300 Q330 318 316 330 Q310 344 256 332Z"
|
||||
fill="#38bdf8" opacity="0.95" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@ -1,12 +1,14 @@
|
||||
<script>
|
||||
import { app } from './lib/stores/app.svelte.js'
|
||||
import { notifications } from './lib/stores/notifications.svelte.js'
|
||||
import { storm } from './lib/stores/storm.svelte.js'
|
||||
import LocationSidebar from './components/LocationSidebar.svelte'
|
||||
import CurrentWeather from './components/CurrentWeather.svelte'
|
||||
import HourlyForecast from './components/HourlyForecast.svelte'
|
||||
import DailyForecast from './components/DailyForecast.svelte'
|
||||
import WeatherDetail from './components/WeatherDetail.svelte'
|
||||
import PrecipitationRadar from './components/PrecipitationRadar.svelte'
|
||||
import StormWatch from './components/StormWatch.svelte'
|
||||
import NotificationBell from './components/NotificationBell.svelte'
|
||||
import SettingsDialog from './components/SettingsDialog.svelte'
|
||||
import AddLocationDialog from './components/AddLocationDialog.svelte'
|
||||
@ -159,6 +161,10 @@
|
||||
notifications.refreshAdvisories() // official NWS advisories (cached, best-effort)
|
||||
}
|
||||
|
||||
// Storm feeds (alerts, SPC outlook, storm reports) for the location.
|
||||
storm.watch()
|
||||
storm.start()
|
||||
|
||||
initDone = true
|
||||
}
|
||||
|
||||
@ -170,6 +176,14 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Refresh storm data whenever the selected location changes. The store skips
|
||||
// the fetch when it already holds fresh data for that point.
|
||||
$effect(() => {
|
||||
if (!initDone) return
|
||||
if (app.selectedLocation) storm.watch()
|
||||
else storm.reset()
|
||||
})
|
||||
|
||||
// Restart the auto-refresh timer whenever the interval preference changes
|
||||
$effect(() => {
|
||||
app.settings.refreshInterval
|
||||
@ -318,6 +332,22 @@
|
||||
|
||||
<!-- Weather content -->
|
||||
{#if app.forecastData && app.selectedLocation}
|
||||
<!-- Active tornado / severe-thunderstorm / extreme alert banner -->
|
||||
{#if storm.banner}
|
||||
<button
|
||||
class="storm-banner animate-slide-down"
|
||||
onclick={() => setView('storms')}
|
||||
aria-label="{storm.banner.event} — open storm details"
|
||||
>
|
||||
<span class="storm-banner-icon">⚠️</span>
|
||||
<span class="storm-banner-text">
|
||||
<strong>{storm.banner.event}</strong>
|
||||
<span class="storm-banner-area">{storm.banner.areaDesc.split(';').slice(0, 2).join(' · ')}</span>
|
||||
</span>
|
||||
<span class="storm-banner-cta">Details ›</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<div class="mobile-nav">
|
||||
<button
|
||||
@ -345,6 +375,11 @@
|
||||
class:active={viewMode === 'radar'}
|
||||
onclick={() => setView('radar')}
|
||||
>Radar</button>
|
||||
<button
|
||||
class="nav-btn"
|
||||
class:active={viewMode === 'storms'}
|
||||
onclick={() => setView('storms')}
|
||||
>Storms</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop tab bar -->
|
||||
@ -354,6 +389,7 @@
|
||||
<button class="tab-btn" class:active={viewMode === 'daily'} onclick={() => setView('daily')}>7-Day Forecast</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'detail'} onclick={() => setView('detail')}>Details</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'radar'} onclick={() => setView('radar')}>Radar</button>
|
||||
<button class="tab-btn" class:active={viewMode === 'storms'} onclick={() => setView('storms')}>Storms</button>
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
@ -391,6 +427,10 @@
|
||||
<div class="animate-fade-in">
|
||||
<PrecipitationRadar />
|
||||
</div>
|
||||
{:else if viewMode === 'storms'}
|
||||
<div class="animate-fade-in">
|
||||
<StormWatch />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -604,6 +644,55 @@
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Active severe-alert banner */
|
||||
.storm-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin: 8px 0 0;
|
||||
padding: 10px 14px;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.storm-banner-icon { font-size: 1.1rem; }
|
||||
|
||||
.storm-banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.storm-banner-area {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storm-banner-cta {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fca5a5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.storm-banner {
|
||||
margin: 6px 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
|
||||
622
src/components/StormWatch.svelte
Normal file
622
src/components/StormWatch.svelte
Normal file
@ -0,0 +1,622 @@
|
||||
<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>
|
||||
448
src/lib/api/storm.js
Normal file
448
src/lib/api/storm.js
Normal file
@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Storm-forecasting data sources.
|
||||
*
|
||||
* Three independent, keyless, CORS-enabled feeds:
|
||||
* 1. NWS active alerts — api.weather.gov/alerts/active?point=… (watches/warnings)
|
||||
* 2. SPC convective outlooks — spc.noaa.gov/products/outlook/*.nolyr.geojson
|
||||
* (Day 1–3 categorical risk + Day 1 tornado/hail/wind probabilities)
|
||||
* 3. IEM Local Storm Reports — mesonet.agron.iastate.edu/geojson/lsr.geojson
|
||||
* (ground-truth reports relayed from NWS WFOs, incl. trained spotters)
|
||||
*
|
||||
* Everything here is plain browser fetch + pure normalization, so the helpers
|
||||
* are unit-testable without a network (see tests/lib/api/storm.test.js).
|
||||
*/
|
||||
|
||||
const SPC_BASE = 'https://www.spc.noaa.gov/products/outlook'
|
||||
const IEM_LSR = 'https://mesonet.agron.iastate.edu/geojson/lsr.geojson'
|
||||
const NWS_API = 'https://api.weather.gov'
|
||||
const NWS_USER_AGENT = 'WeatherLens/0.1 (weather app)'
|
||||
|
||||
// ===========================================================================
|
||||
// Risk levels
|
||||
// ===========================================================================
|
||||
|
||||
/** SPC categorical outlook levels, weakest → strongest. */
|
||||
export const RISK_RANK = { NONE: 0, TSTM: 1, MRGL: 2, SLGT: 3, ENH: 4, MDT: 5, HIGH: 6 }
|
||||
|
||||
/** Display metadata per SPC category (colors follow the official SPC palette). */
|
||||
export const RISK_META = {
|
||||
NONE: { label: 'No outlook', short: '—', color: '#7b8eb5', rank: 0 },
|
||||
TSTM: { label: 'General thunderstorms', short: 'TSTM', color: '#55bb55', rank: 1 },
|
||||
MRGL: { label: 'Marginal risk', short: 'MRGL', color: '#66ff66', rank: 2 },
|
||||
SLGT: { label: 'Slight risk', short: 'SLGT', color: '#ffff00', rank: 3 },
|
||||
ENH: { label: 'Enhanced risk', short: 'ENH', color: '#ffa500', rank: 4 },
|
||||
MDT: { label: 'Moderate risk', short: 'MDT', color: '#ff0000', rank: 5 },
|
||||
HIGH: { label: 'High risk', short: 'HIGH', color: '#ff00ff', rank: 6 },
|
||||
}
|
||||
|
||||
/** Normalize an SPC `LABEL` ("TSTM", "SLGT", "ENH"…) into a RISK_RANK key. */
|
||||
export function riskKey(label) {
|
||||
const k = String(label || '').trim().toUpperCase()
|
||||
return Object.prototype.hasOwnProperty.call(RISK_RANK, k) ? k : 'NONE'
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Geometry helpers
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Ray-casting point-in-ring test. Ring is [[lon, lat], …] and may be open or
|
||||
* closed; both are accepted. Longitude/latitude (GeoJSON order) throughout.
|
||||
* @param {number} lon
|
||||
* @param {number} lat
|
||||
* @param {number[][]} ring
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInRing(lon, lat, ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 3) return false
|
||||
let inside = false
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0]
|
||||
const yi = ring[i][1]
|
||||
const xj = ring[j][0]
|
||||
const yj = ring[j][1]
|
||||
if (xi == null || yi == null || xj == null || yj == null) continue
|
||||
const intersects = (yi > lat) !== (yj > lat) &&
|
||||
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi
|
||||
if (intersects) inside = !inside
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
/**
|
||||
* Point-in-polygon for a GeoJSON Polygon coordinate array: first ring is the
|
||||
* outer boundary, any further rings are holes.
|
||||
* @param {number} lon
|
||||
* @param {number} lat
|
||||
* @param {number[][][]} coords
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function pointInPolygon(lon, lat, coords) {
|
||||
if (!Array.isArray(coords) || !coords.length) return false
|
||||
if (!pointInRing(lon, lat, coords[0])) return false
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
if (pointInRing(lon, lat, coords[i])) return false // inside a hole
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Point-in-geometry for Polygon / MultiPolygon (other types are false). */
|
||||
export function pointInGeometry(lon, lat, geometry) {
|
||||
if (!geometry) return false
|
||||
if (geometry.type === 'Polygon') return pointInPolygon(lon, lat, geometry.coordinates)
|
||||
if (geometry.type === 'MultiPolygon') {
|
||||
return (geometry.coordinates || []).some((poly) => pointInPolygon(lon, lat, poly))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const dLat = toRad(lat2 - lat1)
|
||||
const dLon = toRad(lon2 - lon1)
|
||||
const a = Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SPC outlooks
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* URL for one SPC outlook product. `nolyr` = plain polygon overlay (no labels),
|
||||
* the lightest machine-readable variant (~1–2 KB per day).
|
||||
* @param {number} day 1..3
|
||||
* @param {'cat'|'torn'|'wind'|'hail'} kind
|
||||
*/
|
||||
export function spcOutlookUrl(day, kind = 'cat') {
|
||||
return `${SPC_BASE}/day${day}otlk_${kind}.nolyr.geojson`
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest SPC categorical risk category whose polygon contains the point.
|
||||
* @param {number} lon
|
||||
* @param {number} lat
|
||||
* @param {object} geojson FeatureCollection from spc.noaa.gov
|
||||
* @returns {{ key: string, label: string, short: string, color: string, props: object|null }}
|
||||
*/
|
||||
export function spcRiskAtPoint(lon, lat, geojson) {
|
||||
let best = null
|
||||
for (const f of geojson?.features || []) {
|
||||
if (!pointInGeometry(lon, lat, f.geometry)) continue
|
||||
const key = riskKey(f.properties?.LABEL)
|
||||
if (!best || RISK_RANK[key] > RISK_RANK[best.key]) {
|
||||
best = { key, props: f.properties || {} }
|
||||
}
|
||||
}
|
||||
if (!best) return { key: 'NONE', ...RISK_META.NONE, props: null }
|
||||
const meta = RISK_META[best.key]
|
||||
return { key: best.key, label: meta.label, short: meta.short, color: meta.color, rank: meta.rank, props: best.props }
|
||||
}
|
||||
|
||||
/**
|
||||
* Highest probability (%) whose polygon contains the point, for one SPC
|
||||
* probabilistic product. The "Less Than 2% All Areas" background polygon is
|
||||
* ignored so an empty region reads as 0 rather than "all areas".
|
||||
* @param {number} lon
|
||||
* @param {number} lat
|
||||
* @param {object} geojson
|
||||
* @returns {number}
|
||||
*/
|
||||
export function spcProbabilityAtPoint(lon, lat, geojson) {
|
||||
let max = 0
|
||||
for (const f of geojson?.features || []) {
|
||||
const label = String(f.properties?.LABEL || '')
|
||||
if (/less than/i.test(label)) continue
|
||||
const m = label.match(/\d+/)
|
||||
if (!m) continue
|
||||
if (!pointInGeometry(lon, lat, f.geometry)) continue
|
||||
max = Math.max(max, Number(m[0]))
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse the six SPC responses into the shape the UI consumes.
|
||||
* Any input may be null (a product that isn't issued yet).
|
||||
* @param {{ cat1?: object, cat2?: object, cat3?: object, torn?: object, wind?: object, hail?: object }} src
|
||||
* @param {number} lon
|
||||
* @param {number} lat
|
||||
*/
|
||||
export function normalizeOutlook(src, lon, lat) {
|
||||
const day = (cat) => (cat ? spcRiskAtPoint(lon, lat, cat) : { key: 'NONE', ...RISK_META.NONE, props: null })
|
||||
const issued = src.cat1?.features?.[0]?.properties?.ISSUE_ISO || null
|
||||
const valid = src.cat1?.features?.[0]?.properties?.VALID_ISO || null
|
||||
return {
|
||||
day1: day(src.cat1),
|
||||
day2: day(src.cat2),
|
||||
day3: day(src.cat3),
|
||||
probabilities: {
|
||||
tornado: src.torn ? spcProbabilityAtPoint(lon, lat, src.torn) : 0,
|
||||
wind: src.wind ? spcProbabilityAtPoint(lon, lat, src.wind) : 0,
|
||||
hail: src.hail ? spcProbabilityAtPoint(lon, lat, src.hail) : 0,
|
||||
},
|
||||
issued,
|
||||
valid,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough US coverage check (CONUS + AK + HI) — SPC products are US-only.
|
||||
* The boxes intentionally err wide (southern Canada / northern Mexico fall
|
||||
* inside the CONUS band); actual outlook coverage comes from the SPC polygons,
|
||||
* this only avoids pointless requests far outside the US.
|
||||
*/
|
||||
export function isUsPoint(lat, lon) {
|
||||
if (lat == null || lon == null) return false
|
||||
const conus = lat >= 24 && lat <= 50 && lon >= -125 && lon <= -66
|
||||
const alaska = lat >= 50 && lat <= 72 && lon >= -170 && lon <= -129
|
||||
const hawaii = lat >= 18.5 && lat <= 22.5 && lon >= -161 && lon <= -154
|
||||
return conus || alaska || hawaii
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Local storm reports (IEM / NWS LSRs)
|
||||
// ===========================================================================
|
||||
|
||||
/** Emoji for an LSR `typetext`, e.g. "TSTM WND GST", "FLASH FLOOD", "HAIL". */
|
||||
export function reportIcon(typetext) {
|
||||
const t = String(typetext || '').toLowerCase()
|
||||
if (/tornado|funnel/.test(t)) return '🌪️'
|
||||
if (/hail/.test(t)) return '🧊'
|
||||
if (/wind|gust|wnd/.test(t)) return '💨'
|
||||
if (/flood|rain|precip/.test(t)) return '🌊'
|
||||
if (/snow|ice|freez|winter/.test(t)) return '❄️'
|
||||
if (/fog|visibility/.test(t)) return '🌫️'
|
||||
if (/heat|fire/.test(t)) return '🔥'
|
||||
if (/lightning|tstm/.test(t)) return '⛈️'
|
||||
return '📍'
|
||||
}
|
||||
|
||||
/** Human label for an LSR type code, keeping NWS shorthand readable. */
|
||||
export function reportLabel(typetext) {
|
||||
const t = String(typetext || '').trim()
|
||||
if (!t) return 'Storm report'
|
||||
if (/^TORNADO$/i.test(t)) return 'Tornado'
|
||||
if (/^FUNNEL CLOUD$/i.test(t)) return 'Funnel cloud'
|
||||
if (/^HAIL$/i.test(t)) return 'Hail'
|
||||
if (/^TSTM WND GST$/i.test(t)) return 'Thunderstorm wind gust'
|
||||
if (/^TSTM WND DMG$/i.test(t)) return 'Thunderstorm wind damage'
|
||||
if (/^NON-TSTM WND GST$/i.test(t)) return 'Non-thunderstorm wind gust'
|
||||
if (/^NON-TSTM WND DMG$/i.test(t)) return 'Non-thunderstorm wind damage'
|
||||
if (/FLASH FLOOD/i.test(t)) return 'Flash flood'
|
||||
if (/^FLOOD$/i.test(t)) return 'Flood'
|
||||
return t.charAt(0) + t.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an IEM LSR GeoJSON response into entries sorted nearest-first,
|
||||
* dropping anything outside `radiusKm`.
|
||||
* @param {object} geojson
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {number} [radiusKm=160]
|
||||
*/
|
||||
export function normalizeStormReports(geojson, lat, lon, radiusKm = 160) {
|
||||
const out = []
|
||||
for (const f of geojson?.features || []) {
|
||||
const p = f.properties || {}
|
||||
const coords = f.geometry?.coordinates
|
||||
const rLat = p.lat ?? coords?.[1]
|
||||
const rLon = p.lon ?? coords?.[0]
|
||||
if (rLat == null || rLon == null) continue
|
||||
const distanceKm = haversineKm(lat, lon, rLat, rLon)
|
||||
if (distanceKm > radiusKm) continue
|
||||
const magnitude = p.magnitude != null && p.magnitude !== '' ? p.magnitude : null
|
||||
out.push({
|
||||
id: p.product_id
|
||||
? `${p.product_id}-${p.valid}-${p.city}`
|
||||
: `${rLat},${rLon}-${p.valid}-${p.typetext}`,
|
||||
type: p.typetext || '',
|
||||
label: reportLabel(p.typetext),
|
||||
icon: reportIcon(p.typetext),
|
||||
magnitude,
|
||||
magnitudeUnit: p.unit || null,
|
||||
city: p.city || '',
|
||||
county: p.county || '',
|
||||
state: p.state || p.st || '',
|
||||
source: p.source || '',
|
||||
remark: p.remark || '',
|
||||
valid: p.valid || null,
|
||||
lat: rLat,
|
||||
lon: rLon,
|
||||
distanceKm: Math.round(distanceKm * 10) / 10,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) => a.distanceKm - b.distanceKm)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// NWS active alerts (watches, warnings, advisories)
|
||||
// ===========================================================================
|
||||
|
||||
const SEVERITY_RANK = { Extreme: 3, Severe: 2, Moderate: 1, Minor: 0, Unknown: 0 }
|
||||
|
||||
/** Map an NWS alert event to the app's alert buckets (mirrors nws.js). */
|
||||
export function alertType(event) {
|
||||
const t = String(event || '').toLowerCase()
|
||||
if (/heat/.test(t)) return 'heat'
|
||||
if (/wind|gust|breez/.test(t)) return 'wind'
|
||||
if (/winter|snow|blizzard|freez|frost|ice|cold/.test(t)) return 'cold'
|
||||
if (/thunderstorm|tornado|severe|storm surge/.test(t)) return 'storm'
|
||||
if (/flood/.test(t)) return 'flood'
|
||||
return 'advisory'
|
||||
}
|
||||
|
||||
/** Is this alert one that deserves a page-level banner? */
|
||||
export function isUrgentWarning(event, severity) {
|
||||
const t = String(event || '').toLowerCase()
|
||||
if (/tornado|severe thunderstorm|flash flood emergency|extreme wind/.test(t) && /warning|emergency/.test(t)) return true
|
||||
return String(severity || '') === 'Extreme'
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an `/alerts/active` GeoJSON response into display entries, most
|
||||
* severe first (then soonest ending).
|
||||
* @param {object} geojson
|
||||
*/
|
||||
export function normalizeActiveAlerts(geojson) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
for (const f of geojson?.features || []) {
|
||||
const p = f.properties || {}
|
||||
if (p.status && p.status !== 'Actual') continue
|
||||
if (p.messageType && p.messageType === 'Cancel') continue
|
||||
const event = p.event || 'Weather Alert'
|
||||
const id = p.id || `${event}-${p.onset || p.effective || ''}`
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push({
|
||||
id,
|
||||
event,
|
||||
type: alertType(event),
|
||||
severity: p.severity || 'Unknown',
|
||||
severityRank: SEVERITY_RANK[p.severity] ?? 0,
|
||||
urgency: p.urgency || '',
|
||||
certainty: p.certainty || '',
|
||||
headline: p.headline || '',
|
||||
description: p.description || '',
|
||||
instruction: p.instruction || '',
|
||||
areaDesc: p.areaDesc || '',
|
||||
senderName: p.senderName || '',
|
||||
onset: p.onset || p.effective || null,
|
||||
ends: p.ends || p.expires || null,
|
||||
expires: p.expires || p.ends || null,
|
||||
geometry: f.geometry || null,
|
||||
})
|
||||
}
|
||||
return out.sort((a, b) =>
|
||||
b.severityRank - a.severityRank ||
|
||||
(Date.parse(a.ends || 0) || Infinity) - (Date.parse(b.ends || 0) || Infinity)
|
||||
)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Fetching (in-memory cache + in-flight dedupe)
|
||||
// ===========================================================================
|
||||
|
||||
const TTL = {
|
||||
outlook: 20 * 60 * 1000, // SPC issues ~4×/day; 20 min is plenty
|
||||
reports: 5 * 60 * 1000,
|
||||
alerts: 2 * 60 * 1000,
|
||||
}
|
||||
|
||||
const _cache = new Map() // url -> { at, data }
|
||||
const _inflight = new Map() // url -> Promise
|
||||
|
||||
async function cachedJson(url, ttl, headers = {}) {
|
||||
const hit = _cache.get(url)
|
||||
if (hit && Date.now() - hit.at < ttl) return hit.data
|
||||
if (_inflight.has(url)) return _inflight.get(url)
|
||||
const p = (async () => {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/geo+json, application/json', ...headers },
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`)
|
||||
const data = await res.json()
|
||||
_cache.set(url, { at: Date.now(), data })
|
||||
return data
|
||||
})()
|
||||
_inflight.set(url, p)
|
||||
try {
|
||||
return await p
|
||||
} finally {
|
||||
_inflight.delete(url)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop all cached storm data (used by tests / manual refresh). */
|
||||
export function clearStormCache() {
|
||||
_cache.clear()
|
||||
_inflight.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Day 1–3 categorical outlooks plus Day 1 probabilities and resolve them
|
||||
* against a point. Missing products degrade to `null` instead of throwing.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
*/
|
||||
export async function fetchSpcOutlook(lat, lon) {
|
||||
const urls = {
|
||||
cat1: spcOutlookUrl(1, 'cat'),
|
||||
cat2: spcOutlookUrl(2, 'cat'),
|
||||
cat3: spcOutlookUrl(3, 'cat'),
|
||||
torn: spcOutlookUrl(1, 'torn'),
|
||||
wind: spcOutlookUrl(1, 'wind'),
|
||||
hail: spcOutlookUrl(1, 'hail'),
|
||||
}
|
||||
const keys = Object.keys(urls)
|
||||
const settled = await Promise.allSettled(keys.map((k) => cachedJson(urls[k], TTL.outlook)))
|
||||
const src = {}
|
||||
settled.forEach((r, i) => {
|
||||
src[keys[i]] = r.status === 'fulfilled' ? r.value : null
|
||||
})
|
||||
if (keys.every((k) => src[k] == null)) throw new Error('SPC outlook unavailable')
|
||||
return normalizeOutlook(src, lon, lat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch NWS Local Storm Reports inside a bounding box around the point, then
|
||||
* filter to a radius.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
* @param {{ hours?: number, radiusKm?: number }} [opts]
|
||||
*/
|
||||
export async function fetchStormReports(lat, lon, { hours = 24, radiusKm = 160 } = {}) {
|
||||
// ~1.4° of latitude ≈ 155 km; the box is slightly wider than the radius so
|
||||
// the exact cut can happen in normalizeStormReports without clipping edges.
|
||||
const pad = Math.max((radiusKm / 111) * 1.4, 1.5)
|
||||
const lonPad = pad / Math.max(Math.cos((lat * Math.PI) / 180), 0.2)
|
||||
const ets = new Date()
|
||||
const sts = new Date(ets.getTime() - hours * 3600 * 1000)
|
||||
const iso = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
const params = new URLSearchParams({
|
||||
west: String(lon - lonPad),
|
||||
east: String(lon + lonPad),
|
||||
north: String(lat + pad),
|
||||
south: String(lat - pad),
|
||||
sts: iso(sts),
|
||||
ets: iso(ets),
|
||||
})
|
||||
const data = await cachedJson(`${IEM_LSR}?${params}`, TTL.reports)
|
||||
return normalizeStormReports(data, lat, lon, radiusKm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active NWS alerts for a point. Throws outside the NWS domain.
|
||||
* @param {number} lat
|
||||
* @param {number} lon
|
||||
*/
|
||||
export async function fetchWeatherAlerts(lat, lon) {
|
||||
const url = `${NWS_API}/alerts/active?point=${lat},${lon}`
|
||||
const data = await cachedJson(url, TTL.alerts, { 'User-Agent': NWS_USER_AGENT })
|
||||
return normalizeActiveAlerts(data)
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
* There is no nested route structure — just a single top-level view segment.
|
||||
*/
|
||||
|
||||
export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar']
|
||||
export const VALID_VIEWS = ['current', 'hourly', 'daily', 'detail', 'radar', 'storms']
|
||||
export const DEFAULT_VIEW = 'current'
|
||||
|
||||
/**
|
||||
|
||||
154
src/lib/stores/storm.svelte.js
Normal file
154
src/lib/stores/storm.svelte.js
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Storm-watch store: holds the three storm feeds for the selected location and
|
||||
* keeps them fresh.
|
||||
*
|
||||
* The app is a static single-page build, so this is deliberately polling with a
|
||||
* modest cadence (SPC outlooks change a few times a day, LSRs arrive in bursts,
|
||||
* alerts can change any minute) plus an in-module HTTP cache that dedupes
|
||||
* requests across tabs/remounts.
|
||||
*/
|
||||
|
||||
import { app } from './app.svelte.js'
|
||||
import {
|
||||
fetchSpcOutlook,
|
||||
fetchStormReports,
|
||||
fetchWeatherAlerts,
|
||||
isUsPoint,
|
||||
isUrgentWarning,
|
||||
} from '../api/storm.js'
|
||||
|
||||
/** How often the visible storm data is refreshed. */
|
||||
export const STORM_REFRESH_MS = 5 * 60 * 1000
|
||||
|
||||
export class StormStore {
|
||||
/** @type {Array} active NWS alerts, most severe first */
|
||||
alerts = $state([])
|
||||
/** SPC outlook for the point, or null outside the US / before first load */
|
||||
outlook = $state(null)
|
||||
/** @type {Array} nearby Local Storm Reports, nearest first */
|
||||
reports = $state([])
|
||||
loading = $state(false)
|
||||
/** Non-fatal message when one or more feeds failed. */
|
||||
error = $state('')
|
||||
/** Epoch ms of the last successful load. */
|
||||
updatedAt = $state(0)
|
||||
|
||||
/** Location key the data belongs to ("lat,lon"), guards stale responses. */
|
||||
_key = null
|
||||
_timer = null
|
||||
_promise = null
|
||||
|
||||
/** Does this location have SPC/LSR coverage (US only)? */
|
||||
get hasUsCoverage() {
|
||||
const loc = app.selectedLocation
|
||||
return !!loc && isUsPoint(loc.lat, loc.lon)
|
||||
}
|
||||
|
||||
/** The one alert that deserves a banner above the content (if any). */
|
||||
get banner() {
|
||||
for (const a of this.alerts) {
|
||||
if (isUrgentWarning(a.event, a.severity)) return a
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Number of active alerts, for nav badges. */
|
||||
get alertCount() {
|
||||
return this.alerts.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the storm feeds for the selected location, unless the data for this
|
||||
* location is still fresh (pass `force` to bypass).
|
||||
* @param {{ force?: boolean }} [opts]
|
||||
*/
|
||||
async watch({ force = false } = {}) {
|
||||
const loc = app.selectedLocation
|
||||
if (!loc) {
|
||||
this.reset()
|
||||
return
|
||||
}
|
||||
const key = `${loc.lat},${loc.lon}`
|
||||
if (!force && key === this._key && this.updatedAt && Date.now() - this.updatedAt < STORM_REFRESH_MS) {
|
||||
return
|
||||
}
|
||||
if (this._promise) return this._promise
|
||||
|
||||
this._key = key
|
||||
this.loading = true
|
||||
this._promise = this._load(loc, key).finally(() => {
|
||||
this.loading = false
|
||||
this._promise = null
|
||||
})
|
||||
return this._promise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lat: number, lon: number }} loc
|
||||
* @param {string} key
|
||||
*/
|
||||
async _load(loc, key) {
|
||||
const us = isUsPoint(loc.lat, loc.lon)
|
||||
const [alertsRes, outlookRes, reportsRes] = await Promise.allSettled([
|
||||
fetchWeatherAlerts(loc.lat, loc.lon),
|
||||
us ? fetchSpcOutlook(loc.lat, loc.lon) : Promise.resolve(null),
|
||||
us ? fetchStormReports(loc.lat, loc.lon) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
// A location switch happened while we were in flight — drop this result.
|
||||
if (key !== this._key) return
|
||||
|
||||
const failed = []
|
||||
if (alertsRes.status === 'fulfilled') {
|
||||
this.alerts = alertsRes.value
|
||||
} else {
|
||||
this.alerts = []
|
||||
failed.push('alerts')
|
||||
}
|
||||
if (outlookRes.status === 'fulfilled') {
|
||||
this.outlook = outlookRes.value
|
||||
} else {
|
||||
this.outlook = null
|
||||
failed.push('outlook')
|
||||
}
|
||||
if (reportsRes.status === 'fulfilled') {
|
||||
this.reports = reportsRes.value
|
||||
} else {
|
||||
this.reports = []
|
||||
failed.push('reports')
|
||||
}
|
||||
this.error = failed.length && failed.length < 3
|
||||
? `Some storm data unavailable (${failed.join(', ')})`
|
||||
: ''
|
||||
this.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
/** Start the refresh timer (idempotent). */
|
||||
start() {
|
||||
if (this._timer) return
|
||||
this._timer = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
this.watch({ force: true })
|
||||
}, STORM_REFRESH_MS)
|
||||
}
|
||||
|
||||
/** Stop the refresh timer. */
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer)
|
||||
this._timer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear all data (no location selected). */
|
||||
reset() {
|
||||
this._key = null
|
||||
this.alerts = []
|
||||
this.outlook = null
|
||||
this.reports = []
|
||||
this.error = ''
|
||||
this.updatedAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const storm = new StormStore()
|
||||
104
tests/components/storm-watch.test.js
Normal file
104
tests/components/storm-watch.test.js
Normal file
@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, unmount, tick } from 'svelte'
|
||||
import StormWatch from '../../src/components/StormWatch.svelte'
|
||||
import { storm } from '../../src/lib/stores/storm.svelte.js'
|
||||
import { app } from '../../src/lib/stores/app.svelte.js'
|
||||
|
||||
const risk = (key, label, short, color) => ({ key, label, short, color, props: null })
|
||||
|
||||
describe('StormWatch component', () => {
|
||||
let target
|
||||
let instance
|
||||
|
||||
beforeEach(() => {
|
||||
app.locations = [{ id: 'x', name: 'Home', lat: 36.3567, lon: -95.8146, isCurrent: true }]
|
||||
app.selectedLocationId = 'x'
|
||||
app.settings = { ...app.settings, units: 'imperial' }
|
||||
app.forecastData = { timezone: 'America/Chicago' }
|
||||
storm.updatedAt = Date.now()
|
||||
target = document.createElement('div')
|
||||
document.body.appendChild(target)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (instance) await unmount(instance)
|
||||
target?.remove()
|
||||
instance = null
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders alerts, the outlook and nearby reports', async () => {
|
||||
storm.alerts = [{
|
||||
id: 'a1', event: 'Tornado Warning', type: 'storm', severity: 'Extreme',
|
||||
severityRank: 3, headline: 'Tornado Warning until 5:30 PM',
|
||||
description: 'Take shelter now.', instruction: 'Go to an interior room.',
|
||||
areaDesc: 'Rogers, OK', senderName: 'NWS Tulsa OK',
|
||||
ends: '2026-10-17T22:30:00Z', expires: '2026-10-17T22:45:00Z',
|
||||
}]
|
||||
storm.outlook = {
|
||||
day1: risk('SLGT', 'Slight risk', 'SLGT', '#ffff00'),
|
||||
day2: risk('TSTM', 'General thunderstorms', 'TSTM', '#55bb55'),
|
||||
day3: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
||||
probabilities: { tornado: 5, wind: 15, hail: 30 },
|
||||
issued: '2026-10-17T16:14:00Z',
|
||||
}
|
||||
storm.reports = [{
|
||||
id: 'r1', type: 'HAIL', label: 'Hail', icon: '🧊', magnitude: '1.75',
|
||||
city: 'Claremore', county: 'Rogers', state: 'OK', source: 'Trained Spotter',
|
||||
remark: 'Quarter size hail on Highway 20', valid: '2026-10-17T21:00:00Z',
|
||||
lat: 36.3, lon: -95.6, distanceKm: 12.4,
|
||||
}]
|
||||
|
||||
instance = mount(StormWatch, { target })
|
||||
await tick()
|
||||
|
||||
const text = target.textContent
|
||||
expect(text).toContain('Storm Watch')
|
||||
expect(text).toContain('Tornado Warning')
|
||||
expect(text).toContain('Active alerts')
|
||||
expect(text).toContain('Slight risk')
|
||||
expect(text).toContain('SLGT')
|
||||
expect(text).toContain('15%') // wind probability
|
||||
expect(text).toContain('Claremore')
|
||||
expect(text).toContain('Hail')
|
||||
expect(text).toContain('Rogers Co.')
|
||||
expect(text).toContain('Quarter size hail')
|
||||
expect(target.querySelectorAll('.alert-item')).toHaveLength(1)
|
||||
expect(target.querySelectorAll('.report-item')).toHaveLength(1)
|
||||
expect(target.querySelectorAll('.outlook-day')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('shows all-clear states when nothing is active', async () => {
|
||||
storm.alerts = []
|
||||
storm.reports = []
|
||||
storm.outlook = {
|
||||
day1: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
||||
day2: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
||||
day3: risk('NONE', 'No outlook', '—', '#7b8eb5'),
|
||||
probabilities: { tornado: 0, wind: 0, hail: 0 },
|
||||
issued: null,
|
||||
}
|
||||
|
||||
instance = mount(StormWatch, { target })
|
||||
await tick()
|
||||
|
||||
const text = target.textContent
|
||||
expect(text).toContain('No active watches, warnings or advisories')
|
||||
expect(text).toContain('No severe-weather area covers this location')
|
||||
expect(text).toContain('No storm reports within')
|
||||
})
|
||||
|
||||
it('renders the non-US note instead of an outlook', async () => {
|
||||
app.locations = [{ id: 'y', name: 'London', lat: 51.5, lon: -0.12, isCurrent: true }]
|
||||
app.selectedLocationId = 'y'
|
||||
storm.alerts = []
|
||||
storm.reports = []
|
||||
storm.outlook = null
|
||||
|
||||
instance = mount(StormWatch, { target })
|
||||
await tick()
|
||||
|
||||
expect(target.textContent).toContain('SPC outlooks cover the United States only')
|
||||
expect(target.textContent).toContain('Local Storm Reports cover the United States only')
|
||||
})
|
||||
})
|
||||
237
tests/lib/api/storm.test.js
Normal file
237
tests/lib/api/storm.test.js
Normal file
@ -0,0 +1,237 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
pointInRing,
|
||||
pointInPolygon,
|
||||
pointInGeometry,
|
||||
haversineKm,
|
||||
riskKey,
|
||||
spcRiskAtPoint,
|
||||
spcProbabilityAtPoint,
|
||||
normalizeOutlook,
|
||||
spcOutlookUrl,
|
||||
isUsPoint,
|
||||
reportIcon,
|
||||
reportLabel,
|
||||
normalizeStormReports,
|
||||
normalizeActiveAlerts,
|
||||
alertType,
|
||||
isUrgentWarning,
|
||||
} from '../../../src/lib/api/storm.js'
|
||||
|
||||
// --- fixtures -------------------------------------------------------------
|
||||
|
||||
const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]]
|
||||
|
||||
const fc = (features) => ({ type: 'FeatureCollection', features })
|
||||
|
||||
const catFeature = (label, geom) => ({
|
||||
type: 'Feature',
|
||||
properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' },
|
||||
geometry: geom,
|
||||
})
|
||||
|
||||
const lsrFeature = (props, lon, lat) => ({
|
||||
type: 'Feature',
|
||||
properties: { type: 'Feature', ...props },
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
})
|
||||
|
||||
// --- tests ----------------------------------------------------------------
|
||||
|
||||
describe('storm geometry helpers', () => {
|
||||
const square = box(-97, 35, -96, 36)
|
||||
|
||||
it('pointInRing handles inside, outside and open rings', () => {
|
||||
expect(pointInRing(-96.5, 35.5, square[0])).toBe(true)
|
||||
expect(pointInRing(-97.5, 35.5, square[0])).toBe(false)
|
||||
expect(pointInRing(-96.5, 36.5, square[0])).toBe(false)
|
||||
expect(pointInRing(-96.5, 35.5, [])).toBe(false)
|
||||
})
|
||||
|
||||
it('pointInPolygon excludes holes', () => {
|
||||
const withHole = [box(-97, 35, -96, 36)[0], box(-96.8, 35.2, -96.6, 35.4)[0]]
|
||||
expect(pointInPolygon(-96.9, 35.6, withHole)).toBe(true) // in the ring, outside the hole
|
||||
expect(pointInPolygon(-96.7, 35.3, withHole)).toBe(false) // inside the hole
|
||||
})
|
||||
|
||||
it('pointInGeometry supports Polygon and MultiPolygon', () => {
|
||||
const poly = { type: 'Polygon', coordinates: square }
|
||||
const multi = {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)],
|
||||
}
|
||||
expect(pointInGeometry(-96.5, 35.5, poly)).toBe(true)
|
||||
expect(pointInGeometry(-99.5, 30.5, multi)).toBe(true)
|
||||
expect(pointInGeometry(-98, 33, multi)).toBe(false)
|
||||
expect(pointInGeometry(-96.5, 35.5, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('haversineKm is within a few km of the real distance', () => {
|
||||
// Tulsa (36.154, -95.993) → Oklahoma City (35.468, -97.516) ≈ 157 km
|
||||
const km = haversineKm(36.154, -95.993, 35.468, -97.516)
|
||||
expect(km).toBeGreaterThan(150)
|
||||
expect(km).toBeLessThan(165)
|
||||
// Zero distance for the same point.
|
||||
expect(haversineKm(36, -95, 36, -95)).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('isUsPoint recognises CONUS, Alaska, Hawaii and rejects elsewhere', () => {
|
||||
expect(isUsPoint(36.36, -95.81)).toBe(true) // Oklahoma
|
||||
expect(isUsPoint(61.2, -149.9)).toBe(true) // Anchorage
|
||||
expect(isUsPoint(21.3, -157.8)).toBe(true) // Honolulu
|
||||
expect(isUsPoint(51.5, -0.12)).toBe(false) // London
|
||||
// The CONUS band is deliberately wide, so southern Canada passes; the SPC
|
||||
// polygons (not this check) decide whether a point actually has an outlook.
|
||||
expect(isUsPoint(43.6, -79.4)).toBe(true) // Toronto
|
||||
expect(isUsPoint(19.4, -99.1)).toBe(false) // Mexico City
|
||||
})
|
||||
})
|
||||
|
||||
describe('SPC outlook parsing', () => {
|
||||
it('riskKey maps SPC labels and ignores unknown ones', () => {
|
||||
expect(riskKey('TSTM')).toBe('TSTM')
|
||||
expect(riskKey(' slgt ')).toBe('SLGT')
|
||||
expect(riskKey('HIGH')).toBe('HIGH')
|
||||
expect(riskKey('Less Than 2% All Areas')).toBe('NONE')
|
||||
expect(riskKey(undefined)).toBe('NONE')
|
||||
})
|
||||
|
||||
it('spcRiskAtPoint returns the highest containing category', () => {
|
||||
const data = fc([
|
||||
catFeature('TSTM', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }),
|
||||
catFeature('ENH', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }),
|
||||
catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) }),
|
||||
])
|
||||
const inside = spcRiskAtPoint(-96.5, 35.5, data)
|
||||
expect(inside.key).toBe('ENH')
|
||||
expect(inside.short).toBe('ENH')
|
||||
expect(inside.label).toBe('Enhanced risk')
|
||||
|
||||
// Inside SLGT + TSTM but outside ENH.
|
||||
expect(spcRiskAtPoint(-97.5, 36.5, data).key).toBe('SLGT')
|
||||
|
||||
// Inside the TSTM box only.
|
||||
expect(spcRiskAtPoint(-99, 39, data).key).toBe('TSTM')
|
||||
|
||||
// Outside everything.
|
||||
const none = spcRiskAtPoint(-80, 25, data)
|
||||
expect(none.key).toBe('NONE')
|
||||
expect(none.label).toBe('No outlook')
|
||||
})
|
||||
|
||||
it('spcRiskAtPoint handles MultiPolygon and empty collections', () => {
|
||||
const multi = catFeature('MDT', {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [box(-100, 30, -99, 31), box(-97, 35, -96, 36)],
|
||||
})
|
||||
expect(spcRiskAtPoint(-96.5, 35.5, fc([multi])).key).toBe('MDT')
|
||||
expect(spcRiskAtPoint(-96.5, 35.5, fc([])).key).toBe('NONE')
|
||||
expect(spcRiskAtPoint(-96.5, 35.5, null).key).toBe('NONE')
|
||||
})
|
||||
|
||||
it('spcProbabilityAtPoint takes the max and ignores the "less than" background', () => {
|
||||
const data = fc([
|
||||
catFeature('Less Than 2% All Areas', { type: 'Polygon', coordinates: box(-130, 20, -60, 55) }),
|
||||
catFeature('2%', { type: 'Polygon', coordinates: box(-100, 30, -90, 40) }),
|
||||
catFeature('15%', { type: 'Polygon', coordinates: box(-97, 35, -96, 36) }),
|
||||
])
|
||||
expect(spcProbabilityAtPoint(-96.5, 35.5, data)).toBe(15)
|
||||
expect(spcProbabilityAtPoint(-99, 39, data)).toBe(2)
|
||||
expect(spcProbabilityAtPoint(-80, 25, data)).toBe(0)
|
||||
expect(spcProbabilityAtPoint(-96.5, 35.5, null)).toBe(0)
|
||||
})
|
||||
|
||||
it('normalizeOutlook resolves all three days plus day-1 probabilities', () => {
|
||||
const src = {
|
||||
cat1: fc([catFeature('SLGT', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
|
||||
cat2: fc([catFeature('TSTM', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
|
||||
cat3: null,
|
||||
torn: fc([catFeature('5%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
|
||||
wind: fc([catFeature('15%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
|
||||
hail: fc([catFeature('30%', { type: 'Polygon', coordinates: box(-98, 34, -95, 37) })]),
|
||||
}
|
||||
const out = normalizeOutlook(src, -96.5, 35.5)
|
||||
expect(out.day1.key).toBe('SLGT')
|
||||
expect(out.day2.key).toBe('TSTM')
|
||||
expect(out.day3.key).toBe('NONE')
|
||||
expect(out.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 })
|
||||
expect(out.issued).toBe('2026-10-17T16:14:00+00:00')
|
||||
})
|
||||
|
||||
it('spcOutlookUrl builds the light no-label GeoJSON URLs', () => {
|
||||
expect(spcOutlookUrl(1, 'cat')).toBe(
|
||||
'https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson'
|
||||
)
|
||||
expect(spcOutlookUrl(3, 'hail')).toBe(
|
||||
'https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('local storm reports', () => {
|
||||
it('reportLabel and reportIcon map NWS type codes', () => {
|
||||
expect(reportLabel('TORNADO')).toBe('Tornado')
|
||||
expect(reportLabel('TSTM WND GST')).toBe('Thunderstorm wind gust')
|
||||
expect(reportLabel('FLASH FLOOD')).toBe('Flash flood')
|
||||
expect(reportLabel('HAIL')).toBe('Hail')
|
||||
expect(reportIcon('TORNADO')).toBe('🌪️')
|
||||
expect(reportIcon('TSTM WND DMG')).toBe('💨')
|
||||
expect(reportIcon('HAIL')).toBe('🧊')
|
||||
expect(reportIcon('FLASH FLOOD')).toBe('🌊')
|
||||
})
|
||||
|
||||
it('normalizeStormReports filters by radius and sorts nearest first', () => {
|
||||
const data = fc([
|
||||
lsrFeature({ typetext: 'HAIL', city: 'Far', county: 'X', state: 'OK', valid: '2026-10-17T10:00:00Z', magnitude: '1.75' }, -94.0, 36.3),
|
||||
lsrFeature({ typetext: 'TORNADO', city: 'Near', county: 'Y', state: 'OK', valid: '2026-10-17T11:00:00Z', magnitude: '' }, -95.82, 36.36),
|
||||
lsrFeature({ typetext: 'TSTM WND GST', city: 'Middle', county: 'Z', state: 'OK', valid: '2026-10-17T09:00:00Z', magnitude: '60' }, -96.5, 36.36),
|
||||
lsrFeature({ typetext: 'HAIL', city: 'TooFar', county: 'W', state: 'KS', valid: '2026-10-17T09:00:00Z', magnitude: '2.00' }, -90.0, 40.0),
|
||||
])
|
||||
const out = normalizeStormReports(data, 36.36, -95.82, 100)
|
||||
expect(out.map((r) => r.city)).toEqual(['Near', 'Middle'])
|
||||
expect(out[0].label).toBe('Tornado')
|
||||
expect(out[0].distanceKm).toBeLessThan(1)
|
||||
expect(out[0].magnitude).toBeNull()
|
||||
expect(out[1].magnitude).toBe('60')
|
||||
expect(out[1].distanceKm).toBeGreaterThan(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NWS alert normalization', () => {
|
||||
const alert = (props) => ({
|
||||
type: 'Feature',
|
||||
properties: { status: 'Actual', messageType: 'Alert', ...props },
|
||||
geometry: { type: 'Polygon', coordinates: box(-96, 35, -95, 36) },
|
||||
})
|
||||
|
||||
it('alertType buckets events like the notification store does', () => {
|
||||
expect(alertType('Tornado Warning')).toBe('storm')
|
||||
expect(alertType('Severe Thunderstorm Watch')).toBe('storm')
|
||||
expect(alertType('Flood Warning')).toBe('flood')
|
||||
expect(alertType('Excessive Heat Warning')).toBe('heat')
|
||||
expect(alertType('Red Flag Warning')).toBe('advisory')
|
||||
})
|
||||
|
||||
it('isUrgentWarning flags tornado/severe warnings and Extreme alerts', () => {
|
||||
expect(isUrgentWarning('Tornado Warning', 'Extreme')).toBe(true)
|
||||
expect(isUrgentWarning('Severe Thunderstorm Warning', 'Severe')).toBe(true)
|
||||
expect(isUrgentWarning('Flood Warning', 'Severe')).toBe(false)
|
||||
expect(isUrgentWarning('Heat Advisory', 'Moderate')).toBe(false)
|
||||
expect(isUrgentWarning('Special Weather Statement', 'Extreme')).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizeActiveAlerts sorts by severity, drops cancels, keeps fields', () => {
|
||||
const data = fc([
|
||||
alert({ id: 'b', event: 'Flood Advisory', severity: 'Minor', ends: '2026-10-17T20:00:00Z' }),
|
||||
alert({ id: 'a', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z', headline: 'Take cover now' }),
|
||||
alert({ id: 'c', event: 'Heat Advisory', severity: 'Moderate', ends: '2026-10-18T00:00:00Z' }),
|
||||
alert({ id: 'd', event: 'Old Warning', severity: 'Extreme', messageType: 'Cancel' }),
|
||||
])
|
||||
const out = normalizeActiveAlerts(data)
|
||||
expect(out.map((a) => a.id)).toEqual(['a', 'c', 'b'])
|
||||
expect(out[0].event).toBe('Tornado Warning')
|
||||
expect(out[0].headline).toBe('Take cover now')
|
||||
expect(out[0].type).toBe('storm')
|
||||
expect(out[0].geometry.type).toBe('Polygon')
|
||||
})
|
||||
})
|
||||
194
tests/lib/stores/storm.test.js
Normal file
194
tests/lib/stores/storm.test.js
Normal file
@ -0,0 +1,194 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { StormStore, STORM_REFRESH_MS } from '../../../src/lib/stores/storm.svelte.js'
|
||||
import { app } from '../../../src/lib/stores/app.svelte.js'
|
||||
import { clearStormCache } from '../../../src/lib/api/storm.js'
|
||||
|
||||
// ---- fixtures ------------------------------------------------------------
|
||||
|
||||
const box = (w, s, e, n) => [[[w, s], [e, s], [e, n], [w, n], [w, s]]]
|
||||
|
||||
const catFc = (label) => ({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { LABEL: label, ISSUE_ISO: '2026-10-17T16:14:00+00:00' },
|
||||
geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) },
|
||||
}],
|
||||
})
|
||||
|
||||
const probFc = (label) => ({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: { LABEL: label },
|
||||
geometry: { type: 'Polygon', coordinates: box(-100, 30, -90, 40) },
|
||||
}],
|
||||
})
|
||||
|
||||
const alertsFc = (features) => ({ type: 'FeatureCollection', features })
|
||||
|
||||
const alertFeature = (props) => ({
|
||||
type: 'Feature',
|
||||
properties: { status: 'Actual', messageType: 'Alert', ...props },
|
||||
geometry: { type: 'Polygon', coordinates: box(-97, 35, -96, 36) },
|
||||
})
|
||||
|
||||
const lsrFc = (features) => ({ type: 'FeatureCollection', features })
|
||||
|
||||
const lsr = (city, lon, lat) => ({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
typetext: 'HAIL', city, county: 'X', state: 'OK', source: 'Trained Spotter',
|
||||
valid: '2026-10-17T11:00:00Z', magnitude: '1.75', remark: 'quarter size',
|
||||
},
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
})
|
||||
|
||||
// ---- fetch stub ----------------------------------------------------------
|
||||
|
||||
let calls
|
||||
let overrides
|
||||
|
||||
function installFetch() {
|
||||
calls = []
|
||||
overrides = {}
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
const u = String(url)
|
||||
calls.push(u)
|
||||
for (const [match, handler] of Object.entries(overrides)) {
|
||||
if (u.includes(match)) return handler(u)
|
||||
}
|
||||
const json = (body) => ({ ok: true, status: 200, json: async () => body })
|
||||
if (u.includes('api.weather.gov/alerts/active')) return json(alertsFc([]))
|
||||
if (u.includes('day1otlk_cat')) return json(catFc('SLGT'))
|
||||
if (u.includes('day2otlk_cat')) return json(catFc('MRGL'))
|
||||
if (u.includes('day3otlk_cat')) return json(catFc('TSTM'))
|
||||
if (u.includes('day1otlk_torn')) return json(probFc('5%'))
|
||||
if (u.includes('day1otlk_wind')) return json(probFc('15%'))
|
||||
if (u.includes('day1otlk_hail')) return json(probFc('30%'))
|
||||
if (u.includes('mesonet.agron.iastate.edu')) return json(lsrFc([lsr('Near', -95.82, 36.36)]))
|
||||
return { ok: false, status: 404, json: async () => ({}) }
|
||||
})
|
||||
}
|
||||
|
||||
function setLocation(lat, lon, name = 'Test') {
|
||||
app.locations = [{ id: 'loc-1', name, lat, lon, isCurrent: true }]
|
||||
app.selectedLocationId = 'loc-1'
|
||||
}
|
||||
|
||||
// ---- tests ---------------------------------------------------------------
|
||||
|
||||
describe('StormStore', () => {
|
||||
let store
|
||||
|
||||
beforeEach(() => {
|
||||
clearStormCache()
|
||||
installFetch()
|
||||
store = new StormStore()
|
||||
setLocation(36.3567, -95.8146, 'Home')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
store.stop()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('loads alerts, outlook and reports for the selected location', async () => {
|
||||
overrides['api.weather.gov/alerts/active'] = async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => alertsFc([
|
||||
alertFeature({ id: 't1', event: 'Tornado Warning', severity: 'Extreme', ends: '2026-10-17T19:00:00Z' }),
|
||||
]),
|
||||
})
|
||||
|
||||
await store.watch({ force: true })
|
||||
|
||||
expect(store.alerts).toHaveLength(1)
|
||||
expect(store.alerts[0].event).toBe('Tornado Warning')
|
||||
expect(store.banner?.id).toBe('t1')
|
||||
expect(store.outlook.day1.key).toBe('SLGT')
|
||||
expect(store.outlook.day2.key).toBe('MRGL')
|
||||
expect(store.outlook.probabilities).toEqual({ tornado: 5, wind: 15, hail: 30 })
|
||||
expect(store.reports).toHaveLength(1)
|
||||
expect(store.reports[0].city).toBe('Near')
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.updatedAt).toBeGreaterThan(0)
|
||||
expect(store.alertCount).toBe(1)
|
||||
})
|
||||
|
||||
it('serves fresh data from the store/HTTP cache until it expires', async () => {
|
||||
await store.watch({ force: true })
|
||||
const firstCount = calls.length
|
||||
expect(firstCount).toBeGreaterThan(0)
|
||||
|
||||
await store.watch() // store-level: data for this location is fresh
|
||||
expect(calls.length).toBe(firstCount)
|
||||
|
||||
await store.watch({ force: true }) // HTTP cache still warm
|
||||
expect(calls.length).toBe(firstCount)
|
||||
|
||||
clearStormCache() // simulate the TTLs expiring
|
||||
await store.watch({ force: true })
|
||||
expect(calls.length).toBeGreaterThan(firstCount)
|
||||
})
|
||||
|
||||
it('refetches when the selected location changes', async () => {
|
||||
await store.watch({ force: true })
|
||||
const firstCount = calls.length
|
||||
|
||||
setLocation(27.2, -82.3, 'Elsewhere')
|
||||
await store.watch()
|
||||
|
||||
const fresh = calls.slice(firstCount)
|
||||
expect(fresh.length).toBeGreaterThan(0)
|
||||
expect(fresh.some((u) => u.includes('36.3567'))).toBe(false) // old point not refetched
|
||||
expect(fresh.some((u) => u.includes('27.2'))).toBe(true) // new point queried
|
||||
})
|
||||
|
||||
it('keeps the other feeds when one request fails', async () => {
|
||||
overrides['mesonet.agron.iastate.edu'] = async () => ({
|
||||
ok: false, status: 503, json: async () => ({}),
|
||||
})
|
||||
|
||||
await store.watch({ force: true })
|
||||
|
||||
expect(store.reports).toEqual([])
|
||||
expect(store.outlook.day1.key).toBe('SLGT')
|
||||
expect(store.error).toMatch(/reports/)
|
||||
})
|
||||
|
||||
it('skips SPC/LSR requests outside the US and clears the outlook', async () => {
|
||||
setLocation(51.5, -0.12, 'London')
|
||||
await store.watch({ force: true })
|
||||
|
||||
expect(store.hasUsCoverage).toBe(false)
|
||||
expect(store.outlook).toBeNull()
|
||||
expect(store.reports).toEqual([])
|
||||
expect(calls.some((u) => u.includes('spc.noaa.gov'))).toBe(false)
|
||||
expect(calls.some((u) => u.includes('api.weather.gov'))).toBe(true)
|
||||
})
|
||||
|
||||
it('reset() drops data and the location key', async () => {
|
||||
await store.watch({ force: true })
|
||||
store.reset()
|
||||
expect(store.alerts).toEqual([])
|
||||
expect(store.outlook).toBeNull()
|
||||
expect(store.reports).toEqual([])
|
||||
expect(store.updatedAt).toBe(0)
|
||||
})
|
||||
|
||||
it('start() schedules a refresh at the documented interval', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const spy = vi.spyOn(store, 'watch').mockResolvedValue()
|
||||
store.start()
|
||||
store.start() // idempotent
|
||||
vi.advanceTimersByTime(STORM_REFRESH_MS + 10)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(spy).toHaveBeenCalledWith({ force: true })
|
||||
store.stop()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -16,7 +16,7 @@ function getCommitHash() {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
export default defineConfig(({ command, mode }) => ({
|
||||
define: {
|
||||
__WEATHER_COMMIT__: JSON.stringify(getCommitHash()),
|
||||
},
|
||||
@ -44,6 +44,11 @@ export default defineConfig(({ command }) => ({
|
||||
preview: {
|
||||
allowedHosts: ["dev.thecookiejar.me"]
|
||||
},
|
||||
// Vitest runs with mode 'test'; resolving the browser builds of Svelte is
|
||||
// what makes client-side `mount()` available in component tests. Leave the
|
||||
// build untouched — forcing conditions here breaks the production bundle
|
||||
// (Svelte's server entry gets picked and mount() throws).
|
||||
resolve: mode === 'test' ? { conditions: ['browser'] } : undefined,
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user