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.
238 lines
9.9 KiB
JavaScript
238 lines
9.9 KiB
JavaScript
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')
|
|
})
|
|
})
|