Skip to content

Commit 996a442

Browse files
committed
feat(cap-alerting): add NWS text product parsing for improved readability
Add support for parsing NWS-formatted descriptions in CAP alerts to improve readability on digital signage. The app now automatically detects and formats National Weather Service text products that use legacy abbreviated markers. Features: - Parse period-based forecasts (.TODAY..., .TONIGHT..., .MON..., etc.) - Parse Impact Based Warnings in WWWI format (* WHAT..., * WHERE..., * WHEN..., * IMPACTS...) - Automatic detection based on NWS sender email (w-nws.webmaster@noaa.gov) - Template-based rendering for consistent styling Changes: - Add NWS parser functions to parser.ts - Add NWS render functions to render.ts - Create nws.css for NWS-specific styling - Add HTML templates for NWS content display - Update README with NWS formatting documentation - Add comprehensive test coverage (170 tests passing) The formatted content displays structured information with proper spacing, bullet points, and color-coded labels for better visibility on screens.
1 parent 5ba0147 commit 996a442

8 files changed

Lines changed: 663 additions & 3 deletions

File tree

edge-apps/cap-alerting/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,52 @@ Display Common Alerting Protocol (CAP) emergency alerts on Screenly digital sign
2121

2222
Add tags to your Screenly screens (e.g., `exit:North Lobby`) to provide location-aware exit directions. The app substitutes `{{closest_exit}}` or `[[closest_exit]]` placeholders in alert instructions.
2323

24+
## NWS Text Product Formatting
25+
26+
The app automatically detects and formats National Weather Service (NWS) CAP alerts that use legacy text formats. This improves readability by converting abbreviated markers into clean, readable text with proper spacing and line breaks.
27+
28+
### Supported Formats
29+
30+
**1. Period-based Forecasts** (marine forecasts, zone forecasts)
31+
32+
Markers: `.TODAY...`, `.TONIGHT...`, `.MON...`, `.SUN NIGHT...`, etc.
33+
34+
Example transformation:
35+
36+
```
37+
.TODAY...E wind 20 kt. Seas 11 ft. .TONIGHT...E wind 20 kt.
38+
```
39+
40+
becomes:
41+
42+
```
43+
TODAY: E wind 20 kt. Seas 11 ft.
44+
45+
TONIGHT: E wind 20 kt.
46+
```
47+
48+
**2. Impact Based Warnings (WWWI format)**
49+
50+
Markers: `* WHAT...`, `* WHERE...`, `* WHEN...`, `* IMPACTS...`
51+
52+
Example transformation:
53+
54+
```
55+
* WHAT...North winds 25 to 30 kt. * WHERE...Coastal waters. * WHEN...Until 3 AM.
56+
```
57+
58+
becomes:
59+
60+
```
61+
WHAT: North winds 25 to 30 kt.
62+
63+
WHERE: Coastal waters.
64+
65+
WHEN: Until 3 AM.
66+
```
67+
68+
This formatting only applies to CAP alerts from the NWS sender (`w-nws.webmaster@noaa.gov`).
69+
2470
## Override Playlist Integration
2571

2672
This app is designed to use Screenly's [Override Playlist API](https://developer.screenly.io/api-reference/v4/#tag/Playlists/operation/override_playlist) to automatically interrupt regular content when alerts are active. Configure your backend to call the API when new CAP alerts are detected.

edge-apps/cap-alerting/index.html

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,39 @@
8989
</div>
9090
</template>
9191

92+
<!-- NWS Forecast Content Template (with preamble) -->
93+
<template id="nws-forecast-template">
94+
<div
95+
class="nws-content font-semibold leading-snug body-text text-gray-800 mx-[5vw] mb-[2vh]"
96+
>
97+
<p class="nws-preamble"></p>
98+
<ul class="nws-forecast-list"></ul>
99+
</div>
100+
</template>
101+
102+
<!-- NWS Forecast List Item Template -->
103+
<template id="nws-forecast-item-template">
104+
<li>
105+
<strong class="period-label"></strong>
106+
<span class="period-content"></span>
107+
</li>
108+
</template>
109+
110+
<!-- NWS WWWI Content Template -->
111+
<template id="nws-wwwi-template">
112+
<ul
113+
class="nws-wwwi-list font-semibold leading-snug body-text text-gray-800 mx-[5vw] mb-[2vh]"
114+
></ul>
115+
</template>
116+
117+
<!-- NWS WWWI List Item Template -->
118+
<template id="nws-wwwi-item-template">
119+
<li>
120+
<strong class="wwwi-label"></strong>
121+
<span class="wwwi-content"></span>
122+
</li>
123+
</template>
124+
92125
<script src="screenly.js?version=1"></script>
93126
<script src="dist/js/main.js"></script>
94127
</body>

edge-apps/cap-alerting/src/input.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import 'tailwindcss';
2+
@import './nws.css';
23

34
/* Modern Digital Signage Design - Viewport-based sizing */
45

