@aeroswarm/weather 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/aftn.d.ts +4 -4
- package/dist/aftn.d.ts.map +1 -1
- package/dist/conditions.d.ts +2 -1
- package/dist/conditions.d.ts.map +1 -1
- package/dist/geo.d.ts +54 -0
- package/dist/geo.d.ts.map +1 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +844 -19
- package/dist/metar.d.ts.map +1 -1
- package/dist/nil.d.ts +16 -0
- package/dist/nil.d.ts.map +1 -0
- package/dist/sigmet.d.ts +91 -0
- package/dist/sigmet.d.ts.map +1 -0
- package/dist/snowtam.d.ts +43 -0
- package/dist/snowtam.d.ts.map +1 -0
- package/dist/store.d.ts +4 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/taf.d.ts.map +1 -1
- package/dist/types.d.ts +34 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/vaa.d.ts +75 -0
- package/dist/vaa.d.ts.map +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,24 +1,47 @@
|
|
|
1
1
|
// src/aftn.ts
|
|
2
|
-
var
|
|
2
|
+
var START = /(?:^|\s)(?:\(?(?<simple>METAR|SPECI|TAF|SNOWTAM)(?=[\s)])|(?:(?<fir>[A-Z]{4})\s+)?(?<hazard>SIGMET|AIRMET)\s+[A-Z]?\d{1,3}\s+VALID\b|(?<vaa>VA ADVISORY)(?=\s))/g;
|
|
3
3
|
function extractReports(message) {
|
|
4
4
|
const text = message.replace(/\r/g, "\n").replace(/\bZCZC\b[^\n]*/g, " ").replace(/\bNNNN\b/g, " ");
|
|
5
5
|
const reports = [];
|
|
6
6
|
for (const segment of text.split("=")) {
|
|
7
7
|
const flat = segment.replace(/\s+/g, " ").trim();
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
const starts = [...flat.matchAll(START)].map((match) => {
|
|
9
|
+
const lead = match[0].length - match[0].trimStart().length;
|
|
10
|
+
const groups = match.groups ?? {};
|
|
11
|
+
const kind = groups.simple ?? groups.hazard ?? "VAA";
|
|
12
|
+
const offset = (match.index ?? 0) + lead + (match[0].trimStart().startsWith("(") ? 1 : 0);
|
|
13
|
+
return { kind, offset };
|
|
14
|
+
});
|
|
15
|
+
starts.forEach((start, i) => {
|
|
16
|
+
let body = flat.slice(start.offset, starts[i + 1]?.offset ?? flat.length).trim();
|
|
17
|
+
if (start.kind === "SNOWTAM") body = trimToClosingParenthesis(body);
|
|
18
|
+
reports.push({ kind: start.kind, text: body });
|
|
19
|
+
});
|
|
12
20
|
}
|
|
13
21
|
return reports;
|
|
14
22
|
}
|
|
23
|
+
function trimToClosingParenthesis(body) {
|
|
24
|
+
const close = body.lastIndexOf(")");
|
|
25
|
+
return close >= 0 ? body.slice(0, close).trim() : body;
|
|
26
|
+
}
|
|
15
27
|
|
|
16
28
|
// src/conditions.ts
|
|
17
29
|
var KT_PER_MPS = 1.943844;
|
|
18
|
-
var WIND = /^(\d{3}|VRB)(\d{2,3})(?:G(\d{2,3}))?(KT|MPS)
|
|
30
|
+
var WIND = /^(\d{3}|VRB)(\d{2,3})(?:G(\d{2,3}))?(KT|MPS)(?:(\d{3})V(\d{3}))?$/;
|
|
19
31
|
var WIND_VARIABLE = /^(\d{3})V(\d{3})$/;
|
|
20
32
|
var VISIBILITY = /^\d{4}$/;
|
|
21
|
-
var
|
|
33
|
+
var VISIBILITY_SM = /^([PM])?(\d+)(?:\/(\d+))?SM$/;
|
|
34
|
+
var WHOLE_MILES = /^\d{1,2}$/;
|
|
35
|
+
var METERS_PER_MILE = 1609.344;
|
|
36
|
+
var RVR = /^R(\d{2}[LRC]?)\/([PM])?(\d{4})(?:V([PM])?(\d{4}))?(FT)?([UDN])?$/;
|
|
37
|
+
var FEET_TO_M = 0.3048;
|
|
38
|
+
var RUNWAY_STATE = /^R\d{2}[LRC]?\/(?:CLRD\d{2}|CLRD\/\/|[\d/]{6}|\/{4})$/;
|
|
39
|
+
var WIND_SHEAR_RUNWAY = /^R\d{2}[LRC]?$/;
|
|
40
|
+
var RECENT_WEATHER = /^RE(?:[-+]|VC)?(?:(?:MI|BC|PR|DR|BL|SH|TS|FZ)(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PO|SQ|FC|SS|DS)*|(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PO|SQ|FC|SS|DS)+)$/;
|
|
41
|
+
var DIRECTIONAL_VISIBILITY = /^\d{4}(?:N|NE|E|SE|S|SW|W|NW)$/;
|
|
42
|
+
var WIND_SHEAR = /^WS\d{3}\/\d{3}\d{2,3}KT$/;
|
|
43
|
+
var MISSING = /^\/{2,}$/;
|
|
44
|
+
var CLOUD = /^(FEW|SCT|BKN|OVC)(\d{3}|\/\/\/)(CB|TCU|\/\/\/)?(?:\/(CB|TCU))?$/;
|
|
22
45
|
var VERTICAL_VISIBILITY = /^VV(\d{3}|\/\/\/)$/;
|
|
23
46
|
var WEATHER = /^([-+]|VC)?(MI|BC|PR|DR|BL|SH|TS|FZ)?((?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PO|SQ|FC|SS|DS)*)$/;
|
|
24
47
|
function emptyConditions() {
|
|
@@ -59,8 +82,51 @@ function parseConditionTokens(tokens) {
|
|
|
59
82
|
const out = { rest: [] };
|
|
60
83
|
const clouds = [];
|
|
61
84
|
const weather = [];
|
|
85
|
+
const rvr = [];
|
|
62
86
|
let sawCloudGroup = false;
|
|
63
|
-
for (
|
|
87
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
88
|
+
const token = tokens[index];
|
|
89
|
+
if (WHOLE_MILES.test(token)) {
|
|
90
|
+
const fraction = (tokens[index + 1] ?? "").match(VISIBILITY_SM);
|
|
91
|
+
if (fraction?.[3]) {
|
|
92
|
+
out.visibilityM = Math.round(
|
|
93
|
+
Math.min(1e4, (Number(token) + Number(fraction[2]) / Number(fraction[3])) * METERS_PER_MILE)
|
|
94
|
+
);
|
|
95
|
+
index++;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const miles = token.match(VISIBILITY_SM);
|
|
100
|
+
if (miles) {
|
|
101
|
+
const value = miles[3] ? Number(miles[2]) / Number(miles[3]) : Number(miles[2]);
|
|
102
|
+
out.visibilityM = miles[1] === "P" ? 1e4 : Math.round(Math.min(1e4, value * METERS_PER_MILE));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const runwayVisual = token.match(RVR);
|
|
106
|
+
if (runwayVisual) {
|
|
107
|
+
const unit = runwayVisual[6] === "FT" ? FEET_TO_M : 1;
|
|
108
|
+
const value = Number(runwayVisual[3]) * unit;
|
|
109
|
+
const entry = { runway: runwayVisual[1], meters: Math.round(value) };
|
|
110
|
+
if (runwayVisual[2]) entry.modifier = runwayVisual[2] === "P" ? "above" : "below";
|
|
111
|
+
if (runwayVisual[5]) {
|
|
112
|
+
entry.variableMinM = Math.round(value);
|
|
113
|
+
entry.variableMaxM = Math.round(Number(runwayVisual[5]) * unit);
|
|
114
|
+
}
|
|
115
|
+
if (runwayVisual[7]) {
|
|
116
|
+
entry.trend = runwayVisual[7] === "U" ? "up" : runwayVisual[7] === "D" ? "down" : "none";
|
|
117
|
+
}
|
|
118
|
+
rvr.push(entry);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (token === "WS") {
|
|
122
|
+
const next = tokens[index + 1] ?? "";
|
|
123
|
+
if (WIND_SHEAR_RUNWAY.test(next)) index++;
|
|
124
|
+
else if (next === "ALL" && tokens[index + 2] === "RWY") index += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (RUNWAY_STATE.test(token) || RECENT_WEATHER.test(token) || DIRECTIONAL_VISIBILITY.test(token) || WIND_SHEAR.test(token) || MISSING.test(token)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
64
130
|
const wind = token.match(WIND);
|
|
65
131
|
if (wind) {
|
|
66
132
|
const variable = wind[1] === "VRB";
|
|
@@ -68,7 +134,8 @@ function parseConditionTokens(tokens) {
|
|
|
68
134
|
directionDeg: variable ? null : Number(wind[1]),
|
|
69
135
|
variable,
|
|
70
136
|
speedKt: toKnots(Number(wind[2]), wind[4] ?? "KT"),
|
|
71
|
-
gustKt: wind[3] ? toKnots(Number(wind[3]), wind[4] ?? "KT") : null
|
|
137
|
+
gustKt: wind[3] ? toKnots(Number(wind[3]), wind[4] ?? "KT") : null,
|
|
138
|
+
...wind[5] && wind[6] ? { variableFromDeg: Number(wind[5]), variableToDeg: Number(wind[6]) } : {}
|
|
72
139
|
};
|
|
73
140
|
continue;
|
|
74
141
|
}
|
|
@@ -93,7 +160,8 @@ function parseConditionTokens(tokens) {
|
|
|
93
160
|
clouds.push({
|
|
94
161
|
cover: cloud[1],
|
|
95
162
|
baseFt: cloud[2] === "///" ? null : Number(cloud[2]) * 100,
|
|
96
|
-
|
|
163
|
+
// `FEW025TCU/CB` (dua kemungkinan): dicatat CB, yang lebih berbahaya.
|
|
164
|
+
type: cloud[3] === "CB" || cloud[4] === "CB" ? "CB" : cloud[3] === "TCU" || cloud[4] === "TCU" ? "TCU" : void 0
|
|
97
165
|
});
|
|
98
166
|
continue;
|
|
99
167
|
}
|
|
@@ -106,7 +174,7 @@ function parseConditionTokens(tokens) {
|
|
|
106
174
|
});
|
|
107
175
|
continue;
|
|
108
176
|
}
|
|
109
|
-
if (token === "SKC" || token === "NSC" || token === "NCD") {
|
|
177
|
+
if (token === "SKC" || token === "CLR" || token === "NSC" || token === "NCD") {
|
|
110
178
|
sawCloudGroup = true;
|
|
111
179
|
out.clouds = [];
|
|
112
180
|
continue;
|
|
@@ -120,11 +188,19 @@ function parseConditionTokens(tokens) {
|
|
|
120
188
|
weather.push(present);
|
|
121
189
|
continue;
|
|
122
190
|
}
|
|
191
|
+
if (token.includes("/")) {
|
|
192
|
+
const alternatives = token.split("/").map((part) => parseWeather(part));
|
|
193
|
+
if (alternatives.every((a) => a !== null)) {
|
|
194
|
+
weather.push(...alternatives);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
123
198
|
out.rest.push(token);
|
|
124
199
|
}
|
|
125
200
|
if (clouds.length > 0) out.clouds = clouds;
|
|
126
201
|
else if (sawCloudGroup) out.clouds = [];
|
|
127
202
|
if (weather.length > 0) out.weather = weather;
|
|
203
|
+
if (rvr.length > 0) out.rvr = rvr;
|
|
128
204
|
return out;
|
|
129
205
|
}
|
|
130
206
|
function mergeConditions(base, next) {
|
|
@@ -133,7 +209,8 @@ function mergeConditions(base, next) {
|
|
|
133
209
|
visibilityM: next.visibilityM ?? base.visibilityM,
|
|
134
210
|
cavok: next.cavok ?? base.cavok,
|
|
135
211
|
clouds: next.clouds ?? base.clouds,
|
|
136
|
-
weather: next.weather ?? base.weather
|
|
212
|
+
weather: next.weather ?? base.weather,
|
|
213
|
+
rvr: next.rvr ?? base.rvr
|
|
137
214
|
};
|
|
138
215
|
if (next.noSignificantWeather) merged.weather = [];
|
|
139
216
|
if (next.cavok) {
|
|
@@ -204,6 +281,10 @@ function parseMetar(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
204
281
|
auto = true;
|
|
205
282
|
continue;
|
|
206
283
|
}
|
|
284
|
+
if (token === "COR") {
|
|
285
|
+
corrected = true;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
207
288
|
const temp = token.match(TEMPERATURE);
|
|
208
289
|
if (temp?.[1]) {
|
|
209
290
|
temperatureC = signed(temp[1]);
|
|
@@ -234,10 +315,520 @@ function parseMetar(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
234
315
|
dewPointC,
|
|
235
316
|
qnhHpa,
|
|
236
317
|
trend,
|
|
318
|
+
unparsed: parsed.rest,
|
|
237
319
|
raw: tokens.join(" ")
|
|
238
320
|
};
|
|
239
321
|
}
|
|
240
322
|
|
|
323
|
+
// src/nil.ts
|
|
324
|
+
var NIL = /^(METAR|SPECI|TAF)(?: (?:COR|AMD|CCA|CCB))* ([A-Z]{4})(?: (\d{2})(\d{2})(\d{2})Z)? NIL$/;
|
|
325
|
+
function parseNil(rawText, reference = /* @__PURE__ */ new Date()) {
|
|
326
|
+
const text = rawText.replace(/\s+/g, " ").trim().replace(/=$/, "").trim();
|
|
327
|
+
const match = text.match(NIL);
|
|
328
|
+
if (!match) return null;
|
|
329
|
+
const at = match[3] ? resolveDayTime(reference, Number(match[3]), Number(match[4]), Number(match[5])) : reference;
|
|
330
|
+
if (Number.isNaN(at.getTime())) return null;
|
|
331
|
+
return { kind: "NIL", of: match[1], station: match[2], at, raw: text };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/geo.ts
|
|
335
|
+
var COMPASS_DEGREES = {
|
|
336
|
+
N: 0,
|
|
337
|
+
NNE: 22.5,
|
|
338
|
+
NE: 45,
|
|
339
|
+
ENE: 67.5,
|
|
340
|
+
E: 90,
|
|
341
|
+
ESE: 112.5,
|
|
342
|
+
SE: 135,
|
|
343
|
+
SSE: 157.5,
|
|
344
|
+
S: 180,
|
|
345
|
+
SSW: 202.5,
|
|
346
|
+
SW: 225,
|
|
347
|
+
WSW: 247.5,
|
|
348
|
+
W: 270,
|
|
349
|
+
WNW: 292.5,
|
|
350
|
+
NW: 315,
|
|
351
|
+
NNW: 337.5
|
|
352
|
+
};
|
|
353
|
+
var isCompass = (value) => value in COMPASS_DEGREES;
|
|
354
|
+
var compassDegrees = (direction) => COMPASS_DEGREES[direction];
|
|
355
|
+
var POINT = String.raw`[NS]\d{2,6}\s?[EW]\d{3,7}`;
|
|
356
|
+
var POINT_PARTS = /^([NS])(\d{2,6})\s?([EW])(\d{3,7})$/;
|
|
357
|
+
function degreesMinutes(digits, degreeDigits) {
|
|
358
|
+
const degrees = Number(digits.slice(0, degreeDigits));
|
|
359
|
+
const rest = digits.slice(degreeDigits);
|
|
360
|
+
const minutes = rest === "" ? 0 : Number(rest.padEnd(2, "0").slice(0, 2));
|
|
361
|
+
const seconds = rest.length > 2 ? Number(rest.slice(2).padEnd(2, "0").slice(0, 2)) : 0;
|
|
362
|
+
return degrees + minutes / 60 + seconds / 3600;
|
|
363
|
+
}
|
|
364
|
+
function parsePosition(text) {
|
|
365
|
+
const match = text.trim().match(POINT_PARTS);
|
|
366
|
+
if (!match) return null;
|
|
367
|
+
const [, ns, latDigits, ew, lonDigits] = match;
|
|
368
|
+
const lat = degreesMinutes(latDigits, 2) * (ns === "S" ? -1 : 1);
|
|
369
|
+
const lonDegreeDigits = lonDigits.length === 4 ? 2 : 3;
|
|
370
|
+
const lon = degreesMinutes(lonDigits, lonDegreeDigits) * (ew === "W" ? -1 : 1);
|
|
371
|
+
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
|
372
|
+
return { lat: Math.round(lat * 1e4) / 1e4, lon: Math.round(lon * 1e4) / 1e4 };
|
|
373
|
+
}
|
|
374
|
+
function parsePositions(text) {
|
|
375
|
+
const points = [];
|
|
376
|
+
for (const match of text.matchAll(new RegExp(POINT, "g"))) {
|
|
377
|
+
const point = parsePosition(match[0]);
|
|
378
|
+
if (point) points.push(point);
|
|
379
|
+
}
|
|
380
|
+
return points;
|
|
381
|
+
}
|
|
382
|
+
var LEVEL = String.raw`(?:SFC|FL\d{3}|\d{3,5}FT|\d{3,5}M)`;
|
|
383
|
+
var levelFeet = (token) => {
|
|
384
|
+
if (token === "SFC") return 0;
|
|
385
|
+
let m = token.match(/^FL(\d{3})$/);
|
|
386
|
+
if (m) return Number(m[1]) * 100;
|
|
387
|
+
m = token.match(/^(\d{3,5})FT$/);
|
|
388
|
+
if (m) return Number(m[1]);
|
|
389
|
+
m = token.match(/^(\d{3,5})M$/);
|
|
390
|
+
if (m) return Math.round(Number(m[1]) * 3.28084);
|
|
391
|
+
return null;
|
|
392
|
+
};
|
|
393
|
+
var ALTITUDE_PATTERNS = {
|
|
394
|
+
/** `TOP FL490`, `TOP ABV FL450`, `TOP FL250/390`. */
|
|
395
|
+
top: new RegExp(String.raw`\b(?:CB\s+)?TOPS?\s+(?:TO\s+)?(ABV\s+)?(FL\d{3})(?:\/(\d{3}))?`),
|
|
396
|
+
/** `FL240/300`, `SFC/FL100`, `SFC/6000FT`, `2000/4000FT`. */
|
|
397
|
+
range: new RegExp(String.raw`\b(?:FL(\d{3})\/(\d{3})\b|(${LEVEL}|\d{3,5})\/(${LEVEL}))`),
|
|
398
|
+
/** `ABV FL250` sendirian. */
|
|
399
|
+
above: new RegExp(String.raw`\bABV\s+(FL\d{3})`)
|
|
400
|
+
};
|
|
401
|
+
function findAltitude(text) {
|
|
402
|
+
const top = text.match(ALTITUDE_PATTERNS.top);
|
|
403
|
+
if (top) {
|
|
404
|
+
const first = levelFeet(top[2]);
|
|
405
|
+
const second = top[3] ? Number(top[3]) * 100 : null;
|
|
406
|
+
return {
|
|
407
|
+
matched: top[0],
|
|
408
|
+
range: second ? { baseFt: first, topFt: second } : { baseFt: null, topFt: first, ...top[1] ? { topAbove: true } : {} }
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const range = text.match(ALTITUDE_PATTERNS.range);
|
|
412
|
+
if (range) {
|
|
413
|
+
if (range[1] && range[2]) {
|
|
414
|
+
return { matched: range[0], range: { baseFt: Number(range[1]) * 100, topFt: Number(range[2]) * 100 } };
|
|
415
|
+
}
|
|
416
|
+
const baseToken = /^\d{3,5}$/.test(range[3]) ? `${range[3]}${range[4].replace(/^.*?(FT|M)$/, "$1")}` : range[3];
|
|
417
|
+
return {
|
|
418
|
+
matched: range[0],
|
|
419
|
+
range: { baseFt: levelFeet(baseToken), topFt: levelFeet(range[4]) }
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const above = text.match(ALTITUDE_PATTERNS.above);
|
|
423
|
+
if (above) {
|
|
424
|
+
return { matched: above[0], range: { baseFt: levelFeet(above[1]), topFt: null } };
|
|
425
|
+
}
|
|
426
|
+
const single = text.match(/\bFL(\d{3})\b/);
|
|
427
|
+
if (single) {
|
|
428
|
+
const level = Number(single[1]) * 100;
|
|
429
|
+
return { matched: single[0], range: { baseFt: level, topFt: level } };
|
|
430
|
+
}
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
var KMH_TO_KT = 0.539957;
|
|
434
|
+
function findMovement(text) {
|
|
435
|
+
const stationary = text.match(/\bSTNR\b/);
|
|
436
|
+
const moving = text.match(/\bMOV(?:ING)?\s+(?:FROM\s+)?([NSEW]{1,3})(?:\s+(\d{1,3})(?:-(\d{1,3}))?\s*(KT|KMH))?/);
|
|
437
|
+
if (moving && isCompass(moving[1])) {
|
|
438
|
+
const toKt = (value) => moving[4] === "KMH" ? Math.round(Number(value) * KMH_TO_KT) : Number(value);
|
|
439
|
+
const speed = moving[2] === void 0 ? void 0 : toKt(moving[3] ?? moving[2]);
|
|
440
|
+
const min = moving[3] !== void 0 && moving[2] !== void 0 ? toKt(moving[2]) : void 0;
|
|
441
|
+
return {
|
|
442
|
+
matched: moving[0],
|
|
443
|
+
movement: {
|
|
444
|
+
stationary: false,
|
|
445
|
+
direction: moving[1],
|
|
446
|
+
...speed !== void 0 ? { speedKt: speed } : {},
|
|
447
|
+
...min !== void 0 ? { speedMinKt: min } : {}
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
if (stationary) return { matched: stationary[0], movement: { stationary: true } };
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// src/sigmet.ts
|
|
456
|
+
var HEADER = /^(?:([A-Z]{4}) )?(SIGMET|AIRMET) ([A-Z]?\d{1,3}) VALID (\d{6})\/(\d{6})(?: ([A-Z]{4})-?)?/;
|
|
457
|
+
var FIR_NAME = /^([A-Z0-9'.-]+(?: [A-Z0-9'.-]+){0,5}? (?:FIR\/UIR|FIR|UIR|CTA))(?= |$)/;
|
|
458
|
+
var CANCEL = /^CNL (SIGMET|AIRMET) ([A-Z]?\d{1,3}) (\d{6})\/(\d{6})(?: (.*))?$/;
|
|
459
|
+
var POINTS = String.raw`${POINT}(?:\s*-\s*${POINT})*`;
|
|
460
|
+
var SIDE = "(N|NNE|NE|ENE|E|ESE|SE|SSE|S|SSW|SW|WSW|W|WNW|NW|NNW)";
|
|
461
|
+
var KM_TO_NM = 0.539957;
|
|
462
|
+
var dayTime = (reference, token) => resolveDayTime(reference, Number(token.slice(0, 2)), Number(token.slice(2, 4)), Number(token.slice(4, 6)));
|
|
463
|
+
function timeNear(validFrom, hhmm2) {
|
|
464
|
+
let best = null;
|
|
465
|
+
for (const offset of [-1, 0, 1]) {
|
|
466
|
+
const candidate = new Date(
|
|
467
|
+
Date.UTC(
|
|
468
|
+
validFrom.getUTCFullYear(),
|
|
469
|
+
validFrom.getUTCMonth(),
|
|
470
|
+
validFrom.getUTCDate() + offset,
|
|
471
|
+
Number(hhmm2.slice(0, 2)),
|
|
472
|
+
Number(hhmm2.slice(2, 4))
|
|
473
|
+
)
|
|
474
|
+
);
|
|
475
|
+
if (best === null || Math.abs(candidate.getTime() - validFrom.getTime()) < Math.abs(best.getTime() - validFrom.getTime())) {
|
|
476
|
+
best = candidate;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return best;
|
|
480
|
+
}
|
|
481
|
+
var blank = (text, matched) => text.replace(matched, " ".repeat(matched.length));
|
|
482
|
+
var FILLER = /* @__PURE__ */ new Set(["-", "AND", "VA", "CLD", "AT", "OF", "WI", "CB", "TC", "CENTRE", "PSN", "OBS", "FCST"]);
|
|
483
|
+
function leftovers(rest) {
|
|
484
|
+
const words = rest.split(/\s+/).filter((w) => w !== "" && !FILLER.has(w));
|
|
485
|
+
return words.length > 0 ? [words.join(" ")] : [];
|
|
486
|
+
}
|
|
487
|
+
function findArea(text) {
|
|
488
|
+
const entire = text.match(/\b(?:ENTIRE|WHOLE) FIR\b/);
|
|
489
|
+
if (entire) return { area: { kind: "entire-fir" }, matched: entire[0] };
|
|
490
|
+
const circle = text.match(new RegExp(String.raw`\bWI (\d+)\s*(NM|KM) OF (TC CENTRE|CENTRE|VA CENTRE|(${POINT}))`));
|
|
491
|
+
if (circle) {
|
|
492
|
+
const radius = Number(circle[1]) * (circle[2] === "KM" ? KM_TO_NM : 1);
|
|
493
|
+
const centre = circle[4] ? parsePosition(circle[4]) : null;
|
|
494
|
+
return {
|
|
495
|
+
area: {
|
|
496
|
+
kind: "circle",
|
|
497
|
+
radiusNm: Math.round(radius * 10) / 10,
|
|
498
|
+
center: centre ?? (circle[3]?.startsWith("VA") ? "VA_CENTRE" : circle[3] ? "TC_CENTRE" : null)
|
|
499
|
+
},
|
|
500
|
+
matched: circle[0]
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const corridor = text.match(new RegExp(String.raw`\bWI (\d+)\s*NM WID LINE BTN (${POINTS})`));
|
|
504
|
+
if (corridor) {
|
|
505
|
+
return {
|
|
506
|
+
area: { kind: "corridor", widthNm: Number(corridor[1]), points: parsePositions(corridor[2]) },
|
|
507
|
+
matched: corridor[0]
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const sideLine = text.match(new RegExp(String.raw`\b${SIDE} OF LINE (${POINTS})`));
|
|
511
|
+
if (sideLine) {
|
|
512
|
+
return {
|
|
513
|
+
area: { kind: "line", side: sideLine[1], points: parsePositions(sideLine[2]) },
|
|
514
|
+
matched: sideLine[0]
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const line = text.match(new RegExp(String.raw`\bLINE (${POINTS})`));
|
|
518
|
+
if (line) {
|
|
519
|
+
return { area: { kind: "line", side: null, points: parsePositions(line[1]) }, matched: line[0] };
|
|
520
|
+
}
|
|
521
|
+
const latitude = text.match(/\b(N|S|NE|SE|NW|SW) OF ([NS])(\d{2,4})\b/);
|
|
522
|
+
if (latitude) {
|
|
523
|
+
const sign = latitude[2] === "S" ? -1 : 1;
|
|
524
|
+
const digits = latitude[3];
|
|
525
|
+
const lat = sign * (Number(digits.slice(0, 2)) + (digits.length > 2 ? Number(digits.slice(2).padEnd(2, "0")) / 60 : 0));
|
|
526
|
+
return { area: { kind: "latitude-line", side: latitude[1], lat: Math.round(lat * 1e4) / 1e4 }, matched: latitude[0] };
|
|
527
|
+
}
|
|
528
|
+
const longitude = text.match(/\b(E|W|NE|SE|NW|SW) OF ([EW])(\d{3,5})\b/);
|
|
529
|
+
if (longitude) {
|
|
530
|
+
const sign = longitude[2] === "W" ? -1 : 1;
|
|
531
|
+
const digits = longitude[3];
|
|
532
|
+
const lon = sign * (Number(digits.slice(0, 3)) + (digits.length > 3 ? Number(digits.slice(3).padEnd(2, "0")) / 60 : 0));
|
|
533
|
+
return { area: { kind: "longitude-line", side: longitude[1], lon: Math.round(lon * 1e4) / 1e4 }, matched: longitude[0] };
|
|
534
|
+
}
|
|
535
|
+
const polygon = text.match(new RegExp(String.raw`\bWI (${POINTS})`));
|
|
536
|
+
if (polygon) {
|
|
537
|
+
return { area: { kind: "polygon", points: parsePositions(polygon[1]) }, matched: polygon[0] };
|
|
538
|
+
}
|
|
539
|
+
const bare = text.match(new RegExp(String.raw`(?:^|\s)(${POINTS})`));
|
|
540
|
+
if (bare) {
|
|
541
|
+
const points = parsePositions(bare[1]);
|
|
542
|
+
if (points.length >= 3) return { area: { kind: "polygon", points }, matched: bare[1] };
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
function readPhenomenon(body) {
|
|
547
|
+
let m = body.match(/^(OBSC|EMBD|FRQ|SQL|ISOL|OCNL) (TSGR|TS)\b/);
|
|
548
|
+
if (m) return { hazard: "THUNDERSTORM", phrase: m[0], length: m[0].length };
|
|
549
|
+
m = body.match(/^(ISOL|OCNL|FRQ) (CB|TCU)\b/);
|
|
550
|
+
if (m) return { hazard: "THUNDERSTORM", phrase: m[0], length: m[0].length };
|
|
551
|
+
m = body.match(new RegExp(String.raw`^TC ([A-Z0-9-]+)(?: PSN (${POINT}))?( CB)?`));
|
|
552
|
+
if (m) {
|
|
553
|
+
const position = m[2] ? parsePosition(m[2]) : null;
|
|
554
|
+
return {
|
|
555
|
+
hazard: "TROPICAL_CYCLONE",
|
|
556
|
+
phrase: m[0],
|
|
557
|
+
length: m[0].length,
|
|
558
|
+
cyclone: {
|
|
559
|
+
name: m[1],
|
|
560
|
+
...position ? { position } : {},
|
|
561
|
+
...m[3] ? { cumulonimbus: true } : {}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
m = body.match(/^(SEV|MOD) TURB\b/);
|
|
566
|
+
if (m) return { hazard: "TURBULENCE", phrase: m[0], length: m[0].length };
|
|
567
|
+
m = body.match(/^(SEV|MOD) ICE(?: ?\(FZRA\))?/);
|
|
568
|
+
if (m) return { hazard: "ICING", phrase: m[0], length: m[0].length };
|
|
569
|
+
m = body.match(/^(SEV|MOD) MTW\b/);
|
|
570
|
+
if (m) return { hazard: "MOUNTAIN_WAVE", phrase: m[0], length: m[0].length };
|
|
571
|
+
m = body.match(/^HVY (DS|SS)\b/);
|
|
572
|
+
if (m) return { hazard: m[1] === "DS" ? "DUST_STORM" : "SAND_STORM", phrase: m[0], length: m[0].length };
|
|
573
|
+
m = body.match(/^RDOACT CLD\b/);
|
|
574
|
+
if (m) return { hazard: "RADIOACTIVE_CLOUD", phrase: m[0], length: m[0].length };
|
|
575
|
+
m = body.match(new RegExp(String.raw`^VA(?: ERUPTION)?(?: MT ([A-Z0-9][A-Z0-9 .'()-]*?))?(?: PSN (${POINT}))?(?: POSS)?(?= VA CLD| OBS| FCST| WI|$)`));
|
|
576
|
+
if (m) {
|
|
577
|
+
const position = m[2] ? parsePosition(m[2]) : null;
|
|
578
|
+
return {
|
|
579
|
+
hazard: "VOLCANIC_ASH",
|
|
580
|
+
phrase: m[0],
|
|
581
|
+
length: m[0].length,
|
|
582
|
+
volcano: { name: m[1]?.trim() ?? null, ...position ? { position } : {} }
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
m = body.match(new RegExp(String.raw`^VA ([A-Z][A-Z0-9 .'()-]*?) PSN (${POINT})`));
|
|
586
|
+
if (m) {
|
|
587
|
+
const position = parsePosition(m[2]);
|
|
588
|
+
return {
|
|
589
|
+
hazard: "VOLCANIC_ASH",
|
|
590
|
+
phrase: m[0],
|
|
591
|
+
length: m[0].length,
|
|
592
|
+
volcano: { name: m[1].trim(), ...position ? { position } : {} }
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
m = body.match(/^VA CLD\b/);
|
|
596
|
+
if (m) return { hazard: "VOLCANIC_ASH", phrase: m[0], length: m[0].length, volcano: { name: null } };
|
|
597
|
+
m = body.match(/^SFC WSPD (\d{2,3}) ?(KT|MPS)/);
|
|
598
|
+
if (m) {
|
|
599
|
+
const speed = Number(m[1]);
|
|
600
|
+
return {
|
|
601
|
+
hazard: "SURFACE_WIND",
|
|
602
|
+
phrase: m[0],
|
|
603
|
+
length: m[0].length,
|
|
604
|
+
surfaceWindKt: m[2] === "MPS" ? Math.round(speed * 1.943844) : speed
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
m = body.match(/^SFC VIS (\d{2,5}) ?M(?: ?\(([A-Z ]+)\))?/);
|
|
608
|
+
if (m) {
|
|
609
|
+
return { hazard: "SURFACE_VISIBILITY", phrase: m[0], length: m[0].length, surfaceVisibilityM: Number(m[1]) };
|
|
610
|
+
}
|
|
611
|
+
m = body.match(/^(BKN|OVC) CLD (\d{2,5})\/(\d{3,5}) ?(FT|M)/);
|
|
612
|
+
if (m) return { hazard: "CLOUD", phrase: m[0], length: m[0].length };
|
|
613
|
+
m = body.match(/^(MT OBSC|ISOL TCU)\b/);
|
|
614
|
+
if (m) return { hazard: "OTHER", phrase: m[0], length: m[0].length };
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
function parseSigmet(rawText, reference = /* @__PURE__ */ new Date()) {
|
|
618
|
+
const text = rawText.replace(/\s+/g, " ").trim().replace(/=$/, "").trim();
|
|
619
|
+
const header = text.match(HEADER);
|
|
620
|
+
if (!header || !header[1]) return null;
|
|
621
|
+
const validFrom = dayTime(reference, header[4]);
|
|
622
|
+
const validTo = dayTime(validFrom, header[5]);
|
|
623
|
+
if (Number.isNaN(validFrom.getTime()) || Number.isNaN(validTo.getTime())) return null;
|
|
624
|
+
let rest = text.slice(header[0].length).replace(/^[^A-Z0-9]+/, "").trim();
|
|
625
|
+
if (rest.startsWith(`${header[1]} `)) rest = rest.slice(5);
|
|
626
|
+
const sigmet = {
|
|
627
|
+
kind: header[2],
|
|
628
|
+
fir: header[1],
|
|
629
|
+
issuer: header[6] ?? null,
|
|
630
|
+
series: header[3],
|
|
631
|
+
validFrom,
|
|
632
|
+
validTo,
|
|
633
|
+
hazard: "OTHER",
|
|
634
|
+
phenomenon: "",
|
|
635
|
+
area: null,
|
|
636
|
+
unparsed: [],
|
|
637
|
+
raw: text
|
|
638
|
+
};
|
|
639
|
+
const firName = rest.match(FIR_NAME);
|
|
640
|
+
if (firName) {
|
|
641
|
+
sigmet.firName = firName[1];
|
|
642
|
+
rest = rest.slice(firName[0].length).trim();
|
|
643
|
+
}
|
|
644
|
+
const cancel = rest.match(CANCEL);
|
|
645
|
+
if (cancel) {
|
|
646
|
+
sigmet.hazard = "CANCEL";
|
|
647
|
+
sigmet.phenomenon = `CNL ${cancel[1]} ${cancel[2]}`;
|
|
648
|
+
sigmet.cancel = {
|
|
649
|
+
series: cancel[2],
|
|
650
|
+
validFrom: dayTime(validFrom, cancel[3]),
|
|
651
|
+
validTo: dayTime(validFrom, cancel[4])
|
|
652
|
+
};
|
|
653
|
+
if (cancel[5]) sigmet.note = cancel[5];
|
|
654
|
+
return sigmet;
|
|
655
|
+
}
|
|
656
|
+
const phenomenon = readPhenomenon(rest);
|
|
657
|
+
let body = rest;
|
|
658
|
+
if (phenomenon) {
|
|
659
|
+
sigmet.hazard = phenomenon.hazard;
|
|
660
|
+
sigmet.phenomenon = phenomenon.phrase;
|
|
661
|
+
if (phenomenon.volcano) sigmet.volcano = phenomenon.volcano;
|
|
662
|
+
if (phenomenon.cyclone) sigmet.cyclone = phenomenon.cyclone;
|
|
663
|
+
if (phenomenon.surfaceWindKt !== void 0) sigmet.surfaceWindKt = phenomenon.surfaceWindKt;
|
|
664
|
+
if (phenomenon.surfaceVisibilityM !== void 0) sigmet.surfaceVisibilityM = phenomenon.surfaceVisibilityM;
|
|
665
|
+
body = " ".repeat(phenomenon.length) + rest.slice(phenomenon.length);
|
|
666
|
+
}
|
|
667
|
+
let main = body;
|
|
668
|
+
let forecastPart = null;
|
|
669
|
+
const split = body.search(/ FCST (?:AT )?\d{4}Z/);
|
|
670
|
+
if (split >= 0 && /\bOBS\b/.test(body.slice(0, split))) {
|
|
671
|
+
main = body.slice(0, split);
|
|
672
|
+
forecastPart = body.slice(split);
|
|
673
|
+
}
|
|
674
|
+
const consumed = { rest: main };
|
|
675
|
+
const status = consumed.rest.match(/\b(?:AT (\d{4})Z )?(OBS\/FCST|OBS|FCST)(?: AT (\d{4})Z)?/);
|
|
676
|
+
if (status) {
|
|
677
|
+
sigmet.status = status[2];
|
|
678
|
+
const at = status[1] ?? status[3];
|
|
679
|
+
if (at) sigmet.statusAt = timeNear(validFrom, at);
|
|
680
|
+
consumed.rest = blank(consumed.rest, status[0]);
|
|
681
|
+
}
|
|
682
|
+
const areas = [];
|
|
683
|
+
for (let guard = 0; guard < 6; guard++) {
|
|
684
|
+
const found = findArea(consumed.rest);
|
|
685
|
+
if (!found) break;
|
|
686
|
+
areas.push(found.area);
|
|
687
|
+
consumed.rest = blank(consumed.rest, found.matched);
|
|
688
|
+
}
|
|
689
|
+
if (areas[0]) sigmet.area = areas[0];
|
|
690
|
+
if (areas.length > 1) sigmet.areas = areas;
|
|
691
|
+
const altitude = findAltitude(consumed.rest);
|
|
692
|
+
if (altitude) {
|
|
693
|
+
sigmet.altitude = altitude.range;
|
|
694
|
+
consumed.rest = blank(consumed.rest, altitude.matched);
|
|
695
|
+
}
|
|
696
|
+
const movement = findMovement(consumed.rest);
|
|
697
|
+
if (movement) {
|
|
698
|
+
sigmet.movement = movement.movement;
|
|
699
|
+
consumed.rest = blank(consumed.rest, movement.matched);
|
|
700
|
+
}
|
|
701
|
+
const trends = [...consumed.rest.matchAll(/\b(NC|INTSF|WKN)\b/g)];
|
|
702
|
+
if (trends.length > 0) {
|
|
703
|
+
const names = trends.map((t) => t[1]);
|
|
704
|
+
sigmet.trend = names.find((n) => n !== "NC") ?? "NC";
|
|
705
|
+
for (const t of trends) consumed.rest = blank(consumed.rest, t[0]);
|
|
706
|
+
}
|
|
707
|
+
sigmet.unparsed.push(...leftovers(consumed.rest));
|
|
708
|
+
if (forecastPart) {
|
|
709
|
+
const at = forecastPart.match(/ FCST (?:AT )?(\d{4})Z/);
|
|
710
|
+
let part = forecastPart.replace(/ FCST (?:AT )?\d{4}Z/, " ");
|
|
711
|
+
const forecast = { at: at ? timeNear(validFrom, at[1]) : null, area: null };
|
|
712
|
+
const noVa = part.match(/\bNO VA EXP\b/);
|
|
713
|
+
if (noVa) {
|
|
714
|
+
forecast.noVaExpected = true;
|
|
715
|
+
part = blank(part, noVa[0]);
|
|
716
|
+
}
|
|
717
|
+
const centre = part.match(new RegExp(String.raw`\bTC CENTRE PSN (${POINT})`));
|
|
718
|
+
if (centre) {
|
|
719
|
+
const point = parsePosition(centre[1]);
|
|
720
|
+
if (point) forecast.centre = point;
|
|
721
|
+
part = blank(part, centre[0]);
|
|
722
|
+
}
|
|
723
|
+
const forecastArea = findArea(part);
|
|
724
|
+
if (forecastArea) {
|
|
725
|
+
forecast.area = forecastArea.area;
|
|
726
|
+
part = blank(part, forecastArea.matched);
|
|
727
|
+
}
|
|
728
|
+
const forecastAltitude = findAltitude(part);
|
|
729
|
+
if (forecastAltitude) part = blank(part, forecastAltitude.matched);
|
|
730
|
+
sigmet.forecast = forecast;
|
|
731
|
+
sigmet.unparsed.push(...leftovers(part));
|
|
732
|
+
}
|
|
733
|
+
return sigmet;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/snowtam.ts
|
|
737
|
+
function monthDayTime(reference, digits) {
|
|
738
|
+
const m = digits.match(/^(\d{2})(\d{2})(\d{2})(\d{2})$/);
|
|
739
|
+
if (!m) return null;
|
|
740
|
+
const month = Number(m[1]) - 1;
|
|
741
|
+
const day = Number(m[2]);
|
|
742
|
+
if (month < 0 || month > 11 || day < 1 || day > 31) return null;
|
|
743
|
+
let best = null;
|
|
744
|
+
for (const year of [reference.getUTCFullYear() - 1, reference.getUTCFullYear(), reference.getUTCFullYear() + 1]) {
|
|
745
|
+
const candidate = new Date(Date.UTC(year, month, day, Number(m[3]), Number(m[4])));
|
|
746
|
+
if (candidate.getUTCDate() !== day) continue;
|
|
747
|
+
if (!best || Math.abs(candidate.getTime() - reference.getTime()) < Math.abs(best.getTime() - reference.getTime())) {
|
|
748
|
+
best = candidate;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return best;
|
|
752
|
+
}
|
|
753
|
+
var perThird = (value) => value.split("/").map((part) => /^\d+$/.test(part) ? Number(part) : null);
|
|
754
|
+
var ITEM = /(?:^|\s)([A-T])\)\s*/g;
|
|
755
|
+
function parseSnowtam(rawText, reference = /* @__PURE__ */ new Date()) {
|
|
756
|
+
const text = rawText.replace(/\s+/g, " ").trim().replace(/^\(/, "").replace(/\)$/, "").trim();
|
|
757
|
+
const head = text.match(/^SNOWTAM(?: ([A-Z]{0,4}\d{2,4}))?\s*(.*)$/);
|
|
758
|
+
if (!head) return null;
|
|
759
|
+
const snowtam = {
|
|
760
|
+
kind: "SNOWTAM",
|
|
761
|
+
format: "lettered",
|
|
762
|
+
number: head[1] ?? null,
|
|
763
|
+
aerodrome: null,
|
|
764
|
+
assessedAt: null,
|
|
765
|
+
runways: [],
|
|
766
|
+
items: {},
|
|
767
|
+
unparsed: [],
|
|
768
|
+
raw: text
|
|
769
|
+
};
|
|
770
|
+
const body = head[2];
|
|
771
|
+
const marks = [...body.matchAll(ITEM)];
|
|
772
|
+
if (marks.length === 0 || marks[0][1] !== "A") {
|
|
773
|
+
snowtam.format = "grf";
|
|
774
|
+
const tokens = body.split(" ").filter(Boolean);
|
|
775
|
+
if (/^[A-Z]{4}$/.test(tokens[0] ?? "")) snowtam.aerodrome = tokens.shift();
|
|
776
|
+
if (/^\d{8}$/.test(tokens[0] ?? "")) snowtam.assessedAt = monthDayTime(reference, tokens.shift());
|
|
777
|
+
if (tokens.length > 0) snowtam.unparsed.push(tokens.join(" "));
|
|
778
|
+
return snowtam.aerodrome ? snowtam : null;
|
|
779
|
+
}
|
|
780
|
+
let runway = null;
|
|
781
|
+
marks.forEach((mark, i) => {
|
|
782
|
+
const letter = mark[1];
|
|
783
|
+
const value = body.slice((mark.index ?? 0) + mark[0].length, marks[i + 1]?.index ?? body.length).trim();
|
|
784
|
+
switch (letter) {
|
|
785
|
+
case "A":
|
|
786
|
+
snowtam.aerodrome = value || null;
|
|
787
|
+
break;
|
|
788
|
+
case "B":
|
|
789
|
+
snowtam.assessedAt = monthDayTime(reference, value);
|
|
790
|
+
if (!snowtam.assessedAt) snowtam.unparsed.push(`B) ${value}`);
|
|
791
|
+
break;
|
|
792
|
+
case "C":
|
|
793
|
+
runway = { designator: value };
|
|
794
|
+
snowtam.runways.push(runway);
|
|
795
|
+
break;
|
|
796
|
+
case "D": {
|
|
797
|
+
if (runway && /^\d+$/.test(value)) runway.clearedLengthM = Number(value);
|
|
798
|
+
else if (runway) snowtam.unparsed.push(`D) ${value}`);
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
case "E": {
|
|
802
|
+
const width = value.match(/^(\d+)\s*([LR])?$/);
|
|
803
|
+
if (runway && width) {
|
|
804
|
+
runway.clearedWidthM = { value: Number(width[1]), ...width[2] ? { side: width[2] } : {} };
|
|
805
|
+
} else if (runway) snowtam.unparsed.push(`E) ${value}`);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
case "F":
|
|
809
|
+
if (runway) runway.deposits = perThird(value);
|
|
810
|
+
break;
|
|
811
|
+
case "G":
|
|
812
|
+
if (runway) runway.meanDepthMm = perThird(value);
|
|
813
|
+
break;
|
|
814
|
+
case "H":
|
|
815
|
+
if (runway) runway.friction = value.split("/");
|
|
816
|
+
break;
|
|
817
|
+
case "S":
|
|
818
|
+
snowtam.items.S = value;
|
|
819
|
+
snowtam.nextObservationAt = monthDayTime(reference, value);
|
|
820
|
+
break;
|
|
821
|
+
case "T":
|
|
822
|
+
snowtam.items.T = value;
|
|
823
|
+
snowtam.remarks = value;
|
|
824
|
+
break;
|
|
825
|
+
default:
|
|
826
|
+
snowtam.items[letter] = value;
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
return snowtam.aerodrome ? snowtam : null;
|
|
830
|
+
}
|
|
831
|
+
|
|
241
832
|
// src/taf.ts
|
|
242
833
|
var ISSUED = /^(\d{2})(\d{2})(\d{2})Z$/;
|
|
243
834
|
var VALIDITY = /^(\d{2})(\d{2})\/(\d{2})(\d{2})$/;
|
|
@@ -269,6 +860,7 @@ function parseTaf(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
269
860
|
index += 1;
|
|
270
861
|
const validFrom = resolveDayTime(issuedAt, Number(validity[1]), Number(validity[2]));
|
|
271
862
|
const validTo = resolveDayTime(issuedAt, Number(validity[3]), Number(validity[4]));
|
|
863
|
+
if (Number.isNaN(validFrom.getTime()) || Number.isNaN(validTo.getTime())) return null;
|
|
272
864
|
const base = {
|
|
273
865
|
station,
|
|
274
866
|
issuedAt,
|
|
@@ -279,14 +871,22 @@ function parseTaf(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
279
871
|
raw: tokens.join(" ")
|
|
280
872
|
};
|
|
281
873
|
if (tokens[index] === "CNL") {
|
|
282
|
-
return { kind: "TAF", ...base, cancelled: true, periods: [] };
|
|
874
|
+
return { kind: "TAF", ...base, cancelled: true, periods: [], unparsed: [] };
|
|
283
875
|
}
|
|
284
876
|
const groups = [
|
|
285
877
|
{ change: "BASE", from: validFrom, to: validTo, tokens: [] }
|
|
286
878
|
];
|
|
287
879
|
let current = groups[0];
|
|
288
880
|
let probability;
|
|
881
|
+
let remark = false;
|
|
882
|
+
let malformed = false;
|
|
883
|
+
const dropped = [];
|
|
884
|
+
let dropping = false;
|
|
885
|
+
let amendmentsNotScheduled = false;
|
|
289
886
|
const startGroup = (change, from, to) => {
|
|
887
|
+
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) malformed = true;
|
|
888
|
+
remark = false;
|
|
889
|
+
dropping = false;
|
|
290
890
|
current = { change, probability, from, to, tokens: [] };
|
|
291
891
|
probability = void 0;
|
|
292
892
|
groups.push(current);
|
|
@@ -306,6 +906,9 @@ function parseTaf(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
306
906
|
resolveDayTime(issuedAt, Number(period[3]), Number(period[4]))
|
|
307
907
|
);
|
|
308
908
|
i += 1;
|
|
909
|
+
} else {
|
|
910
|
+
dropping = true;
|
|
911
|
+
dropped.push(token);
|
|
309
912
|
}
|
|
310
913
|
}
|
|
311
914
|
continue;
|
|
@@ -321,7 +924,11 @@ function parseTaf(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
321
924
|
}
|
|
322
925
|
if (token === "BECMG" || token === "TEMPO") {
|
|
323
926
|
const period = tokens[i + 1]?.match(VALIDITY);
|
|
324
|
-
if (!period)
|
|
927
|
+
if (!period) {
|
|
928
|
+
dropping = true;
|
|
929
|
+
dropped.push(token);
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
325
932
|
startGroup(
|
|
326
933
|
token,
|
|
327
934
|
resolveDayTime(issuedAt, Number(period[1]), Number(period[2])),
|
|
@@ -330,20 +937,41 @@ function parseTaf(raw, reference = /* @__PURE__ */ new Date()) {
|
|
|
330
937
|
i += 1;
|
|
331
938
|
continue;
|
|
332
939
|
}
|
|
333
|
-
|
|
940
|
+
if (token === "RMK") {
|
|
941
|
+
remark = true;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (token === "AMD" && tokens[i + 1] === "NOT" && /^(SKED|SCHEDULED)$/.test(tokens[i + 2] ?? "")) {
|
|
945
|
+
amendmentsNotScheduled = true;
|
|
946
|
+
i += 2;
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
if (remark) continue;
|
|
950
|
+
if (VALIDITY.test(token)) dropping = true;
|
|
951
|
+
if (dropping) dropped.push(token);
|
|
952
|
+
else current.tokens.push(token);
|
|
334
953
|
}
|
|
954
|
+
if (malformed) return null;
|
|
335
955
|
const periods = groups.map((group) => {
|
|
336
|
-
const { rest
|
|
956
|
+
const { rest, ...conditions } = parseConditionTokens(group.tokens);
|
|
337
957
|
return {
|
|
338
958
|
change: group.change,
|
|
339
959
|
probability: group.probability,
|
|
340
960
|
from: group.from,
|
|
341
961
|
to: group.to,
|
|
342
962
|
conditions,
|
|
963
|
+
unparsed: rest,
|
|
343
964
|
raw: group.tokens.join(" ")
|
|
344
965
|
};
|
|
345
966
|
});
|
|
346
|
-
return {
|
|
967
|
+
return {
|
|
968
|
+
kind: "TAF",
|
|
969
|
+
...base,
|
|
970
|
+
cancelled: false,
|
|
971
|
+
...amendmentsNotScheduled ? { amendmentsNotScheduled } : {},
|
|
972
|
+
periods,
|
|
973
|
+
unparsed: dropped
|
|
974
|
+
};
|
|
347
975
|
}
|
|
348
976
|
var lerp = (a, b, t) => a + (b - a) * t;
|
|
349
977
|
function lerpAngle(a, b, t) {
|
|
@@ -406,6 +1034,163 @@ function forecastAt(taf, at, options = {}) {
|
|
|
406
1034
|
return { conditions: state, temporary, transition };
|
|
407
1035
|
}
|
|
408
1036
|
|
|
1037
|
+
// src/vaa.ts
|
|
1038
|
+
var LABELS = [
|
|
1039
|
+
"DTG",
|
|
1040
|
+
"VAAC",
|
|
1041
|
+
"VOLCANO",
|
|
1042
|
+
"PSN",
|
|
1043
|
+
"AREA",
|
|
1044
|
+
"SUMMIT ELEV",
|
|
1045
|
+
"SOURCE ELEV",
|
|
1046
|
+
"ADVISORY NR",
|
|
1047
|
+
"INFO SOURCE",
|
|
1048
|
+
"AVIATION COLOUR CODE",
|
|
1049
|
+
"ERUPTION DETAILS",
|
|
1050
|
+
"OBS VA DTG",
|
|
1051
|
+
"OBS VA CLD",
|
|
1052
|
+
"EST VA DTG",
|
|
1053
|
+
"EST VA CLD",
|
|
1054
|
+
"FCST VA CLD \\+ ?\\d+ ?HR",
|
|
1055
|
+
"RMK",
|
|
1056
|
+
"NXT ADVISORY",
|
|
1057
|
+
"NEXT ADVISORY"
|
|
1058
|
+
];
|
|
1059
|
+
var LABEL = new RegExp(`(?:^|\\s)(${LABELS.join("|")}):`, "g");
|
|
1060
|
+
var FLIGHT_LEVEL = String.raw`(?:SFC|FL\d{3})`;
|
|
1061
|
+
var LAYER_LEVELS = new RegExp(String.raw`(${FLIGHT_LEVEL})\/(${FLIGHT_LEVEL})`, "g");
|
|
1062
|
+
var levelFeet2 = (token) => token === "SFC" ? 0 : Number(token.slice(2)) * 100;
|
|
1063
|
+
function fullDate(text) {
|
|
1064
|
+
const m = text.match(/(\d{4})(\d{2})(\d{2})\/(\d{2})(\d{2})Z/);
|
|
1065
|
+
return m ? new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]))) : null;
|
|
1066
|
+
}
|
|
1067
|
+
function dayHour(reference, text) {
|
|
1068
|
+
const m = text.match(/(\d{2})\/(\d{2})(\d{2})Z/);
|
|
1069
|
+
return m ? resolveDayTime(reference, Number(m[1]), Number(m[2]), Number(m[3])) : null;
|
|
1070
|
+
}
|
|
1071
|
+
function parseVaCloud(text, reference) {
|
|
1072
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1073
|
+
if (/^(?:NOT AVBL|NOT AVAILABLE|NIL)\b/.test(flat)) return { status: "not-available", layers: [], text: flat };
|
|
1074
|
+
if (/^NO VA EXP/.test(flat)) return { status: "none-expected", layers: [], text: flat };
|
|
1075
|
+
if (/^(?:VA )?NOT IDENTIFIABLE/.test(flat)) {
|
|
1076
|
+
const cloud = { status: "not-identifiable", layers: [], text: flat };
|
|
1077
|
+
const wind = flat.match(/WIND (FL\d{3}) (\d{3})\/(\d{2,3})KT/);
|
|
1078
|
+
if (wind) {
|
|
1079
|
+
cloud.wind = { levelFt: levelFeet2(wind[1]), directionDeg: Number(wind[2]), speedKt: Number(wind[3]) };
|
|
1080
|
+
}
|
|
1081
|
+
return cloud;
|
|
1082
|
+
}
|
|
1083
|
+
const layers = [];
|
|
1084
|
+
const found = [...flat.matchAll(LAYER_LEVELS)];
|
|
1085
|
+
found.forEach((match, i) => {
|
|
1086
|
+
const start = match.index ?? 0;
|
|
1087
|
+
const end = found[i + 1]?.index ?? flat.length;
|
|
1088
|
+
const part = flat.slice(start, end);
|
|
1089
|
+
const layer = {
|
|
1090
|
+
baseFt: levelFeet2(match[1]),
|
|
1091
|
+
topFt: levelFeet2(match[2]),
|
|
1092
|
+
area: parsePositions(part)
|
|
1093
|
+
};
|
|
1094
|
+
const move = findMovement(part);
|
|
1095
|
+
if (move) layer.movement = move.movement;
|
|
1096
|
+
if (i === 0) {
|
|
1097
|
+
const at = dayHour(reference, flat.slice(0, start));
|
|
1098
|
+
if (at) layer.at = at;
|
|
1099
|
+
}
|
|
1100
|
+
layers.push(layer);
|
|
1101
|
+
});
|
|
1102
|
+
return { status: "identified", layers, text: flat };
|
|
1103
|
+
}
|
|
1104
|
+
function parseVaa(rawText, _reference = /* @__PURE__ */ new Date()) {
|
|
1105
|
+
const text = rawText.replace(/\s+/g, " ").trim().replace(/=$/, "").trim();
|
|
1106
|
+
const marks = [...text.matchAll(LABEL)];
|
|
1107
|
+
if (marks.length === 0) return null;
|
|
1108
|
+
const values = /* @__PURE__ */ new Map();
|
|
1109
|
+
const order = [];
|
|
1110
|
+
marks.forEach((mark, i) => {
|
|
1111
|
+
const labelEnd = (mark.index ?? 0) + mark[0].length;
|
|
1112
|
+
const value = text.slice(labelEnd, marks[i + 1]?.index ?? text.length).trim();
|
|
1113
|
+
const name = mark[1].replace(/\s*\+\s*/, " +").replace(/ ?HR$/, " HR");
|
|
1114
|
+
if (!values.has(name)) order.push(name);
|
|
1115
|
+
values.set(name, value);
|
|
1116
|
+
});
|
|
1117
|
+
const issuedAt = fullDate(values.get("DTG") ?? "");
|
|
1118
|
+
const vaac = values.get("VAAC");
|
|
1119
|
+
const volcanoText = values.get("VOLCANO");
|
|
1120
|
+
const advisoryNumber = values.get("ADVISORY NR");
|
|
1121
|
+
if (!issuedAt || !vaac || !volcanoText || !advisoryNumber) return null;
|
|
1122
|
+
const number = volcanoText.match(/^(.*?)\s+(\d{6})$/);
|
|
1123
|
+
const advisory = {
|
|
1124
|
+
kind: "VAA",
|
|
1125
|
+
vaac,
|
|
1126
|
+
issuedAt,
|
|
1127
|
+
volcano: { name: (number?.[1] ?? volcanoText) || null, ...number ? { number: number[2] } : {} },
|
|
1128
|
+
advisoryNumber,
|
|
1129
|
+
forecasts: [],
|
|
1130
|
+
unparsed: [],
|
|
1131
|
+
raw: text
|
|
1132
|
+
};
|
|
1133
|
+
if (/^(UNNAMED|UNKNOWN|NIL)$/i.test(advisory.volcano.name ?? "")) advisory.volcano.name = null;
|
|
1134
|
+
const psn = values.get("PSN");
|
|
1135
|
+
if (psn) {
|
|
1136
|
+
const position = parsePosition(psn.match(new RegExp(POINT))?.[0] ?? "");
|
|
1137
|
+
if (position) advisory.position = position;
|
|
1138
|
+
else advisory.unparsed.push(`PSN: ${psn}`);
|
|
1139
|
+
}
|
|
1140
|
+
const area = values.get("AREA");
|
|
1141
|
+
if (area) advisory.area = area;
|
|
1142
|
+
const elevation = (values.get("SUMMIT ELEV") ?? values.get("SOURCE ELEV"))?.match(/(\d+)\s*M/);
|
|
1143
|
+
if (elevation) advisory.summitElevationM = Number(elevation[1]);
|
|
1144
|
+
const source = values.get("INFO SOURCE");
|
|
1145
|
+
if (source) advisory.infoSource = source;
|
|
1146
|
+
const colour = values.get("AVIATION COLOUR CODE");
|
|
1147
|
+
if (colour) advisory.colourCode = colour;
|
|
1148
|
+
const eruption = values.get("ERUPTION DETAILS");
|
|
1149
|
+
if (eruption) {
|
|
1150
|
+
const at = fullDate(eruption);
|
|
1151
|
+
const top = eruption.match(/\bFL(\d{3})\b/);
|
|
1152
|
+
advisory.eruption = {
|
|
1153
|
+
text: eruption,
|
|
1154
|
+
...at ? { at } : {},
|
|
1155
|
+
...top ? { topFt: Number(top[1]) * 100 } : {}
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
const observedAt = dayHour(issuedAt, values.get("OBS VA DTG") ?? "");
|
|
1159
|
+
const observed = values.get("OBS VA CLD");
|
|
1160
|
+
if (observed !== void 0) advisory.observed = { at: observedAt, cloud: parseVaCloud(observed, issuedAt) };
|
|
1161
|
+
const estimated = values.get("EST VA CLD");
|
|
1162
|
+
if (estimated !== void 0) {
|
|
1163
|
+
advisory.estimated = { at: dayHour(issuedAt, values.get("EST VA DTG") ?? ""), cloud: parseVaCloud(estimated, issuedAt) };
|
|
1164
|
+
}
|
|
1165
|
+
for (const name of order) {
|
|
1166
|
+
const forecast = name.match(/^FCST VA CLD \+(\d+) HR$/);
|
|
1167
|
+
if (!forecast) continue;
|
|
1168
|
+
const hours = Number(forecast[1]);
|
|
1169
|
+
const base = observedAt ?? issuedAt;
|
|
1170
|
+
const cloud = parseVaCloud(values.get(name), issuedAt);
|
|
1171
|
+
advisory.forecasts.push({
|
|
1172
|
+
hoursAhead: hours,
|
|
1173
|
+
at: cloud.layers[0]?.at ?? new Date(base.getTime() + hours * 36e5),
|
|
1174
|
+
cloud
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const remarks = values.get("RMK");
|
|
1178
|
+
if (remarks) advisory.remarks = remarks;
|
|
1179
|
+
const next = values.get("NXT ADVISORY") ?? values.get("NEXT ADVISORY");
|
|
1180
|
+
if (next !== void 0) {
|
|
1181
|
+
const by = fullDate(next);
|
|
1182
|
+
advisory.next = { text: next, noFurther: /NO FURTHER ADVISOR/.test(next), ...by ? { by } : {} };
|
|
1183
|
+
}
|
|
1184
|
+
for (const layer of [
|
|
1185
|
+
...advisory.observed?.cloud.layers ?? [],
|
|
1186
|
+
...advisory.estimated?.cloud.layers ?? [],
|
|
1187
|
+
...advisory.forecasts.flatMap((f) => f.cloud.layers)
|
|
1188
|
+
]) {
|
|
1189
|
+
if (layer.area.length === 0) advisory.unparsed.push(`awan abu tanpa titik batas: FL ${layer.baseFt}-${layer.topFt}`);
|
|
1190
|
+
}
|
|
1191
|
+
return advisory;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
409
1194
|
// src/animation.ts
|
|
410
1195
|
var COVERAGE = {
|
|
411
1196
|
FEW: 0.19,
|
|
@@ -1039,9 +1824,17 @@ var WeatherStore = class {
|
|
|
1039
1824
|
ingest(message, options = {}) {
|
|
1040
1825
|
const receivedAt = options.receivedAt ?? /* @__PURE__ */ new Date();
|
|
1041
1826
|
const manual = options.manual === true;
|
|
1042
|
-
const result = { metars: 0, tafs: 0, skipped: 0, stations: [] };
|
|
1827
|
+
const result = { metars: 0, tafs: 0, nil: 0, other: 0, skipped: 0, stations: [] };
|
|
1043
1828
|
const touched = /* @__PURE__ */ new Set();
|
|
1044
1829
|
for (const raw of extractReports(message)) {
|
|
1830
|
+
if (raw.kind !== "METAR" && raw.kind !== "SPECI" && raw.kind !== "TAF") {
|
|
1831
|
+
result.other++;
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
if (parseNil(raw.text, receivedAt)) {
|
|
1835
|
+
result.nil++;
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1045
1838
|
if (raw.kind === "TAF") {
|
|
1046
1839
|
const taf = parseTaf(raw.text, receivedAt);
|
|
1047
1840
|
if (!taf) {
|
|
@@ -1254,12 +2047,33 @@ var WeatherStore = class {
|
|
|
1254
2047
|
function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
|
|
1255
2048
|
const reports = [];
|
|
1256
2049
|
for (const raw of extractReports(message)) {
|
|
1257
|
-
|
|
2050
|
+
let report;
|
|
2051
|
+
switch (raw.kind) {
|
|
2052
|
+
case "TAF":
|
|
2053
|
+
report = parseNil(raw.text, reference) ?? parseTaf(raw.text, reference);
|
|
2054
|
+
break;
|
|
2055
|
+
case "METAR":
|
|
2056
|
+
case "SPECI":
|
|
2057
|
+
report = parseNil(raw.text, reference) ?? parseMetar(raw.text, reference);
|
|
2058
|
+
break;
|
|
2059
|
+
case "SIGMET":
|
|
2060
|
+
case "AIRMET":
|
|
2061
|
+
report = parseSigmet(raw.text, reference);
|
|
2062
|
+
break;
|
|
2063
|
+
case "VAA":
|
|
2064
|
+
report = parseVaa(raw.text, reference);
|
|
2065
|
+
break;
|
|
2066
|
+
case "SNOWTAM":
|
|
2067
|
+
report = parseSnowtam(raw.text, reference);
|
|
2068
|
+
break;
|
|
2069
|
+
}
|
|
1258
2070
|
if (report) reports.push(report);
|
|
1259
2071
|
}
|
|
1260
2072
|
return reports;
|
|
1261
2073
|
}
|
|
1262
2074
|
export {
|
|
2075
|
+
ALTITUDE_PATTERNS,
|
|
2076
|
+
POINT,
|
|
1263
2077
|
WEATHER_GRID_STANDARD,
|
|
1264
2078
|
WEATHER_GRID_VERSION,
|
|
1265
2079
|
WeatherStore,
|
|
@@ -1267,13 +2081,17 @@ export {
|
|
|
1267
2081
|
animationSnapshot,
|
|
1268
2082
|
blendConditions,
|
|
1269
2083
|
blendWindGrids,
|
|
2084
|
+
compassDegrees,
|
|
1270
2085
|
decodeGridJson,
|
|
1271
2086
|
decodeWeatherMessage,
|
|
1272
2087
|
directionFromUV,
|
|
1273
2088
|
emptyConditions,
|
|
1274
2089
|
extractReports,
|
|
2090
|
+
findAltitude,
|
|
2091
|
+
findMovement,
|
|
1275
2092
|
forecastAt,
|
|
1276
2093
|
interpolateScalar,
|
|
2094
|
+
isCompass,
|
|
1277
2095
|
latticePoints,
|
|
1278
2096
|
mergeConditions,
|
|
1279
2097
|
nudgeScalar,
|
|
@@ -1282,7 +2100,14 @@ export {
|
|
|
1282
2100
|
parseGrib2Json,
|
|
1283
2101
|
parseGridJson,
|
|
1284
2102
|
parseMetar,
|
|
2103
|
+
parseNil,
|
|
2104
|
+
parsePosition,
|
|
2105
|
+
parsePositions,
|
|
2106
|
+
parseSigmet,
|
|
2107
|
+
parseSnowtam,
|
|
1285
2108
|
parseTaf,
|
|
2109
|
+
parseVaCloud,
|
|
2110
|
+
parseVaa,
|
|
1286
2111
|
parseWeatherGridSeries,
|
|
1287
2112
|
pointOf,
|
|
1288
2113
|
resolveDayTime,
|