edge-apps/cap-alerting/src/main.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ import {
99
} from '@screenly/edge-apps'
1010

1111
import { CAPAlert, CAPInfo, CAPMode } from './types/cap'
12-
import { parseCap } from './parser'
12+
import { parseCap, isNwsAlert, parseNwsTextProduct } from './parser'
1313
import { CAPFetcher } from './fetcher'
1414
import { getNearestExit, splitIntoSentences, proxyUrl } from './utils'
15-
import { highlightKeywords } from './render'
15+
import {
16+
highlightKeywords,
17+
renderNwsWwwiContent,
18+
renderNwsPeriodContent,
19+
} from './render'
1620

1721
function getTemplate(id: string): HTMLTemplateElement {
1822
const template = document.getElementById(id) as HTMLTemplateElement | null
@@ -144,7 +148,22 @@ function renderAlertCard(
144148
'#description',
145149
) as HTMLParagraphElement
146150
if (info.description) {
147-
descriptionEl.textContent = info.description
151+
// Try to parse and render NWS formatted content
152+
if (isNwsAlert(alert.sender)) {
153+
const nwsResult = parseNwsTextProduct(info.description)
154+
if (nwsResult) {
155+
// Replace the paragraph with the rendered NWS content
156+
const nwsContent =
157+
nwsResult.type === 'wwwi'
158+
? renderNwsWwwiContent(nwsResult)
159+
: renderNwsPeriodContent(nwsResult)
160+
descriptionEl.replaceWith(nwsContent)
161+
} else {
162+
descriptionEl.textContent = info.description
163+
}
164+
} else {
165+
descriptionEl.textContent = info.description
166+
}
148167
} else {
149168
descriptionEl.style.display = 'none'
150169
}

edge-apps/cap-alerting/src/nws.css

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* NWS formatted content styling */
2+
.nws-preamble {
3+
margin-bottom: 1.5vh;
4+
line-height: 1.4;
5+
}
6+
7+
.nws-forecast-list,
8+
.nws-wwwi-list {
9+
list-style: none;
10+
padding: 0;
11+
margin: 0;
12+
}
13+
14+
.nws-forecast-list li,
15+
.nws-wwwi-list li {
16+
position: relative;
17+
padding-left: 3vw;
18+
margin-bottom: 1vh;
19+
}
20+
21+
.nws-forecast-list li::before,
22+
.nws-wwwi-list li::before {
23+
content: '•';
24+
position: absolute;
25+
left: 0;
26+
color: rgb(234, 88, 12);
27+
font-weight: 900;
28+
}
29+
30+
.nws-forecast-list li strong,
31+
.nws-wwwi-list li strong {
32+
color: rgb(30, 64, 175);
33+
font-weight: 800;
34+
}

edge-apps/cap-alerting/src/parser.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,184 @@ export function parseCap(xml: string): CAPAlert[] {
121121

122122
return alertsJson.map(parseAlert)
123123
}
124+
125+
/** Represents a single section in NWS formatted content */
126+
export interface NwsSection {
127+
label: string
128+
content: string
129+
}
130+
131+
/** Result of parsing NWS WWWI format (What/Where/When/Impacts) */
132+
export interface NwsWwwiResult {
133+
type: 'wwwi'
134+
sections: NwsSection[]
135+
}
136+
137+
/** Result of parsing NWS period-based forecast format */
138+
export interface NwsPeriodResult {
139+
type: 'period'
140+
preamble: string
141+
periods: NwsSection[]
142+
}
143+
144+
/** Result of parsing NWS text - either WWWI, period format, or plain text */
145+
export type NwsParseResult = NwsWwwiResult | NwsPeriodResult | null
146+
147+
/**
148+
* Checks if the CAP alert is from the National Weather Service (NWS).
149+
* NWS alerts use the sender email: w-nws.webmaster@noaa.gov
150+
*
151+
* @param sender - The sender field from the CAP alert
152+
* @returns true if the alert is from NWS
153+
*/
154+
export function isNwsAlert(sender: string): boolean {
155+
return sender === 'w-nws.webmaster@noaa.gov'
156+
}
157+
158+
/**
159+
* Parses NWS "What/Where/When/Impacts" (WWWI) format used in Impact Based Warnings.
160+
* Returns structured data with sections for rendering.
161+
*
162+
* Example input:
163+
* "* WHAT...North winds 25 to 30 kt. * WHERE...Pt St George to Cape Mendocino."
164+
*
165+
* @param text - The NWS text product description
166+
* @returns Parsed WWWI result or null if format not detected
167+
*/
168+
export function parseNwsWwwiProduct(text: string): NwsWwwiResult | null {
169+
if (!text) return null
170+
171+
// Check if this looks like an NWS WWWI format
172+
const wwwiPattern =
173+
/\*\s*(WHAT|WHERE|WHEN|IMPACTS?|ADDITIONAL DETAILS?)\.{3}/i
174+
if (!wwwiPattern.test(text)) {
175+
return null
176+
}
177+
178+
// Split into sections
179+
const sections: NwsSection[] = []
180+
const sectionRegex =
181+
/\*\s*(WHAT|WHERE|WHEN|IMPACTS?|ADDITIONAL DETAILS?)\.{3}\s*/gi
182+
let match: RegExpExecArray | null
183+
184+
// Find all section markers and their positions
185+
const matches: { label: string; index: number; length: number }[] = []
186+
while ((match = sectionRegex.exec(text)) !== null) {
187+
matches.push({
188+
label: match[1],
189+
index: match.index,
190+
length: match[0].length,
191+
})
192+
}
193+
194+
// Extract content for each section
195+
for (let i = 0; i < matches.length; i++) {
196+
const current = matches[i]
197+
const contentStart = current.index + current.length
198+
const contentEnd =
199+
i < matches.length - 1 ? matches[i + 1].index : text.length
200+
const content = text.slice(contentStart, contentEnd).trim()
201+
202+
if (content) {
203+
sections.push({ label: current.label, content })
204+
}
205+
}
206+
207+
if (sections.length === 0) {
208+
return null
209+
}
210+
211+
return { type: 'wwwi', sections }
212+
}
213+
214+
/**
215+
* Parses NWS text products that use the legacy .PERIOD... format
216+
* commonly found in marine forecasts, zone forecasts, and CAP descriptions.
217+
* Returns structured data with preamble and periods for rendering.
218+
*
219+
* Example input:
220+
* "Coastal Waters Forecast... .TODAY...E wind 20 kt. .TONIGHT...E wind 20 kt."
221+
*
222+
* @param text - The NWS text product description
223+
* @returns Parsed period result or null if format not detected
224+
*/
225+
export function parseNwsPeriodProduct(text: string): NwsPeriodResult | null {
226+
if (!text) return null
227+
228+
// Pattern to match period markers including "AND" combinations like ".SUN AND SUN NIGHT..."
229+
const periodPattern =
230+
/\.(TODAY|TONIGHT|TOMORROW|(?:MON|TUES|WEDNES|THURS|FRI|SATUR|SUN)(?:DAY)?|(?:MON|TUE|WED|THU|FRI|SAT|SUN))(\s+(?:AND\s+)?(?:NIGHT|MORNING|AFTERNOON|(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:\s+NIGHT)?|THROUGH\s+\w+))?\.{3}/gi
231+
232+
// Check if this looks like an NWS text product with .PERIOD... format
233+
if (!periodPattern.test(text)) {
234+
return null
235+
}
236+
237+
// Reset regex lastIndex after test
238+
periodPattern.lastIndex = 0
239+
240+
// Find the first period marker to split preamble from forecasts
241+
const firstMatch = periodPattern.exec(text)
242+
if (!firstMatch) {
243+
return null
244+
}
245+
246+
const preamble = text.slice(0, firstMatch.index).trim()
247+
248+
// Reset and find all period markers
249+
periodPattern.lastIndex = 0
250+
const periods: NwsSection[] = []
251+
const matches: { label: string; index: number; length: number }[] = []
252+
let match: RegExpExecArray | null
253+
254+
while ((match = periodPattern.exec(text)) !== null) {
255+
const day = match[1]
256+
const modifier = match[2] || ''
257+
matches.push({
258+
label: `${day}${modifier}`.trim(),
259+
index: match.index,
260+
length: match[0].length,
261+
})
262+
}
263+
264+
// Extract content for each period
265+
for (let i = 0; i < matches.length; i++) {
266+
const current = matches[i]
267+
const contentStart = current.index + current.length
268+
const contentEnd =
269+
i < matches.length - 1 ? matches[i + 1].index : text.length
270+
const content = text.slice(contentStart, contentEnd).trim()
271+
272+
if (content) {
273+
periods.push({ label: current.label, content })
274+
}
275+
}
276+
277+
if (periods.length === 0) {
278+
return null
279+
}
280+
281+
return { type: 'period', preamble, periods }
282+
}
283+
284+
/**
285+
* Parses NWS text products for improved readability on digital signage.
286+
* Detects format type and returns structured data:
287+
* - Period-based forecasts: .TODAY..., .TONIGHT..., .MON..., etc.
288+
* - Impact Based Warnings (WWWI): * WHAT..., * WHERE..., * WHEN..., * IMPACTS...
289+
*
290+
* @param text - The NWS text product description
291+
* @returns Parsed result or null if no NWS format detected
292+
*/
293+
export function parseNwsTextProduct(text: string): NwsParseResult {
294+
if (!text) return null
295+
296+
// Try WWWI format first (Impact Based Warnings)
297+
const wwwiResult = parseNwsWwwiProduct(text)
298+
if (wwwiResult) {
299+
return wwwiResult
300+
}
301+
302+
// Try period-based format (marine forecasts, etc.)
303+
return parseNwsPeriodProduct(text)
304+
}

0 commit comments

Comments
 (0)